feat(coding-agent): add message copy shortcut
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `Ctrl+X` to copy the last assistant message, or the selected message in `/tree`.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions.
|
- Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions.
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ See `/hotkeys` for the full list. Customize via `~/.pi/agent/keybindings.json`.
|
|||||||
| Shift+Tab | Cycle thinking level |
|
| Shift+Tab | Cycle thinking level |
|
||||||
| Ctrl+O | Collapse/expand tool output |
|
| Ctrl+O | Collapse/expand tool output |
|
||||||
| Ctrl+T | Collapse/expand thinking blocks |
|
| Ctrl+T | Collapse/expand thinking blocks |
|
||||||
|
| Ctrl+X | Copy the last assistant message |
|
||||||
|
|
||||||
### Message Queue
|
### 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 ←/→
|
- 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
|
- 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
|
- 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.
|
**`/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 |
|
| Keybinding id | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `app.tools.expand` | `ctrl+o` | Collapse or expand tool output |
|
| `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.followUp` | `alt+enter` | Queue follow-up message |
|
||||||
| `app.message.dequeue` | `alt+up` | Restore queued messages to editor |
|
| `app.message.dequeue` | `alt+up` | Restore queued messages to editor |
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| File reference | Type `@` to fuzzy-search project files |
|
||||||
| Path completion | Press Tab to complete paths |
|
| Path completion | Press Tab to complete paths |
|
||||||
| Multi-line input | Shift+Enter, or Ctrl+Enter on Windows Terminal |
|
| 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 |
|
| Images | Paste with Ctrl+V, Alt+V on Windows, or drag into the terminal |
|
||||||
| Shell command | `!command` runs and sends output to the model |
|
| Shell command | `!command` runs and sends output to the model |
|
||||||
| Hidden shell command | `!!command` runs without sending 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.tools.expand",
|
||||||
"app.thinking.toggle",
|
"app.thinking.toggle",
|
||||||
"app.editor.external",
|
"app.editor.external",
|
||||||
|
"app.message.copy",
|
||||||
"app.message.followUp",
|
"app.message.followUp",
|
||||||
"tui.input.submit",
|
"tui.input.submit",
|
||||||
"tui.select.confirm",
|
"tui.select.confirm",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface AppKeybindings {
|
|||||||
"app.thinking.toggle": true;
|
"app.thinking.toggle": true;
|
||||||
"app.session.toggleNamedFilter": true;
|
"app.session.toggleNamedFilter": true;
|
||||||
"app.editor.external": true;
|
"app.editor.external": true;
|
||||||
|
"app.message.copy": true;
|
||||||
"app.message.followUp": true;
|
"app.message.followUp": true;
|
||||||
"app.message.dequeue": true;
|
"app.message.dequeue": true;
|
||||||
"app.clipboard.pasteImage": true;
|
"app.clipboard.pasteImage": true;
|
||||||
@@ -95,6 +96,10 @@ export const KEYBINDINGS = {
|
|||||||
defaultKeys: "ctrl+g",
|
defaultKeys: "ctrl+g",
|
||||||
description: "Open external editor",
|
description: "Open external editor",
|
||||||
},
|
},
|
||||||
|
"app.message.copy": {
|
||||||
|
defaultKeys: "ctrl+x",
|
||||||
|
description: "Copy message to clipboard",
|
||||||
|
},
|
||||||
"app.message.followUp": {
|
"app.message.followUp": {
|
||||||
defaultKeys: "alt+enter",
|
defaultKeys: "alt+enter",
|
||||||
description: "Queue follow-up message",
|
description: "Queue follow-up message",
|
||||||
@@ -112,11 +117,11 @@ export const KEYBINDINGS = {
|
|||||||
"app.session.fork": { defaultKeys: [], description: "Fork current session" },
|
"app.session.fork": { defaultKeys: [], description: "Fork current session" },
|
||||||
"app.session.resume": { defaultKeys: [], description: "Resume a session" },
|
"app.session.resume": { defaultKeys: [], description: "Resume a session" },
|
||||||
"app.tree.foldOrUp": {
|
"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",
|
description: "Fold tree branch or move up",
|
||||||
},
|
},
|
||||||
"app.tree.unfoldOrDown": {
|
"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",
|
description: "Unfold tree branch or move down",
|
||||||
},
|
},
|
||||||
"app.tree.editLabel": {
|
"app.tree.editLabel": {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ class TreeList implements Component {
|
|||||||
|
|
||||||
public onSelect?: (entryId: string) => void;
|
public onSelect?: (entryId: string) => void;
|
||||||
public onCancel?: () => void;
|
public onCancel?: () => void;
|
||||||
|
public onCopy?: (text: string | undefined) => void;
|
||||||
public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
|
public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -623,6 +624,11 @@ class TreeList implements Component {
|
|||||||
return this.filteredNodes[this.selectedIndex]?.node;
|
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 {
|
updateNodeLabel(entryId: string, label: string | undefined, labelTimestamp?: string): void {
|
||||||
for (const flatNode of this.flatNodes) {
|
for (const flatNode of this.flatNodes) {
|
||||||
if (flatNode.node.entry.id === entryId) {
|
if (flatNode.node.entry.id === entryId) {
|
||||||
@@ -871,19 +877,49 @@ class TreeList implements Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractContent(content: unknown): string {
|
private extractContent(content: unknown): string {
|
||||||
const maxLen = 200;
|
return this.extractFullContent(content).slice(0, 200);
|
||||||
if (typeof content === "string") return content.slice(0, maxLen);
|
}
|
||||||
if (Array.isArray(content)) {
|
|
||||||
let result = "";
|
private extractFullContent(content: unknown): string {
|
||||||
for (const c of content) {
|
if (typeof content === "string") return content;
|
||||||
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
|
if (!Array.isArray(content)) return "";
|
||||||
result += (c as { text: string }).text;
|
|
||||||
if (result.length >= maxLen) return result.slice(0, maxLen);
|
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 {
|
private hasTextContent(content: unknown): boolean {
|
||||||
@@ -990,6 +1026,8 @@ class TreeList implements Component {
|
|||||||
if (selected && this.onSelect) {
|
if (selected && this.onSelect) {
|
||||||
this.onSelect(selected.node.entry.id);
|
this.onSelect(selected.node.entry.id);
|
||||||
}
|
}
|
||||||
|
} else if (kb.matches(keyData, "app.message.copy")) {
|
||||||
|
this.copySelected();
|
||||||
} else if (kb.matches(keyData, "tui.select.cancel")) {
|
} else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||||
if (this.searchQuery) {
|
if (this.searchQuery) {
|
||||||
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.select.up", "tui.select.down"], label: "move" },
|
||||||
{ keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" },
|
{ keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" },
|
||||||
{ keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" },
|
{ keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" },
|
||||||
|
{ keys: ["app.message.copy"], label: "copy" },
|
||||||
{ keys: ["app.tree.editLabel"], label: "label" },
|
{ keys: ["app.tree.editLabel"], label: "label" },
|
||||||
{ keys: ["app.tree.toggleLabelTimestamp"], label: "label time" },
|
{ keys: ["app.tree.toggleLabelTimestamp"], label: "label time" },
|
||||||
{
|
{
|
||||||
@@ -1292,6 +1331,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
|
|||||||
private labelInputContainer: Container;
|
private labelInputContainer: Container;
|
||||||
private treeContainer: Container;
|
private treeContainer: Container;
|
||||||
private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;
|
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
|
// Focusable implementation - propagate to labelInput when active for IME cursor positioning
|
||||||
private _focused = false;
|
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 = new TreeList(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode);
|
||||||
this.treeList.onSelect = onSelect;
|
this.treeList.onSelect = onSelect;
|
||||||
this.treeList.onCancel = onCancel;
|
this.treeList.onCancel = onCancel;
|
||||||
|
this.treeList.onCopy = (text) => this.onCopy?.(text);
|
||||||
this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
|
this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
|
||||||
|
|
||||||
this.treeContainer = new Container();
|
this.treeContainer = new Container();
|
||||||
|
|||||||
@@ -2583,6 +2583,7 @@ export class InteractiveMode {
|
|||||||
this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
|
this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
|
||||||
this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
|
this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
|
||||||
this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor());
|
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.followUp", () => this.handleFollowUp());
|
||||||
this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
|
this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
|
||||||
this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand());
|
this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand());
|
||||||
@@ -4708,6 +4709,18 @@ export class InteractiveMode {
|
|||||||
initialSelectedId,
|
initialSelectedId,
|
||||||
initialFilterMode,
|
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 };
|
return { component: selector, focus: selector };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5735,6 +5748,7 @@ export class InteractiveMode {
|
|||||||
const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle");
|
const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle");
|
||||||
const externalEditor = this.getAppKeyDisplay("app.editor.external");
|
const externalEditor = this.getAppKeyDisplay("app.editor.external");
|
||||||
const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward");
|
const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward");
|
||||||
|
const copyMessage = this.getAppKeyDisplay("app.message.copy");
|
||||||
const followUp = this.getAppKeyDisplay("app.message.followUp");
|
const followUp = this.getAppKeyDisplay("app.message.followUp");
|
||||||
const dequeue = this.getAppKeyDisplay("app.message.dequeue");
|
const dequeue = this.getAppKeyDisplay("app.message.dequeue");
|
||||||
const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage");
|
const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage");
|
||||||
@@ -5778,6 +5792,7 @@ export class InteractiveMode {
|
|||||||
| \`${expandTools}\` | Toggle tool output expansion |
|
| \`${expandTools}\` | Toggle tool output expansion |
|
||||||
| \`${toggleThinking}\` | Toggle thinking block visibility |
|
| \`${toggleThinking}\` | Toggle thinking block visibility |
|
||||||
| \`${externalEditor}\` | Edit message in external editor |
|
| \`${externalEditor}\` | Edit message in external editor |
|
||||||
|
| \`${copyMessage}\` | Copy last assistant message |
|
||||||
| \`${followUp}\` | Queue follow-up message |
|
| \`${followUp}\` | Queue follow-up message |
|
||||||
| \`${dequeue}\` | Restore queued messages |
|
| \`${dequeue}\` | Restore queued messages |
|
||||||
| \`${pasteImage}\` | Paste image from clipboard |
|
| \`${pasteImage}\` | Paste image from clipboard |
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ describe("TreeSelectorComponent", () => {
|
|||||||
const plainLines = selector.render(30).map(stripVTControlCharacters);
|
const plainLines = selector.render(30).map(stripVTControlCharacters);
|
||||||
const plain = plainLines.join("\n");
|
const plain = plainLines.join("\n");
|
||||||
expect(plain).toContain("branch");
|
expect(plain).toContain("branch");
|
||||||
|
expect(plain).toContain("copy");
|
||||||
expect(plain).toContain("filters");
|
expect(plain).toContain("filters");
|
||||||
expect(plain).toContain("cycle");
|
expect(plain).toContain("cycle");
|
||||||
expect(plain).toContain("label time");
|
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", () => {
|
describe("label timestamps", () => {
|
||||||
test("toggles label timestamps for labeled nodes", () => {
|
test("toggles label timestamps for labeled nodes", () => {
|
||||||
const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];
|
const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];
|
||||||
|
|||||||
Reference in New Issue
Block a user