d0c8f9d5d2
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.
185 lines
9.0 KiB
Markdown
185 lines
9.0 KiB
Markdown
# 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` module** — `osc52_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.
|