Files
hermes-agent/ui-tui/src/__tests__/emoji.test.ts
Brooklyn Nicholson 136519a2c9 fix(tui): inject VS16 so text-default emoji render as color glyphs
Models frequently emit bare codepoints like U+26A0 (⚠), U+2139 (ℹ),
U+2764 (❤), U+2714 (✔), U+2600 (☀), U+263A (☺) which, per Unicode, have
Emoji_Presentation=No and render as monochrome text-style glyphs in
terminals unless followed by VS16 (U+FE0F). Agent output leaked through
the TUI like `⚠ careful` instead of `⚠️ careful`.

Added `ensureEmojiPresentation` (lib/emoji.ts): scans for the curated
set of text-default codepoints and appends VS16 when the next char is
not already VS16, ZWJ, or a keycap-enclosing mark. Idempotent and
fast-pathed by a Unicode-range regex so ASCII-heavy text is untouched.

Applied once at the top of `Md`'s line parse. Hermes-ink's stringWidth
already accounts for VS16, so cursor/layout stays correct.
2026-04-21 15:52:39 -05:00

65 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`)
})
})