From 821622e80d7f7d5732db54d8b68ce63d846dbb72 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 15 Jul 2026 04:54:07 +0700 Subject: [PATCH] docs: Tambah spec desain clipboard copy via OSC52 --- .../2026-07-15-clipboard-osc52-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-clipboard-osc52-design.md diff --git a/docs/superpowers/specs/2026-07-15-clipboard-osc52-design.md b/docs/superpowers/specs/2026-07-15-clipboard-osc52-design.md new file mode 100644 index 0000000..7e110f7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-clipboard-osc52-design.md @@ -0,0 +1,114 @@ +# Clipboard Copy via OSC52 — Design + +**Status:** Approved, pending implementation plan +**Date:** 2026-07-15 +**Scope:** `src/app/state/misc.rs`, `src/controller/input.rs`, `src/main.rs`, +`src/ipc/protocol.rs` + +## Context + +There is no clipboard support anywhere in the TUI today, and mouse capture is enabled +(`EnableMouseCapture` in `main.rs`), which in most terminal emulators suppresses native +click-drag text selection unless the user holds a modifier — making an in-app copy action +more valuable than it would be in a plain scrollback. OSC52 is a terminal escape sequence +(`\x1b]52;c;\x07`) that asks the terminal emulator itself to set the system +clipboard; it needs no OS-level clipboard library (no X11/Wayland/win32 dependency) and +the `base64` crate is already a dependency (used in `service/oauth/pkce.rs`), so no new +crate is needed for this feature. + +Key architectural constraint discovered while designing this: `controller::input::handle_key` +runs on the **daemon** process in `--daemon`/`--attach` mode (`main.rs:359`, inside +`handle_daemon_client`), not on the process that owns the user's actual terminal. A raw +`io::stdout()` write inside `handle_key` would go to the headless daemon's stdout in that +mode, not the user's terminal. The copy action therefore can't write the escape sequence +directly from `handle_key` — it has to signal intent via state, and the terminal-owning +process (single-process `run_loop_inner`, or the attach client's loop) performs the actual +write. + +## Goals + +- `Ctrl+Y` copies the most recent `Role::Assistant` message's raw text (not the rendered + markdown spans) to the system clipboard via OSC52. +- Works identically in single-process mode and in `--daemon`/`--attach` mode. +- No new dependency. + +## Non-goals + +- No native clipboard fallback (e.g. `arboard`) for terminals that don't honor OSC52 — + unsupported terminals silently swallow the escape sequence; no error surfaces to the + user beyond the optimistic "Copied to clipboard" toast (there's no ack mechanism in the + OSC52 protocol to verify the terminal actually did it). +- No copy-last-code-block variant — out of scope for this pass; the whole-message copy + covers the common case and is simple to extend later if needed. +- No mouse-drag text selection — unrelated, much larger feature; not being built here. + +## State (`misc.rs`) + +- `MiscState` gains `pub pending_clipboard_copy: Option`, initialized to `None` in + `MiscState::new()`. + +## `input.rs` + +- New top-level arm alongside the existing `Ctrl+C`/`Ctrl+D` handlers: + `KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL)`. It finds the last + message in `state.transcript_cache.messages` with `role == Role::Assistant`: + - If found: `state.misc.pending_clipboard_copy = Some(msg.content.clone())`. + - If not found: push an `Info` toast ("No assistant message to copy yet") and leave + `pending_clipboard_copy` as `None`. + - Returns `Vec::new()` — this is a direct state mutation inside `handle_key`, matching + the existing `Ctrl+S` editor-save precedent (`main.rs`'s editor branch also mutates + state/does I/O directly rather than going through an `Action`). + +## OSC52 write helper (`main.rs`) + +``` +fn write_osc52(stdout: &mut impl Write, text: &str) -> io::Result<()> { + let b64 = base64::engine::general_purpose::STANDARD.encode(text); + write!(stdout, "\x1b]52;c;{b64}\x07")?; + stdout.flush() +} +``` + +Generic over `impl Write` so both the single-process loop (writing to `io::stdout()`) and +tests (writing to a `Vec` to assert the formatted sequence) can use it without a real +terminal. + +## Single-process mode (`run_loop_inner`) + +After the existing `for action in actions { apply_action(state, action); }` block, add: + +``` +if let Some(text) = state.misc.pending_clipboard_copy.take() { + let _ = write_osc52(&mut io::stdout(), &text); + state.push_toast(Toast::new(ToastKind::Success, "Copied to clipboard".into())); +} +``` + +## Daemon/attach mode + +- `ipc/protocol.rs`: add `DaemonFrame::ClipboardCopy(String)` (alongside `StateUpdate`, + `StreamToken`, `SystemNote`, `Closed` — same `Serialize`/`Deserialize` derive). +- `handle_daemon_client` (`main.rs`): after each branch that calls `handle_key`/`apply_action` + (`KeyPress` and `Submit`, the only two that can reach the input handler), before the + existing `send_daemon_update(&mut conn, state)?;` call, add: + ``` + if let Some(text) = state.misc.pending_clipboard_copy.take() { + conn.send(&DaemonFrame::ClipboardCopy(text))?; + } + ``` +- Attach-client loop (`main.rs`, the function matching on `DaemonFrame::StateUpdate` / + `SystemNote` / `Closed` around line 573): add a `DaemonFrame::ClipboardCopy(text) => { + let _ = write_osc52(&mut io::stdout(), &text); client_state.push_toast(...); }` arm, + mirroring the existing `SystemNote` handling but performing the actual terminal write + since this process — not the daemon — owns the user's terminal. + +## Testing + +Inline `#[cfg(test)] mod tests` per CLAUDE.md convention: + +- `input.rs`: `Ctrl+Y` with a transcript containing multiple messages sets + `pending_clipboard_copy` to the *last* assistant message's content, ignoring later + user/tool messages that might follow it; with no assistant message present, it pushes + an info toast and leaves `pending_clipboard_copy` as `None`. +- `main.rs`: `write_osc52` writing into a `Vec` buffer produces the exact expected + `\x1b]52;c;\x07` byte sequence for a known input string.