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
+50 -1
View File
@@ -7,7 +7,7 @@
//! builder, help / usage references. The ast_builders match
//! against the `MatchedPath` items in declaration order.
use crate::dsl::command::{AppCommand, Command, MessagesValue, ModeValue};
use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue};
use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
Word,
@@ -37,8 +37,16 @@ fn validate_unknown_messages(value: &str) -> Result<(), ValidationError> {
})
}
fn validate_unknown_copy(value: &str) -> Result<(), ValidationError> {
Err(ValidationError {
message_key: "copy.unknown",
args: vec![("value", value.to_string())],
})
}
const UNKNOWN_MODE_VALIDATOR: IdentValidator = validate_unknown_mode;
const UNKNOWN_MESSAGES_VALIDATOR: IdentValidator = validate_unknown_messages;
const UNKNOWN_COPY_VALIDATOR: IdentValidator = validate_unknown_copy;
// --- Shapes (constants are referenced by Optional/Choice slices) --
@@ -114,6 +122,29 @@ const MESSAGES_CHOICES: &[Node] = &[
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
const MESSAGES_VALUE_OPT: Node = Node::Optional(&MESSAGES_VALUE);
// `copy [all|last]`: same shape as `messages` — known scope words are
// `Word` siblings (so they reach completion + the expected set); the
// trailing catch-all `Ident` funnels any other word into the friendly
// `copy.unknown` validator. Bare `copy` (no value) means `all`.
const COPY_CHOICES: &[Node] = &[
Node::Word(Word::keyword("all")),
Node::Word(Word::keyword("last")),
Node::Ident {
source: IdentSource::Free,
role: "copy_value",
validator: Some(UNKNOWN_COPY_VALIDATOR),
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const COPY_VALUE: Node = Node::Choice(COPY_CHOICES);
const COPY_VALUE_OPT: Node = Node::Optional(&COPY_VALUE);
const EMPTY_SEQ: Node = Node::Seq(&[]);
const SAVE_AS_OPT: Node = Node::Optional(&SAVE_AS_WORD);
@@ -202,6 +233,17 @@ fn build_messages(path: &MatchedPath, _source: &str) -> Result<Command, Validati
Ok(Command::App(AppCommand::Messages { value }))
}
fn build_copy(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
// The unknown-value branch's validator always errors, so reaching
// here means either a known scope word or a bare `copy` (= all).
let scope = if path.contains_word("last") {
CopyScope::Last
} else {
CopyScope::All
};
Ok(Command::App(AppCommand::Copy { scope }))
}
// --- Command nodes -------------------------------------------------
pub static QUIT: CommandNode = CommandNode {
@@ -287,3 +329,10 @@ pub static REDO: CommandNode = CommandNode {
ast_builder: build_redo,
help_id: Some("app.redo"),
usage_ids: &["parse.usage.redo"],};
pub static COPY: CommandNode = CommandNode {
entry: Word::keyword("copy"),
shape: COPY_VALUE_OPT,
ast_builder: build_copy,
help_id: Some("app.copy"),
usage_ids: &["parse.usage.copy"],};
+1
View File
@@ -613,6 +613,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&app::MESSAGES, CommandCategory::Simple),
(&app::UNDO, CommandCategory::Simple),
(&app::REDO, CommandCategory::Simple),
(&app::COPY, CommandCategory::Simple),
(&ddl::DROP, CommandCategory::Simple),
(&ddl::ADD, CommandCategory::Simple),
(&ddl::RENAME, CommandCategory::Simple),