From bbbdde47b9d13aebc8af271c649fc15f19db5f26 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 19:49:05 +0200 Subject: [PATCH 1/2] fix(renderer): column-align wide-glyph cells in libghostty-vt snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mapNativeCells packed one SnapshotCell per native cell record and dropped the native col/width, so a width-2 glyph (CJK/emoji) became a single entry with no spacer for its trailing column. Index-as-column consumers — the Session Dashboard projection (which pins libghostty-vt) and its cursor-cell highlight — then shifted every cell after the glyph one column left. Pack cells column-indexed instead: place each record at its true column and emit an empty spacer for a wide glyph's trailing column (and any gap), mirroring the ghostty-web backend, which already emits one cell per column. No protocol-schema change; visibleLines text was already correct. Adds regression tests for the producer (mocked records plus the real native engine, guarded by availability) and the dashboard projection. Closes #112 Change-Id: Ie1e757983e51ec60ff26ae423ad07a0865000828 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 1 + src/dashboard/liveViewProjection.ts | 13 +- src/renderer/libghosttyVt/backend.ts | 58 +++++-- .../unit/dashboard/liveViewProjection.test.ts | 47 ++++++ .../unit/renderer/libghosttyVtBackend.test.ts | 156 ++++++++++++++++++ 5 files changed, 255 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05357bd..c8730e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ## Fixed +- Wide characters (CJK/emoji) no longer misalign per-cell snapshot rendering. The `libghostty-vt` backend's `mapNativeCells` packed one array entry per native cell _record_ and discarded the native `col`/`width`, so a width-2 glyph became a single entry with no spacer for its trailing column — shifting every cell after it one column to the left and offsetting the cursor-cell highlight in the Session Dashboard (which pins `libghostty-vt`). Cells are now column-indexed: each row places records at their true column and emits an empty spacer for a wide glyph's trailing column, matching the `ghostty-web` backend so `snapshot --include-cells` and the dashboard Live View stay aligned past wide glyphs. `visibleLines` text was already correct ([#112](https://github.com/coder/agent-tty/issues/112)). - Restored the empty `## [Unreleased]` heading on `main` after the v0.2.0 release-prep commit so the `Update Unreleased Changelog` workflow stops failing on every push. `docs/RELEASE-PROCESS.md` now documents the rename-and-insert rule that keeps both `[Unreleased]` and `[v]` headings present after a release cut ([#103](https://github.com/coder/agent-tty/pull/103)). ## [v0.2.0] - 2026-05-13 diff --git a/src/dashboard/liveViewProjection.ts b/src/dashboard/liveViewProjection.ts index 5976f5d..d5956ba 100644 --- a/src/dashboard/liveViewProjection.ts +++ b/src/dashboard/liveViewProjection.ts @@ -75,15 +75,18 @@ class SnapshotGrid { } cellAt(row: number, col: number): ProjectedCell { - // Known limitation: `SnapshotCell[]` is densely packed without a column key - // (the renderer drops native `col`/`width`), so we treat array index as the - // terminal column. A wide glyph (CJK/emoji) spans two columns but is one - // entry, shifting everything after it left. Shared with `snapshot`; fixing - // it needs `col`/`width` on the schema. See coder/agent-tty#112. + // `SnapshotCell[]` is column-indexed: both renderer backends emit one cell + // per terminal column and pad an empty spacer for the trailing column of a + // wide glyph (CJK/emoji), so the array index is the terminal column and the + // cursor-cell highlight stays aligned past a wide glyph. See + // coder/agent-tty#112. const styled = this.cellRows.get(row)?.[col]; if (styled !== undefined) { return styled.char === '' ? { ...styled, char: ' ' } : styled; } + // Fallback for columns without cell data: index the text by code point. + // Not display-column-accurate for wide glyphs, but only reached past the + // last populated cell (typically trailing blanks). const char = Array.from(this.textRows.get(row) ?? '')[col] ?? ' '; return { char: char === '' ? ' ' : char }; } diff --git a/src/renderer/libghosttyVt/backend.ts b/src/renderer/libghosttyVt/backend.ts index 8c734b1..67bb0ec 100644 --- a/src/renderer/libghosttyVt/backend.ts +++ b/src/renderer/libghosttyVt/backend.ts @@ -14,6 +14,7 @@ import type { ScreenshotOptions, SnapshotOptions, } from '../backend.js'; +import type { SnapshotCell } from '../../protocol/schemas.js'; import { GhosttyWebBackend } from '../ghosttyWeb/backend.js'; import type { RenderProfileConfig, @@ -264,6 +265,29 @@ function copyOptionalString( } } +function toStyledCell(cell: NativeSnapshotCell): SnapshotCell { + return { + char: cell.text, + ...(cell.foreground === undefined ? {} : { fg: cell.foreground }), + ...(cell.background === undefined ? {} : { bg: cell.background }), + ...(cell.bold === undefined ? {} : { bold: cell.bold }), + ...(cell.italic === undefined ? {} : { italic: cell.italic }), + ...(cell.underline === undefined ? {} : { underline: cell.underline }), + }; +} + +/** + * Pack native cell records into a **column-indexed** `SnapshotCell[]` per row, + * so that `cells[col]` is the cell at terminal column `col`. The native + * snapshot emits one record per occupied column and represents a wide glyph + * (CJK/emoji, `width: 2`) as a single record with no record for the trailing + * column. We place each record at its `col` and emit an empty spacer for every + * trailing column a wide glyph covers (and defensively for any gap), keeping + * array index aligned with the terminal column. This mirrors the `ghostty-web` + * backend, which already emits one cell per column, and keeps index-as-column + * consumers (e.g. the Session Dashboard projection and its cursor-cell + * highlight) correct past a wide glyph. See coder/agent-tty#112. + */ function mapNativeCells( nativeCells: readonly NativeSnapshotCell[] | undefined, ): SemanticSnapshot['cells'] | undefined { @@ -280,21 +304,25 @@ function mapNativeCells( return [...grouped.entries()] .sort(([leftRow], [rightRow]) => leftRow - rightRow) - .map(([lineNumber, rowCells]) => ({ - lineNumber, - cells: rowCells - .sort((left, right) => left.col - right.col) - .map((cell) => ({ - char: cell.text, - ...(cell.foreground === undefined ? {} : { fg: cell.foreground }), - ...(cell.background === undefined ? {} : { bg: cell.background }), - ...(cell.bold === undefined ? {} : { bold: cell.bold }), - ...(cell.italic === undefined ? {} : { italic: cell.italic }), - ...(cell.underline === undefined - ? {} - : { underline: cell.underline }), - })), - })); + .map(([lineNumber, rowCells]) => { + const sorted = [...rowCells].sort((left, right) => left.col - right.col); + const cells: SnapshotCell[] = []; + for (const cell of sorted) { + // Fill any gap so the next record lands at its true column. + while (cells.length < cell.col) { + cells.push({ char: '' }); + } + const styled = toStyledCell(cell); + cells.push(styled); + // A wide glyph covers its trailing column(s): emit an empty spacer + // carrying the glyph's styling so the trailing half shades correctly + // and the array index stays aligned with the terminal column. + for (let span = 1; span < cell.width; span += 1) { + cells.push({ ...styled, char: '' }); + } + } + return { lineNumber, cells }; + }); } export class LibghosttyVtBackend implements RendererBackend { diff --git a/test/unit/dashboard/liveViewProjection.test.ts b/test/unit/dashboard/liveViewProjection.test.ts index fbde268..73b4c49 100644 --- a/test/unit/dashboard/liveViewProjection.test.ts +++ b/test/unit/dashboard/liveViewProjection.test.ts @@ -182,6 +182,53 @@ describe('projectLiveView', () => { }); }); + it('keeps cells and the cursor highlight column-aligned past a wide glyph (coder/agent-tty#112)', () => { + // Column-indexed cells as the renderer backends now emit them: 🚀 occupies + // col 7 with an empty spacer at col 8, so "done" stays at cols 10..13. + const packed = [ + 'r', + 'o', + 'c', + 'k', + 'e', + 't', + ' ', + '🚀', + '', + ' ', + 'd', + 'o', + 'n', + 'e', + ]; + const snapshot: SemanticSnapshot = { + sessionId: 'session', + capturedAtSeq: 0, + cols: packed.length, + rows: 1, + cursorRow: 0, + cursorCol: 10, // true terminal column of "d" + isAltScreen: false, + visibleLines: [{ row: 0, text: 'rocket 🚀 done' }], + cells: [{ lineNumber: 0, cells: packed.map((char) => ({ char })) }], + }; + + const view = projectLiveView({ + snapshot, + pane: { cols: packed.length, rows: 1 }, + mode: 'one-to-one', + }); + + const row = view.cells[0] ?? []; + expect(row[7]?.char).toBe('🚀'); + expect(row[8]?.char).toBe(' '); // empty spacer renders as a space + expect(row[10]?.char).toBe('d'); + expect(row[13]?.char).toBe('e'); + // The cursor highlights its true column ("d"), not a left-shifted cell. + expect(row[10]?.cursor).toBe(true); + expect(row[9]?.cursor).toBeUndefined(); + }); + it('falls back to visibleLines text when the snapshot carries no cells', () => { const snapshot = snapshotFromRows(['ab', 'cd'], { includeCells: false }); expect(snapshot.cells).toBeUndefined(); diff --git a/test/unit/renderer/libghosttyVtBackend.test.ts b/test/unit/renderer/libghosttyVtBackend.test.ts index 6f1145e..1d66a22 100644 --- a/test/unit/renderer/libghosttyVtBackend.test.ts +++ b/test/unit/renderer/libghosttyVtBackend.test.ts @@ -10,6 +10,17 @@ import { createLogger } from '../../../src/util/logger.js'; import { createFakeBackend } from '../../helpers/fakeBackend.js'; +// Load the real optional native engine when present so we can verify the actual +// wide-glyph cell layout (not just a mock of it). Skips where it is unavailable. +let nativeModule: LibghosttyVtNativeModule | null = null; +try { + const loaded = await import('@coder/libghostty-vt-node'); + nativeModule = { createTerminal: loaded.createTerminal }; +} catch { + nativeModule = null; +} +const itWithNative = nativeModule ? it : it.skip; + function createProfile(): RenderProfileConfig { return { name: 'reference-dark', @@ -274,6 +285,151 @@ describe('LibghosttyVtBackend', () => { }); }); + it('packs wide glyphs into column-aligned cells with spacer placeholders (coder/agent-tty#112)', async () => { + // The native engine emits a single width-2 record for a wide glyph (CJK or + // emoji) and no record for its trailing column. mapNativeCells must insert + // a spacer there so the array index stays aligned with the terminal column + // and content after the glyph is not shifted left. + const wideSnapshot = { + cols: 20, + rows: 2, + cursorRow: 0, + cursorCol: 10, // true terminal column of the "d" in "done" + isAltScreen: false, + visibleLines: [ + { row: 0, text: 'rocket 🚀 done' }, + { row: 1, text: 'A漢字B' }, + ], + cells: [ + { row: 0, col: 0, text: 'r', width: 1 }, + { row: 0, col: 1, text: 'o', width: 1 }, + { row: 0, col: 2, text: 'c', width: 1 }, + { row: 0, col: 3, text: 'k', width: 1 }, + { row: 0, col: 4, text: 'e', width: 1 }, + { row: 0, col: 5, text: 't', width: 1 }, + { row: 0, col: 6, text: ' ', width: 1 }, + { row: 0, col: 7, text: '🚀', width: 2 }, // wide: no record for col 8 + { row: 0, col: 9, text: ' ', width: 1 }, + { row: 0, col: 10, text: 'd', width: 1 }, + { row: 0, col: 11, text: 'o', width: 1 }, + { row: 0, col: 12, text: 'n', width: 1 }, + { row: 0, col: 13, text: 'e', width: 1 }, + { row: 1, col: 0, text: 'A', width: 1 }, + { row: 1, col: 1, text: '漢', width: 2 }, // wide: no record for col 2 + { row: 1, col: 3, text: '字', width: 2 }, // wide: no record for col 4 + { row: 1, col: 5, text: 'B', width: 1 }, + ], + }; + const terminal = { + feed: vi.fn(), + resize: vi.fn(), + snapshot: vi.fn(() => wideSnapshot), + getVisibleText: vi.fn(() => 'rocket 🚀 done'), + dispose: vi.fn(), + }; + const module: LibghosttyVtNativeModule = { + createTerminal: vi.fn(() => terminal), + }; + const backend = new LibghosttyVtBackend('session-01', createProfile(), { + loadNative: () => Promise.resolve(module), + logger: createLogger('info', () => undefined), + initialCols: 20, + initialRows: 2, + }); + + await backend.boot(); + await backend.replayTo( + createReplayInput({ + initialCols: 20, + initialRows: 2, + targetSeq: 0, + events: [ + { + seq: 0, + ts: '2026-03-20T12:00:00.000Z', + type: 'output', + payload: { data: 'rocket 🚀 done' }, + }, + ], + }), + ); + + const snapshot = await backend.snapshot({ includeCells: true }); + const row0 = + snapshot.cells?.find((line) => line.lineNumber === 0)?.cells ?? []; + const row1 = + snapshot.cells?.find((line) => line.lineNumber === 1)?.cells ?? []; + + // Emoji: glyph at its true column, empty spacer next, no left shift after. + expect(row0[7]?.char).toBe('🚀'); + expect(row0[8]?.char).toBe(''); + expect(row0[9]?.char).toBe(' '); + expect(row0[10]?.char).toBe('d'); + expect(row0[13]?.char).toBe('e'); + // The cursor column indexes the "d", not the previously-shifted "o". + expect(row0[snapshot.cursorCol]?.char).toBe('d'); + + // Two CJK wide glyphs: "B" stays at its true column 5 (was off-by-2). + expect(row1.map((cell) => cell.char)).toEqual([ + 'A', + '漢', + '', + '字', + '', + 'B', + ]); + }); + + itWithNative( + 'column-aligns real wide glyphs from the native engine (coder/agent-tty#112)', + async () => { + const backend = new LibghosttyVtBackend( + 'session-native', + createProfile(), + { + loadNative: () => + Promise.resolve(nativeModule as LibghosttyVtNativeModule), + logger: createLogger('info', () => undefined), + initialCols: 40, + initialRows: 4, + }, + ); + + await backend.boot(); + await backend.replayTo( + createReplayInput({ + sessionId: 'session-native', + initialCols: 40, + initialRows: 4, + targetSeq: 0, + events: [ + { + seq: 0, + ts: '2026-03-20T12:00:00.000Z', + type: 'output', + payload: { data: 'rocket 🚀 done' }, + }, + ], + }), + ); + + const snapshot = await backend.snapshot({ includeCells: true }); + const chars = + snapshot.cells + ?.find((line) => line.lineNumber === 0) + ?.cells.map((cell) => cell.char) ?? []; + await backend.dispose(); + + // The real engine places the emoji at column 7 (after "rocket ") as a + // width-2 record with no record for column 8; mapNativeCells fills the + // spacer so "done" stays at its true columns. + expect(chars[7]).toBe('🚀'); + expect(chars[8]).toBe(''); // wide-glyph spacer, not a left shift + expect(chars[10]).toBe('d'); + expect(chars[13]).toBe('e'); + }, + ); + it('delegates getVisibleText to the native terminal', async () => { const fixture = createNativeFixture({ visibleText: 'delegated text' }); const backend = createBackend(fixture); From 3c68cbb83658c0e5c5a68b5d7d2cb66debbaa213 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 19:50:08 +0200 Subject: [PATCH 2/2] docs(CHANGELOG.md): reference PR #118 for the wide-glyph fix Change-Id: Ib18c233c8a00d8b9ef3d46b48e39cc4de0b7abbf Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8730e6..ffd9585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ ## Fixed -- Wide characters (CJK/emoji) no longer misalign per-cell snapshot rendering. The `libghostty-vt` backend's `mapNativeCells` packed one array entry per native cell _record_ and discarded the native `col`/`width`, so a width-2 glyph became a single entry with no spacer for its trailing column — shifting every cell after it one column to the left and offsetting the cursor-cell highlight in the Session Dashboard (which pins `libghostty-vt`). Cells are now column-indexed: each row places records at their true column and emits an empty spacer for a wide glyph's trailing column, matching the `ghostty-web` backend so `snapshot --include-cells` and the dashboard Live View stay aligned past wide glyphs. `visibleLines` text was already correct ([#112](https://github.com/coder/agent-tty/issues/112)). +- Wide characters (CJK/emoji) no longer misalign per-cell snapshot rendering. The `libghostty-vt` backend's `mapNativeCells` packed one array entry per native cell _record_ and discarded the native `col`/`width`, so a width-2 glyph became a single entry with no spacer for its trailing column — shifting every cell after it one column to the left and offsetting the cursor-cell highlight in the Session Dashboard (which pins `libghostty-vt`). Cells are now column-indexed: each row places records at their true column and emits an empty spacer for a wide glyph's trailing column, matching the `ghostty-web` backend so `snapshot --include-cells` and the dashboard Live View stay aligned past wide glyphs. `visibleLines` text was already correct ([#118](https://github.com/coder/agent-tty/pull/118), closes [#112](https://github.com/coder/agent-tty/issues/112)). - Restored the empty `## [Unreleased]` heading on `main` after the v0.2.0 release-prep commit so the `Update Unreleased Changelog` workflow stops failing on every push. `docs/RELEASE-PROCESS.md` now documents the rename-and-insert rule that keeps both `[Unreleased]` and `[v]` headings present after a release cut ([#103](https://github.com/coder/agent-tty/pull/103)). ## [v0.2.0] - 2026-05-13