Skip to content
This repository was archived by the owner on Aug 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions packages/client/src/components/CollapsedToolGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useDisplayPrefs } from "../hooks/useDisplayPrefs.js";
import { useMobile } from "../hooks/useMobile.js";
import type { ToolCallGroup } from "../lib/group-tool-calls.js";
import { getSummary } from "../lib/tool-summary.js";
import { unwrapXdToolCall } from "../lib/unwrap-xd.js";
import { MarkdownContent } from "./MarkdownContent.js";
import { ToolCallStep } from "./ToolCallStep.js";
import type { ToolContext } from "./tool-renderers/index.js";
Expand All @@ -33,21 +34,21 @@ export function CollapsedToolGroup({ group, toolContext }: Props) {
return key === null || prefs.toolCalls[key];
});
if (visibleMessages.length === 0) return null;
const lastMsg = group.messages[group.messages.length - 1];
const firstArgs = group.messages[0]?.args;
const firstMsg = group.messages[0];
const { effectiveToolName, effectiveArgs } = unwrapXdToolCall(group.toolName, firstMsg?.args, firstMsg?.toolDetails);

return (
<div className={`${isMobile ? "mx-2" : "mx-4"} border-l-2 border-[var(--border-secondary)] pl-3`}>
<button
onClick={() => setExpanded(!expanded)}
title={getSummary(group.toolName, firstArgs)}
title={getSummary(effectiveToolName, effectiveArgs)}
className={`flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] w-full text-left ${isMobile ? "min-h-[44px] py-2" : ""}`}
data-testid="collapsed-group"
>
<span className="inline-flex text-[var(--text-muted)]">
<Icon path={mdiRepeat} size={0.55} />
</span>
<span className="truncate">{getSummary(group.toolName, firstArgs)}</span>
<span className="truncate">{getSummary(effectiveToolName, effectiveArgs)}</span>
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-[var(--bg-tertiary)] text-[var(--text-muted)] text-[10px] font-medium">
×{visibleMessages.length}
</span>
Expand Down
14 changes: 9 additions & 5 deletions packages/client/src/components/ToolBurstGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { ToolBurstGroup as ToolBurstGroupData } from "../lib/group-tool-bur
import type { ChatItem, ToolCallGroup } from "../lib/group-tool-calls.js";
import { t as i18nT } from "../lib/i18n";
import { getSummary, getToolIcon } from "../lib/tool-summary.js";
import { unwrapXdToolCall } from "../lib/unwrap-xd.js";
import { CollapsedToolGroup } from "./CollapsedToolGroup.js";
import { MarkdownContent } from "./MarkdownContent.js";
import { ThinkingBlock } from "./ThinkingBlock.js";
Expand Down Expand Up @@ -75,8 +76,8 @@ function formatDuration(ms: number): string {
function breakdown(members: ChatMessage[]): { name: string; icon: string; count: number }[] {
const counts = new Map<string, number>();
for (const m of members) {
const name = m.toolName ?? "unknown";
counts.set(name, (counts.get(name) ?? 0) + 1);
const { effectiveToolName } = unwrapXdToolCall(m.toolName ?? "unknown", m.args, m.toolDetails);
counts.set(effectiveToolName, (counts.get(effectiveToolName) ?? 0) + 1);
}
return [...counts.entries()].map(([name, count]) => ({ name, icon: getToolIcon(name), count }));
}
Expand Down Expand Up @@ -195,18 +196,20 @@ export function ToolBurstGroup({ burst, toolContext }: Props) {
const doneCount = visibleMembers.filter((m) => m.toolStatus !== "running").length;
const failedCount = visibleMembers.filter((m) => m.toolStatus === "error").length;
const runningMember = visibleMembers.find((m) => m.toolStatus === "running");
const liveCommand = runningMember ? getSummary(runningMember.toolName ?? "unknown", runningMember.args) : "";
const runningUnwrapped = runningMember ? unwrapXdToolCall(runningMember.toolName ?? "unknown", runningMember.args, runningMember.toolDetails) : null;
const liveCommand = runningUnwrapped ? getSummary(runningUnwrapped.effectiveToolName, runningUnwrapped.effectiveArgs) : "";
const durationMs = totalDuration(visibleMembers);
const single = total === 1;
const soleMember = visibleMembers[0];
const soleUnwrapped = unwrapXdToolCall(soleMember.toolName ?? "unknown", soleMember.args, soleMember.toolDetails);

// ── Slots ────────────────────────────────────────────────────────────────
const leftGlyph = (
<span
className={`inline-flex ${isRunning ? "text-yellow-400 tool-group-spin-pulse" : "text-green-400"} ${flash ? "tool-group-flash" : ""}`}
>
<Icon
path={isRunning ? mdiLoading : single ? getToolIcon(soleMember.toolName ?? "unknown") : mdiCheck}
path={isRunning ? mdiLoading : single ? getToolIcon(soleUnwrapped.effectiveToolName) : mdiCheck}
size={0.55}
spin={isRunning}
/>
Expand Down Expand Up @@ -356,10 +359,11 @@ function headerSlots(p: {
}
if (p.single) {
// Single completed call: tool icon (in leftGlyph) + its own summary + duration.
const { effectiveToolName, effectiveArgs } = unwrapXdToolCall(p.soleMember.toolName ?? "unknown", p.soleMember.args, p.soleMember.toolDetails);
return {
title: (
<span className="truncate text-[var(--text-secondary)]" data-testid="tool-burst-summary">
{getSummary(p.soleMember.toolName ?? "unknown", p.soleMember.args)}
{getSummary(effectiveToolName, effectiveArgs)}
</span>
),
meta: (
Expand Down
27 changes: 14 additions & 13 deletions packages/client/src/components/ToolCallStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { ChatImage } from "../lib/event-reducer.js";
import { TRUNCATION_MARKER_PREFIX } from "../lib/event-reducer.js";
import { t as i18nT } from "../lib/i18n";
import { getSummary } from "../lib/tool-summary.js";
import { unwrapXdToolCall } from "../lib/unwrap-xd.js";
import { ElapsedBadge } from "./ElapsedBadge.js";
import { ErrorBoundary } from "./ErrorBoundary.js";
import { ToolStubRow } from "./ToolStubRow.js";
Expand Down Expand Up @@ -87,14 +88,15 @@ const statusIcons: Record<string, ReactNode> = {
};

export function ToolCallStep({ toolName, toolCallId, args, status, result, images, context, startedAt, duration, toolDetails, showResultBody = true, hideStatusIcon = false, onAbort, onForceKill, isSuperseded = false, toolStub }: Props) {
const { effectiveToolName, effectiveArgs } = unwrapXdToolCall(toolName, args, toolDetails);
const isMobile = useMobile();
const hasImages = images && images.length > 0;
const isAgentRunning = toolName === "Agent" && status === "running";
const isInputTool = isInputNeededTool(toolName, args);
const isAgentRunning = effectiveToolName === "Agent" && status === "running";
const isInputTool = isInputNeededTool(effectiveToolName, args);
const isFailedInputTool = isInputTool && status === "error";
const [expanded, setExpanded] = useState(hasImages || isAgentRunning || (isInputTool && !isFailedInputTool));
const [stopState, setStopState] = useState<StopState>("idle");
const Renderer = getToolRenderer(toolName);
const Renderer = getToolRenderer(effectiveToolName);
const pendingInteractiveRequest = context.session?.interactiveRequests.find(
(request) => request.status === "pending" && request.toolCallId === toolCallId,
);
Expand Down Expand Up @@ -123,14 +125,13 @@ export function ToolCallStep({ toolName, toolCallId, args, status, result, image
const registry = useSlotRegistryOrNull();
const pluginClaim = React.useMemo<ClaimEntry | null>(() => {
if (!registry) return null;
const claims = forToolName(registry.getClaims("tool-renderer"), toolName);
const claims = forToolName(registry.getClaims("tool-renderer"), effectiveToolName);
for (const c of claims) {
if (claimShouldRender(c, toolName)) return c;
if (claimShouldRender(c, effectiveToolName)) return c;
}
return null;
}, [registry, toolName]);
}, [registry, effectiveToolName]);
const PluginComponent = pluginClaim?.Component;

// Reset stop state when tool finishes
React.useEffect(() => {
if (status !== "running") setStopState("idle");
Expand Down Expand Up @@ -174,7 +175,7 @@ export function ToolCallStep({ toolName, toolCallId, args, status, result, image
<div className={`${isMobile ? "mx-2" : "mx-4"} border-l-2 border-[var(--border-secondary)] pl-3`}>
<button
onClick={() => setExpanded(!expanded)}
title={getSummary(toolName, args)}
title={getSummary(effectiveToolName, effectiveArgs)}
className={`flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] w-full text-left ${isMobile ? "min-h-[44px] py-2" : ""}`}
>
{!hideStatusIcon && (
Expand All @@ -192,7 +193,7 @@ export function ToolCallStep({ toolName, toolCallId, args, status, result, image
: statusIcons[status]}
</span>
)}
<span className="truncate">{getSummary(toolName, args)}</span>
<span className="truncate">{getSummary(effectiveToolName, effectiveArgs)}</span>
<ElapsedBadge startedAt={startedAt} duration={duration} />
{isSuperseded && (
<span
Expand Down Expand Up @@ -243,8 +244,8 @@ export function ToolCallStep({ toolName, toolCallId, args, status, result, image
{PluginComponent && pluginClaim ? (
<CurrentPluginLayer pluginId={pluginClaim.pluginId}>
<PluginComponent
toolName={toolName}
toolInput={args ?? {}}
toolName={effectiveToolName}
toolInput={effectiveArgs}
sessionId={context.sessionId ?? ""}
status={status}
result={displayResult}
Expand All @@ -255,9 +256,9 @@ export function ToolCallStep({ toolName, toolCallId, args, status, result, image
</CurrentPluginLayer>
) : (
<Renderer
toolName={toolName}
toolName={effectiveToolName}
toolCallId={toolCallId}
args={args}
args={effectiveArgs}
status={status}
result={displayResult}
images={images}
Expand Down
39 changes: 39 additions & 0 deletions packages/client/src/components/tool-renderers/AstToolRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { OpenFileButton } from "./OpenFileButton.js";
import type { ToolRendererProps } from "./types.js";

export function AstToolRenderer({ toolName, args, status, result, context }: ToolRendererProps) {
const filePath = args?.path as string | undefined;
const pattern = (args?.pattern ?? args?.query ?? args?.rule) as string | undefined;

return (
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2 flex-wrap">
<span className="px-1.5 py-0.5 rounded bg-teal-500/20 text-teal-300 font-mono font-bold text-[10px] uppercase">
{toolName}
</span>
{filePath && (
<div className="flex items-center gap-1">
<span className="font-mono text-[var(--text-primary)]">{filePath}</span>
<OpenFileButton filePath={filePath} context={context} />
</div>
)}
</div>

{pattern && (
<div className="text-[var(--text-secondary)] font-mono bg-[var(--bg-tertiary)] p-1.5 rounded border border-[var(--border-subtle)]">
<span className="text-[var(--text-muted)]">pattern:</span> {pattern}
</div>
)}

{status === "running" && !result && (
<div className="text-[var(--text-muted)] italic">AST operation running…</div>
)}

{result && (
<pre className="p-2 rounded bg-[var(--bg-code)] text-[var(--text-secondary)] font-mono whitespace-pre-wrap max-h-60 overflow-auto border border-[var(--border-subtle)]">
{result}
</pre>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ToolResultImages } from "./ToolResultImages.js";
import type { ToolRendererProps } from "./types.js";

export function BrowserToolRenderer({ args, status, result, images }: ToolRendererProps) {
const action = (args?.action as string) || "browse";
const url = (args?.url as string) || (args?.target as string);
const selector = args?.selector as string | undefined;
const text = args?.text as string | undefined;

return (
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2 flex-wrap">
<span className="px-1.5 py-0.5 rounded bg-sky-500/20 text-sky-300 font-mono font-bold text-[10px] uppercase">
{action}
</span>
{url && <span className="font-mono text-[var(--text-primary)] truncate max-w-lg">{url}</span>}
</div>

{(selector || text) && (
<div className="flex gap-3 text-[var(--text-tertiary)] text-[11px]">
{selector && <div><span className="font-semibold text-[var(--text-muted)]">Selector:</span> <code className="bg-[var(--bg-tertiary)] px-1 rounded">{selector}</code></div>}
{text && <div><span className="font-semibold text-[var(--text-muted)]">Text:</span> {text}</div>}
</div>
)}

{images && images.length > 0 && (
<div className="pt-1">
<ToolResultImages images={images} alt="Browser screenshot" />
</div>
)}

{status === "running" && !result && (!images || images.length === 0) && (
<div className="text-[var(--text-muted)] italic">Browser active…</div>
)}

{result && (
<pre className="p-2 rounded bg-[var(--bg-code)] text-[var(--text-secondary)] font-mono whitespace-pre-wrap max-h-60 overflow-auto border border-[var(--border-subtle)]">
{result}
</pre>
)}
</div>
);
}
60 changes: 60 additions & 0 deletions packages/client/src/components/tool-renderers/EvalToolRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { getSyntaxTheme } from "../../lib/syntax-theme.js";
import { useThemeContext } from "../ThemeProvider.js";
import type { ToolRendererProps } from "./types.js";

export function EvalToolRenderer({ args, status, result }: ToolRendererProps) {
let syntaxStyle;
try {
const { resolved: theme, themeName } = useThemeContext();
syntaxStyle = getSyntaxTheme(theme, themeName);
} catch {
syntaxStyle = undefined;
}

const language = (args?.language as string) || "py";
const code = (args?.code as string) || (args?.raw as string);
const title = args?.title as string | undefined;

return (
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] font-mono font-bold text-[10px] text-[var(--accent)] uppercase">
{language}
</span>
{title && <span className="font-semibold text-[var(--text-secondary)]">{title}</span>}
</div>

{code && (
<div className="max-h-80 overflow-auto rounded border border-[var(--border-subtle)]">
{syntaxStyle ? (
<SyntaxHighlighter
style={syntaxStyle}
language={language === "py" ? "python" : language === "js" ? "javascript" : language}
PreTag="div"
showLineNumbers={true}
customStyle={{ margin: 0, padding: "0.5rem", fontSize: "12px", background: "var(--bg-code)" }}
>
{code}
</SyntaxHighlighter>
) : (
<pre className="p-2 bg-[var(--bg-code)] font-mono text-[var(--text-secondary)]">{code}</pre>
)}
</div>
)}

{status === "running" && !result && (
<div className="text-[var(--text-muted)] italic">Executing code…</div>
)}

{result && (
<div className="space-y-1">
<div className="text-[10px] uppercase font-semibold text-[var(--text-muted)]">Output</div>
<pre className="p-2 rounded bg-[var(--bg-code)] text-[var(--text-secondary)] font-mono whitespace-pre-wrap max-h-60 overflow-auto border border-[var(--border-subtle)]">
{result}
</pre>
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ToolRendererProps } from "./types.js";

export function GithubToolRenderer({ args, status, result }: ToolRendererProps) {
const op = (args?.op ?? args?.action ?? args?.command) as string | undefined;
const repo = args?.repo as string | undefined;
const issueOrPr = (args?.issue ?? args?.pr ?? args?.number) as string | number | undefined;
const title = (args?.title ?? args?.subject) as string | undefined;

return (
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2 flex-wrap">
<span className="px-1.5 py-0.5 rounded bg-slate-500/20 text-slate-300 font-mono font-bold text-[10px] uppercase">
github {op ?? ""}
</span>
{repo && <span className="font-mono text-[var(--text-secondary)]">{repo}</span>}
{issueOrPr && <span className="font-mono font-semibold text-[var(--accent)]">#{issueOrPr}</span>}
</div>

{title && (
<div className="font-semibold text-[var(--text-primary)]">
{title}
</div>
)}

{status === "running" && !result && (
<div className="text-[var(--text-muted)] italic">GitHub request in progress…</div>
)}

{result && (
<pre className="p-2 rounded bg-[var(--bg-code)] text-[var(--text-secondary)] font-mono whitespace-pre-wrap max-h-60 overflow-auto border border-[var(--border-subtle)]">
{result}
</pre>
)}
</div>
);
}
39 changes: 39 additions & 0 deletions packages/client/src/components/tool-renderers/GoalToolRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ToolRendererProps } from "./types.js";

export function GoalToolRenderer({ args, status, result }: ToolRendererProps) {
const objective = (args?.objective ?? args?.goal ?? args?.title) as string | undefined;
const goalStatus = (args?.status as string) || "active";

const statusStyles: Record<string, string> = {
active: "bg-sky-500/20 text-sky-300",
accomplished: "bg-emerald-500/20 text-emerald-300",
abandoned: "bg-rose-500/20 text-rose-300",
};

return (
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<span className={`px-1.5 py-0.5 rounded font-mono font-bold text-[10px] uppercase ${statusStyles[goalStatus] ?? "bg-slate-500/20 text-slate-300"}`}>
{goalStatus}
</span>
<span className="font-semibold text-[var(--text-primary)]">Goal</span>
</div>

{objective && (
<div className="p-2 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] font-medium">
{objective}
</div>
)}

{status === "running" && !result && (
<div className="text-[var(--text-muted)] italic">Evaluating goal…</div>
)}

{result && (
<pre className="p-2 rounded bg-[var(--bg-code)] text-[var(--text-secondary)] font-mono whitespace-pre-wrap max-h-60 overflow-auto border border-[var(--border-subtle)]">
{result}
</pre>
)}
</div>
);
}
Loading
Loading