diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 46831be05..17d2d601c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/utils/copy-targets.ts b/packages/coding-agent/src/modes/utils/copy-targets.ts index 4e830fc27..a839edea2 100644 --- a/packages/coding-agent/src/modes/utils/copy-targets.ts +++ b/packages/coding-agent/src/modes/utils/copy-targets.ts @@ -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) diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 189967edc..62939ab46 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -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 = [ { 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(""); }, }, diff --git a/packages/coding-agent/test/slash-commands/copy.test.ts b/packages/coding-agent/test/slash-commands/copy.test.ts new file mode 100644 index 000000000..9f355252e --- /dev/null +++ b/packages/coding-agent/test/slash-commands/copy.test.ts @@ -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 }>): 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(""); + }); +});