diff --git a/src/agent/loop.ts b/src/agent/loop.ts index 6702e16..4a9795a 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -246,6 +246,8 @@ export async function runAgent(opts: RunAgentOptions): Promise { }); 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 { 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 { // 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 { } } 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 | undefined; - if (limitMs > 0) { - toolTimer = setTimeout(() => { - try { toolAc.abort(); } catch { /* ignore */ } - }, Math.max(500, limitMs - 400)); - } let r: Awaited>; + 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 { 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)}` }; diff --git a/src/agent/tools/shared.ts b/src/agent/tools/shared.ts index a05d19b..f37ab19 100644 --- a/src/agent/tools/shared.ts +++ b/src/agent/tools/shared.ts @@ -76,11 +76,13 @@ export const TOOL_TIMEOUT_MS: Record = { 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 = Object.fromEntries( @@ -109,9 +111,19 @@ export function setToolTimeoutOverrides(sec: Record | undefined) * into the tool when possible (Shell/Grep honor it). * Always settles (never hangs) even if `p` never resolves. */ -export function withToolTimeout(p: Promise, ms: number, label: string): Promise { - // 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( + p: Promise, + ms: number, + label: string, + onTimeout?: () => void, +): Promise { + // 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(p: Promise, 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( diff --git a/src/agent/tools/shell.ts b/src/agent/tools/shell.ts index d281151..1d2c877 100644 --- a/src/agent/tools/shell.ts +++ b/src/agent/tools/shell.ts @@ -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 { diff --git a/src/agent/types.ts b/src/agent/types.ts index 1e25bfe..93b9888 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -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 } diff --git a/src/shared/turns.ts b/src/shared/turns.ts index ead26d6..3321530 100644 --- a/src/shared/turns.ts +++ b/src/shared/turns.ts @@ -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; } diff --git a/src/ui/sidebarProvider.ts b/src/ui/sidebarProvider.ts index 970037d..bdd2a1e 100644 --- a/src/ui/sidebarProvider.ts +++ b/src/ui/sidebarProvider.ts @@ -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 }), diff --git a/webview-ui/settings/App.tsx b/webview-ui/settings/App.tsx index ecb60de..f9e44f6 100644 --- a/webview-ui/settings/App.tsx +++ b/webview-ui/settings/App.tsx @@ -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. */ diff --git a/webview-ui/shared/icons.tsx b/webview-ui/shared/icons.tsx index 8c8c36c..da49f07 100644 --- a/webview-ui/shared/icons.tsx +++ b/webview-ui/shared/icons.tsx @@ -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; export type IconName = keyof typeof MAP; diff --git a/webview-ui/sidebar/components/Tool.tsx b/webview-ui/sidebar/components/Tool.tsx index 95bd2f3..b7fcab7 100644 --- a/webview-ui/sidebar/components/Tool.tsx +++ b/webview-ui/sidebar/components/Tool.tsx @@ -227,6 +227,7 @@ function SubagentCard({ block, onOpen }: { block: ToolBlock; onOpen?: (callId: s {i.description || "Subagent"} + {running ? `${steps} steps…` : `${steps} steps`} {isReadonlySubagent(i) ? "Explore" : "Agent"} {running ? : } @@ -323,6 +324,64 @@ function StatusIcon({ status }: { status: ToolBlock["status"] }) { return status === "completed" ? : ; } +/** 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(() => { + 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 ( + + {left === 0 ? "timeout" : formatCountdown(left)} + + ); +} + 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 }) { Read {basename(i.path)} {rangeTxt ? "L" + rangeTxt : ""} + @@ -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 ( -
-
+
+
{!isEdit && ( @@ -559,7 +622,14 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl {isEdit ? : } - {meta.label} + {isShell ? ( + + $ + {shellCmd || "…"} + + ) : ( + {meta.label} + )} {isEdit && block.diff && (() => { const s = diffStats(block.diff); return ( @@ -571,6 +641,8 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl })()}
+ {isShell && shellCmd ? : null} + {!isEdit && {meta.badge}}
@@ -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.
{editPreview(block.name, i) || "Writing…"}
- ) : block.name === "run_terminal" || block.name === "Shell" ? ( -
{block.result ?? "Running…"}
+ ) : isShell && shellParsed ? ( +
+ {shellParsed.meta ?
{shellParsed.meta}
: null} +
+                {shellParsed.body || (block.status === "running" ? "Running…" : "")}
+              
+ {shellParsed.footer ? ( +
+ {shellParsed.footer} +
+ ) : null} +
) : (
{block.status === "running" ? "Running…" : (block.result || "").slice(0, 4000)}
)} @@ -592,3 +674,91 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
); } + +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 ( + + ); +} + +/** 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 }; +} diff --git a/webview-ui/sidebar/sidebar.css b/webview-ui/sidebar/sidebar.css index 2323b4f..9c2dcc0 100644 --- a/webview-ui/sidebar/sidebar.css +++ b/webview-ui/sidebar/sidebar.css @@ -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; } diff --git a/webview-ui/sidebar/types.ts b/webview-ui/sidebar/types.ts index 2898bcf..c1540b7 100644 --- a/webview-ui/sidebar/types.ts +++ b/webview-ui/sidebar/types.ts @@ -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 } | { type: "resolveApproval"; requestId: string; approve?: boolean; pattern?: string; addPattern?: "allow" | "deny"; setMode?: ApprovalMode } | { type: "setMode"; mode: Mode }