feat(cli): support /copy N to copy Nth-last AI message - #4761
Conversation
Lets users grab earlier AI replies without scrolling — `/copy 2` copies the second-to-last AI message, `/copy 3 code python` extracts the last Python code block from the third-to-last, etc. Useful when the agent's final action is something low-signal (TODO update, status line) and the substantive output is one or two turns back. The arg parser strips a leading positive-integer token and treats it as a 1-based message index (1 = last AI message); the remaining tokens are passed unchanged to the existing code/LaTeX sub-selectors. `/copy code python 2` keeps its prior meaning (2nd python block in last message) because its leading token isn't a digit. Closes #4744
📋 Review SummaryThis PR extends the 🔍 General Feedback
🎯 Specific Feedback🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
…ression test
Claude Code's /copy only exposes N as an arg ("Copy Claude's last response
to clipboard (or /copy N for the Nth-latest)"); block selection happens in
a UI picker, not via command syntax. Aligning the hint with that — the
existing code/latex/<lang>/<index> sub-selectors still work but they were
never advertised in a hint before and double-numeric "[N] … [<index>]"
was confusing.
Also lock in /copy 3 code python 2 (message-index + code + lang +
within-message block-index) as a regression test, since that combo was
not previously asserted.
The `[N]` argumentHint alone is opaque — users see "[N]" in the completion menu but the description "Copy the last result or code snippet to clipboard" never says what N does. Claude Code's /copy solves this by inlining the hint in the description itself: "Copy Claude's last response to clipboard (or /copy N for the Nth-latest)". Mirror that pattern. The i18n key is the English source string, so all 9 locale files (en/zh/zh-TW/de/fr/pt/ca/ru/ja) must update both the key and the localized value to avoid orphaning translations and falling back to English. Translated each one to keep parity. Also drop "or code snippet" — code/latex sub-selection is a secondary feature documented in docs/users/features/markdown-rendering.md, and Claude Code's reference UX doesn't mention it in the description.
E2E verification with built bundleBuilt Setupnpm run build && npm run bundle
tmux new-session -d -s copy-e2e -x 200 -y 60 \
"cd /tmp/copy-e2e && node dist/cli.js --approval-mode yolo"
# Turn 1 — produce two python code blocks + a marker
> Please respond with EXACTLY this content and nothing else. Two python code blocks
then a marker line. Block 1: print("block A from turn 1"). Block 2:
print("block B from turn 1"). After both blocks add one line saying MARKER_TURN_ONE.
# Turn 2
> Respond with EXACTLY one line containing only the text: MARKER_TURN_TWO
# Turn 3
> Respond with EXACTLY one line containing only the text: MARKER_TURN_THREEBefore each echo "---SENTINEL_BEFORE---" | pbcopy
tmux send-keys -t copy-e2e "/copy <args>" Enter
sleep 3 && pbpasteResults
The "Only 4 AI messages" count is one higher than the 3 user-visible turns; this is the correct underlying count in Final terminal capture (last 30 lines)Raw clipboard logWhat this confirms
|
…ranslation Three review findings from a self-review pass: 1. Result strings hardcoded "last AI output" / "Last output copied" even when the user explicitly addressed an earlier message via /copy N. A user running `/copy 3 code` previously got "No matching code block found in the last AI output." — but they didn't ask about the last, they asked about the 3rd-last. Source label now branches on N: N=1 / no-N keep the original "last AI output" / "Last output copied" wording (tests stable); N>1 reads "AI message N". Covers the three "found in" error strings, the "contains no text to copy" branch, and the full-message success label. New tests assert the AI-message-N wording in the no-text and selector-miss cases. 2. The action handler signature still used `_args` (underscore-prefix indicates an unused parameter), but the body now reads from it via `parseLeadingMessageIndex(_args)`. Rename to `args` so the convention matches actual usage and other commands in this directory. 3. de translation `N-letzte` floats grammatically (adjective without a head noun); native speakers understand it but it reads clipped. Add the missing article: `für die N-letzte`.
| ? `AI message ${messageIndex}` | ||
| : 'the last AI output'; | ||
| const sourceLabelCapitalized = isIndexed | ||
| ? `AI message ${messageIndex}` |
There was a problem hiding this comment.
[Suggestion] When isIndexed is true, both sourceLabel and sourceLabelCapitalized evaluate to the same template literal `AI message ${messageIndex}`. The "Capitalized" suffix in the variable name is misleading since the distinction only exists in the non-indexed branch.
Consider inlining the capitalization at the one sentence-initial usage site (line 488) and dropping sourceLabelCapitalized:
| ? `AI message ${messageIndex}` | |
| const sourceLabel = isIndexed | |
| ? `AI message ${messageIndex}` | |
| : 'the last AI output'; |
Then at line 488: `${isIndexed ? `AI message ${messageIndex}` : 'Last AI output'} contains no text to copy.`
— qwen3.7-max via Qwen Code /review
| return { | ||
| type: 'message', | ||
| messageType: 'info', | ||
| content: selectedCodeBlock |
There was a problem hiding this comment.
[Suggestion] When a sub-selector succeeds with messageIndex > 1, the success message (e.g., "python code block 1 copied to the clipboard") does not indicate which AI message the content came from. Users with multiple AI messages containing similar code blocks won't know which message was the source.
Consider including the source message index in the sub-selector success label when isIndexed:
| content: selectedCodeBlock | |
| : isIndexed | |
| ? `${selectedCodeBlock.label} (from AI message ${messageIndex}) copied to the clipboard` | |
| : `${selectedCodeBlock.label} copied to the clipboard`, |
— qwen3.7-max via Qwen Code /review
Local Verification ReportPR: #4761 — CI Status
Local TSC Compilation
Notes:
Local Test Results
Verification: Same test load failure reproduced on Code Review SummaryKey changes:
Code quality:
Verdict✅ Ready to merge — All changes are clean, well-tested, CI passing. TSC errors and test load failures are all pre-existing on base branch. |
tanzhenxin
left a comment
There was a problem hiding this comment.
LGTM — clean implementation, good test coverage, and i18n/docs are complete. Backward compatibility for the existing sub-selectors is preserved. Thanks for the contribution!
One optional follow-up (non-blocking): /copy N currently counts every model turn, including textless tool-only turns. Claude Code and our internal reference index over text-bearing AI messages (skipping tool-use-only turns), so plain /copy stays useful even when the final turn is a bare tool call. Happy to handle that as a small follow-up later — not a blocker for this PR.
Summary
Extends
/copyso users can grab an earlier AI message without scrolling —/copy 2copies the second-to-last AI message,/copy 3 code pythonextracts the last Python code block from the third-to-last, and so on. This mirrors the/copy Nbehavior in Claude Code and is the motivating ask in #4744.Useful when the agent's final action is something low-signal (TODO update, status line, brief "done!") and the substantive output — code, diff, explanation — is one or two turns back.
Before / After
/copy/copy code/copy code python 2pythoncode block from last AI message/copy 2/copy 3 code pythonpythoncode block from the 3rd-last AI message/copy 0/copy 100(only 1 msg)Design
The arg parser strips a leading positive-integer token and treats it as a 1-based message index (1 = last AI message). Remaining tokens are passed unchanged to the existing code/LaTeX sub-selectors. Because leading tokens like
code,latex,mermaid, or a lang name are not digits, every existing flow (/copy code python 2,/copy mermaid 1,/copy latex inline 2,/copy code 2, …) keeps its prior meaning.docs/users/features/.Test plan
packages/cli/src/ui/commands/copyCommand.test.ts:/copy 2returns the 2nd-last AI message/copy 1equals/copy(last AI message)/copy 2 code pythonruns the code sub-selector against the 2nd-last message/copy 2 latexruns the latex sub-selector against the 2nd-last message/copy 0rejected with a friendly error/copy Nexceeding count → "Only X AI message(s) in this session." (singular + plural)/copy code python 2regression — still selects the 2nd python block from the last messagenpm test --workspace=packages/cli -- copyCommand→ 34/34 pass (26 existing + 8 new)eslinton changed files → cleanCloses #4744