@@ -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("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user