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
+6
View File
@@ -129,6 +129,12 @@ pub enum Action {
/// refreshes the table list + schema cache.
Undo,
Redo,
/// Copy text to the system clipboard (the `copy` command, ADR-0041).
/// The `App` builds the full text from `App.output` (so `update`
/// stays pure); the runtime performs the I/O — emit an OSC 52 escape
/// to the terminal *and* a best-effort native write, ignoring native
/// failure (a headless host is fine; OSC 52 carried it).
CopyToClipboard(String),
/// User changed the input mode mid-session (the `mode` command).
/// The runtime records it through the worker so `project.yaml`
/// reflects the live mode and it is restored on the next open
+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
+223
View File
@@ -0,0 +1,223 @@
//! System-clipboard delivery for the `copy` command (ADR-0041).
//!
//! A copy **always does both** of two writes, because OSC 52
//! acceptance is undetectable (the terminal sends no
//! acknowledgement, so a true "fall back when OSC 52 is
//! unsupported" cannot be built):
//!
//! 1. An **OSC 52** terminal escape (`ESC ] 52 ; c ; <base64> BEL`),
//! which needs no native dependency and reaches the *local*
//! clipboard even over SSH. Inside tmux it is wrapped in tmux's
//! DCS passthrough so it reaches the outer terminal.
//! 2. A best-effort **native** write (`arboard`). A failure (e.g. a
//! headless host with no display) is ignored — OSC 52 has already
//! carried the payload.
//!
//! The two carry identical content, so on a local desktop the
//! redundant double-write is harmless.
//!
//! The pure parts ([`osc52_sequence`], [`emit_osc52`], [`deliver`])
//! are unit-tested against in-memory sinks; the native side is
//! abstracted behind [`NativeClipboard`] so [`deliver`] can be tested
//! without a display or touching the real clipboard.
use std::io::{self, Write};
use base64::Engine as _;
/// ASCII escape — opens the OSC and (doubled) the tmux passthrough.
const ESC: char = '\u{1b}';
/// ASCII bell — terminates the OSC 52 string.
const BEL: char = '\u{07}';
/// Build the OSC 52 clipboard-set escape for `text`.
///
/// When `tmux` is true the sequence is wrapped in tmux's DCS
/// passthrough (`ESC P tmux; … ESC \`), with every `ESC` in the
/// inner sequence doubled, so it survives a tmux layer (the common
/// SSH-into-tmux case). The selection parameter is `c` (clipboard).
#[must_use]
pub fn osc52_sequence(text: &str, tmux: bool) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
let inner = format!("{ESC}]52;c;{b64}{BEL}");
if tmux {
let doubled = inner.replace(ESC, &format!("{ESC}{ESC}"));
format!("{ESC}Ptmux;{doubled}{ESC}\\")
} else {
inner
}
}
/// Emit the OSC 52 sequence for `text` to `w` and flush.
pub fn emit_osc52<W: Write>(w: &mut W, text: &str, tmux: bool) -> io::Result<()> {
w.write_all(osc52_sequence(text, tmux).as_bytes())?;
w.flush()
}
/// True when running inside tmux (`$TMUX` is set).
#[must_use]
pub fn in_tmux() -> bool {
std::env::var_os("TMUX").is_some()
}
/// The native (OS) clipboard sink, abstracted so [`deliver`] is
/// testable without a real display.
pub trait NativeClipboard {
/// Write `text` to the OS clipboard, best-effort. Implementations
/// must swallow their own errors (a headless host is expected).
fn set_text(&mut self, text: &str);
}
/// Deliver `text` to the clipboard via **both** paths (ADR-0041):
/// the OSC 52 escape to `w`, then the best-effort native write.
///
/// The native write is attempted **regardless** of the OSC 52 result
/// (a stdout write error must not suppress the native path). The
/// returned `io::Result` is the OSC 52 outcome, for the caller to log.
pub fn deliver<W: Write, N: NativeClipboard>(
w: &mut W,
native: &mut N,
text: &str,
tmux: bool,
) -> io::Result<()> {
let osc = emit_osc52(w, text, tmux);
native.set_text(text);
osc
}
/// The production [`NativeClipboard`] backed by `arboard`.
///
/// The `arboard::Clipboard` is created **lazily on first use** (so a
/// session that only ever relies on OSC 52 never opens an X11
/// connection) and then **kept alive** — arboard's X11 backend serves
/// the selection from a background thread owned by the `Clipboard`, so
/// dropping it after each write would lose the contents. If
/// construction fails (no display), it stays `None` and every write is
/// a silent no-op.
#[derive(Default)]
pub struct SystemClipboard {
inner: Option<arboard::Clipboard>,
}
impl SystemClipboard {
#[must_use]
pub const fn new() -> Self {
Self { inner: None }
}
}
impl NativeClipboard for SystemClipboard {
fn set_text(&mut self, text: &str) {
if self.inner.is_none() {
match arboard::Clipboard::new() {
Ok(cb) => self.inner = Some(cb),
Err(e) => {
tracing::debug!(
error = %e,
"native clipboard unavailable; relying on OSC 52"
);
return;
}
}
}
if let Some(cb) = &mut self.inner
&& let Err(e) = cb.set_text(text)
{
tracing::debug!(error = %e, "native clipboard write failed; OSC 52 carried it");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A test double recording the last native write.
#[derive(Default)]
struct SpyClipboard {
last: Option<String>,
calls: usize,
}
impl NativeClipboard for SpyClipboard {
fn set_text(&mut self, text: &str) {
self.last = Some(text.to_owned());
self.calls += 1;
}
}
#[test]
fn osc52_sequence_is_the_canonical_escape() {
// ESC ] 52 ; c ; <base64-of-"hi"> BEL. base64("hi") = "aGk=".
let seq = osc52_sequence("hi", false);
assert_eq!(seq, "\u{1b}]52;c;aGk=\u{07}");
}
#[test]
fn osc52_base64_round_trips_the_payload() {
// Pull the base64 field out and decode it back to the input,
// proving we encode the raw UTF-8 bytes (incl. non-ASCII).
let text = "┌─┐ ✓ café";
let seq = osc52_sequence(text, false);
let b64 = seq
.strip_prefix("\u{1b}]52;c;")
.and_then(|s| s.strip_suffix('\u{07}'))
.expect("canonical OSC 52 shape");
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("valid base64");
assert_eq!(String::from_utf8(decoded).unwrap(), text);
}
#[test]
fn tmux_wraps_in_passthrough_and_doubles_the_esc() {
let plain = osc52_sequence("hi", false);
let wrapped = osc52_sequence("hi", true);
// The inner sequence has exactly one ESC (the OSC opener); the
// BEL terminator is not an ESC, so doubling touches only it.
let doubled_inner = plain.replace('\u{1b}', "\u{1b}\u{1b}");
assert_eq!(wrapped, format!("\u{1b}Ptmux;{doubled_inner}\u{1b}\\"));
// Concretely: leading `ESC ESC ]` and the trailing `ESC \`.
assert!(wrapped.starts_with("\u{1b}Ptmux;\u{1b}\u{1b}]52;c;"));
assert!(wrapped.ends_with("\u{07}\u{1b}\\"));
}
#[test]
fn emit_osc52_writes_the_sequence_to_the_sink() {
let mut buf: Vec<u8> = Vec::new();
emit_osc52(&mut buf, "hi", false).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), osc52_sequence("hi", false));
}
#[test]
fn deliver_writes_osc52_and_calls_native_once() {
let mut buf: Vec<u8> = Vec::new();
let mut spy = SpyClipboard::default();
deliver(&mut buf, &mut spy, "payload", false).unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
osc52_sequence("payload", false)
);
assert_eq!(spy.last.as_deref(), Some("payload"));
assert_eq!(spy.calls, 1, "native is attempted exactly once");
}
#[test]
fn deliver_still_calls_native_when_the_writer_errors() {
// A writer that always fails: the native path must still fire
// (OSC 52 failure must not suppress the native fallback).
struct FailWriter;
impl Write for FailWriter {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::other("nope"))
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::other("nope"))
}
}
let mut spy = SpyClipboard::default();
let res = deliver(&mut FailWriter, &mut spy, "payload", false);
assert!(res.is_err(), "the OSC 52 error is returned for logging");
assert_eq!(spy.last.as_deref(), Some("payload"), "native still fired");
}
}
+1 -1
View File
@@ -1512,7 +1512,7 @@ mod tests {
// commands in the entry-keyword set.
for expected in &[
"quit", "help", "rebuild", "save", "new", "load", "export",
"import", "mode", "messages", "undo", "redo",
"import", "mode", "messages", "undo", "redo", "copy",
] {
assert!(
cs.contains(&expected.to_string()),
+14
View File
@@ -516,6 +516,19 @@ pub enum AppCommand {
Undo,
/// Re-apply the most recently undone change, after confirmation.
Redo,
/// Copy the output panel to the system clipboard (ADR-0041).
/// `copy` / `copy all` copy the whole panel; `copy last` copies
/// the most recent command's output.
Copy { scope: CopyScope },
}
/// Which slice of the output panel `copy` targets (ADR-0041).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CopyScope {
/// The entire output buffer (`copy` bare, or `copy all`).
All,
/// From the most recent echo line to the end (`copy last`).
Last,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -871,6 +884,7 @@ impl Command {
AppCommand::Messages { .. } => "messages",
AppCommand::Undo => "undo",
AppCommand::Redo => "redo",
AppCommand::Copy { .. } => "copy",
},
}
}
+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),
+1 -1
View File
@@ -21,7 +21,7 @@ pub mod walker;
pub use action::ReferentialAction;
pub use command::{
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, Expr,
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
SqlForeignKey,
};
+6
View File
@@ -186,6 +186,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.app.messages", &[]),
("help.app.undo", &[]),
("help.app.redo", &[]),
("help.app.copy", &[]),
("help.ddl.create", &[]),
("help.ddl.sql_create_table", &[]),
("help.ddl.sql_drop_table", &[]),
@@ -291,6 +292,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.export", &[]),
("parse.usage.help", &[]),
("parse.usage.import", &[]),
("parse.usage.copy", &[]),
("parse.usage.load", &[]),
("parse.usage.messages", &[]),
("parse.usage.mode", &[]),
@@ -468,6 +470,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("mode.show_simple", &[]),
("mode.unknown", &["value"]),
("mode.usage", &[]),
// ---- copy (ADR-0041) ----
("copy.done", &["count"]),
("copy.nothing", &[]),
("copy.unknown", &["value"]),
// ---- DbError Display fallback ----
("db.error.invalid_value", &["detail"]),
("db.error.io", &["detail"]),
+12
View File
@@ -224,6 +224,9 @@ help:
import <zip> [as <t>] Unpack <zip> into a new project and
switch to it. <t> overrides the target
name (else taken from the zip).
copy [all|last] Copy the output panel to the system
clipboard (`copy last` copies just the
most recent command's output).
# In-app `help` command output (ADR-0024 §help_id). The
# renderer iterates the command REGISTRY and translates each
# CommandNode's `help_id` — so a newly-registered command
@@ -266,6 +269,8 @@ help:
undo — undo the last change (with confirmation)
redo: |-
redo — redo the last undone change (with confirmation)
copy: |-
copy [all|last] — copy the output panel to the clipboard (`copy last` = the most recent command)
ddl:
create: |-
create table <T> with pk [<col>(<type>), ...] — create a table
@@ -563,6 +568,7 @@ parse:
messages: "messages | messages short | messages verbose"
undo: "undo"
redo: "redo"
copy: "copy | copy all | copy last"
# ---- Pre-submit diagnostics (ADR-0027) -------------------------------
# Surfaced by the validity indicator and the hint panel before
@@ -852,6 +858,12 @@ messages:
set_verbose: "messages: verbose"
unknown: "unknown messages mode '{value}' (expected 'short' or 'verbose')"
# ---- copy (app-level command, ADR-0041) -----------------------------
copy:
done: "copied {count} line(s) to the clipboard"
nothing: "nothing to copy — the output panel is empty"
unknown: "unknown copy target '{value}' (expected 'all' or 'last')"
# ---- Database-error fallback wording + cascade summaries ------------
db:
# DbError variants — fallback Display wording for paths that
+1
View File
@@ -9,6 +9,7 @@ pub mod action;
pub mod app;
pub mod archive;
pub mod cli;
pub mod clipboard;
pub mod completion;
pub mod db;
pub mod dsl;
+20
View File
@@ -376,6 +376,12 @@ async fn run_loop(
// no wake-ups. See `IndicatorDebounce` for the decision
// logic; `app.input_indicator` mirrors it for the renderer.
let mut debounce = IndicatorDebounce::default();
// Long-lived native clipboard for the `copy` command (ADR-0041).
// Created lazily on first copy (so an OSC-52-only session never
// opens an X11 connection) and kept alive for the session — the
// X11 backend serves the selection from a thread owned by this
// handle, so it must outlive each write.
let mut native_clipboard = crate::clipboard::SystemClipboard::new();
loop {
let event = if debounce.is_armed() {
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await {
@@ -557,6 +563,20 @@ async fn run_loop(
tracing::warn!(error = %e, "could not persist input mode");
}
}
Action::CopyToClipboard(text) => {
// OSC 52 to the terminal + a best-effort native
// write (ADR-0041). Both always fire; an OSC 52
// write error is logged but never fatal.
let mut out = std::io::stdout();
if let Err(e) = crate::clipboard::deliver(
&mut out,
&mut native_clipboard,
&text,
crate::clipboard::in_tmux(),
) {
tracing::warn!(error = %e, "could not emit clipboard OSC 52 escape");
}
}
}
}
// A keystroke hides the indicator and re-arms the
+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