/** * Tool call card: collapses to a single line by * default — status icon + tool name + duration (a live-ticking timer while running) + status * badge; clicking expands full arguments and output; a nested subagent renders below them * regardless of collapsed state. * The pending-approval row is always visible regardless of collapsed state; when a pending * approval appears anywhere in a nested subagent chain, the card auto-expands once (respecting * the user's choice if they've manually collapsed it since). * * Duration accounting = **argument-generation segment + execution segment** (excludes time * spent waiting on human approval): the model streaming out arguments token by token is often * slower than the tool call itself, so reporting only the execution segment would badly * understate this step's cost. While waiting on approval, the already-settled generation * segment is shown, with a separate "Waiting for approval" badge attached. */ import { useEffect, useRef, useState } from "react"; import { S } from "../../lib/strings"; import { humanizeDuration } from "../../lib/format"; import { approvalKey } from "../../lib/omni/stream-model"; import type { ToolCallItem } from "../../lib/omni/stream-model"; import { Badge, stopReasonTone } from "../../components/ui/badge"; import { Chevron } from "../../components/ui/chevron"; import { ZoomableImage } from "../../components/ui/image-zoom"; import { StatusIcon } from "../../components/ui/status-icon"; import type { RunState } from "../../components/ui/status-icon"; import { ApprovalButtons } from "./approval-buttons"; import { LiveDuration } from "./live-duration"; import { SubagentCard } from "./subagent-card"; import type { StreamRenderContext } from "./message-stream"; /** Tools that accept the optional model-written `description` argument. */ const DESCRIBED_TOOLS = new Set([ "exec_command", "input_command", "run_subagent", "input_subagent", ]); /** The three file tools: previewed by their `file_path` argument. */ const FILE_TOOLS = new Set(["read_file", "edit_file", "write_file"]); /** * Shortens a path for one-line display: at most one parent directory plus the filename * (`…/parent/file.ts`); paths already within that shape are shown as-is (same rule as the * CLI's tool-render). The full path stays in the expanded arguments block. */ export function shortenPath(p: string): string { const segments = p.split("/").filter((s) => s.length > 0); if (segments.length <= 2) return p; return `…/${segments[segments.length - 2]}/${segments[segments.length - 1]}`; } /** * Argument preview (same approach as the CLI's tool-render): exec_command shows `$ `, * the file tools show their shortened file path, other tools show a single-line * `name(args)` prefix. Arguments may be incomplete JSON (mid-stream), so extraction is done * leniently. The preview deliberately keeps the real arguments (not the model-written * description): it heads the approval row, and the user must approve the actual command, * not the model's summary of it. */ export function previewArguments(name: string, argsJson: string): string { if (name === "exec_command") { const cmd = extractStringField(argsJson, "cmd"); if (cmd !== null) return `$ ${cmd.replace(/\s+/g, " ").trim()}`; } if (FILE_TOOLS.has(name)) { const filePath = extractStringField(argsJson, "file_path"); if (filePath !== null) return shortenPath(filePath.replace(/\s+/g, " ").trim()); } return argsJson.replace(/\s+/g, " ").trim(); } /** * Collapsed-header subtitle: the human-readable line next to the tool name — the * model-written `description` argument for the command/subagent tools (declared in their * config schema; per-tool `call_description: false` removes it, in which case the model * never sends it), or the shortened file path for the file tools. Null when there is * nothing beyond the raw arguments. */ export function headerSubtitle(name: string, argsJson: string): string | null { if (DESCRIBED_TOOLS.has(name)) { const desc = extractStringField(argsJson, "description"); if (desc !== null) { const line = desc.replace(/\s+/g, " ").trim(); if (line) return line; } return null; } if (FILE_TOOLS.has(name)) { const filePath = extractStringField(argsJson, "file_path"); if (filePath !== null) { const line = filePath.replace(/\s+/g, " ").trim(); if (line) return shortenPath(line); } } return null; } /** * Decoded file-tool payload for the pending-approval block: the user is approving a * concrete rewrite (old_string/new_string/content), so the bare path is not enough — the * actual arguments are rendered in the scrollable expanded style while the call is PENDING. * Null for other tools or unparseable arguments (arguments are complete by approval time). */ export function pendingFilePayload(name: string, argsJson: string): string | null { if (!FILE_TOOLS.has(name)) return null; let parsed: unknown; try { parsed = JSON.parse(argsJson); } catch { return null; } if (parsed === null || typeof parsed !== "object") return null; const args = parsed as Record; const sections: string[] = []; const push = (label: string, value: unknown): void => { if (value === undefined) return; if (typeof value === "string" && value.includes("\n")) { sections.push(`${label}:\n${value}`); } else { sections.push(`${label}: ${typeof value === "string" ? value : JSON.stringify(value)}`); } }; push("file_path", args["file_path"]); if (name === "read_file") { push("offset", args["offset"]); push("limit", args["limit"]); } else if (name === "edit_file") { push("old_string", args["old_string"]); push("new_string", args["new_string"]); if (args["replace_all"] === true) push("replace_all", true); } else if (name === "write_file") { push("content", args["content"]); } return sections.join("\n"); } /** Extracts the current value of a string field from a possibly-incomplete JSON object string (a simplified version, good enough for preview purposes). */ function extractStringField(argsJson: string, field: string): string | null { const key = `"${field}"`; const keyIndex = argsJson.indexOf(key); if (keyIndex === -1) return null; let i = keyIndex + key.length; while (/\s/.test(argsJson[i] ?? "")) i += 1; if (argsJson[i] !== ":") return null; i += 1; while (/\s/.test(argsJson[i] ?? "")) i += 1; if (argsJson[i] !== '"') return null; i += 1; let out = ""; let escaped = false; for (; i < argsJson.length; i += 1) { const ch = argsJson[i]!; if (escaped) { out += ch === "n" ? "\n" : ch === "t" ? "\t" : ch; escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (ch === '"') return out; out += ch; } return out; } export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRenderContext }) { const [open, setOpen] = useState(false); const userToggled = useRef(false); // Matched by the current origin chain + toolCallId: prevents parent/child session tool_call_id collisions from lighting each other up. const pending = ctx.pendingApprovals.get(approvalKey(ctx.origin, item.toolCallId)); // Whether there's a pending approval anywhere in the nested subagent chain (keys are `origin-chain toolCallId`, matched by prefix at any depth). const nestedPrefix = item.subagentSessionId ? [...ctx.origin, item.subagentSessionId].join("/") : null; const hasNestedPending = nestedPrefix !== null && [...ctx.pendingApprovals.keys()].some( (k) => k.startsWith(`${nestedPrefix} `) || k.startsWith(`${nestedPrefix}/`), ); // A nested pending approval needs the user's action: auto-expand once (respected if the user manually collapses it afterward). useEffect(() => { if (hasNestedPending && !userToggled.current) setOpen(true); }, [hasNestedPending]); const preview = previewArguments(item.name, item.argumentsText); const subtitle = headerSubtitle(item.name, item.argumentsText); // Executing = the call has finished streaming, output hasn't arrived yet, and it's not waiting on approval (approval wait time doesn't count toward execution). const executing = item.callComplete && !item.outputComplete && !pending; // Argument-generation segment (settled): the live execution timer accumulates on top of this as a baseline, so the displayed duration doesn't shrink back once output arrives. const genMs = item.argStartedAtMs !== undefined && item.callStartedAtMs !== undefined ? Math.max(0, item.callStartedAtMs - item.argStartedAtMs) : 0; const failed = (item.callStopReason !== undefined && item.callStopReason !== "completed") || (item.outputStopReason !== undefined && item.outputStopReason !== "completed"); const state: RunState = pending ? "waiting" : executing || item.callStreaming ? "running" : failed ? "failed" : "done"; // Decision wording ("Approved · manual" / "已拒绝 · 手动" …): carried ONLY by the left status // icon's title/aria-label — per review the row shows no visible decision text at any // breakpoint; the icon is the single source of truth for how the call was decided. const decisionText = item.decision ? `${item.decision === "allow" ? S.chat.decisionAllow : S.chat.decisionDeny} · ${ item.decisionSource === "manual" ? S.chat.decisionManual : S.chat.decisionAuto }` : null; // A user denial reports stop_reason "aborted" on the output it feeds back; that abort IS the // decision, not an independent outcome — the icon reads "Denied", and no separate "aborted" // badge repeats it. A user-abort of a RUNNING tool carries no deny decision and keeps its own // "aborted" marker (the stop-reason branch below). const deniedByUser = item.decision === "deny" && item.outputStopReason === "aborted"; const stateLabel = pending ? S.chat.approvalWaiting : state === "running" ? S.chat.workRunning : state === "done" ? (decisionText ?? S.chat.workDone) : deniedByUser ? (decisionText ?? undefined) : (item.outputStopReason ?? item.callStopReason); return (
{/* Collapsed row: status icon + tool name + total duration (generation + execution, excluding approval wait). Expand chevron on the right. */} {/* Pending approval: always visible regardless of collapsed state — shows the tool name and arguments so the user knows what they're approving. */} {pending && (
{/* The user must be able to read the FULL command before deciding: below sm the preview wraps in whole (expanded-args style: pre-wrap + break-all, no inner scroll, the block may grow) — the one-line treatment resumes once decided, since this pending block unmounts and only the truncating header subtitle remains. At ≥sm the row stays one line (the desktop column is wide enough in practice). */}
{item.name || S.chat.unknownTool} {preview}
{/* File tools: the one-line preview shows only the (shortened) path, but the user is approving a concrete rewrite — render the decoded payload (old_string/new_string/ content) in the scrollable expanded style while pending. */} {(() => { const payload = pendingFilePayload(item.name, item.argumentsText); return payload !== null ? (
                {payload}
              
) : null; })()} ctx.onApprove(item.toolCallId, decision, ctx.origin)} />
)} {/* Expanded details: full arguments / output */} {open && (
{item.argumentsText && ( // Arguments are shown as a fully wrapped block (no height cap, no scrollbar): the // arguments are key to understanding this call, and tucking them into an inner // scroll area would make them hard to read and fight with the message stream's own scroll.
              {item.argumentsText}
            
)} {(item.output || item.outputStreaming) && (
              {item.output}
              {item.outputStreaming && ▌}
            
)} {/* Tool output images (e.g. read_image): shown as thumbnails, click to zoom (ZoomableImage). */} {item.images && item.images.length > 0 && (
{item.images.map((src, i) => ( ))}
)}
)} {/* Subagent card: always visible (not hidden after completion, only collapsed by default), unaffected by the tool card's collapsed state. Rendered below the expanded arguments/output so the nested conversation reads after the tool call's own content, not between the header and its details; pt-2 keeps a gap from the tinted args/output blocks above. */} {item.subagent && (
)}
); }