| 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,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 | ]); |
| 28 | 30 | ||
| 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 | }); |
| 37 | 52 |
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 | } |
| 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 | /** Post-selection popup: available selection actions, always enabled. */ | 275 | /** Post-selection popup: available selection actions, always enabled. */ |
| 271 | export function terminalSelectionMenuItems(options?: { | 276 | export function terminalSelectionMenuItems(options?: { |
| ... | @@ -280,8 +285,8 @@ 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 | 288 | * Right-click menu for selection, clipboard, and local scrollback actions. |
| 284 | * until a selection exists) plus Paste. Paste is always offered: the browser | 289 | * Paste is always offered: the browser |
| 285 | * (and Electron's default editing menu) can only paste into an editable | 290 | * (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 | } |
| 302 | 309 | ||
| ... | @@ -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 | }; |
| 755 | 770 |
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 | }); |
| 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 | 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; |
| 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 | const RAW_CELL_DATA = { | 63 | const 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 | } |
| 660 | 687 | ||
| 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_SUCCESS | 694 | 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 | } |
| 673 | 709 | ||
| 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) !== 0 | 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 | 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 | } |
| 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 | 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 | } |
| 1209 | 1238 | ||
| 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 | } |
| 1214 | 1247 | ||
| 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 | } |
| 107 | 107 | ||
| 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 | } |
| 111 | 111 | ||
| 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 | } |
| 136 | 136 | ||
| 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 () => ({ |
| 42 | describe("GhosttyTerminalSurface visibility", () => { | 42 | describe("GhosttyTerminalSurface visibility", () => { |
| 43 | const surfaces = new Set<GhosttyTerminalSurface>(); | 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 | // Keep the real surface, renderer, and WASM core. Only browser layout and | 79 | // 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 | }); |
| 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 | 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 | } |
| 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 | 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 in | 1150 | // 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. WebKit | 1151 | // 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. |
| 6 | 6 | ||
| 7 | These limits apply when you reconnect and when T3 Code restores saved terminal | 7 | These limits apply when you reconnect and when T3 Code restores saved terminal |
| 8 | history. A client can show less scrollback than the server keeps. | 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. |