<!-- ABOUTME: Renders a collapsible tool call block with metadata tags and content. -->
<!-- ABOUTME: Supports Task tool calls with inline subagent conversation expansion. -->
<script lang="ts">
import type { ToolCall } from "../../api/types.js";
import SubagentInline from "./SubagentInline.svelte";
import {
extractToolParamMeta,
generateFallbackContent,
} from "../../utils/tool-params.js";
import { applyHighlight, escapeHTML } from "../../utils/highlight.js";
interface Props {
content: string;
label?: string;
toolCall?: ToolCall;
highlightQuery?: string;
isCurrentHighlight?: boolean;
}
let { content, label, toolCall, highlightQuery = "", isCurrentHighlight = false }: Props = $props();
let userCollapsed: boolean = $state(true);
let userOutputCollapsed: boolean = $state(true);
let userOverride: boolean = $state(false);
let userOutputOverride: boolean = $state(false);
let searchExpandedInput: boolean = $state(false);
let searchExpandedOutput: boolean = $state(false);
let prevQuery: string = "";
// Auto-expand when a search match exists in input or output
// content. Only reset user overrides when the query itself
// changes, not when content updates (e.g. during streaming).
$effect(() => {
const hq = highlightQuery;
if (!hq.trim()) {
searchExpandedInput = false;
searchExpandedOutput = false;
prevQuery = hq;
return;
}
const q = hq.toLowerCase();
const inputText = (
taskPrompt ?? content ?? fallbackContent ?? ""
).toLowerCase();
const outputText = (
toolCall?.result_content ?? ""
).toLowerCase();
searchExpandedInput = inputText.includes(q);
searchExpandedOutput = outputText.includes(q);
if (hq !== prevQuery) {
userOverride = false;
userOutputOverride = false;
prevQuery = hq;
}
});
let collapsed = $derived(
userOverride ? userCollapsed
: (searchExpandedInput || searchExpandedOutput) ? false
: userCollapsed,
);
let outputCollapsed = $derived(
userOutputOverride ? userOutputCollapsed
: searchExpandedOutput ? false
: userOutputCollapsed,
);
let outputPreviewLine = $derived.by(() => {
const rc = toolCall?.result_content;
if (!rc) return "";
const nl = rc.indexOf("\n");
return (nl === -1 ? rc : rc.slice(0, nl)).slice(0, 100);
});
/** Parsed input parameters from structured tool call data */
let inputParams = $derived.by(() => {
if (!toolCall?.input_json) return null;
try {
return JSON.parse(toolCall.input_json);
} catch {
return null;
}
});
let previewLine = $derived.by(() => {
const line = content.split("\n")[0]?.slice(0, 100) ?? "";
if (line) return line;
// For Edit/Write/Read with no content, show file path as preview
const filePath =
inputParams?.file_path ?? inputParams?.path ?? inputParams?.filePath;
if (filePath) return String(filePath).slice(0, 100);
// For glob/search tools, show pattern
if (inputParams?.pattern) return String(inputParams.pattern).slice(0, 100);
return "";
});
/** For Task tool calls, extract key metadata fields */
let taskMeta = $derived.by(() => {
if (!isTask || !inputParams)
return null;
const meta: { label: string; value: string }[] = [];
if (inputParams.subagent_type) {
meta.push({
label: "type",
value: inputParams.subagent_type,
});
}
if (inputParams.description) {
meta.push({
label: "description",
value: inputParams.description,
});
}
return meta.length ? meta : null;
});
/** For TaskCreate, show subject and description */
let taskCreateMeta = $derived.by(() => {
if (toolCall?.tool_name !== "TaskCreate" || !inputParams)
return null;
const meta: { label: string; value: string }[] = [];
if (inputParams.subject) {
meta.push({ label: "subject", value: inputParams.subject });
}
if (inputParams.description) {
meta.push({ label: "description", value: inputParams.description });
}
return meta.length ? meta : null;
});
/** For TaskUpdate, show taskId and status */
let taskUpdateMeta = $derived.by(() => {
if (toolCall?.tool_name !== "TaskUpdate" || !inputParams)
return null;
const meta: { label: string; value: string }[] = [];
if (inputParams.taskId) {
meta.push({ label: "task", value: `#${inputParams.taskId}` });
}
if (inputParams.status) {
meta.push({ label: "status", value: inputParams.status });
}
if (inputParams.subject) {
meta.push({ label: "subject", value: inputParams.subject });
}
return meta.length ? meta : null;
});
/** Extract metadata tags for common tool types */
let toolParamMeta = $derived.by(() => {
if (!inputParams || !toolCall) return null;
return extractToolParamMeta(toolCall.tool_name, inputParams, toolCall.category);
});
/** Combined metadata for any tool type */
let metaTags = $derived(
taskMeta ??
taskCreateMeta ??
taskUpdateMeta ??
toolParamMeta ??
null,
);
/** Generate content from input_json when regex content is empty.
* Try category first (e.g. "Edit"), then fall back to raw tool_name
* (e.g. "apply_patch") so tools that don't match their category's
* specific field patterns still get the generic key-value output. */
let fallbackContent = $derived.by(() => {
if (content || !inputParams || !toolCall) return null;
const cat = toolCall.category || null;
const result = cat ? generateFallbackContent(cat, inputParams) : null;
return result ?? generateFallbackContent(toolCall.tool_name, inputParams);
});
let isTask = $derived(
toolCall?.tool_name === "Task" ||
toolCall?.tool_name === "Agent" ||
toolCall?.category === "Task" ||
(toolCall?.tool_name?.includes("subagent") ?? false),
);
let taskPrompt = $derived(
isTask ? inputParams?.prompt ?? null : null,
);
let subagentSessionId = $derived(
isTask ? toolCall?.subagent_session_id ?? null : null,
);
</script>
<div class="tool-block">
<button
class="tool-header"
onclick={() => {
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
userCollapsed = !userCollapsed;
userOverride = true;
}}
>
<span class="tool-chevron" class:open={!collapsed}>
▸
</span>
{#if label}
<span class="tool-label">{label}</span>
{/if}
{#if collapsed && previewLine}
<span class="tool-preview">{previewLine}</span>
{/if}
</button>
{#if !collapsed}
{#if metaTags}
<div class="tool-meta">
{#each metaTags as { label: metaLabel, value }}
<span class="meta-tag">
<span class="meta-label">{metaLabel}:</span>
{value}
</span>
{/each}
</div>
{/if}
{#if taskPrompt}
<pre class="tool-content" use:applyHighlight={{ q: highlightQuery, current: isCurrentHighlight, content: taskPrompt }}>{@html escapeHTML(taskPrompt)}</pre>
{:else if content}
<pre class="tool-content" use:applyHighlight={{ q: highlightQuery, current: isCurrentHighlight, content }}>{@html escapeHTML(content)}</pre>
{:else if fallbackContent}
<pre class="tool-content" use:applyHighlight={{ q: highlightQuery, current: isCurrentHighlight, content: fallbackContent }}>{@html escapeHTML(fallbackContent)}</pre>
{/if}
{#if toolCall?.result_content}
<button
class="output-header"
onclick={(e) => {
e.stopPropagation();
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
userOutputCollapsed = !userOutputCollapsed;
userOutputOverride = true;
}}
>
<span class="tool-chevron" class:open={!outputCollapsed}>
▸
</span>
<span class="output-label">output</span>
{#if outputCollapsed && outputPreviewLine}
<span class="tool-preview">{outputPreviewLine}</span>
{/if}
</button>
{#if !outputCollapsed}
<pre class="tool-content output-content" use:applyHighlight={{ q: highlightQuery, current: isCurrentHighlight, content: toolCall.result_content }}>{@html escapeHTML(toolCall.result_content)}</pre>
{/if}
{/if}
{/if}
{#if subagentSessionId}
<SubagentInline sessionId={subagentSessionId} />
{/if}
</div>
<style>
.tool-block {
border-left: 2px solid var(--vscode-editorWarning-foreground);
background: var(--wb-tool-bg);
border-radius: 0 var(--wsb-radius-sm) var(--wsb-radius-sm) 0;
margin: 0;
}
.tool-header {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-6) var(--wb-spacing-10);
width: 100%;
text-align: left;
font-size: var(--wb-font-size-12);
color: var(--vscode-foreground);
min-width: 0;
border-radius: 0 var(--wsb-radius-sm) var(--wsb-radius-sm) 0;
transition: background 0.1s;
user-select: text;
}
.tool-header:hover {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
}
.tool-chevron {
display: inline-block;
font-size: var(--wb-font-size-10);
transition: transform 0.15s;
flex-shrink: 0;
color: var(--vscode-disabledForeground);
}
.tool-chevron.open {
transform: rotate(90deg);
}
.tool-label {
font-family: var(--wsb-font-mono);
font-weight: 500;
font-size: var(--wb-font-size-11);
color: var(--vscode-editorWarning-foreground);
white-space: nowrap;
flex-shrink: 0;
}
.tool-preview {
font-family: var(--wsb-font-mono);
font-size: var(--wb-font-size-12);
color: var(--vscode-disabledForeground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.tool-meta {
display: flex;
flex-wrap: wrap;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-6) var(--wb-spacing-14);
border-top: 1px solid var(--vscode-widget-border);
}
.meta-tag {
font-family: var(--wsb-font-mono);
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
background: var(--vscode-input-background);
padding: var(--wb-spacing-2) var(--wb-spacing-6);
border-radius: var(--wsb-radius-sm);
}
.meta-label {
color: var(--vscode-foreground);
font-weight: 500;
}
.tool-content {
padding: var(--wb-spacing-8) var(--wb-spacing-14) var(--wb-spacing-10);
font-family: var(--wsb-font-mono);
font-size: var(--wb-font-size-12);
color: var(--vscode-foreground);
line-height: 1.5;
overflow-x: auto;
border-top: 1px solid var(--vscode-widget-border);
}
.output-header {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-5) var(--wb-spacing-10);
width: 100%;
text-align: left;
font-size: var(--wb-font-size-12);
color: var(--vscode-foreground);
min-width: 0;
border-top: 1px solid var(--vscode-widget-border);
transition: background 0.1s;
user-select: text;
}
.output-header:hover {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
}
.output-label {
font-family: var(--wsb-font-mono);
font-weight: 500;
font-size: var(--wb-font-size-11);
color: var(--vscode-foreground);
white-space: nowrap;
flex-shrink: 0;
}
.output-content {
max-height: 300px;
overflow-y: auto;
}
</style>