diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts index c0935587d0..2b5179fad1 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts @@ -46,6 +46,116 @@ const ESC = '\u001b' const hasDecstbm = (text: string) => new RegExp(`${ESC}\\[\\d+;\\d+r`).test(text) describe('LogUpdate.render diff contract', () => { + it.each([ + ['adds a combining mark', 'ร', 'ร้', 0], + ['removes a combining mark', 'ร้', 'ร', 1], + ['replaces a combining mark', 'ร้', 'ร่', 2] + ])('clears a narrow grapheme cell before it %s', (_label, before, after, x) => { + const prev = mkScreen(4, 1) + const next = mkScreen(4, 1) + + setCellAt(prev, x, 0, { + char: before, + styleId: stylePool.none, + width: CellWidth.Narrow, + hyperlink: undefined + }) + setCellAt(next, x, 0, { + char: after, + styleId: stylePool.none, + width: CellWidth.Narrow, + hyperlink: undefined + }) + next.damage = { x, y: 0, width: 1, height: 1 } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prev, 4, 1), mkFrame(next, 4, 1), true, false) + const writeIndex = diff.findIndex(part => part.type === 'stdout' && part.content === after) + + expect(writeIndex).toBeGreaterThan(0) + expect(diff.slice(0, writeIndex)).toContainEqual({ type: 'stdout', content: ' ' }) + expect(diff.slice(0, writeIndex)).toContainEqual({ type: 'cursorTo', col: x + 1 }) + }) + + it('does not clear before replacing ordinary single-codepoint text', () => { + const prev = mkScreen(4, 1) + const next = mkScreen(4, 1) + paint(prev, 0, 'a') + paint(next, 0, 'b') + next.damage = { x: 0, y: 0, width: 1, height: 1 } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prev, 4, 1), mkFrame(next, 4, 1), true, false) + + expect(stdoutOnly(diff)).toBe('b') + expect(diff.some(part => part.type === 'cursorTo')).toBe(false) + expect(diff.some(part => part.type === 'stdout' && part.content === ' ')).toBe(false) + }) + + it('does not clear before painting a grapheme into an empty cell', () => { + const prev = mkScreen(4, 1) + const next = mkScreen(4, 1) + + setCellAt(next, 1, 0, { + char: 'ร้', + styleId: stylePool.none, + width: CellWidth.Narrow, + hyperlink: undefined + }) + next.damage = { x: 1, y: 0, width: 1, height: 1 } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prev, 4, 1), mkFrame(next, 4, 1), true, false) + + expect(stdoutOnly(diff)).toBe('ร้') + expect(diff.some(part => part.type === 'cursorTo')).toBe(false) + }) + + it('does not clear when only a grapheme cell style changes', () => { + const prev = mkScreen(4, 1) + const next = mkScreen(4, 1) + const style = stylePool.intern([{ code: '\u001b[32m', endCode: '\u001b[39m' }]) + + setCellAt(prev, 1, 0, { char: 'ร้', styleId: stylePool.none, width: CellWidth.Narrow, hyperlink: undefined }) + setCellAt(next, 1, 0, { char: 'ร้', styleId: style, width: CellWidth.Narrow, hyperlink: undefined }) + next.damage = { x: 1, y: 0, width: 1, height: 1 } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prev, 4, 1), mkFrame(next, 4, 1), true, false) + + expect(stdoutOnly(diff)).toBe('ร้') + expect(diff.some(part => part.type === 'cursorTo')).toBe(false) + }) + + it('resets active hyperlink and style before pre-clearing a grapheme cell', () => { + const prev = mkScreen(4, 1) + const next = mkScreen(4, 1) + const oldStyle = stylePool.intern([{ code: '\u001b[31m', endCode: '\u001b[39m' }]) + const newStyle = stylePool.intern([{ code: '\u001b[32m', endCode: '\u001b[39m' }]) + + setCellAt(prev, 0, 0, { char: 'x', styleId: stylePool.none, width: CellWidth.Narrow, hyperlink: undefined }) + setCellAt(next, 0, 0, { char: 'y', styleId: oldStyle, width: CellWidth.Narrow, hyperlink: 'https://old.example' }) + setCellAt(prev, 1, 0, { char: 'ร้', styleId: oldStyle, width: CellWidth.Narrow, hyperlink: 'https://old.example' }) + setCellAt(next, 1, 0, { char: 'ร', styleId: newStyle, width: CellWidth.Narrow, hyperlink: undefined }) + next.damage = { x: 0, y: 0, width: 2, height: 1 } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prev, 4, 1), mkFrame(next, 4, 1), true, false) + const writeIndex = diff.findIndex(part => part.type === 'stdout' && part.content === 'ร') + const clearIndex = diff.findIndex(part => part.type === 'stdout' && part.content === ' ') + const resetStyle = stylePool.transition(oldStyle, stylePool.none) + const styleResetIndex = diff.findIndex(part => part.type === 'styleStr' && part.str === resetStyle) + const closeIndex = diff.findIndex(part => part.type === 'hyperlink' && part.uri === '') + + expect(writeIndex).toBeGreaterThan(clearIndex) + expect(clearIndex).toBeGreaterThan(-1) + expect(styleResetIndex).toBeGreaterThan(-1) + expect(styleResetIndex).toBeLessThan(clearIndex) + expect(closeIndex).toBeGreaterThan(-1) + expect(closeIndex).toBeLessThan(clearIndex) + expect(diff.slice(clearIndex, writeIndex)).toContainEqual({ type: 'cursorTo', col: 2 }) + }) + it('emits only changed cells when most rows match', () => { const w = 20 const h = 4 diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.ts index e0536125c9..21996fc42a 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.ts @@ -356,6 +356,25 @@ export class LogUpdate { moveCursorTo(screen, x, y) if (added) { + // Some terminals retain the previous combining-mark state when a + // multi-codepoint, single-column grapheme is overwritten in place. + // Clear the physical cell first, then return to it before drawing the + // replacement. Keep the common single-codepoint path byte-for-byte + // unchanged. + if ( + removed && + !isEmptyCellAt(prev.screen, x, y) && + removed.char !== added.char && + removed.width === CellWidth.Narrow && + added.width === CellWidth.Narrow && + (hasMultipleCodePoints(removed.char) || hasMultipleCodePoints(added.char)) + ) { + currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none) + currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined) + screen.diff.push({ type: 'stdout', content: ' ' }) + screen.diff.push({ type: 'cursorTo', col: screen.cursor.x + 1 }) + } + const targetHyperlink = added.hyperlink currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink) const styleStr = stylePool.transition(currentStyleId, added.styleId) @@ -727,6 +746,17 @@ function needsWidthCompensation(char: string): boolean { return false } +/** Whether a grapheme contains more than one Unicode code point. */ +function hasMultipleCodePoints(char: string): boolean { + const first = char.codePointAt(0) + + if (first === undefined) { + return false + } + + return char.length > (first > 0xffff ? 2 : 1) +} + class VirtualScreen { // Public for direct mutation by writeCellWithStyleStr (avoids txn overhead). // File-private class — not exposed outside log-update.ts.