Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ([#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<version>]` headings present after a release cut ([#103](https://github.com/coder/agent-tty/pull/103)).

## [v0.2.0] - 2026-05-13
Expand Down
13 changes: 8 additions & 5 deletions src/dashboard/liveViewProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
58 changes: 43 additions & 15 deletions src/renderer/libghosttyVt/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions test/unit/dashboard/liveViewProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
156 changes: 156 additions & 0 deletions test/unit/renderer/libghosttyVtBackend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
Loading