From cc9a2f783375e51a6e8d1f2f9d01d5f7e22813d1 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 7 Aug 2026 10:30:35 -0600 Subject: [PATCH] fix(desktop): make terminal output selectable (#4980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - mirror the retained canvas terminal grid into a transparent, selectable text layer - preserve the canvas renderer and terminal focus behavior for ordinary clicks - reconstruct wide and combining glyphs correctly for clipboard text ## Why Buzz Term renders output entirely on a canvas and deliberately called `preventDefault()` on viewport mouse-down, so native selection and copy could not work. A canvas has no selectable text even if that cancellation is removed. The transparent text layer stays aligned with the visible cell grid, lets WebView native selection drive drag highlighting and copy, and follows active-session switches without changing the renderer or PTY protocol. ## Validation - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 4,373 passed - pre-push `desktop-check`, `desktop-test`, and `branch-skew` hooks passed on `1f2a3f8db63f6fe36b4a28bc911aea3c5186b2b0` --------- Signed-off-by: Wes Co-authored-by: Carl --- desktop/src-tauri/Cargo.lock | 1 + .../crates/buzz-terminal/src/damage.rs | 7 + .../crates/buzz-terminal/tests/clusters.rs | 4 + desktop/src-tauri/src/terminal_runtime.rs | 15 +- desktop/src-tauri/src/terminal_transport.rs | 1 + .../terminal/TerminalSubstrate.test.mjs | 178 ++++++++++++++++++ .../features/terminal/TerminalSubstrate.tsx | 92 ++++++++- .../terminal/terminalRenderer.test.mjs | 83 ++++++++ .../src/features/terminal/terminalRenderer.ts | 113 ++++++++++- .../src/shared/styles/globals/terminal.css | 25 +++ 10 files changed, 502 insertions(+), 17 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index fbaa547a03..54eb3627e5 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1021,6 +1021,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "dirs", "getrandom 0.4.3", "hex", "nix 0.31.3", diff --git a/desktop/src-tauri/crates/buzz-terminal/src/damage.rs b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs index 0cc41e1685..c4bed4f1f3 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/damage.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs @@ -118,6 +118,10 @@ pub struct Style { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RowFrame { pub line: usize, + /// Whether this row continues onto the next screen row without a hard + /// line break. Retained separately from visual style so copy serialization + /// can reconstruct logical lines without exposing geometry flags to spans. + pub wrapped: bool, pub spans: Vec, } @@ -332,6 +336,9 @@ impl Encoder { self.hashes[line] = hash; rows.push(RowFrame { line, + wrapped: cells + .last() + .is_some_and(|cell| cell.flags.contains(Flags::WRAPLINE)), spans: spans(&cells), }); } diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs b/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs index 9486aa8742..a8bb94b02f 100644 --- a/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs +++ b/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs @@ -306,6 +306,10 @@ fn wrapping_does_not_split_a_uniform_run() { .iter() .find(|row| row.line == 0) .expect("wrapped row must be present"); + assert!( + first.wrapped, + "soft-wrap geometry must survive row encoding" + ); let texts: Vec<&str> = first.spans.iter().map(|s| s.text.as_str()).collect(); assert_eq!( texts, diff --git a/desktop/src-tauri/src/terminal_runtime.rs b/desktop/src-tauri/src/terminal_runtime.rs index 87f969592d..840c0ee257 100644 --- a/desktop/src-tauri/src/terminal_runtime.rs +++ b/desktop/src-tauri/src/terminal_runtime.rs @@ -113,6 +113,7 @@ pub(crate) struct WireSpan { #[serde(rename_all = "camelCase")] pub(crate) struct WireRow { line: usize, + wrapped: bool, spans: Vec, } @@ -187,6 +188,7 @@ fn wire_publication(publication: Publication) -> Result { .collect::>>()?; Ok(WireRow { line: row.line, + wrapped: row.wrapped, spans, }) }) @@ -819,7 +821,11 @@ mod tests { subscription_id: SubscriptionId::new(), sequence: 7, frame: buzz_terminal::damage::Frame { - rows: vec![RowFrame { line: 3, spans }], + rows: vec![RowFrame { + line: 3, + wrapped: true, + spans, + }], cursor: CursorFrame { line: 1, column: 2, @@ -848,6 +854,7 @@ mod tests { Frame { rows: vec![RowFrame { line: marker, + wrapped: false, spans: Vec::new(), }], cursor: CursorFrame { @@ -922,6 +929,12 @@ mod tests { assert_post_snapshot_capture_survives_attach(publisher); } + #[test] + fn mapper_preserves_soft_wrap_metadata() { + let message = wire_publication(publication(Vec::new())).unwrap(); + assert!(message.rows[0].wrapped); + } + #[test] fn mapper_expands_ascii_runs_without_unicode_classification() { let message = wire_publication(publication(vec![Span { diff --git a/desktop/src-tauri/src/terminal_transport.rs b/desktop/src-tauri/src/terminal_transport.rs index 548cd3087a..b6f4484428 100644 --- a/desktop/src-tauri/src/terminal_transport.rs +++ b/desktop/src-tauri/src/terminal_transport.rs @@ -240,6 +240,7 @@ mod tests { Frame { rows: vec![RowFrame { line: marker, + wrapped: false, spans: Vec::new(), }], cursor: CursorFrame { diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index ef138396b9..d6113cbf08 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -558,6 +558,7 @@ function frameWith(text, generation = 1) { rows: [ { line: 0, + wrapped: false, spans: [ { style: { fg: 0, bg: 0, flags: 0 }, @@ -857,3 +858,180 @@ test("the handoff chord still toggles with the tab layer installed", async () => // Splash animation lifecycle. // // This substrate is mounted unconditionally on every route and merely + +test("mirrors the active canvas grid into a selectable plain-text layer", async () => { + const subject = fixture({ + sessionFrames: [ + { frame: frameWith("one"), sessionId: "one" }, + { frame: frameWith("two"), sessionId: "two" }, + ], + sessions: TWO_SESSIONS, + }); + await ready(subject.view); + const selectionLayer = subject.view.container.querySelector( + ".buzz-terminal-selection-layer", + ); + await waitFor(() => + assert.equal( + selectionLayer.querySelector("[data-terminal-selection-row='0']") + .textContent, + "one", + ), + ); + + subject.rerender({ sessions: SWAPPED_SESSIONS }); + await waitFor(() => + assert.equal( + selectionLayer.querySelector("[data-terminal-selection-row='0']") + .textContent, + "two", + ), + ); +}); + +test("lays out screen rows separately but copies soft wraps as one logical line", async () => { + const frame = { + cursor: { column: 0, line: 0, visible: false }, + full: true, + rows: [ + { + line: 0, + wrapped: true, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 0, text: "a", width: 1 }, + { column: 1, text: "b", width: 1 }, + { column: 2, text: "c", width: 1 }, + { column: 3, text: "d", width: 1 }, + { column: 4, text: " ", width: 1 }, + ], + }, + ], + }, + { + line: 1, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 0, text: "é", width: 1 }, + { column: 1, text: "f", width: 1 }, + ], + }, + ], + }, + ], + viewport: { columns: 5, generation: 1, screenLines: 2 }, + }; + const subject = fixture({ + sessionFrames: [{ frame, sessionId: "one" }], + }); + await ready(subject.view); + const selectionLayer = subject.view.container.querySelector( + ".buzz-terminal-selection-layer", + ); + await waitFor(() => + assert.equal( + selectionLayer.querySelectorAll("[data-terminal-selection-row]").length, + 2, + ), + ); + const rows = selectionLayer.querySelectorAll("[data-terminal-selection-row]"); + assert.equal(rows[0].textContent, "abcd "); + assert.equal(rows[1].textContent, "éf"); + + const selection = window.getSelection(); + selection.removeAllRanges(); + const range = document.createRange(); + range.setStart(rows[0].firstChild, 1); + range.setEnd(rows[1].firstChild, rows[1].textContent.length); + selection.addRange(range); + const copied = new Map(); + fireEvent.copy(rows[0].parentElement, { + clipboardData: { setData: (type, value) => copied.set(type, value) }, + }); + assert.equal(copied.get("text/plain"), "bcd éf"); +}); + +test("copy normalizes grapheme and empty-row DOM endpoints", async () => { + const frame = { + cursor: { column: 0, line: 0, visible: false }, + full: true, + rows: [ + { + line: 0, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 0, text: "😀", width: 2 }, + { column: 2, text: "é", width: 1 }, + ], + }, + ], + }, + { line: 1, wrapped: false, spans: [] }, + { + line: 2, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [{ column: 0, text: "界", width: 2 }], + }, + ], + }, + ], + viewport: { columns: 5, generation: 1, screenLines: 3 }, + }; + const subject = fixture({ sessionFrames: [{ frame, sessionId: "one" }] }); + await ready(subject.view); + const layer = subject.view.container.querySelector( + ".buzz-terminal-selection-layer", + ); + await waitFor(() => + assert.equal( + layer.querySelectorAll("[data-terminal-selection-row]").length, + 3, + ), + ); + const rows = layer.querySelectorAll("[data-terminal-selection-row]"); + const copyRange = (range) => { + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + const copied = new Map(); + fireEvent.copy(layer, { + clipboardData: { setData: (type, value) => copied.set(type, value) }, + }); + return copied.get("text/plain"); + }; + + const splitEmoji = document.createRange(); + splitEmoji.setStart(rows[0].firstChild, 1); + splitEmoji.setEnd(rows[0].firstChild, 1); + // A collapsed native selection does not dispatch custom clipboard content; + // span from the middle of the emoji into the combining cluster instead. + splitEmoji.setEnd(rows[0].firstChild, 3); + assert.equal(copyRange(splitEmoji), "😀é"); + + const throughBlank = document.createRange(); + throughBlank.setStart(rows[0], 1); + throughBlank.setEnd(rows[2], 0); + assert.equal(copyRange(throughBlank), "\n\n"); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.setBaseAndExtent(rows[2].firstChild, 1, rows[0].firstChild, 0); + const reverseCopied = new Map(); + fireEvent.copy(layer, { + clipboardData: { + setData: (type, value) => reverseCopied.set(type, value), + }, + }); + assert.equal(reverseCopied.get("text/plain"), "😀é\n\n界"); +}); diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index 81ac91528a..d8aaee2e2d 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -19,6 +19,7 @@ import { buildBannerColorTable, phaseAt } from "./terminalBannerWave"; import { TERMINAL_CELL_METRICS, type TerminalFrame, + type TerminalSelectionRow, TerminalGrid, } from "./terminalRenderer"; @@ -128,6 +129,9 @@ export function TerminalSubstrate({ ); const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz"); const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 }); + const [selectionRows, setSelectionRows] = React.useState< + readonly TerminalSelectionRow[] + >([]); const [welcomeVisible, setWelcomeVisible] = React.useState(false); const [cursorPainted, setCursorPainted] = React.useState(true); const [cursorReset, setCursorReset] = React.useState(0); @@ -457,6 +461,7 @@ export function TerminalSubstrate({ gridRef.current = activeSessionId ? (gridsRef.current.get(activeSessionId) ?? null) : null; + setSelectionRows(gridRef.current?.selectionRows() ?? []); paintTerminal(); }, [activeSessionId, cursorPainted, frames, terminalPalette]); @@ -672,17 +677,84 @@ export function TerminalSubstrate({ - {/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */} -
{ - // Preventing the canvas mousedown also suppresses selection. Revisit - // this when the terminal gains mouse selection support. - event.preventDefault(); - textareaRef.current?.focus({ preventScroll: true }); - }} - > +
+ {welcomeVisible && banner ? ( ) : null} diff --git a/desktop/src/features/terminal/terminalRenderer.test.mjs b/desktop/src/features/terminal/terminalRenderer.test.mjs index e718310f5a..97c3ca925e 100644 --- a/desktop/src/features/terminal/terminalRenderer.test.mjs +++ b/desktop/src/features/terminal/terminalRenderer.test.mjs @@ -68,6 +68,7 @@ test("clusters draw at computed columns, never accumulated text width", () => { rows: [ { line: 0, + wrapped: false, spans: [ { style: { fg: 0x01000007, bg: 0x01000101, flags: 0 }, @@ -108,6 +109,7 @@ test("combining marks stay in one cluster and consume one cell", () => { rows: [ { line: 0, + wrapped: false, spans: [ { style: { fg: 0x01000007, bg: 0x01000101, flags: 0 }, @@ -165,3 +167,84 @@ test("cursor visibility can blink without a new terminal frame", () => { grid.paint(restored, metrics, palette); assert.ok(restored.fills.some((fill) => fill[0] === 20 && fill[2] === 1.2)); }); + +test("text preserves soft-wrap spaces and hard line breaks", () => { + const grid = new TerminalGrid({ generation: 0, columns: 5, screenLines: 3 }); + grid.apply({ + viewport: grid.viewport, + full: true, + cursor: { line: 0, column: 0, visible: false }, + rows: [ + { + line: 0, + wrapped: true, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [..."abcd "].map((text, column) => ({ + column, + text, + width: 1, + })), + }, + ], + }, + { + line: 1, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 0, text: "é", width: 1 }, + { column: 1, text: "f", width: 1 }, + ], + }, + ], + }, + { + line: 2, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [{ column: 0, text: "tail", width: 1 }], + }, + ], + }, + ], + }); + + assert.equal(grid.text(), "abcd éf\ntail"); +}); + +test("selection offsets expand to complete grapheme clusters and clamp empty rows", () => { + const grid = new TerminalGrid({ generation: 0, columns: 5, screenLines: 2 }); + grid.apply({ + viewport: grid.viewport, + full: true, + cursor: { line: 0, column: 0, visible: false }, + rows: [ + { + line: 0, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 0, text: "😀", width: 2 }, + { column: 2, text: "é", width: 1 }, + ], + }, + ], + }, + { line: 1, wrapped: false, spans: [] }, + ], + }); + + assert.equal(grid.normalizeSelectionOffset(0, 1, "start"), 0); + assert.equal(grid.normalizeSelectionOffset(0, 1, "end"), 2); + assert.equal(grid.normalizeSelectionOffset(0, 3, "start"), 2); + assert.equal(grid.normalizeSelectionOffset(0, 3, "end"), 4); + assert.equal(grid.normalizeSelectionOffset(1, 1, "end"), 0); +}); diff --git a/desktop/src/features/terminal/terminalRenderer.ts b/desktop/src/features/terminal/terminalRenderer.ts index 085679408a..8b46899710 100644 --- a/desktop/src/features/terminal/terminalRenderer.ts +++ b/desktop/src/features/terminal/terminalRenderer.ts @@ -23,7 +23,11 @@ export type TerminalSpan = { clusters: readonly TerminalCluster[]; }; -export type TerminalRow = { line: number; spans: readonly TerminalSpan[] }; +export type TerminalRow = { + line: number; + wrapped: boolean; + spans: readonly TerminalSpan[]; +}; export type TerminalCursor = { line: number; column: number; @@ -52,7 +56,17 @@ export const TERMINAL_CELL_METRICS = { boldFont: '700 14px "JetBrains Mono", monospace', } as const satisfies CellMetrics; -type RetainedRow = readonly TerminalSpan[]; +export type TerminalSelectionRow = { + boundaries: readonly number[]; + line: number; + text: string; + wrapped: boolean; +}; + +type RetainedRow = { + wrapped: boolean; + spans: readonly TerminalSpan[]; +}; export type PaintContext = Pick< CanvasRenderingContext2D, @@ -120,7 +134,10 @@ export class TerminalGrid { constructor(viewport: TerminalViewport) { this.#viewport = viewport; - this.#rows = Array.from({ length: viewport.screenLines }, () => []); + this.#rows = Array.from({ length: viewport.screenLines }, () => ({ + wrapped: false, + spans: [], + })); this.markAllDirty(); } @@ -128,6 +145,87 @@ export class TerminalGrid { return this.#viewport; } + selectionRows(): readonly TerminalSelectionRow[] { + return this.#rows.map((row, line) => { + const cells = Array.from({ length: this.#viewport.columns }, () => " "); + for (const span of row.spans) { + for (const cluster of span.clusters) { + cells[cluster.column] = cluster.text; + for (let offset = 1; offset < cluster.width; offset++) { + cells[cluster.column + offset] = ""; + } + } + } + const text = cells.join(""); + const retainedText = row.wrapped ? text : text.trimEnd(); + const boundaries = [0]; + for (const segment of new Intl.Segmenter(undefined, { + granularity: "grapheme", + }).segment(retainedText)) { + boundaries.push(segment.index + segment.segment.length); + } + return { + boundaries, + line, + text: retainedText, + wrapped: row.wrapped, + }; + }); + } + + normalizeSelectionOffset( + rowIndex: number, + offset: number, + edge: "start" | "end", + ): number { + const row = this.selectionRows()[rowIndex]; + if (!row) return 0; + const clamped = Math.max(0, Math.min(offset, row.text.length)); + if (edge === "start") { + for (let index = row.boundaries.length - 1; index >= 0; index--) { + if (row.boundaries[index] <= clamped) return row.boundaries[index]; + } + return 0; + } + return ( + row.boundaries.find((boundary) => boundary >= clamped) ?? row.text.length + ); + } + + selectionText( + startRow: number, + startOffset: number, + endRow: number, + endOffset: number, + ): string { + const rows = this.selectionRows(); + if ( + startRow < 0 || + endRow < startRow || + endRow >= rows.length || + startOffset < 0 || + endOffset < 0 + ) { + return ""; + } + let selected = ""; + for (let index = startRow; index <= endRow; index++) { + const row = rows[index]; + const from = index === startRow ? startOffset : 0; + const to = index === endRow ? endOffset : row.text.length; + selected += row.text.slice(from, to); + if (index < endRow && !row.wrapped) selected += "\n"; + } + return selected; + } + + text(): string { + return this.selectionRows() + .map((row) => (row.wrapped ? row.text : `${row.text}\n`)) + .join("") + .replace(/\n$/, ""); + } + apply(frame: TerminalFrame): boolean { if ( frame.viewport.generation !== this.#viewport.generation || @@ -142,7 +240,7 @@ export class TerminalGrid { this.#dirty.add(this.#cursor.line); for (const row of frame.rows) { if (row.line < this.#rows.length) { - this.#rows[row.line] = row.spans; + this.#rows[row.line] = { wrapped: row.wrapped, spans: row.spans }; this.#dirty.add(row.line); } } @@ -151,7 +249,10 @@ export class TerminalGrid { resize(viewport: TerminalViewport): void { this.#viewport = viewport; - this.#rows = Array.from({ length: viewport.screenLines }, () => []); + this.#rows = Array.from({ length: viewport.screenLines }, () => ({ + wrapped: false, + spans: [], + })); this.#cursor = { line: 0, column: 0, visible: false }; this.markAllDirty(); } @@ -186,7 +287,7 @@ export class TerminalGrid { this.#viewport.columns * metrics.width, metrics.height, ); - for (const span of this.#rows[line] ?? []) { + for (const span of this.#rows[line]?.spans ?? []) { const background = resolvePackedColor( span.style.bg, palette, diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css index 26b5f42ba8..544f0ae2ef 100644 --- a/desktop/src/shared/styles/globals/terminal.css +++ b/desktop/src/shared/styles/globals/terminal.css @@ -183,6 +183,31 @@ display: block; } + .buzz-terminal-selection-layer { + bottom: 0; + color: transparent; + font: + 14px / 17px "JetBrains Mono", + monospace; + left: 1.25rem; + margin: 0; + overflow: hidden; + position: absolute; + right: 1.25rem; + top: 0.5rem; + user-select: text; + white-space: pre; + } + + .buzz-terminal-selection-layer > div { + height: 17px; + } + + .buzz-terminal-selection-layer::selection { + background: hsl(var(--accent) / 0.65); + color: transparent; + } + .buzz-terminal-welcome { inset: 0; pointer-events: none;