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
4 changes: 4 additions & 0 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
);
Expand Down Expand Up @@ -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,
Expand Down
220 changes: 220 additions & 0 deletions src/cli/ui/copy-history.ts
Original file line number Diff line number Diff line change
@@ -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<Card>,
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);
}
}
7 changes: 7 additions & 0 deletions src/cli/ui/slash/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions src/cli/ui/slash/handlers/basic.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 }),
Expand All @@ -169,5 +201,6 @@ export const handlers: Record<string, SlashHandler> = {
retry,
loop,
keys,
copy,
about,
};
2 changes: 2 additions & 0 deletions src/cli/ui/slash/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<import("../state/cards.js").Card>;
mcpSpecs?: string[];
codeUndo?: (args: readonly string[]) => CodeUndoOutput;
codeApply?: (indices?: readonly number[]) => string;
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/EN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 `<N>` 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.",
Expand Down