docs: Tambah spec desain clipboard copy via OSC52
This commit is contained in:
@@ -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;<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.
|
||||
Reference in New Issue
Block a user