From 1f2a3f8db63f6fe36b4a28bc911aea3c5186b2b0 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 17:50:25 -0600 Subject: [PATCH 1/5] fix(desktop): make terminal output selectable Mirror the retained canvas grid into a transparent text layer so native mouse selection and clipboard copy work without replacing the terminal's canvas renderer. Co-authored-by: Carl Signed-off-by: Wes --- .../terminal/TerminalSubstrate.test.mjs | 22 ++++++++++++ .../features/terminal/TerminalSubstrate.tsx | 24 +++++++------ .../terminal/terminalRenderer.test.mjs | 35 +++++++++++++++++++ .../src/features/terminal/terminalRenderer.ts | 17 +++++++++ .../src/shared/styles/globals/terminal.css | 21 +++++++++++ 5 files changed, 109 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index ef138396b9..241db9554d 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -857,3 +857,25 @@ 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.textContent.split("\n")[0], "one"), + ); + + subject.rerender({ sessions: SWAPPED_SESSIONS }); + await waitFor(() => + assert.equal(selectionLayer.textContent.split("\n")[0], "two"), + ); +}); diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index 81ac91528a..1730772f7e 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -128,6 +128,7 @@ export function TerminalSubstrate({ ); const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz"); const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 }); + const [selectionText, setSelectionText] = React.useState(""); const [welcomeVisible, setWelcomeVisible] = React.useState(false); const [cursorPainted, setCursorPainted] = React.useState(true); const [cursorReset, setCursorReset] = React.useState(0); @@ -457,6 +458,7 @@ export function TerminalSubstrate({ gridRef.current = activeSessionId ? (gridsRef.current.get(activeSessionId) ?? null) : null; + setSelectionText(gridRef.current?.text() ?? ""); paintTerminal(); }, [activeSessionId, cursorPainted, frames, terminalPalette]); @@ -672,17 +674,19 @@ 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..ab30ea11ed 100644 --- a/desktop/src/features/terminal/terminalRenderer.test.mjs +++ b/desktop/src/features/terminal/terminalRenderer.test.mjs @@ -165,3 +165,38 @@ 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 reconstructs selectable viewport content with cell alignment", () => { + const grid = new TerminalGrid({ generation: 0, columns: 8, screenLines: 2 }); + grid.apply({ + viewport: grid.viewport, + full: true, + cursor: { line: 0, column: 0, visible: false }, + rows: [ + { + line: 0, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [ + { column: 1, text: "A", width: 1 }, + { column: 3, text: "๐Ÿ˜€", width: 2 }, + { column: 6, text: "B", width: 1 }, + ], + }, + ], + }, + { + line: 1, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [{ column: 0, text: "eฬ", width: 1 }], + }, + ], + }, + ], + }); + + assert.equal(grid.text(), " A ๐Ÿ˜€ B\neฬ"); +}); diff --git a/desktop/src/features/terminal/terminalRenderer.ts b/desktop/src/features/terminal/terminalRenderer.ts index 085679408a..9bef794697 100644 --- a/desktop/src/features/terminal/terminalRenderer.ts +++ b/desktop/src/features/terminal/terminalRenderer.ts @@ -128,6 +128,23 @@ export class TerminalGrid { return this.#viewport; } + text(): string { + return this.#rows + .map((spans) => { + const cells = Array.from({ length: this.#viewport.columns }, () => " "); + for (const span of spans) { + for (const cluster of span.clusters) { + cells[cluster.column] = cluster.text; + for (let offset = 1; offset < cluster.width; offset++) { + cells[cluster.column + offset] = ""; + } + } + } + return cells.join("").trimEnd(); + }) + .join("\n"); + } + apply(frame: TerminalFrame): boolean { if ( frame.viewport.generation !== this.#viewport.generation || diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css index 26b5f42ba8..35759069bb 100644 --- a/desktop/src/shared/styles/globals/terminal.css +++ b/desktop/src/shared/styles/globals/terminal.css @@ -183,6 +183,27 @@ 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::selection { + background: hsl(var(--accent) / 0.65); + color: transparent; + } + .buzz-terminal-welcome { inset: 0; pointer-events: none; From 802965a2933750a4f1773d7710a86d21069818a4 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 18:08:31 -0600 Subject: [PATCH 2/5] fix(desktop): preserve terminal soft wraps when copying Carry terminal row wrap geometry through the retained-grid transport so selection serialization joins visual rows only when they belong to the same logical line. Co-authored-by: Carl Signed-off-by: Wes --- .../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 | 1 + .../terminal/terminalRenderer.test.mjs | 20 +++++++++-- .../src/features/terminal/terminalRenderer.ts | 35 +++++++++++++------ 7 files changed, 69 insertions(+), 14 deletions(-) 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 241db9554d..1e2e1705be 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 }, diff --git a/desktop/src/features/terminal/terminalRenderer.test.mjs b/desktop/src/features/terminal/terminalRenderer.test.mjs index ab30ea11ed..e1c6715785 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 }, @@ -166,8 +168,8 @@ test("cursor visibility can blink without a new terminal frame", () => { assert.ok(restored.fills.some((fill) => fill[0] === 20 && fill[2] === 1.2)); }); -test("text reconstructs selectable viewport content with cell alignment", () => { - const grid = new TerminalGrid({ generation: 0, columns: 8, screenLines: 2 }); +test("text preserves soft wraps and hard line breaks", () => { + const grid = new TerminalGrid({ generation: 0, columns: 8, screenLines: 3 }); grid.apply({ viewport: grid.viewport, full: true, @@ -175,6 +177,7 @@ test("text reconstructs selectable viewport content with cell alignment", () => rows: [ { line: 0, + wrapped: true, spans: [ { style: { fg: 0, bg: 0, flags: 0 }, @@ -188,6 +191,7 @@ test("text reconstructs selectable viewport content with cell alignment", () => }, { line: 1, + wrapped: false, spans: [ { style: { fg: 0, bg: 0, flags: 0 }, @@ -195,8 +199,18 @@ test("text reconstructs selectable viewport content with cell alignment", () => }, ], }, + { + line: 2, + wrapped: false, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [{ column: 0, text: "tail", width: 1 }], + }, + ], + }, ], }); - assert.equal(grid.text(), " A ๐Ÿ˜€ B\neฬ"); + assert.equal(grid.text(), " A ๐Ÿ˜€ Beฬ\ntail"); }); diff --git a/desktop/src/features/terminal/terminalRenderer.ts b/desktop/src/features/terminal/terminalRenderer.ts index 9bef794697..226ded054c 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,10 @@ export const TERMINAL_CELL_METRICS = { boldFont: '700 14px "JetBrains Mono", monospace', } as const satisfies CellMetrics; -type RetainedRow = readonly TerminalSpan[]; +type RetainedRow = { + wrapped: boolean; + spans: readonly TerminalSpan[]; +}; export type PaintContext = Pick< CanvasRenderingContext2D, @@ -120,7 +127,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(); } @@ -130,9 +140,9 @@ export class TerminalGrid { text(): string { return this.#rows - .map((spans) => { + .map((row) => { const cells = Array.from({ length: this.#viewport.columns }, () => " "); - for (const span of spans) { + 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++) { @@ -140,9 +150,11 @@ export class TerminalGrid { } } } - return cells.join("").trimEnd(); + const text = cells.join("").trimEnd(); + return row.wrapped ? text : `${text}\n`; }) - .join("\n"); + .join("") + .replace(/\n$/, ""); } apply(frame: TerminalFrame): boolean { @@ -159,7 +171,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); } } @@ -168,7 +180,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(); } @@ -203,7 +218,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, From 78e634faeece2fcbf64cf15225720b9a6d69309a Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 18:14:25 -0600 Subject: [PATCH 3/5] fix(desktop): preserve spaces at terminal soft wraps Only trim terminal padding from hard-ended rows. A boundary space on a soft-wrapped row is command data and must survive selection serialization. Co-authored-by: Carl Signed-off-by: Wes --- .../terminal/terminalRenderer.test.mjs | 21 +++++++++++-------- .../src/features/terminal/terminalRenderer.ts | 4 ++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/terminal/terminalRenderer.test.mjs b/desktop/src/features/terminal/terminalRenderer.test.mjs index e1c6715785..3e83c19811 100644 --- a/desktop/src/features/terminal/terminalRenderer.test.mjs +++ b/desktop/src/features/terminal/terminalRenderer.test.mjs @@ -168,8 +168,8 @@ test("cursor visibility can blink without a new terminal frame", () => { assert.ok(restored.fills.some((fill) => fill[0] === 20 && fill[2] === 1.2)); }); -test("text preserves soft wraps and hard line breaks", () => { - const grid = new TerminalGrid({ generation: 0, columns: 8, screenLines: 3 }); +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, @@ -181,11 +181,11 @@ test("text preserves soft wraps and hard line breaks", () => { spans: [ { style: { fg: 0, bg: 0, flags: 0 }, - clusters: [ - { column: 1, text: "A", width: 1 }, - { column: 3, text: "๐Ÿ˜€", width: 2 }, - { column: 6, text: "B", width: 1 }, - ], + clusters: [..."abcd "].map((text, column) => ({ + column, + text, + width: 1, + })), }, ], }, @@ -195,7 +195,10 @@ test("text preserves soft wraps and hard line breaks", () => { spans: [ { style: { fg: 0, bg: 0, flags: 0 }, - clusters: [{ column: 0, text: "eฬ", width: 1 }], + clusters: [ + { column: 0, text: "eฬ", width: 1 }, + { column: 1, text: "f", width: 1 }, + ], }, ], }, @@ -212,5 +215,5 @@ test("text preserves soft wraps and hard line breaks", () => { ], }); - assert.equal(grid.text(), " A ๐Ÿ˜€ Beฬ\ntail"); + assert.equal(grid.text(), "abcd eฬf\ntail"); }); diff --git a/desktop/src/features/terminal/terminalRenderer.ts b/desktop/src/features/terminal/terminalRenderer.ts index 226ded054c..d6feb8f632 100644 --- a/desktop/src/features/terminal/terminalRenderer.ts +++ b/desktop/src/features/terminal/terminalRenderer.ts @@ -150,8 +150,8 @@ export class TerminalGrid { } } } - const text = cells.join("").trimEnd(); - return row.wrapped ? text : `${text}\n`; + const text = cells.join(""); + return row.wrapped ? text : `${text.trimEnd()}\n`; }) .join("") .replace(/\n$/, ""); From 738c23739dadd645c930d3e1ad08f5963b9fbfc6 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 18:45:18 -0600 Subject: [PATCH 4/5] fix(desktop): align terminal selection rows Render one selectable DOM row per terminal screen row for accurate native hit testing, then serialize the selected cell range on copy so soft wraps remain absent from clipboard text. Co-authored-by: Carl Signed-off-by: Wes --- .../terminal/TerminalSubstrate.test.mjs | 79 ++++++++++++++++++- .../features/terminal/TerminalSubstrate.tsx | 68 ++++++++++++++-- .../src/features/terminal/terminalRenderer.ts | 67 +++++++++++++--- .../src/shared/styles/globals/terminal.css | 4 + 4 files changed, 198 insertions(+), 20 deletions(-) diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index 1e2e1705be..ca7d8c25e1 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -872,11 +872,86 @@ test("mirrors the active canvas grid into a selectable plain-text layer", async ".buzz-terminal-selection-layer", ); await waitFor(() => - assert.equal(selectionLayer.textContent.split("\n")[0], "one"), + assert.equal( + selectionLayer.querySelector("[data-terminal-selection-row='0']") + .textContent, + "one", + ), ); subject.rerender({ sessions: SWAPPED_SESSIONS }); await waitFor(() => - assert.equal(selectionLayer.textContent.split("\n")[0], "two"), + 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: "eฬ", 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, "eฬ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 eฬf"); +}); diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index 1730772f7e..c356fc27c8 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,7 +129,9 @@ export function TerminalSubstrate({ ); const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz"); const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 }); - const [selectionText, setSelectionText] = React.useState(""); + 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); @@ -458,7 +461,7 @@ export function TerminalSubstrate({ gridRef.current = activeSessionId ? (gridsRef.current.get(activeSessionId) ?? null) : null; - setSelectionText(gridRef.current?.text() ?? ""); + setSelectionRows(gridRef.current?.selectionRows() ?? []); paintTerminal(); }, [activeSessionId, cursorPainted, frames, terminalPalette]); @@ -676,17 +679,72 @@ export function TerminalSubstrate({
- + {selectionRows.map((row) => ( +
+ {row.text || "\u00a0"} +
+ ))} +
{welcomeVisible && banner ? ( ) : null} diff --git a/desktop/src/features/terminal/terminalRenderer.ts b/desktop/src/features/terminal/terminalRenderer.ts index d6feb8f632..5f10215b06 100644 --- a/desktop/src/features/terminal/terminalRenderer.ts +++ b/desktop/src/features/terminal/terminalRenderer.ts @@ -56,6 +56,12 @@ export const TERMINAL_CELL_METRICS = { boldFont: '700 14px "JetBrains Mono", monospace', } as const satisfies CellMetrics; +export type TerminalSelectionRow = { + line: number; + text: string; + wrapped: boolean; +}; + type RetainedRow = { wrapped: boolean; spans: readonly TerminalSpan[]; @@ -138,21 +144,56 @@ export class TerminalGrid { return this.#viewport; } - text(): string { - return this.#rows - .map((row) => { - 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] = ""; - } + 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(""); - return row.wrapped ? text : `${text.trimEnd()}\n`; - }) + } + const text = cells.join(""); + return { + line, + text: row.wrapped ? text : text.trimEnd(), + wrapped: row.wrapped, + }; + }); + } + + 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$/, ""); } diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css index 35759069bb..544f0ae2ef 100644 --- a/desktop/src/shared/styles/globals/terminal.css +++ b/desktop/src/shared/styles/globals/terminal.css @@ -199,6 +199,10 @@ white-space: pre; } + .buzz-terminal-selection-layer > div { + height: 17px; + } + .buzz-terminal-selection-layer::selection { background: hsl(var(--accent) / 0.65); color: transparent; From 5df9b5d5f8634e0b29339af2b9ec2ed11d2fc1bd Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 18:50:46 -0600 Subject: [PATCH 5/5] fix(desktop): normalize terminal copy boundaries Clamp native DOM range endpoints to retained grapheme boundaries before serializing clipboard text, and keep empty-row placeholder offsets within the terminal model. Co-authored-by: Carl Signed-off-by: Wes --- .../terminal/TerminalSubstrate.test.mjs | 80 +++++++++++++++++++ .../features/terminal/TerminalSubstrate.tsx | 10 +++ .../terminal/terminalRenderer.test.mjs | 31 +++++++ .../src/features/terminal/terminalRenderer.ts | 30 ++++++- 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index ca7d8c25e1..d6113cbf08 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -955,3 +955,83 @@ test("lays out screen rows separately but copies soft wraps as one logical line" }); assert.equal(copied.get("text/plain"), "bcd eฬ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: "eฬ", 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), "๐Ÿ˜€eฬ"); + + 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"), "๐Ÿ˜€eฬ\n\n็•Œ"); +}); diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index c356fc27c8..d8aaee2e2d 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -727,6 +727,16 @@ export function TerminalSubstrate({ [startIndex, endIndex] = [endIndex, startIndex]; [startOffset, endOffset] = [endOffset, startOffset]; } + startOffset = grid.normalizeSelectionOffset( + startIndex, + startOffset, + "start", + ); + endOffset = grid.normalizeSelectionOffset( + endIndex, + endOffset, + "end", + ); event.preventDefault(); event.clipboardData.setData( "text/plain", diff --git a/desktop/src/features/terminal/terminalRenderer.test.mjs b/desktop/src/features/terminal/terminalRenderer.test.mjs index 3e83c19811..97c3ca925e 100644 --- a/desktop/src/features/terminal/terminalRenderer.test.mjs +++ b/desktop/src/features/terminal/terminalRenderer.test.mjs @@ -217,3 +217,34 @@ test("text preserves soft-wrap spaces and hard line breaks", () => { assert.equal(grid.text(), "abcd eฬ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: "eฬ", 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 5f10215b06..8b46899710 100644 --- a/desktop/src/features/terminal/terminalRenderer.ts +++ b/desktop/src/features/terminal/terminalRenderer.ts @@ -57,6 +57,7 @@ export const TERMINAL_CELL_METRICS = { } as const satisfies CellMetrics; export type TerminalSelectionRow = { + boundaries: readonly number[]; line: number; text: string; wrapped: boolean; @@ -156,14 +157,41 @@ export class TerminalGrid { } } 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: row.wrapped ? text : text.trimEnd(), + 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,