import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid" import { stringify as stringifyYaml } from "yaml" import { messageStoreBus } from "../stores/message-v2/bus" import { useTheme } from "../lib/theme" import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useConfig } from "../stores/preferences" import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances" import { copyToClipboard } from "../lib/clipboard" import type { PermissionRequestLike } from "../types/permission" import { getPermissionSessionId } from "../types/permission" import type { QuestionRequest } from "@opencode-ai/sdk/v2" import { useI18n } from "../lib/i18n" import { resolveToolRenderer } from "./tool-call/renderers" import { QuestionToolBlock } from "./tool-call/question-block" import { PermissionToolBlock } from "./tool-call/permission-block" import { createAnsiContentRenderer } from "./tool-call/ansi-render" import { createDiffContentRenderer } from "./tool-call/diff-render" import { createMarkdownContentRenderer } from "./tool-call/markdown-render" import { extractDiagnostics, diagnosticFileName } from "./tool-call/diagnostics" import { renderDiagnosticsSection } from "./tool-call/diagnostics-section" import type { DiffPayload, DiffRenderOptions, MarkdownRenderOptions, AnsiRenderOptions, ToolCallPart, ToolRendererContext, ToolScrollHelpers, } from "./tool-call/types" import { ensureMarkdownContent, getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction, readToolStatePayload, } 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"]) 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 /** * When true, tool call starts collapsed regardless of user preferences. * Users can still expand/collapse manually. */ forceCollapsed?: boolean } export default function ToolCall(props: ToolCallProps) { const { preferences, setDiffViewMode } = useConfig() const { isDark } = useTheme() const { t } = useI18n() const toolCallMemo = createMemo(() => props.toolCall) const toolName = createMemo(() => toolCallMemo()?.tool || "") const toolCallIdentifier = createMemo(() => { const partId = toolCallMemo()?.id if (!partId) { throw new Error("Tool call requires a part id") } return partId }) 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 activeRequest = createMemo(() => activeInterruption().get(props.instanceId) ?? null) const cacheVersion = createMemo(() => { if (typeof props.partVersion === "number") { return String(props.partVersion) } if (typeof props.messageVersion === "number") { return String(props.messageVersion) } return "noversion" }) const messageVersionAccessor = createMemo(() => props.messageVersion) const partVersionAccessor = createMemo(() => props.partVersion) const createVariantCache = (variant: string | (() => string), version?: () => string) => useGlobalCache({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, scope: TOOL_CALL_CACHE_SCOPE, cacheId: () => { const context = cacheContext() const resolvedVariant = typeof variant === "function" ? variant() : variant return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, resolvedVariant) }, version: () => (version ? version() : cacheVersion()), }) const diffCache = createVariantCache("diff") const permissionDiffCache = createVariantCache("permission-diff") const ansiRunningCache = createVariantCache("ansi-running", () => "running") const ansiFinalCache = createVariantCache("ansi-final") 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 questionState = createMemo(() => store().getQuestionState(props.messageId, toolCallIdentifier())) const pendingQuestion = createMemo(() => { const state = questionState() if (state) { return { request: state.entry.request as QuestionRequest, active: state.active } } return undefined }) const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded") const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded") const defaultExpandedForTool = createMemo(() => { if (props.forceCollapsed) { return false } const prefExpanded = toolOutputDefaultExpanded() const toolName = toolCallMemo()?.tool || "" if (toolName === "read") { const state = toolState() if (state?.status === "error") { return true } return false } return prefExpanded }) const [userExpanded, setUserExpanded] = createSignal(null) const toolInputsVisibility = createMemo(() => preferences().toolInputsVisibility || "collapsed") const [toolInputVisibilityOverride, setToolInputVisibilityOverride] = createSignal<"hidden" | "expanded" | null>(null) const effectiveToolInputsVisibility = createMemo(() => toolInputVisibilityOverride() ?? toolInputsVisibility()) const isToolInputVisible = createMemo(() => effectiveToolInputsVisibility() !== "hidden") const inputDefaultExpanded = createMemo(() => effectiveToolInputsVisibility() === "expanded") const [inputSectionOverride, setInputSectionOverride] = createSignal(null) const [outputSectionOverride, setOutputSectionOverride] = createSignal(null) const inputSectionExpanded = () => { const override = inputSectionOverride() if (override !== null) return override return inputDefaultExpanded() } const outputSectionExpanded = () => { const override = outputSectionOverride() if (override !== null) return override return true } const isPermissionActive = createMemo(() => { const pending = pendingPermission() if (!pending?.permission) return false const active = activeRequest() return active?.kind === "permission" && active.id === pending.permission.id }) const isQuestionActive = createMemo(() => { const pending = pendingQuestion() if (!pending?.request) return false const active = activeRequest() return active?.kind === "question" && active.id === pending.request.id }) const expanded = () => { if (isPermissionActive() || isQuestionActive()) return true const override = userExpanded() if (override !== null) return override return defaultExpandedForTool() } const toolInput = createMemo(() => { const state = toolState() return readToolStatePayload(state).input }) const hasToolInput = createMemo(() => { const input = toolInput() return input && Object.keys(input).length > 0 }) const toolInputMarkdown = createMemo(() => { const input = toolInput() if (!input || Object.keys(input).length === 0) return null try { const yamlText = stringifyYaml(input) return ensureMarkdownContent(yamlText, "yaml", true) } catch (error) { log.error("Failed to convert tool call input to YAML", error) try { const jsonText = JSON.stringify(input, null, 2) return ensureMarkdownContent(jsonText, "json", true) } catch (nestedError) { log.error("Failed to stringify tool call input", nestedError) return null } } }) const permissionDetails = createMemo(() => pendingPermission()?.permission) const questionDetails = createMemo(() => pendingQuestion()?.request) const activePermissionKey = createMemo(() => { const permission = permissionDetails() return permission && isPermissionActive() ? permission.id : "" }) const activeQuestionKey = createMemo(() => { const request = questionDetails() return request && isQuestionActive() ? request.id : "" }) const [permissionSubmitting, setPermissionSubmitting] = createSignal(false) const [permissionError, setPermissionError] = createSignal(null) const [diagnosticsOverride, setDiagnosticsOverride] = createSignal(undefined) const diagnosticsExpanded = () => { if (isPermissionActive() || isQuestionActive()) 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 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(true) }) } const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => { const next = element || undefined if (next === scrollContainerRef) { return } scrollContainerRef = next 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