import { createSignal, Show, For, createEffect, createMemo, onCleanup, type Accessor } 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 { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances" import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission" import type { QuestionRequest } from "@opencode-ai/sdk/v2" import type { TextPart, RenderCache } from "../types/message" import { resolveToolRenderer } from "./tool-call/renderers" import type { DiffPayload, DiffRenderOptions, MarkdownRenderOptions, AnsiRenderOptions, 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" import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi" import { escapeHtml } from "../lib/markdown" const log = getLogger("session") type ToolState = import("@opencode-ai/sdk").ToolState type AnsiRenderCache = RenderCache & { hasAnsi: boolean } type QuestionOption = { label: string; description: string } type QuestionPrompt = { header: string question: string options: QuestionOption[] multiple?: boolean } type QuestionToolBlockProps = { toolName: Accessor toolState: Accessor toolCallId: Accessor request: Accessor active: Accessor submitting: Accessor error: Accessor draftAnswers: Accessor> setDraftAnswers: (updater: (prev: Record) => Record) => void onSubmit: () => void | Promise onDismiss: () => void | Promise } 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 } 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 QuestionToolBlock(props: QuestionToolBlockProps) { const requestId = createMemo(() => { const state = props.toolState() const request = props.request() return request?.id ?? (state as any)?.input?.requestID ?? `question-${props.toolCallId()}` }) const questions = createMemo(() => { const state = props.toolState() const request = props.request() const isQuestionTool = props.toolName() === "question" if (!request && !isQuestionTool) return [] as QuestionPrompt[] const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? [] const list = Array.isArray(questionsSource) ? questionsSource : [] return list as QuestionPrompt[] }) const isVisible = createMemo(() => { const request = props.request() const isQuestionTool = props.toolName() === "question" return Boolean(request) || isQuestionTool }) const answers = createMemo(() => { const state = props.toolState() const completedAnswers = (state as any)?.status === "completed" && Array.isArray((state as any)?.metadata?.answers) ? ((state as any).metadata.answers as string[][]) : undefined if (completedAnswers) return completedAnswers const request = props.request() const requestAnswers = request?.questions?.map((q) => (q as any)?.answer) // defensive (if server ever inlines) if (Array.isArray(requestAnswers) && requestAnswers.some((row) => Array.isArray(row) && row.length > 0)) { return requestAnswers as string[][] } const draft = props.draftAnswers()[requestId()] ?? [] return Array.isArray(draft) ? draft : [] }) const updateAnswer = (questionIndex: number, next: string[]) => { if (!props.active()) return props.setDraftAnswers((prev) => { const current = prev[requestId()] ?? [] const updated = [...current] updated[questionIndex] = next return { ...prev, [requestId()]: updated } }) } const toggleOption = (questionIndex: number, label: string) => { const info = questions()[questionIndex] const multi = info?.multiple === true const existing = answers()[questionIndex] ?? [] if (multi) { const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label] updateAnswer(questionIndex, next) return } updateAnswer(questionIndex, [label]) } const submitDisabled = () => { if (!props.active()) return true if (props.submitting()) return true return questions().some((_, index) => (answers()[index]?.length ?? 0) === 0) } const toggleFromCustomInput = (questionIndex: number, input: HTMLInputElement | null) => { if (!props.active()) return const value = input?.value?.trim() ?? "" if (!value) return const info = questions()[questionIndex] const multi = info?.multiple === true if (!multi) { // When switching a radio to custom, clear existing selection first. updateAnswer(questionIndex, []) } toggleOption(questionIndex, value) } const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => { if (!props.active()) return if (valuesToRemove.length === 0) return const existing = answers()[questionIndex] ?? [] const next = existing.filter((value) => !valuesToRemove.includes(value)) updateAnswer(questionIndex, next) } const handleCustomTyping = (questionIndex: number, input: HTMLInputElement) => { if (!props.active()) return const value = input.value.trim() const info = questions()[questionIndex] const multi = info?.multiple === true if (!multi) { updateAnswer(questionIndex, value ? [value] : []) return } const optionLabels = new Set((info?.options ?? []).map((opt) => opt.label)) const existing = answers()[questionIndex] ?? [] const last = input.dataset.lastValue ?? "" let next = existing.filter((item) => item !== last) if (value) { if (!optionLabels.has(value) && !next.includes(value)) { next = [...next, value] } else if (optionLabels.has(value)) { // If they typed an existing option label, don't treat it as custom. } else if (!next.includes(value)) { next = [...next, value] } input.dataset.lastValue = value } else { delete input.dataset.lastValue } updateAnswer(questionIndex, next) } return ( 0}>
{props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"} {questions().length === 1 ? "Question" : "Questions"}
{(q, index) => { const i = () => index() const multi = () => q?.multiple === true const selected = () => answers()[i()] ?? [] const inputType = () => (multi() ? "checkbox" : "radio") const groupName = () => `question-${requestId()}-${i()}` const optionLabels = () => new Set((q?.options ?? []).map((opt) => opt.label)) const customSelected = () => selected().filter((value) => !optionLabels().has(value)) const customValue = () => customSelected()[0] ?? "" const customChecked = () => customValue().length > 0 return (
Q{i() + 1}: {q?.header}
Multiple
{q?.question}
{(opt) => { const checked = () => selected().includes(opt.label) return ( ) }}
) }}
Enter Submit Esc Dismiss
{props.error()}

Waiting for earlier responses.

) } 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(() => { 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 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 runningAnsiRenderer = createAnsiStreamRenderer() let runningAnsiSource = "" 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(() => { const prefExpanded = toolOutputDefaultExpanded() const toolName = toolCallMemo()?.tool || "" if (toolName === "read") { return false } return prefExpanded }) const [userExpanded, setUserExpanded] = createSignal(null) 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 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() }) } 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