Files
Pawan Osman b63ac10f8f Enhance tool timeout management and UI integration
- Updated the `runAgent` function to improve timeout handling for MCP tools, ensuring shared budget management and better execution limits.
- Introduced a countdown mechanism for tool calls, allowing for real-time UI feedback on execution status and tool cancellations.
- Enhanced the UI components to display timeout badges and countdowns for running tools, improving user awareness and responsiveness.
- Implemented a silent countdown feature for collapsed tool groups, ensuring timely cancellations without UI clutter.
- Refactored the `Tool` component to integrate timeout badges, providing clearer visual indicators of tool status.
2026-07-15 18:04:43 +03:00

800 lines
31 KiB
TypeScript

/*
* Copyright (c) 2026 Pawan Osman <https://github.com/PawanOsman>
*
* This file is part of OpenCursor — AI coding agent chat inside VS Code.
* https://github.com/PawanOsman/OpenCursor
*
* Licensed under the MIT License. See LICENSE file in the project root.
*/
import * as React from "react";
import { Icon, IconName } from "../../shared/icons";
import { basename, renderMarkdown } from "../../shared/markdown";
import { vscode } from "../../shared/vscode";
import type { ToolBlock, OutMessage, AssistantBlock, FileIconInfo } from "../types";
function post(msg: OutMessage) {
vscode.postMessage(msg);
}
// ---- IDE file icons (resolved by the host from the active icon theme) ----
const iconCache = new Map<string, FileIconInfo | null>();
const iconWaiters = new Map<string, ((i: FileIconInfo | null) => void)[]>();
const loadedFonts = new Set<string>();
window.addEventListener("message", (e: MessageEvent) => {
const m = e.data;
if (m?.type !== "fileIcon") return;
const icon: FileIconInfo | null = m.icon || null;
iconCache.set(m.filename, icon);
if (icon?.kind === "font" && !loadedFonts.has(icon.fontFamily)) {
loadedFonts.add(icon.fontFamily);
const style = document.createElement("style");
style.textContent = `@font-face { font-family: "${icon.fontFamily}"; src: url("${icon.src}") format("${icon.format}"); }`;
document.head.appendChild(style);
}
(iconWaiters.get(m.filename) || []).forEach((fn) => fn(icon));
iconWaiters.delete(m.filename);
});
// fontCharacter is either a literal glyph ("") or an escape like "\\E001".
function decodeFontChar(ch: string): string {
const m = ch.match(/^\\+([0-9a-fA-F]{4,6})$/);
return m ? String.fromCodePoint(parseInt(m[1], 16)) : ch;
}
function FileIcon({ path, fallback }: { path: string; fallback: IconName }) {
const filename = basename(path || "").toLowerCase();
const [icon, setIcon] = React.useState<FileIconInfo | null>(() => iconCache.get(filename) ?? null);
React.useEffect(() => {
// filename changes while the tool args stream in ("" → partial → final):
// always re-sync from the cache, and (re)request when unknown.
if (!filename) { setIcon(null); return; }
if (iconCache.has(filename)) { setIcon(iconCache.get(filename) ?? null); return; }
let live = true;
const fn = (i: FileIconInfo | null) => live && setIcon(i);
iconWaiters.set(filename, [...(iconWaiters.get(filename) || []), fn]);
post({ type: "getFileIcon", filename });
return () => { live = false; };
}, [filename]);
if (icon?.kind === "img") return <img className="file-icon-img" src={icon.src} alt="" />;
if (icon?.kind === "font") {
return (
<span
className="file-icon-font"
style={{ fontFamily: icon.fontFamily, color: icon.color, fontSize: icon.size }}
>
{decodeFontChar(icon.char)}
</span>
);
}
return <Icon name={fallback} />;
}
// Read-only subagent types (mirror of the backend set in agent/tools/agent.ts).
// A subagent is read-only ("Explore") only if it explicitly opts in OR uses a
// read-only subagent_type; otherwise it can edit and is shown as "Agent".
const RO_SUBAGENT_TYPES = new Set([
"explore",
"cursor-guide",
"docs-researcher",
"code-reviewer",
"bugbot",
"security-review",
"ci-investigator",
]);
export function isReadonlySubagent(i: any): boolean {
if (!i) return false;
if (i.readonly === true) return true;
if (i.readonly === false) return false;
return RO_SUBAGENT_TYPES.has(String(i.subagent_type || ""));
}
function toolMeta(name: string, i: any): { icon: IconName; label: string; badge: string; cls: string } {
i = i || {};
switch (name) {
case "read_file":
case "Read":
return { icon: "file", label: "Read " + basename(i.path), badge: "Read", cls: "badge-read" };
case "list_dir":
case "ListDir":
return { icon: "folder", label: "List " + (i.path || "."), badge: "Read", cls: "badge-read" };
case "glob":
case "Glob":
return { icon: "search", label: "Glob " + (i.pattern || ""), badge: "Read", cls: "badge-read" };
case "grep":
case "Grep":
return { icon: "search", label: 'Grep "' + (i.pattern || "") + '"', badge: "Read", cls: "badge-read" };
case "SemanticSearch":
return { icon: "search", label: "Search " + (i.query || ""), badge: "Read", cls: "badge-read" };
case "SearchDocs":
return { icon: "book", label: "Search " + (i.doc ? i.doc + " docs" : "docs") + (i.query ? ' "' + i.query + '"' : ""), badge: "Read", cls: "badge-read" };
case "file_search":
case "FileSearch":
return { icon: "fileSearch", label: "Find " + (i.query || ""), badge: "Read", cls: "badge-read" };
case "read_lints":
case "ReadLints":
return { icon: "ruler", label: "Lints" + (i.path ? " " + basename(i.path) : ""), badge: "Read", cls: "badge-read" };
case "todo_read":
case "TodoRead":
return { icon: "todo", label: "Read todos", badge: "Read", cls: "badge-read" };
case "todo_write":
case "TodoWrite":
return { icon: "todo", label: "Update todos", badge: "Plan", cls: "badge-plan" };
case "web_search":
case "WebSearch":
return { icon: "globe", label: 'Search web "' + (i.search_term || "") + '"', badge: "Web", cls: "badge-web" };
case "web_fetch":
case "WebFetch":
return { icon: "link", label: "Fetch " + (i.url || ""), badge: "Web", cls: "badge-web" };
case "task":
case "Task":
return { icon: "task", label: i.description || "Subagent", badge: "Agent", cls: "badge-agent" };
case "edit_file":
case "StrReplace":
case "Write":
return { icon: "file", label: basename(i.path || ""), badge: "Edit", cls: "badge-edit" };
case "delete_file":
case "Delete":
return { icon: "trash", label: "Delete " + basename(i.path), badge: "Edit", cls: "badge-edit" };
case "run_terminal":
case "Shell":
return { icon: "terminal", label: i.command || "", badge: "Terminal", cls: "badge-term" };
default: {
// MCP tools arrive as "mcp__<Server>__<tool_name>"; prettify to "Server · tool name".
const mcp = name.match(/^mcp__(.+?)__(.+)$/);
if (mcp) {
const server = mcp[1];
const tool = mcp[2].replace(/[_-]+/g, " ").trim();
return { icon: "link", label: `${server} · ${tool}`, badge: server, cls: "badge-web" };
}
return { icon: "file", label: name, badge: "Tool", cls: "badge-read" };
}
}
}
// Parse "[x] ..." style todo render output into structured items.
function parseTodos(output: string): { status: string; content: string }[] {
const items: { status: string; content: string }[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
let m = line.match(/^\[(x| |~|-)\]\s+(.*)$/);
if (m) {
const map: Record<string, string> = { x: "completed", " ": "pending", "~": "in_progress", "-": "cancelled" };
items.push({ status: map[m[1]] || "pending", content: m[2] });
continue;
}
m = line.match(/^-\s*\[(\w+)\]\s+(.*)$/);
if (m) {
items.push({ status: m[1], content: m[2] });
}
}
return items;
}
function TodoList({ block }: { block: ToolBlock }) {
const items = parseTodos(block.result || "");
return (
<div className="tool-card todo-card">
<div className="tool-card-header todo-header">
<span className="ticon">
<Icon name="todo" />
</span>
<span className="label">Todos</span>
<span className="right">
<TimeoutBadge block={block} />
<StatusIcon status={block.status} />
</span>
</div>
<div className="todo-list">
{items.length === 0 ? (
<div className="todo-empty">{block.status === "running" ? "Updating…" : "(no todos)"}</div>
) : (
items.map((t, idx) => (
<div key={idx} className={"todo-item " + t.status}>
<span className="todo-mark">
{t.status === "completed" ? (
<Icon name="check" />
) : t.status === "in_progress" ? (
<Icon name="circleDot" />
) : t.status === "cancelled" ? (
<Icon name="close" />
) : (
<Icon name="circle" />
)}
</span>
<span className="todo-text">{t.content}</span>
</div>
))
)}
</div>
</div>
);
}
function SubagentCard({ block, onOpen }: { block: ToolBlock; onOpen?: (callId: string) => void }) {
const i = block.input || {};
// Background subagents complete the parent tool-call immediately while they keep
// streaming, so drive "running" off the subagent's own status, not the tool status.
const subDone = block.subStatus === "finished" || block.subStatus === "cancelled" || block.subStatus === "error";
const running = !subDone && (block.status === "running" || !!block.subStatus || (block.subBlocks?.length ?? 0) > 0);
const steps = (block.subBlocks ?? []).filter((b) => b.kind === "tool").length;
const subtitle = running ? subagentActivity(block.subBlocks) : undefined;
return (
<div className="subagent-card" onClick={() => onOpen?.(block.callId)} role="button">
<div className="subagent-card-main">
<span className="ticon"><Icon name="task" /></span>
<span className="label">{i.description || "Subagent"}</span>
<span className="sub-spacer" />
<TimeoutBadge block={block} />
<span className="sub-steps">{running ? `${steps} steps…` : `${steps} steps`}</span>
<span className="badge badge-agent">{isReadonlySubagent(i) ? "Explore" : "Agent"}</span>
{running ? <span className="spinner" /> : <StatusIcon status={block.subStatus === "error" ? "error" : "completed"} />}
<Icon name="chevR" size={14} className="sub-open-chev" />
</div>
{subtitle && <div className="subagent-card-subtitle">{subtitle}</div>}
</div>
);
}
// Human-readable "what is the subagent doing right now" line, derived from the
// most recent streamed sub-block (tool call / thinking / text).
function subagentActivity(blocks?: AssistantBlock[]): string {
const last = blocks && blocks.length ? blocks[blocks.length - 1] : undefined;
if (!last) return "Starting…";
if (last.kind === "thinking") return "Planning next move…";
if (last.kind === "text") return "Generating…";
if (last.kind === "tool") {
const label = SUBAGENT_TOOL_ACTIVITY[last.name] || "Working…";
return last.status === "running" ? label : "Planning next move…";
}
return "Working…";
}
const SUBAGENT_TOOL_ACTIVITY: Record<string, string> = {
Read: "Reading files…", read_file: "Reading files…",
ListDir: "Listing files…", list_dir: "Listing files…",
Glob: "Finding files…", glob: "Finding files…",
FileSearch: "Searching files…", file_search: "Searching files…",
Grep: "Searching code…", grep: "Searching code…",
SemanticSearch: "Searching codebase…", semantic_search: "Searching codebase…",
SearchDocs: "Searching docs…",
StrReplace: "Editing files…", Write: "Writing files…", edit_file: "Editing files…",
Delete: "Deleting files…", delete_file: "Deleting files…",
EditNotebook: "Editing notebook…",
Shell: "Running command…", run_terminal: "Running command…",
AwaitShell: "Waiting on command…",
WebSearch: "Searching web…", web_search: "Searching web…",
WebFetch: "Fetching page…", web_fetch: "Fetching page…",
Task: "Delegating…", task: "Delegating…",
TodoWrite: "Updating plan…", todo_write: "Updating plan…",
ReadLints: "Checking lints…",
};
function PlanCard({ block, onImplement }: { block: ToolBlock; onImplement?: (path: string) => void }) {
const i = block.input || {};
const title: string = i.title || "Plan";
const content: string = i.content || "";
// The write_plan result is "wrote plan to .plans/<file>.md".
const planPath = (block.result || "").replace(/^wrote plan to\s+/, "").trim() || undefined;
const [open, setOpen] = React.useState(true);
const done = block.status === "completed";
return (
<div className="plan-card">
<div className="plan-header" onClick={() => setOpen((o) => !o)}>
<span className={"tchev" + (open ? " open" : "")}>
<Icon name="chevD" />
</span>
<span className="ticon">
<Icon name="todo" />
</span>
<span className="plan-title">{title}</span>
<TimeoutBadge block={block} />
<span className="badge badge-plan">Plan</span>
<StatusIcon status={block.status} />
</div>
{open && (
<div className="plan-body">
{content ? (
<div className="markdown-content" dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }} />
) : (
<div className="plan-empty">{block.status === "running" ? "Writing plan…" : "(empty plan)"}</div>
)}
</div>
)}
{done && (
<div className="plan-actions">
{planPath && (
<button className="plan-open" onClick={() => post({ type: "openFile", path: planPath })}>
<Icon name="file" /> {basename(planPath)}
</button>
)}
<button className="plan-implement" onClick={() => onImplement && onImplement(planPath || "")}>
<Icon name="agent" /> Implement plan
</button>
</div>
)}
</div>
);
}
function StatusIcon({ status }: { status: ToolBlock["status"] }) {
if (status === "running") return <span className="spinner" />;
return status === "completed" ? <Icon name="check" className="ok-icon" /> : <Icon name="close" className="err-icon" />;
}
/** True while this tool/task should show a live timeout countdown. */
export function isToolCountdownActive(block: ToolBlock): boolean {
if (block.name === "AskQuestion" || block.name === "ask_question") return false;
const isTask = block.name === "Task" || block.name === "task";
if (isTask) {
const subDone = block.subStatus === "finished" || block.subStatus === "cancelled" || block.subStatus === "error";
if (subDone) return false;
// Parent tool may complete early (bg Task); keep counting while nested work open.
return block.status === "running" || block.subStatus === "running" || !!block.subBlocks?.length;
}
return block.status === "running";
}
/** Dedup kill-at-zero across multiple countdown mounts (explore head + card). */
const firedTimeouts = new Set<string>();
/**
* Live countdown for a running tool/task. At 0: cancel/kill via host.
* Uses host `startedAt` when present; otherwise starts the clock on first
* observation so every timed tool always shows a countdown.
*/
export function useToolCountdown(block: ToolBlock): number | null {
const budget = block.timeoutMs && block.timeoutMs > 0 ? block.timeoutMs : 0;
const hostStarted = block.startedAt && block.startedAt > 0 ? block.startedAt : 0;
const running = isToolCountdownActive(block);
const localStart = React.useRef(0);
const [left, setLeft] = React.useState<number | null>(null);
React.useEffect(() => {
if (!running || !budget || !block.callId) {
localStart.current = 0;
setLeft(null);
return;
}
// Prefer host clock; fall back to first UI observation of this run.
if (hostStarted) localStart.current = hostStarted;
else if (!localStart.current) localStart.current = Date.now();
// Allow re-fire only on a brand-new callId (set already cleared on settle).
if (block.status !== "running" && !(block.name === "Task" || block.name === "task")) {
firedTimeouts.delete(block.callId);
}
const tick = () => {
const start = hostStarted || localStart.current;
if (!start) return;
const sec = Math.max(0, Math.ceil((start + budget - Date.now()) / 1000));
setLeft(sec);
if (sec <= 0 && !firedTimeouts.has(block.callId)) {
firedTimeouts.add(block.callId);
post({ type: "cancelSubagent", callId: block.callId, reason: "timeout" });
}
};
tick();
const id = window.setInterval(tick, 250);
return () => window.clearInterval(id);
}, [budget, hostStarted, running, block.callId, block.status, block.subStatus, block.timeoutMs, block.name]);
if (!running || !budget || left == null) return null;
return left;
}
function formatCountdown(sec: number): string {
if (sec >= 60) {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
return `${sec}s`;
}
/** Visible countdown badge; also drives kill-at-zero via useToolCountdown. */
export function TimeoutBadge({ block }: { block: ToolBlock }) {
const left = useToolCountdown(block);
if (left == null) return null;
const urgent = left <= 5;
return (
<span
className={"tool-timeout" + (urgent ? " urgent" : "") + (left === 0 ? " zero" : "")}
title="Timeout remaining — tool is killed at 0"
>
{left === 0 ? "timeout" : formatCountdown(left)}
</span>
);
}
/** Silent countdown (kill at 0) without rendering — for collapsed explore groups. */
export function ToolTimeoutWatch({ block }: { block: ToolBlock }) {
useToolCountdown(block);
return null;
}
function Diff({ diff }: { diff: string }) {
const lines = diff.split("\n");
const needsExpand = lines.length > 6;
const [expanded, setExpanded] = React.useState(!needsExpand);
return (
<>
<div className={"tool-diff " + (expanded ? "expanded" : "collapsed")}>
{lines.map((line, idx) => {
const cls = line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx";
return (
<div key={idx} className={"dl " + cls}>
{line}
</div>
);
})}
</div>
{needsExpand && (
<div className="diff-expand" onClick={(e) => { e.stopPropagation(); setExpanded((x) => !x); }}>
<Icon name="chevD" size={12} className={expanded ? "flip" : ""} />
</div>
)}
</>
);
}
// +N / -M counts from a unified diff.
function diffStats(diff: string): { add: number; del: number } {
let add = 0, del = 0;
for (const l of diff.split("\n")) {
if (l.startsWith("+")) add++;
else if (l.startsWith("-")) del++;
}
return { add, del };
}
export function ReadLine({ block }: { block: ToolBlock }) {
const i = block.input || {};
const start = block.startLine || i.start_line || "";
const end = block.endLine || i.end_line || "";
const rangeTxt = start && end ? start + "-" + end : start ? start + "-" : "";
return (
<div
className="read-line"
onClick={() =>
post({
type: "openFile",
path: i.path || "",
startLine: start ? Number(start) : undefined,
endLine: end ? Number(end) : undefined,
})
}
>
<span className="ricon">
<Icon name="file" />
</span>
<span className="rname">Read {basename(i.path)}</span>
<span className="rlines">{rangeTxt ? "L" + rangeTxt : ""}</span>
<TimeoutBadge block={block} />
<span className="rstatus">
<StatusIcon status={block.status} />
</span>
</div>
);
}
interface QItem { question: string; options?: string[]; multiple?: boolean }
// Options may arrive as plain strings or Cursor-shape {id,label} objects; coerce to strings.
function optLabel(o: any): string {
return typeof o === "string" ? o : String(o?.label ?? o?.id ?? "");
}
function QuestionCard({ block }: { block: ToolBlock }) {
const header: string = block.input?.header || block.input?.title || "Questions";
const questions: QItem[] = (Array.isArray(block.input?.questions) ? block.input.questions : []).map((q: any) => ({
question: String(q?.question ?? q?.prompt ?? ""),
options: Array.isArray(q?.options) ? q.options.map(optLabel) : undefined,
multiple: !!(q?.multiple ?? q?.allow_multiple),
}));
const answered = block.status !== "running";
const [step, setStep] = React.useState(0);
const [answers, setAnswers] = React.useState<Record<string, string[]>>({});
const [custom, setCustom] = React.useState<Record<string, string>>({});
const [customMode, setCustomMode] = React.useState<Record<string, boolean>>({});
const [sent, setSent] = React.useState(false);
if (questions.length === 0) return null;
const q = questions[step];
const opts = q.options || [];
const sel = answers[String(step)] || [];
const customText = custom[String(step)] || "";
const customSelected = customMode[String(step)] || false;
const setCustomSelected = (on: boolean) => setCustomMode((c) => ({ ...c, [String(step)]: on }));
const toggle = (opt: string) => {
if (!q.multiple) setCustomSelected(false);
setAnswers((a) => {
const cur = a[String(step)] || [];
if (q.multiple) {
return { ...a, [String(step)]: cur.includes(opt) ? cur.filter((x) => x !== opt) : [...cur, opt] };
}
return { ...a, [String(step)]: [opt] };
});
};
const pickCustom = () => {
if (!q.multiple) setAnswers((a) => ({ ...a, [String(step)]: [] }));
setCustomSelected(true);
};
// Build this step's final answer list, folding in the custom text if chosen.
const resolveAnswers = (base: Record<string, string[]>): Record<string, string[]> => {
const out = { ...base };
const v = (custom[String(step)] || "").trim();
if (customSelected && v) {
const cur = q.multiple ? (out[String(step)] || []).filter((x) => x !== v) : [];
out[String(step)] = [...cur, v];
}
return out;
};
const submit = () => {
const final = resolveAnswers(answers);
setAnswers(final);
setSent(true);
post({ type: "answerQuestion", callId: block.callId, answers: final });
};
const advance = () => {
setAnswers((a) => resolveAnswers(a));
setStep((s) => s + 1);
};
const last = step === questions.length - 1;
if (answered || sent) {
return (
<div className="question-card done">
<div className="qc-head"><Icon name="chat" size={14} /> {header}</div>
{questions.map((qq, i) => {
const a = answers[String(i)] || [];
return (
<div className="qc-answered" key={i}>
<div className="qc-q">{i + 1}. {qq.question}</div>
<div className="qc-a">{a.length ? a.join(", ") : "(skipped)"}</div>
</div>
);
})}
</div>
);
}
return (
<div className="question-card">
<div className="qc-head">
<span><Icon name="chat" size={14} /> {header}</span>
<span className="qc-step">{step + 1} of {questions.length}</span>
</div>
<div className="qc-question">{step + 1}. {q.question}</div>
{opts.map((opt, oi) => (
<button
key={oi}
className={"qc-option" + (sel.includes(opt) && !(!q.multiple && customSelected) ? " selected" : "")}
onClick={() => toggle(opt)}
>
<span className="qc-key">{String.fromCharCode(65 + oi)}</span>
<span>{opt}</span>
</button>
))}
<button
className={"qc-option qc-option-custom" + (customSelected ? " selected" : "")}
onClick={() => (customSelected ? setCustomSelected(false) : pickCustom())}
>
<span className="qc-key">{String.fromCharCode(65 + opts.length)}</span>
<span>Other</span>
</button>
{customSelected && (
<input
className="qc-custom"
placeholder="Type a custom answer…"
autoFocus
value={customText}
onChange={(e) => setCustom((c) => ({ ...c, [String(step)]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") (last ? submit() : advance());
}}
/>
)}
<div className="qc-foot">
{step > 0 && <button className="qc-nav" onClick={() => setStep((s) => s - 1)}>Back</button>}
<span className="qc-spacer" />
<button className="qc-skip" onClick={() => (last ? submit() : setStep((s) => s + 1))}>Skip</button>
<button className="qc-next" onClick={() => (last ? submit() : advance())}>
{last ? "Submit" : "Continue"}
</button>
</div>
</div>
);
}
// The code being written, pulled from whichever arg the edit tool streams.
function editPreview(name: string, i: any): string {
if (name === "Write" || name === "edit_file") return String(i.contents ?? i.content ?? "");
if (name === "StrReplace") return String(i.new_string ?? "");
return "";
}
export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBlock; onImplement?: (path: string) => void; onOpenSubagent?: (callId: string) => void }) {
if (block.name === "write_plan" || block.name === "WritePlan") return <PlanCard block={block} onImplement={onImplement} />;
if (block.name === "ask_question" || block.name === "AskQuestion") return <QuestionCard block={block} />;
if (block.name === "read_file" || block.name === "Read") return <ReadLine block={block} />;
if (block.name === "todo_write" || block.name === "todo_read" || block.name === "TodoWrite" || block.name === "TodoRead") return <TodoList block={block} />;
if (block.name === "task" || block.name === "Task") return <SubagentCard block={block} onOpen={onOpenSubagent} />;
const i = block.input || {};
const meta = toolMeta(block.name, i);
const isEdit = block.name === "edit_file" || block.name === "StrReplace" || block.name === "Write";
const isShell = block.name === "run_terminal" || block.name === "Shell" || block.name === "AwaitShell";
const [open, setOpen] = React.useState(isEdit || isShell);
const onHeaderClick = () => {
if (isEdit) {
post({ type: "openFile", path: i.path || "", startLine: block.startLine });
} else {
setOpen((o) => !o);
}
};
const showBody = isEdit ? true : open;
const shellCmd = isShell ? String(i.command || meta.label || "") : "";
const shellParsed = isShell ? parseShellResult(block.result, shellCmd) : null;
return (
<div className={"tool-card " + (isEdit ? "edit-card" : "compact-card") + (isShell ? " shell-card" : "")}>
<div className={"tool-card-header " + (isEdit ? "edit-header" : "compact") + (isShell ? " shell-header" : "")} onClick={onHeaderClick}>
<div className="left">
{!isEdit && (
<span className={"tchev" + (open ? " open" : "")}>
<Icon name="chevD" />
</span>
)}
<span className="ticon">
{isEdit ? <FileIcon path={i.path || ""} fallback={meta.icon} /> : <Icon name={meta.icon} />}
</span>
{isShell ? (
<span className="shell-prompt-line" title={shellCmd}>
<span className="shell-prompt">$</span>
<span className="shell-cmd">{shellCmd || "…"}</span>
</span>
) : (
<span className="label">{meta.label}</span>
)}
{isEdit && block.diff && (() => {
const s = diffStats(block.diff);
return (
<span className="edit-stats">
{s.add > 0 && <span className="stat-add">+{s.add}</span>}
{s.del > 0 && <span className="stat-del">-{s.del}</span>}
</span>
);
})()}
</div>
<div className="right">
{isShell && shellCmd ? <CopyCommandButton command={shellCmd} /> : null}
<TimeoutBadge block={block} />
{!isEdit && <span className={"badge " + meta.cls}>{meta.badge}</span>}
<StatusIcon status={block.status} />
</div>
</div>
{showBody && (
<div className="tool-card-body">
{block.diff ? (
<Diff diff={block.diff} />
) : isEdit && block.status === "running" ? (
// Stream the code as the model writes it; swapped for the diff on completion.
<pre className="tool-result streaming">{editPreview(block.name, i) || "Writing…"}</pre>
) : isShell && shellParsed ? (
<div className="shell-body">
{shellParsed.meta ? <div className="shell-meta">{shellParsed.meta}</div> : null}
<pre className="terminal-output">
{shellParsed.body || (block.status === "running" ? "Running…" : "")}
</pre>
{shellParsed.footer ? (
<div className={"shell-footer" + (shellParsed.ok === false ? " err" : shellParsed.ok ? " ok" : "")}>
{shellParsed.footer}
</div>
) : null}
</div>
) : (
<pre className="tool-result">{block.status === "running" ? "Running…" : (block.result || "").slice(0, 4000)}</pre>
)}
</div>
)}
</div>
);
}
function CopyCommandButton({ command }: { command: string }) {
const [copied, setCopied] = React.useState(false);
const onCopy = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const done = () => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
};
if (navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(command).then(done).catch(() => {
// fallback below
try {
const ta = document.createElement("textarea");
ta.value = command;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
done();
} catch { /* ignore */ }
});
} else {
try {
const ta = document.createElement("textarea");
ta.value = command;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
done();
} catch { /* ignore */ }
}
};
return (
<button
type="button"
className={"shell-copy-btn" + (copied ? " copied" : "")}
title={copied ? "Copied" : "Copy command"}
aria-label={copied ? "Copied" : "Copy command"}
onClick={onCopy}
>
<Icon name={copied ? "check" : "copy"} size={12} />
</button>
);
}
/** Strip duplicated `$ command` lines from shell tool output for cleaner card body. */
function parseShellResult(raw: string | undefined, command: string): {
meta: string;
body: string;
footer: string;
ok: boolean | null;
} {
if (!raw) return { meta: "", body: "", footer: "", ok: null };
const lines = raw.replace(/\r\n/g, "\n").split("\n");
let meta = "";
let footer = "";
let ok: boolean | null = null;
const bodyLines: string[] = [];
const cmdNorm = command.trim();
for (const line of lines) {
if (!meta && /^\[shell\s/.test(line)) {
meta = line.replace(/^\[shell\s+/, "").replace(/\]\s*$/, "").trim();
continue;
}
if (/^\(exit_code=/.test(line) || /^\(still running/.test(line)) {
footer = line.replace(/^\(/, "").replace(/\)$/, "");
const m = line.match(/exit_code=(-?\d+)/);
if (m) ok = Number(m[1]) === 0;
continue;
}
// Drop the echo of the command (header already shows it).
const t = line.trim();
if (t === `$ ${cmdNorm}` || t === cmdNorm || (cmdNorm && t === `$ ${cmdNorm}`)) continue;
if (t.startsWith("$ ") && cmdNorm && t.slice(2).trim() === cmdNorm) continue;
bodyLines.push(line);
}
// Trim leading/trailing blank lines from body.
while (bodyLines.length && !bodyLines[0].trim()) bodyLines.shift();
while (bodyLines.length && !bodyLines[bodyLines.length - 1].trim()) bodyLines.pop();
return { meta, body: bodyLines.join("\n"), footer, ok };
}