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", () => {...@@ -24,6 +24,8 @@ describe("terminal selection menus", () => {
24 "add-to-chat",24 "add-to-chat",
25 "copy",25 "copy",
26 "paste",26 "paste",
27 "select-all",
28 "scroll-to-bottom",
27 ]);29 ]);
2830
29 expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([31 expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([
...@@ -31,7 +33,20 @@ describe("terminal selection menus", () => {...@@ -31,7 +33,20 @@ describe("terminal selection menus", () => {
31 ]);33 ]);
32 expect(34 expect(
33 terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id),35 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 ]);
35 });50 });
36});51});
3752
apps/web/src/components/ThreadTerminalDrawer.tsx+18-3
...@@ -265,7 +265,12 @@ export function terminalSelectionLineRange(position: {...@@ -265,7 +265,12 @@ export function terminalSelectionLineRange(position: {
265 };265 };
266}266}
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
270/** Post-selection popup: available selection actions, always enabled. */275/** Post-selection popup: available selection actions, always enabled. */
271export function terminalSelectionMenuItems(options?: {276export function terminalSelectionMenuItems(options?: {
...@@ -280,8 +285,8 @@ export function terminalSelectionMenuItems(options?: {...@@ -280,8 +285,8 @@ export function terminalSelectionMenuItems(options?: {
280}285}
281286
282/**287/**
283 * Right-click menu for the terminal canvas: the selection actions (disabled288 * Right-click menu for selection, clipboard, and local scrollback actions.
284 * until a selection exists) plus Paste. Paste is always offered: the browser289 * Paste is always offered: the browser
285 * (and Electron's default editing menu) can only paste into an editable290 * (and Electron's default editing menu) can only paste into an editable
286 * element, so a canvas terminal never gets a usable entry from them.291 * element, so a canvas terminal never gets a usable entry from them.
287 */292 */
...@@ -297,6 +302,8 @@ export function terminalContextMenuItems(options: {...@@ -297,6 +302,8 @@ export function terminalContextMenuItems(options: {
297 disabled: !hasSelection,302 disabled: !hasSelection,
298 })),303 })),
299 { id: "paste", label: "Paste", ...(options.readOnly ? { disabled: true } : {}) },304 { id: "paste", label: "Paste", ...(options.readOnly ? { disabled: true } : {}) },
305 { id: "select-all", label: "Select all" },
306 { id: "scroll-to-bottom", label: "Jump to latest" },
300 ];307 ];
301}308}
302309
...@@ -750,6 +757,14 @@ export function TerminalViewport({...@@ -750,6 +757,14 @@ export function TerminalViewport({
750 case "paste":757 case "paste":
751 await pasteFromClipboard(requestId);758 await pasteFromClipboard(requestId);
752 return;759 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;
753 }768 }
754 };769 };
755770
apps/web/src/terminal/ghostty/core.test.ts+20
...@@ -154,6 +154,26 @@ describe("GhosttyTerminalCore snapshots", () => {...@@ -154,6 +154,26 @@ describe("GhosttyTerminalCore snapshots", () => {
154 });154 });
155 });155 });
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
157 it("reuses a grown grapheme buffer and releases it on disposal", async () => {177 it("reuses a grown grapheme buffer and releases it on disposal", async () => {
158 const core = await createCore();178 const core = await createCore();
159 const runtime = await loadGhosttyRuntime();179 const runtime = await loadGhosttyRuntime();
apps/web/src/terminal/ghostty/core.ts+77-44
...@@ -47,6 +47,19 @@ const CELL_DATA = {...@@ -47,6 +47,19 @@ const CELL_DATA = {
47 selected: 7,47 selected: 7,
48} as const;48} 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
50const RAW_CELL_DATA = {63const RAW_CELL_DATA = {
51 wide: 3,64 wide: 3,
52} as const;65} as const;
...@@ -210,6 +223,7 @@ export class GhosttyTerminalCore {...@@ -210,6 +223,7 @@ export class GhosttyTerminalCore {
210 private graphemes = 0;223 private graphemes = 0;
211 private graphemeCapacity = 0;224 private graphemeCapacity = 0;
212 private style = 0;225 private style = 0;
226 private cellQuery = 0;
213 private scrollbar = 0;227 private scrollbar = 0;
214 private rows: GhosttyRow[] = [];228 private rows: GhosttyRow[] = [];
215 private disposed = false;229 private disposed = false;
...@@ -310,6 +324,19 @@ export class GhosttyTerminalCore {...@@ -310,6 +324,19 @@ export class GhosttyTerminalCore {
310 const styleSize = this.runtime.layout("GhosttyStyle").size;324 const styleSize = this.runtime.layout("GhosttyStyle").size;
311 this.style = this.runtime.alloc(styleSize);325 this.style = this.runtime.alloc(styleSize);
312 this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);326 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 }
313 this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size);340 this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size);
314 this.setTheme(theme);341 this.setTheme(theme);
315 this.resize(cols, rows, cellWidth, cellHeight);342 this.resize(cols, rows, cellWidth, cellHeight);
...@@ -658,17 +685,26 @@ export class GhosttyTerminalCore {...@@ -658,17 +685,26 @@ export class GhosttyTerminalCore {
658 }685 }
659 }686 }
660687
661 selectAll(): void {688 selectAll(): GhosttySelectionRange["screen"] | null {
662 this.ensureActive();689 this.ensureActive();
663 const layout = this.runtime.layout("GhosttySelection");690 const layout = this.runtime.layout("GhosttySelection");
664 const selection = this.runtime.alloc(layout.size);691 const selection = this.runtime.alloc(layout.size);
665 this.runtime.setField(selection, "GhosttySelection", "size", layout.size);692 try {
666 if (693 this.runtime.setField(selection, "GhosttySelection", "size", layout.size);
667 this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS694 if (
668 ) {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;
669 this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);703 this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
704 return { start, end };
705 } finally {
706 this.runtime.free(selection, layout.size);
670 }707 }
671 this.runtime.free(selection, layout.size);
672 }708 }
673709
674 selectWord(col: number, row: number): GhosttySelectionRange | null {710 selectWord(col: number, row: number): GhosttySelectionRange | null {
...@@ -877,6 +913,7 @@ export class GhosttyTerminalCore {...@@ -877,6 +913,7 @@ export class GhosttyTerminalCore {
877 this.runtime.call("ghostty_terminal_free", this.terminal);913 this.runtime.call("ghostty_terminal_free", this.terminal);
878 }914 }
879 if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size);915 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);
880 if (this.scrollbar) {917 if (this.scrollbar) {
881 this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size);918 this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size);
882 }919 }
...@@ -954,17 +991,29 @@ export class GhosttyTerminalCore {...@@ -954,17 +991,29 @@ export class GhosttyTerminalCore {
954 cells.length < cols &&991 cells.length < cols &&
955 this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0992 this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0
956 ) {993 ) {
957 let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground);
958 let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground);
959 this.runtime.bytes(this.style, styleSize).fill(0);994 this.runtime.bytes(this.style, styleSize).fill(0);
960 this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);995 this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
961 this.runtime.call(996 const result = this.runtime.call(
962 "ghostty_render_state_row_cells_get",997 "ghostty_render_state_row_cells_get_multi",
963 cellsIterator,998 cellsIterator,
964 CELL_DATA.style,999 5,
965 this.style,1000 this.cellQuery + CELL_QUERY.keys,
1001 this.cellQuery + CELL_QUERY.values,
1002 this.cellQuery + CELL_QUERY.written,
966 );1003 );
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;
968 let text = "";1017 let text = "";
969 if (graphemeLength > 0) {1018 if (graphemeLength > 0) {
970 const bufferSize = graphemeLength * 4;1019 const bufferSize = graphemeLength * 4;
...@@ -1008,13 +1057,12 @@ export class GhosttyTerminalCore {...@@ -1008,13 +1057,12 @@ export class GhosttyTerminalCore {
1008 );1057 );
1009 wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true);1058 wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true);
1010 }1059 }
1011 const selected = this.getCellBool(cellsIterator, CELL_DATA.selected);
1012 // Read the style after allocation and ABI calls, which can grow WASM memory.1060 // Read the style after allocation and ABI calls, which can grow WASM memory.
1013 const styleView = this.runtime.view(this.style, styleSize);1061 const styleView = this.runtime.memoryDataView();
1014 if (styleView.getUint8(styleFields.inverse!.offset) !== 0) {1062 if (styleView.getUint8(this.style + styleFields.inverse!.offset) !== 0) {
1015 [foreground, background] = [background, foreground];1063 [foreground, background] = [background, foreground];
1016 }1064 }
1017 if (styleView.getUint8(styleFields.faint!.offset) !== 0) {1065 if (styleView.getUint8(this.style + styleFields.faint!.offset) !== 0) {
1018 foreground = blend(foreground, background);1066 foreground = blend(foreground, background);
1019 }1067 }
1020 cells.push({1068 cells.push({
...@@ -1022,12 +1070,12 @@ export class GhosttyTerminalCore {...@@ -1022,12 +1070,12 @@ export class GhosttyTerminalCore {
1022 wide,1070 wide,
1023 foreground,1071 foreground,
1024 background,1072 background,
1025 bold: styleView.getUint8(styleFields.bold!.offset) !== 0,1073 bold: styleView.getUint8(this.style + styleFields.bold!.offset) !== 0,
1026 italic: styleView.getUint8(styleFields.italic!.offset) !== 0,1074 italic: styleView.getUint8(this.style + styleFields.italic!.offset) !== 0,
1027 invisible: styleView.getUint8(styleFields.invisible!.offset) !== 0,1075 invisible: styleView.getUint8(this.style + styleFields.invisible!.offset) !== 0,
1028 strikethrough: styleView.getUint8(styleFields.strikethrough!.offset) !== 0,1076 strikethrough: styleView.getUint8(this.style + styleFields.strikethrough!.offset) !== 0,
1029 overline: styleView.getUint8(styleFields.overline!.offset) !== 0,1077 overline: styleView.getUint8(this.style + styleFields.overline!.offset) !== 0,
1030 underline: styleView.getInt32(styleFields.underline!.offset, true) !== 0,1078 underline: styleView.getInt32(this.style + styleFields.underline!.offset, true) !== 0,
1031 selected,1079 selected,
1032 });1080 });
1033 }1081 }
...@@ -1177,25 +1225,6 @@ export class GhosttyTerminalCore {...@@ -1177,25 +1225,6 @@ export class GhosttyTerminalCore {
1177 );1225 );
1178 }1226 }
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
1199 private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor {1228 private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor {
1200 this.runtime.bytes(this.scratch, 3).fill(0);1229 this.runtime.bytes(this.scratch, 3).fill(0);
1201 const result = this.runtime.call(1230 const result = this.runtime.call(
...@@ -1208,8 +1237,12 @@ export class GhosttyTerminalCore {...@@ -1208,8 +1237,12 @@ export class GhosttyTerminalCore {
1208 }1237 }
12091238
1210 private readColor(pointer: number): GhosttyColor {1239 private readColor(pointer: number): GhosttyColor {
1211 const bytes = this.runtime.bytes(pointer, 3);1240 const view = this.runtime.memoryDataView();
1212 return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 };1241 return {
1242 r: view.getUint8(pointer),
1243 g: view.getUint8(pointer + 1),
1244 b: view.getUint8(pointer + 2),
1245 };
1213 }1246 }
12141247
1215 private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell {1248 private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell {
apps/web/src/terminal/ghostty/runtime.ts+4-4
...@@ -106,7 +106,7 @@ export class GhosttyRuntime {...@@ -106,7 +106,7 @@ export class GhosttyRuntime {
106 }106 }
107107
108 readPointer(slot: number): number {108 readPointer(slot: number): number {
109 return this.currentMemoryView().getUint32(slot, true);109 return this.memoryDataView().getUint32(slot, true);
110 }110 }
111111
112 attachPtyWriter(terminal: number, writer: (data: string) => void): number {112 attachPtyWriter(terminal: number, writer: (data: string) => void): number {
...@@ -135,7 +135,7 @@ export class GhosttyRuntime {...@@ -135,7 +135,7 @@ export class GhosttyRuntime {
135 }135 }
136136
137 /** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */137 /** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */
138 private currentMemoryView(): DataView {138 memoryDataView(): DataView {
139 if (this.memoryView.buffer !== this.memory.buffer) {139 if (this.memoryView.buffer !== this.memory.buffer) {
140 this.memoryView = new DataView(this.memory.buffer);140 this.memoryView = new DataView(this.memory.buffer);
141 }141 }
...@@ -145,7 +145,7 @@ export class GhosttyRuntime {...@@ -145,7 +145,7 @@ export class GhosttyRuntime {
145 setField(pointer: number, structName: string, fieldName: string, value: number): void {145 setField(pointer: number, structName: string, fieldName: string, value: number): void {
146 const field = this.layout(structName).fields[fieldName];146 const field = this.layout(structName).fields[fieldName];
147 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}`);
148 const view = this.currentMemoryView();148 const view = this.memoryDataView();
149 const offset = pointer + field.offset;149 const offset = pointer + field.offset;
150 switch (field.type) {150 switch (field.type) {
151 case "bool":151 case "bool":
...@@ -173,7 +173,7 @@ export class GhosttyRuntime {...@@ -173,7 +173,7 @@ export class GhosttyRuntime {
173 readField(pointer: number, structName: string, fieldName: string): number {173 readField(pointer: number, structName: string, fieldName: string): number {
174 const field = this.layout(structName).fields[fieldName];174 const field = this.layout(structName).fields[fieldName];
175 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}`);
176 const view = this.currentMemoryView();176 const view = this.memoryDataView();
177 const offset = pointer + field.offset;177 const offset = pointer + field.offset;
178 switch (field.type) {178 switch (field.type) {
179 case "bool":179 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 () => ({...@@ -42,6 +42,40 @@ vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({
42describe("GhosttyTerminalSurface visibility", () => {42describe("GhosttyTerminalSurface visibility", () => {
43 const surfaces = new Set<GhosttyTerminalSurface>();43 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
45 // Keep the real surface, renderer, and WASM core. Only browser layout and79 // Keep the real surface, renderer, and WASM core. Only browser layout and
46 // scheduling are replaced so tests can count work while the terminal is hidden.80 // scheduling are replaced so tests can count work while the terminal is hidden.
47 function createHarness() {81 function createHarness() {
...@@ -216,6 +250,183 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -216,6 +250,183 @@ describe("GhosttyTerminalSurface visibility", () => {
216 vi.restoreAllMocks();250 vi.restoreAllMocks();
217 });251 });
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
219 it.each([430 it.each([
220 { platform: "Linux x86_64", modifiers: { ctrlKey: true, metaKey: false } },431 { platform: "Linux x86_64", modifiers: { ctrlKey: true, metaKey: false } },
221 { platform: "MacIntel", modifiers: { ctrlKey: false, metaKey: true } },432 { platform: "MacIntel", modifiers: { ctrlKey: false, metaKey: true } },
apps/web/src/terminal/ghostty/surface.ts+73-7
...@@ -982,6 +982,21 @@ export class GhosttyTerminalSurface {...@@ -982,6 +982,21 @@ export class GhosttyTerminalSurface {
982 return this.core.selectionText();982 return this.core.selectionText();
983 }983 }
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
985 getSelectionPosition(): GhosttySelectionPosition | null {1000 getSelectionPosition(): GhosttySelectionPosition | null {
986 if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection())1001 if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection())
987 return null;1002 return null;
...@@ -998,7 +1013,15 @@ export class GhosttyTerminalSurface {...@@ -998,7 +1013,15 @@ export class GhosttyTerminalSurface {
998 const position = this.getSelectionPosition();1013 const position = this.getSelectionPosition();
999 if (!position) return null;1014 if (!position) return null;
1000 const viewportEnd = this.core.screenPointToViewport(position.end.x, position.end.y);1015 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 }
1002 const bounds = this.canvas.getBoundingClientRect();1025 const bounds = this.canvas.getBoundingClientRect();
1003 return {1026 return {
1004 right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width,1027 right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width,
...@@ -1074,6 +1097,55 @@ export class GhosttyTerminalSurface {...@@ -1074,6 +1097,55 @@ export class GhosttyTerminalSurface {
1074 this.suppressedKeyCodes.add(event.code);1097 this.suppressedKeyCodes.add(event.code);
1075 return;1098 return;
1076 }1099 }
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 }
1077 if (isTerminalCopyShortcut(event) && this.hasSelection()) {1149 if (isTerminalCopyShortcut(event) && this.hasSelection()) {
1078 // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in1150 // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in
1079 // onCopyEvent; not preventing the default keeps that path alive. WebKit1151 // onCopyEvent; not preventing the default keeps that path alive. WebKit
...@@ -1152,12 +1224,6 @@ export class GhosttyTerminalSurface {...@@ -1152,12 +1224,6 @@ export class GhosttyTerminalSurface {
1152 }1224 }
1153 return;1225 return;
1154 }1226 }
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 }
1161 this.clearPrimedCopy();1227 this.clearPrimedCopy();
1162 const data = this.core.encodeKey(event);1228 const data = this.core.encodeKey(event);
1163 if (data.length === 0) return;1229 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....@@ -6,3 +6,13 @@ line can be shortened at the start. New terminal output is not truncated.
66
7These limits apply when you reconnect and when T3 Code restores saved terminal7These limits apply when you reconnect and when T3 Code restores saved terminal
8history. A client can show less scrollback than the server keeps.8history. 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.