import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js" import { messageStoreBus } from "../stores/message-v2/bus" 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 { useGlobalCache } from "../lib/hooks/use-global-cache" import { useConfig } from "../stores/preferences" import type { DiffViewMode } from "../stores/preferences" import { sendPermissionResponse } from "../stores/instances" import type { TextPart, SDKPart, ClientPart, RenderCache } 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 TOOL_CALL_CACHE_SCOPE = "tool-call" function makeRenderCacheKey( toolCallId?: string | null, messageId?: string, partId?: string | null, variant = "default", ) { const messageComponent = messageId ?? "unknown-message" const toolCallComponent = partId ?? toolCallId ?? "unknown-tool-call" return `${messageComponent}:${toolCallComponent}:${variant}` } interface ToolCallProps { toolCall: Extract toolCallId?: string messageId?: string messageVersion?: number partVersion?: number instanceId: string sessionId: string onContentRendered?: () => void } 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 LspRangePosition { line?: number character?: number } interface LspRange { start?: LspRangePosition } interface LspDiagnostic { message?: string severity?: number range?: LspRange } interface DiagnosticEntry { id: string severity: number tone: "error" | "warning" | "info" label: string icon: string message: string filePath: string displayPath: string line: number column: number } 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 } } function normalizeDiagnosticPath(path: string) { return path.replace(/\\/g, "/") } function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] { if (severity === 1) return "error" if (severity === 2) return "warning" return "info" } function getSeverityMeta(tone: DiagnosticEntry["tone"]) { if (tone === "error") return { label: "ERR", icon: "!", rank: 0 } if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 } return { label: "INFO", icon: "i", rank: 2 } } function extractDiagnostics(toolName: string, state: ToolState | undefined): DiagnosticEntry[] { if (!state) return [] const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state) if (!supportsMetadata) return [] const metadata = (state.metadata || {}) as Record const input = (state.input || {}) as Record const diagnosticsMap = metadata?.diagnostics as Record | undefined if (!diagnosticsMap) return [] const preferredPath = [ input.filePath, metadata.filePath, metadata.filepath, input.path, ].find((value) => typeof value === "string" && value.length > 0) as string | undefined const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0) if (candidateEntries.length === 0) return [] const prioritizedEntries = (() => { if (!normalizedPreferred) return candidateEntries const matched = candidateEntries.filter(([path]) => { const normalized = normalizeDiagnosticPath(path) if (normalized === normalizedPreferred) return true if (normalized.endsWith(`/${normalizedPreferred}`)) return true const normalizedBase = normalized.split("/").pop() const preferredBase = normalizedPreferred.split("/").pop() return normalizedBase && preferredBase ? normalizedBase === preferredBase : false }) return matched.length > 0 ? matched : candidateEntries })() const entries: DiagnosticEntry[] = [] for (const [pathKey, list] of prioritizedEntries) { if (!Array.isArray(list)) continue const normalizedPath = normalizeDiagnosticPath(pathKey) for (let index = 0; index < list.length; index++) { const diagnostic = list[index] if (!diagnostic || typeof diagnostic.message !== "string") continue const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined) const severityMeta = getSeverityMeta(tone) const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0 const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0 entries.push({ id: `${normalizedPath}-${index}-${diagnostic.message}`, severity: severityMeta.rank, tone, label: severityMeta.label, icon: severityMeta.icon, message: diagnostic.message, filePath: normalizedPath, displayPath: getRelativePath(normalizedPath), line, column, }) } } return entries.sort((a, b) => a.severity - b.severity) } function diagnosticFileName(entries: DiagnosticEntry[]) { const first = entries[0] return first ? first.displayPath : "" } function renderDiagnosticsSection( entries: DiagnosticEntry[], expanded: boolean, toggle: () => void, toolIcon: string, fileLabel: string, ) { if (entries.length === 0) return null return (
{(entry) => (
{entry.icon} {entry.label} {entry.displayPath} :L{entry.line || "-"}:C{entry.column || "-"} {entry.message}
)}
) } export default function ToolCall(props: ToolCallProps) { const { preferences, setDiffViewMode } = useConfig() const { isDark } = useTheme() const toolCallId = () => props.toolCallId || props.toolCall?.id || "" const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) const cacheContext = createMemo(() => ({ toolCallId: toolCallId(), messageId: props.messageId, partId: props.toolCall?.id ?? null, })) const createVariantCache = (variant: string) => useGlobalCache({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, scope: TOOL_CALL_CACHE_SCOPE, key: () => { const context = cacheContext() return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, variant) }, }) const diffCache = createVariantCache("diff") const permissionDiffCache = createVariantCache("permission-diff") const markdownCache = createVariantCache("markdown") const permissionState = createMemo(() => store().getPermissionState(props.messageId, props.toolCall?.id)) const pendingPermission = createMemo(() => { const state = permissionState() if (state) { return { permission: state.entry.permission, active: state.active } } return props.toolCall.pendingPermission }) const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded") const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded") const defaultExpandedForTool = createMemo(() => { const prefExpanded = toolOutputDefaultExpanded() const toolName = props.toolCall?.tool || "" if (toolName === "read") { return false } return prefExpanded }) const [userExpanded, setUserExpanded] = createSignal(null) const expanded = () => { const permission = pendingPermission() if (permission?.active) return true const override = userExpanded() if (override !== null) return override return defaultExpandedForTool() } const permissionDetails = createMemo(() => pendingPermission()?.permission) const isPermissionActive = createMemo(() => pendingPermission()?.active === true) const activePermissionKey = createMemo(() => { const permission = permissionDetails() return permission && isPermissionActive() ? permission.id : "" }) const [permissionSubmitting, setPermissionSubmitting] = createSignal(false) const [permissionError, setPermissionError] = createSignal(null) const [diagnosticsOverride, setDiagnosticsOverride] = createSignal(undefined) const diagnosticsExpanded = () => { const permission = pendingPermission() if (permission?.active) return true const override = diagnosticsOverride() if (override !== undefined) return override return diagnosticsDefaultExpanded() } const diagnosticsEntries = createMemo(() => { const tool = props.toolCall?.tool || "" const state = props.toolCall?.state if (!state) return [] return extractDiagnostics(tool, state) }) let scrollContainerRef: HTMLDivElement | undefined let toolCallRootRef: HTMLDivElement | undefined const persistScrollSnapshot = (_element?: HTMLElement | null) => {} const handleScrollRendered = () => {} const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => { scrollContainerRef = element || undefined } createEffect(() => { const permission = permissionDetails() if (!permission) { setPermissionSubmitting(false) setPermissionError(null) } else { setPermissionError(null) } }) createEffect(() => { const activeKey = activePermissionKey() if (!activeKey) return requestAnimationFrame(() => { toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" }) }) }) createEffect(() => { const activeKey = activePermissionKey() if (!activeKey) return const handler = (event: KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault() handlePermissionResponse("once") } else if (event.key === "a" || event.key === "A") { event.preventDefault() handlePermissionResponse("always") } else if (event.key === "d" || event.key === "D") { event.preventDefault() handlePermissionResponse("reject") } } document.addEventListener("keydown", handler) onCleanup(() => document.removeEventListener("keydown", handler)) }) createEffect(() => { if (!expanded()) { scrollContainerRef = undefined } }) 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}` } const combinedStatusClass = () => { const base = statusClass() return pendingPermission() ? `${base} tool-call-awaiting-permission` : base } function toggle() { const permission = pendingPermission() if (permission?.active) { return } setUserExpanded((prev) => { const current = prev === null ? defaultExpandedForTool() : prev return !current }) } 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..." } } async function handlePermissionResponse(response: "once" | "always" | "reject") { const permission = permissionDetails() if (!permission || !isPermissionActive()) { return } setPermissionSubmitting(true) setPermissionError(null) try { const sessionId = permission.sessionID || props.sessionId await sendPermissionResponse(props.instanceId, sessionId, permission.id, response) } catch (error) { console.error("Failed to send permission response:", error) setPermissionError(error instanceof Error ? error.message : "Unable to update permission") } finally { setPermissionSubmitting(false) } } type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" interface TodoViewItem { id: string content: string status: TodoViewStatus } function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus { if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus return "pending" } function extractTodosFromState(state: ToolState | undefined): TodoViewItem[] { if (!state) return [] const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) ? state.metadata || {} : {} const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : [] const items: TodoViewItem[] = [] for (let index = 0; index < todos.length; index++) { const todo = todos[index] const content = typeof todo?.content === "string" ? todo.content.trim() : "" if (!content) continue const status = normalizeTodoStatus((todo as any).status) const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : `${index}-${content}` items.push({ id, content, status }) } return items } function summarizeTodos(todos: TodoViewItem[]) { return todos.reduce( (acc, todo) => { acc.total += 1 acc[todo.status] = (acc[todo.status] || 0) + 1 return acc }, { total: 0, pending: 0, in_progress: 0, completed: 0, cancelled: 0 } as Record, ) } function getTodoStatusLabel(status: TodoViewStatus): string { switch (status) { case "completed": return "Completed" case "in_progress": return "In progress" case "cancelled": return "Cancelled" default: return "Pending" } } const getTodoTitle = () => { const state = props.toolCall?.state if (!state) return "Plan" const todos = extractTodosFromState(state) if (state.status !== "completed" || todos.length === 0) return "Plan" const counts = summarizeTodos(todos) if (counts.pending === counts.total) return "Creating plan" if (counts.completed === counts.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 getTodoTitle() 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" || toolName === "todowrite") { return renderTodoTool() } if (state.status === "pending") { return null } if (toolName === "task") { return renderTaskTool() } const diffPayload = extractDiffPayload(toolName, state) if (diffPayload) { return renderDiffTool(diffPayload) } return renderMarkdownTool(toolName, state) } function renderDiffTool(payload: DiffPayload, options?: { variant?: string; disableScrollTracking?: boolean; label?: string }) { const relativePath = payload.filePath ? getRelativePath(payload.filePath) : "" const toolbarLabel = options?.label || (relativePath ? `Diff ยท ${relativePath}` : "Diff") const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff" const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode const themeKey = isDark() ? "dark" : "light" // Check if we have valid cache let cachedHtml: string | undefined const cached = cacheHandle.get() 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 (!options?.disableScrollTracking) { handleScrollRendered() } props.onContentRendered?.() } return (
{ if (options?.disableScrollTracking) return initializeScrollContainer(element) }} onScroll={options?.disableScrollTracking ? undefined : (event) => persistScrollSnapshot(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 markdownPart: TextPart = { type: "text", text: content } const cached = markdownCache.get() if (cached) { markdownPart.renderCache = cached } const handleMarkdownRendered = () => { markdownCache.set(markdownPart.renderCache) handleScrollRendered() props.onContentRendered?.() } return (
initializeScrollContainer(element)} onScroll={(event) => persistScrollSnapshot(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 renderTodoTool = () => { const state = props.toolCall?.state if (!state) return null const todos = extractTodosFromState(state) const counts = summarizeTodos(todos) if (counts.total === 0) { return
No plan items yet.
} return (
{(todo) => { const label = getTodoStatusLabel(todo.status) return (
{todo.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) => persistScrollSnapshot(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 renderPermissionBlock = () => { const permission = permissionDetails() if (!permission) return null const active = isPermissionActive() const metadata = (permission.metadata ?? {}) as Record const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null const diffPathRaw = (() => { if (typeof metadata.filePath === "string") { return metadata.filePath as string } if (typeof metadata.path === "string") { return metadata.path as string } return undefined })() const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null return (
{active ? "Permission Required" : "Permission Queued"} {permission.type}
{permission.title}
{(payload) => (
{renderDiffTool(payload(), { variant: "permission-diff", disableScrollTracking: true, label: payload().filePath ? `Requested diff ยท ${getRelativePath(payload().filePath || "")}` : "Requested diff", })}
)}
Waiting for earlier permission responses.

} >
Enter Allow once A Always allow D Deny
{permissionError()}
) } const toolName = () => props.toolCall?.tool || "" const status = () => props.toolCall?.state?.status || "" return (
{ toolCallRootRef = element || undefined }} class={`tool-call ${combinedStatusClass()}`} > {expanded() && (
{renderToolBody()} {renderError()} {renderPermissionBlock()}
Waiting for permission...
)} {renderDiagnosticsSection( diagnosticsEntries(), diagnosticsExpanded(), () => setDiagnosticsOverride((prev) => { const current = prev === undefined ? diagnosticsDefaultExpanded() : prev return !current }), getToolIcon(toolName()), diagnosticFileName(diagnosticsEntries()), )}
) }