mirror of
https://github.com/PawanOsman/ChatGPT.git
synced 2026-07-18 08:05:57 +02:00
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:
+70
-17
@@ -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)}` };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user