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 @@...@@ -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
5function codepointView(codepoints: ReadonlyArray<number>): DataView {13function codepointView(codepoints: ReadonlyArray<number>): DataView {
6 const view = new DataView(new ArrayBuffer(codepoints.length * 4));14 const view = new DataView(new ArrayBuffer(codepoints.length * 4));
...@@ -30,7 +38,111 @@ describe("ghosttyCellText", () => {...@@ -30,7 +38,111 @@ describe("ghosttyCellText", () => {
30 expect([...text]).toEqual(["\u{1F642}", "\u{20E3}"]);38 expect([...text]).toEqual(["\u{1F642}", "\u{20E3}"]);
31 });39 });
3240
41 it("converts a single astral codepoint", () => {
42 expect(ghosttyCellText(codepointView([0x1f642]), 1)).toBe("šŸ™‚");
43 });
44
33 it("returns an empty string for empty cells", () => {45 it("returns an empty string for empty cells", () => {
34 expect(ghosttyCellText(codepointView([]), 0)).toBe("");46 expect(ghosttyCellText(codepointView([]), 0)).toBe("");
35 });47 });
36});48});
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 {...@@ -174,6 +174,7 @@ function sameColor(left: GhosttyColor, right: GhosttyColor): boolean {
174 * every codepoint into String.fromCodePoint at once.174 * every codepoint into String.fromCodePoint at once.
175 */175 */
176export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string {176export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string {
177 if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(0, true));
177 const CHUNK_SIZE = 4_096;178 const CHUNK_SIZE = 4_096;
178 let text = "";179 let text = "";
179 for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) {180 for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) {
...@@ -206,6 +207,8 @@ export class GhosttyTerminalCore {...@@ -206,6 +207,8 @@ export class GhosttyTerminalCore {
206 private ptyWriterId = 0;207 private ptyWriterId = 0;
207 private ptyWriter: ((data: string) => void) | null = null;208 private ptyWriter: ((data: string) => void) | null = null;
208 private scratch = 0;209 private scratch = 0;
210 private graphemes = 0;
211 private graphemeCapacity = 0;
209 private style = 0;212 private style = 0;
210 private scrollbar = 0;213 private scrollbar = 0;
211 private rows: GhosttyRow[] = [];214 private rows: GhosttyRow[] = [];
...@@ -878,6 +881,7 @@ export class GhosttyTerminalCore {...@@ -878,6 +881,7 @@ export class GhosttyTerminalCore {
878 this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size);881 this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size);
879 }882 }
880 if (this.scratch) this.runtime.free(this.scratch, 16);883 if (this.scratch) this.runtime.free(this.scratch, 16);
884 if (this.graphemes) this.runtime.free(this.graphemes, this.graphemeCapacity);
881 for (const slot of [885 for (const slot of [
882 this.mouseEventSlot,886 this.mouseEventSlot,
883 this.mouseEncoderSlot,887 this.mouseEncoderSlot,
...@@ -944,6 +948,7 @@ export class GhosttyTerminalCore {...@@ -944,6 +948,7 @@ export class GhosttyTerminalCore {
944 ),948 ),
945 );949 );
946 const cellsIterator = this.runtime.readPointer(this.rowCellsSlot);950 const cellsIterator = this.runtime.readPointer(this.rowCellsSlot);
951 const { size: styleSize, fields: styleFields } = this.runtime.layout("GhosttyStyle");
947 const cells: GhosttyCell[] = [];952 const cells: GhosttyCell[] = [];
948 while (953 while (
949 cells.length < cols &&954 cells.length < cols &&
...@@ -951,7 +956,6 @@ export class GhosttyTerminalCore {...@@ -951,7 +956,6 @@ export class GhosttyTerminalCore {
951 ) {956 ) {
952 let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground);957 let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground);
953 let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground);958 let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground);
954 const styleSize = this.runtime.layout("GhosttyStyle").size;
955 this.runtime.bytes(this.style, styleSize).fill(0);959 this.runtime.bytes(this.style, styleSize).fill(0);
956 this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);960 this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
957 this.runtime.call(961 this.runtime.call(
...@@ -960,30 +964,30 @@ export class GhosttyTerminalCore {...@@ -960,30 +964,30 @@ export class GhosttyTerminalCore {
960 CELL_DATA.style,964 CELL_DATA.style,
961 this.style,965 this.style,
962 );966 );
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 }
968 const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength);967 const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength);
969 let text = "";968 let text = "";
970 if (graphemeLength > 0) {969 if (graphemeLength > 0) {
971 const bufferSize = graphemeLength * 4;970 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 }
973 if (978 if (
974 this.runtime.call(979 this.runtime.call(
975 "ghostty_render_state_row_cells_get",980 "ghostty_render_state_row_cells_get",
976 cellsIterator,981 cellsIterator,
977 CELL_DATA.graphemes,982 CELL_DATA.graphemes,
978 codepoints,983 this.graphemes,
979 ) === GHOSTTY_SUCCESS984 ) === GHOSTTY_SUCCESS
980 ) {985 ) {
981 // Read through a DataView: the byte-array allocator guarantees no986 // Read through a DataView: the byte-array allocator guarantees no
982 // 4-byte alignment, which a Uint32Array view would require.987 // 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);
984 text = ghosttyCellText(codepointView, graphemeLength);989 text = ghosttyCellText(codepointView, graphemeLength);
985 }990 }
986 this.runtime.free(codepoints, bufferSize);
987 }991 }
988 let wide = 0;992 let wide = 0;
989 if (text.length === 0 && cells.at(-1)?.text.length) {993 if (text.length === 0 && cells.at(-1)?.text.length) {
...@@ -1004,18 +1008,27 @@ export class GhosttyTerminalCore {...@@ -1004,18 +1008,27 @@ export class GhosttyTerminalCore {
1004 );1008 );
1005 wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true);1009 wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true);
1006 }1010 }
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 }
1007 cells.push({1020 cells.push({
1008 text,1021 text,
1009 wide,1022 wide,
1010 foreground,1023 foreground,
1011 background,1024 background,
1012 bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0,1025 bold: styleView.getUint8(styleFields.bold!.offset) !== 0,
1013 italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0,1026 italic: styleView.getUint8(styleFields.italic!.offset) !== 0,
1014 invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0,1027 invisible: styleView.getUint8(styleFields.invisible!.offset) !== 0,
1015 strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0,1028 strikethrough: styleView.getUint8(styleFields.strikethrough!.offset) !== 0,
1016 overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0,1029 overline: styleView.getUint8(styleFields.overline!.offset) !== 0,
1017 underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0,1030 underline: styleView.getInt32(styleFields.underline!.offset, true) !== 0,
1018 selected: this.getCellBool(cellsIterator, CELL_DATA.selected),1031 selected,
1019 });1032 });
1020 }1033 }
1021 while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground));1034 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 {...@@ -23,6 +23,7 @@ export class GhosttyRuntime {
23 readonly memory: WebAssembly.Memory;23 readonly memory: WebAssembly.Memory;
24 readonly layouts: TypeLayouts;24 readonly layouts: TypeLayouts;
25 private readonly exports: WebAssembly.Exports;25 private readonly exports: WebAssembly.Exports;
26 private memoryView: DataView;
26 private readonly ptyWriters = new Map<number, (data: string) => void>();27 private readonly ptyWriters = new Map<number, (data: string) => void>();
27 private nextPtyWriterId = 1;28 private nextPtyWriterId = 1;
28 private writePtyFunctionIndex = 0;29 private writePtyFunctionIndex = 0;
...@@ -34,6 +35,7 @@ export class GhosttyRuntime {...@@ -34,6 +35,7 @@ export class GhosttyRuntime {
34 throw new Error("libghostty-vt did not export WebAssembly memory");35 throw new Error("libghostty-vt did not export WebAssembly memory");
35 }36 }
36 this.memory = memory;37 this.memory = memory;
38 this.memoryView = new DataView(memory.buffer);
37 const jsonPointer = this.call("ghostty_type_json");39 const jsonPointer = this.call("ghostty_type_json");
38 const bytes = new Uint8Array(memory.buffer);40 const bytes = new Uint8Array(memory.buffer);
39 let end = jsonPointer;41 let end = jsonPointer;
...@@ -104,7 +106,7 @@ export class GhosttyRuntime {...@@ -104,7 +106,7 @@ export class GhosttyRuntime {
104 }106 }
105107
106 readPointer(slot: number): number {108 readPointer(slot: number): number {
107 return new DataView(this.memory.buffer).getUint32(slot, true);109 return this.currentMemoryView().getUint32(slot, true);
108 }110 }
109111
110 attachPtyWriter(terminal: number, writer: (data: string) => void): number {112 attachPtyWriter(terminal: number, writer: (data: string) => void): number {
...@@ -132,27 +134,36 @@ export class GhosttyRuntime {...@@ -132,27 +134,36 @@ export class GhosttyRuntime {
132 return new Uint8Array(this.memory.buffer, pointer, size);134 return new Uint8Array(this.memory.buffer, pointer, size);
133 }135 }
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
135 setField(pointer: number, structName: string, fieldName: string, value: number): void {145 setField(pointer: number, structName: string, fieldName: string, value: number): void {
136 const field = this.layout(structName).fields[fieldName];146 const field = this.layout(structName).fields[fieldName];
137 if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);147 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;
139 switch (field.type) {150 switch (field.type) {
140 case "bool":151 case "bool":
141 case "u8":152 case "u8":
142 view.setUint8(0, value);153 view.setUint8(offset, value);
143 return;154 return;
144 case "u16":155 case "u16":
145 view.setUint16(0, value, true);156 view.setUint16(offset, value, true);
146 return;157 return;
147 case "i32":158 case "i32":
148 view.setInt32(0, value, true);159 view.setInt32(offset, value, true);
149 return;160 return;
150 case "u32":161 case "u32":
151 case "enum":162 case "enum":
152 view.setUint32(0, value, true);163 view.setUint32(offset, value, true);
153 return;164 return;
154 case "u64":165 case "u64":
155 view.setBigUint64(0, BigInt(value), true);166 view.setBigUint64(offset, BigInt(value), true);
156 return;167 return;
157 default:168 default:
158 throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);169 throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);
...@@ -162,20 +173,21 @@ export class GhosttyRuntime {...@@ -162,20 +173,21 @@ export class GhosttyRuntime {
162 readField(pointer: number, structName: string, fieldName: string): number {173 readField(pointer: number, structName: string, fieldName: string): number {
163 const field = this.layout(structName).fields[fieldName];174 const field = this.layout(structName).fields[fieldName];
164 if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);175 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;
166 switch (field.type) {178 switch (field.type) {
167 case "bool":179 case "bool":
168 case "u8":180 case "u8":
169 return view.getUint8(0);181 return view.getUint8(offset);
170 case "u16":182 case "u16":
171 return view.getUint16(0, true);183 return view.getUint16(offset, true);
172 case "i32":184 case "i32":
173 return view.getInt32(0, true);185 return view.getInt32(offset, true);
174 case "u32":186 case "u32":
175 case "enum":187 case "enum":
176 return view.getUint32(0, true);188 return view.getUint32(offset, true);
177 case "u64":189 case "u64":
178 return Number(view.getBigUint64(0, true));190 return Number(view.getBigUint64(offset, true));
179 default:191 default:
180 throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);192 throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);
181 }193 }