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:
claude@clouddev1
2026-06-02 14:23:21 +00:00
parent 1ea376be26
commit d0c8f9d5d2
25 changed files with 1203 additions and 13 deletions
+75
View File
@@ -1376,6 +1376,81 @@ mod tests {
);
}
#[test]
fn plain_text_matches_rendered_line_content() {
// ADR-0041 drift-lock: `OutputLine::plain_text()` (the `copy`
// payload) must equal the visible content `render_output_line`
// produces — the concatenation of its span texts — for every
// line shape. If the renderer changes how a line reads, this
// fails until `plain_text` is brought back in step, so the
// clipboard can never silently diverge from the screen.
let theme = Theme::dark();
let label = crate::echo::TEACHING_ECHO_LABEL;
let mut pending = OutputLine::echo("create table T", Mode::Simple);
pending.status = Some(EchoStatus::Pending);
let mut ok = OutputLine::echo("create table T", Mode::Simple);
ok.status = Some(EchoStatus::Ok);
let mut err = OutputLine::echo("insert into T values (1)", Mode::Advanced);
err.status = Some(EchoStatus::Err);
let lines = vec![
pending,
ok,
err,
OutputLine {
text: " T".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
status: None,
},
OutputLine {
text: "no such table".to_string(),
kind: OutputKind::Error,
mode_at_submission: Mode::Simple,
styled_runs: None,
status: None,
},
OutputLine {
text: format!("{label}CREATE TABLE T (id serial)"),
kind: OutputKind::TeachingEcho,
mode_at_submission: Mode::Advanced,
styled_runs: None,
status: None,
},
OutputLine::styled(
"SCAN Customers".to_string(),
OutputKind::System,
Mode::Simple,
vec![
OutputSpan {
byte_range: (0, 4),
class: OutputStyleClass::Expensive,
},
OutputSpan {
byte_range: (4, 14),
class: OutputStyleClass::Neutral,
},
],
),
];
for line in &lines {
let rendered: String = render_output_line(line, &theme)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(
line.plain_text(),
rendered,
"plain_text drifted from render for a {:?} line",
line.kind,
);
}
}
#[test]
fn category_three_prose_line_renders_all_dim() {
// ADR-0038 §6: the existing illuminating client_side notes and