Skip to content

feat(tui): add /copy to copy the last reply or transcript to the clipboard - #4359

Merged
Astro-Han merged 4 commits into
apache:mainfrom
liuxiaocs7:liuxiaocs7/tui-copy
Sep 1, 2026
Merged

feat(tui): add /copy to copy the last reply or transcript to the clipboard#4359
Astro-Han merged 4 commits into
apache:mainfrom
liuxiaocs7:liuxiaocs7/tui-copy

Conversation

@liuxiaocs7

@liuxiaocs7 liuxiaocs7 commented Aug 31, 2026

Copy link
Copy Markdown
Member

Summary

Add a /copy slash 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 no
pbcopy/xclip/wl-copy binary. Under tmux the bare sequence is forwarded by
set-clipboard on (no DCS passthrough — that needs allow-passthrough on, off
by 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.

/copy is refused mid-turn — while a turn streams, the last assistant entry
is 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 — register copy. The
    satisfies Record<TuiSlashCommandId, …> and commands record types force the
    handler and both-locale descriptions to exist.
  • packages/cli/src/pi-tui-runner.ts — the copy handler.
  • packages/cli/src/tui-copy-catalog.ts — new copy i18n section + descriptions.

Verification

  • npm --workspace maka-agent run typecheck — clean.
  • New unit tests: tui-clipboard.test.ts (OSC 52 bytes, base64, no tmux
    passthrough wrapper), tui-copy-command.test.ts (last-assistant extraction
    incl. skipping empty entries, /copy all serialization incl. collapsing
    consecutive steps, locale resolution).
  • Runner integration tests in pi-tui-runner.test.ts: /copy after a reply
    writes the OSC 52 payload and shows the confirmation; /copy mid-turn is
    refused, not steered, and writes no clipboard sequence.
  • Existing catalog / primary-guidance consistency suites still pass with the new command.

Evidence (idle /copy, FakeTerminal asserts the base64 OSC 52 payload reaches
the terminal plus the confirmation notice — the lightest command-output proof of
a clipboard write, which a screenshot cannot show):

✔ /copy writes the last reply to the clipboard via OSC 52
✔ /copy is refused mid-turn instead of copying the half-written reply
ℹ tests 20  pass 20  fail 0   (tui-clipboard + tui-copy-command + catalog + guidance)

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:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

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 Code trailer.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@github-actions github-actions Bot added the effort/M Under 500 readable lines label Aug 31, 2026
…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
@liuxiaocs7
liuxiaocs7 force-pushed the liuxiaocs7/tui-copy branch from 8f1be9a to 66778f3 Compare August 31, 2026 14:37

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/cli/src/tui-clipboard.ts Outdated
Comment thread packages/cli/src/tui-clipboard.ts
Comment thread packages/cli/src/tui-copy-command.ts Outdated
…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
@liuxiaocs7

Copy link
Copy Markdown
Member Author

Thanks for going back over this adversarially — the discard-vs-echo correction is exactly right, and it's the behaviour I fixed against. 4bddbf8f6 addresses all three.

P2 — tmux reasoning + confirmation message (tui-clipboard.ts)
Agreed on both halves. The header comment now states the bare sequence is dropped under the default set-clipboard external (forwarding needs set-clipboard on), that it also depends on an Ms entry in the outer terminfo — which nested tmux and the inner side of GNU screen lack, and screen only recognises OSC 52 wrapped in DCS — and keeps the note that DCS passthrough is avoided because allow-passthrough is off by default. The confirmation no longer claims success:

Sent the last reply to the terminal via OSC 52 · N chars — it reaches the clipboard only if your terminal (and tmux, if used) allows OSC 52 writes.

With a fire-and-forget, ack-less protocol that's the strongest thing the code can honestly assert.

P2 — unbounded payload / silent truncation (tui-clipboard.ts)
Agreed. copyToClipboard now returns a result and refuses above MAX_CLIPBOARD_TEXT_BYTES (16 KiB of UTF-8, measured in bytes rather than JS string length); the handler surfaces a tooLarge error notice instead of emitting. On the value: the real limits differ per terminal (kitty a few KB, Tabby ~1 KB, xterm ~1 MB) and are undetectable from here, so no single cap can guarantee a below-cap copy lands — I set it above any plausible single reply to bound the pathological whole-transcript case with a readable refusal, and paired it with the best-effort wording above rather than pretend sub-cap always succeeds. Happy to lower it if you'd rather bias toward "emitted ⇒ very likely lands" on the common terminals.

P2 — collapse merges separate user messages (tui-copy-command.ts)
Fixed, all three paths:

  • Only consecutive assistant steps collapse now; every user turn opens its own block, so queued steering (two adjacent user entries) stays two You: blocks.
  • Because a new block only ever opens on a user turn or a fresh assistant run, skipping a text-less assistant entry between two user turns no longer fuses them.
  • goal_continuation and legacy_automation — both user-authored driving turns stored as user messages — now serialize under the user label instead of being dropped, so a goal session keeps the prompts that drove it.

You were also right that /copy all had no runner coverage: added a runner test asserting the serialized transcript (the You:/Maka: labels and ordering) reaches the terminal as the OSC 52 payload, so a label swap now fails.

Unit + runner suites green (clipboard cap/refusal + UTF-8 byte measurement; serializer adjacency and driving-turn cases; /copy all end to end).


AI-assisted: changes authored with Claude Code under my review; I ran the affected suites locally against compiled dist/.

…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
@liuxiaocs7

Copy link
Copy Markdown
Member Author

Follow-up: pushed two commits and merged main. A closer self-review of the OSC 52 path turned up three corrections beyond my earlier reply.

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 MAX_CLIPBOARD_TEXT_BYTES to 4 KiB, so the emitted sequence (~5.5 KB) clears that buffer and anything larger is refused with a readable error. It still cannot guarantee delivery on a terminal with an unusually small buffer (e.g. Tabby ~1 KB) — that residual is what the best-effort confirmation now covers.

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 OSC sequence too long, truncating and sets nothing). Corrected the header, and the refusal notice now says the payload is dropped, not truncated.

3 — /copy all mislabeled provenance. I had labeled goal_continuation / legacy_automation as You:, but they are non-user-triggered (TurnOrigin in turn-origin.ts) and the TUI renders them with their own provenance headers. They now carry distinct labels ("Goal continuation (autonomous):" / "Legacy automation (history only):") and stay their own blocks, so the assistant turns on either side still do not merge.

Also merged main — the only conflict was a test-fixture union in MESSAGE_VALUES. Affected suites pass locally; CI is running.

Since this changed the code under the approved commit, a re-review would be appreciated when you have a moment, @Astro-Han.

@Astro-Han
Astro-Han merged commit 98fc505 into apache:main Sep 1, 2026
1 check passed
abhinav-phi pushed a commit to abhinav-phi/maka that referenced this pull request Sep 1, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/M Under 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(tui): add a /copy command to copy the last reply (or transcript) to the clipboard

2 participants