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