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 { 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, RenderCache } from "../types/message" import { resolveToolRenderer } from "./tool-call/renderers" import type { DiffPayload, DiffRenderOptions, MarkdownRenderOptions, ToolCallPart, ToolRendererContext, ToolScrollHelpers, } from "./tool-call/types" import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils" import { resolveTitleForTool } from "./tool-call/tool-title" import { getLogger } from "../lib/logger" const log = getLogger("session") type ToolState = import("@opencode-ai/sdk").ToolState const TOOL_CALL_CACHE_SCOPE = "tool-call" const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48 const TOOL_SCROLL_INTENT_WINDOW_MS = 600 const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) const FILE_CHANGE_EVENT = "opencode:workspace-files-changed" 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: ToolCallPart toolCallId?: string messageId?: string messageVersion?: number partVersion?: number instanceId: string sessionId: string onContentRendered?: () => void } 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 } 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(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 if (!normalizedPreferred) return [] const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0) if (candidateEntries.length === 0) return [] const prioritizedEntries = candidateEntries.filter(([path]) => { const normalized = normalizeDiagnosticPath(path) return normalized === normalizedPreferred }) if (prioritizedEntries.length === 0) return [] 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, 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 toolCallMemo = createMemo(() => props.toolCall) const toolName = createMemo(() => toolCallMemo()?.tool || "") const toolCallIdentifier = createMemo(() => toolCallMemo()?.callID || props.toolCallId || toolCallMemo()?.id || "") const toolState = createMemo(() => toolCallMemo()?.state) const cacheContext = createMemo(() => ({ toolCallId: toolCallIdentifier(), messageId: props.messageId, partId: toolCallMemo()?.id ?? null, })) const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) 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, toolCallIdentifier())) const pendingPermission = createMemo(() => { const state = permissionState() if (state) { return { permission: state.entry.permission, active: state.active } } return toolCallMemo()?.pendingPermission }) const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded") const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded") const defaultExpandedForTool = createMemo(() => { const prefExpanded = toolOutputDefaultExpanded() const toolName = toolCallMemo()?.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 state = toolState() if (!state) return [] return extractDiagnostics(state) }) const [scrollContainer, setScrollContainer] = createSignal() const [bottomSentinel, setBottomSentinel] = createSignal(null) const [autoScroll, setAutoScroll] = createSignal(true) const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) let toolCallRootRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined let detachScrollIntentListeners: (() => void) | undefined let lastFileEventKey = "" let pendingScrollFrame: number | null = null let pendingAnchorScroll: number | null = null let userScrollIntentUntil = 0 let lastKnownScrollTop = 0 function restoreScrollPosition(forceBottom = false) { const container = scrollContainerRef if (!container) return if (forceBottom) { container.scrollTop = container.scrollHeight lastKnownScrollTop = container.scrollTop } else { container.scrollTop = lastKnownScrollTop } } const persistScrollSnapshot = (element?: HTMLElement | null) => { if (!element) return lastKnownScrollTop = element.scrollTop } const handleScrollRendered = () => { requestAnimationFrame(() => { restoreScrollPosition(autoScroll()) if (!expanded()) return scheduleAnchorScroll() }) } const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => { scrollContainerRef = element || undefined setScrollContainer(scrollContainerRef) if (scrollContainerRef) { restoreScrollPosition(autoScroll()) } } function markUserScrollIntent() { const now = typeof performance !== "undefined" ? performance.now() : Date.now() userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS } function hasUserScrollIntent() { const now = typeof performance !== "undefined" ? performance.now() : Date.now() return now <= userScrollIntentUntil } function attachScrollIntentListeners(element: HTMLDivElement) { if (detachScrollIntentListeners) { detachScrollIntentListeners() detachScrollIntentListeners = undefined } const handlePointerIntent = () => markUserScrollIntent() const handleKeyIntent = (event: KeyboardEvent) => { if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) { markUserScrollIntent() } } element.addEventListener("wheel", handlePointerIntent, { passive: true }) element.addEventListener("pointerdown", handlePointerIntent) element.addEventListener("touchstart", handlePointerIntent, { passive: true }) element.addEventListener("keydown", handleKeyIntent) detachScrollIntentListeners = () => { element.removeEventListener("wheel", handlePointerIntent) element.removeEventListener("pointerdown", handlePointerIntent) element.removeEventListener("touchstart", handlePointerIntent) element.removeEventListener("keydown", handleKeyIntent) } } function scheduleAnchorScroll(immediate = false) { if (!autoScroll()) return const sentinel = bottomSentinel() const container = scrollContainerRef if (!sentinel || !container) return if (pendingAnchorScroll !== null) { cancelAnimationFrame(pendingAnchorScroll) pendingAnchorScroll = null } pendingAnchorScroll = requestAnimationFrame(() => { pendingAnchorScroll = null const containerRect = container.getBoundingClientRect() const sentinelRect = sentinel.getBoundingClientRect() const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX if (Math.abs(delta) > 1) { container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" }) } lastKnownScrollTop = container.scrollTop }) } function handleScroll() { const container = scrollContainer() if (!container) return if (pendingScrollFrame !== null) { cancelAnimationFrame(pendingScrollFrame) } const isUserScroll = hasUserScrollIntent() pendingScrollFrame = requestAnimationFrame(() => { pendingScrollFrame = null const atBottom = bottomSentinelVisible() if (isUserScroll) { if (atBottom) { if (!autoScroll()) setAutoScroll(true) } else if (autoScroll()) { setAutoScroll(false) } } }) } const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => { handleScroll() persistScrollSnapshot(event.currentTarget) } const scrollHelpers: ToolScrollHelpers = { registerContainer: (element, options) => { if (options?.disableTracking) return initializeScrollContainer(element) }, handleScroll: handleScrollEvent, renderSentinel: (options) => { if (options?.disableTracking) return null return