| author | |
| committer | |
| log | 9a3070bcf023c3c025633659addeef4cf628133a |
| tree | 41cff920fe31c476d6487ab3d9a0f4a6d5b1e367 |
| parent | 12069eefd707f78eafc27812027c994eea0613cf |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 24 | "add-to-chat", |
| 25 | 25 | "copy", |
| 26 | 26 | "paste", |
| 27 | "select-all", | |
| 28 | "scroll-to-bottom", | |
| 27 | 29 | ]); |
| 28 | 30 | |
| 29 | 31 | expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([ |
| ... | ... | @@ -31,7 +33,20 @@ describe("terminal selection menus", () => { |
| 31 | 33 | ]); |
| 32 | 34 | expect( |
| 33 | 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 | }); |
| 37 | 52 |
apps/web/src/components/ThreadTerminalDrawer.tsx+18-3| ... | ... | @@ -265,7 +265,12 @@ export function terminalSelectionLineRange(position: { |
| 265 | 265 | }; |
| 266 | 266 | } |
| 267 | 267 | |
| 268 | export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; | |
| 268 | export type TerminalContextMenuAction = | |
| 269 | | "add-to-chat" | |
| 270 | | "copy" | |
| 271 | | "paste" | |
| 272 | | "select-all" | |
| 273 | | "scroll-to-bottom"; | |
| 269 | 274 | |
| 270 | 275 | /** Post-selection popup: available selection actions, always enabled. */ |
| 271 | 276 | export function terminalSelectionMenuItems(options?: { |
| ... | ... | @@ -280,8 +285,8 @@ export function terminalSelectionMenuItems(options?: { |
| 280 | 285 | } |
| 281 | 286 | |
| 282 | 287 | /** |
| 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 | |
| 285 | 290 | * (and Electron's default editing menu) can only paste into an editable |
| 286 | 291 | * element, so a canvas terminal never gets a usable entry from them. |
| 287 | 292 | */ |
| ... | ... | @@ -297,6 +302,8 @@ export function terminalContextMenuItems(options: { |
| 297 | 302 | disabled: !hasSelection, |
| 298 | 303 | })), |
| 299 | 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 | } |
| 302 | 309 | |
| ... | ... | @@ -750,6 +757,14 @@ export function TerminalViewport({ |
| 750 | 757 | case "paste": |
| 751 | 758 | await pasteFromClipboard(requestId); |
| 752 | 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 | }; |
| 755 | 770 |
apps/web/src/terminal/ghostty/core.test.ts+20| ... | ... | @@ -154,6 +154,26 @@ describe("GhosttyTerminalCore snapshots", () => { |
| 154 | 154 | }); |
| 155 | 155 | }); |
| 156 | 156 | |
| 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 | 177 | it("reuses a grown grapheme buffer and releases it on disposal", async () => { |
| 158 | 178 | const core = await createCore(); |
| 159 | 179 | const runtime = await loadGhosttyRuntime(); |
apps/web/src/terminal/ghostty/core.ts+77-44| ... | ... | @@ -47,6 +47,19 @@ const CELL_DATA = { |
| 47 | 47 | selected: 7, |
| 48 | 48 | } as const; |
| 49 | 49 | |
| 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. | |
| 52 | const 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 | ||
| 50 | 63 | const RAW_CELL_DATA = { |
| 51 | 64 | wide: 3, |
| 52 | 65 | } as const; |
| ... | ... | @@ -210,6 +223,7 @@ export class GhosttyTerminalCore { |
| 210 | 223 | private graphemes = 0; |
| 211 | 224 | private graphemeCapacity = 0; |
| 212 | 225 | private style = 0; |
| 226 | private cellQuery = 0; | |
| 213 | 227 | private scrollbar = 0; |
| 214 | 228 | private rows: GhosttyRow[] = []; |
| 215 | 229 | private disposed = false; |
| ... | ... | @@ -310,6 +324,19 @@ export class GhosttyTerminalCore { |
| 310 | 324 | const styleSize = this.runtime.layout("GhosttyStyle").size; |
| 311 | 325 | this.style = this.runtime.alloc(styleSize); |
| 312 | 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 | 340 | this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size); |
| 314 | 341 | this.setTheme(theme); |
| 315 | 342 | this.resize(cols, rows, cellWidth, cellHeight); |
| ... | ... | @@ -658,17 +685,26 @@ export class GhosttyTerminalCore { |
| 658 | 685 | } |
| 659 | 686 | } |
| 660 | 687 | |
| 661 | selectAll(): void { | |
| 688 | selectAll(): GhosttySelectionRange["screen"] | null { | |
| 662 | 689 | this.ensureActive(); |
| 663 | 690 | const layout = this.runtime.layout("GhosttySelection"); |
| 664 | 691 | 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; | |
| 669 | 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 | } |
| 673 | 709 | |
| 674 | 710 | selectWord(col: number, row: number): GhosttySelectionRange | null { |
| ... | ... | @@ -877,6 +913,7 @@ export class GhosttyTerminalCore { |
| 877 | 913 | this.runtime.call("ghostty_terminal_free", this.terminal); |
| 878 | 914 | } |
| 879 | 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 | 917 | if (this.scrollbar) { |
| 881 | 918 | this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size); |
| 882 | 919 | } |
| ... | ... | @@ -954,17 +991,29 @@ export class GhosttyTerminalCore { |
| 954 | 991 | cells.length < cols && |
| 955 | 992 | 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 | 994 | this.runtime.bytes(this.style, styleSize).fill(0); |
| 960 | 995 | 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", | |
| 963 | 998 | 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, | |
| 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 | 1017 | let text = ""; |
| 969 | 1018 | if (graphemeLength > 0) { |
| 970 | 1019 | const bufferSize = graphemeLength * 4; |
| ... | ... | @@ -1008,13 +1057,12 @@ export class GhosttyTerminalCore { |
| 1008 | 1057 | ); |
| 1009 | 1058 | wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); |
| 1010 | 1059 | } |
| 1011 | const selected = this.getCellBool(cellsIterator, CELL_DATA.selected); | |
| 1012 | 1060 | // 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) { | |
| 1015 | 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 | 1066 | foreground = blend(foreground, background); |
| 1019 | 1067 | } |
| 1020 | 1068 | cells.push({ |
| ... | ... | @@ -1022,12 +1070,12 @@ export class GhosttyTerminalCore { |
| 1022 | 1070 | wide, |
| 1023 | 1071 | foreground, |
| 1024 | 1072 | 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, | |
| 1031 | 1079 | selected, |
| 1032 | 1080 | }); |
| 1033 | 1081 | } |
| ... | ... | @@ -1177,25 +1225,6 @@ export class GhosttyTerminalCore { |
| 1177 | 1225 | ); |
| 1178 | 1226 | } |
| 1179 | 1227 | |
| 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 | 1228 | private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { |
| 1200 | 1229 | this.runtime.bytes(this.scratch, 3).fill(0); |
| 1201 | 1230 | const result = this.runtime.call( |
| ... | ... | @@ -1208,8 +1237,12 @@ export class GhosttyTerminalCore { |
| 1208 | 1237 | } |
| 1209 | 1238 | |
| 1210 | 1239 | 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 | }; | |
| 1213 | 1246 | } |
| 1214 | 1247 | |
| 1215 | 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 | 106 | } |
| 107 | 107 | |
| 108 | 108 | readPointer(slot: number): number { |
| 109 | return this.currentMemoryView().getUint32(slot, true); | |
| 109 | return this.memoryDataView().getUint32(slot, true); | |
| 110 | 110 | } |
| 111 | 111 | |
| 112 | 112 | attachPtyWriter(terminal: number, writer: (data: string) => void): number { |
| ... | ... | @@ -135,7 +135,7 @@ export class GhosttyRuntime { |
| 135 | 135 | } |
| 136 | 136 | |
| 137 | 137 | /** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */ |
| 138 | private currentMemoryView(): DataView { | |
| 138 | memoryDataView(): DataView { | |
| 139 | 139 | if (this.memoryView.buffer !== this.memory.buffer) { |
| 140 | 140 | this.memoryView = new DataView(this.memory.buffer); |
| 141 | 141 | } |
| ... | ... | @@ -145,7 +145,7 @@ export class GhosttyRuntime { |
| 145 | 145 | setField(pointer: number, structName: string, fieldName: string, value: number): void { |
| 146 | 146 | const field = this.layout(structName).fields[fieldName]; |
| 147 | 147 | if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); |
| 148 | const view = this.currentMemoryView(); | |
| 148 | const view = this.memoryDataView(); | |
| 149 | 149 | const offset = pointer + field.offset; |
| 150 | 150 | switch (field.type) { |
| 151 | 151 | case "bool": |
| ... | ... | @@ -173,7 +173,7 @@ export class GhosttyRuntime { |
| 173 | 173 | readField(pointer: number, structName: string, fieldName: string): number { |
| 174 | 174 | const field = this.layout(structName).fields[fieldName]; |
| 175 | 175 | if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); |
| 176 | const view = this.currentMemoryView(); | |
| 176 | const view = this.memoryDataView(); | |
| 177 | 177 | const offset = pointer + field.offset; |
| 178 | 178 | switch (field.type) { |
| 179 | 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 | 42 | describe("GhosttyTerminalSurface visibility", () => { |
| 43 | 43 | const surfaces = new Set<GhosttyTerminalSurface>(); |
| 44 | 44 | |
| 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 | 79 | // Keep the real surface, renderer, and WASM core. Only browser layout and |
| 46 | 80 | // scheduling are replaced so tests can count work while the terminal is hidden. |
| 47 | 81 | function createHarness() { |
| ... | ... | @@ -216,6 +250,183 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 216 | 250 | vi.restoreAllMocks(); |
| 217 | 251 | }); |
| 218 | 252 | |
| 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 | 430 | it.each([ |
| 220 | 431 | { platform: "Linux x86_64", modifiers: { ctrlKey: true, metaKey: false } }, |
| 221 | 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 | 982 | return this.core.selectionText(); |
| 983 | 983 | } |
| 984 | 984 | |
| 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 | 1000 | getSelectionPosition(): GhosttySelectionPosition | null { |
| 986 | 1001 | if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection()) |
| 987 | 1002 | return null; |
| ... | ... | @@ -998,7 +1013,15 @@ export class GhosttyTerminalSurface { |
| 998 | 1013 | const position = this.getSelectionPosition(); |
| 999 | 1014 | if (!position) return null; |
| 1000 | 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 | 1025 | const bounds = this.canvas.getBoundingClientRect(); |
| 1003 | 1026 | return { |
| 1004 | 1027 | right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width, |
| ... | ... | @@ -1074,6 +1097,55 @@ export class GhosttyTerminalSurface { |
| 1074 | 1097 | this.suppressedKeyCodes.add(event.code); |
| 1075 | 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 | 1149 | if (isTerminalCopyShortcut(event) && this.hasSelection()) { |
| 1078 | 1150 | // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in |
| 1079 | 1151 | // onCopyEvent; not preventing the default keeps that path alive. WebKit |
| ... | ... | @@ -1152,12 +1224,6 @@ export class GhosttyTerminalSurface { |
| 1152 | 1224 | } |
| 1153 | 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 | 1227 | this.clearPrimedCopy(); |
| 1162 | 1228 | const data = this.core.encodeKey(event); |
| 1163 | 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 | 6 | |
| 7 | 7 | These limits apply when you reconnect and when T3 Code restores saved terminal |
| 8 | 8 | history. A client can show less scrollback than the server keeps. |
| 9 | ||
| 10 | On web and desktop, use Shift+PageUp and Shift+PageDown to read scrollback | |
| 11 | without leaving terminal input. Ctrl+Shift+Home and Ctrl+Shift+End jump to the | |
| 12 | start and latest output; use Cmd instead of Ctrl on macOS. Full-screen terminal | |
| 13 | programs keep these navigation keys. | |
| 14 | ||
| 15 | To copy all retained output, choose Select all in the terminal's context menu, | |
| 16 | then copy. Cmd+A on macOS and Ctrl+Shift+A elsewhere select the same output. | |
| 17 | Ctrl+A still moves to the beginning of shell input on Windows and Linux. | |
| 18 | Jump to latest in the context menu returns to the current output. |