From b5f7e7aa2f4e5c0f5c3b28405c4414933e290bac Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 6 Aug 2026 17:47:22 +0900 Subject: [PATCH] feat(ui): add /copy command to paste the last response cleanly (#127) Adds a /copy slash command that copies the last assistant response to the system clipboard (pbcopy / xclip / wl-copy / Set-Clipboard), stripping ANSI codes so the pasted text is clean. (cherry picked from commit 3bf0b5a43422d7232dd13e24616d0961c6315bcc) --- packages/cli/src/tests/clipboard.test.ts | 7 +++ packages/cli/src/tests/slash-commands.test.ts | 1 + packages/cli/src/ui/core/clipboard.ts | 44 +++++++++++++++++++ packages/cli/src/ui/core/slash-commands.ts | 7 +++ packages/cli/src/ui/views/App.tsx | 17 +++++++ packages/cli/src/ui/views/PromptInput.tsx | 7 ++- 6 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/tests/clipboard.test.ts b/packages/cli/src/tests/clipboard.test.ts index 3ca892eb..6a3142a3 100644 --- a/packages/cli/src/tests/clipboard.test.ts +++ b/packages/cli/src/tests/clipboard.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { cleanOutputForClipboard } from "../ui/core/clipboard"; // eslint-disable-next-line @typescript-eslint/consistent-type-imports type ClipboardModule = typeof import("../ui/core/clipboard"); @@ -77,3 +78,9 @@ test( } } ); + +test("cleanOutputForClipboard strips ANSI codes and normalizes line endings", () => { + assert.equal(cleanOutputForClipboard("\u001B[32mhello\u001B[39m\r\nworld\r\n"), "hello\nworld\n"); + assert.equal(cleanOutputForClipboard(" code\n"), " code\n"); + assert.equal(cleanOutputForClipboard(""), "\n"); +}); diff --git a/packages/cli/src/tests/slash-commands.test.ts b/packages/cli/src/tests/slash-commands.test.ts index 311c01ed..549d10d0 100644 --- a/packages/cli/src/tests/slash-commands.test.ts +++ b/packages/cli/src/tests/slash-commands.test.ts @@ -31,6 +31,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => { "undo", "mcp", "raw", + "copy", "exit", ]); }); diff --git a/packages/cli/src/ui/core/clipboard.ts b/packages/cli/src/ui/core/clipboard.ts index c9e30e92..703d4062 100644 --- a/packages/cli/src/ui/core/clipboard.ts +++ b/packages/cli/src/ui/core/clipboard.ts @@ -156,3 +156,47 @@ export async function readClipboardImageAsync(): Promise }); }); } + +const ANSI_ESCAPE_REGEX = /\u001B\[[0-9;]*m/g; + +/** + * Prepare assistant output for clean clipboard pasting: strip ANSI escape + * codes and normalize line endings so the copied text pastes without terminal + * color codes. (#127) + */ +export function cleanOutputForClipboard(text: string): string { + const withoutAnsi = text.replace(ANSI_ESCAPE_REGEX, ""); + return withoutAnsi.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trimEnd() + "\n"; +} + +function tryRunWithInput(command: string, args: string[], input: string): boolean { + try { + const result = spawnSync(command, args, { input, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); + return result.status === 0 && !result.error; + } catch { + return false; + } +} + +/** + * Copy plain text to the system clipboard using the platform-native helper. + * Returns false when no clipboard tool is available. (#127) + */ +export function writeClipboardText(text: string): boolean { + if (!text) { + return false; + } + if (process.platform === "darwin") { + return tryRunWithInput("pbcopy", [], text); + } + if (process.platform === "linux") { + if (tryRunWithInput("xclip", ["-selection", "clipboard"], text)) { + return true; + } + return tryRunWithInput("wl-copy", [], text); + } + if (process.platform === "win32") { + return tryRunWithInput("powershell", ["-NoProfile", "-Command", "[Console]::In.ReadToEnd() | Set-Clipboard"], text); + } + return false; +} diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts index 77292b40..fc2c2c1a 100644 --- a/packages/cli/src/ui/core/slash-commands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -13,6 +13,7 @@ export type SlashCommandKind = | "undo" | "mcp" | "raw" + | "copy" | "exit"; export type SlashCommandItem = { @@ -92,6 +93,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ args: ["lite", "normal", "raw-scrollback"], description: "Toggle display mode for viewing or collapsing reasoning content", }, + { + kind: "copy", + name: "copy", + label: "/copy", + description: "Copy the last assistant response to the clipboard", + }, { kind: "exit", name: "exit", diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 034c62ac..c251f708 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -9,6 +9,7 @@ import { MessageView, RawModeExitPrompt } from "../components"; import { SessionList } from "./SessionList"; import { type UndoRestoreMode, UndoSelector } from "./UndoSelector"; import { buildLoadingText } from "../core/loading-text"; +import { cleanOutputForClipboard, writeClipboardText } from "../core/clipboard"; import { findExpandedThinkingId } from "../core/thinking-state"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; @@ -392,6 +393,22 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes navigateToSubView("mcp-status"); return; } + if (submission.command === "copy") { + const activeSessionId = sessionManager.getActiveSessionId(); + const sessionMessages = activeSessionId ? sessionManager.listSessionMessages(activeSessionId) : []; + const lastAssistant = [...sessionMessages].reverse().find((msg) => msg.role === "assistant"); + const content = typeof lastAssistant?.content === "string" ? lastAssistant.content : ""; + if (!content.trim()) { + setErrorLine("No assistant response to copy yet."); + return; + } + if (writeClipboardText(cleanOutputForClipboard(content))) { + setMessages((prev) => [...prev, buildSyntheticUserMessage("Copied last response to clipboard", 0)]); + } else { + setErrorLine("Failed to copy: no clipboard tool available."); + } + return; + } const prompt: UserPromptContent = { text: submission.text, diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 9e6240a4..e57d7c35 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -73,7 +73,7 @@ export type PromptSubmission = { permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; planMode?: boolean; - command?: "new" | "resume" | "fork" | "continue" | "undo" | "mcp" | "exit"; + command?: "new" | "resume" | "fork" | "continue" | "undo" | "mcp" | "copy" | "exit"; }; export type PromptDraft = { @@ -734,6 +734,11 @@ export const PromptInput = React.memo(function PromptInput({ resetPromptInput(); return; } + if (item.kind === "copy") { + onSubmit({ text: "/copy", imageUrls: [], command: "copy" }); + resetPromptInput(); + return; + } if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER);