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.
This commit is contained in:
@@ -72,6 +72,7 @@ explicitly; it is not heuristic. The initial canonical list is:
|
||||
| `mode` | Switch between simple and advanced (`mode simple`/`mode advanced`). |
|
||||
| `help` | Show contextual help. |
|
||||
| `hint` | Request a hint for the current input (ADR pending). |
|
||||
| `copy` | Copy the output panel to the system clipboard (`copy` / `copy all` / `copy last`; ADR-0041, issue #11). |
|
||||
| `quit` | Exit the application. |
|
||||
|
||||
This list is **definitive** and applies in both modes. Adding,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
+2
-1
@@ -8,7 +8,7 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0000 — Record architecture decisions](0000-record-architecture-decisions.md)
|
||||
- [ADR-0001 — Language and TUI framework](0001-language-and-tui-framework.md)
|
||||
- [ADR-0002 — Database engine](0002-database-engine.md)
|
||||
- [ADR-0003 — Input modes and command dispatch](0003-input-modes-and-command-dispatch.md) — the persistent `Simple`/`Advanced` mode and the `:` one-shot escape. The **startup mode is no longer always `simple`**: it is restored from the project's stored mode and overridable with `--mode` (see **ADR-0015 Amendment 1**, issue #14)
|
||||
- [ADR-0003 — Input modes and command dispatch](0003-input-modes-and-command-dispatch.md) — the persistent `Simple`/`Advanced` mode and the `:` one-shot escape. The **startup mode is no longer always `simple`**: it is restored from the project's stored mode and overridable with `--mode` (see **ADR-0015 Amendment 1**, issue #14). The app-command registry gains **`copy`** (ADR-0041, issue #11)
|
||||
- [ADR-0004 — Project file format](0004-project-file-format.md)
|
||||
- [ADR-0005 — Column type vocabulary](0005-column-type-vocabulary.md)
|
||||
- [ADR-0006 — Undo snapshots and replay log](0006-undo-snapshots-and-replay-log.md) — **Accepted**. The **replay/journal half** (U3/U4) shipped via ADR-0034; the **undo/snapshot half** (U1/U2) is settled by **Amendment 1 (2026-05-24)** and **implemented 2026-05-24** (plan: `docs/plans/20260524-adr-0006-undo-snapshots.md`; ring in `src/undo.rs`, worker hook in `src/db.rs`). Amendment 1 **supersedes the original "snapshots only before destructive operations" model**: a snapshot is taken before **every** data/schema mutation (DSL + SQL) for familiar single-step (Ctrl-Z) undo — so the confirmation collapses to *naming the one command being undone* (no db-diff). Snapshot is a **hybrid whole-project copy** — database via the online backup API **plus** `project.yaml`/`data/*.csv` as files — reconciling this ADR with ADR-0015's "text is authoritative, db is derived"; undo restores all three directly. Staged before the mutation's transaction, finalised after the db commit (preserves ADR-0015 §6 commit-db-last); rolled-back ops leave no snapshot. **Persisted** ring under `.snapshots/`, **N = 50** (raised from 10), git-ignored + export-excluded + temp-cleanup-aware. `redo` supported, **redo stack discarded on new work**. **Batch ops record one undo step** (`replay` + future batch via a Begin/EndBatch worker primitive); **`import` is outside undo** (it switches projects per ADR-0015 §11, leaving the current project untouched). A **`--no-undo` CLI flag** disables snapshotting (hardware escape hatch). Adds the `backup` feature to `rusqlite`
|
||||
@@ -46,3 +46,4 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0038 — The DSL → SQL teaching echo](0038-dsl-to-sql-teaching-echo.md) — **Accepted** (design agreed 2026-05-27; **fully implemented + verified** — every catalogue row in §7 Buckets A + B and the §6 category-3 prose round-trips per line through the advanced walker per §1, and the §4 de-emphasised styled-runs polish is wired: handoff-46 `04c8e42` shipped the channel + create-table slice, handoff-47 `90479cb` the full Bucket A expansion + a skeleton contract-gap fix (dropped per-column `DEFAULT`/`CHECK`), `275c726` the Bucket B resolved-name + multi-statement renderers (auto- and user-named `add index`, positional `drop index`, `add`/`drop relationship` in both selector forms, `drop column --cascade`, `add relationship --create-fk`), `e6ad1ae` the last category-3 line — the `change column --dont-convert` *caveat* (shortid + transform notes were already surfaced via pre-existing `client_side.*` keys), and `2aab457` the §4 styled-runs polish: a new `OutputKind::TeachingEcho` custom rendering branch (dimmed `Executing SQL:` prefix + the SQL re-lexed in advanced mode for token-class colouring, same as the input echo) plus a new `OutputStyleClass::Hint` for every cat-3 prose line — caveat *and* the existing illuminating notes, user-confirmed broader scope), **realises ADR-0030 §10** (the teaching bridge) — the Phase-5 echo **ADR-0035 §12 forward-referenced** — building on **ADR-0037** (the `SubmissionMode` gate) and **ADR-0035 Amendment 2** (standard-first dialect + `ALTER COLUMN` gap-fill). When a **DSL-form** command runs in advanced/one-shot mode, the worker emits the equivalent SQL beneath `[ok]` as a de-emphasised styled `OutputLine` (ADR-0028); the App renders it. **Defining invariant — the copy-paste contract:** every echoed line is *runnable advanced-mode SQL* (round-trip-tested: parse the echo → same-effect command; a planned "copy the echo" affordance depends on it). **Type vocabulary = the playground's own keywords** (`serial`/`shortid`/…, accepted by `from_sql_name`, decision (a)); **statement shape = the standard-first dialect** (Am2). **DML uses substituted literals, not `?`** (per-type `Value → SQL-literal`, round-trip-safe; `blob` moot — no literal syntax exists; auto-gen columns omitted to match `do_insert` + X4). **Firing reality — a DDL + `show data` feature:** in advanced mode `insert`/`update`/`delete … where` are SQL-first (`Sql*` = already SQL = nothing to echo per §10); only DSL-*only* spellings echo (DDL + `show data` + the `delete`/`update … --all-rows` fall-throughs — the latter via **ADR-0033 Amendment 4**, a bug-fix folded in here that reverses Amendment 3's `update … --all-rows` misparse). **Three-category framework** for "what happens beyond the literal SQL": **(1) engine-implementation-hiding** (the rebuild, rowid PK, non-PK `serial` MAX+1) — *never surfaced*; **(2) decomposable into advanced SQL** (`drop column --cascade`, `--create-fk` relationship) — *shown as the runnable multi-line sequence, one statement per line*; **(3) playground type-behaviour with no SQL-expressible form** (`shortid` generation — no `shortid()`; type-conversion transforms — no `USING`) — *de-emphasised prose expansion from the worker's `client_side.*` notes*. Carries the **full catalogue** (Buckets A single-statement / B resolved-name + multi-line / C no-echo) mapping every DSL-form command to its echo. OOS: reverse SQL→DSL echo (§13 OOS-5), app commands / `show table` / `explain` / `replay`, a `blob` literal, the column-level UNIQUE/CHECK drop residual (Bucket C until Am2's gap closes), and surfacing any category-1 engine internal
|
||||
- [ADR-0039 — EXPLAIN over advanced-mode SQL queries](0039-explain-over-advanced-sql.md) — **Accepted** (2026-05-27), **implemented 2026-05-30 (issue #7)**, **supersedes ADR-0030 §13 OOS-2**. Lets `explain` wrap the advanced SQL commands (`Select`/`SqlInsert`/`SqlUpdate`/`SqlDelete`, plus `with`/CTE which builds a `Select`) in addition to the DSL `ShowData`/`Update`/`Delete` it already covers (ADR-0028), running `EXPLAIN QUERY PLAN` over the validated SQL text through the existing ADR-0028 span-styled plan tree (advanced mode only; DSL `explain` unchanged in both modes). Implemented via a second `Advanced` `explain` CommandNode (`EXPLAIN_SQL`) registered under the shared `explain` entry word — reusing the established `insert`/`update`/`delete` shared-word dispatch (`decide`: SQL-first / DSL-fallback), so `explain show data …` and DSL-only `--all-rows` still reach the DSL node; rejected a `DynamicSubgrammar` mode-gate (its resolution cache key omits `mode`). `build_explain_sql` slices the inner SQL off the source (excludes `explain`) and reuses the existing SQL builders; `do_explain_plan` runs the carried text verbatim, no params. Advanced `explain update`/`delete` now route through SQL (identical plan, full SQL syntax); DSL-explain tests pinned to simple mode. Reframed OOS-2 as a *deferred* exclusion (per ADR-0000's out-of-scope discipline), not a rejection. OOS (deferred): EXPLAIN of DDL (no query plan exists)
|
||||
- [ADR-0040 — A per-command completion marker (✓/✗) replaces the `[ok]` summary line](0040-completion-marker-replaces-ok-summary.md) — **Accepted 2026-05-30 (issue #9)**, amends ADR-0014 / ADR-0028 / ADR-0019 output conventions, builds on ADR-0037's mode-tagged echo. An audit of the whole command surface found the `[ok] <verb> <subject>` summary line duplicates the echo line above it (verb+subject) everywhere; its only unique contribution is the success-vs-error signal (and `explain select` even rendered `[ok] explain` with an empty subject post-ADR-0039). Decision: drop the `[ok]` line and the symmetric `"…" failed:` prefix; the echo line gains a trailing inline **✓** (green, success) / **✗** (red, failure) — `running:` becomes a pending state that resolves to `<input> ✓/✗` on completion (status set via the existing `rfind(Echo)` lookup). Content (row counts, structure, data, plan tree, teaching echo) unchanged. Scoped to the DSL/data/SQL family that has the redundant echo+`[ok]` pair; app-command `[ok]` lines (`rebuild`/`export`/`now editing`) are payload-bearing, have no echo to mark, and stay as-is. `ok.summary` retired; `dsl.failed` reduced to the rendered reason. Broad but mechanical snapshot churn. OOS: app-command `[ok]` lines, the `[WRN]` validity indicator, and the tag colours (issue #10)
|
||||
- [ADR-0041 — Copy the output panel to the system clipboard](0041-copy-output-to-clipboard.md) — **Accepted 2026-06-02 (issue #11)**, amends ADR-0003's app-command registry (adds **`copy`** / `copy all` / `copy last`). The friction it removes: filing a bug report meant terminal-selecting the output panel and fighting wrapping/borders. New **app-level command** (sigil-free, both modes): `copy` / `copy all` copy the whole panel; `copy last` copies from the most recent echo line to the end. **Mechanism — OSC 52 *and* native (`arboard`), always both**, because OSC 52 acceptance is undetectable (no terminal ack), so a true "fall back when unsupported" can't be built: emit the OSC 52 escape (no new dep — `base64`+`crossterm`; works over SSH; tmux-passthrough-wrapped via `$TMUX`), then a best-effort native write whose failure is ignored (headless host — OSC 52 carried it); the two carry identical content. **Format — plain text verbatim as rendered** (tags, `✓`/`✗`, box-drawing) joined by `\n`, without viewport padding/wrapping; a drift-lock test pins `OutputLine::plain_text` to `render_output_line`. `arboard` added **`--no-default-features`** (drops the `image` crate; X11-only on Linux — `wayland-data-control` deliberately omitted as it ~doubles the dep tree and OSC 52 covers native-Wayland). Security: write-only, scans clean for arboard's tree (cargo audit / osv-scanner / grype), 1Password-maintained, minimal surface. OOS: Markdown export, selection/range, a keybinding, OSC 52 read, `screen` passthrough
|
||||
|
||||
@@ -385,6 +385,15 @@ since ADR-0027.)
|
||||
redisplaying schema info on demand. *(Progress: `show table
|
||||
<name>` and `show data <Table>` implemented;
|
||||
`show tables`, `show relationships`, etc. pending.)*
|
||||
- [x] **V6** Copy the output panel to the system clipboard
|
||||
(issue #11, ADR-0041). `copy` / `copy all` copy the whole
|
||||
panel; `copy last` copies the most recent command's output.
|
||||
Delivery is OSC 52 (SSH-friendly, no native dep) *plus* a
|
||||
best-effort native write (`arboard`), always both; the payload
|
||||
is the panel's plain text exactly as rendered. Removes the
|
||||
terminal-select-and-fight-wrapping friction of filing bug
|
||||
reports. (Complements V4's planned Markdown export — a
|
||||
different "get the session out" path.)
|
||||
|
||||
## Project lifecycle (per ADR-0004)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user