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 { buildToolSpeechText, 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" import { useSpeech } from "../lib/hooks/use-speech" import SpeechActionButton from "./speech-action-button" const log = getLogger("session") type ToolState = import("@opencode-ai/sdk/v2").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 } function ToolCallDetails(props: { toolCallMemo: () => ToolCallPart toolState: () => ToolState | undefined toolName: () => string toolCallIdentifier: () => string instanceId: string sessionId: string messageId?: string messageVersion?: number partVersion?: number onContentRendered?: () => void preferences: ReturnType["preferences"] setDiffViewMode: ReturnType["setDiffViewMode"] isDark: () => boolean t: ReturnType["t"] store: () => ReturnType pendingPermission: () => { permission: PermissionRequestLike; active: boolean } | undefined pendingQuestion: () => { request: QuestionRequest; active: boolean } | undefined isPermissionActive: () => boolean isQuestionActive: () => boolean hasToolInput: () => boolean isToolInputVisible: () => boolean toolInput: () => Record | undefined inputSectionExpanded: () => boolean outputSectionExpanded: () => boolean toggleInputSection: () => void toggleOutputSection: () => void toolCallRootEl: () => HTMLDivElement | undefined scrollTopSnapshot: () => number setScrollTopSnapshot: (next: number) => void }) { const messageVersionAccessor = createMemo(() => props.messageVersion) const partVersionAccessor = createMemo(() => props.partVersion) const cacheContext = createMemo(() => ({ toolCallId: props.toolCallIdentifier(), messageId: props.messageId, partId: props.toolCallMemo()?.id ?? 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 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 permissionDetails = createMemo(() => props.pendingPermission()?.permission) const questionDetails = createMemo(() => props.pendingQuestion()?.request) const activePermissionKey = createMemo(() => { const permission = permissionDetails() return permission && props.isPermissionActive() ? permission.id : "" }) const activeQuestionKey = createMemo(() => { const request = questionDetails() return request && props.isQuestionActive() ? request.id : "" }) const [permissionSubmitting, setPermissionSubmitting] = createSignal(false) const [permissionError, setPermissionError] = createSignal(null) const [scrollContainer, setScrollContainer] = createSignal() const [bottomSentinel, setBottomSentinel] = createSignal(null) const [autoScroll, setAutoScroll] = createSignal(true) const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) let scrollContainerRef: HTMLDivElement | undefined let detachScrollIntentListeners: (() => void) | undefined let pendingScrollFrame: number | null = null let pendingAnchorScroll: number | null = null let userScrollIntentUntil = 0 let lastKnownScrollTop = props.scrollTopSnapshot() function restoreScrollPosition(forceBottom = false) { const container = scrollContainerRef if (!container) return if (forceBottom) { container.scrollTop = container.scrollHeight lastKnownScrollTop = container.scrollTop props.setScrollTopSnapshot(lastKnownScrollTop) } else { container.scrollTop = lastKnownScrollTop } } const persistScrollSnapshot = (element?: HTMLElement | null) => { if (!element) return lastKnownScrollTop = element.scrollTop props.setScrollTopSnapshot(lastKnownScrollTop) } 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 props.setScrollTopSnapshot(lastKnownScrollTop) }) } 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 handleScrollRendered = () => { requestAnimationFrame(() => { restoreScrollPosition(autoScroll()) scheduleAnchorScroll(true) }) } const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => { const next = element || undefined if (next === scrollContainerRef) { return } scrollContainerRef = next setScrollContainer(scrollContainerRef) if (scrollContainerRef) { // Refresh our snapshot on mount (e.g. when remounting after collapse) lastKnownScrollTop = props.scrollTopSnapshot() restoreScrollPosition(autoScroll()) } } const scrollHelpers: ToolScrollHelpers = { registerContainer: (element, options) => { if (options?.disableTracking) return initializeScrollContainer(element) }, handleScroll: handleScrollEvent, renderSentinel: (options) => { if (options?.disableTracking) return null return