Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/cli/src/tests/clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
1 change: 1 addition & 0 deletions packages/cli/src/tests/slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
"undo",
"mcp",
"raw",
"copy",
"exit",
]);
});
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/ui/core/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,47 @@ export async function readClipboardImageAsync(): Promise<ClipboardImage | null>
});
});
}

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;
}
7 changes: 7 additions & 0 deletions packages/cli/src/ui/core/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type SlashCommandKind =
| "undo"
| "mcp"
| "raw"
| "copy"
| "exit";

export type SlashCommandItem = {
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/ui/views/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/ui/views/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down