Files
zesdex/docs/superpowers/specs/2026-07-15-clipboard-osc52-design.md
T

5.4 KiB

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;<base64>\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<String>, 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<u8> 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<u8> buffer produces the exact expected \x1b]52;c;<base64>\x07 byte sequence for a known input string.