feat(coding-agent): add message copy shortcut

This commit is contained in:
Armin Ronacher
2026-07-10 20:36:30 +02:00
parent 3ea064ea2a
commit 3b686ac224
9 changed files with 106 additions and 13 deletions
+4
View File
@@ -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.
+2
View File
@@ -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.
@@ -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 |
+1
View File
@@ -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 |
@@ -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",
@@ -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": {
@@ -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();
@@ -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 |
@@ -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")];