831 lines
24 KiB
TypeScript
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>
|
|
)
|
|
}
|