feat(tui): add /copy to copy the last reply or transcript to the clipboard - #4359
Conversation
…board Add a `/copy` slash command to the terminal TUI: - `/copy` copies the last assistant reply (plain text) to the system clipboard. - `/copy all` copies the whole conversation (user + assistant turns). Clipboard writes use the OSC 52 escape sequence via `terminal.write()`, so copy works across SSH and needs no `pbcopy`/`xclip`/`wl-copy` binary; under tmux the bare sequence is forwarded by `set-clipboard on` (no DCS passthrough, which is off by default). The command is refused mid-turn — copying a half-streamed reply would silently hand back a partial message — skips empty assistant entries, and collapses an assistant turn's internal steps into one block in `/copy all`. Confirmations are localized (en/zh); an empty transcript shows a notice instead of writing an empty clipboard. New pure helpers `tui-clipboard.ts` (OSC 52) and `tui-copy-command.ts` (text extraction + i18n) are unit tested, plus end-to-end runner tests for idle copy and mid-turn refusal. Closes apache#4358 Generated-by: Claude Code
8f1be9a to
66778f3
Compare
Astro-Han
left a comment
There was a problem hiding this comment.
Thanks — reviewed 66778f3a. Approving; one P3 below, not blocking.
OSC 52 is the right mechanism here, and the header comment earns its length: choosing the bare sequence over tmux DCS passthrough because allow-passthrough is off by default, preferring BEL over ST, and being explicit that the protocol has no acknowledgement are all things the next reader would otherwise have to rediscover. Base64-encoding the payload also closes the injection question by construction — no assistant text can terminate the sequence early or emit escapes of its own, which was my first concern.
Refusing /copy mid-turn is the right default: the last assistant entry during streaming is the half-written one, and handing that back silently would be worse than refusing. Skipping text-less assistant entries so a tool-only or aborted turn doesn't mask the real reply is the kind of case that usually surfaces as a bug report later.
P3 — the payload is unbounded, and the stated rationale doesn't cover the realistic failure. /copy all serializes the whole transcript into one OSC 52 sequence with no cap. The comment weighs silent truncation against "a terminal that doesn't support OSC 52 is a no-op", but there is a third outcome: a terminal whose OSC string limit is exceeded mid-sequence terminates the escape early and renders the remaining base64 as literal text. xterm's maxStringParm has historically defaulted to around 1 KB, and most emulators sit somewhere between a few KB and 100 KB, so an ordinary long conversation can reach it. The result is tens of thousands of base64 characters dumped into a running TUI — neither a clean drop nor a partial copy, and it needs a redraw to recover.
The fix is the same thing the comment already wants: cap the payload and refuse explicitly when the transcript exceeds it. An error the user can read is the "clearer failure" the rationale is arguing for; an unbounded sequence is a bet on the terminal's parser.
Evidence boundary: I read the clipboard module, the copy command, and the transcript serialization on this head. I did not run the TUI or test against a terminal, so the threshold above is from emulator defaults rather than a reproduction.
AI-assisted review: drafted with Maka; I verified the base64 encoding, the mid-turn refusal, and the absence of any size bound against the branch source myself.
Astro-Han
left a comment
There was a problem hiding this comment.
Follow-up on my approval — I went back over 66778f3a adversarially, and the first thing it turned up is that the P3 I filed was mechanically wrong. Correction and three findings inline; my approval predates all of it.
What I got wrong: I claimed a terminal exceeding its OSC string limit terminates the escape early and renders the remaining base64 as literal text, and I cited xterm's maxStringParm defaulting to ~1 KB. Neither holds. kitty logs OSC sequence too long, truncating and discards; st before 0.8.3 returns once its buffer fills, also discarding. The xterm resource is maxStringParse, not maxStringParm, and xterm accepts at least 1 MB of OSC 52 data. I found no implementation that echoes the tail to the screen. Sorry for sending you after the wrong failure mode.
The finding survives the correction and gets worse, because the real behaviour is silent truncation — which is exactly what the module comment argues cannot happen. Details inline.
Evidence boundary: I read the five production files on this head, the slash dispatch and every handler's midTurn, the transcript entry construction in pi-transcript.ts, the existing clipboard authority in packages/ui, and issue #4358. I ran the tmux and terminfo checks quoted below on my own machine (tmux 3.7c, macOS) and executed this PR's serializeTranscriptText with its types stripped to produce the outputs below. I did not run the test suite, and the kitty/Tabby/st byte thresholds are from their own issue trackers rather than my own measurement.
On the privacy question I expected to raise and did not: /copy all serializes only user and assistant prose, never tool output, and chat-turn.tsx:317 already copies message bodies with { redact: false } while redaction is reserved for tool output. This PR is consistent with that, so there is nothing to fix.
AI-assisted review: drafted with Maka; I verified the tmux defaults, the terminfo Ms entries, and the serializer outputs on my own machine, and traced the midTurn precedent and the transcript entry construction against the branch source myself.
…laims, split user turns Addresses review on apache#4359: - Cap OSC 52 writes at MAX_CLIPBOARD_TEXT_BYTES (16 KiB of UTF-8) and refuse above it with a readable error. Terminals silently *discard* an oversized OSC-string payload (kitty truncates and keeps a prefix, st returns once its buffer fills) rather than cleanly dropping it, so the previous "we never truncate silently" rationale was wrong. copyToClipboard now returns a result and the handler surfaces a `tooLarge` notice. - Reword the copy confirmation as best-effort. An OSC 52 write is fire-and-forget with no acknowledgement and does not land on a default tmux (`set-clipboard external` ignores it; forwarding needs `set-clipboard on` plus an `Ms` terminfo capability) or on Terminal.app / GNU screen, so the message no longer claims the copy succeeded. The header's tmux reasoning is corrected. - serializeTranscriptText: collapse only consecutive assistant steps so every user turn opens its own block — queued steering, or two user turns split by a skipped text-less assistant entry, no longer merge — and serialize goal_continuation / legacy_automation as user turns instead of dropping them. Tests: clipboard cap/refusal and UTF-8 byte measurement; serializer adjacency and driving-turn cases; a `/copy all` runner test exercising the role labels end to end (previously uncovered). Generated-by: Claude Code
|
Thanks for going back over this adversarially — the discard-vs-echo correction is exactly right, and it's the behaviour I fixed against. P2 — tmux reasoning + confirmation message (
With a fire-and-forget, ack-less protocol that's the strongest thing the code can honestly assert. P2 — unbounded payload / silent truncation ( P2 — collapse merges separate user messages (
You were also right that Unit + runner suites green (clipboard cap/refusal + UTF-8 byte measurement; serializer adjacency and driving-turn cases; AI-assisted: changes authored with Claude Code under my review; I ran the affected suites locally against compiled |
…dex review) - Lower MAX_CLIPBOARD_TEXT_BYTES to 4 KiB so the emitted OSC 52 sequence (~5.5 KB) clears the ~8 KB OSC-string buffer of mainstream terminals like kitty, turning an oversized copy into a readable refusal on the terminals where 16 KiB would still silently truncate (16 KiB only bounded the unbounded case). - Correct the header comment: past its buffer a terminal drops the write (kitty logs `OSC sequence too long, truncating` and sets nothing) rather than keeping a usable prefix; the tooLarge notice now says the payload is dropped, not truncated. - serializeTranscriptText: goal_continuation and legacy_automation are non-user-triggered turns (TurnOrigin) that the TUI shows with provenance headers, so label them distinctly instead of as `You:`; each stays its own block, so the assistant turns around them still do not merge. - zh copy strings drop the protocol name and use full-width punctuation. Generated-by: Claude Code
# Conflicts: # packages/cli/src/__tests__/tui-copy-catalog.test.ts
|
Follow-up: pushed two commits and merged 1 — the cap was too loose. 16 KiB let through a payload whose encoded sequence (~21 KB) sails past the ~8 KB OSC-string buffer of terminals like kitty — exactly the silent-truncation the cap was meant to convert into a readable failure. Lowered 2 — the comment was inaccurate. I had written that an over-long payload leaves "a prefix"; it does not. Past its buffer the terminal drops the write (kitty logs 3 — Also merged Since this changed the code under the approved commit, a re-review would be appreciated when you have a moment, @Astro-Han. |
…board (apache#4359) The TUI had no way to get a reply out of the terminal: selecting text by mouse fights the alternate screen and line wrapping, and there was no command for it. Add `/copy`, which puts either the last reply or the whole transcript on the system clipboard through OSC 52, so it works over SSH and inside a multiplexer without a helper binary on the remote side. OSC 52 asks the terminal emulator to set the clipboard, and payload size is the sharp edge. Past a terminal's OSC-string buffer the write is dropped, not truncated to a usable prefix — kitty logs `OSC sequence too long, truncating` and sets nothing, and the escape sequence itself never reaches the screen, so the failure is silent. Buffers vary widely, so the only honest option is to refuse: `MAX_CLIPBOARD_TEXT_BYTES` is 4 KiB, sized so the emitted sequence (base64 at ~4/3 plus 8 bytes of framing, ~5.5 KB) clears the ~8 KB buffer of mainstream terminals. An oversized copy becomes a readable refusal carrying the byte count and the limit. The bare sequence is the right primitive under tmux, but it does not land on a default one: `set-clipboard` has defaulted to `external` since tmux 2.6, which ignores an application setting a tmux buffer, and forwarding also needs an `Ms` capability that nested tmux and the inner side of GNU screen lack. Wrapping in tmux's DCS passthrough is avoided rather than unsupported — it needs `allow-passthrough on`, off by default in modern tmux, and it bypasses tmux's own clipboard policy. The header comment records this so the next reader does not rediscover it. `serializeTranscriptText` labels `goal_continuation` and `legacy_automation` turns by their provenance instead of as `You:`, matching what the TUI displays, and keeps each as its own block so the assistant turns around them still do not merge. Generated-by: Claude Code
Summary
Add a
/copyslash command to the terminal TUI./copy— copy the last assistant reply (plain text) to the system clipboard./copy all— copy the whole conversation (user + assistant turns, with role labels).Clipboard writes use the OSC 52 escape sequence through the existing
terminal.write()path, so copy works across SSH and needs nopbcopy/xclip/wl-copybinary. Under tmux the bare sequence is forwarded byset-clipboard on(no DCS passthrough — that needsallow-passthrough on, offby default, and would silently drop the copy). It is fire-and-forget (no ack),
so a terminal that doesn't support OSC 52 (e.g. macOS Terminal.app) is a no-op.
/copyis refused mid-turn — while a turn streams, the last assistant entryis the half-written one, and copying that would silently hand back a partial
message. It skips empty assistant entries (a tool-only/aborted turn, or durable
recovery, can leave a text-less entry that would otherwise mask the real reply),
and collapses an assistant turn's internal steps into one block in
/copy all.Confirmations are localized (en/zh); an empty transcript shows a notice.
Fixes #4358
Files
packages/cli/src/tui-clipboard.ts(new) — pure OSC 52 sequence builder + writer.packages/cli/src/tui-copy-command.ts(new) —lastAssistantText/serializeTranscriptText+ localized copy accessor.packages/core/src/slash-command-catalog.ts— registercopy. Thesatisfies Record<TuiSlashCommandId, …>andcommandsrecord types force thehandler and both-locale descriptions to exist.
packages/cli/src/pi-tui-runner.ts— thecopyhandler.packages/cli/src/tui-copy-catalog.ts— newcopyi18n section + descriptions.Verification
npm --workspace maka-agent run typecheck— clean.tui-clipboard.test.ts(OSC 52 bytes, base64, no tmuxpassthrough wrapper),
tui-copy-command.test.ts(last-assistant extractionincl. skipping empty entries,
/copy allserialization incl. collapsingconsecutive steps, locale resolution).
pi-tui-runner.test.ts:/copyafter a replywrites the OSC 52 payload and shows the confirmation;
/copymid-turn isrefused, not steered, and writes no clipboard sequence.
Evidence (idle
/copy,FakeTerminalasserts the base64 OSC 52 payload reachesthe terminal plus the confirmation notice — the lightest command-output proof of
a clipboard write, which a screenshot cannot show):
Note: a clean full workspace build needs the repo-pinned TypeScript 7 toolchain;
the affected suites above were run against compiled
dist/.AI use
Select exactly one:
Tool(s) and scope: Claude Code (Claude) authored the implementation, tests, and
this description under human direction and review. Affected commit carries a
Generated-by: Claude Codetrailer.Checklist
Does this PR entail a change in behavior?