1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2import {
3 applyTerminalAttachStreamEvent,
4 DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
5 INITIAL_TERMINAL_OUTPUT_CURSOR,
6 nextTerminalAttachSeedState,
7 readTerminalOutputUpdate,
8 terminalOutputText,
9 type TerminalBufferState,
10} from "@t3tools/client-runtime/state/terminal";
11
12import { writeTerminalOutputUpdate } from "../../components/ThreadTerminalDrawer";
13import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core";
14import { loadGhosttyRuntime } from "./runtime";
15import { terminalNavigationShortcutData } from "../../keybindings";
16
17vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({
18 default: (await import("./vendor/ghostty-vt.wasm?inline")).default,
19}));
20vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({
21 default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default,
22}));
23
24function codepointView(codepoints: ReadonlyArray<number>): DataView {
25 const view = new DataView(new ArrayBuffer(codepoints.length * 4));
26 codepoints.forEach((codepoint, index) => view.setUint32(index * 4, codepoint, true));
27 return view;
28}
29
30describe("ghosttyCellText", () => {
31 it("converts oversized grapheme clusters without hitting engine spread limits", () => {
32 const graphemeLength = 130_000;
33 const view = new DataView(new ArrayBuffer(graphemeLength * 4));
34 for (let index = 0; index < graphemeLength; index += 1) {
35 view.setUint32(index * 4, index === 0 ? "a".codePointAt(0)! : 0x301, true);
36 }
37 const text = ghosttyCellText(view, graphemeLength);
38 expect(text.length).toBe(graphemeLength);
39 expect(text.codePointAt(0)).toBe("a".codePointAt(0));
40 expect(text.codePointAt(1)).toBe(0x301);
41 expect(text.codePointAt(graphemeLength - 1)).toBe(0x301);
42 });
43
44 // regression: #7809 oversized terminal graphemes crashed rendering
45 it("converts small clusters including astral codepoints", () => {
46 const text = ghosttyCellText(codepointView([0x1f642, 0x20e3]), 2);
47 expect([...text]).toEqual(["\u{1F642}", "\u{20E3}"]);
48 });
49});
50
51describe("GhosttyTerminalCore snapshots", () => {
52 const cores = new Set<GhosttyTerminalCore>();
53
54 async function createCore(onData: (data: string) => void = () => {}) {
55 const core = await GhosttyTerminalCore.create(
56 12,
57 3,
58 8,
59 16,
60 {
61 foreground: { r: 255, g: 255, b: 255 },
62 background: { r: 0, g: 0, b: 0 },
63 cursor: { r: 255, g: 255, b: 255 },
64 },
65 onData,
66 );
67 cores.add(core);
68 return core;
69 }
70
71 function createSession(history: string) {
72 return applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
73 type: "snapshot",
74 snapshot: {
75 threadId: "terminal-stream-test",
76 terminalId: "term-1",
77 cwd: "/repo",
78 worktreePath: null,
79 status: "running",
80 pid: 123,
81 history,
82 exitCode: null,
83 exitSignal: null,
84 label: "Terminal",
85 updatedAt: "2026-09-04T00:00:00.000Z",
86 },
87 });
88 }
89
90 function append(state: TerminalBufferState, data: string) {
91 return applyTerminalAttachStreamEvent(state, {
92 type: "output",
93 threadId: "terminal-stream-test",
94 terminalId: "term-1",
95 data,
96 });
97 }
98
99 afterEach(() => {
100 for (const core of cores) core.dispose();
101 cores.clear();
102 vi.restoreAllMocks();
103 });
104
105 it.each([
106 { key: "ArrowLeft", expected: "\x1b[1;5D" },
107 { key: "ArrowRight", expected: "\x1b[1;5C" },
108 ])("sends Ctrl+$key as a modified arrow instead of Alt+B/F", async ({ key, expected }) => {
109 const core = await createCore();
110 const event = {
111 code: key,
112 key,
113 repeat: false,
114 shiftKey: false,
115 ctrlKey: true,
116 altKey: false,
117 metaKey: false,
118 isComposing: false,
119 getModifierState: () => false,
120 } as unknown as KeyboardEvent;
121
122 expect(terminalNavigationShortcutData(event, "Win32") ?? core.encodeKey(event)).toBe(expected);
123 });
124
125 it("keeps a leading U+FEFF in an unbracketed paste", async () => {
126 const core = await createCore();
127 core.write("\x1b[?2004l");
128
129 expect(core.encodePaste("\ufeffsample")).toBe("\ufeffsample");
130 });
131
132 it.each([
133 { code: "", key: "Backspace", expected: "\x7f" },
134 { code: "Unidentified", key: "Backspace", expected: "\x7f" },
135 { code: "Backspace", key: "Backspace", expected: "\x7f" },
136 { code: "Delete", key: "Backspace", expected: "\x1b[3~" },
137 { code: "", key: "a", expected: "a" },
138 { code: "", key: "Unidentified", expected: "" },
139 ])("encodes key $key with physical code '$code'", async ({ code, key, expected }) => {
140 const core = await createCore();
141 const event = {
142 code,
143 key,
144 repeat: false,
145 shiftKey: false,
146 ctrlKey: false,
147 altKey: false,
148 metaKey: false,
149 isComposing: false,
150 getModifierState: () => false,
151 } as unknown as KeyboardEvent;
152
153 expect(core.encodeKey(event)).toBe(expected);
154 });
155
156 it.each(["", "\x1b[>1u"])("encodes macOS Option text with keyboard mode %j", async (mode) => {
157 using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
158 const core = await createCore();
159 core.write(mode);
160 const option = {
161 code: "KeyL",
162 key: "@",
163 repeat: false,
164 shiftKey: false,
165 ctrlKey: false,
166 altKey: true,
167 metaKey: false,
168 isComposing: false,
169 getModifierState: () => false,
170 } as unknown as KeyboardEvent;
171
172 expect(core.encodeKey(option)).toBe("@");
173 expect(core.encodeKey({ ...option, code: "KeyA", key: "Å", shiftKey: true })).toBe("Å");
174 });
175
176 it("preserves styles, wide cells, and selection after shared memory grows", async () => {
177 const core = await createCore();
178 const runtime = await loadGhosttyRuntime();
179 const grapheme = `e${"\u0301".repeat(64)}`;
180 core.write(`\x1b[1;3;4;8;9;53;38;2;123;45;67;48;2;9;8;7m${grapheme}\x1b[0m界🙂`);
181 const cells = core.snapshot().rowData[0]!.cells;
182 expect(cells[0]).toEqual({
183 text: grapheme,
184 wide: 0,
185 foreground: { r: 123, g: 45, b: 67 },
186 background: { r: 9, g: 8, b: 7 },
187 bold: true,
188 italic: true,
189 invisible: true,
190 strikethrough: true,
191 overline: true,
192 underline: true,
193 selected: false,
194 });
195 expect(cells.slice(1, 5).map(({ text, wide }) => ({ text, wide }))).toEqual([
196 { text: "界", wide: 0 },
197 { text: "", wide: GHOSTTY_CELL_WIDE.spacerTail },
198 { text: "🙂", wide: 0 },
199 { text: "", wide: GHOSTTY_CELL_WIDE.spacerTail },
200 ]);
201
202 runtime.memory.grow(1);
203 core.setSelection({ x: 0, y: 0 }, { x: 2, y: 0 });
204 expect(core.snapshot().rowData[0]!.cells[0]).toEqual({ ...cells[0], selected: true });
205 core.clearSelection();
206 expect(core.snapshot().rowData[0]!.cells[0]).toEqual(cells[0]);
207
208 core.resetAndWrite("\x1b[2;7;38;2;40;100;200;48;2;12;34;56mC\x1b[0m");
209 expect(core.snapshot().rowData[0]!.cells[0]).toMatchObject({
210 text: "C",
211 foreground: { r: 22, g: 59, b: 112 },
212 background: { r: 40, g: 100, b: 200 },
213 bold: false,
214 underline: false,
215 selected: false,
216 });
217 });
218
219 it("resolves foreground and background independently without retaining prior cell colors", async () => {
220 const core = await createCore();
221 core.write(
222 "\x1b[38;2;123;45;67mF\x1b[39;48;2;9;8;7mB\x1b[0mD\x1b[38;2;12;34;56;48;2;78;90;123mC\x1b[0mE",
223 );
224 expect(
225 core
226 .snapshot()
227 .rowData[0]!.cells.slice(0, 6)
228 .map(({ text, foreground, background }) => ({ text, foreground, background })),
229 ).toEqual([
230 { text: "F", foreground: { r: 123, g: 45, b: 67 }, background: { r: 0, g: 0, b: 0 } },
231 { text: "B", foreground: { r: 255, g: 255, b: 255 }, background: { r: 9, g: 8, b: 7 } },
232 { text: "D", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } },
233 { text: "C", foreground: { r: 12, g: 34, b: 56 }, background: { r: 78, g: 90, b: 123 } },
234 { text: "E", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } },
235 { text: "", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } },
236 ]);
237 });
238
239 it("paints ANSI colors from the theme palette and repaints them on theme change", async () => {
240 const core = await createCore();
241 const theme = {
242 foreground: { r: 255, g: 255, b: 255 },
243 background: { r: 0, g: 0, b: 0 },
244 cursor: { r: 255, g: 255, b: 255 },
245 };
246 const palette = Array.from({ length: 16 }, (_, index) => ({ r: index, g: 1, b: 2 }));
247 core.write("\x1b[31mR\x1b[38;5;196mX\x1b[0m");
248 core.setTheme({ ...theme, palette });
249 let cells = core.snapshot().rowData[0]!.cells;
250 expect(cells[0]!.foreground).toEqual({ r: 1, g: 1, b: 2 });
251 expect(cells[1]!.foreground).toEqual({ r: 255, g: 0, b: 0 });
252
253 core.setTheme({ ...theme, palette: palette.map((color) => ({ ...color, g: 9 })) });
254 cells = core.snapshot().rowData[0]!.cells;
255 expect(cells[0]!.foreground).toEqual({ r: 1, g: 9, b: 2 });
256 expect(cells[1]!.foreground).toEqual({ r: 255, g: 0, b: 0 });
257 });
258
259 it("preserves Unicode and split ANSI parser state across batched renderer reads", async () => {
260 const [core, reference] = await Promise.all([createCore(), createCore()]);
261 let state = createSession("");
262 const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
263 writeTerminalOutputUpdate(core, first);
264 let cursor = first.cursor;
265 const reset = vi.spyOn(core, "resetAndWrite");
266 const inputs = [
267 `${"a".repeat(16_383)}🙂`,
268 "\x1b[3",
269 "1m",
270 "e",
271 "\u0301界🙂",
272 "\x1b[0",
273 "m\r\n",
274 "\x1b]8;;https://t3.codes\x1b",
275 "\\link",
276 "\x1b]8;;\x1b",
277 "\\\x1b[?1049h",
278 "alternate",
279 "\x1b[?1049l",
280 "\r\nend",
281 ];
282 let received = "";
283 for (const [index, data] of inputs.entries()) {
284 state = append(state, data);
285 if (index % 3 !== 0 && index !== inputs.length - 1) continue;
286 const update = readTerminalOutputUpdate(state.output, cursor);
287 if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`);
288 received += update.data;
289 writeTerminalOutputUpdate(core, update);
290 cursor = update.cursor;
291 }
292
293 reference.write(inputs.join(""));
294 expect(received).toBe(inputs.join(""));
295 expect(reset).not.toHaveBeenCalled();
296 expect(core.snapshot()).toEqual(reference.snapshot());
297 });
298
299 it("answers a live VT query when batched reads cross a chunk compaction", async () => {
300 const replies: string[] = [];
301 const core = await createCore((data) => replies.push(data));
302 core.write("\x1b[5n");
303 expect(replies).toEqual(["\x1b[0n"]);
304 replies.length = 0;
305
306 let state = createSession("");
307 const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
308 writeTerminalOutputUpdate(core, initial);
309 let cursor = initial.cursor;
310 for (let index = 0; index < 1000; index += 1) {
311 state = append(state, "x");
312 const update = readTerminalOutputUpdate(state.output, cursor);
313 writeTerminalOutputUpdate(core, update);
314 cursor = update.cursor;
315 }
316 for (let index = 0; index < 24; index += 1) state = append(state, "x");
317 state = append(state, "\x1b[5n");
318 const update = readTerminalOutputUpdate(state.output, cursor);
319 writeTerminalOutputUpdate(core, update);
320
321 expect({ type: update.type, replies }).toEqual({ type: "append", replies: ["\x1b[0n"] });
322 });
323
324 it("recovers a lagging renderer once from bounded output and resumes appending", async () => {
325 const [core, reference] = await Promise.all([createCore(), createCore()]);
326 let state = createSession("\x1b[31mold");
327 const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
328 writeTerminalOutputUpdate(core, initial);
329 const reset = vi.spyOn(core, "resetAndWrite");
330 const data = "line\r\n".repeat(8192);
331 for (let index = 0; index < 16; index += 1) state = append(state, data);
332
333 const recovery = readTerminalOutputUpdate(state.output, initial.cursor);
334 if (recovery.type !== "reset") throw new Error(`Expected reset, received ${recovery.type}`);
335 expect(new TextEncoder().encode(recovery.data).byteLength).toBe(
336 DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
337 );
338 writeTerminalOutputUpdate(core, recovery);
339 reference.resetAndWrite(recovery.data);
340 expect(core.snapshot()).toEqual(reference.snapshot());
341
342 state = append(state, "\r\nlatest");
343 const next = readTerminalOutputUpdate(state.output, recovery.cursor);
344 expect(next.type).toBe("append");
345 writeTerminalOutputUpdate(core, next);
346 reference.write("\r\nlatest");
347 expect(reset).toHaveBeenCalledTimes(1);
348 expect(core.snapshot()).toEqual(reference.snapshot());
349 });
350
351 it("replays the latest retained output when WASM arrives after several events", async () => {
352 const pendingCore = createCore();
353 let state = createSession("before");
354 state = append(state, "\r\nduring ");
355 state = append(state, "🙂 load");
356 const core = await pendingCore;
357 const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
358 writeTerminalOutputUpdate(core, first);
359 const reference = await createCore();
360 reference.resetAndWrite(terminalOutputText(state.output));
361 const reset = vi.spyOn(core, "resetAndWrite");
362
363 state = append(state, "\r\nafter");
364 const next = readTerminalOutputUpdate(state.output, first.cursor);
365 expect(next).toMatchObject({ type: "append", data: "\r\nafter" });
366 writeTerminalOutputUpdate(core, next);
367 reference.write("\r\nafter");
368 expect(reset).not.toHaveBeenCalled();
369 expect(core.snapshot()).toEqual(reference.snapshot());
370 });
371
372 it("resets real Ghostty for a repeated snapshot, clear, restart, and a fresh attach", async () => {
373 const [core, reference] = await Promise.all([createCore(), createCore()]);
374 let state = createSession("hello");
375 const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
376 writeTerminalOutputUpdate(core, initial);
377 let cursor = initial.cursor;
378
379 const snapshot = {
380 threadId: "terminal-stream-test",
381 terminalId: "term-1",
382 cwd: "/repo",
383 worktreePath: null,
384 status: "running" as const,
385 pid: 456,
386 history: "hello",
387 exitCode: null,
388 exitSignal: null,
389 label: "Terminal",
390 updatedAt: "2026-09-04T00:00:01.000Z",
391 };
392 const events = [
393 { type: "snapshot", snapshot },
394 { type: "cleared", threadId: snapshot.threadId, terminalId: snapshot.terminalId },
395 { type: "restarted", threadId: snapshot.threadId, terminalId: snapshot.terminalId, snapshot },
396 ] as const;
397 for (const event of events) {
398 core.write("\r\nstale local text");
399 state = applyTerminalAttachStreamEvent(state, event);
400 const update = readTerminalOutputUpdate(state.output, cursor);
401 expect(update.type).toBe("reset");
402 writeTerminalOutputUpdate(core, update);
403 cursor = update.cursor;
404 reference.resetAndWrite(event.type === "cleared" ? "" : "hello");
405 expect(core.snapshot()).toEqual(reference.snapshot());
406 }
407
408 core.write("\r\nstale local text");
409 state = createSession("hello");
410 const reattached = readTerminalOutputUpdate(state.output, cursor);
411 expect(reattached.type).toBe("reset");
412 writeTerminalOutputUpdate(core, reattached);
413 reference.resetAndWrite("hello");
414 expect(core.snapshot()).toEqual(reference.snapshot());
415 });
416});
417
418describe("GhosttyTerminalCore scrollback", () => {
419 it("retains thousands of rows of scrollback", async () => {
420 const core = await GhosttyTerminalCore.create(
421 80,
422 10,
423 8,
424 16,
425 {
426 foreground: { r: 255, g: 255, b: 255 },
427 background: { r: 0, g: 0, b: 0 },
428 cursor: { r: 255, g: 255, b: 255 },
429 },
430 () => {},
431 );
432 try {
433 core.write(Array.from({ length: 1_200 }, (_, index) => `${index}\r\n`).join(""));
434 const scrollbar = core.scrollbarState();
435 // max_scrollback is a byte budget in the pinned ABI, not a row count.
436 expect(scrollbar!.total - scrollbar!.len).toBeGreaterThan(1_000);
437 } finally {
438 core.dispose();
439 }
440 });
441});