From 8e8fdcf307e5211f77a2479d070e178f35ac92f7 Mon Sep 17 00:00:00 2001 From: skift Date: Sat, 30 May 2026 11:52:25 +0800 Subject: [PATCH] feat(cli): add /copy command for clipboard copy in TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new /copy slash command that extracts chat content through OSC 52 clipboard (with temp-file fallback for oversized text). - /copy (no args) → copies the latest assistant response - /copy all → copies the serialized chat conversation - /copy N → copies the last N serializable items New src/cli/ui/copy-history.ts handles card-to-text serialization covering all card kinds (user, assistant, tool, reasoning, diff, plan, task, usage, memory, subagent, search, etc.). /copy is restricted to local TUI sessions only — remote QQ/ Telegram/Weixin slash commands cannot trigger host clipboard writes. Fixes #2260 (copy portion) --- src/cli/ui/App.tsx | 4 + src/cli/ui/copy-history.ts | 220 +++++++++++++++++++++++++++++ src/cli/ui/slash/commands.ts | 7 + src/cli/ui/slash/handlers/basic.ts | 33 +++++ src/cli/ui/slash/types.ts | 2 + src/i18n/EN.ts | 15 ++ 6 files changed, 281 insertions(+) create mode 100644 src/cli/ui/copy-history.ts diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 5f410619b..65b1558be 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -488,6 +488,7 @@ function AppInner({ markPhase("app_inner_start"); const log = useScrollback(); const agentStore = useAgentStore(); + const getCardsForSlash = useCallback(() => agentStore.getState().cards, [agentStore]); const hasConversation = useAgentState((s) => s.cards.some((c) => c.kind === "user" || c.kind === "streaming"), ); @@ -3069,6 +3070,9 @@ function AppInner({ status: weixin.status, }, sessionId: session, + // Only expose card access for local TUI — remote QQ/Telegram/Weixin + // slash commands should never write to the host terminal's clipboard. + getCards: fromQQ || fromTelegram || fromWeixin ? undefined : getCardsForSlash, getEngineeringLifecycleSnapshot: codeMode ? () => engineeringLifecycleRef.current?.snapshot() ?? null : undefined, diff --git a/src/cli/ui/copy-history.ts b/src/cli/ui/copy-history.ts new file mode 100644 index 000000000..6925514c6 --- /dev/null +++ b/src/cli/ui/copy-history.ts @@ -0,0 +1,220 @@ +import type { Card } from "./state/cards.js"; + +export type CopyHistoryMode = + | { kind: "latest-assistant" } + | { kind: "all" } + | { kind: "last"; count: number }; + +export interface CopyHistorySelection { + label: string; + text: string; +} + +export function parseCopyHistoryArgs(args: readonly string[]): CopyHistoryMode | { error: string } { + if (args.length === 0) return { kind: "latest-assistant" }; + if (args.length === 1) { + const raw = args[0]!.toLowerCase(); + if (raw === "last" || raw === "assistant" || raw === "reply" || raw === "response") { + return { kind: "latest-assistant" }; + } + if (raw === "all") return { kind: "all" }; + if (/^\d+$/.test(raw)) { + const count = Number.parseInt(raw, 10); + if (count > 0) return { kind: "last", count }; + } + } + return { error: "usage" }; +} + +export function selectCopyHistory( + cards: ReadonlyArray, + mode: CopyHistoryMode = { kind: "latest-assistant" }, +): CopyHistorySelection | null { + if (mode.kind === "latest-assistant") { + for (let i = cards.length - 1; i >= 0; i--) { + const card = cards[i]!; + if (card.kind !== "streaming" || !card.done) continue; + const text = normalize(card.text); + if (text) return { label: "last assistant response", text }; + } + return null; + } + + const entries = cards.flatMap(cardToCopyEntries).filter((entry) => entry.text.length > 0); + const selected = mode.kind === "all" ? entries : entries.slice(-mode.count); + if (selected.length === 0) return null; + return { + label: mode.kind === "all" ? "conversation" : `last ${selected.length} item(s)`, + text: selected.map((entry) => `${entry.label}:\n${entry.text}`).join("\n\n"), + }; +} + +interface CopyEntry { + label: string; + text: string; +} + +function cardToCopyEntries(card: Card): CopyEntry[] { + switch (card.kind) { + case "user": + return [{ label: "User", text: normalize(card.text) }]; + case "streaming": + return [{ label: "Assistant", text: normalize(card.text) }]; + case "tool": { + const args = card.args === undefined ? "" : formatJson(card.args); + const body = [args ? `Args:\n${args}` : "", card.output ? `Output:\n${card.output}` : ""] + .filter(Boolean) + .join("\n\n"); + return [{ label: `Tool ${card.name}`, text: normalize(body) }]; + } + case "reasoning": + return [{ label: "Reasoning", text: normalize(card.text) }]; + case "live": + return [ + { label: "Info", text: normalize(card.meta ? `${card.text}\n${card.meta}` : card.text) }, + ]; + case "warn": + return [ + { + label: "Warning", + text: normalize([card.title, card.message, card.detail].filter(Boolean).join("\n")), + }, + ]; + case "error": + return [ + { + label: "Error", + text: normalize([card.title, card.message, card.stack].filter(Boolean).join("\n")), + }, + ]; + case "plan": + return [ + { + label: "Plan", + text: normalize( + [card.title, ...card.steps.map((step) => `- [${step.status}] ${step.title}`)].join( + "\n", + ), + ), + }, + ]; + case "task": + return [ + { + label: "Task", + text: normalize( + [ + `${card.title} (${card.status})`, + ...card.steps.map((step) => `- [${step.status}] ${step.title}`), + ].join("\n"), + ), + }, + ]; + case "diff": + return [ + { + label: `Diff ${card.file}`, + text: normalize( + card.hunks + .map((hunk) => + [ + hunk.header, + ...hunk.lines.map( + (line) => + `${line.kind === "add" ? "+" : line.kind === "del" ? "-" : " "}${line.text}`, + ), + ].join("\n"), + ) + .join("\n\n"), + ), + }, + ]; + case "usage": + return [ + { + label: "Usage", + text: normalize( + `turn ${card.turn}: prompt ${card.tokens.prompt}, reasoning ${card.tokens.reason}, output ${card.tokens.output}, cache ${card.cacheHit}%, cost $${card.cost}`, + ), + }, + ]; + case "memory": + return [ + { + label: "Memory", + text: normalize( + card.entries.map((entry) => `- ${entry.category}: ${entry.summary}`).join("\n"), + ), + }, + ]; + case "subagent": + return [ + { + label: `Subagent ${card.name}`, + text: normalize( + [ + `${card.task} (${card.status})`, + ...card.children + .flatMap(cardToCopyEntries) + .map((entry) => `${entry.label}:\n${entry.text}`), + ].join("\n\n"), + ), + }, + ]; + case "search": + return [ + { + label: "Search", + text: normalize( + [ + `query: ${card.query}`, + ...card.hits.map((hit) => `${hit.file}:${hit.line}: ${hit.preview}`), + ].join("\n"), + ), + }, + ]; + case "ctx": + return [{ label: "Context", text: normalize(card.text) }]; + case "tip": + return [ + { + label: "Tip", + text: normalize( + [ + card.topic, + ...card.sections.flatMap((section) => [ + section.title ? `[${section.title}]` : "", + ...section.rows.map((row) => `${row.key}\t${row.text}`), + ]), + card.footer ?? "", + ].join("\n"), + ), + }, + ]; + case "doctor": + return [ + { + label: "Doctor", + text: normalize( + card.checks.map((c) => `- [${c.level}] ${c.label}: ${c.detail}`).join("\n"), + ), + }, + ]; + case "compaction": + return [{ label: "Compaction", text: normalize(card.summary) }]; + default: + return []; + } +} + +function normalize(text: string): string { + return text.trim(); +} + +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} diff --git a/src/cli/ui/slash/commands.ts b/src/cli/ui/slash/commands.ts index 15f1a12aa..a49b88457 100644 --- a/src/cli/ui/slash/commands.ts +++ b/src/cli/ui/slash/commands.ts @@ -63,6 +63,13 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [ group: "chat", summary: "abort the current model turn (typed alternative to Esc)", }, + { + cmd: "copy", + group: "chat", + argsHint: "[all|last|N]", + summary: + "copy latest assistant reply through OSC 52; `all` copies the chat; `N` copies the last N items", + }, { cmd: "btw", group: "chat", diff --git a/src/cli/ui/slash/handlers/basic.ts b/src/cli/ui/slash/handlers/basic.ts index 53cdcafaa..e4e767f1f 100644 --- a/src/cli/ui/slash/handlers/basic.ts +++ b/src/cli/ui/slash/handlers/basic.ts @@ -1,7 +1,9 @@ import { wrapToCells } from "@/cli/ui/text-width.js"; import { t, tObj } from "@/i18n/index.js"; import { VERSION } from "@/version.js"; +import { writeClipboard } from "../../clipboard.js"; import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js"; +import { parseCopyHistoryArgs, selectCopyHistory } from "../../copy-history.js"; import { SLASH_COMMANDS, SLASH_GROUP_ORDER, orderSlashCommandsByGroup } from "../commands.js"; import type { SlashHandler } from "../dispatch.js"; import type { SlashCommandSpec, SlashGroup } from "../types.js"; @@ -151,6 +153,36 @@ const keys: SlashHandler = (_args, _loop, ctx) => { return {}; }; +const copy: SlashHandler = (args, _loop, ctx) => { + const mode = parseCopyHistoryArgs(args); + if ("error" in mode) { + return { + info: t("handlers.basic.copyUsage", { cmd: "/copy [all|last|N]" }), + }; + } + if (!ctx.getCards) { + return { info: t("handlers.basic.copyTuiOnly") }; + } + const cards = ctx.getCards(); + if (!cards || cards.length === 0) { + return { info: t("handlers.basic.copyEmpty") }; + } + const selection = selectCopyHistory(cards, mode); + if (!selection) { + return { info: t("handlers.basic.copyEmpty") }; + } + const result = writeClipboard(selection.text); + if (result.osc52) { + return { info: t("handlers.basic.copyOkOsc52", { label: selection.label, size: result.size }) }; + } + if (result.filePath) { + return { + info: t("handlers.basic.copyOkFile", { label: selection.label, path: result.filePath }), + }; + } + return { info: t("handlers.basic.copyFailed") }; +}; + const about: SlashHandler = () => { const lines = [ t("handlers.basic.aboutHeader", { version: VERSION }), @@ -169,5 +201,6 @@ export const handlers: Record = { retry, loop, keys, + copy, about, }; diff --git a/src/cli/ui/slash/types.ts b/src/cli/ui/slash/types.ts index ff4b58d0c..23f1001f6 100644 --- a/src/cli/ui/slash/types.ts +++ b/src/cli/ui/slash/types.ts @@ -63,6 +63,8 @@ export type PlanModeToggleSource = "slash" | "explicit-intent"; export interface SlashContext { configPath?: string; + /** Snapshot the current list of agent cards — used by /copy to serialize chat content. */ + getCards?: () => ReadonlyArray; mcpSpecs?: string[]; codeUndo?: (args: readonly string[]) => CodeUndoOutput; codeApply?: (indices?: readonly number[]) => string; diff --git a/src/i18n/EN.ts b/src/i18n/EN.ts index ef542766d..1ee80aa1c 100644 --- a/src/i18n/EN.ts +++ b/src/i18n/EN.ts @@ -175,6 +175,10 @@ export const EN: TranslationSchema = { text: "Ctrl+Shift+C (Win/Linux) · Cmd+C (macOS) — or auto-copy-on-select if your terminal does it", }, { key: "paste", text: "Ctrl+V or Ctrl+Shift+V (Win/Linux) · Cmd+V (macOS)" }, + { + key: "/copy", + text: "copy the latest assistant response; `/copy all` copies the full chat", + }, { key: "bracketed paste", text: "multi-line pastes stay one block — no auto-submit on intermediate newlines", @@ -848,6 +852,17 @@ export const EN: TranslationSchema = { helpSessionNone: " reasonix chat --no-session disable persistence for this run", retryNone: "nothing to retry — no prior user message in this session's log.", retryInfo: '▸ retrying: "{preview}"', + copyUsage: + "{cmd} — copy the latest assistant reply (default), `all` for the chat, or `` for last N items", + copyTuiOnly: "/copy needs an interactive TUI session (local terminal only).", + copyEmpty: + "nothing to copy — no non-empty content found in this session.", + copyOkOsc52: + "▸ copied {label} to clipboard via OSC 52 ({size} characters)", + copyOkFile: + "▸ copied {label} to temp file (OSC 52 unavailable) → {path}", + copyFailed: + "▸ copy failed — clipboard write returned no target (OSC 52 or file).", loopTuiOnly: "/loop is only available in the interactive TUI (not in run/replay).", loopStopped: "▸ loop stopped.", loopNoActive: "no active loop to stop.",