diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 33966b96890b..66d0fb0ce71f 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -24,6 +24,8 @@ describe("terminal selection menus", () => { "add-to-chat", "copy", "paste", + "select-all", + "scroll-to-bottom", ]); expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([ @@ -31,7 +33,20 @@ describe("terminal selection menus", () => { ]); expect( terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id), - ).toEqual(["copy", "paste"]); + ).toEqual(["copy", "paste", "select-all", "scroll-to-bottom"]); + }); + + it("keeps local viewing actions available to a read-only observer", () => { + const items = terminalContextMenuItems({ hasSelection: false, readOnly: true }); + expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual([ + "add-to-chat", + "copy", + "paste", + ]); + expect(items.filter((item) => !item.disabled).map((item) => item.id)).toEqual([ + "select-all", + "scroll-to-bottom", + ]); }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index bd40a4417955..18799a40aaae 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -264,7 +264,12 @@ export function terminalSelectionLineRange(position: { }; } -export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; +export type TerminalContextMenuAction = + | "add-to-chat" + | "copy" + | "paste" + | "select-all" + | "scroll-to-bottom"; /** Post-selection popup: available selection actions, always enabled. */ export function terminalSelectionMenuItems(options?: { @@ -279,8 +284,8 @@ export function terminalSelectionMenuItems(options?: { } /** - * Right-click menu for the terminal canvas: the selection actions (disabled - * until a selection exists) plus Paste. Paste is always offered: the browser + * Right-click menu for selection, clipboard, and local scrollback actions. + * Paste is always offered: the browser * (and Electron's default editing menu) can only paste into an editable * element, so a canvas terminal never gets a usable entry from them. */ @@ -296,6 +301,8 @@ export function terminalContextMenuItems(options: { disabled: !hasSelection, })), { id: "paste", label: "Paste", ...(options.readOnly ? { disabled: true } : {}) }, + { id: "select-all", label: "Select all" }, + { id: "scroll-to-bottom", label: "Jump to latest" }, ]; } @@ -749,6 +756,14 @@ export function TerminalViewport({ case "paste": await pasteFromClipboard(requestId); return; + case "select-all": + terminalRef.current?.selectAll(); + focusIfCurrent(requestId); + return; + case "scroll-to-bottom": + terminalRef.current?.scrollToBottom(); + focusIfCurrent(requestId); + return; } }; diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 48cc4256de61..ce430a104d79 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -154,6 +154,26 @@ describe("GhosttyTerminalCore snapshots", () => { }); }); + it("resolves foreground and background independently without retaining prior cell colors", async () => { + const core = await createCore(); + core.write( + "\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", + ); + expect( + core + .snapshot() + .rowData[0]!.cells.slice(0, 6) + .map(({ text, foreground, background }) => ({ text, foreground, background })), + ).toEqual([ + { text: "F", foreground: { r: 123, g: 45, b: 67 }, background: { r: 0, g: 0, b: 0 } }, + { text: "B", foreground: { r: 255, g: 255, b: 255 }, background: { r: 9, g: 8, b: 7 } }, + { text: "D", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } }, + { text: "C", foreground: { r: 12, g: 34, b: 56 }, background: { r: 78, g: 90, b: 123 } }, + { text: "E", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } }, + { text: "", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } }, + ]); + }); + it("reuses a grown grapheme buffer and releases it on disposal", async () => { const core = await createCore(); const runtime = await loadGhosttyRuntime(); diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index d01e20529d45..7fcee783cd31 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -47,6 +47,19 @@ const CELL_DATA = { selected: 7, } as const; +// wasm32 arrays for the official row_cells_get_multi API. Optional colors +// come last because an unset color stops the query at that field. +const CELL_QUERY = { + keys: 0, + values: 20, + graphemesLength: 40, + selected: 44, + background: 48, + foreground: 52, + written: 56, + size: 60, +} as const; + const RAW_CELL_DATA = { wide: 3, } as const; @@ -210,6 +223,7 @@ export class GhosttyTerminalCore { private graphemes = 0; private graphemeCapacity = 0; private style = 0; + private cellQuery = 0; private scrollbar = 0; private rows: GhosttyRow[] = []; private disposed = false; @@ -310,6 +324,19 @@ export class GhosttyTerminalCore { const styleSize = this.runtime.layout("GhosttyStyle").size; this.style = this.runtime.alloc(styleSize); this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); + this.cellQuery = this.runtime.alloc(CELL_QUERY.size); + const queryView = this.runtime.memoryDataView(); + const cellFields = [ + [CELL_DATA.style, this.style], + [CELL_DATA.graphemesLength, this.cellQuery + CELL_QUERY.graphemesLength], + [CELL_DATA.selected, this.cellQuery + CELL_QUERY.selected], + [CELL_DATA.background, this.cellQuery + CELL_QUERY.background], + [CELL_DATA.foreground, this.cellQuery + CELL_QUERY.foreground], + ] as const; + for (const [index, [key, pointer]] of cellFields.entries()) { + queryView.setUint32(this.cellQuery + CELL_QUERY.keys + index * 4, key, true); + queryView.setUint32(this.cellQuery + CELL_QUERY.values + index * 4, pointer, true); + } this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size); this.setTheme(theme); this.resize(cols, rows, cellWidth, cellHeight); @@ -658,17 +685,26 @@ export class GhosttyTerminalCore { } } - selectAll(): void { + selectAll(): GhosttySelectionRange["screen"] | null { this.ensureActive(); const layout = this.runtime.layout("GhosttySelection"); const selection = this.runtime.alloc(layout.size); - this.runtime.setField(selection, "GhosttySelection", "size", layout.size); - if ( - this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS - ) { + try { + this.runtime.setField(selection, "GhosttySelection", "size", layout.size); + if ( + this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) !== + GHOSTTY_SUCCESS + ) { + return null; + } + const start = this.pointFromGridRef(selection + layout.fields.start!.offset, 2); + const end = this.pointFromGridRef(selection + layout.fields.end!.offset, 2); + if (!start || !end) return null; this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + return { start, end }; + } finally { + this.runtime.free(selection, layout.size); } - this.runtime.free(selection, layout.size); } selectWord(col: number, row: number): GhosttySelectionRange | null { @@ -877,6 +913,7 @@ export class GhosttyTerminalCore { this.runtime.call("ghostty_terminal_free", this.terminal); } if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size); + if (this.cellQuery) this.runtime.free(this.cellQuery, CELL_QUERY.size); if (this.scrollbar) { this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size); } @@ -954,17 +991,29 @@ export class GhosttyTerminalCore { cells.length < cols && this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0 ) { - let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground); - let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground); this.runtime.bytes(this.style, styleSize).fill(0); this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); - this.runtime.call( - "ghostty_render_state_row_cells_get", + const result = this.runtime.call( + "ghostty_render_state_row_cells_get_multi", cellsIterator, - CELL_DATA.style, - this.style, + 5, + this.cellQuery + CELL_QUERY.keys, + this.cellQuery + CELL_QUERY.values, + this.cellQuery + CELL_QUERY.written, ); - const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength); + const queryView = this.runtime.memoryDataView(); + const written = queryView.getUint32(this.cellQuery + CELL_QUERY.written, true); + if (written < 3) this.assertSuccess("ghostty_render_state_row_cells_get_multi", result); + const graphemeLength = queryView.getUint32(this.cellQuery + CELL_QUERY.graphemesLength, true); + const selected = queryView.getUint8(this.cellQuery + CELL_QUERY.selected) !== 0; + let background = + written >= 4 ? this.readColor(this.cellQuery + CELL_QUERY.background) : defaultBackground; + let foreground = + written === 5 + ? this.readColor(this.cellQuery + CELL_QUERY.foreground) + : written === 3 + ? this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground) + : defaultForeground; let text = ""; if (graphemeLength > 0) { const bufferSize = graphemeLength * 4; @@ -1008,13 +1057,12 @@ export class GhosttyTerminalCore { ); wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); } - const selected = this.getCellBool(cellsIterator, CELL_DATA.selected); // Read the style after allocation and ABI calls, which can grow WASM memory. - const styleView = this.runtime.view(this.style, styleSize); - if (styleView.getUint8(styleFields.inverse!.offset) !== 0) { + const styleView = this.runtime.memoryDataView(); + if (styleView.getUint8(this.style + styleFields.inverse!.offset) !== 0) { [foreground, background] = [background, foreground]; } - if (styleView.getUint8(styleFields.faint!.offset) !== 0) { + if (styleView.getUint8(this.style + styleFields.faint!.offset) !== 0) { foreground = blend(foreground, background); } cells.push({ @@ -1022,12 +1070,12 @@ export class GhosttyTerminalCore { wide, foreground, background, - bold: styleView.getUint8(styleFields.bold!.offset) !== 0, - italic: styleView.getUint8(styleFields.italic!.offset) !== 0, - invisible: styleView.getUint8(styleFields.invisible!.offset) !== 0, - strikethrough: styleView.getUint8(styleFields.strikethrough!.offset) !== 0, - overline: styleView.getUint8(styleFields.overline!.offset) !== 0, - underline: styleView.getInt32(styleFields.underline!.offset, true) !== 0, + bold: styleView.getUint8(this.style + styleFields.bold!.offset) !== 0, + italic: styleView.getUint8(this.style + styleFields.italic!.offset) !== 0, + invisible: styleView.getUint8(this.style + styleFields.invisible!.offset) !== 0, + strikethrough: styleView.getUint8(this.style + styleFields.strikethrough!.offset) !== 0, + overline: styleView.getUint8(this.style + styleFields.overline!.offset) !== 0, + underline: styleView.getInt32(this.style + styleFields.underline!.offset, true) !== 0, selected, }); } @@ -1177,25 +1225,6 @@ export class GhosttyTerminalCore { ); } - private getCellU32(iterator: number, data: number): number { - this.runtime.bytes(this.scratch, 4).fill(0); - const result = this.runtime.call( - "ghostty_render_state_row_cells_get", - iterator, - data, - this.scratch, - ); - return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0; - } - - private getCellBool(iterator: number, data: number): boolean { - this.runtime.bytes(this.scratch, 1)[0] = 0; - return ( - this.runtime.call("ghostty_render_state_row_cells_get", iterator, data, this.scratch) === - GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 - ); - } - private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { this.runtime.bytes(this.scratch, 3).fill(0); const result = this.runtime.call( @@ -1208,8 +1237,12 @@ export class GhosttyTerminalCore { } private readColor(pointer: number): GhosttyColor { - const bytes = this.runtime.bytes(pointer, 3); - return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 }; + const view = this.runtime.memoryDataView(); + return { + r: view.getUint8(pointer), + g: view.getUint8(pointer + 1), + b: view.getUint8(pointer + 2), + }; } private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell { diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts index 976900fa6d84..ad06389def55 100644 --- a/apps/web/src/terminal/ghostty/runtime.ts +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -106,7 +106,7 @@ export class GhosttyRuntime { } readPointer(slot: number): number { - return this.currentMemoryView().getUint32(slot, true); + return this.memoryDataView().getUint32(slot, true); } attachPtyWriter(terminal: number, writer: (data: string) => void): number { @@ -135,7 +135,7 @@ export class GhosttyRuntime { } /** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */ - private currentMemoryView(): DataView { + memoryDataView(): DataView { if (this.memoryView.buffer !== this.memory.buffer) { this.memoryView = new DataView(this.memory.buffer); } @@ -145,7 +145,7 @@ export class GhosttyRuntime { setField(pointer: number, structName: string, fieldName: string, value: number): void { const field = this.layout(structName).fields[fieldName]; if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); - const view = this.currentMemoryView(); + const view = this.memoryDataView(); const offset = pointer + field.offset; switch (field.type) { case "bool": @@ -173,7 +173,7 @@ export class GhosttyRuntime { readField(pointer: number, structName: string, fieldName: string): number { const field = this.layout(structName).fields[fieldName]; if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); - const view = this.currentMemoryView(); + const view = this.memoryDataView(); const offset = pointer + field.offset; switch (field.type) { case "bool": diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index cd08933e5c47..530183cd238a 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -42,6 +42,40 @@ vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({ describe("GhosttyTerminalSurface visibility", () => { const surfaces = new Set(); + function key( + surface: GhosttyTerminalSurface, + key: string, + code: string, + modifiers: Partial< + Pick< + KeyboardEvent, + | "altKey" + | "ctrlKey" + | "metaKey" + | "shiftKey" + | "isComposing" + | "keyCode" + | "getModifierState" + > + > = {}, + type = "keydown", + ) { + const event = Object.assign(new Event(type, { cancelable: true }), { + key, + code, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + isComposing: false, + keyCode: 0, + getModifierState: () => false, + ...modifiers, + }); + surface.input.dispatchEvent(event); + return event; + } + // Keep the real surface, renderer, and WASM core. Only browser layout and // scheduling are replaced so tests can count work while the terminal is hidden. function createHarness() { @@ -216,6 +250,183 @@ describe("GhosttyTerminalSurface visibility", () => { vi.restoreAllMocks(); }); + it.each([ + { platform: "Linux x86_64", primary: { ctrlKey: true } }, + { platform: "MacIntel", primary: { metaKey: true } }, + ])( + "navigates history from terminal input without sending keys ($platform)", + async ({ platform, primary }) => { + vi.stubGlobal("navigator", { platform }); + const harness = createHarness(); + const surface = await harness.create({ beforeKey: () => true }); + surface.write(Array.from({ length: 20 }, (_, index) => `line ${index}`).join("\r\n")); + harness.flushFrame(); + const latest = harness.renderedSnapshot.rowData[0]?.text; + expect(surface.isAtBottom()).toBe(true); + + expect(key(surface, "PageUp", "PageUp", { shiftKey: true }).defaultPrevented).toBe(true); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.text).toContain("line 8"); + expect(surface.isAtBottom()).toBe(false); + key(surface, "PageDown", "PageDown", { shiftKey: true }); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe(latest); + + key(surface, "Home", "Home", { ...primary, shiftKey: true }); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.text).toContain("line 0"); + key(surface, "End", "End", { ...primary, shiftKey: true }); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe(latest); + expect(surface.isAtBottom()).toBe(true); + expect(harness.onData).not.toHaveBeenCalled(); + }, + ); + + it("forwards navigation presses and releases when scrollback state is unavailable", async () => { + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const harness = createHarness(); + const surface = await harness.create({ beforeKey: () => true }); + surface.write("history\x1b[>31u"); + vi.spyOn(GhosttyTerminalCore.prototype, "scrollbarState").mockReturnValue(null); + + for (const [name, modifiers] of [ + ["PageUp", { shiftKey: true }], + ["Home", { ctrlKey: true, shiftKey: true }], + ] as const) { + key(surface, name, name, modifiers); + key(surface, name, name, modifiers, "keyup"); + } + expect(harness.onData).toHaveBeenCalledTimes(4); + expect(harness.onData.mock.calls[1]?.[0]).toContain(":3"); + expect(harness.onData.mock.calls[3]?.[0]).toContain(":3"); + }); + + it("selects full history and keeps selection coordinates available outside the viewport", async () => { + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const harness = createHarness(); + const surface = await harness.create({ beforeKey: () => true }); + const lines = Array.from({ length: 20 }, (_, index) => `line ${index}`); + surface.write(lines.join("\r\n")); + harness.flushFrame(); + surface.selectAll(); + harness.flushFrame(); + expect(surface.getSelection()).toBe(lines.join("\n")); + expect(surface.getSelectionPosition()).toMatchObject({ start: { x: 0, y: 0 } }); + expect(surface.getSelectionPosition()?.end.y).toBe(19); + expect(surface.getSelectionEndClientRect()).not.toBeNull(); + expect(harness.renderedSnapshot.rowData.every((row) => row.cells[0]?.selected)).toBe(true); + + key(surface, "Home", "Home", { ctrlKey: true, shiftKey: true }); + harness.flushFrame(); + surface.selectAll(); + expect(surface.getSelection()).toBe(lines.join("\n")); + expect(surface.getSelectionPosition()?.end.y).toBe(19); + expect(surface.getSelectionEndClientRect()).toBeNull(); + surface.clearSelection(); + expect(surface.getSelection()).toBe(""); + expect(surface.getSelectionPosition()).toBeNull(); + }); + + it.each([ + { platform: "Linux x86_64", modifiers: { ctrlKey: true, shiftKey: true } }, + { platform: "MacIntel", modifiers: { metaKey: true } }, + ])("selects all through the local shortcut ($platform)", async ({ platform, modifiers }) => { + vi.stubGlobal("navigator", { platform }); + const harness = createHarness(); + const surface = await harness.create({ beforeKey: () => true }); + surface.write("hello\r\nworld"); + harness.flushFrame(); + expect(key(surface, "a", "KeyA", modifiers).defaultPrevented).toBe(true); + expect(surface.getSelection()).toBe("hello\nworld"); + expect(surface.getSelectionPosition()).not.toBeNull(); + expect(harness.onData).not.toHaveBeenCalled(); + }); + + it("preserves shell Ctrl+A and ordinary application navigation", async () => { + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const harness = createHarness(); + const surface = await harness.create({ beforeKey: () => true }); + key(surface, "a", "KeyA", { ctrlKey: true }); + key(surface, "PageUp", "PageUp"); + key(surface, "PageDown", "PageDown"); + key(surface, "Home", "Home"); + key(surface, "End", "End"); + expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([ + "\x01", + "\x1b[5~", + "\x1b[6~", + "\x1b[H", + "\x1b[F", + ]); + expect(surface.hasSelection()).toBe(false); + }); + + it("leaves modified history keys with alternate-screen applications", async () => { + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const harness = createHarness(); + const surface = await harness.create({ beforeKey: () => true }); + surface.write("\x1b[?1049happ"); + key(surface, "PageUp", "PageUp", { shiftKey: true }); + key(surface, "End", "End", { ctrlKey: true, shiftKey: true }); + expect(harness.onData.mock.calls.map(([data]) => data)).toEqual(["\x1b[5;2~", "\x1b[1;6F"]); + surface.selectAll(); + expect(surface.getSelection()).toBe("app"); + surface.write("\x1b[?1049l"); + expect(surface.getSelection()).toBe(""); + }); + + it("suppresses local shortcut releases in Kitty sessions, including read-only observers", async () => { + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const harness = createHarness(); + const surface = await harness.create({ beforeKey: () => true }); + surface.input.readOnly = true; + surface.write("history\x1b[>31u"); + for (const [name, code, modifiers] of [ + ["PageUp", "PageUp", { shiftKey: true }], + ["Home", "Home", { ctrlKey: true, shiftKey: true }], + ["a", "KeyA", { ctrlKey: true, shiftKey: true }], + ] as const) { + key(surface, name, code, modifiers); + key(surface, name, code, modifiers, "keyup"); + } + expect(surface.getSelection()).toBe("history"); + expect(harness.onData).not.toHaveBeenCalled(); + + // An ordinary application key still has both press and release reports. + key(surface, "PageUp", "PageUp"); + key(surface, "PageUp", "PageUp", {}, "keyup"); + expect(harness.onData).toHaveBeenCalledTimes(2); + expect(harness.onData.mock.calls[1]?.[0]).toContain(":3"); + }); + + it("honors host interception, IME candidates, and AltGr before local shortcuts", async () => { + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const harness = createHarness(); + let allow = false; + const surface = await harness.create({ beforeKey: () => allow }); + surface.write("hello\x1b[>31u"); + key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true }); + key(surface, "a", "KeyA", {}, "keyup"); + expect(surface.hasSelection()).toBe(false); + allow = true; + surface.input.value = "候補"; + key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true, isComposing: true }); + key(surface, "a", "KeyA", {}, "keyup"); + key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true, keyCode: 229 }); + key(surface, "a", "KeyA", {}, "keyup"); + key(surface, "a", "KeyA", { + ctrlKey: true, + shiftKey: true, + altKey: true, + getModifierState: (modifier) => modifier === "AltGraph", + }); + key(surface, "a", "KeyA", {}, "keyup"); + expect(surface.input.value).toBe("候補"); + expect(surface.hasSelection()).toBe(false); + expect(harness.onData).not.toHaveBeenCalled(); + }); + it.each([ { platform: "Linux x86_64", modifiers: { ctrlKey: true, metaKey: false } }, { platform: "MacIntel", modifiers: { ctrlKey: false, metaKey: true } }, diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 1ec3f48bcffc..5ea27cea08c9 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -982,6 +982,21 @@ export class GhosttyTerminalSurface { return this.core.selectionText(); } + /** Select the active screen's entire history, including rows outside the viewport. */ + selectAll(): void { + this.clearPrimedCopy(); + const range = this.core.selectAll(); + this.selectionAnchorScreen = range?.start ?? null; + this.selectionEndScreen = range?.end ?? null; + this.selectionEnd = range ? this.core.screenPointToViewport(range.end.x, range.end.y) : null; + this.selectionMode = "cell"; + this.selectionBase = null; + this.setSelectionAutoscroll(0); + this.options.onSelectionChange(); + this.forceFullRender = true; + this.requestRender(); + } + getSelectionPosition(): GhosttySelectionPosition | null { if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection()) return null; @@ -998,7 +1013,15 @@ export class GhosttyTerminalSurface { const position = this.getSelectionPosition(); if (!position) return null; const viewportEnd = this.core.screenPointToViewport(position.end.x, position.end.y); - if (!viewportEnd) return null; + if ( + !viewportEnd || + viewportEnd.x < 0 || + viewportEnd.x >= this.cols || + viewportEnd.y < 0 || + viewportEnd.y >= this.rows + ) { + return null; + } const bounds = this.canvas.getBoundingClientRect(); return { right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width, @@ -1074,6 +1097,55 @@ export class GhosttyTerminalSurface { this.suppressedKeyCodes.add(event.code); return; } + // IME candidates belong to the textarea, even if the key also resembles + // a local shortcut. Safari signals the initial composition with code 229. + if (isTerminalCompositionKey(event, this.composing)) { + this.suppressedKeyCodes.add(event.code); + return; + } + const mac = isMacPlatform(navigator.platform); + const primaryModifier = mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; + const selectAllShortcut = + event.key.toLowerCase() === "a" && + primaryModifier && + !event.altKey && + (mac ? !event.shiftKey : event.shiftKey); + if (selectAllShortcut) { + event.preventDefault(); + event.stopPropagation(); + this.suppressedKeyCodes.add(event.code); + this.selectAll(); + return; + } + // Shift+PageUp/Down pages history; Ctrl+Shift+Home/End (Cmd on macOS) + // jumps to its edges. Full-screen applications retain these keys. + const pageHistory = + event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + (event.key === "PageUp" || event.key === "PageDown"); + const jumpHistory = + event.shiftKey && + !event.altKey && + primaryModifier && + (event.key === "Home" || event.key === "End"); + if ((pageHistory || jumpHistory) && !this.core.isAlternateScreen()) { + const state = this.readScrollbarState(); + if (state !== null) { + event.preventDefault(); + event.stopPropagation(); + this.suppressedKeyCodes.add(event.code); + const delta = + event.key === "Home" + ? -state.offset + : event.key === "End" + ? state.total - state.len - state.offset + : Math.max(1, state.len) * (event.key === "PageUp" ? -1 : 1); + this.scrollViewport(delta); + return; + } + } if (isTerminalCopyShortcut(event) && this.hasSelection()) { // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in // onCopyEvent; not preventing the default keeps that path alive. WebKit @@ -1152,12 +1224,6 @@ export class GhosttyTerminalSurface { } return; } - // keyCode 229 is Safari's only signal that this keydown opens an IME - // composition; encoding it would double the committed text. Do not blank - // the textarea first: onInput leaves the in-progress candidate there. - if (isTerminalCompositionKey(event, this.composing)) { - return; - } this.clearPrimedCopy(); const data = this.core.encodeKey(event); if (data.length === 0) return; diff --git a/docs/user/terminal.md b/docs/user/terminal.md index 2e31ddf3b177..73413803bef8 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -6,3 +6,13 @@ line can be shortened at the start. New terminal output is not truncated. These limits apply when you reconnect and when T3 Code restores saved terminal history. A client can show less scrollback than the server keeps. + +On web and desktop, use Shift+PageUp and Shift+PageDown to read scrollback +without leaving terminal input. Ctrl+Shift+Home and Ctrl+Shift+End jump to the +start and latest output; use Cmd instead of Ctrl on macOS. Full-screen terminal +programs keep these navigation keys. + +To copy all retained output, choose Select all in the terminal's context menu, +then copy. Cmd+A on macOS and Ctrl+Shift+A elsewhere select the same output. +Ctrl+A still moves to the beginning of shell input on Windows and Linux. +Jump to latest in the context menu returns to the current output.