Files
NomadArch/packages/ui/src/components/tool-call.tsx
Gemini AI eaf93e2924
Some checks failed
Release Binaries / release (push) Has been cancelled
Fix TypeScript compilation errors in native mode session management
2025-12-27 11:02:08 +04:00

939 lines
31 KiB
TypeScript

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<string, unknown>
const input = (state.input || {}) as Record<string, unknown>
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | 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 (
<div class="tool-call-diagnostics-wrapper">
<button
type="button"
class="tool-call-diagnostics-heading"
aria-expanded={expanded}
onClick={toggle}
>
<span class="tool-call-icon" aria-hidden="true">
{expanded ? "▼" : "▶"}
</span>
<span class="tool-call-emoji" aria-hidden="true">🛠</span>
<span class="tool-call-summary">Diagnostics</span>
<span class="tool-call-diagnostics-file" title={fileLabel}>{fileLabel}</span>
</button>
<Show when={expanded}>
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
<div class="tool-call-diagnostics-body" role="list">
<For each={entries}>
{(entry) => (
<div class="tool-call-diagnostic-row" role="listitem">
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
<span>{entry.label}</span>
</span>
<span class="tool-call-diagnostic-path" title={entry.filePath}>
{entry.displayPath}
<span class="tool-call-diagnostic-coords">
:L{entry.line || "-"}:C{entry.column || "-"}
</span>
</span>
<span class="tool-call-diagnostic-message">{entry.message}</span>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
)
}
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<boolean | null>(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<string | null>(null)
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(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<HTMLDivElement | undefined>()
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(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 <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
},
}
createEffect(() => {
const container = scrollContainer()
if (!container) return
attachScrollIntentListeners(container)
onCleanup(() => {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
})
createEffect(() => {
const container = scrollContainer()
const sentinel = bottomSentinel()
if (!container || !sentinel) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === sentinel) {
setBottomSentinelVisible(entry.isIntersecting)
}
})
},
{ root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` },
)
observer.observe(sentinel)
onCleanup(() => observer.disconnect())
})
createEffect(() => {
if (!expanded()) {
setScrollContainer(undefined)
scrollContainerRef = undefined
setBottomSentinel(null)
setAutoScroll(true)
}
})
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 state = toolState()
if (!state || state.status !== "completed") return
const tool = toolName()
if (!["write", "edit", "patch"].includes(tool)) return
const key = `${toolCallIdentifier()}:${tool}:${state.status}`
if (key === lastFileEventKey) return
lastFileEventKey = key
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId: props.instanceId } }))
}
})
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))
})
const statusIcon = () => {
const status = toolState()?.status || ""
switch (status) {
case "pending":
return "⏸"
case "running":
return "⏳"
case "completed":
return "✓"
case "error":
return "✗"
default:
return ""
}
}
const statusClass = () => {
const status = toolState()?.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 renderer = createMemo(() => resolveToolRenderer(toolName()))
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions) {
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"
let cachedHtml: string | undefined
const cached = cacheHandle.get<RenderCache>()
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 (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
ref={(element) => scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
onScroll={options?.disableScrollTracking ? undefined : scrollHelpers.handleScroll}
>
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
<div class="tool-call-diff-toggle">
<button
type="button"
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
aria-pressed={diffMode() === "split"}
onClick={() => handleModeChange("split")}
>
Split
</button>
<button
type="button"
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
aria-pressed={diffMode() === "unified"}
onClick={() => handleModeChange("unified")}
>
Unified
</button>
</div>
</div>
<ToolCallDiffViewer
diffText={payload.diffText}
filePath={payload.filePath}
theme={themeKey}
mode={diffMode()}
cachedHtml={cachedHtml}
cacheEntryParams={cacheHandle.params()}
onRendered={handleDiffRendered}
/>
{scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
</div>
)
}
function renderMarkdownContent(options: MarkdownRenderOptions) {
if (!options.content) {
return null
}
const size = options.size || "default"
const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const state = toolState()
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
if (shouldDeferMarkdown) {
return (
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
{scrollHelpers.renderSentinel()}
</div>
)
}
const markdownPart: TextPart = { type: "text", text: options.content }
const cached = markdownCache.get<RenderCache>()
if (cached) {
markdownPart.renderCache = cached
}
const handleMarkdownRendered = () => {
markdownCache.set(markdownPart.renderCache)
handleScrollRendered()
props.onContentRendered?.()
}
return (
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<Markdown
part={markdownPart}
isDark={isDark()}
instanceId={props.instanceId}
disableHighlight={disableHighlight}
onRendered={handleMarkdownRendered}
/>
{scrollHelpers.renderSentinel()}
</div>
)
}
const messageVersionAccessor = createMemo(() => props.messageVersion)
const partVersionAccessor = createMemo(() => props.partVersion)
const rendererContext: ToolRendererContext = {
toolCall: toolCallMemo,
toolState,
toolName,
messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor,
renderMarkdown: renderMarkdownContent,
renderDiff: renderDiffContent,
scrollHelpers,
}
let previousPartVersion: number | undefined
createEffect(() => {
const version = partVersionAccessor()
if (!expanded()) {
return
}
if (version === undefined) {
return
}
if (previousPartVersion !== undefined && version === previousPartVersion) {
return
}
previousPartVersion = version
scheduleAnchorScroll()
})
createEffect(() => {
if (expanded() && autoScroll()) {
scheduleAnchorScroll(true)
}
})
const getRendererAction = () => renderer().getAction?.(rendererContext) ?? getDefaultToolAction(toolName())
const renderToolTitle = () => {
const state = toolState()
const currentTool = toolName()
if (currentTool !== "task") {
return resolveTitleForTool({ toolName: currentTool, state })
}
if (!state) return getRendererAction()
if (state.status === "pending") return getRendererAction()
const customTitle = renderer().getTitle?.(rendererContext)
if (customTitle) return customTitle
if (isToolStateRunning(state) && state.title) {
return state.title
}
if (isToolStateCompleted(state) && state.title) {
return state.title
}
return getToolName(currentTool)
}
const renderToolBody = () => {
return renderer().renderBody(rendererContext)
}
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) {
log.error("Failed to send permission response", error)
setPermissionError(error instanceof Error ? error.message : "Unable to update permission")
} finally {
setPermissionSubmitting(false)
}
}
const renderError = () => {
const state = toolState() || {}
if (state.status === "error" && state.error) {
return (
<div class="tool-call-error-content">
<strong>Error:</strong> {state.error}
</div>
)
}
return null
}
const renderPermissionBlock = () => {
const permission = permissionDetails()
if (!permission) return null
const active = isPermissionActive()
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
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 (
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header">
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
<span class="tool-call-permission-type">{permission.type}</span>
</div>
<div class="tool-call-permission-body">
<div class="tool-call-permission-title">
<code>{permission.title}</code>
</div>
<Show when={diffPayload}>
{(payload) => (
<div class="tool-call-permission-diff">
{renderDiffContent(payload(), {
variant: "permission-diff",
disableScrollTracking: true,
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
})}
</div>
)}
</Show>
<Show
when={active}
fallback={<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>}
>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("once")}
>
Allow Once
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("always")}
>
Always Allow
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("reject")}
>
Deny
</button>
</div>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>Allow once</span>
<kbd class="kbd">A</kbd>
<span>Always allow</span>
<kbd class="kbd">D</kbd>
<span>Deny</span>
</div>
</div>
<Show when={permissionError()}>
<div class="tool-call-permission-error">{permissionError()}</div>
</Show>
</Show>
</div>
</div>
)
}
const status = () => toolState()?.status || ""
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
return (
<div
ref={(element) => {
toolCallRootRef = element || undefined
}}
class={`tool-call ${combinedStatusClass()}`}
>
<button
class="tool-call-header"
onClick={toggle}
aria-expanded={expanded()}
data-status-icon={statusIcon()}
>
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
{renderToolTitle()}
</span>
</button>
{expanded() && (
<div class="tool-call-details">
{renderToolBody()}
{renderError()}
{renderPermissionBlock()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>Waiting to run...</span>
</div>
</Show>
</div>
)}
<Show when={diagnosticsEntries().length}>
{renderDiagnosticsSection(
diagnosticsEntries(),
diagnosticsExpanded(),
() => setDiagnosticsOverride((prev) => {
const current = prev === undefined ? diagnosticsDefaultExpanded() : prev
return !current
}),
diagnosticFileName(diagnosticsEntries()),
)}
</Show>
</div>
)
}