Merge pull request #13253 from NousResearch/bb/tui-emoji-vs16-injection

fix(tui): inject VS16 so text-default emoji render as color glyphs
This commit is contained in:
brooklyn!
2026-04-21 15:53:29 -05:00
committed by GitHub
3 changed files with 121 additions and 1 deletions

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { ensureEmojiPresentation } from '../lib/emoji.js'
const VS16 = '\uFE0F'
describe('ensureEmojiPresentation', () => {
it('passes through ASCII unchanged', () => {
expect(ensureEmojiPresentation('hello world')).toBe('hello world')
expect(ensureEmojiPresentation('')).toBe('')
})
it('passes through emoji that already defaults to emoji presentation', () => {
expect(ensureEmojiPresentation('🚀 rocket')).toBe('🚀 rocket')
expect(ensureEmojiPresentation('😀')).toBe('😀')
})
it('injects VS16 after text-default emoji codepoints', () => {
expect(ensureEmojiPresentation('⚠ careful')).toBe(`${VS16} careful`)
expect(ensureEmojiPresentation(' info')).toBe(`${VS16} info`)
expect(ensureEmojiPresentation('love ❤ you')).toBe(`love ❤${VS16} you`)
expect(ensureEmojiPresentation('✔ done')).toBe(`${VS16} done`)
})
it('is idempotent when VS16 is already present', () => {
const already = `${VS16} ${VS16}${VS16}`
expect(ensureEmojiPresentation(already)).toBe(already)
expect(ensureEmojiPresentation(ensureEmojiPresentation('⚠'))).toBe(`${VS16}`)
})
it('leaves keycap sequences alone when the base is not a text-default emoji', () => {
expect(ensureEmojiPresentation('1\u20e3')).toBe('1\u20e3')
})
it('injects VS16 before ZWJ so text-default bases participate in emoji sequences', () => {
// ❤ + ZWJ + 🔥 → ❤️‍🔥 (heart on fire). Without VS16 between the heart
// and the ZWJ, terminals render the heart in text/monochrome form and
// the ZWJ ligature can fail to form.
const heartFire = '\u2764\u200d\ud83d\udd25'
expect(ensureEmojiPresentation(heartFire)).toBe(`\u2764\uFE0F\u200d\ud83d\udd25`)
})
it('leaves explicit text-presentation selector (VS15) alone', () => {
// `❤︎` (U+2764 + U+FE0E) asks for text presentation — injecting VS16
// would create an invalid double-variation sequence.
const explicitText = '\u2764\ufe0e'
expect(ensureEmojiPresentation(explicitText)).toBe(explicitText)
})
it('returns the original reference when no change is needed', () => {
const already = `${VS16} ${VS16}${VS16}`
// Reference equality — the lazy allocator should short-circuit to the
// input when nothing needed injection.
expect(ensureEmojiPresentation(already)).toBe(already)
})
it('handles mixed content', () => {
expect(ensureEmojiPresentation('⚠ path: /tmp/x ❤ done')).toBe(`${VS16} path: /tmp/x ❤${VS16} done`)
})
})

View File

@@ -1,6 +1,7 @@
import { Box, Link, Text } from '@hermes/ink'
import { memo, type ReactNode, useMemo } from 'react'
import { ensureEmojiPresentation } from '../lib/emoji.js'
import { highlightLine, isHighlightable } from '../lib/syntax.js'
import type { Theme } from '../theme.js'
@@ -232,7 +233,7 @@ interface MdProps {
function MdImpl({ compact, t, text }: MdProps) {
const nodes = useMemo(() => {
const lines = text.split('\n')
const lines = ensureEmojiPresentation(text).split('\n')
const nodes: ReactNode[] = []
let i = 0

55
ui-tui/src/lib/emoji.ts Normal file
View File

@@ -0,0 +1,55 @@
const VS15 = 0xfe0e
const VS16 = 0xfe0f
const KEYCAP = 0x20e3
const TEXT_DEFAULT_EMOJI = new Set<number>([
0x00a9, 0x00ae, 0x203c, 0x2049, 0x2122, 0x2139, 0x2194, 0x2195, 0x2196, 0x2197, 0x2198, 0x2199, 0x21a9, 0x21aa,
0x2328, 0x23cf, 0x23ed, 0x23ee, 0x23ef, 0x23f1, 0x23f2, 0x23f8, 0x23f9, 0x23fa, 0x24c2, 0x25aa, 0x25ab, 0x25b6,
0x25c0, 0x25fb, 0x25fc, 0x2600, 0x2601, 0x2602, 0x2603, 0x2604, 0x260e, 0x2611, 0x2618, 0x261d, 0x2620, 0x2622,
0x2623, 0x2626, 0x262a, 0x262e, 0x262f, 0x2638, 0x2639, 0x263a, 0x2640, 0x2642, 0x265f, 0x2660, 0x2663, 0x2665,
0x2666, 0x2668, 0x267b, 0x267e, 0x2692, 0x2694, 0x2695, 0x2696, 0x2697, 0x2699, 0x269b, 0x269c, 0x26a0, 0x26a7,
0x26b0, 0x26b1, 0x26c8, 0x26cf, 0x26d1, 0x26d3, 0x26d4, 0x26e9, 0x26f0, 0x26f1, 0x26f4, 0x26f7, 0x26f8, 0x26f9,
0x2702, 0x2708, 0x2709, 0x270c, 0x270d, 0x270f, 0x2712, 0x2714, 0x2716, 0x271d, 0x2721, 0x2733, 0x2734, 0x2744,
0x2747, 0x2763, 0x2764, 0x27a1, 0x2934, 0x2935, 0x2b05, 0x2b06, 0x2b07, 0x3030, 0x303d, 0x3297, 0x3299
])
const MAYBE_TEXT_EMOJI_RE =
/[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9\u21aa\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb\u25fc\u2600-\u2604\u260e\u2611\u2618\u261d\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u265f\u2660\u2663\u2665\u2666\u2668\u267b\u267e\u2692\u2694-\u2697\u2699\u269b\u269c\u26a0\u26a7\u26b0\u26b1\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26f0\u26f1\u26f4\u26f7-\u26f9\u2702\u2708\u2709\u270c\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u3030\u303d\u3297\u3299]/
export function ensureEmojiPresentation(text: string): string {
if (!text || !MAYBE_TEXT_EMOJI_RE.test(text)) {
return text
}
// Lazy output: only start building when we actually need to insert VS16.
// Short-circuits the whole walk for strings where every text-default emoji
// is already followed by VS16/VS15, avoiding per-codepoint string growth.
let out: null | string = null
let last = 0
let i = 0
while (i < text.length) {
const cp = text.codePointAt(i)!
const size = cp > 0xffff ? 2 : 1
if (TEXT_DEFAULT_EMOJI.has(cp)) {
const next = text.codePointAt(i + size)
// Skip only when the sequence already carries an explicit presentation
// selector. VS16 means the user (or a prior pass) already requested
// emoji presentation; VS15 is an explicit text-presentation request so
// leave it alone and don't pile VS16 on top of it. Inject before ZWJ
// and KEYCAP so ZWJ-joined sequences (e.g. ❤️‍🔥) and digit keycaps
// both render as emoji rather than text.
if (next !== VS16 && next !== VS15) {
out ??= ''
out += text.slice(last, i + size) + '\uFE0F'
last = i + size
}
}
i += size
}
return out === null ? text : out + text.slice(last)
}