Skip to content

Commit 32da67b

Browse files
ThomasK33claude
andauthored
fix(renderer): column-align wide-glyph cells in libghostty-vt snapshots (#118)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent c33af92 commit 32da67b

5 files changed

Lines changed: 255 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
## Fixed
2222

23+
- 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)).
2324
- 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<version>]` headings present after a release cut ([#103](https://github.com/coder/agent-tty/pull/103)).
2425

2526
## [v0.2.0] - 2026-05-13

src/dashboard/liveViewProjection.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,18 @@ class SnapshotGrid {
7575
}
7676

7777
cellAt(row: number, col: number): ProjectedCell {
78-
// Known limitation: `SnapshotCell[]` is densely packed without a column key
79-
// (the renderer drops native `col`/`width`), so we treat array index as the
80-
// terminal column. A wide glyph (CJK/emoji) spans two columns but is one
81-
// entry, shifting everything after it left. Shared with `snapshot`; fixing
82-
// it needs `col`/`width` on the schema. See coder/agent-tty#112.
78+
// `SnapshotCell[]` is column-indexed: both renderer backends emit one cell
79+
// per terminal column and pad an empty spacer for the trailing column of a
80+
// wide glyph (CJK/emoji), so the array index is the terminal column and the
81+
// cursor-cell highlight stays aligned past a wide glyph. See
82+
// coder/agent-tty#112.
8383
const styled = this.cellRows.get(row)?.[col];
8484
if (styled !== undefined) {
8585
return styled.char === '' ? { ...styled, char: ' ' } : styled;
8686
}
87+
// Fallback for columns without cell data: index the text by code point.
88+
// Not display-column-accurate for wide glyphs, but only reached past the
89+
// last populated cell (typically trailing blanks).
8790
const char = Array.from(this.textRows.get(row) ?? '')[col] ?? ' ';
8891
return { char: char === '' ? ' ' : char };
8992
}

src/renderer/libghosttyVt/backend.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ScreenshotOptions,
1515
SnapshotOptions,
1616
} from '../backend.js';
17+
import type { SnapshotCell } from '../../protocol/schemas.js';
1718
import { GhosttyWebBackend } from '../ghosttyWeb/backend.js';
1819
import type {
1920
RenderProfileConfig,
@@ -264,6 +265,29 @@ function copyOptionalString(
264265
}
265266
}
266267

268+
function toStyledCell(cell: NativeSnapshotCell): SnapshotCell {
269+
return {
270+
char: cell.text,
271+
...(cell.foreground === undefined ? {} : { fg: cell.foreground }),
272+
...(cell.background === undefined ? {} : { bg: cell.background }),
273+
...(cell.bold === undefined ? {} : { bold: cell.bold }),
274+
...(cell.italic === undefined ? {} : { italic: cell.italic }),
275+
...(cell.underline === undefined ? {} : { underline: cell.underline }),
276+
};
277+
}
278+
279+
/**
280+
* Pack native cell records into a **column-indexed** `SnapshotCell[]` per row,
281+
* so that `cells[col]` is the cell at terminal column `col`. The native
282+
* snapshot emits one record per occupied column and represents a wide glyph
283+
* (CJK/emoji, `width: 2`) as a single record with no record for the trailing
284+
* column. We place each record at its `col` and emit an empty spacer for every
285+
* trailing column a wide glyph covers (and defensively for any gap), keeping
286+
* array index aligned with the terminal column. This mirrors the `ghostty-web`
287+
* backend, which already emits one cell per column, and keeps index-as-column
288+
* consumers (e.g. the Session Dashboard projection and its cursor-cell
289+
* highlight) correct past a wide glyph. See coder/agent-tty#112.
290+
*/
267291
function mapNativeCells(
268292
nativeCells: readonly NativeSnapshotCell[] | undefined,
269293
): SemanticSnapshot['cells'] | undefined {
@@ -280,21 +304,25 @@ function mapNativeCells(
280304

281305
return [...grouped.entries()]
282306
.sort(([leftRow], [rightRow]) => leftRow - rightRow)
283-
.map(([lineNumber, rowCells]) => ({
284-
lineNumber,
285-
cells: rowCells
286-
.sort((left, right) => left.col - right.col)
287-
.map((cell) => ({
288-
char: cell.text,
289-
...(cell.foreground === undefined ? {} : { fg: cell.foreground }),
290-
...(cell.background === undefined ? {} : { bg: cell.background }),
291-
...(cell.bold === undefined ? {} : { bold: cell.bold }),
292-
...(cell.italic === undefined ? {} : { italic: cell.italic }),
293-
...(cell.underline === undefined
294-
? {}
295-
: { underline: cell.underline }),
296-
})),
297-
}));
307+
.map(([lineNumber, rowCells]) => {
308+
const sorted = [...rowCells].sort((left, right) => left.col - right.col);
309+
const cells: SnapshotCell[] = [];
310+
for (const cell of sorted) {
311+
// Fill any gap so the next record lands at its true column.
312+
while (cells.length < cell.col) {
313+
cells.push({ char: '' });
314+
}
315+
const styled = toStyledCell(cell);
316+
cells.push(styled);
317+
// A wide glyph covers its trailing column(s): emit an empty spacer
318+
// carrying the glyph's styling so the trailing half shades correctly
319+
// and the array index stays aligned with the terminal column.
320+
for (let span = 1; span < cell.width; span += 1) {
321+
cells.push({ ...styled, char: '' });
322+
}
323+
}
324+
return { lineNumber, cells };
325+
});
298326
}
299327

300328
export class LibghosttyVtBackend implements RendererBackend {

test/unit/dashboard/liveViewProjection.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,53 @@ describe('projectLiveView', () => {
182182
});
183183
});
184184

185+
it('keeps cells and the cursor highlight column-aligned past a wide glyph (coder/agent-tty#112)', () => {
186+
// Column-indexed cells as the renderer backends now emit them: 🚀 occupies
187+
// col 7 with an empty spacer at col 8, so "done" stays at cols 10..13.
188+
const packed = [
189+
'r',
190+
'o',
191+
'c',
192+
'k',
193+
'e',
194+
't',
195+
' ',
196+
'🚀',
197+
'',
198+
' ',
199+
'd',
200+
'o',
201+
'n',
202+
'e',
203+
];
204+
const snapshot: SemanticSnapshot = {
205+
sessionId: 'session',
206+
capturedAtSeq: 0,
207+
cols: packed.length,
208+
rows: 1,
209+
cursorRow: 0,
210+
cursorCol: 10, // true terminal column of "d"
211+
isAltScreen: false,
212+
visibleLines: [{ row: 0, text: 'rocket 🚀 done' }],
213+
cells: [{ lineNumber: 0, cells: packed.map((char) => ({ char })) }],
214+
};
215+
216+
const view = projectLiveView({
217+
snapshot,
218+
pane: { cols: packed.length, rows: 1 },
219+
mode: 'one-to-one',
220+
});
221+
222+
const row = view.cells[0] ?? [];
223+
expect(row[7]?.char).toBe('🚀');
224+
expect(row[8]?.char).toBe(' '); // empty spacer renders as a space
225+
expect(row[10]?.char).toBe('d');
226+
expect(row[13]?.char).toBe('e');
227+
// The cursor highlights its true column ("d"), not a left-shifted cell.
228+
expect(row[10]?.cursor).toBe(true);
229+
expect(row[9]?.cursor).toBeUndefined();
230+
});
231+
185232
it('falls back to visibleLines text when the snapshot carries no cells', () => {
186233
const snapshot = snapshotFromRows(['ab', 'cd'], { includeCells: false });
187234
expect(snapshot.cells).toBeUndefined();

test/unit/renderer/libghosttyVtBackend.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ import { createLogger } from '../../../src/util/logger.js';
1010

1111
import { createFakeBackend } from '../../helpers/fakeBackend.js';
1212

13+
// Load the real optional native engine when present so we can verify the actual
14+
// wide-glyph cell layout (not just a mock of it). Skips where it is unavailable.
15+
let nativeModule: LibghosttyVtNativeModule | null = null;
16+
try {
17+
const loaded = await import('@coder/libghostty-vt-node');
18+
nativeModule = { createTerminal: loaded.createTerminal };
19+
} catch {
20+
nativeModule = null;
21+
}
22+
const itWithNative = nativeModule ? it : it.skip;
23+
1324
function createProfile(): RenderProfileConfig {
1425
return {
1526
name: 'reference-dark',
@@ -274,6 +285,151 @@ describe('LibghosttyVtBackend', () => {
274285
});
275286
});
276287

288+
it('packs wide glyphs into column-aligned cells with spacer placeholders (coder/agent-tty#112)', async () => {
289+
// The native engine emits a single width-2 record for a wide glyph (CJK or
290+
// emoji) and no record for its trailing column. mapNativeCells must insert
291+
// a spacer there so the array index stays aligned with the terminal column
292+
// and content after the glyph is not shifted left.
293+
const wideSnapshot = {
294+
cols: 20,
295+
rows: 2,
296+
cursorRow: 0,
297+
cursorCol: 10, // true terminal column of the "d" in "done"
298+
isAltScreen: false,
299+
visibleLines: [
300+
{ row: 0, text: 'rocket 🚀 done' },
301+
{ row: 1, text: 'A漢字B' },
302+
],
303+
cells: [
304+
{ row: 0, col: 0, text: 'r', width: 1 },
305+
{ row: 0, col: 1, text: 'o', width: 1 },
306+
{ row: 0, col: 2, text: 'c', width: 1 },
307+
{ row: 0, col: 3, text: 'k', width: 1 },
308+
{ row: 0, col: 4, text: 'e', width: 1 },
309+
{ row: 0, col: 5, text: 't', width: 1 },
310+
{ row: 0, col: 6, text: ' ', width: 1 },
311+
{ row: 0, col: 7, text: '🚀', width: 2 }, // wide: no record for col 8
312+
{ row: 0, col: 9, text: ' ', width: 1 },
313+
{ row: 0, col: 10, text: 'd', width: 1 },
314+
{ row: 0, col: 11, text: 'o', width: 1 },
315+
{ row: 0, col: 12, text: 'n', width: 1 },
316+
{ row: 0, col: 13, text: 'e', width: 1 },
317+
{ row: 1, col: 0, text: 'A', width: 1 },
318+
{ row: 1, col: 1, text: '漢', width: 2 }, // wide: no record for col 2
319+
{ row: 1, col: 3, text: '字', width: 2 }, // wide: no record for col 4
320+
{ row: 1, col: 5, text: 'B', width: 1 },
321+
],
322+
};
323+
const terminal = {
324+
feed: vi.fn(),
325+
resize: vi.fn(),
326+
snapshot: vi.fn(() => wideSnapshot),
327+
getVisibleText: vi.fn(() => 'rocket 🚀 done'),
328+
dispose: vi.fn(),
329+
};
330+
const module: LibghosttyVtNativeModule = {
331+
createTerminal: vi.fn(() => terminal),
332+
};
333+
const backend = new LibghosttyVtBackend('session-01', createProfile(), {
334+
loadNative: () => Promise.resolve(module),
335+
logger: createLogger('info', () => undefined),
336+
initialCols: 20,
337+
initialRows: 2,
338+
});
339+
340+
await backend.boot();
341+
await backend.replayTo(
342+
createReplayInput({
343+
initialCols: 20,
344+
initialRows: 2,
345+
targetSeq: 0,
346+
events: [
347+
{
348+
seq: 0,
349+
ts: '2026-03-20T12:00:00.000Z',
350+
type: 'output',
351+
payload: { data: 'rocket 🚀 done' },
352+
},
353+
],
354+
}),
355+
);
356+
357+
const snapshot = await backend.snapshot({ includeCells: true });
358+
const row0 =
359+
snapshot.cells?.find((line) => line.lineNumber === 0)?.cells ?? [];
360+
const row1 =
361+
snapshot.cells?.find((line) => line.lineNumber === 1)?.cells ?? [];
362+
363+
// Emoji: glyph at its true column, empty spacer next, no left shift after.
364+
expect(row0[7]?.char).toBe('🚀');
365+
expect(row0[8]?.char).toBe('');
366+
expect(row0[9]?.char).toBe(' ');
367+
expect(row0[10]?.char).toBe('d');
368+
expect(row0[13]?.char).toBe('e');
369+
// The cursor column indexes the "d", not the previously-shifted "o".
370+
expect(row0[snapshot.cursorCol]?.char).toBe('d');
371+
372+
// Two CJK wide glyphs: "B" stays at its true column 5 (was off-by-2).
373+
expect(row1.map((cell) => cell.char)).toEqual([
374+
'A',
375+
'漢',
376+
'',
377+
'字',
378+
'',
379+
'B',
380+
]);
381+
});
382+
383+
itWithNative(
384+
'column-aligns real wide glyphs from the native engine (coder/agent-tty#112)',
385+
async () => {
386+
const backend = new LibghosttyVtBackend(
387+
'session-native',
388+
createProfile(),
389+
{
390+
loadNative: () =>
391+
Promise.resolve(nativeModule as LibghosttyVtNativeModule),
392+
logger: createLogger('info', () => undefined),
393+
initialCols: 40,
394+
initialRows: 4,
395+
},
396+
);
397+
398+
await backend.boot();
399+
await backend.replayTo(
400+
createReplayInput({
401+
sessionId: 'session-native',
402+
initialCols: 40,
403+
initialRows: 4,
404+
targetSeq: 0,
405+
events: [
406+
{
407+
seq: 0,
408+
ts: '2026-03-20T12:00:00.000Z',
409+
type: 'output',
410+
payload: { data: 'rocket 🚀 done' },
411+
},
412+
],
413+
}),
414+
);
415+
416+
const snapshot = await backend.snapshot({ includeCells: true });
417+
const chars =
418+
snapshot.cells
419+
?.find((line) => line.lineNumber === 0)
420+
?.cells.map((cell) => cell.char) ?? [];
421+
await backend.dispose();
422+
423+
// The real engine places the emoji at column 7 (after "rocket ") as a
424+
// width-2 record with no record for column 8; mapNativeCells fills the
425+
// spacer so "done" stays at its true columns.
426+
expect(chars[7]).toBe('🚀');
427+
expect(chars[8]).toBe(''); // wide-glyph spacer, not a left shift
428+
expect(chars[10]).toBe('d');
429+
expect(chars[13]).toBe('e');
430+
},
431+
);
432+
277433
it('delegates getVisibleText to the native terminal', async () => {
278434
const fixture = createNativeFixture({ visibleText: 'delegated text' });
279435
const backend = createBackend(fixture);

0 commit comments

Comments
 (0)