Files
rdbms-playground/docs/adr/0041-copy-output-to-clipboard.md
claude@clouddev1 d0c8f9d5d2 feat: copy the output panel to the system clipboard (#11)
New app-level `copy` / `copy all` / `copy last` command (ADR-0041).
Delivery is OSC 52 *and* a best-effort native write (arboard), always
both — OSC 52 acceptance is undetectable, so a true fallback can't be
built. Payload is the panel's plain text exactly as rendered (tags,
✓/✗, box-drawing), drift-locked to render_output_line. arboard added
--no-default-features (X11-only; OSC 52 covers Wayland).

Amends ADR-0003's command registry; requirements V6.
2026-06-02 14:23:21 +00:00

9.0 KiB

ADR-0041: Copy the output panel to the system clipboard

Status

Accepted — 2026-06-02 (issue #11). Amends ADR-0003's app-command registry (adds copy). First feature to add a native clipboard dependency (arboard); builds on the ADR-0040 echo/marker and ADR-0037-Am1 tag model for the "what is on screen" definition.

Context

Filing a bug report today means terminal-selecting the relevant region of the output panel, fighting the panel border and line wrapping, and pasting the result — often with stray box characters or truncated lines. A built-in copy removes that friction and tightens the bug-report → reproduction loop, which is the stated motivation of issue #11.

The output panel is a rolling VecDeque<OutputLine> (OUTPUT_CAPACITY = 1000). Each OutputLine carries the raw text plus a kind, a mode_at_submission, optional styled_runs, and an echo status; the visible text — the [simple]/[system]/[error] tag, the running: prefix vs. the trailing / marker (ADR-0040), the de-emphasised Executing SQL: teaching prefix (ADR-0038) — is composed at render time in render_output_line, not stored in text.

Four design axes were open (the issue enumerated them). Each was escalated to the user; the answers below are the user's.

Decision

1. Command surface — copy / copy all / copy last

A new app-level command (works in both modes, sigil-free per ADR-0009), added to the ADR-0003 registry:

Form Copies
copy the entire output panel (bare form = all)
copy all the entire output panel (explicit)
copy last the most recent command's output unit

copy last is defined as from the most recent OutputKind::Echo line to the end of the buffer — i.e. that command's echo, its result body (table / plan / counts), and any teaching-echo / cascade notes. If the buffer has no echo line, there is nothing to copy.

Boundary note (accepted). App-level commands (mode, messages, copy itself) push [system] lines with no echo, so they have no clean "last command" boundary: after one, copy last reaches back to the previous DSL command's echo and bundles the intervening app-command notes (including a prior copy's own confirmation line, if present). This is inherent to echo-less app commands and is accepted — copy last targets the last executed DSL/data/SQL command, which is the bug-report case; copy all is the catch-all.

No keybinding (user's choice — typed command only). An unknown sub-word (copy foo) funnels to a friendly copy.unknown error, mirroring mode/messages.

2. Mechanism — OSC 52 and native (arboard), always both

A copy always does two things, in order:

  1. Emit an OSC 52 escape (ESC ] 52 ; c ; <base64> BEL) to the terminal. Needs no new dependency — base64 and crossterm are already present. Works over SSH (the local terminal owns the clipboard). Inside tmux the sequence is wrapped in tmux's DCS passthrough (ESC P tmux; … ESC \, every inner ESC doubled), detected via $TMUX.
  2. Attempt a native write via arboard. Reaches the local desktop clipboard reliably; a failure (e.g. a headless SSH host with no display) is silently ignored — OSC 52 has already carried the payload.

Why both, unconditionally. OSC 52 acceptance is undetectable — the terminal sends no acknowledgement, so a true "fall back when OSC 52 is unsupported" cannot be built. Doing both means at least one path delivers in every environment: local desktop (native, plus a redundant identical OSC 52 write — harmless), SSH (OSC 52), SSH-in-tmux (wrapped OSC 52). The two writes carry identical content, so there is no conflict.

3. Format — plain text, verbatim as shown

