From 3b686ac224db0eb24cadb6fd0149db94c6aa1854 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 10 Jul 2026 20:36:30 +0200 Subject: [PATCH] feat(coding-agent): add message copy shortcut --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/README.md | 2 + packages/coding-agent/docs/keybindings.md | 1 + packages/coding-agent/docs/usage.md | 1 + .../src/core/extensions/runner.ts | 1 + packages/coding-agent/src/core/keybindings.ts | 9 ++- .../interactive/components/tree-selector.ts | 63 +++++++++++++++---- .../src/modes/interactive/interactive-mode.ts | 15 +++++ .../coding-agent/test/tree-selector.test.ts | 23 +++++++ 9 files changed, 106 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 933c6f5c..82b38058 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `Ctrl+X` to copy the last assistant message, or the selected message in `/tree`. + ### Fixed - Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index dc69b4d2..ac176dfc 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -212,6 +212,7 @@ See `/hotkeys` for the full list. Customize via `~/.pi/agent/keybindings.json`. | Shift+Tab | Cycle thinking level | | Ctrl+O | Collapse/expand tool output | | Ctrl+T | Collapse/expand thinking blocks | +| Ctrl+X | Copy the last assistant message | ### Message Queue @@ -255,6 +256,7 @@ Use `/session` in interactive mode to see the current session ID before reusing - Search by typing, fold/unfold and jump between branches with Ctrl+←/Ctrl+→ or Alt+←/Alt+→, page with ←/→ - Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all +- Press Ctrl+X to copy the selected message - Press Shift+L to label entries as bookmarks and Shift+T to toggle label timestamps **`/fork`** - Create a new session file from a previous user message on the active branch. Opens a selector, copies the active path up to that point, and places the selected prompt in the editor for modification. diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 0bb0f492..08a58150 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -119,6 +119,7 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 | Keybinding id | Default | Description | |--------|---------|-------------| | `app.tools.expand` | `ctrl+o` | Collapse or expand tool output | +| `app.message.copy` | `ctrl+x` | Copy the last assistant message, or the selected message in `/tree` | | `app.message.followUp` | `alt+enter` | Queue follow-up message | | `app.message.dequeue` | `alt+up` | Restore queued messages to editor | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 87fc0545..49b41927 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -22,6 +22,7 @@ The editor can be replaced temporarily by built-in UI such as `/settings` or by | File reference | Type `@` to fuzzy-search project files | | Path completion | Press Tab to complete paths | | Multi-line input | Shift+Enter, or Ctrl+Enter on Windows Terminal | +| Copy response | Ctrl+X copies the last assistant message; in `/tree`, it copies the selected message | | Images | Paste with Ctrl+V, Alt+V on Windows, or drag into the terminal | | Shell command | `!command` runs and sends output to the model | | Hidden shell command | `!!command` runs without sending output to the model | diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 2a699a7d..8dcddef3 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -78,6 +78,7 @@ const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ "app.tools.expand", "app.thinking.toggle", "app.editor.external", + "app.message.copy", "app.message.followUp", "tui.input.submit", "tui.select.confirm", diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 8231d9ef..0e0bd443 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -23,6 +23,7 @@ export interface AppKeybindings { "app.thinking.toggle": true; "app.session.toggleNamedFilter": true; "app.editor.external": true; + "app.message.copy": true; "app.message.followUp": true; "app.message.dequeue": true; "app.clipboard.pasteImage": true; @@ -95,6 +96,10 @@ export const KEYBINDINGS = { defaultKeys: "ctrl+g", description: "Open external editor", }, + "app.message.copy": { + defaultKeys: "ctrl+x", + description: "Copy message to clipboard", + }, "app.message.followUp": { defaultKeys: "alt+enter", description: "Queue follow-up message", @@ -112,11 +117,11 @@ export const KEYBINDINGS = { "app.session.fork": { defaultKeys: [], description: "Fork current session" }, "app.session.resume": { defaultKeys: [], description: "Resume a session" }, "app.tree.foldOrUp": { - defaultKeys: ["ctrl+left", "alt+left"], + defaultKeys: process.platform === "darwin" ? ["alt+left", "ctrl+left"] : ["ctrl+left", "alt+left"], description: "Fold tree branch or move up", }, "app.tree.unfoldOrDown": { - defaultKeys: ["ctrl+right", "alt+right"], + defaultKeys: process.platform === "darwin" ? ["alt+right", "ctrl+right"] : ["ctrl+right", "alt+right"], description: "Unfold tree branch or move down", }, "app.tree.editLabel": { diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 8bcdc2fb..f70e2060 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -122,6 +122,7 @@ class TreeList implements Component { public onSelect?: (entryId: string) => void; public onCancel?: () => void; + public onCopy?: (text: string | undefined) => void; public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void; constructor( @@ -623,6 +624,11 @@ class TreeList implements Component { return this.filteredNodes[this.selectedIndex]?.node; } + copySelected(): void { + const node = this.getSelectedNode(); + this.onCopy?.(node ? this.getEntryCopyText(node) : undefined); + } + updateNodeLabel(entryId: string, label: string | undefined, labelTimestamp?: string): void { for (const flatNode of this.flatNodes) { if (flatNode.node.entry.id === entryId) { @@ -871,19 +877,49 @@ class TreeList implements Component { } private extractContent(content: unknown): string { - const maxLen = 200; - if (typeof content === "string") return content.slice(0, maxLen); - if (Array.isArray(content)) { - let result = ""; - for (const c of content) { - if (typeof c === "object" && c !== null && "type" in c && c.type === "text") { - result += (c as { text: string }).text; - if (result.length >= maxLen) return result.slice(0, maxLen); - } + return this.extractFullContent(content).slice(0, 200); + } + + private extractFullContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + + let result = ""; + for (const block of content) { + if (typeof block === "object" && block !== null && "type" in block && block.type === "text") { + result += (block as { text: string }).text; } - return result; } - return ""; + return result; + } + + private getEntryCopyText(node: SessionTreeNode): string | undefined { + const entry = node.entry; + let text: string | undefined; + + switch (entry.type) { + case "message": + if (entry.message.role === "bashExecution") { + text = entry.message.command; + } else if ("content" in entry.message) { + text = this.extractFullContent(entry.message.content); + if (!text && entry.message.role === "assistant") { + text = entry.message.errorMessage; + } + } + break; + case "custom_message": + text = this.extractFullContent(entry.content); + break; + case "compaction": + text = entry.summary; + break; + case "branch_summary": + text = entry.summary; + break; + } + + return text?.trim() ? text : undefined; } private hasTextContent(content: unknown): boolean { @@ -990,6 +1026,8 @@ class TreeList implements Component { if (selected && this.onSelect) { this.onSelect(selected.node.entry.id); } + } else if (kb.matches(keyData, "app.message.copy")) { + this.copySelected(); } else if (kb.matches(keyData, "tui.select.cancel")) { if (this.searchQuery) { this.searchQuery = ""; @@ -1180,6 +1218,7 @@ const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: b { keys: ["tui.select.up", "tui.select.down"], label: "move" }, { keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" }, { keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" }, + { keys: ["app.message.copy"], label: "copy" }, { keys: ["app.tree.editLabel"], label: "label" }, { keys: ["app.tree.toggleLabelTimestamp"], label: "label time" }, { @@ -1292,6 +1331,7 @@ export class TreeSelectorComponent extends Container implements Focusable { private labelInputContainer: Container; private treeContainer: Container; private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void; + public onCopy?: (text: string | undefined) => void; // Focusable implementation - propagate to labelInput when active for IME cursor positioning private _focused = false; @@ -1324,6 +1364,7 @@ export class TreeSelectorComponent extends Container implements Focusable { this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode); this.treeList.onSelect = onSelect; this.treeList.onCancel = onCancel; + this.treeList.onCopy = (text) => this.onCopy?.(text); this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel); this.treeContainer = new Container(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 605bfbf1..1d6864e1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2583,6 +2583,7 @@ export class InteractiveMode { this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion()); this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility()); this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor()); + this.defaultEditor.onAction("app.message.copy", () => void this.handleCopyCommand()); this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp()); this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue()); this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand()); @@ -4708,6 +4709,18 @@ export class InteractiveMode { initialSelectedId, initialFilterMode, ); + selector.onCopy = async (text) => { + if (!text) { + this.showError("Selected entry has no text to copy"); + return; + } + try { + await copyToClipboard(text); + this.showStatus("Copied selected message to clipboard"); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + }; return { component: selector, focus: selector }; }); } @@ -5735,6 +5748,7 @@ export class InteractiveMode { const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle"); const externalEditor = this.getAppKeyDisplay("app.editor.external"); const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward"); + const copyMessage = this.getAppKeyDisplay("app.message.copy"); const followUp = this.getAppKeyDisplay("app.message.followUp"); const dequeue = this.getAppKeyDisplay("app.message.dequeue"); const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage"); @@ -5778,6 +5792,7 @@ export class InteractiveMode { | \`${expandTools}\` | Toggle tool output expansion | | \`${toggleThinking}\` | Toggle thinking block visibility | | \`${externalEditor}\` | Edit message in external editor | +| \`${copyMessage}\` | Copy last assistant message | | \`${followUp}\` | Queue follow-up message | | \`${dequeue}\` | Restore queued messages | | \`${pasteImage}\` | Paste image from clipboard | diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 4281b986..9dc750d5 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -264,6 +264,7 @@ describe("TreeSelectorComponent", () => { const plainLines = selector.render(30).map(stripVTControlCharacters); const plain = plainLines.join("\n"); expect(plain).toContain("branch"); + expect(plain).toContain("copy"); expect(plain).toContain("filters"); expect(plain).toContain("cycle"); expect(plain).toContain("label time"); @@ -272,6 +273,28 @@ describe("TreeSelectorComponent", () => { }); }); + describe("copy", () => { + test("copies the full selected message with ctrl+x", () => { + const message = `${"long message ".repeat(30)}\nsecond line`; + const tree = buildTree([userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", message)]); + const selector = new TreeSelectorComponent( + tree, + "asst-1", + 24, + () => {}, + () => {}, + ); + let copied: string | undefined; + selector.onCopy = (text) => { + copied = text; + }; + + selector.handleInput("\x18"); + + expect(copied).toBe(message); + }); + }); + describe("label timestamps", () => { test("toggles label timestamps for labeled nodes", () => { const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];