diff --git a/packages/coding-agent/src/clipboard.ts b/packages/coding-agent/src/clipboard.ts new file mode 100644 index 00000000..5baf954a --- /dev/null +++ b/packages/coding-agent/src/clipboard.ts @@ -0,0 +1,28 @@ +import { execSync } from "child_process"; +import { platform } from "os"; + +export function copyToClipboard(text: string): void { + const p = platform(); + const options = { input: text, timeout: 5000 }; + + try { + if (p === "darwin") { + execSync("pbcopy", options); + } else if (p === "win32") { + execSync("clip", options); + } else { + // Linux - try xclip first, fall back to xsel + try { + execSync("xclip -selection clipboard", options); + } catch { + execSync("xsel --clipboard --input", options); + } + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (p === "linux") { + throw new Error(`Failed to copy to clipboard. Install xclip or xsel: ${msg}`); + } + throw new Error(`Failed to copy to clipboard: ${msg}`); + } +} diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index de2e1027..5cba73e7 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -18,6 +18,7 @@ import { } from "@mariozechner/pi-tui"; import { exec } from "child_process"; import { getChangelogPath, parseChangelog } from "../changelog.js"; +import { copyToClipboard } from "../clipboard.js"; import { calculateContextTokens, compact, shouldCompact } from "../compaction.js"; import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js"; import { exportSessionToHtml } from "../export-html.js"; @@ -155,6 +156,11 @@ export class TuiRenderer { description: "Export session to HTML file", }; + const copyCommand: SlashCommand = { + name: "copy", + description: "Copy last agent message to clipboard", + }; + const sessionCommand: SlashCommand = { name: "session", description: "Show session info and stats", @@ -221,6 +227,7 @@ export class TuiRenderer { modelCommand, themeCommand, exportCommand, + copyCommand, sessionCommand, changelogCommand, branchCommand, @@ -383,6 +390,13 @@ export class TuiRenderer { return; } + // Check for /copy command + if (text === "/copy") { + this.handleCopyCommand(); + this.editor.setText(""); + return; + } + // Check for /session command if (text === "/session") { this.handleSessionCommand(); @@ -1573,6 +1587,46 @@ export class TuiRenderer { } } + private handleCopyCommand(): void { + // Find the last assistant message + const lastAssistantMessage = this.agent.state.messages + .slice() + .reverse() + .find((m) => m.role === "assistant"); + + if (!lastAssistantMessage) { + this.showError("No agent messages to copy yet."); + return; + } + + // Extract raw text content from all text blocks + let textContent = ""; + + for (const content of lastAssistantMessage.content) { + if (content.type === "text") { + textContent += content.text; + } + } + + if (!textContent.trim()) { + this.showError("Last agent message contains no text content."); + return; + } + + // Copy to clipboard using cross-platform compatible method + try { + copyToClipboard(textContent); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + return; + } + + // Show confirmation message + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("dim", "Copied last agent message to clipboard"), 1, 0)); + this.ui.requestRender(); + } + private handleSessionCommand(): void { // Get session info const sessionFile = this.sessionManager.getSessionFile();