From 5b9c8df63bad87edf9cdbcf728a347159a05356e Mon Sep 17 00:00:00 2001 From: Marcos Lopez Date: Mon, 17 Aug 2026 02:07:46 -0600 Subject: [PATCH 1/6] feat(web): faster drag autoscroll and richer terminal selection Dragging a selection past the edge of the terminal scrolled at a fixed 1 row per 80ms regardless of how far past the edge the pointer went, so selecting a screenful of scrollback meant holding still for seconds. The autoscroll rate is now driven by how far past the edge the pointer sits and integrated over real elapsed time on rAF, so a slight overhang still creeps for fine control while a long drag covers pages. It also stops re-rendering once scrollback reaches either end. Selection also gained four capabilities, all built on libghostty-vt primitives that were already vendored but unused: - Select all (Cmd+A, Ctrl+Shift+A off mac, and a context menu entry). A bare Ctrl+A stays with the shell for readline and tmux. - Block selection on Alt+drag, via the selection struct's rectangle flag. - Keyboard selection with Shift+Arrows/Home/End/PageUp/PageDown, gated off on the alternate screen and under mouse tracking so a running application keeps its own shifted chords. - Select command output, which needs OSC 133 marks from the shell; the menu entry is probed per click and hidden when the shell emits none. Written by Claude Opus 5 (1M context) via Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/ThreadTerminalDrawer.test.ts | 26 ++ .../src/components/ThreadTerminalDrawer.tsx | 36 ++- apps/web/src/terminal/ghostty/core.ts | 74 ++++- apps/web/src/terminal/ghostty/surface.test.ts | 146 +++++++++ apps/web/src/terminal/ghostty/surface.ts | 301 ++++++++++++++++-- 5 files changed, 546 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index e60d1d71678f..07cc9d57ba71 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -2,12 +2,38 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveTerminalSelectionActionPosition, + terminalContextMenuItems, shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, terminalSelectionActionDelayForClickCount, terminalSelectionLineRange, } from "./ThreadTerminalDrawer"; +describe("terminalContextMenuItems", () => { + it("keeps Select All and Paste usable with no selection", () => { + const items = terminalContextMenuItems({ hasSelection: false }); + const enabled = items.filter((item) => item.disabled !== true).map((item) => item.id); + expect(enabled).toEqual(["select-all", "paste"]); + }); + + it("enables the selection actions once a selection exists", () => { + const items = terminalContextMenuItems({ hasSelection: true }); + expect(items.some((item) => item.disabled === true)).toBe(false); + expect(items.map((item) => item.id)).toEqual(["add-to-chat", "copy", "select-all", "paste"]); + }); + + it("omits Select command output unless the click lands on bounded output", () => { + expect(terminalContextMenuItems({ hasSelection: false }).map((item) => item.id)).not.toContain( + "select-output", + ); + expect( + terminalContextMenuItems({ hasSelection: false, hasCommandOutput: true }).map( + (item) => item.id, + ), + ).toEqual(["add-to-chat", "copy", "select-output", "select-all", "paste"]); + }); +}); + describe("resolveTerminalSelectionActionPosition", () => { it("prefers the selection rect over the last pointer position", () => { expect( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index cf2adaca2cf4..68fda39e35a5 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -256,7 +256,12 @@ export function terminalSelectionLineRange(position: { }; } -export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; +export type TerminalContextMenuAction = + | "add-to-chat" + | "copy" + | "paste" + | "select-all" + | "select-output"; /** Post-selection popup: just the two selection actions, always enabled. */ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { @@ -268,18 +273,27 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c /** * Right-click menu for the terminal canvas: the selection actions (disabled - * until a selection exists) plus Paste. 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. + * until a selection exists) plus Select All and Paste. Both are always offered: + * the browser (and Electron's default editing menu) only act on an editable + * element, so a canvas terminal never gets usable entries from them. + * + * "Select command output" only appears over output Ghostty can bound, which + * needs OSC 133 marks from the shell. Offering it on an unmarked shell would be + * an entry that never does anything. */ export function terminalContextMenuItems(options: { hasSelection: boolean; + hasCommandOutput?: boolean; }): ContextMenuItem[] { return [ ...terminalSelectionMenuItems().map((item) => ({ ...item, disabled: !options.hasSelection, })), + ...(options.hasCommandOutput === true + ? [{ id: "select-output" as const, label: "Select command output" }] + : []), + { id: "select-all", label: "Select all" }, { id: "paste", label: "Paste" }, ]; } @@ -633,7 +647,11 @@ export function TerminalViewport({ let clicked: TerminalContextMenuAction | null; try { clicked = await localApi.contextMenu.show( - terminalContextMenuItems({ hasSelection: selectionAction !== null }), + terminalContextMenuItems({ + hasSelection: selectionAction !== null, + hasCommandOutput: + terminalRef.current?.hasCommandOutputAt(event.clientX, event.clientY) === true, + }), { x: event.clientX, y: event.clientY }, ); } catch (error) { @@ -651,6 +669,14 @@ export function TerminalViewport({ case "copy": if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); return; + case "select-all": + terminalRef.current?.selectAll(); + focusIfCurrent(requestId); + return; + case "select-output": + terminalRef.current?.selectCommandOutputAt(event.clientX, event.clientY); + focusIfCurrent(requestId); + return; case "paste": await pasteFromClipboard(requestId); return; diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index 022ad038c318..7ff389cfe01b 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -602,7 +602,8 @@ export class GhosttyTerminalCore { return encoded; } - setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput): void { + /** `rectangle` selects the block bounded by the two points instead of the text flow between them. */ + setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput, rectangle = false): void { this.ensureActive(); const selectionLayout = this.runtime.layout("GhosttySelection"); const gridRefSize = this.runtime.layout("GhosttyGridRef").size; @@ -621,6 +622,7 @@ export class GhosttyTerminalCore { this.runtime .bytes(selection + endField.offset, endField.size) .set(this.runtime.bytes(endRef, endField.size)); + this.runtime.setField(selection, "GhosttySelection", "rectangle", rectangle ? 1 : 0); this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); } finally { this.runtime.free(start, gridRefSize); @@ -629,17 +631,75 @@ export class GhosttyTerminalCore { } } - selectAll(): void { + /** + * The bounds of the command output containing a cell, without selecting it. + * Null unless the shell emits OSC 133 semantic prompt marks and the cell sits + * inside a marked output region — a prompt or an unmarked shell yields none. + */ + outputRangeAt(col: number, row: number): GhosttySelectionRange["screen"] | null { + return this.selectOutputAt(col, row, false); + } + + /** Selects the whole output of the command that produced this cell. */ + selectOutput(col: number, row: number): GhosttySelectionRange["screen"] | null { + return this.selectOutputAt(col, row, true); + } + + private selectOutputAt( + col: number, + row: number, + apply: boolean, + ): GhosttySelectionRange["screen"] | null { + this.ensureActive(); + const selectionLayout = this.runtime.layout("GhosttySelection"); + const selection = this.runtime.alloc(selectionLayout.size); + let ref = 0; + let screen: GhosttySelectionRange["screen"] | null = null; + try { + this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); + ref = this.gridRef(col, row); + if ( + this.runtime.call("ghostty_terminal_select_output", this.terminal, ref, selection) === + GHOSTTY_SUCCESS + ) { + const start = this.pointFromGridRef(selection + selectionLayout.fields.start!.offset, 2); + const end = this.pointFromGridRef(selection + selectionLayout.fields.end!.offset, 2); + if (start !== null && end !== null) screen = { start, end }; + if (apply) this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } + } finally { + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + this.runtime.free(selection, selectionLayout.size); + } + return screen; + } + + /** + * Selects everything the terminal holds, scrollback included, and reports the + * bounds in screen coordinates. Only screen points are returned: the start of + * a full selection usually sits above the viewport, where a viewport point + * does not exist. Null means there was nothing to select. + */ + 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 - ) { - this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + let screen: GhosttySelectionRange["screen"] | null = null; + try { + if ( + this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === + GHOSTTY_SUCCESS + ) { + const start = this.pointFromGridRef(selection + layout.fields.start!.offset, 2); + const end = this.pointFromGridRef(selection + layout.fields.end!.offset, 2); + if (start !== null && end !== null) screen = { start, end }; + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } + } finally { + this.runtime.free(selection, layout.size); } - this.runtime.free(selection, layout.size); + return screen; } selectWord(col: number, row: number): GhosttySelectionRange | null { diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index c11529e0c46c..c236bf146b5a 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -11,6 +11,7 @@ import { isTerminalCopyShortcut, isTerminalLinkPointerGesture, isTerminalPasteShortcut, + isTerminalSelectAllShortcut, loadTerminalFontFamily, shouldBlinkTerminalCursor, shouldReportTerminalMouse, @@ -18,6 +19,10 @@ import { terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, + terminalSelectionAutoscrollRate, + terminalSelectionKeyMove, + terminalSelectionKeyTarget, + terminalSelectionOvershoot, terminalLinkAtColumn, terminalLinkAtPosition, terminalLinkAtPositionWithRange, @@ -233,6 +238,33 @@ describe("isTerminalCopyShortcut", () => { }); }); +describe("isTerminalSelectAllShortcut", () => { + const event = (overrides: Partial[0]> = {}) => ({ + ctrlKey: false, + key: "a", + metaKey: false, + shiftKey: false, + ...overrides, + }); + + it("uses Cmd+A on macOS", () => { + expect(isTerminalSelectAllShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); + expect(isTerminalSelectAllShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false); + }); + + it("leaves Ctrl+A to the shell and uses Ctrl+Shift+A elsewhere", () => { + // A bare Ctrl+A is readline's beginning-of-line and tmux's prefix. + expect(isTerminalSelectAllShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false); + expect( + isTerminalSelectAllShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64"), + ).toBe(true); + }); + + it("ignores other keys", () => { + expect(isTerminalSelectAllShortcut(event({ key: "s", metaKey: true }), "MacIntel")).toBe(false); + }); +}); + describe("isTerminalPasteShortcut", () => { const event = (overrides: Partial[0]> = {}) => ({ ctrlKey: false, @@ -419,6 +451,120 @@ describe("terminalWheelDeltaRows", () => { }); }); +describe("terminalSelectionKeyMove", () => { + const event = (overrides: Partial[0]> = {}) => ({ + key: "ArrowDown", + shiftKey: true, + ctrlKey: false, + metaKey: false, + altKey: false, + ...overrides, + }); + + it("maps the shifted arrows to single steps", () => { + expect(terminalSelectionKeyMove(event({ key: "ArrowLeft" }), 24)).toEqual({ + columns: -1, + rows: 0, + }); + expect(terminalSelectionKeyMove(event({ key: "ArrowUp" }), 24)).toEqual({ + columns: 0, + rows: -1, + }); + }); + + it("pages by the viewport height", () => { + expect(terminalSelectionKeyMove(event({ key: "PageUp" }), 24)?.rows).toBe(-24); + expect(terminalSelectionKeyMove(event({ key: "PageDown" }), 24)?.rows).toBe(24); + }); + + it("ignores presses without Shift or carrying another modifier", () => { + expect(terminalSelectionKeyMove(event({ shiftKey: false }), 24)).toBeNull(); + expect(terminalSelectionKeyMove(event({ ctrlKey: true }), 24)).toBeNull(); + expect(terminalSelectionKeyMove(event({ altKey: true }), 24)).toBeNull(); + expect(terminalSelectionKeyMove(event({ key: "x" }), 24)).toBeNull(); + }); +}); + +describe("terminalSelectionKeyTarget", () => { + const bounds = { cols: 80, maxRow: 500 }; + + it("clamps to the grid instead of running past its edges", () => { + expect(terminalSelectionKeyTarget({ x: 0, y: 0 }, { columns: -1, rows: -1 }, bounds)).toEqual({ + x: 0, + y: 0, + }); + expect(terminalSelectionKeyTarget({ x: 79, y: 500 }, { columns: 1, rows: 1 }, bounds)).toEqual({ + x: 79, + y: 500, + }); + }); + + it("jumps to the row edges for Home and End", () => { + expect( + terminalSelectionKeyTarget( + { x: 40, y: 12 }, + { columns: 0, rows: 0, toLineStart: true }, + bounds, + ), + ).toEqual({ x: 0, y: 12 }); + expect( + terminalSelectionKeyTarget( + { x: 40, y: 12 }, + { columns: 0, rows: 0, toLineEnd: true }, + bounds, + ), + ).toEqual({ x: 79, y: 12 }); + }); + + it("clamps a page jump to the top of the scrollback", () => { + expect(terminalSelectionKeyTarget({ x: 5, y: 10 }, { columns: 0, rows: -24 }, bounds).y).toBe( + 0, + ); + }); +}); + +describe("terminalSelectionOvershoot", () => { + const bounds = { top: 100, bottom: 400 }; + + it("is zero inside the grid", () => { + expect(terminalSelectionOvershoot(100, bounds)).toBe(0); + expect(terminalSelectionOvershoot(250, bounds)).toBe(0); + expect(terminalSelectionOvershoot(400, bounds)).toBe(0); + }); + + it("signs the distance past each edge", () => { + expect(terminalSelectionOvershoot(60, bounds)).toBe(-40); + expect(terminalSelectionOvershoot(475, bounds)).toBe(75); + }); +}); + +describe("terminalSelectionAutoscrollRate", () => { + it("does not scroll while the pointer is inside the grid", () => { + expect(terminalSelectionAutoscrollRate(0, 16)).toBe(0); + }); + + it("speeds up the further the drag goes past the edge", () => { + const near = terminalSelectionAutoscrollRate(8, 16); + const far = terminalSelectionAutoscrollRate(160, 16); + expect(near).toBeGreaterThan(0); + expect(far).toBeGreaterThan(near * 5); + }); + + it("caps the rate so a far drag stays steerable", () => { + expect(terminalSelectionAutoscrollRate(100_000, 16)).toBe(400); + }); + + it("scrolls upward for a drag above the grid", () => { + expect(terminalSelectionAutoscrollRate(-160, 16)).toBe( + -terminalSelectionAutoscrollRate(160, 16), + ); + }); + + it("ignores an unmeasured cell height", () => { + expect(terminalSelectionAutoscrollRate(160, 0)).toBe(0); + }); +}); + describe("terminalWheelArrowData", () => { it("emits one arrow per row honoring application cursor keys", () => { expect(terminalWheelArrowData(-2, false)).toBe("\u001b[A\u001b[A"); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 9492e2d02628..2018d2a0de15 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -34,6 +34,12 @@ export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS; const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; +/** Selection autoscroll rate at the grid edge, and how fast it ramps past it. */ +const SELECTION_AUTOSCROLL_BASE_ROWS_PER_SECOND = 10; +const SELECTION_AUTOSCROLL_ROWS_PER_SECOND_PER_CELL = 14; +const SELECTION_AUTOSCROLL_MAX_ROWS_PER_SECOND = 400; +/** Frame budget cap so a throttled tab does not resume with one giant jump. */ +const SELECTION_AUTOSCROLL_MAX_STEP_MS = 100; /** Half a blink cycle: the visible and hidden phases are equally long. */ const CURSOR_BLINK_INTERVAL_MS = 500; const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; @@ -348,6 +354,77 @@ export function isTerminalPasteShortcut( return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } +export function isTerminalSelectAllShortcut( + event: Pick, + platform = navigator.platform, +) { + if (event.key.toLowerCase() !== "a") return false; + // Cmd+A is free on mac, but a bare Ctrl+A is readline's beginning-of-line and + // tmux's default prefix, so elsewhere select all takes the Shift chord and + // leaves the plain press for the shell — the same split copy already makes. + return isMacPlatform(platform) + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && event.shiftKey; +} + +export interface TerminalSelectionKeyMove { + readonly columns: number; + readonly rows: number; + readonly toLineStart?: boolean; + readonly toLineEnd?: boolean; +} + +/** + * How a Shift chord moves the free end of a keyboard selection, or null when + * the press is not one. `pageRows` is the viewport height, so PageUp/PageDown + * cover exactly one screen. + * + * Callers must ignore this on the alternate screen and under mouse tracking: + * there the chord belongs to the running application, which is the only thing + * that knows what a shifted arrow means to it. + */ +export function terminalSelectionKeyMove( + event: Pick, + pageRows: number, +): TerminalSelectionKeyMove | null { + if (!event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return null; + switch (event.key) { + case "ArrowLeft": + return { columns: -1, rows: 0 }; + case "ArrowRight": + return { columns: 1, rows: 0 }; + case "ArrowUp": + return { columns: 0, rows: -1 }; + case "ArrowDown": + return { columns: 0, rows: 1 }; + case "PageUp": + return { columns: 0, rows: -Math.max(1, pageRows) }; + case "PageDown": + return { columns: 0, rows: Math.max(1, pageRows) }; + case "Home": + return { columns: 0, rows: 0, toLineStart: true }; + case "End": + return { columns: 0, rows: 0, toLineEnd: true }; + default: + return null; + } +} + +/** Where a keyboard move lands, clamped to the grid and the scrollback. */ +export function terminalSelectionKeyTarget( + origin: { readonly x: number; readonly y: number }, + move: TerminalSelectionKeyMove, + bounds: { readonly cols: number; readonly maxRow: number }, +): { readonly x: number; readonly y: number } { + const lastColumn = Math.max(0, bounds.cols - 1); + const x = move.toLineStart + ? 0 + : move.toLineEnd + ? lastColumn + : Math.max(0, Math.min(origin.x + move.columns, lastColumn)); + return { x, y: Math.max(0, Math.min(origin.y + move.rows, Math.max(0, bounds.maxRow))) }; +} + export function isTerminalCompositionCommitInput(event: Pick): boolean { return ( event.inputType === "" || @@ -456,6 +533,34 @@ export function advanceTerminalSelectionClickSequence( }; } +/** + * Rows per second the viewport scrolls while a selection drag rests `overshoot` + * pixels past the grid edge, negative above and positive below. The rate ramps + * with distance so a slight overhang creeps for fine control while a long drag + * covers pages: at a fixed rate, selecting a screenful of scrollback takes + * longer than the drag itself. + */ +export function terminalSelectionAutoscrollRate(overshoot: number, cellHeight: number): number { + if (overshoot === 0 || cellHeight <= 0) return 0; + const cells = Math.abs(overshoot) / cellHeight; + const rate = Math.min( + SELECTION_AUTOSCROLL_MAX_ROWS_PER_SECOND, + SELECTION_AUTOSCROLL_BASE_ROWS_PER_SECOND + + cells * SELECTION_AUTOSCROLL_ROWS_PER_SECOND_PER_CELL, + ); + return overshoot < 0 ? -rate : rate; +} + +/** Signed pixels the pointer sits past the top or bottom edge of the grid. */ +export function terminalSelectionOvershoot( + clientY: number, + bounds: { readonly top: number; readonly bottom: number }, +): number { + if (clientY < bounds.top) return clientY - bounds.top; + if (clientY > bounds.bottom) return clientY - bounds.bottom; + return 0; +} + export interface GhosttySelectionPosition { readonly start: { readonly x: number; readonly y: number }; readonly end: { readonly x: number; readonly y: number }; @@ -516,14 +621,17 @@ export class GhosttyTerminalSurface { private selectionAnchorScreen: { x: number; y: number } | null = null; private selectionEndScreen: { x: number; y: number } | null = null; private selectionMode: "cell" | "word" | "line" = "cell"; + private selectionRectangle = false; // Word/line selection base in screen coordinates so streaming output cannot // shift the origin of a drag selection. private selectionBase: { start: { x: number; y: number }; end: { x: number; y: number }; } | null = null; - private selectionScrollTimer: number | null = null; - private selectionScrollDelta = 0; + private selectionScrollFrame: number | null = null; + private selectionScrollOvershoot = 0; + private selectionScrollRemainder = 0; + private selectionScrollTime = 0; private selectionPointer: { x: number; y: number } | null = null; private mouseReportingPointerId: number | null = null; private mouseReportingButton: number | null = null; @@ -865,6 +973,54 @@ export class GhosttyTerminalSurface { }; } + /** + * Selects the whole terminal, scrollback included. The viewport stays put: + * jumping to an edge would lose the rows the user was looking at, and the + * selection is not something they need to see to copy it. + */ + selectAll(): void { + const screen = this.core.selectAll(); + if (screen === null) return; + // A fresh anchor, so a later drag extends from this selection's own bounds + // rather than from wherever the previous gesture happened to leave them. + this.selectionEnd = null; + this.selectionMode = "cell"; + this.selectionBase = null; + this.selectionRectangle = false; + this.selectionAnchorScreen = screen.start; + this.selectionEndScreen = screen.end; + this.options.onSelectionChange(); + this.forceFullRender = true; + this.requestRender(); + } + + /** + * Whether the cell under these client coordinates belongs to command output + * Ghostty can bound. Requires OSC 133 marks from the shell, so an unmarked + * shell reports false everywhere and callers can hide the affordance. + */ + hasCommandOutputAt(clientX: number, clientY: number): boolean { + const cell = this.cellAt(clientX, clientY); + return this.core.outputRangeAt(cell.x, cell.y) !== null; + } + + /** Selects the full output of the command that produced this cell. */ + selectCommandOutputAt(clientX: number, clientY: number): boolean { + const cell = this.cellAt(clientX, clientY); + const screen = this.core.selectOutput(cell.x, cell.y); + if (screen === null) return false; + this.selectionEnd = null; + this.selectionMode = "cell"; + this.selectionBase = null; + this.selectionRectangle = false; + this.selectionAnchorScreen = screen.start; + this.selectionEndScreen = screen.end; + this.options.onSelectionChange(); + this.forceFullRender = true; + this.requestRender(); + return true; + } + clearSelection(): void { this.core.clearSelection(); this.selectionEnd = null; @@ -872,6 +1028,7 @@ export class GhosttyTerminalSurface { this.selectionEndScreen = null; this.selectionMode = "cell"; this.selectionBase = null; + this.selectionRectangle = false; this.setSelectionAutoscroll(0); this.options.onSelectionChange(); // Selection highlights span rows Ghostty may not mark dirty for this change. @@ -898,7 +1055,7 @@ export class GhosttyTerminalSurface { this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); this.dprMedia = null; this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange); - if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); + this.stopSelectionAutoscroll(); if (this.resizeNotifyTimer !== null) { window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = null; @@ -934,6 +1091,25 @@ export class GhosttyTerminalSurface { this.suppressedKeyCodes.add(event.code); return; } + if (isTerminalSelectAllShortcut(event)) { + // Nothing native to fall back on: the grid is a canvas, so the browser's + // own select all would target the page instead. + event.preventDefault(); + this.suppressedKeyCodes.add(event.code); + this.selectAll(); + return; + } + // The alternate screen and mouse tracking belong to the running program: + // a shifted arrow there is its input, not a selection gesture. + if (!this.core.isAlternateScreen() && !this.core.isMouseTracking()) { + const move = terminalSelectionKeyMove(event, this.rows); + if (move !== null) { + event.preventDefault(); + this.suppressedKeyCodes.add(event.code); + this.extendSelectionByKey(move); + 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 @@ -1161,6 +1337,9 @@ export class GhosttyTerminalSurface { this.clearHoveredLink(); const cell = this.cellAt(event.clientX, event.clientY); this.selectionMoved = false; + // Alt starts a block selection, so pulling one column out of tabular output + // does not drag in the whole width of every row it crosses. + this.selectionRectangle = event.altKey; this.selectionClickSequence = advanceTerminalSelectionClickSequence( this.selectionClickSequence, event, @@ -1187,9 +1366,13 @@ export class GhosttyTerminalSurface { this.selectionAnchorScreen = screen; this.selectionEndScreen = screen; if (screen) { - this.core.setSelection({ ...screen, tag: 2 }, { ...screen, tag: 2 }); + this.core.setSelection( + { ...screen, tag: 2 }, + { ...screen, tag: 2 }, + this.selectionRectangle, + ); } else { - this.core.setSelection(cell, cell); + this.core.setSelection(cell, cell, this.selectionRectangle); } } this.forceFullRender = true; @@ -1222,9 +1405,7 @@ export class GhosttyTerminalSurface { this.clearHoveredLink(); this.selectionPointer = { x: event.clientX, y: event.clientY }; const bounds = this.canvas.getBoundingClientRect(); - this.setSelectionAutoscroll( - event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0, - ); + this.setSelectionAutoscroll(terminalSelectionOvershoot(event.clientY, bounds)); const cell = this.cellAt(event.clientX, event.clientY); if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return; this.extendSelectionTo(event.clientX, event.clientY); @@ -1253,30 +1434,98 @@ export class GhosttyTerminalSurface { const end = range === null ? cellScreen : beforeBase ? range.screen.start : range.screen.end; this.selectionAnchorScreen = anchor; this.selectionEndScreen = end; - this.core.setSelection({ ...anchor, tag: 2 }, { ...end, tag: 2 }); + this.core.setSelection({ ...anchor, tag: 2 }, { ...end, tag: 2 }, this.selectionRectangle); this.options.onSelectionChange(); this.forceFullRender = true; this.requestRender(); } - private setSelectionAutoscroll(delta: number): void { - this.selectionScrollDelta = delta; - if (delta === 0) { - if (this.selectionScrollTimer !== null) { - window.clearInterval(this.selectionScrollTimer); - this.selectionScrollTimer = null; - } + /** `overshoot` is signed pixels past the grid edge; 0 stops the autoscroll. */ + /** + * Moves the free end of the selection, anchoring at the cursor the first time + * so a selection can be started from the keyboard alone, and scrolls whatever + * the new end lands on into view. + */ + private extendSelectionByKey(move: TerminalSelectionKeyMove): void { + const state = this.readScrollbarState(); + const origin = + this.selectionEndScreen ?? + (this.snapshot === null + ? null + : this.core.viewportPointToScreen(this.snapshot.cursorX, this.snapshot.cursorY)); + if (origin === null) return; + if (this.selectionAnchorScreen === null) { + this.selectionAnchorScreen = origin; + this.selectionMode = "cell"; + this.selectionBase = null; + this.selectionRectangle = false; + } + const target = terminalSelectionKeyTarget(origin, move, { + cols: this.cols, + maxRow: state === null ? this.rows - 1 : state.total - 1, + }); + this.selectionEnd = null; + this.selectionEndScreen = target; + this.core.setSelection( + { ...this.selectionAnchorScreen, tag: 2 }, + { ...target, tag: 2 }, + this.selectionRectangle, + ); + if (state !== null) { + // Keep the moving end on screen; a selection you cannot see is not one. + const above = target.y - state.offset; + const below = target.y - (state.offset + state.len - 1); + if (above < 0) this.scrollViewport(above); + else if (below > 0) this.scrollViewport(below); + } + this.options.onSelectionChange(); + this.forceFullRender = true; + this.requestRender(); + } + + private setSelectionAutoscroll(overshoot: number): void { + this.selectionScrollOvershoot = overshoot; + if (overshoot === 0) { + this.stopSelectionAutoscroll(); return; } - if (this.selectionScrollTimer !== null) return; + if (this.selectionScrollFrame !== null) return; // Dragging past the edge scrolls the viewport and keeps extending the - // selection into the newly revealed rows, like xterm's drag scroller. - this.selectionScrollTimer = window.setInterval(() => { - if (this.disposed || this.selectionScrollDelta === 0) return; - this.scrollViewport(this.selectionScrollDelta); + // selection into the newly revealed rows, like xterm's drag scroller. The + // rate is distance-driven and integrated over real elapsed time, so it + // neither creeps on a long drag nor changes with display refresh rate. + this.selectionScrollRemainder = 0; + this.selectionScrollTime = performance.now(); + this.selectionScrollFrame = window.requestAnimationFrame(this.stepSelectionAutoscroll); + } + + private readonly stepSelectionAutoscroll = (now: number) => { + this.selectionScrollFrame = null; + if (this.disposed || this.selectionScrollOvershoot === 0) return; + const elapsed = Math.min( + Math.max(0, now - this.selectionScrollTime), + SELECTION_AUTOSCROLL_MAX_STEP_MS, + ); + this.selectionScrollTime = now; + this.selectionScrollRemainder += + terminalSelectionAutoscrollRate(this.selectionScrollOvershoot, this.metrics.height) * + (elapsed / 1000); + const rows = Math.trunc(this.selectionScrollRemainder); + if (rows !== 0) { + this.selectionScrollRemainder -= rows; + // Holding the drag past the edge at either end of scrollback moves + // nothing, so skip the re-extension and its full repaint. + const scrolled = this.scrollViewport(rows); const pointer = this.selectionPointer; - if (pointer) this.extendSelectionTo(pointer.x, pointer.y); - }, 80); + if (scrolled !== 0 && pointer) this.extendSelectionTo(pointer.x, pointer.y); + } + this.selectionScrollFrame = window.requestAnimationFrame(this.stepSelectionAutoscroll); + }; + + private stopSelectionAutoscroll(): void { + if (this.selectionScrollFrame === null) return; + window.cancelAnimationFrame(this.selectionScrollFrame); + this.selectionScrollFrame = null; } private updateHoverCursor(event: PointerEvent): void { @@ -1524,7 +1773,8 @@ export class GhosttyTerminalSurface { this.scrollbar.removeEventListener("keydown", this.onScrollbarKeyDown); } - private scrollViewport(deltaRows: number): void { + /** Returns the rows actually scrolled, which is 0 at either end of scrollback. */ + private scrollViewport(deltaRows: number): number { let delta = Math.trunc(deltaRows); const state = this.readScrollbarState(); if (state !== null) { @@ -1533,11 +1783,12 @@ export class GhosttyTerminalSurface { delta = offset - state.offset; this.scrollbarState = { ...state, offset }; } - if (delta === 0) return; + if (delta === 0) return 0; this.core.scroll(delta); this.forceFullRender = true; this.scrollbarDirty = true; this.requestRender(); + return delta; } private scrollbarToPointer(clientY: number, bounds: DOMRect): void { From d488ae45b38d9fd06708a4cd61f127e491bfd01a Mon Sep 17 00:00:00 2001 From: Marcos Lopez Date: Mon, 17 Aug 2026 02:18:53 -0600 Subject: [PATCH 2/6] fix(web): anchor keyboard selection and select-output correctly Two defects in the selection work, both found in review. Ghostty reports a cursor scrolled out of the viewport as -1, and grid refs clamp negatives to zero, so the first Shift+Arrow taken while viewing scrollback anchored at the top left of the viewport instead of at the cursor. An out-of-viewport cursor is now no origin at all, so the press does nothing rather than selecting a region the user never chose. The terminal context menu also probed the command under the pointer before awaiting the menu, then re-resolved the same coordinates after it closed, so output arriving meanwhile could select a different command or none. The output range is captured once up front and the action applies that range; screen coordinates stay pinned to their content, so it survives the wait. Written by Claude Opus 5 (1M context) via Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/ThreadTerminalDrawer.tsx | 10 ++-- apps/web/src/terminal/ghostty/surface.test.ts | 15 ++++++ apps/web/src/terminal/ghostty/surface.ts | 49 +++++++++++++------ 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 68fda39e35a5..f759beaec7c6 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -644,13 +644,17 @@ export function TerminalViewport({ clearSelectionAction(); const selectionAction = readSelectionAction(); const requestId = selectionActionRequestIdRef.current; + // Resolve the command under the pointer now and act on that range, not + // on the coordinates: the menu is asynchronous, and output arriving + // while it is open would put a different command under the same point. + const commandOutput = + terminalRef.current?.commandOutputRangeAt(event.clientX, event.clientY) ?? null; let clicked: TerminalContextMenuAction | null; try { clicked = await localApi.contextMenu.show( terminalContextMenuItems({ hasSelection: selectionAction !== null, - hasCommandOutput: - terminalRef.current?.hasCommandOutputAt(event.clientX, event.clientY) === true, + hasCommandOutput: commandOutput !== null, }), { x: event.clientX, y: event.clientY }, ); @@ -674,7 +678,7 @@ export function TerminalViewport({ focusIfCurrent(requestId); return; case "select-output": - terminalRef.current?.selectCommandOutputAt(event.clientX, event.clientY); + if (commandOutput) terminalRef.current?.selectCommandOutputRange(commandOutput); focusIfCurrent(requestId); return; case "paste": diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index c236bf146b5a..ad1ed2ce4a59 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -19,6 +19,7 @@ import { terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, + terminalCursorViewportPoint, terminalSelectionAutoscrollRate, terminalSelectionKeyMove, terminalSelectionKeyTarget, @@ -485,6 +486,20 @@ describe("terminalSelectionKeyMove", () => { }); }); +describe("terminalCursorViewportPoint", () => { + it("reports the cursor cell while it is on screen", () => { + expect(terminalCursorViewportPoint({ cursorX: 12, cursorY: 3 })).toEqual({ x: 12, y: 3 }); + expect(terminalCursorViewportPoint({ cursorX: 0, cursorY: 0 })).toEqual({ x: 0, y: 0 }); + }); + + it("has no point for a cursor scrolled out of the viewport", () => { + // Ghostty encodes that state as -1; clamping it would silently anchor a + // keyboard selection at the top left of the viewport instead. + expect(terminalCursorViewportPoint({ cursorX: -1, cursorY: -1 })).toBeNull(); + expect(terminalCursorViewportPoint(null)).toBeNull(); + }); +}); + describe("terminalSelectionKeyTarget", () => { const bounds = { cols: 80, maxRow: 500 }; diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 2018d2a0de15..04f6a94b5a58 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -3,6 +3,7 @@ import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../term import { GhosttyTerminalCore, type GhosttyScrollbar, + type GhosttySelectionRange, type GhosttySnapshot, type GhosttyTheme, } from "./core"; @@ -410,6 +411,19 @@ export function terminalSelectionKeyMove( } } +/** + * The cursor's viewport cell, or null when it has none. Ghostty reports a + * cursor scrolled out of the viewport as -1, and grid refs clamp negatives to + * zero — so a caller that forwards it unchecked silently anchors at the top + * left of the viewport instead of at the cursor. + */ +export function terminalCursorViewportPoint( + snapshot: { readonly cursorX: number; readonly cursorY: number } | null, +): { readonly x: number; readonly y: number } | null { + if (snapshot === null || snapshot.cursorX < 0 || snapshot.cursorY < 0) return null; + return { x: snapshot.cursorX, y: snapshot.cursorY }; +} + /** Where a keyboard move lands, clamped to the grid and the scrollback. */ export function terminalSelectionKeyTarget( origin: { readonly x: number; readonly y: number }, @@ -995,30 +1009,32 @@ export class GhosttyTerminalSurface { } /** - * Whether the cell under these client coordinates belongs to command output - * Ghostty can bound. Requires OSC 133 marks from the shell, so an unmarked - * shell reports false everywhere and callers can hide the affordance. + * The bounds of the command output under these client coordinates, or null + * when there is none. Requires OSC 133 marks from the shell, so an unmarked + * shell reports null everywhere and callers can hide the affordance. + * + * Callers that act on this later must keep the returned range rather than the + * coordinates: a menu takes time to answer, and streaming output or a scroll + * puts a different command under the same point by the time it does. Screen + * coordinates stay pinned to their content, so the range survives that. */ - hasCommandOutputAt(clientX: number, clientY: number): boolean { + commandOutputRangeAt(clientX: number, clientY: number): GhosttySelectionRange["screen"] | null { const cell = this.cellAt(clientX, clientY); - return this.core.outputRangeAt(cell.x, cell.y) !== null; + return this.core.outputRangeAt(cell.x, cell.y); } - /** Selects the full output of the command that produced this cell. */ - selectCommandOutputAt(clientX: number, clientY: number): boolean { - const cell = this.cellAt(clientX, clientY); - const screen = this.core.selectOutput(cell.x, cell.y); - if (screen === null) return false; + /** Selects a command output range captured earlier by `commandOutputRangeAt`. */ + selectCommandOutputRange(range: GhosttySelectionRange["screen"]): void { this.selectionEnd = null; this.selectionMode = "cell"; this.selectionBase = null; this.selectionRectangle = false; - this.selectionAnchorScreen = screen.start; - this.selectionEndScreen = screen.end; + this.selectionAnchorScreen = range.start; + this.selectionEndScreen = range.end; + this.core.setSelection({ ...range.start, tag: 2 }, { ...range.end, tag: 2 }); this.options.onSelectionChange(); this.forceFullRender = true; this.requestRender(); - return true; } clearSelection(): void { @@ -1448,11 +1464,12 @@ export class GhosttyTerminalSurface { */ private extendSelectionByKey(move: TerminalSelectionKeyMove): void { const state = this.readScrollbarState(); + const cursor = terminalCursorViewportPoint(this.snapshot); + // No selection and no on-screen cursor means there is nothing to extend + // from; anchoring anywhere else would select a region the user never chose. const origin = this.selectionEndScreen ?? - (this.snapshot === null - ? null - : this.core.viewportPointToScreen(this.snapshot.cursorX, this.snapshot.cursorY)); + (cursor === null ? null : this.core.viewportPointToScreen(cursor.x, cursor.y)); if (origin === null) return; if (this.selectionAnchorScreen === null) { this.selectionAnchorScreen = origin; From 116455d00468f52dc396e68713e8ea932b193d99 Mon Sep 17 00:00:00 2001 From: Marcos Lopez Date: Mon, 17 Aug 2026 02:22:32 -0600 Subject: [PATCH 3/6] fix(web): ignore clicks outside the grid when offering command output A right-click in the canvas padding or the slack below the grid resolved to the nearest cell, so an OSC 133 output along that edge could be offered and selected even though the click landed outside the terminal. The probe now hit tests exactly, the way link hovering already does, and reports no command for a click that is on no cell. Drag selection keeps clamping, which is what a drag past the edge wants. Written by Claude Opus 5 (1M context) via Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/terminal/ghostty/surface.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 04f6a94b5a58..4b34af6a0ce0 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -1019,7 +1019,20 @@ export class GhosttyTerminalSurface { * coordinates stay pinned to their content, so the range survives that. */ commandOutputRangeAt(clientX: number, clientY: number): GhosttySelectionRange["screen"] | null { - const cell = this.cellAt(clientX, clientY); + // Exact hit testing, not the clamping `cellAt` a drag wants: a click in the + // padding or the slack below the grid lands on no cell, and offering the + // nearest row's command there would act on output nobody pointed at. + const cell = terminalGridCellAt({ + bounds: this.canvas.getBoundingClientRect(), + clientX, + clientY, + cols: this.cols, + rows: this.rows, + metrics: this.metrics, + padding: CONTENT_PADDING, + originY: this.originY, + }); + if (cell === null) return null; return this.core.outputRangeAt(cell.x, cell.y); } From d9087fe7bda559b6a956dbd6dcdbce62d35cb936 Mon Sep 17 00:00:00 2001 From: Marcos Lopez Date: Mon, 17 Aug 2026 02:33:27 -0600 Subject: [PATCH 4/6] fix(web): void a captured command output when the buffer is replaced A right-click captures the command output under the pointer and applies it after the menu answers. A full buffer replace in that window (a session replay, not an append) repoints every screen coordinate at new content, and nothing cancelled the pending action: the drawer clears the selection on a buffer update, but shouldClearTerminalSelectionAction only cancels for the popup path, so the context menu's request id never moved and the stale range was applied to unrelated rows. Captures now carry the buffer generation they were taken from and are dropped when it no longer matches. Appends leave the generation alone, since those coordinates stay pinned to their rows. Written by Claude Opus 5 (1M context) via Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/terminal/ghostty/surface.ts | 30 ++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 4b34af6a0ce0..171460650b0f 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -575,6 +575,16 @@ export function terminalSelectionOvershoot( return 0; } +/** + * A command output resolved at one point in time, stamped with the buffer it + * came from so a later apply can tell whether that buffer is still the one on + * screen. + */ +export interface TerminalCommandOutputCapture { + readonly range: GhosttySelectionRange["screen"]; + readonly generation: number; +} + export interface GhosttySelectionPosition { readonly start: { readonly x: number; readonly y: number }; readonly end: { readonly x: number; readonly y: number }; @@ -636,6 +646,7 @@ export class GhosttyTerminalSurface { private selectionEndScreen: { x: number; y: number } | null = null; private selectionMode: "cell" | "word" | "line" = "cell"; private selectionRectangle = false; + private bufferGeneration = 0; // Word/line selection base in screen coordinates so streaming output cannot // shift the origin of a drag selection. private selectionBase: { @@ -792,6 +803,10 @@ export class GhosttyTerminalSurface { resetAndWrite(data: string): void { if (this.disposed) return; this.core.resetAndWrite(data); + // Replacing the buffer repoints every screen coordinate at new content, so + // anything captured against the old one is void. Appends do not: those + // coordinates stay pinned to the rows they named. + this.bufferGeneration += 1; // A replayed session starts from the visible phase like any other write: // reattaching mid-blink must not open on an invisible cursor. this.cursorOn = true; @@ -1018,7 +1033,7 @@ export class GhosttyTerminalSurface { * puts a different command under the same point by the time it does. Screen * coordinates stay pinned to their content, so the range survives that. */ - commandOutputRangeAt(clientX: number, clientY: number): GhosttySelectionRange["screen"] | null { + commandOutputRangeAt(clientX: number, clientY: number): TerminalCommandOutputCapture | null { // Exact hit testing, not the clamping `cellAt` a drag wants: a click in the // padding or the slack below the grid lands on no cell, and offering the // nearest row's command there would act on output nobody pointed at. @@ -1033,11 +1048,18 @@ export class GhosttyTerminalSurface { originY: this.originY, }); if (cell === null) return null; - return this.core.outputRangeAt(cell.x, cell.y); + const range = this.core.outputRangeAt(cell.x, cell.y); + return range === null ? null : { range, generation: this.bufferGeneration }; } - /** Selects a command output range captured earlier by `commandOutputRangeAt`. */ - selectCommandOutputRange(range: GhosttySelectionRange["screen"]): void { + /** + * Selects a command output captured earlier by `commandOutputRangeAt`, unless + * the buffer was replaced in between — a capture from a buffer that no longer + * exists would land on whatever content took those coordinates over. + */ + selectCommandOutputRange(capture: TerminalCommandOutputCapture): void { + if (capture.generation !== this.bufferGeneration) return; + const range = capture.range; this.selectionEnd = null; this.selectionMode = "cell"; this.selectionBase = null; From 7a26d2323689772bb420e386c362aff3f0193365 Mon Sep 17 00:00:00 2001 From: Marcos Lopez Date: Mon, 17 Aug 2026 02:36:43 -0600 Subject: [PATCH 5/6] fix(web): keep the terminal selection through streaming output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every buffer update cleared the selection, so any output arriving while text was selected wiped it — holding a selection on a live log was not possible. The clear dates from the xterm.js renderer (#2978), six weeks before libghostty-vt replaced it (#4860), and was carried over unchanged. Ghostty pins a selection to its content, so an append no longer needs it. Only a full buffer replace clears now, where every coordinate genuinely does repoint at new content. Written by Claude Opus 5 (1M context) via Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/ThreadTerminalDrawer.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index f759beaec7c6..4ca4ee70d392 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -958,11 +958,16 @@ export function TerminalViewport({ current.buffer.length >= previous.buffer.length && current.buffer.startsWith(previous.buffer) ) { + // An append leaves the selection alone: Ghostty pins it to its content, + // so it survives the output scrolling past. Clearing here dates from the + // xterm.js renderer, whose selection was invalidated by any write, and + // kept making a selection impossible to hold on a live log. terminal.write(current.buffer.slice(previous.buffer.length)); } else { + // A replace repoints every coordinate, so the old selection is meaningless. writeTerminalBuffer(terminal, current.buffer); + terminal.clearSelection(); } - terminal.clearSelection(); if (current.error !== null && current.error !== previous.error) { writeSystemMessage(terminal, current.error); From f24d700e1c3e6c3ff3b191c0238d72a971f476c0 Mon Sep 17 00:00:00 2001 From: Marcos Lopez Date: Mon, 17 Aug 2026 02:41:43 -0600 Subject: [PATCH 6/6] fix(web): drop cached selection coordinates when the buffer is replaced resetAndWrite bumped the buffer generation but left selectionAnchorScreen and selectionEndScreen pointing into the buffer it had just discarded, so a Shift+Arrow after a replace would resume from that stale end and select unrelated content. Only the callers happening to clear afterwards kept it from biting. The surface owns those coordinates, so it drops them itself now, and the drawer no longer clears on the caller side. Behaviour is unchanged for appends, which still keep the selection. Written by Claude Opus 5 (1M context) via Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/ThreadTerminalDrawer.tsx | 3 ++- apps/web/src/terminal/ghostty/surface.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 4ca4ee70d392..d637bb86a8d3 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -965,8 +965,9 @@ export function TerminalViewport({ terminal.write(current.buffer.slice(previous.buffer.length)); } else { // A replace repoints every coordinate, so the old selection is meaningless. + // resetAndWrite drops it, which keeps the invariant with the surface that + // owns the coordinates rather than with each caller that replaces a buffer. writeTerminalBuffer(terminal, current.buffer); - terminal.clearSelection(); } if (current.error !== null && current.error !== previous.error) { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 171460650b0f..2ecac7f30cba 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -804,9 +804,11 @@ export class GhosttyTerminalSurface { if (this.disposed) return; this.core.resetAndWrite(data); // Replacing the buffer repoints every screen coordinate at new content, so - // anything captured against the old one is void. Appends do not: those - // coordinates stay pinned to the rows they named. + // everything anchored to the old one is void: captures held by embedders, + // and the anchor a keyboard selection would resume from. Appends invalidate + // neither, since those coordinates stay pinned to the rows they named. this.bufferGeneration += 1; + this.clearSelection(); // A replayed session starts from the visible phase like any other write: // reattaching mid-blink must not open on an invisible cursor. this.cursorOn = true;