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
+233
View File
@@ -134,6 +134,50 @@ impl OutputLine {
status: Some(EchoStatus::Pending),
}
}
/// The plain-text form of this line *as rendered* (ADR-0041) — the
/// `[tag]`, the body, and the echo decoration (`running:` prefix or
/// trailing `✓`/`✗`) — without colour, viewport padding, or
/// wrapping. This is what the `copy` command puts on the clipboard.
///
/// It mirrors the content `render_output_line` (`ui.rs`) produces;
/// the line content is theme-independent (only colour is not), so no
/// theme is needed. A drift-lock test in `ui.rs`
/// (`plain_text_matches_rendered_line_content`) pins the two
/// together so a renderer change can't silently desync the copy.
#[must_use]
pub fn plain_text(&self) -> String {
let tag = match self.kind {
OutputKind::Echo => {
format!("[{}] ", self.mode_at_submission.label().to_lowercase())
}
OutputKind::System | OutputKind::TeachingEcho => "[system] ".to_string(),
OutputKind::Error => "[error] ".to_string(),
};
if self.kind == OutputKind::Echo {
// Pending / untracked echoes keep the `running: ` prefix;
// completed ones drop it and gain a ✓/✗ marker (ADR-0040).
let input = self
.text
.strip_prefix(crate::dsl::ECHO_PREFIX)
.unwrap_or(self.text.as_str());
let mut s = tag;
if !matches!(self.status, Some(EchoStatus::Ok | EchoStatus::Err)) {
s.push_str(crate::dsl::ECHO_PREFIX);
}
s.push_str(input);
match self.status {
Some(EchoStatus::Ok) => s.push_str(""),
Some(EchoStatus::Err) => s.push_str(""),
_ => {}
}
return s;
}
// System / Error / TeachingEcho / styled lines: the body is
// `self.text` verbatim (the teaching-echo `Executing SQL: `
// label and the styled-run slices all tile `self.text`).
format!("{tag}{}", self.text)
}
}
/// What mode the next submission would be evaluated in.
@@ -1330,9 +1374,53 @@ impl App {
}
AppCommand::Undo => self.handle_undo_command(false),
AppCommand::Redo => self.handle_undo_command(true),
AppCommand::Copy { scope } => self.handle_copy_command(scope),
}
}
/// `copy` / `copy all` / `copy last` (ADR-0041). Builds the
/// plain-text payload from the output panel and hands it to the
/// runtime via [`Action::CopyToClipboard`]; the confirmation note
/// is pushed *after* the text is captured, so it is never copied.
fn handle_copy_command(&mut self, scope: crate::dsl::CopyScope) -> Vec<Action> {
match self.copy_text(scope) {
None => {
self.note_system(crate::t!("copy.nothing"));
Vec::new()
}
Some(text) => {
let count = text.lines().count();
self.note_system(crate::t!("copy.done", count = count));
vec![Action::CopyToClipboard(text)]
}
}
}
/// The clipboard payload for `scope`: every output line's
/// [`OutputLine::plain_text`] joined by `\n`. `All` is the whole
/// buffer; `Last` is from the most recent echo line to the end
/// (ADR-0041). `None` when there is nothing to copy (empty buffer,
/// or `Last` with no echo line).
fn copy_text(&self, scope: crate::dsl::CopyScope) -> Option<String> {
let start = match scope {
crate::dsl::CopyScope::All => 0,
crate::dsl::CopyScope::Last => self
.output
.iter()
.rposition(|l| l.kind == OutputKind::Echo)?,
};
let lines: Vec<String> = self
.output
.iter()
.skip(start)
.map(OutputLine::plain_text)
.collect();
if lines.is_empty() {
return None;
}
Some(lines.join("\n"))
}
/// `undo` / `redo` dispatch. When undo is disabled (`--no-undo`)
/// the command reports that and does nothing; otherwise it asks
/// the runtime to peek the snapshot and open the confirmation
@@ -2945,6 +3033,151 @@ mod tests {
);
}
// ---- copy to clipboard (ADR-0041, issue #11) ----
/// Two output lines: a completed (`✓`) simple-mode echo and a
/// `[system]` structure line — exercises the echo decoration and
/// the tag prefix in `plain_text`.
fn app_with_two_output_lines() -> App {
let mut app = App::new();
let mut echo = OutputLine::echo("create table T", Mode::Simple);
echo.status = Some(EchoStatus::Ok);
app.output.push_back(echo);
app.output.push_back(OutputLine {
text: " T".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
status: None,
});
app
}
#[test]
fn copy_all_emits_action_with_whole_panel_and_confirms() {
let mut app = app_with_two_output_lines();
let before = app.output.len();
type_str(&mut app, "copy");
let actions = submit(&mut app);
// The payload is every line's rendered form, joined by \n —
// tag + ✓ on the echo, tag on the system line.
let expected = "[simple] create table T ✓\n[system] T";
assert_eq!(
actions,
vec![Action::CopyToClipboard(expected.to_string())],
"copy emits the whole-panel payload",
);
// A confirmation [system] line is appended — and is NOT part
// of the copied text (captured before the push).
assert_eq!(app.output.len(), before + 1, "one confirmation line added");
let note = app.output.back().unwrap();
assert_eq!(note.kind, OutputKind::System);
assert!(
note.text.contains("clipboard"),
"confirmation mentions the clipboard: {:?}",
note.text,
);
assert!(
!expected.contains("clipboard"),
"the confirmation is never in the copied text",
);
}
#[test]
fn copy_bare_is_identical_to_copy_all() {
let mut a1 = app_with_two_output_lines();
type_str(&mut a1, "copy");
let bare = submit(&mut a1);
let mut a2 = app_with_two_output_lines();
type_str(&mut a2, "copy all");
let all = submit(&mut a2);
assert_eq!(bare, all, "`copy` defaults to `copy all`");
}
#[test]
fn copy_last_slices_from_the_most_recent_echo() {
let mut app = app_with_two_output_lines();
// An earlier, unrelated command + a fresh echo with a body.
app.output.push_front(OutputLine {
text: "earlier note".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
status: None,
});
let mut echo2 = OutputLine::echo("show data T", Mode::Simple);
echo2.status = Some(EchoStatus::Ok);
app.output.push_back(echo2);
app.output.push_back(OutputLine {
text: "(0 rows)".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
status: None,
});
type_str(&mut app, "copy last");
let actions = submit(&mut app);
// Only the LAST command (its echo + body), not the earlier one.
let expected = "[simple] show data T ✓\n[system] (0 rows)";
assert_eq!(actions, vec![Action::CopyToClipboard(expected.to_string())]);
}
#[test]
fn copy_on_empty_panel_notes_nothing_and_emits_no_action() {
let mut app = App::new();
assert!(app.output.is_empty());
type_str(&mut app, "copy");
let actions = submit(&mut app);
assert!(actions.is_empty(), "nothing to copy → no clipboard action");
let note = app.output.back().expect("a 'nothing to copy' note");
assert_eq!(note.kind, OutputKind::System);
assert!(note.text.contains("nothing to copy"), "{:?}", note.text);
}
#[test]
fn copy_last_without_an_echo_notes_nothing() {
// A panel with only echo-less [system] lines has no "last
// command" boundary → nothing to copy (ADR-0041 boundary note).
let mut app = App::new();
app.output.push_back(OutputLine {
text: "just a note".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
status: None,
});
type_str(&mut app, "copy last");
let actions = submit(&mut app);
assert!(actions.is_empty());
assert!(
app.output.back().unwrap().text.contains("nothing to copy"),
"no echo → nothing to copy",
);
}
#[test]
fn copy_with_unknown_target_is_a_friendly_error() {
let mut app = app_with_two_output_lines();
type_str(&mut app, "copy sideways");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::CopyToClipboard(_))),
"an unknown target does not copy",
);
let rendered = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("unknown copy target"),
"friendly copy.unknown wording shown: {rendered}",
);
}
#[test]
fn project_switched_event_restores_the_stored_mode() {
// A switch carries the target project's stored mode; the