fix(desktop): make terminal output selectable (#4980)

## 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 <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-07 10:30:35 -06:00
committed by GitHub
parent 60ae74b656
commit cc9a2f7833
10 changed files with 502 additions and 17 deletions

View File

@@ -1021,6 +1021,7 @@ dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"dirs",
"getrandom 0.4.3",
"hex",
"nix 0.31.3",

View File

@@ -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<Span>,
}
@@ -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),
});
}

View File

@@ -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,

View File

@@ -113,6 +113,7 @@ pub(crate) struct WireSpan {
#[serde(rename_all = "camelCase")]
pub(crate) struct WireRow {
line: usize,
wrapped: bool,
spans: Vec<WireSpan>,
}
@@ -187,6 +188,7 @@ fn wire_publication(publication: Publication) -> Result<FrameMessage> {
.collect::<Result<Vec<_>>>()?;
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 {

View File

@@ -240,6 +240,7 @@ mod tests {
Frame {
rows: vec![RowFrame {
line: marker,
wrapped: false,
spans: Vec::new(),
}],
cursor: CursorFrame {

View File

@@ -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界");
});

View File

@@ -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({
</button>
</div>
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */}
<div
className="buzz-terminal-viewport px-5 pt-2"
onMouseDown={(event) => {
// Preventing the canvas mousedown also suppresses selection. Revisit
// this when the terminal gains mouse selection support.
event.preventDefault();
textareaRef.current?.focus({ preventScroll: true });
}}
>
<div className="buzz-terminal-viewport px-5 pt-2">
<canvas ref={canvasRef} />
<div
aria-hidden="true"
className="buzz-terminal-selection-layer"
onCopy={(event) => {
const selection = window.getSelection();
const grid = gridRef.current;
if (!selection || selection.isCollapsed || !grid) return;
const rowFor = (node: Node | null) =>
(node?.nodeType === 1
? (node as Element)
: node?.parentElement
)?.closest<HTMLElement>("[data-terminal-selection-row]");
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
if (!anchorNode || !focusNode) return;
let startRow = rowFor(anchorNode);
let endRow = rowFor(focusNode);
if (!startRow || !endRow) return;
let startIndex = Number(startRow.dataset.terminalSelectionRow);
let endIndex = Number(endRow.dataset.terminalSelectionRow);
const offsetInRow = (
row: HTMLElement,
node: Node,
offset: number,
) => {
const range = document.createRange();
range.selectNodeContents(row);
range.setEnd(node, offset);
return range.toString().length;
};
let startOffset = offsetInRow(
startRow,
anchorNode,
selection.anchorOffset,
);
let endOffset = offsetInRow(
endRow,
focusNode,
selection.focusOffset,
);
if (
startIndex > endIndex ||
(startIndex === endIndex && startOffset > endOffset)
) {
[startRow, endRow] = [endRow, startRow];
[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",
grid.selectionText(startIndex, startOffset, endIndex, endOffset),
);
}}
onMouseUp={() => {
if (window.getSelection()?.isCollapsed !== false) {
textareaRef.current?.focus({ preventScroll: true });
}
}}
>
{selectionRows.map((row) => (
<div data-terminal-selection-row={row.line} key={row.line}>
{row.text || "\u00a0"}
</div>
))}
</div>
{welcomeVisible && banner ? (
<canvas className="buzz-terminal-welcome" ref={bannerCanvasRef} />
) : null}

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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;