import { createSignal, Show, For, createEffect, onCleanup } from "solid-js" import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state" import { Markdown } from "./markdown" import { ToolCallDiffViewer } from "./diff-viewer" import { useTheme } from "../lib/theme" import { getLanguageFromPath } from "../lib/markdown" import { isRenderableDiffText } from "../lib/diff-utils" import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache" import { preferences, setDiffViewMode, type DiffViewMode } from "../stores/preferences" import type { TextPart, SDKPart, ClientPart } from "../types/message" type ToolCallPart = Extract // Import ToolState types from SDK type ToolState = import("@opencode-ai/sdk").ToolState type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted type ToolStateError = import("@opencode-ai/sdk").ToolStateError // Type guards function isToolStateRunning(state: ToolState): state is ToolStateRunning { return state.status === "running" } function isToolStateCompleted(state: ToolState): state is ToolStateCompleted { return state.status === "completed" } function isToolStateError(state: ToolState): state is ToolStateError { return state.status === "error" } const toolScrollState = new Map() function makeRenderCacheKey( toolCallId?: string | null, messageId?: string, messageVersion?: number, partVersion?: number, ) { const suffix = `${messageVersion ?? 0}:${partVersion ?? 0}` const keyBase = `${messageId}:${toolCallId}` return `${keyBase}::${suffix}` } function updateScrollState(id: string, element: HTMLElement) { if (!id) return const distanceFromBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) const atBottom = distanceFromBottom <= 2 toolScrollState.set(id, { scrollTop: element.scrollTop, atBottom }) } function restoreScrollState(id: string, element: HTMLElement) { if (!id) return const state = toolScrollState.get(id) if (!state) { requestAnimationFrame(() => { element.scrollTop = element.scrollHeight updateScrollState(id, element) }) return } requestAnimationFrame(() => { if (state.atBottom) { element.scrollTop = element.scrollHeight } else { const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0) element.scrollTop = Math.min(state.scrollTop, maxScrollTop) } updateScrollState(id, element) }) } interface ToolCallProps { toolCall: Extract toolCallId?: string messageId?: string messageVersion?: number partVersion?: number } function getToolIcon(tool: string): string { switch (tool) { case "bash": return "โšก" case "edit": return "โœ๏ธ" case "read": return "๐Ÿ“–" case "write": return "๐Ÿ“" case "glob": return "๐Ÿ”" case "grep": return "๐Ÿ”Ž" case "webfetch": return "๐ŸŒ" case "task": return "๐ŸŽฏ" case "todowrite": case "todoread": return "๐Ÿ“‹" case "list": return "๐Ÿ“" case "patch": return "๐Ÿ”ง" default: return "๐Ÿ”ง" } } function getToolName(tool: string): string { switch (tool) { case "bash": return "Shell" case "webfetch": return "Fetch" case "invalid": return "Invalid" case "todowrite": case "todoread": return "Plan" default: const normalized = tool.replace(/^opencode_/, "") return normalized.charAt(0).toUpperCase() + normalized.slice(1) } } function getRelativePath(path: string): string { if (!path) return "" const parts = path.split("/") return parts.slice(-1)[0] || path } const diffCapableTools = new Set(["edit", "patch"]) interface DiffPayload { diffText: string filePath?: string } function extractDiffPayload(toolName: string, state: ToolState): DiffPayload | null { if (!diffCapableTools.has(toolName)) return null if (!state) return null const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) ? state.metadata || {} : {} const output = isToolStateCompleted(state) ? state.output : undefined const candidates = [metadata.diff, output, metadata.output] let diffText: string | null = null for (const candidate of candidates) { if (typeof candidate === "string" && isRenderableDiffText(candidate)) { diffText = candidate break } } if (!diffText) { return null } const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) ? state.input as Record : {} const filePath = (typeof input.filePath === "string" ? input.filePath : undefined) || (typeof metadata.filePath === "string" ? metadata.filePath : undefined) || (typeof input.path === "string" ? input.path : undefined) return { diffText, filePath } } export default function ToolCall(props: ToolCallProps) { const { isDark } = useTheme() const toolCallId = () => props.toolCallId || props.toolCall?.id || "" const expanded = () => isToolCallExpanded(toolCallId()) const [initializedId, setInitializedId] = createSignal(null) let scrollContainerRef: HTMLDivElement | undefined const handleScrollRendered = () => { const id = toolCallId() if (!id || !scrollContainerRef) return restoreScrollState(id, scrollContainerRef) } const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => { const resolvedElement = element || undefined scrollContainerRef = resolvedElement const id = toolCallId() if (!resolvedElement || !id) return if (!toolScrollState.has(id)) { requestAnimationFrame(() => { if (!scrollContainerRef || toolCallId() !== id) return scrollContainerRef.scrollTop = scrollContainerRef.scrollHeight updateScrollState(id, scrollContainerRef) }) } else { restoreScrollState(id, resolvedElement) } } createEffect(() => { const id = toolCallId() if (!id || initializedId() === id) return const tool = props.toolCall?.tool || "" const shouldExpand = tool !== "read" setToolCallExpanded(id, shouldExpand) setInitializedId(id) }) // Cleanup cache entry when component unmounts or toolCallId changes createEffect(() => { const id = toolCallId() if (!id) return onCleanup(() => { toolScrollState.delete(id) }) }) createEffect(() => { if (props.toolCall?.tool !== "task") return const state = props.toolCall?.state const summarySignature = JSON.stringify( state && (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) ? state.metadata?.summary ?? [] : [] ) requestAnimationFrame(() => { void summarySignature handleScrollRendered() }) }) const statusIcon = () => { const status = props.toolCall?.state?.status || "" switch (status) { case "pending": return "โธ" case "running": return "โณ" case "completed": return "โœ“" case "error": return "โœ—" default: return "" } } const statusClass = () => { const status = props.toolCall?.state?.status || "pending" return `tool-call-status-${status}` } function toggle() { toggleToolCallExpanded(toolCallId()) } const renderToolAction = () => { const toolName = props.toolCall?.tool || "" switch (toolName) { case "task": return "Delegating..." case "bash": return "Writing command..." case "edit": return "Preparing edit..." case "webfetch": return "Fetching from the web..." case "glob": return "Finding files..." case "grep": return "Searching content..." case "list": return "Listing directory..." case "read": return "Reading file..." case "write": return "Preparing write..." case "todowrite": case "todoread": return "Planning..." case "patch": return "Preparing patch..." default: return "Working..." } } const getTodoTitle = () => { const state = props.toolCall?.state || {} if (state.status !== "completed") return "Plan" const metadata = state.metadata || {} const todos = metadata.todos || [] if (!Array.isArray(todos) || todos.length === 0) return "Plan" const counts = { pending: 0, completed: 0 } for (const todo of todos) { const status = todo.status || "pending" if (status in counts) counts[status as keyof typeof counts]++ } const total = todos.length if (counts.pending === total) return "Creating plan" if (counts.completed === total) return "Completing plan" return "Updating plan" } const renderToolTitle = () => { const toolName = props.toolCall?.tool || "" const state = props.toolCall?.state if (!state) return renderToolAction() if (state.status === "pending") return renderToolAction() const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) ? (state.input as Record) : {} as Record if (isToolStateRunning(state) && state.title) { return state.title } if (isToolStateCompleted(state)) { return state.title } const name = getToolName(toolName) switch (toolName) { case "read": if (typeof input.filePath === "string") { return `${name} ${getRelativePath(input.filePath)}` } return name case "edit": case "write": if (typeof input.filePath === "string") { return `${name} ${getRelativePath(input.filePath)}` } return name case "bash": if (typeof input.description === "string") { return `${name} ${input.description}` } return name case "task": const description = input.description const subagent = input.subagent_type if (description && subagent) { return `${name}[${subagent}] ${description}` } else if (description) { return `${name} ${description}` } return name case "webfetch": if (input.url) { return `${name} ${input.url}` } return name case "todowrite": return getTodoTitle() case "todoread": return "Plan" case "invalid": if (typeof input.tool === "string") { return getToolName(input.tool) } return name default: return name } } function renderToolBody() { const toolName = props.toolCall?.tool || "" const state = props.toolCall?.state || {} if (toolName === "todoread") { return null } if (state.status === "pending") { return null } if (toolName === "todowrite") { return renderTodowriteTool() } if (toolName === "task") { return renderTaskTool() } const diffPayload = extractDiffPayload(toolName, state) if (diffPayload) { return renderDiffTool(diffPayload) } return renderMarkdownTool(toolName, state) } function renderDiffTool(payload: DiffPayload) { const relativePath = payload.filePath ? getRelativePath(payload.filePath) : "" const toolbarLabel = relativePath ? `Diff ยท ${relativePath}` : "Diff" const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion) const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode const themeKey = isDark() ? "dark" : "light" // Check if we have valid cache let cachedHtml: string | undefined if (cacheKey) { const cached = getToolRenderCache(cacheKey) const currentMode = diffMode() if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) { cachedHtml = cached.html } } const handleModeChange = (mode: DiffViewMode) => { setDiffViewMode(mode) } const handleDiffRendered = () => { if (cacheKey && !cachedHtml) { // Cache will be updated by the diff viewer component itself // We'll capture HTML from the rendered component } handleScrollRendered() } return (
initializeScrollContainer(element)} onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} >
{toolbarLabel}
) } function renderMarkdownTool(toolName: string, state: ToolState) { const content = getMarkdownContent(toolName, state) if (!content) { return null } const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch" const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}` const disableHighlight = state?.status === "running" const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion) const markdownPart: TextPart = { type: "text", text: content } if (cacheKey) { const cached = getToolRenderCache(cacheKey) if (cached) { markdownPart.renderCache = cached } } const handleMarkdownRendered = () => { if (cacheKey) { setToolRenderCache(cacheKey, markdownPart.renderCache) } handleScrollRendered() } return (
initializeScrollContainer(element)} onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} >
) } function getMarkdownContent(toolName: string, state: ToolState): string | null { if (!state) return null const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) ? state.input as Record : {} const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) ? state.metadata || {} : {} switch (toolName) { case "read": { const preview = typeof metadata.preview === "string" ? metadata.preview : null const language = getLanguageFromPath(typeof input.filePath === "string" ? input.filePath : "") return ensureMarkdownContent(preview, language, true) } case "edit": { const diffText = typeof metadata.diff === "string" ? metadata.diff : null const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null return ensureMarkdownContent(diffText || fallback, "diff", true) } case "write": { const content = typeof input.content === "string" ? input.content : null const metadataContent = typeof metadata.content === "string" ? metadata.content : null const language = getLanguageFromPath(typeof input.filePath === "string" ? input.filePath : "") return ensureMarkdownContent(content || metadataContent, language, true) } case "patch": { const patchContent = typeof metadata.diff === "string" ? metadata.diff : null const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null return ensureMarkdownContent(patchContent || fallback, "diff", true) } case "bash": { const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" const outputResult = formatUnknown( isToolStateCompleted(state) ? state.output : (isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output : undefined ) const parts = [command, outputResult?.text].filter(Boolean) const combined = parts.join("\n") return ensureMarkdownContent(combined, "bash", true) } case "webfetch": { const result = formatUnknown( isToolStateCompleted(state) ? state.output : (isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output : undefined ) if (!result) return null return ensureMarkdownContent(result.text, result.language, true) } default: { const result = formatUnknown( isToolStateCompleted(state) ? state.output : (isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output : metadata.diff ?? metadata.preview ?? input.content, ) if (!result) return null return ensureMarkdownContent(result.text, result.language, true) } } } function ensureMarkdownContent( value: string | null, language?: string, forceFence = false, ): string | null { if (!value) { return null } const trimmed = value.replace(/\s+$/, "") if (!trimmed) { return null } const startsWithFence = trimmed.trimStart().startsWith("```") if (startsWithFence && !forceFence) { return trimmed } const langSuffix = language ? language : "" if (language || forceFence) { return `\u0060\u0060\u0060${langSuffix}\n${trimmed}\n\u0060\u0060\u0060` } return trimmed } function formatUnknown(value: unknown): { text: string; language?: string } | null { if (value === null || value === undefined) { return null } if (typeof value === "string") { return { text: value } } if (typeof value === "number" || typeof value === "boolean") { return { text: String(value) } } if (Array.isArray(value)) { const parts = value .map((item) => { const formatted = formatUnknown(item) return formatted?.text ?? "" }) .filter(Boolean) if (parts.length === 0) { return null } return { text: parts.join("\n") } } if (typeof value === "object") { try { return { text: JSON.stringify(value, null, 2), language: "json" } } catch (error) { console.error("Failed to stringify tool call output", error) return { text: String(value) } } } return null } const renderTodowriteTool = () => { const state = props.toolCall?.state if (!state) return null const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) ? state.metadata || {} : {} const todos = metadata.todos || [] if (!Array.isArray(todos) || todos.length === 0) { return null } const getStatusLabel = (status: string): string => { switch (status) { case "completed": return "Completed" case "in_progress": return "In progress" case "cancelled": return "Cancelled" default: return "Pending" } } const shouldShowTag = (status: string) => status === "in_progress" || status === "cancelled" return (
{(todo) => { const content = typeof todo.content === "string" ? todo.content.trim() : "" if (!content) return null const status = typeof todo.status === "string" ? todo.status : "pending" const label = getStatusLabel(status) return (
{content} {label}
) }}
) } const renderTaskTool = () => { const state = props.toolCall?.state if (!state) return null const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) ? state.metadata || {} : {} const summary = metadata.summary || [] if (!Array.isArray(summary) || summary.length === 0) { return null } return (
initializeScrollContainer(element)} onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} >
{(item) => { const tool = item.tool || "unknown" const itemInput = item.state?.input || {} const icon = getToolIcon(tool) let description = "" switch (tool) { case "bash": description = itemInput.description || itemInput.command || "" break case "edit": case "read": case "write": description = `${tool} ${getRelativePath(itemInput.filePath || "")}` break default: description = tool } return (
{icon} {description}
) }}
) } const renderError = () => { const state = props.toolCall?.state || {} if (state.status === "error" && state.error) { return (
Error: {state.error}
) } return null } const toolName = () => props.toolCall?.tool || "" const status = () => props.toolCall?.state?.status || "" return (
{renderToolBody()} {renderError()}
Waiting for permission...
) }