authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-10-08 17:22:30+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 08:22:30-07:00
log9a3070bcf023c3c025633659addeef4cf628133a
tree41cff920fe31c476d6487ab3d9a0f4a6d5b1e367
parent12069eefd707f78eafc27812027c994eea0613cf
signature Signed by PGP key B5690EEEBB952194

feat(web): improve terminal scrollback navigation and snapshots (#17091)


8 files changed, 429 insertions(+), 59 deletions(-)

apps/web/src/components/ThreadTerminalDrawer.test.ts+16-1
......@@ -24,6 +24,8 @@ describe("terminal selection menus", () => {
2424 "add-to-chat",
2525 "copy",
2626 "paste",
27 "select-all",
28 "scroll-to-bottom",
2729 ]);
2830
2931 expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([
......@@ -31,7 +33,20 @@ describe("terminal selection menus", () => {
3133 ]);
3234 expect(
3335 terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id),
34 ).toEqual(["copy", "paste"]);
36 ).toEqual(["copy", "paste", "select-all", "scroll-to-bottom"]);
37 });
38
39 it("keeps local viewing actions available to a read-only observer", () => {
40 const items = terminalContextMenuItems({ hasSelection: false, readOnly: true });
41 expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual([
42 "add-to-chat",
43 "copy",
44 "paste",
45 ]);
46 expect(items.filter((item) => !item.disabled).map((item) => item.id)).toEqual([
47 "select-all",
48 "scroll-to-bottom",
49 ]);
3550 });
3651});
3752
apps/web/src/components/ThreadTerminalDrawer.tsx+18-3
......@@ -265,7 +265,12 @@ export function terminalSelectionLineRange(position: {
265265 };
266266}
267267
268export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste";
268export type TerminalContextMenuAction =
269 | "add-to-chat"
270 | "copy"
271 | "paste"
272 | "select-all"
273 | "scroll-to-bottom";
269274
270275/** Post-selection popup: available selection actions, always enabled. */
271276export function terminalSelectionMenuItems(options?: {
......@@ -280,8 +285,8 @@ export function terminalSelectionMenuItems(options?: {
280285}
281286
282287/**
283 * Right-click menu for the terminal canvas: the selection actions (disabled
284 * until a selection exists) plus Paste. Paste is always offered: the browser
288 * Right-click menu for selection, clipboard, and local scrollback actions.
289 * Paste is always offered: the browser
285290 * (and Electron's default editing menu) can only paste into an editable
286291 * element, so a canvas terminal never gets a usable entry from them.
287292 */
......@@ -297,6 +302,8 @@ export function terminalContextMenuItems(options: {
297302 disabled: !hasSelection,
298303 })),
299304 { id: "paste", label: "Paste", ...(options.readOnly ? { disabled: true } : {}) },
305 { id: "select-all", label: "Select all" },
306 { id: "scroll-to-bottom", label: "Jump to latest" },
300307 ];
301308}
302309
......@@ -750,6 +757,14 @@ export function TerminalViewport({
750757 case "paste":
751758 await pasteFromClipboard(requestId);
752759 return;
760 case "select-all":
761 terminalRef.current?.selectAll();
762 focusIfCurrent(requestId);
763 return;
764 case "scroll-to-bottom":
765 terminalRef.current?.scrollToBottom();
766 focusIfCurrent(requestId);
767 return;
753768 }
754769 };
755770
apps/web/src/terminal/ghostty/core.test.ts+20
......@@ -154,6 +154,26 @@ describe("GhosttyTerminalCore snapshots", () => {
154154 });
155155 });
156156
157 it("resolves foreground and background independently without retaining prior cell colors", async () => {
158 const core = await createCore();
159 core.write(
160 "\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",
161 );
162 expect(
163 core
164 .snapshot()
165 .rowData[0]!.cells.slice(0, 6)
166 .map(({ text, foreground, background }) => ({ text, foreground, background })),
167 ).toEqual([
168 { text: "F", foreground: { r: 123, g: 45, b: 67 }, background: { r: 0, g: 0, b: 0 } },
169 { text: "B", foreground: { r: 255, g: 255, b: 255 }, background: { r: 9, g: 8, b: 7 } },
170 { text: "D", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } },
171 { text: "C", foreground: { r: 12, g: 34, b: 56 }, background: { r: 78, g: 90, b: 123 } },
172 { text: "E", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } },
173 { text: "", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } },
174 ]);
175 });
176
157177 it("reuses a grown grapheme buffer and releases it on disposal", async () => {
158178 const core = await createCore();
159179 const runtime = await loadGhosttyRuntime();
apps/web/src/terminal/ghostty/core.ts+77-44
......@@ -47,6 +47,19 @@ const CELL_DATA = {
4747 selected: 7,
4848} as const;
4949
50// wasm32 arrays for the official row_cells_get_multi API. Optional colors
51// come last because an unset color stops the query at that field.
52const CELL_QUERY = {
53 keys: 0,
54 values: 20,
55 graphemesLength: 40,
56 selected: 44,
57 background: 48,
58 foreground: 52,
59 written: 56,
60 size: 60,
61} as const;
62
5063const RAW_CELL_DATA = {
5164 wide: 3,
5265} as const;
......@@ -210,6 +223,7 @@ export class GhosttyTerminalCore {
210223 private graphemes = 0;
211224 private graphemeCapacity = 0;
212225 private style = 0;
226 private cellQuery = 0;
213227 private scrollbar = 0;
214228 private rows: GhosttyRow[] = [];
215229 private disposed = false;
......@@ -310,6 +324,19 @@ export class GhosttyTerminalCore {
310324 const styleSize = this.runtime.layout("GhosttyStyle").size;
311325 this.style = this.runtime.alloc(styleSize);
312326 this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
327 this.cellQuery = this.runtime.alloc(CELL_QUERY.size);
328 const queryView = this.runtime.memoryDataView();
329 const cellFields = [
330 [CELL_DATA.style, this.style],
331 [CELL_DATA.graphemesLength, this.cellQuery + CELL_QUERY.graphemesLength],
332 [CELL_DATA.selected, this.cellQuery + CELL_QUERY.selected],
333 [CELL_DATA.background, this.cellQuery + CELL_QUERY.background],
334 [CELL_DATA.foreground, this.cellQuery + CELL_QUERY.foreground],
335 ] as const;
336 for (const [index, [key, pointer]] of cellFields.entries()) {
337 queryView.setUint32(this.cellQuery + CELL_QUERY.keys + index * 4, key, true);
338 queryView.setUint32(this.cellQuery + CELL_QUERY.values + index * 4, pointer, true);
339 }
313340 this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size);
314341 this.setTheme(theme);
315342 this.resize(cols, rows, cellWidth, cellHeight);
......@@ -658,17 +685,26 @@ export class GhosttyTerminalCore {
658685 }
659686 }
660687
661 selectAll(): void {
688 selectAll(): GhosttySelectionRange["screen"] | null {
662689 this.ensureActive();
663690 const layout = this.runtime.layout("GhosttySelection");
664691 const selection = this.runtime.alloc(layout.size);
665 this.runtime.setField(selection, "GhosttySelection", "size", layout.size);
666 if (
667 this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS
668 ) {
692 try {
693 this.runtime.setField(selection, "GhosttySelection", "size", layout.size);
694 if (
695 this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) !==
696 GHOSTTY_SUCCESS
697 ) {
698 return null;
699 }
700 const start = this.pointFromGridRef(selection + layout.fields.start!.offset, 2);
701 const end = this.pointFromGridRef(selection + layout.fields.end!.offset, 2);
702 if (!start || !end) return null;
669703 this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
704 return { start, end };
705 } finally {
706 this.runtime.free(selection, layout.size);
670707 }
671 this.runtime.free(selection, layout.size);
672708 }
673709
674710 selectWord(col: number, row: number): GhosttySelectionRange | null {
......@@ -877,6 +913,7 @@ export class GhosttyTerminalCore {
877913 this.runtime.call("ghostty_terminal_free", this.terminal);
878914 }
879915 if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size);
916 if (this.cellQuery) this.runtime.free(this.cellQuery, CELL_QUERY.size);
880917 if (this.scrollbar) {
881918 this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size);
882919 }
......@@ -954,17 +991,29 @@ export class GhosttyTerminalCore {
954991 cells.length < cols &&
955992 this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0
956993 ) {
957 let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground);
958 let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground);
959994 this.runtime.bytes(this.style, styleSize).fill(0);
960995 this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
961 this.runtime.call(
962 "ghostty_render_state_row_cells_get",
996 const result = this.runtime.call(
997 "ghostty_render_state_row_cells_get_multi",
963998 cellsIterator,
964 CELL_DATA.style,
965 this.style,
999 5,
1000 this.cellQuery + CELL_QUERY.keys,
1001 this.cellQuery + CELL_QUERY.values,
1002 this.cellQuery + CELL_QUERY.written,
9661003 );
967 const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength);
1004 const queryView = this.runtime.memoryDataView();
1005 const written = queryView.getUint32(this.cellQuery + CELL_QUERY.written, true);
1006 if (written < 3) this.assertSuccess("ghostty_render_state_row_cells_get_multi", result);
1007 const graphemeLength = queryView.getUint32(this.cellQuery + CELL_QUERY.graphemesLength, true);
1008 const selected = queryView.getUint8(this.cellQuery + CELL_QUERY.selected) !== 0;
1009 let background =
1010 written >= 4 ? this.readColor(this.cellQuery + CELL_QUERY.background) : defaultBackground;
1011 let foreground =
1012 written === 5
1013 ? this.readColor(this.cellQuery + CELL_QUERY.foreground)
1014 : written === 3
1015 ? this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground)
1016 : defaultForeground;
9681017 let text = "";
9691018 if (graphemeLength > 0) {
9701019 const bufferSize = graphemeLength * 4;
......@@ -1008,13 +1057,12 @@ export class GhosttyTerminalCore {
10081057 );
10091058 wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true);
10101059 }
1011 const selected = this.getCellBool(cellsIterator, CELL_DATA.selected);
10121060 // 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) {
1061 const styleView = this.runtime.memoryDataView();
1062 if (styleView.getUint8(this.style + styleFields.inverse!.offset) !== 0) {
10151063 [foreground, background] = [background, foreground];
10161064 }
1017 if (styleView.getUint8(styleFields.faint!.offset) !== 0) {
1065 if (styleView.getUint8(this.style + styleFields.faint!.offset) !== 0) {
10181066 foreground = blend(foreground, background);
10191067 }
10201068 cells.push({
......@@ -1022,12 +1070,12 @@ export class GhosttyTerminalCore {
10221070 wide,
10231071 foreground,
10241072 background,
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,
1073 bold: styleView.getUint8(this.style + styleFields.bold!.offset) !== 0,
1074 italic: styleView.getUint8(this.style + styleFields.italic!.offset) !== 0,
1075 invisible: styleView.getUint8(this.style + styleFields.invisible!.offset) !== 0,
1076 strikethrough: styleView.getUint8(this.style + styleFields.strikethrough!.offset) !== 0,
1077 overline: styleView.getUint8(this.style + styleFields.overline!.offset) !== 0,
1078 underline: styleView.getInt32(this.style + styleFields.underline!.offset, true) !== 0,
10311079 selected,
10321080 });
10331081 }
......@@ -1177,25 +1225,6 @@ export class GhosttyTerminalCore {
11771225 );
11781226 }
11791227
1180 private getCellU32(iterator: number, data: number): number {
1181 this.runtime.bytes(this.scratch, 4).fill(0);
1182 const result = this.runtime.call(
1183 "ghostty_render_state_row_cells_get",
1184 iterator,
1185 data,
1186 this.scratch,
1187 );
1188 return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0;
1189 }
1190
1191 private getCellBool(iterator: number, data: number): boolean {
1192 this.runtime.bytes(this.scratch, 1)[0] = 0;
1193 return (
1194 this.runtime.call("ghostty_render_state_row_cells_get", iterator, data, this.scratch) ===
1195 GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
1196 );
1197 }
1198
11991228 private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor {
12001229 this.runtime.bytes(this.scratch, 3).fill(0);
12011230 const result = this.runtime.call(
......@@ -1208,8 +1237,12 @@ export class GhosttyTerminalCore {
12081237 }
12091238
12101239 private readColor(pointer: number): GhosttyColor {
1211 const bytes = this.runtime.bytes(pointer, 3);
1212 return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 };
1240 const view = this.runtime.memoryDataView();
1241 return {
1242 r: view.getUint8(pointer),
1243 g: view.getUint8(pointer + 1),
1244 b: view.getUint8(pointer + 2),
1245 };
12131246 }
12141247
12151248 private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell {
apps/web/src/terminal/ghostty/runtime.ts+4-4
......@@ -106,7 +106,7 @@ export class GhosttyRuntime {
106106 }
107107
108108 readPointer(slot: number): number {
109 return this.currentMemoryView().getUint32(slot, true);
109 return this.memoryDataView().getUint32(slot, true);
110110 }
111111
112112 attachPtyWriter(terminal: number, writer: (data: string) => void): number {
......@@ -135,7 +135,7 @@ export class GhosttyRuntime {
135135 }
136136
137137 /** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */
138 private currentMemoryView(): DataView {
138 memoryDataView(): DataView {
139139 if (this.memoryView.buffer !== this.memory.buffer) {
140140 this.memoryView = new DataView(this.memory.buffer);
141141 }
......@@ -145,7 +145,7 @@ export class GhosttyRuntime {
145145 setField(pointer: number, structName: string, fieldName: string, value: number): void {
146146 const field = this.layout(structName).fields[fieldName];
147147 if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);
148 const view = this.currentMemoryView();
148 const view = this.memoryDataView();
149149 const offset = pointer + field.offset;
150150 switch (field.type) {
151151 case "bool":
......@@ -173,7 +173,7 @@ export class GhosttyRuntime {
173173 readField(pointer: number, structName: string, fieldName: string): number {
174174 const field = this.layout(structName).fields[fieldName];
175175 if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);
176 const view = this.currentMemoryView();
176 const view = this.memoryDataView();
177177 const offset = pointer + field.offset;
178178 switch (field.type) {
179179 case "bool":
apps/web/src/terminal/ghostty/surface.test.ts+211
......@@ -42,6 +42,40 @@ vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({
4242describe("GhosttyTerminalSurface visibility", () => {
4343 const surfaces = new Set<GhosttyTerminalSurface>();
4444
45 function key(
46 surface: GhosttyTerminalSurface,
47 key: string,
48 code: string,
49 modifiers: Partial<
50 Pick<
51 KeyboardEvent,
52 | "altKey"
53 | "ctrlKey"
54 | "metaKey"
55 | "shiftKey"
56 | "isComposing"
57 | "keyCode"
58 | "getModifierState"
59 >
60 > = {},
61 type = "keydown",
62 ) {
63 const event = Object.assign(new Event(type, { cancelable: true }), {
64 key,
65 code,
66 altKey: false,
67 ctrlKey: false,
68 metaKey: false,
69 shiftKey: false,
70 isComposing: false,
71 keyCode: 0,
72 getModifierState: () => false,
73 ...modifiers,
74 });
75 surface.input.dispatchEvent(event);
76 return event;
77 }
78
4579 // Keep the real surface, renderer, and WASM core. Only browser layout and
4680 // scheduling are replaced so tests can count work while the terminal is hidden.
4781 function createHarness() {
......@@ -216,6 +250,183 @@ describe("GhosttyTerminalSurface visibility", () => {
216250 vi.restoreAllMocks();
217251 });
218252
253 it.each([
254 { platform: "Linux x86_64", primary: { ctrlKey: true } },
255 { platform: "MacIntel", primary: { metaKey: true } },
256 ])(
257 "navigates history from terminal input without sending keys ($platform)",
258 async ({ platform, primary }) => {
259 vi.stubGlobal("navigator", { platform });
260 const harness = createHarness();
261 const surface = await harness.create({ beforeKey: () => true });
262 surface.write(Array.from({ length: 20 }, (_, index) => `line ${index}`).join("\r\n"));
263 harness.flushFrame();
264 const latest = harness.renderedSnapshot.rowData[0]?.text;
265 expect(surface.isAtBottom()).toBe(true);
266
267 expect(key(surface, "PageUp", "PageUp", { shiftKey: true }).defaultPrevented).toBe(true);
268 harness.flushFrame();
269 expect(harness.renderedSnapshot.rowData[0]?.text).toContain("line 8");
270 expect(surface.isAtBottom()).toBe(false);
271 key(surface, "PageDown", "PageDown", { shiftKey: true });
272 harness.flushFrame();
273 expect(harness.renderedSnapshot.rowData[0]?.text).toBe(latest);
274
275 key(surface, "Home", "Home", { ...primary, shiftKey: true });
276 harness.flushFrame();
277 expect(harness.renderedSnapshot.rowData[0]?.text).toContain("line 0");
278 key(surface, "End", "End", { ...primary, shiftKey: true });
279 harness.flushFrame();
280 expect(harness.renderedSnapshot.rowData[0]?.text).toBe(latest);
281 expect(surface.isAtBottom()).toBe(true);
282 expect(harness.onData).not.toHaveBeenCalled();
283 },
284 );
285
286 it("forwards navigation presses and releases when scrollback state is unavailable", async () => {
287 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
288 const harness = createHarness();
289 const surface = await harness.create({ beforeKey: () => true });
290 surface.write("history\x1b[>31u");
291 vi.spyOn(GhosttyTerminalCore.prototype, "scrollbarState").mockReturnValue(null);
292
293 for (const [name, modifiers] of [
294 ["PageUp", { shiftKey: true }],
295 ["Home", { ctrlKey: true, shiftKey: true }],
296 ] as const) {
297 key(surface, name, name, modifiers);
298 key(surface, name, name, modifiers, "keyup");
299 }
300 expect(harness.onData).toHaveBeenCalledTimes(4);
301 expect(harness.onData.mock.calls[1]?.[0]).toContain(":3");
302 expect(harness.onData.mock.calls[3]?.[0]).toContain(":3");
303 });
304
305 it("selects full history and keeps selection coordinates available outside the viewport", async () => {
306 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
307 const harness = createHarness();
308 const surface = await harness.create({ beforeKey: () => true });
309 const lines = Array.from({ length: 20 }, (_, index) => `line ${index}`);
310 surface.write(lines.join("\r\n"));
311 harness.flushFrame();
312 surface.selectAll();
313 harness.flushFrame();
314 expect(surface.getSelection()).toBe(lines.join("\n"));
315 expect(surface.getSelectionPosition()).toMatchObject({ start: { x: 0, y: 0 } });
316 expect(surface.getSelectionPosition()?.end.y).toBe(19);
317 expect(surface.getSelectionEndClientRect()).not.toBeNull();
318 expect(harness.renderedSnapshot.rowData.every((row) => row.cells[0]?.selected)).toBe(true);
319
320 key(surface, "Home", "Home", { ctrlKey: true, shiftKey: true });
321 harness.flushFrame();
322 surface.selectAll();
323 expect(surface.getSelection()).toBe(lines.join("\n"));
324 expect(surface.getSelectionPosition()?.end.y).toBe(19);
325 expect(surface.getSelectionEndClientRect()).toBeNull();
326 surface.clearSelection();
327 expect(surface.getSelection()).toBe("");
328 expect(surface.getSelectionPosition()).toBeNull();
329 });
330
331 it.each([
332 { platform: "Linux x86_64", modifiers: { ctrlKey: true, shiftKey: true } },
333 { platform: "MacIntel", modifiers: { metaKey: true } },
334 ])("selects all through the local shortcut ($platform)", async ({ platform, modifiers }) => {
335 vi.stubGlobal("navigator", { platform });
336 const harness = createHarness();
337 const surface = await harness.create({ beforeKey: () => true });
338 surface.write("hello\r\nworld");
339 harness.flushFrame();
340 expect(key(surface, "a", "KeyA", modifiers).defaultPrevented).toBe(true);
341 expect(surface.getSelection()).toBe("hello\nworld");
342 expect(surface.getSelectionPosition()).not.toBeNull();
343 expect(harness.onData).not.toHaveBeenCalled();
344 });
345
346 it("preserves shell Ctrl+A and ordinary application navigation", async () => {
347 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
348 const harness = createHarness();
349 const surface = await harness.create({ beforeKey: () => true });
350 key(surface, "a", "KeyA", { ctrlKey: true });
351 key(surface, "PageUp", "PageUp");
352 key(surface, "PageDown", "PageDown");
353 key(surface, "Home", "Home");
354 key(surface, "End", "End");
355 expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([
356 "\x01",
357 "\x1b[5~",
358 "\x1b[6~",
359 "\x1b[H",
360 "\x1b[F",
361 ]);
362 expect(surface.hasSelection()).toBe(false);
363 });
364
365 it("leaves modified history keys with alternate-screen applications", async () => {
366 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
367 const harness = createHarness();
368 const surface = await harness.create({ beforeKey: () => true });
369 surface.write("\x1b[?1049happ");
370 key(surface, "PageUp", "PageUp", { shiftKey: true });
371 key(surface, "End", "End", { ctrlKey: true, shiftKey: true });
372 expect(harness.onData.mock.calls.map(([data]) => data)).toEqual(["\x1b[5;2~", "\x1b[1;6F"]);
373 surface.selectAll();
374 expect(surface.getSelection()).toBe("app");
375 surface.write("\x1b[?1049l");
376 expect(surface.getSelection()).toBe("");
377 });
378
379 it("suppresses local shortcut releases in Kitty sessions, including read-only observers", async () => {
380 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
381 const harness = createHarness();
382 const surface = await harness.create({ beforeKey: () => true });
383 surface.input.readOnly = true;
384 surface.write("history\x1b[>31u");
385 for (const [name, code, modifiers] of [
386 ["PageUp", "PageUp", { shiftKey: true }],
387 ["Home", "Home", { ctrlKey: true, shiftKey: true }],
388 ["a", "KeyA", { ctrlKey: true, shiftKey: true }],
389 ] as const) {
390 key(surface, name, code, modifiers);
391 key(surface, name, code, modifiers, "keyup");
392 }
393 expect(surface.getSelection()).toBe("history");
394 expect(harness.onData).not.toHaveBeenCalled();
395
396 // An ordinary application key still has both press and release reports.
397 key(surface, "PageUp", "PageUp");
398 key(surface, "PageUp", "PageUp", {}, "keyup");
399 expect(harness.onData).toHaveBeenCalledTimes(2);
400 expect(harness.onData.mock.calls[1]?.[0]).toContain(":3");
401 });
402
403 it("honors host interception, IME candidates, and AltGr before local shortcuts", async () => {
404 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
405 const harness = createHarness();
406 let allow = false;
407 const surface = await harness.create({ beforeKey: () => allow });
408 surface.write("hello\x1b[>31u");
409 key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true });
410 key(surface, "a", "KeyA", {}, "keyup");
411 expect(surface.hasSelection()).toBe(false);
412 allow = true;
413 surface.input.value = "候補";
414 key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true, isComposing: true });
415 key(surface, "a", "KeyA", {}, "keyup");
416 key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true, keyCode: 229 });
417 key(surface, "a", "KeyA", {}, "keyup");
418 key(surface, "a", "KeyA", {
419 ctrlKey: true,
420 shiftKey: true,
421 altKey: true,
422 getModifierState: (modifier) => modifier === "AltGraph",
423 });
424 key(surface, "a", "KeyA", {}, "keyup");
425 expect(surface.input.value).toBe("候補");
426 expect(surface.hasSelection()).toBe(false);
427 expect(harness.onData).not.toHaveBeenCalled();
428 });
429
219430 it.each([
220431 { platform: "Linux x86_64", modifiers: { ctrlKey: true, metaKey: false } },
221432 { platform: "MacIntel", modifiers: { ctrlKey: false, metaKey: true } },
apps/web/src/terminal/ghostty/surface.ts+73-7
......@@ -982,6 +982,21 @@ export class GhosttyTerminalSurface {
982982 return this.core.selectionText();
983983 }
984984
985 /** Select the active screen's entire history, including rows outside the viewport. */
986 selectAll(): void {
987 this.clearPrimedCopy();
988 const range = this.core.selectAll();
989 this.selectionAnchorScreen = range?.start ?? null;
990 this.selectionEndScreen = range?.end ?? null;
991 this.selectionEnd = range ? this.core.screenPointToViewport(range.end.x, range.end.y) : null;
992 this.selectionMode = "cell";
993 this.selectionBase = null;
994 this.setSelectionAutoscroll(0);
995 this.options.onSelectionChange();
996 this.forceFullRender = true;
997 this.requestRender();
998 }
999
9851000 getSelectionPosition(): GhosttySelectionPosition | null {
9861001 if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection())
9871002 return null;
......@@ -998,7 +1013,15 @@ export class GhosttyTerminalSurface {
9981013 const position = this.getSelectionPosition();
9991014 if (!position) return null;
10001015 const viewportEnd = this.core.screenPointToViewport(position.end.x, position.end.y);
1001 if (!viewportEnd) return null;
1016 if (
1017 !viewportEnd ||
1018 viewportEnd.x < 0 ||
1019 viewportEnd.x >= this.cols ||
1020 viewportEnd.y < 0 ||
1021 viewportEnd.y >= this.rows
1022 ) {
1023 return null;
1024 }
10021025 const bounds = this.canvas.getBoundingClientRect();
10031026 return {
10041027 right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width,
......@@ -1074,6 +1097,55 @@ export class GhosttyTerminalSurface {
10741097 this.suppressedKeyCodes.add(event.code);
10751098 return;
10761099 }
1100 // IME candidates belong to the textarea, even if the key also resembles
1101 // a local shortcut. Safari signals the initial composition with code 229.
1102 if (isTerminalCompositionKey(event, this.composing)) {
1103 this.suppressedKeyCodes.add(event.code);
1104 return;
1105 }
1106 const mac = isMacPlatform(navigator.platform);
1107 const primaryModifier = mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
1108 const selectAllShortcut =
1109 event.key.toLowerCase() === "a" &&
1110 primaryModifier &&
1111 !event.altKey &&
1112 (mac ? !event.shiftKey : event.shiftKey);
1113 if (selectAllShortcut) {
1114 event.preventDefault();
1115 event.stopPropagation();
1116 this.suppressedKeyCodes.add(event.code);
1117 this.selectAll();
1118 return;
1119 }
1120 // Shift+PageUp/Down pages history; Ctrl+Shift+Home/End (Cmd on macOS)
1121 // jumps to its edges. Full-screen applications retain these keys.
1122 const pageHistory =
1123 event.shiftKey &&
1124 !event.altKey &&
1125 !event.ctrlKey &&
1126 !event.metaKey &&
1127 (event.key === "PageUp" || event.key === "PageDown");
1128 const jumpHistory =
1129 event.shiftKey &&
1130 !event.altKey &&
1131 primaryModifier &&
1132 (event.key === "Home" || event.key === "End");
1133 if ((pageHistory || jumpHistory) && !this.core.isAlternateScreen()) {
1134 const state = this.readScrollbarState();
1135 if (state !== null) {
1136 event.preventDefault();
1137 event.stopPropagation();
1138 this.suppressedKeyCodes.add(event.code);
1139 const delta =
1140 event.key === "Home"
1141 ? -state.offset
1142 : event.key === "End"
1143 ? state.total - state.len - state.offset
1144 : Math.max(1, state.len) * (event.key === "PageUp" ? -1 : 1);
1145 this.scrollViewport(delta);
1146 return;
1147 }
1148 }
10771149 if (isTerminalCopyShortcut(event) && this.hasSelection()) {
10781150 // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in
10791151 // onCopyEvent; not preventing the default keeps that path alive. WebKit
......@@ -1152,12 +1224,6 @@ export class GhosttyTerminalSurface {
11521224 }
11531225 return;
11541226 }
1155 // keyCode 229 is Safari's only signal that this keydown opens an IME
1156 // composition; encoding it would double the committed text. Do not blank
1157 // the textarea first: onInput leaves the in-progress candidate there.
1158 if (isTerminalCompositionKey(event, this.composing)) {
1159 return;
1160 }
11611227 this.clearPrimedCopy();
11621228 const data = this.core.encodeKey(event);
11631229 if (data.length === 0) return;
docs/user/terminal.md+10
......@@ -6,3 +6,13 @@ line can be shortened at the start. New terminal output is not truncated.
66
77These limits apply when you reconnect and when T3 Code restores saved terminal
88history. A client can show less scrollback than the server keeps.
9
10On web and desktop, use Shift+PageUp and Shift+PageDown to read scrollback
11without leaving terminal input. Ctrl+Shift+Home and Ctrl+Shift+End jump to the
12start and latest output; use Cmd instead of Ctrl on macOS. Full-screen terminal
13programs keep these navigation keys.
14
15To copy all retained output, choose Select all in the terminal's context menu,
16then copy. Cmd+A on macOS and Ctrl+Shift+A elsewhere select the same output.
17Ctrl+A still moves to the beginning of shell input on Windows and Linux.
18Jump to latest in the context menu returns to the current output.