Enhance tool timeout management and UI feedback for subagents

- Updated the `runAgent` function to include timeout management for tool calls, allowing for better handling of execution limits and user feedback.
- Introduced `timeoutMs` and `startedAt` properties in the `tool-call-started` event to facilitate countdown displays in the UI.
- Enhanced the UI to show countdown timers for running tools, improving user awareness of execution status.
- Implemented immediate UI updates on tool cancellation or timeout, ensuring a responsive experience even during long-running operations.
- Refactored the `SidebarProvider` to handle subagent cancellations more effectively, providing clearer status updates in the UI.
This commit is contained in:
Pawan Osman
2026-07-15 17:56:30 +03:00
parent 5dab80f764
commit 6b8301a28e
11 changed files with 501 additions and 51 deletions
+70 -17
View File
@@ -246,6 +246,8 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
});
bgSubagents.push(tracked);
void tracked.then((v) => { bgSettled[idx] = v; });
// Mark nested status running so UI countdown keeps ticking after parent tool completes.
if (callId) emit({ type: "subagent-event", callId, event: { type: "run-status", status: "running" } });
return `Launched ${title} in the background${callId ? ` (call ${callId})` : ""}. It will keep working and stream its results; you do not need to wait or poll for it. When all background subagents finish, their summaries will be delivered to you automatically and you can continue.`;
}
try {
@@ -537,9 +539,17 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
emit({ type: "thinking-delta", text: ev.text });
} else if (ev.type === "tool-call-start") {
// Surface the tool card the moment the model commits to a call.
// No startedAt yet — countdown begins when execute actually starts.
callIdByIndex.set(ev.index, ev.id);
argsByIndex.set(ev.index, "");
emit({ type: "tool-call-started", callId: ev.id, name: ev.name, input: {} });
const tMs = toolTimeoutMs(ev.name);
emit({
type: "tool-call-started",
callId: ev.id,
name: ev.name,
input: {},
timeoutMs: tMs > 0 ? tMs : undefined,
});
} else if (ev.type === "tool-call-args-delta") {
const id = callIdByIndex.get(ev.index);
const acc = (argsByIndex.get(ev.index) ?? "") + ev.delta;
@@ -642,8 +652,32 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
// with {} would call tools with missing params — fail the call instead.
badArgs = true;
}
emit({ type: "tool-call-started", callId: call.id, name: call.name, input });
return { call, input, badArgs };
const tMs = toolTimeoutMs(call.name);
// Shell: countdown uses block_until_ms when shorter than tool budget.
let timeoutMs = tMs > 0 ? tMs : undefined;
if ((call.name === "Shell" || call.name === "AwaitShell") && !badArgs) {
const raw = typeof input?.block_until_ms === "number" ? input.block_until_ms : undefined;
if (raw !== undefined && raw > 0) {
const block = Math.min(raw, call.name === "Shell" ? 30_000 : 45_000);
timeoutMs = timeoutMs ? Math.min(timeoutMs, block) : block;
} else if (call.name === "Shell" && (raw === undefined || raw === null)) {
// Default foreground shell wait.
timeoutMs = timeoutMs ? Math.min(timeoutMs, 15_000) : 15_000;
}
}
// Task: use Task budget (foreground); bg still has BG_SUBAGENT_MAX_MS.
if (call.name === "Task" && input?.run_in_background === true) {
timeoutMs = BG_SUBAGENT_MAX_MS;
}
// Announce card + budget; startedAt set when exec actually begins.
emit({
type: "tool-call-started",
callId: call.id,
name: call.name,
input,
timeoutMs,
});
return { call, input, badArgs, timeoutMs };
});
const results = new Array<{ status: "completed" | "error"; output: string; diff?: string; startLine?: number; endLine?: number; image?: { mime: string; base64: string } }>(parsed.length);
@@ -741,39 +775,57 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
}
}
try {
// Per-tool hard timeout + linked abort so hung Shell/Grep/Glob
// cannot block the agent loop. Task/AskQuestion skip the timeout.
const limitMs = toolTimeoutMs(call.name);
// Per-tool hard timeout + linked abort. On timeout: kill immediately
// and settle UI — never leave the card spinning "Working".
const limitMs = parsed[i].timeoutMs ?? toolTimeoutMs(call.name);
const toolAc = new AbortController();
const onParentAbort = () => {
const killTool = () => {
try { toolAc.abort(); } catch { /* ignore */ }
};
// Register so UI countdown-0 / cancelSubagent can kill any tool.
if (registerSubagentAbort) {
registerSubagentAbort(call.id, killTool);
}
// Countdown clock starts now (not when the card was announced).
emit({
type: "tool-call-started",
callId: call.id,
name: call.name,
input,
timeoutMs: limitMs > 0 ? limitMs : undefined,
startedAt: Date.now(),
});
const onParentAbort = () => killTool();
if (signal.aborted) onParentAbort();
else signal.addEventListener("abort", onParentAbort, { once: true });
// Abort slightly before the race rejects so tools can clean up.
let toolTimer: ReturnType<typeof setTimeout> | undefined;
if (limitMs > 0) {
toolTimer = setTimeout(() => {
try { toolAc.abort(); } catch { /* ignore */ }
}, Math.max(500, limitMs - 400));
}
let r: Awaited<ReturnType<typeof tool.execute>>;
let timedOut = false;
try {
r = await withToolTimeout(
Promise.resolve().then(() => tool.execute(input, toolAc.signal, call.id, toolCtx)),
limitMs,
call.name,
() => {
timedOut = true;
killTool();
// Immediate UI settle on timeout — don't wait for tool cleanup.
results[i] = {
status: "error",
output: `error: timeout: ${call.name} exceeded ${Math.round((limitMs || 0) / 1000)}s. Tool aborted - retry with a narrower scope or shorter command.`,
};
finishUi(i);
},
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
try { toolAc.abort(); } catch { /* ignore */ }
const isTo = timedOut || msg.startsWith("timeout:");
r = {
output: msg.startsWith("timeout:")
? `error: ${msg}. Tool aborted - retry with a narrower scope or shorter command.`
output: isTo
? `error: timeout: ${call.name} exceeded ${Math.round((limitMs || 0) / 1000)}s. Tool aborted - retry with a narrower scope or shorter command.`
: `error: ${msg}`,
};
} finally {
if (toolTimer) clearTimeout(toolTimer);
signal.removeEventListener("abort", onParentAbort);
}
const status: "completed" | "error" = r.output.startsWith("error:") ? "error" : "completed";
@@ -782,6 +834,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
if (status === "completed" && isEditTool && onAfterEdit) {
onAfterEdit(String(input?.path ?? ""));
}
// Immediate UI settle (especially on timeout) — do not wait for siblings.
finishUi(i);
} catch (e) {
results[i] = { status: "error", output: `error: ${e instanceof Error ? e.message : String(e)}` };
+18 -5
View File
@@ -76,11 +76,13 @@ export const TOOL_TIMEOUT_MS: Record<string, number> = {
TodoRead: 5_000,
WritePlan: 10_000,
SwitchMode: 5_000,
// Foreground Task budget (bg subagents use BG_SUBAGENT_MAX_MS in loop).
Task: 6 * 60_000,
};
/** Default when a tool has no explicit entry. */
export const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
/** Tools that manage their own lifetime (user wait / nested agent). */
export const NO_TOOL_TIMEOUT = new Set(["Task", "AskQuestion"]);
/** Tools that manage their own lifetime (user wait only). Task has a hard budget. */
export const NO_TOOL_TIMEOUT = new Set(["AskQuestion"]);
/** Built-in defaults in seconds (for settings UI). */
export const DEFAULT_TOOL_TIMEOUTS_SEC: Record<string, number> = Object.fromEntries(
@@ -109,9 +111,19 @@ export function setToolTimeoutOverrides(sec: Record<string, number> | undefined)
* into the tool when possible (Shell/Grep honor it).
* Always settles (never hangs) even if `p` never resolves.
*/
export function withToolTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
// ms <= 0: no outer race (Task/AskQuestion manage their own lifetime).
// Still attach a settle handler so a late rejection cannot become unhandled.
/**
* Race a tool promise against a hard timeout.
* On timeout: call `onTimeout` first (abort/kill), then reject immediately so
* the loop can settle UI without waiting for the underlying work.
* Late resolve/reject of `p` is ignored (no unhandled rejection).
*/
export function withToolTimeout<T>(
p: Promise<T>,
ms: number,
label: string,
onTimeout?: () => void,
): Promise<T> {
// ms <= 0: no outer race (AskQuestion manages its own lifetime).
if (!ms || ms <= 0) {
return Promise.resolve(p).catch((e) => {
throw e instanceof Error ? e : new Error(String(e));
@@ -123,6 +135,7 @@ export function withToolTimeout<T>(p: Promise<T>, ms: number, label: string): Pr
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { onTimeout?.(); } catch { /* ignore */ }
reject(new Error(`timeout: ${label} exceeded ${Math.round(limit / 1000)}s`));
}, limit);
Promise.resolve(p).then(
+16 -6
View File
@@ -27,6 +27,8 @@ const isWin = process.platform === "win32";
const DEFAULT_BLOCK_MS = 15_000;
const MAX_BLOCK_MS = 30_000;
const MAX_AWAIT_MS = 45_000;
/** Absolute hard wall even if block_until_ms is large / tool timeout is higher. */
const SHELL_HARD_WALL_MS = 45_000;
/** Build a notify_on_output config from the tool input, if present. */
function buildNotify(input: any, ctx: any): ShellNotify | undefined {
@@ -246,22 +248,30 @@ export const runTerminalTool = defineTool("Shell", true, async (input, abortSign
};
}
// Cap wait by tool abort + hard wall so Shell never outlives its countdown.
const waitMs = blockMs <= 0 ? 0 : Math.min(blockMs, SHELL_HARD_WALL_MS);
try {
await waitForShell(sh, blockMs, undefined, abortSignal);
await waitForShell(sh, waitMs, undefined, abortSignal);
try {
sh.pump?.();
} catch {
/* ignore */
}
// Timed out with no sentinel: do NOT leave a hung command poisoning the
// session — kill and respawn so the next Shell call is clean.
if (!sh.done && blockMs > 0) {
sh.output += `\n(timeout after ${blockMs}ms — session reset; re-run with a shorter command or block_until_ms=0 to background)`;
// Abort/timeout: kill session immediately so the loop is free.
if (abortSignal?.aborted && !sh.done) {
sh.output += "\n(aborted / timed out)";
sh.done = true;
sh.exitCode = sh.exitCode ?? 124;
killSession();
} else if (!sh.done && blockMs === 0) {
} else if (!sh.done && waitMs > 0) {
// Timed out with no sentinel: do NOT leave a hung command poisoning the
// session — kill and respawn so the next Shell call is clean.
sh.output += `\n(timeout after ${waitMs}ms — session reset; re-run with a shorter command or block_until_ms=0 to background)`;
sh.done = true;
sh.exitCode = sh.exitCode ?? 124;
killSession();
} else if (!sh.done && waitMs === 0) {
// Immediate background: keep pumping via interval until done/timeout later.
const bgPump = setInterval(() => {
try {
+1 -1
View File
@@ -73,7 +73,7 @@ export type ProviderEvent =
export type AgentEvent =
| { type: "text-delta"; text: string }
| { type: "thinking-delta"; text: string }
| { type: "tool-call-started"; callId: string; name: string; input: unknown }
| { type: "tool-call-started"; callId: string; name: string; input: unknown; timeoutMs?: number; startedAt?: number }
// Live JSON-arg streaming for a started call (UI parses partial input).
| { type: "tool-call-args"; callId: string; argsText: string }
| { type: "tool-call-completed"; callId: string; name: string; status: "completed" | "error"; result: string; diff?: string; startLine?: number; endLine?: number }
+48 -5
View File
@@ -16,7 +16,7 @@ export type Mode = "agent" | "ask" | "plan" | "multitask" | "debug";
export type AgentEvent =
| { type: "text-delta"; text: string }
| { type: "thinking-delta"; text: string }
| { type: "tool-call-started"; callId: string; name: string; input: any }
| { type: "tool-call-started"; callId: string; name: string; input: any; timeoutMs?: number; startedAt?: number }
| { type: "tool-call-args"; callId: string; argsText: string }
| {
type: "tool-call-completed";
@@ -58,6 +58,10 @@ export interface ToolBlock {
diff?: string;
startLine?: number;
endLine?: number;
/** Hard timeout budget (ms). UI shows countdown; 0/undefined = none. */
timeoutMs?: number;
/** Wall-clock start for countdown (ms since epoch). */
startedAt?: number;
/** For task (subagent) blocks: the nested read-only sub-chat stream. */
subBlocks?: AssistantBlock[];
subStatus?: "running" | "finished" | "error" | "cancelled";
@@ -196,8 +200,28 @@ export function applyToBlocks(blocksIn: AssistantBlock[], ev: AgentEvent): Assis
} else if (ev.type === "tool-call-started") {
if (last && last.kind === "thinking" && !last.endedAt) blocks[blocks.length - 1] = { ...last, endedAt: Date.now() };
const existing = blocks.findIndex((b) => b.kind === "tool" && b.callId === ev.callId);
if (existing >= 0) blocks[existing] = { ...blocks[existing], name: ev.name, input: ev.input } as AssistantBlock;
else blocks.push({ kind: "tool", callId: ev.callId, name: ev.name, input: ev.input, status: "running" });
const timeoutMs = ev.timeoutMs && ev.timeoutMs > 0 ? ev.timeoutMs : undefined;
const startedAt = ev.startedAt;
if (existing >= 0) {
const prev = blocks[existing] as ToolBlock;
blocks[existing] = {
...prev,
name: ev.name,
input: Object.keys(ev.input || {}).length ? ev.input : prev.input,
timeoutMs: timeoutMs ?? prev.timeoutMs,
startedAt: startedAt ?? prev.startedAt,
} as AssistantBlock;
} else {
blocks.push({
kind: "tool",
callId: ev.callId,
name: ev.name,
input: ev.input,
status: "running",
timeoutMs,
startedAt,
});
}
} else if (ev.type === "tool-call-args") {
return blocks.map((b) =>
b.kind === "tool" && b.callId === ev.callId ? { ...b, input: parsePartialArgs(ev.argsText, b.input) } : b
@@ -277,10 +301,29 @@ export function applyEvent(turns: Turn[], ev: AgentEvent): Turn[] {
const { list, turn } = ensureAssistant(turns);
closeThinking(turn);
const existing = turn.blocks.findIndex((b) => b.kind === "tool" && b.callId === ev.callId);
const timeoutMs = ev.timeoutMs && ev.timeoutMs > 0 ? ev.timeoutMs : undefined;
// startedAt only when provided (execute time). Stream preview may omit it.
const startedAt = ev.startedAt;
if (existing >= 0) {
turn.blocks[existing] = { ...turn.blocks[existing], name: ev.name, input: ev.input } as AssistantBlock;
const prev = turn.blocks[existing] as ToolBlock;
turn.blocks[existing] = {
...prev,
name: ev.name,
input: Object.keys(ev.input || {}).length ? ev.input : prev.input,
status: prev.status === "running" ? "running" : prev.status,
timeoutMs: timeoutMs ?? prev.timeoutMs,
startedAt: startedAt ?? prev.startedAt,
} as AssistantBlock;
} else {
turn.blocks.push({ kind: "tool", callId: ev.callId, name: ev.name, input: ev.input, status: "running" });
turn.blocks.push({
kind: "tool",
callId: ev.callId,
name: ev.name,
input: ev.input,
status: "running",
timeoutMs,
startedAt,
});
}
return list;
}
+51 -10
View File
@@ -228,34 +228,68 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
break;
}
case "cancelSubagent":
// callId is globally unique; find the owning session and abort that child.
// callId is globally unique; abort tool/subagent and settle the card now.
for (const [cid, s] of this._sessions) {
const a = s.subagentAborts.get(data.callId);
if (!a) continue;
try { a(); } catch { /* ignore */ }
// Mark this Task card cancelled immediately so the spinner stops even if
// the child never emits a terminal run-status.
if (a) {
try { a(); } catch { /* ignore */ }
s.subagentAborts.delete(data.callId);
}
// Mark card settled immediately so spinner stops even if the worker
// never emits a terminal event (timeout / hung process).
let hit = false;
s.turns = s.turns.map((turn) => {
if (turn.role !== "assistant") return turn;
let changed = false;
const blocks = turn.blocks.map((b) => {
if (b.kind !== "tool" || b.callId !== data.callId) return b;
if (b.status !== "running" && b.subStatus !== "running") return b;
hit = true;
changed = true;
const timedOut = data.reason === "timeout";
return {
...b,
status: b.status === "running" ? ("error" as const) : b.status,
result: b.result || "(cancelled)",
subStatus: "cancelled" as const,
status: "error" as const,
result: b.result || (timedOut ? `(timeout after ${Math.round((b.timeoutMs || 0) / 1000)}s)` : "(cancelled)"),
subStatus: (b.name === "Task" || b.name === "task" || b.subStatus)
? (timedOut ? "error" : "cancelled") as const
: b.subStatus,
};
});
return changed ? { ...turn, blocks } : turn;
});
if (!hit && !a) continue;
this._persistTurnsNow(cid, s);
// Find tool name for a proper completed event.
let toolName = "Tool";
for (const turn of s.turns) {
if (turn.role !== "assistant") continue;
const b = turn.blocks.find((x) => x.kind === "tool" && x.callId === data.callId);
if (b && b.kind === "tool") { toolName = b.name; break; }
}
const resultMsg = data.reason === "timeout" ? "(timeout)" : "(cancelled)";
this._view?.webview.postMessage({
type: "agentEvent",
convId: cid,
event: { type: "subagent-event", callId: data.callId, event: { type: "run-status", status: "cancelled" } },
event: {
type: "tool-call-completed",
callId: data.callId,
name: toolName,
status: "error",
result: resultMsg,
},
});
if (toolName === "Task" || toolName === "task") {
this._view?.webview.postMessage({
type: "agentEvent",
convId: cid,
event: {
type: "subagent-event",
callId: data.callId,
event: { type: "run-status", status: data.reason === "timeout" ? "error" : "cancelled" },
},
});
}
break;
}
break;
@@ -1470,7 +1504,14 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
approve: (toolName, input, callId) => this._approveTool(convId, session, toolName, input, callId),
customSubagents: features.subagents,
subagentModel: features.subagentModel,
registerSubagentAbort: (callId, abort) => session.subagentAborts.set(callId, abort),
registerSubagentAbort: (callId, abort) => {
// Chain aborts (tool kill + nested Task child) so timeout fires both.
const prev = session.subagentAborts.get(callId);
session.subagentAborts.set(callId, () => {
try { prev?.(); } catch { /* ignore */ }
try { abort(); } catch { /* ignore */ }
});
},
askUser: (callId, _header, _questions, sig) => this._askUser(session, callId, sig),
onAfterRun: () => runHooks(features.hooks, "afterRun", { prompt: text }),
onBeforeShell: (command) => runBlockingHooks(features.hooks, "beforeShell", { command }),
+1
View File
@@ -103,6 +103,7 @@ const TOOL_TIMEOUT_DEFAULTS: { name: string; sec: number }[] = [
{ name: "TodoRead", sec: 5 },
{ name: "WritePlan", sec: 10 },
{ name: "SwitchMode", sec: 5 },
{ name: "Task", sec: 360 },
];
/** Search terms per section so the nav filter finds settings inside pages too. */
+2
View File
@@ -27,6 +27,7 @@ import {
GitCommitHorizontal,
Globe,
BookOpen,
Copy,
History as HistoryIcon,
Image as ImageIcon,
Infinity as InfinityIcon,
@@ -96,6 +97,7 @@ const MAP = {
book: BookOpen,
more: MoreHorizontal,
download: Download,
copy: Copy,
} satisfies Record<string, LucideIcon>;
export type IconName = keyof typeof MAP;
+176 -6
View File
@@ -227,6 +227,7 @@ function SubagentCard({ block, onOpen }: { block: ToolBlock; onOpen?: (callId: s
<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"} />}
@@ -323,6 +324,64 @@ function StatusIcon({ status }: { status: ToolBlock["status"] }) {
return status === "completed" ? <Icon name="check" className="ok-icon" /> : <Icon name="close" className="err-icon" />;
}
/** Live countdown for a running tool. At 0: cancel/kill via host. */
function useToolCountdown(block: ToolBlock): number | null {
const budget = block.timeoutMs && block.timeoutMs > 0 ? block.timeoutMs : 0;
const started = block.startedAt || 0;
const isTask = block.name === "Task" || block.name === "task";
const subDone = block.subStatus === "finished" || block.subStatus === "cancelled" || block.subStatus === "error";
// Task: keep countdown while nested work is open (incl. bg after parent tool completed).
const running = isTask
? !subDone && (block.status === "running" || block.subStatus === "running" || (!!block.subBlocks?.length && !subDone))
: block.status === "running";
const [left, setLeft] = React.useState<number | null>(() => {
if (!budget || !started || !running) return null;
return Math.max(0, Math.ceil((started + budget - Date.now()) / 1000));
});
const fired = React.useRef(false);
React.useEffect(() => {
fired.current = false;
// Wait until execute has stamped startedAt (budget alone is not enough).
if (!budget || !started || !running) {
setLeft(null);
return;
}
const tick = () => {
const sec = Math.max(0, Math.ceil((started + budget - Date.now()) / 1000));
setLeft(sec);
if (sec <= 0 && !fired.current) {
fired.current = true;
post({ type: "cancelSubagent", callId: block.callId, reason: "timeout" });
}
};
tick();
const id = window.setInterval(tick, 250);
return () => window.clearInterval(id);
}, [budget, started, running, block.callId, block.status, block.subStatus]);
if (!running || 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`;
}
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">
{left === 0 ? "timeout" : formatCountdown(left)}
</span>
);
}
function Diff({ diff }: { diff: string }) {
const lines = diff.split("\n");
const needsExpand = lines.length > 6;
@@ -380,6 +439,7 @@ export function ReadLine({ block }: { block: ToolBlock }) {
</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>
@@ -535,7 +595,8 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
const i = block.input || {};
const meta = toolMeta(block.name, i);
const isEdit = block.name === "edit_file" || block.name === "StrReplace" || block.name === "Write";
const [open, setOpen] = React.useState(isEdit);
const isShell = block.name === "run_terminal" || block.name === "Shell" || block.name === "AwaitShell";
const [open, setOpen] = React.useState(isEdit || isShell);
const onHeaderClick = () => {
if (isEdit) {
@@ -546,10 +607,12 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
};
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")}>
<div className={"tool-card-header " + (isEdit ? "edit-header" : "compact")} onClick={onHeaderClick}>
<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" : "")}>
@@ -559,7 +622,14 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
<span className="ticon">
{isEdit ? <FileIcon path={i.path || ""} fallback={meta.icon} /> : <Icon name={meta.icon} />}
</span>
<span className="label">{meta.label}</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 (
@@ -571,6 +641,8 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
})()}
</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>
@@ -582,8 +654,18 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
) : 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>
) : block.name === "run_terminal" || block.name === "Shell" ? (
<pre className="terminal-output">{block.result ?? "Running…"}</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>
)}
@@ -592,3 +674,91 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
</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 };
}
+117
View File
@@ -154,6 +154,123 @@ body {
.tool-card-header .left .ticon svg { width: 14px; height: 14px; }
.tool-card-header.compact .left .ticon svg { width: 12px; height: 12px; }
.tool-card-header .left .label { font-family: "Cascadia Code", Consolas, monospace; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- Shell / Terminal card ---- */
.tool-card.shell-card {
border-color: color-mix(in srgb, var(--accent, #4a9) 28%, var(--border-soft));
background: var(--bg);
}
.tool-card.shell-card .tool-card-header.shell-header {
height: auto;
min-height: 28px;
padding: 6px 8px;
align-items: flex-start;
gap: 8px;
background: color-mix(in srgb, var(--terminal-bg, var(--bg-dark)) 55%, var(--input-bg));
}
.tool-card.shell-card .tool-card-header .left {
flex: 1 1 auto;
min-width: 0;
align-items: flex-start;
gap: 6px;
}
.tool-card.shell-card .tool-card-header .right {
flex: 0 0 auto;
align-items: center;
padding-top: 1px;
}
.shell-prompt-line {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
font-family: "Cascadia Code", Consolas, monospace;
font-size: 11.5px;
line-height: 1.4;
}
.shell-prompt {
flex: 0 0 auto;
color: var(--accent, #4a9);
font-weight: 600;
user-select: none;
}
.shell-copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--fg-faint);
cursor: pointer;
flex-shrink: 0;
}
.shell-copy-btn:hover {
background: color-mix(in srgb, var(--fg) 12%, transparent);
color: var(--fg);
}
.shell-copy-btn.copied {
color: var(--green, #3c9);
}
.shell-copy-btn:focus-visible {
outline: 1px solid var(--accent, #4a9);
outline-offset: 1px;
}
.shell-cmd {
flex: 1 1 auto;
min-width: 0;
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
.shell-body {
display: flex;
flex-direction: column;
background: var(--terminal-bg, var(--bg-dark));
}
.shell-meta {
padding: 4px 10px;
font-family: "Cascadia Code", Consolas, monospace;
font-size: 10px;
color: var(--fg-faint);
border-bottom: 1px solid var(--border-soft);
letter-spacing: 0.01em;
}
.shell-footer {
padding: 4px 10px 6px;
font-family: "Cascadia Code", Consolas, monospace;
font-size: 10.5px;
color: var(--fg-faint);
border-top: 1px solid var(--border-soft);
}
.shell-footer.ok { color: var(--green, #3c9); }
.shell-footer.err { color: var(--error, #e55); }
.tool-card.shell-card .terminal-output {
padding: 8px 10px;
max-height: 280px;
border: none;
background: transparent;
}
.tool-card.shell-card .tool-card-body {
border-top: 1px solid var(--border-soft);
background: var(--terminal-bg, var(--bg-dark));
}
.tool-timeout {
font-family: "Cascadia Code", Consolas, monospace;
font-size: 10.5px;
font-variant-numeric: tabular-nums;
color: var(--fg-faint);
padding: 1px 5px;
border-radius: 3px;
background: color-mix(in srgb, var(--fg) 8%, transparent);
flex-shrink: 0;
}
.tool-timeout.urgent { color: #e0a040; background: color-mix(in srgb, #e0a040 18%, transparent); }
.tool-timeout.zero { color: #e05555; background: color-mix(in srgb, #e05555 18%, transparent); }
.tool-card-header.edit-header .label { font-family: inherit; font-size: 12px; color: var(--fg); }
.edit-stats { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; flex: 0 0 auto; }
.file-icon-img { width: 14px; height: 14px; display: block; }
+1 -1
View File
@@ -150,7 +150,7 @@ export type OutMessage =
| { type: "deleteConversation"; id: string }
| { type: "persistTurns"; convId?: string; turns: Turn[] }
| { type: "cancelRun"; convId?: string }
| { type: "cancelSubagent"; callId: string }
| { type: "cancelSubagent"; callId: string; reason?: "timeout" | "user" }
| { type: "answerQuestion"; callId: string; answers: Record<string, string[]> }
| { type: "resolveApproval"; requestId: string; approve?: boolean; pattern?: string; addPattern?: "allow" | "deny"; setMode?: ApprovalMode }
| { type: "setMode"; mode: Mode }