fix(cli): restored copy code and cmd shortcuts

Fixes #3893
This commit is contained in:
roboomp
2026-06-30 11:15:54 +00:00
parent 5bc68f57cd
commit ea47fb0efa
4 changed files with 136 additions and 2 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `/copy code` and `/copy cmd` being treated as normal prompts instead of copying the latest code or command block. ([#3893](https://github.com/can1357/oh-my-pi/issues/3893))
## [16.2.8] - 2026-06-30
### Added
@@ -118,6 +118,18 @@ export function extractCodeBlocks(text: string): CodeBlock[] {
.map(b => ({ lang: b.lang, code: b.code }));
}
/** Walk the transcript backwards for the most recent fenced assistant code block. */
export function extractLastCodeBlock(messages: readonly AgentMessage[]): CodeBlock | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
const text = assistantText(msg);
if (!text) continue;
const blocks = extractCodeBlocks(text);
if (blocks.length > 0) return blocks[blocks.length - 1];
}
return undefined;
}
/** Extract `>`-quoted blocks from assistant markdown, in document order. */
export function extractQuoteBlocks(text: string): QuoteBlock[] {
return extractBlocks(text)
@@ -27,6 +27,7 @@ import { resolveMemoryBackend } from "../memory-backend";
import { describeLoopLimitRuntime } from "../modes/loop-limit";
import { theme } from "../modes/theme/theme";
import type { InteractiveModeContext } from "../modes/types";
import { extractLastCodeBlock, extractLastCommand } from "../modes/utils/copy-targets";
import type { AgentSession, FreshSessionResult } from "../session/agent-session";
import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
import { resolveResumableSession } from "../session/session-listing";
@@ -34,6 +35,7 @@ import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
import { expandTilde, resolveToCwd } from "../tools/path-utils";
import { urlHyperlinkAlways } from "../tui";
import { getChangelogPath, parseChangelog } from "../utils/changelog";
import { copyToClipboard } from "../utils/clipboard";
import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
import { buildContextReportText } from "./helpers/context-report";
import { formatDuration } from "./helpers/format";
@@ -841,8 +843,39 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
{
name: "copy",
description: "Pick text or code from the conversation to copy",
handleTui: (_command, runtime) => {
runtime.ctx.showCopySelector();
allowArgs: true,
handleTui: async (command, runtime) => {
const arg = command.args.trim().toLowerCase();
if (!arg) {
runtime.ctx.showCopySelector();
runtime.ctx.editor.setText("");
return;
}
if (arg === "code") {
const block = extractLastCodeBlock(runtime.ctx.session.messages);
if (!block) {
runtime.ctx.showStatus("No code block to copy.");
runtime.ctx.editor.setText("");
return;
}
await copyToClipboard(block.code);
runtime.ctx.showStatus("Copied code block to clipboard");
runtime.ctx.editor.setText("");
return;
}
if (arg === "cmd" || arg === "command") {
const lastCommand = extractLastCommand(runtime.ctx.session.messages);
if (!lastCommand) {
runtime.ctx.showStatus("No command to copy.");
runtime.ctx.editor.setText("");
return;
}
await copyToClipboard(lastCommand.code);
runtime.ctx.showStatus(`Copied ${lastCommand.kind === "bash" ? "bash command" : "eval code"} to clipboard`);
runtime.ctx.editor.setText("");
return;
}
runtime.ctx.showStatus("Usage: /copy [code|cmd]");
runtime.ctx.editor.setText("");
},
},
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, spyOn, vi } from "bun:test";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry";
import * as clipboard from "@oh-my-pi/pi-coding-agent/utils/clipboard";
function assistantText(text: string): AgentMessage {
return { role: "assistant", content: [{ type: "text", text }] } as unknown as AgentMessage;
}
function assistantCalls(toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>): AgentMessage {
return {
role: "assistant",
content: toolCalls.map((tc, i) => ({ type: "toolCall", id: `tc-${i}`, name: tc.name, arguments: tc.arguments })),
} as unknown as AgentMessage;
}
function createRuntimeHarness(messages: AgentMessage[]) {
const setText = vi.fn();
const showStatus = vi.fn();
const showWarning = vi.fn();
const showCopySelector = vi.fn();
return {
setText,
showStatus,
showWarning,
showCopySelector,
runtime: {
ctx: {
session: { messages },
editor: { setText },
showStatus,
showWarning,
showCopySelector,
} as unknown as InteractiveModeContext,
},
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("/copy slash command", () => {
it("copies the last assistant code block without opening the picker", async () => {
const copySpy = spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined);
const harness = createRuntimeHarness([
assistantText("old\n```ts\nconst oldValue = 1;\n```"),
assistantText("new\n```sh\necho first\n```\n```py\nprint('last')\n```"),
]);
expect(await executeBuiltinSlashCommand("/copy code", harness.runtime)).toBe(true);
expect(copySpy).toHaveBeenCalledWith("print('last')");
expect(harness.showStatus).toHaveBeenCalledWith("Copied code block to clipboard");
expect(harness.showCopySelector).not.toHaveBeenCalled();
expect(harness.setText).toHaveBeenCalledWith("");
});
it("copies the last runnable command without opening the picker", async () => {
const copySpy = spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined);
const harness = createRuntimeHarness([
assistantCalls([{ name: "bash", arguments: { command: "echo old" } }]),
assistantCalls([{ name: "eval", arguments: { language: "py", code: "print(42)" } }]),
]);
expect(await executeBuiltinSlashCommand("/copy cmd", harness.runtime)).toBe(true);
expect(copySpy).toHaveBeenCalledWith("print(42)");
expect(harness.showStatus).toHaveBeenCalledWith("Copied eval code to clipboard");
expect(harness.showCopySelector).not.toHaveBeenCalled();
expect(harness.setText).toHaveBeenCalledWith("");
});
it("keeps bare /copy on the picker", async () => {
const copySpy = spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined);
const harness = createRuntimeHarness([assistantText("answer")]);
expect(await executeBuiltinSlashCommand("/copy", harness.runtime)).toBe(true);
expect(harness.showCopySelector).toHaveBeenCalledTimes(1);
expect(copySpy).not.toHaveBeenCalled();
expect(harness.setText).toHaveBeenCalledWith("");
});
});