authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 06:27:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 06:27:42-07:00
logcccd7e3c885065e925f559c5708378cdb3b51eb3
tree1fab1a315576d4b1f0bc13f3aa7bd7af70f227b5
parent8ac5462920c45cdee63af15b2598909736f2ec84
signature Signed by PGP key B5690EEEBB952194

perf(web): speed up terminal snapshots (#9663)


3 files changed, 169 insertions(+), 32 deletions(-)

apps/web/src/terminal/ghostty/core.test.ts+114-2
......@@ -1,6 +1,14 @@
1import { describe, expect, it } from "vite-plus/test";
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22
3import { ghosttyCellText } from "./core";
3import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core";
4import { loadGhosttyRuntime } from "./runtime";
5
6vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({
7 default: (await import("./vendor/ghostty-vt.wasm?inline")).default,
8}));
9vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({
10 default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default,
11}));
412
513function codepointView(codepoints: ReadonlyArray<number>): DataView {
614 const view = new DataView(new ArrayBuffer(codepoints.length * 4));
......@@ -30,7 +38,111 @@ describe("ghosttyCellText", () => {
3038 expect([...text]).toEqual(["\u{1F642}", "\u{20E3}"]);
3139 });
3240
41 it("converts a single astral codepoint", () => {
42 expect(ghosttyCellText(codepointView([0x1f642]), 1)).toBe("šŸ™‚");
43 });
44
3345 it("returns an empty string for empty cells", () => {
3446 expect(ghosttyCellText(codepointView([]), 0)).toBe("");
3547 });
3648});
49
50describe("GhosttyTerminalCore snapshots", () => {
51 const cores = new Set<GhosttyTerminalCore>();
52
53 async function createCore() {
54 const core = await GhosttyTerminalCore.create(
55 12,
56 3,
57 8,
58 16,
59 {
60 foreground: { r: 255, g: 255, b: 255 },
61 background: { r: 0, g: 0, b: 0 },
62 cursor: { r: 255, g: 255, b: 255 },
63 },
64 () => {},
65 );
66 cores.add(core);
67 return core;
68 }
69
70 afterEach(() => {
71 for (const core of cores) core.dispose();
72 cores.clear();
73 vi.restoreAllMocks();
74 });
75
76 it("preserves styles, wide cells, and selection after shared memory grows", async () => {
77 const core = await createCore();
78 const runtime = await loadGhosttyRuntime();
79 const grapheme = `e${"\u0301".repeat(64)}`;
80 core.write(`\x1b[1;3;4;8;9;53;38;2;123;45;67;48;2;9;8;7m${grapheme}\x1b[0mē•ŒšŸ™‚`);
81 const cells = core.snapshot().rowData[0]!.cells;
82 expect(cells[0]).toEqual({
83 text: grapheme,
84 wide: 0,
85 foreground: { r: 123, g: 45, b: 67 },
86 background: { r: 9, g: 8, b: 7 },
87 bold: true,
88 italic: true,
89 invisible: true,
90 strikethrough: true,
91 overline: true,
92 underline: true,
93 selected: false,
94 });
95 expect(cells.slice(1, 5).map(({ text, wide }) => ({ text, wide }))).toEqual([
96 { text: "ē•Œ", wide: 0 },
97 { text: "", wide: GHOSTTY_CELL_WIDE.spacerTail },
98 { text: "šŸ™‚", wide: 0 },
99 { text: "", wide: GHOSTTY_CELL_WIDE.spacerTail },
100 ]);
101
102 runtime.memory.grow(1);
103 core.setSelection({ x: 0, y: 0 }, { x: 2, y: 0 });
104 expect(core.snapshot().rowData[0]!.cells[0]).toEqual({ ...cells[0], selected: true });
105 core.clearSelection();
106 expect(core.snapshot().rowData[0]!.cells[0]).toEqual(cells[0]);
107
108 core.resetAndWrite("\x1b[2;7;38;2;40;100;200;48;2;12;34;56mC\x1b[0m");
109 expect(core.snapshot().rowData[0]!.cells[0]).toMatchObject({
110 text: "C",
111 foreground: { r: 22, g: 59, b: 112 },
112 background: { r: 40, g: 100, b: 200 },
113 bold: false,
114 underline: false,
115 selected: false,
116 });
117 });
118
119 it("reuses a grown grapheme buffer and releases it on disposal", async () => {
120 const core = await createCore();
121 const runtime = await loadGhosttyRuntime();
122 core.write("ASCII");
123 core.snapshot();
124
125 const grapheme = `z${"\u0301".repeat(256)}`;
126 core.resetAndWrite(`${grapheme}X`);
127 const alloc = vi.spyOn(runtime, "alloc");
128 const free = vi.spyOn(runtime, "free");
129 expect(
130 core
131 .snapshot()
132 .rowData[0]!.cells.slice(0, 2)
133 .map((cell) => cell.text),
134 ).toEqual([grapheme, "X"]);
135 expect(alloc).toHaveBeenCalledTimes(1);
136 const allocation = alloc.mock.results[0]!;
137 if (allocation.type !== "return") throw new Error("Grapheme allocation did not return");
138 const buffer = allocation.value;
139 const capacity = alloc.mock.calls[0]![0];
140
141 core.write("\rQ\u0301");
142 alloc.mockClear();
143 expect(core.snapshot().rowData[0]!.cells[0]!.text).toBe("Q\u0301");
144 expect(alloc).not.toHaveBeenCalled();
145 core.dispose();
146 expect(free).toHaveBeenCalledWith(buffer, capacity);
147 });
148});
apps/web/src/terminal/ghostty/core.ts+30-17
......@@ -174,6 +174,7 @@ function sameColor(left: GhosttyColor, right: GhosttyColor): boolean {
174174 * every codepoint into String.fromCodePoint at once.
175175 */
176176export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string {
177 if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(0, true));
177178 const CHUNK_SIZE = 4_096;
178179 let text = "";
179180 for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) {
......@@ -206,6 +207,8 @@ export class GhosttyTerminalCore {
206207 private ptyWriterId = 0;
207208 private ptyWriter: ((data: string) => void) | null = null;
208209 private scratch = 0;
210 private graphemes = 0;
211 private graphemeCapacity = 0;
209212 private style = 0;
210213 private scrollbar = 0;
211214 private rows: GhosttyRow[] = [];
......@@ -878,6 +881,7 @@ export class GhosttyTerminalCore {
878881 this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size);
879882 }
880883 if (this.scratch) this.runtime.free(this.scratch, 16);
884 if (this.graphemes) this.runtime.free(this.graphemes, this.graphemeCapacity);
881885 for (const slot of [
882886 this.mouseEventSlot,
883887 this.mouseEncoderSlot,
......@@ -944,6 +948,7 @@ export class GhosttyTerminalCore {
944948 ),
945949 );
946950 const cellsIterator = this.runtime.readPointer(this.rowCellsSlot);
951 const { size: styleSize, fields: styleFields } = this.runtime.layout("GhosttyStyle");
947952 const cells: GhosttyCell[] = [];
948953 while (
949954 cells.length < cols &&
......@@ -951,7 +956,6 @@ export class GhosttyTerminalCore {
951956 ) {
952957 let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground);
953958 let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground);
954 const styleSize = this.runtime.layout("GhosttyStyle").size;
955959 this.runtime.bytes(this.style, styleSize).fill(0);
956960 this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
957961 this.runtime.call(
......@@ -960,30 +964,30 @@ export class GhosttyTerminalCore {
960964 CELL_DATA.style,
961965 this.style,
962966 );
963 const inverse = this.runtime.readField(this.style, "GhosttyStyle", "inverse") !== 0;
964 if (inverse) [foreground, background] = [background, foreground];
965 if (this.runtime.readField(this.style, "GhosttyStyle", "faint") !== 0) {
966 foreground = blend(foreground, background);
967 }
968967 const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength);
969968 let text = "";
970969 if (graphemeLength > 0) {
971970 const bufferSize = graphemeLength * 4;
972 const codepoints = this.runtime.alloc(bufferSize);
971 if (bufferSize > this.graphemeCapacity) {
972 const capacity = Math.max(bufferSize, this.graphemeCapacity * 2);
973 const buffer = this.runtime.alloc(capacity);
974 this.runtime.free(this.graphemes, this.graphemeCapacity);
975 this.graphemes = buffer;
976 this.graphemeCapacity = capacity;
977 }
973978 if (
974979 this.runtime.call(
975980 "ghostty_render_state_row_cells_get",
976981 cellsIterator,
977982 CELL_DATA.graphemes,
978 codepoints,
983 this.graphemes,
979984 ) === GHOSTTY_SUCCESS
980985 ) {
981986 // Read through a DataView: the byte-array allocator guarantees no
982987 // 4-byte alignment, which a Uint32Array view would require.
983 const codepointView = this.runtime.view(codepoints, bufferSize);
988 const codepointView = this.runtime.view(this.graphemes, bufferSize);
984989 text = ghosttyCellText(codepointView, graphemeLength);
985990 }
986 this.runtime.free(codepoints, bufferSize);
987991 }
988992 let wide = 0;
989993 if (text.length === 0 && cells.at(-1)?.text.length) {
......@@ -1004,18 +1008,27 @@ export class GhosttyTerminalCore {
10041008 );
10051009 wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true);
10061010 }
1011 const selected = this.getCellBool(cellsIterator, CELL_DATA.selected);
1012 // Read the style after allocation and ABI calls, which can grow WASM memory.
1013 const styleView = this.runtime.view(this.style, styleSize);
1014 if (styleView.getUint8(styleFields.inverse!.offset) !== 0) {
1015 [foreground, background] = [background, foreground];
1016 }
1017 if (styleView.getUint8(styleFields.faint!.offset) !== 0) {
1018 foreground = blend(foreground, background);
1019 }
10071020 cells.push({
10081021 text,
10091022 wide,
10101023 foreground,
10111024 background,
1012 bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0,
1013 italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0,
1014 invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0,
1015 strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0,
1016 overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0,
1017 underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0,
1018 selected: this.getCellBool(cellsIterator, CELL_DATA.selected),
1025 bold: styleView.getUint8(styleFields.bold!.offset) !== 0,
1026 italic: styleView.getUint8(styleFields.italic!.offset) !== 0,
1027 invisible: styleView.getUint8(styleFields.invisible!.offset) !== 0,
1028 strikethrough: styleView.getUint8(styleFields.strikethrough!.offset) !== 0,
1029 overline: styleView.getUint8(styleFields.overline!.offset) !== 0,
1030 underline: styleView.getInt32(styleFields.underline!.offset, true) !== 0,
1031 selected,
10191032 });
10201033 }
10211034 while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground));
apps/web/src/terminal/ghostty/runtime.ts+25-13
......@@ -23,6 +23,7 @@ export class GhosttyRuntime {
2323 readonly memory: WebAssembly.Memory;
2424 readonly layouts: TypeLayouts;
2525 private readonly exports: WebAssembly.Exports;
26 private memoryView: DataView;
2627 private readonly ptyWriters = new Map<number, (data: string) => void>();
2728 private nextPtyWriterId = 1;
2829 private writePtyFunctionIndex = 0;
......@@ -34,6 +35,7 @@ export class GhosttyRuntime {
3435 throw new Error("libghostty-vt did not export WebAssembly memory");
3536 }
3637 this.memory = memory;
38 this.memoryView = new DataView(memory.buffer);
3739 const jsonPointer = this.call("ghostty_type_json");
3840 const bytes = new Uint8Array(memory.buffer);
3941 let end = jsonPointer;
......@@ -104,7 +106,7 @@ export class GhosttyRuntime {
104106 }
105107
106108 readPointer(slot: number): number {
107 return new DataView(this.memory.buffer).getUint32(slot, true);
109 return this.currentMemoryView().getUint32(slot, true);
108110 }
109111
110112 attachPtyWriter(terminal: number, writer: (data: string) => void): number {
......@@ -132,27 +134,36 @@ export class GhosttyRuntime {
132134 return new Uint8Array(this.memory.buffer, pointer, size);
133135 }
134136
137 /** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */
138 private currentMemoryView(): DataView {
139 if (this.memoryView.buffer !== this.memory.buffer) {
140 this.memoryView = new DataView(this.memory.buffer);
141 }
142 return this.memoryView;
143 }
144
135145 setField(pointer: number, structName: string, fieldName: string, value: number): void {
136146 const field = this.layout(structName).fields[fieldName];
137147 if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);
138 const view = this.view(pointer + field.offset, field.size);
148 const view = this.currentMemoryView();
149 const offset = pointer + field.offset;
139150 switch (field.type) {
140151 case "bool":
141152 case "u8":
142 view.setUint8(0, value);
153 view.setUint8(offset, value);
143154 return;
144155 case "u16":
145 view.setUint16(0, value, true);
156 view.setUint16(offset, value, true);
146157 return;
147158 case "i32":
148 view.setInt32(0, value, true);
159 view.setInt32(offset, value, true);
149160 return;
150161 case "u32":
151162 case "enum":
152 view.setUint32(0, value, true);
163 view.setUint32(offset, value, true);
153164 return;
154165 case "u64":
155 view.setBigUint64(0, BigInt(value), true);
166 view.setBigUint64(offset, BigInt(value), true);
156167 return;
157168 default:
158169 throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);
......@@ -162,20 +173,21 @@ export class GhosttyRuntime {
162173 readField(pointer: number, structName: string, fieldName: string): number {
163174 const field = this.layout(structName).fields[fieldName];
164175 if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);
165 const view = this.view(pointer + field.offset, field.size);
176 const view = this.currentMemoryView();
177 const offset = pointer + field.offset;
166178 switch (field.type) {
167179 case "bool":
168180 case "u8":
169 return view.getUint8(0);
181 return view.getUint8(offset);
170182 case "u16":
171 return view.getUint16(0, true);
183 return view.getUint16(offset, true);
172184 case "i32":
173 return view.getInt32(0, true);
185 return view.getInt32(offset, true);
174186 case "u32":
175187 case "enum":
176 return view.getUint32(0, true);
188 return view.getUint32(offset, true);
177189 case "u64":
178 return Number(view.getBigUint64(0, true));
190 return Number(view.getBigUint64(offset, true));
179191 default:
180192 throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);
181193 }