diff --git a/packages/client/ui-primitives/src/ansi.ts b/packages/client/ui-primitives/src/ansi.ts index 9f1adde2f9..a26d1f2dc7 100644 --- a/packages/client/ui-primitives/src/ansi.ts +++ b/packages/client/ui-primitives/src/ansi.ts @@ -177,9 +177,10 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string if (ZERO_WIDTH.test(char)) { // No column of its own: it attaches to the cell already written, so a // redraw that covers that cell covers the mark with it. - const at = Math.max(0, cursor - 1) - const base = columns[at] - if (base !== undefined) columns[at] = { sgr: base.sgr, char: base.char + char } + // With no cell to attach to (line start, or straight after a redraw to + // column 0) a terminal shows nothing rather than a lone accent. + const base = cursor > 0 ? columns[cursor - 1] : undefined + if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char } continue } columns[cursor] = { sgr, char } @@ -207,7 +208,7 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string // later write can still land past them. // Only the FIRST parameter selects the mode; a terminal ignores the rest // (`1;2K` erases exactly as `1K` does — verified against a real terminal). - const mode = params.split(';')[0] ?? '' + const mode = String(params.split(';')[0]) if (mode === '1') for (let index = 0; index <= cursor; index++) columns[index] = { sgr, char: ' ' } else columns.length = mode === '2' ? 0 : cursor continue diff --git a/packages/client/ui-primitives/tests/ansi.spec.ts b/packages/client/ui-primitives/tests/ansi.spec.ts index 2de9634406..e06e4a0179 100644 --- a/packages/client/ui-primitives/tests/ansi.spec.ts +++ b/packages/client/ui-primitives/tests/ansi.spec.ts @@ -9,6 +9,8 @@ import { parseAnsiLines } from '../src/ansi.ts' const ESC = '\u001b' const BS = '\u0008' +/** A combining acute accent: zero-width, so it takes no terminal column. */ +const ACCENT = '\u0301' /** Paint `text` with the SGR `codes`, then reset. */ function sgr(codes: string, text: string): string { @@ -318,6 +320,27 @@ describe('parseAnsiLines: line-end state and column widths', () => { expect(onlySpan('e\u0301x\rYZ')).toEqual({ text: 'YZ', style: undefined }) }) + it('drops a combining mark left with no cell to attach to by a redraw', () => { + // Verified in a real terminal: `ab` then CR then U+0301 then `x` shows `xb`. + // The redraw puts the cursor at column 0, so the mark has no preceding cell + // and the terminal shows nothing for it rather than a lone accent. + expect(onlySpan(`ab\r${ACCENT}x`)).toEqual({ text: 'xb', style: undefined }) + // A mark with no movement on its line never reaches the replay at all: it + // is width business, not a cursor move, so it stays as authored. + expect(onlySpan(`${ACCENT}abc`)).toEqual({ text: `${ACCENT}abc`, style: undefined }) + }) + + it('carries a colour opened after the last write onto the next line', () => { + // The mirror of the reset case, verified in a real terminal: `ab` CR `X` then + // `\x1b[31m` with nothing after it shows `Xb` UNSTYLED and the next line red. + // The scan ends styled while the last cell is not, so the convergence has to + // open the run at the line end for it to reach the following line. + expect(parseAnsiLines(`ab\rX${ESC}[31m\nnext`)).toEqual([ + [{ text: 'Xb', style: undefined }], + [{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }], + ]) + }) + it('blanks a wide character\'s spacer once its lead cell is overwritten', () => { // Verified in a real terminal: `中x` redrawn with `A` shows `A x` — the wide // glyph's second cell becomes a blank rather than closing the gap, so the