The clipboard receives the rendered logical line for each OutputLine — tag included ([simple] create table T ✓, [system] Customers, [error] …), the / marker, the Executing SQL: prefix, and box-drawing tables — joined by \n. No colour (clipboards are plain text), no Markdown conversion, no tag stripping. "As shown" means the renderer's per-line content, without the viewport's right-edge space-padding or soft-wrapping: the copied text is the full logical line (so it reflows cleanly in the paste target and carries no trailing whitespace), not the literal terminal cells. So a pasted bug report reproduces the user's screen and tells the maintainer which lines were echoes, system notes, or errors, and what each command's outcome was. Fidelity is enforced by a drift-lock test against render_output_line (see Implementation notes).

A short [system] confirmation line is appended after the copy (copy.done, "Copied N line(s) to the clipboard."); an empty target yields copy.nothing and no clipboard write.

4. arboard features — --no-default-features (X11 on Linux)

arboard is added with default features off (drops the heavy image crate; we only handle text). On Linux this is X11-only — the wayland-data-control feature was deliberately not enabled because it nearly doubles the dependency tree (~30 crates: wl-clipboard-rs, wayland-*, quick-xml, nom, petgraph), and OSC 52 already covers native-Wayland sessions (and most Wayland desktops run XWayland, so x11rb works regardless). Minimising dependency surface is the secure-by-default posture (CLAUDE.md security policy). Revisit only if a concrete native-Wayland-without-OSC-52 need appears.

Security

New dependency, so the Security-Reviewer lens applies (CLAUDE.md):

  • Maintainer/licence: arboard 3.6.1, maintained by 1Password, MIT OR Apache-2.0 (matches the project), MSRV 1.71 — the de-facto standard Rust clipboard crate.
  • Scans (against the --no-default-features lockfile): cargo audit (41 crates) → 0 vulnerabilities; osv-scanner → no issues. Re-run before signoff.
  • Feature posture: write-only. We never read the clipboard, so the OSC 52 read exfiltration vector is not in play; OSC 52/native write of the user's own visible output is benign.
  • No secrets exposure: the playground holds learning data, not credentials; copied content is whatever the user already sees.

Limitations (accepted, documented)

  • OSC 52 payload size: some terminals cap the escape length (older xterm defaults are small). A very large copy all may be truncated on the OSC 52 path in such terminals; the native path delivers the full text locally. The 1000-line buffer cap bounds the worst case.
  • OSC 52 terminal support varies and cannot be confirmed (no read-back). tmux needs set-clipboard on; screen passthrough is a different format and is not wrapped (documented gap).
  • Native over SSH writes the remote machine's clipboard (useless to the user) — which is exactly why OSC 52 runs too and the native error is ignored.

Implementation notes

  • Action::CopyToClipboard(String)update() stays pure: it builds the full text from App.output and returns the action; the runtime performs the I/O. The confirmation [system] line is pushed after the text is captured, so it is never part of the copy.
  • OutputLine::plain_text() — a theme-free helper reproducing the on-screen content (line content is theme-independent; only colour is not). A drift-lock test asserts it equals the concatenation of render_output_line(line, &theme) span contents for every line shape (pending/ok/err echo, system, error, teaching echo, styled plan, data-table row), so the copy can never silently diverge from the renderer.
  • clipboard moduleosc52_sequence(text, tmux) -> String and emit_osc52(&mut impl Write, …) are pure/injectable (unit-tested against a Vec<u8>, no terminal needed). A long-lived arboard::Clipboard is held by the runtime and created lazily on first copy (so OSC-52-only users never pay the X11 connect), then reused — required because arboard's X11 backend serves the selection from a background thread owned by the Clipboard; dropping it after each set_text would lose the contents.

Out of scope

  • Markdown / styled export (the issue's option) — plain-text was chosen; Markdown table/plan conversion is a separate effort.
  • Selection / range copy and a keybinding — typed command only.
  • OSC 52 read / paste-in — write-only; reading is the security vector and is unneeded.
  • screen passthrough wrapping — tmux only.

See also

  • ADR-0003 — the app-command registry copy joins.
  • ADR-0040 / ADR-0037 Amendment 1 — the echo / marker and the tag model that define "what is on screen".
  • ADR-0038 — the teaching-echo line shape reproduced verbatim.
  • ADR-0007 — export (the other "get data out" path; complementary).
  • Issue #11 — the report and the four escalated design axes.