Files
CodeNomad/src/components/tool-call.tsx
2025-11-11 21:06:37 +00:00

831 lines
24 KiB
TypeScript

import { createSignal, Show, For, createEffect, onCleanup } from "solid-js"
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { ToolCallDiffViewer } from "./diff-viewer"
import { useTheme } from "../lib/theme"
import { getLanguageFromPath } from "../lib/markdown"
import { isRenderableDiffText } from "../lib/diff-utils"
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
import { preferences, setDiffViewMode, type DiffViewMode } from "../stores/preferences"
import type { TextPart, SDKPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
// Import ToolState types from SDK
type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
// Type guards
function isToolStateRunning(state: ToolState): state is ToolStateRunning {
return state.status === "running"
}
function isToolStateCompleted(state: ToolState): state is ToolStateCompleted {
return state.status === "completed"
}
function isToolStateError(state: ToolState): state is ToolStateError {
return state.status === "error"
}
const toolScrollState = new Map<string, { scrollTop: number; atBottom: boolean }>()
function makeRenderCacheKey(
toolCallId?: string | null,
messageId?: string,
messageVersion?: number,
partVersion?: number,
) {
const suffix = `${messageVersion ?? 0}:${partVersion ?? 0}`
const keyBase = `${messageId}:${toolCallId}`
return `${keyBase}::${suffix}`
}
function updateScrollState(id: string, element: HTMLElement) {
if (!id) return
const distanceFromBottom = element.scrollHeight - (element.scrollTop + element.clientHeight)
const atBottom = distanceFromBottom <= 2
toolScrollState.set(id, { scrollTop: element.scrollTop, atBottom })
}
function restoreScrollState(id: string, element: HTMLElement) {
if (!id) return
const state = toolScrollState.get(id)
if (!state) {
requestAnimationFrame(() => {
element.scrollTop = element.scrollHeight
updateScrollState(id, element)
})
return
}
requestAnimationFrame(() => {
if (state.atBottom) {
element.scrollTop = element.scrollHeight
} else {
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
element.scrollTop = Math.min(state.scrollTop, maxScrollTop)
}
updateScrollState(id, element)
})
}
interface ToolCallProps {
toolCall: Extract<ClientPart, { type: "tool" }>
toolCallId?: string
messageId?: string
messageVersion?: number
partVersion?: number
}
function getToolIcon(tool: string): string {
switch (tool) {
case "bash":
return "⚡"
case "edit":
return "✏️"
case "read":
return "📖"
case "write":
return "📝"
case "glob":
return "🔍"
case "grep":
return "🔎"
case "webfetch":
return "🌐"
case "task":
return "🎯"
case "todowrite":
case "todoread":
return "📋"
case "list":
return "📁"
case "patch":
return "🔧"
default:
return "🔧"
}
}
function getToolName(tool: string): string {
switch (tool) {
case "bash":
return "Shell"
case "webfetch":
return "Fetch"
case "invalid":
return "Invalid"
case "todowrite":
case "todoread":
return "Plan"
default:
const normalized = tool.replace(/^opencode_/, "")
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
}
}
function getRelativePath(path: string): string {
if (!path) return ""
const parts = path.split("/")
return parts.slice(-1)[0] || path
}
const diffCapableTools = new Set(["edit", "patch"])
interface DiffPayload {
diffText: string
filePath?: string
}
function extractDiffPayload(toolName: string, state: ToolState): DiffPayload | null {
if (!diffCapableTools.has(toolName)) return null
if (!state) return null
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata || {}
: {}
const output = isToolStateCompleted(state) ? state.output : undefined
const candidates = [metadata.diff, output, metadata.output]
let diffText: string | null = null
for (const candidate of candidates) {
if (typeof candidate === "string" && isRenderableDiffText(candidate)) {
diffText = candidate
break
}
}
if (!diffText) {
return null
}
const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.input as Record<string, unknown>
: {}
const filePath = (typeof input.filePath === "string" ? input.filePath : undefined) ||
(typeof metadata.filePath === "string" ? metadata.filePath : undefined) ||
(typeof input.path === "string" ? input.path : undefined)
return { diffText, filePath }
}
export default function ToolCall(props: ToolCallProps) {
const { isDark } = useTheme()
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
const expanded = () => isToolCallExpanded(toolCallId())
const [initializedId, setInitializedId] = createSignal<string | null>(null)
let scrollContainerRef: HTMLDivElement | undefined
const handleScrollRendered = () => {
const id = toolCallId()
if (!id || !scrollContainerRef) return
restoreScrollState(id, scrollContainerRef)
}
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
const resolvedElement = element || undefined
scrollContainerRef = resolvedElement
const id = toolCallId()
if (!resolvedElement || !id) return
if (!toolScrollState.has(id)) {
requestAnimationFrame(() => {
if (!scrollContainerRef || toolCallId() !== id) return
scrollContainerRef.scrollTop = scrollContainerRef.scrollHeight
updateScrollState(id, scrollContainerRef)
})
} else {
restoreScrollState(id, resolvedElement)
}
}
createEffect(() => {
const id = toolCallId()
if (!id || initializedId() === id) return
const tool = props.toolCall?.tool || ""
const shouldExpand = tool !== "read"
setToolCallExpanded(id, shouldExpand)
setInitializedId(id)
})
// Cleanup cache entry when component unmounts or toolCallId changes
createEffect(() => {
const id = toolCallId()
if (!id) return
onCleanup(() => {
toolScrollState.delete(id)
})
})
createEffect(() => {
if (props.toolCall?.tool !== "task") return
const state = props.toolCall?.state
const summarySignature = JSON.stringify(
state && (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata?.summary ?? []
: []
)
requestAnimationFrame(() => {
void summarySignature
handleScrollRendered()
})
})
const statusIcon = () => {
const status = props.toolCall?.state?.status || ""
switch (status) {
case "pending":
return "⏸"
case "running":
return "⏳"
case "completed":
return "✓"
case "error":
return "✗"
default:
return ""
}
}
const statusClass = () => {
const status = props.toolCall?.state?.status || "pending"
return `tool-call-status-${status}`
}
function toggle() {
toggleToolCallExpanded(toolCallId())
}
const renderToolAction = () => {
const toolName = props.toolCall?.tool || ""
switch (toolName) {
case "task":
return "Delegating..."
case "bash":
return "Writing command..."
case "edit":
return "Preparing edit..."
case "webfetch":
return "Fetching from the web..."
case "glob":
return "Finding files..."
case "grep":
return "Searching content..."
case "list":
return "Listing directory..."
case "read":
return "Reading file..."
case "write":
return "Preparing write..."
case "todowrite":
case "todoread":
return "Planning..."
case "patch":
return "Preparing patch..."
default:
return "Working..."
}
}
const getTodoTitle = () => {
const state = props.toolCall?.state || {}
if (state.status !== "completed") return "Plan"
const metadata = state.metadata || {}
const todos = metadata.todos || []
if (!Array.isArray(todos) || todos.length === 0) return "Plan"
const counts = { pending: 0, completed: 0 }
for (const todo of todos) {
const status = todo.status || "pending"
if (status in counts) counts[status as keyof typeof counts]++
}
const total = todos.length
if (counts.pending === total) return "Creating plan"
if (counts.completed === total) return "Completing plan"
return "Updating plan"
}
const renderToolTitle = () => {
const toolName = props.toolCall?.tool || ""
const state = props.toolCall?.state
if (!state) return renderToolAction()
if (state.status === "pending") return renderToolAction()
const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? (state.input as Record<string, unknown>)
: {} as Record<string, unknown>
if (isToolStateRunning(state) && state.title) {
return state.title
}
if (isToolStateCompleted(state)) {
return state.title
}
const name = getToolName(toolName)
switch (toolName) {
case "read":
if (typeof input.filePath === "string") {
return `${name} ${getRelativePath(input.filePath)}`
}
return name
case "edit":
case "write":
if (typeof input.filePath === "string") {
return `${name} ${getRelativePath(input.filePath)}`
}
return name
case "bash":
if (typeof input.description === "string") {
return `${name} ${input.description}`
}
return name
case "task":
const description = input.description
const subagent = input.subagent_type
if (description && subagent) {
return `${name}[${subagent}] ${description}`
} else if (description) {
return `${name} ${description}`
}
return name
case "webfetch":
if (input.url) {
return `${name} ${input.url}`
}
return name
case "todowrite":
return getTodoTitle()
case "todoread":
return "Plan"
case "invalid":
if (typeof input.tool === "string") {
return getToolName(input.tool)
}
return name
default:
return name
}
}
function renderToolBody() {
const toolName = props.toolCall?.tool || ""
const state = props.toolCall?.state || {}
if (toolName === "todoread") {
return null
}
if (state.status === "pending") {
return null
}
if (toolName === "todowrite") {
return renderTodowriteTool()
}
if (toolName === "task") {
return renderTaskTool()
}
const diffPayload = extractDiffPayload(toolName, state)
if (diffPayload) {
return renderDiffTool(diffPayload)
}
return renderMarkdownTool(toolName, state)
}
function renderDiffTool(payload: DiffPayload) {
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = relativePath ? `Diff · ${relativePath}` : "Diff"
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
const themeKey = isDark() ? "dark" : "light"
// Check if we have valid cache
let cachedHtml: string | undefined
if (cacheKey) {
const cached = getToolRenderCache(cacheKey)
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 (cacheKey && !cachedHtml) {
// Cache will be updated by the diff viewer component itself
// We'll capture HTML from the rendered component
}
handleScrollRendered()
}
return (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
ref={(element) => initializeScrollContainer(element)}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
>
<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}
cacheKey={cacheKey}
onRendered={handleDiffRendered}
/>
</div>
)
}
function renderMarkdownTool(toolName: string, state: ToolState) {
const content = getMarkdownContent(toolName, state)
if (!content) {
return null
}
const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch"
const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}`
const disableHighlight = state?.status === "running"
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
const markdownPart: TextPart = { type: "text", text: content }
if (cacheKey) {
const cached = getToolRenderCache(cacheKey)
if (cached) {
markdownPart.renderCache = cached
}
}
const handleMarkdownRendered = () => {
if (cacheKey) {
setToolRenderCache(cacheKey, markdownPart.renderCache)
}
handleScrollRendered()
}
return (
<div
class={messageClass}
ref={(element) => initializeScrollContainer(element)}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
>
<Markdown
part={markdownPart}
isDark={isDark()}
disableHighlight={disableHighlight}
onRendered={handleMarkdownRendered}
/>
</div>
)
}
function getMarkdownContent(toolName: string, state: ToolState): string | null {
if (!state) return null
const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.input as Record<string, unknown>
: {}
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata || {}
: {}
switch (toolName) {
case "read": {
const preview = typeof metadata.preview === "string" ? metadata.preview : null
const language = getLanguageFromPath(typeof input.filePath === "string" ? input.filePath : "")
return ensureMarkdownContent(preview, language, true)
}
case "edit": {
const diffText = typeof metadata.diff === "string" ? metadata.diff : null
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
return ensureMarkdownContent(diffText || fallback, "diff", true)
}
case "write": {
const content = typeof input.content === "string" ? input.content : null
const metadataContent = typeof metadata.content === "string" ? metadata.content : null
const language = getLanguageFromPath(typeof input.filePath === "string" ? input.filePath : "")
return ensureMarkdownContent(content || metadataContent, language, true)
}
case "patch": {
const patchContent = typeof metadata.diff === "string" ? metadata.diff : null
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
return ensureMarkdownContent(patchContent || fallback, "diff", true)
}
case "bash": {
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(
isToolStateCompleted(state) ? state.output :
(isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output :
undefined
)
const parts = [command, outputResult?.text].filter(Boolean)
const combined = parts.join("\n")
return ensureMarkdownContent(combined, "bash", true)
}
case "webfetch": {
const result = formatUnknown(
isToolStateCompleted(state) ? state.output :
(isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output :
undefined
)
if (!result) return null
return ensureMarkdownContent(result.text, result.language, true)
}
default: {
const result = formatUnknown(
isToolStateCompleted(state) ? state.output :
(isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output :
metadata.diff ?? metadata.preview ?? input.content,
)
if (!result) return null
return ensureMarkdownContent(result.text, result.language, true)
}
}
}
function ensureMarkdownContent(
value: string | null,
language?: string,
forceFence = false,
): string | null {
if (!value) {
return null
}
const trimmed = value.replace(/\s+$/, "")
if (!trimmed) {
return null
}
const startsWithFence = trimmed.trimStart().startsWith("```")
if (startsWithFence && !forceFence) {
return trimmed
}
const langSuffix = language ? language : ""
if (language || forceFence) {
return `\u0060\u0060\u0060${langSuffix}\n${trimmed}\n\u0060\u0060\u0060`
}
return trimmed
}
function formatUnknown(value: unknown): { text: string; language?: string } | null {
if (value === null || value === undefined) {
return null
}
if (typeof value === "string") {
return { text: value }
}
if (typeof value === "number" || typeof value === "boolean") {
return { text: String(value) }
}
if (Array.isArray(value)) {
const parts = value
.map((item) => {
const formatted = formatUnknown(item)
return formatted?.text ?? ""
})
.filter(Boolean)
if (parts.length === 0) {
return null
}
return { text: parts.join("\n") }
}
if (typeof value === "object") {
try {
return { text: JSON.stringify(value, null, 2), language: "json" }
} catch (error) {
console.error("Failed to stringify tool call output", error)
return { text: String(value) }
}
}
return null
}
const renderTodowriteTool = () => {
const state = props.toolCall?.state
if (!state) return null
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata || {}
: {}
const todos = metadata.todos || []
if (!Array.isArray(todos) || todos.length === 0) {
return null
}
const getStatusLabel = (status: string): string => {
switch (status) {
case "completed":
return "Completed"
case "in_progress":
return "In progress"
case "cancelled":
return "Cancelled"
default:
return "Pending"
}
}
const shouldShowTag = (status: string) => status === "in_progress" || status === "cancelled"
return (
<div class="tool-call-todos" role="list">
<For each={todos}>
{(todo) => {
const content = typeof todo.content === "string" ? todo.content.trim() : ""
if (!content) return null
const status = typeof todo.status === "string" ? todo.status : "pending"
const label = getStatusLabel(status)
return (
<div
class="tool-call-todo-item"
classList={{
"tool-call-todo-item-completed": status === "completed",
"tool-call-todo-item-cancelled": status === "cancelled",
"tool-call-todo-item-active": status === "in_progress",
}}
role="listitem"
>
<span class="tool-call-todo-checkbox" data-status={status} aria-label={label}></span>
<div class="tool-call-todo-body">
<span class="tool-call-todo-text">{content}</span>
<Show when={shouldShowTag(status)}>
<span class="tool-call-todo-tag">{label}</span>
</Show>
</div>
</div>
)
}}
</For>
</div>
)
}
const renderTaskTool = () => {
const state = props.toolCall?.state
if (!state) return null
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata || {}
: {}
const summary = metadata.summary || []
if (!Array.isArray(summary) || summary.length === 0) {
return null
}
return (
<div
class="message-text tool-call-markdown tool-call-task-container"
ref={(element) => initializeScrollContainer(element)}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
>
<div class="tool-call-task-summary">
<For each={summary}>
{(item) => {
const tool = item.tool || "unknown"
const itemInput = item.state?.input || {}
const icon = getToolIcon(tool)
let description = ""
switch (tool) {
case "bash":
description = itemInput.description || itemInput.command || ""
break
case "edit":
case "read":
case "write":
description = `${tool} ${getRelativePath(itemInput.filePath || "")}`
break
default:
description = tool
}
return (
<div class="tool-call-task-item">
{icon} {description}
</div>
)
}}
</For>
</div>
</div>
)
}
const renderError = () => {
const state = props.toolCall?.state || {}
if (state.status === "error" && state.error) {
return (
<div class="tool-call-error-content">
<strong>Error:</strong> {state.error}
</div>
)
}
return null
}
const toolName = () => props.toolCall?.tool || ""
const status = () => props.toolCall?.state?.status || ""
return (
<div class={`tool-call ${statusClass()}`}>
<button class="tool-call-header" onClick={toggle} aria-expanded={expanded()}>
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
<span class="tool-call-emoji">{getToolIcon(toolName())}</span>
<span class="tool-call-summary">{renderToolTitle()}</span>
<span class="tool-call-status">{statusIcon()}</span>
</button>
<Show when={expanded()}>
<div class="tool-call-details">
{renderToolBody()}
{renderError()}
<Show when={status() === "pending"}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>Waiting for permission...</span>
</div>
</Show>
</div>
</Show>
</div>
)
}