Implement comprehensive tool call rendering with state persistence

- Implement tool-specific rendering for all 14 tool types (read, edit, write, bash, webfetch, todowrite, task, etc.)
- Each tool shows contextually relevant information (file previews, diffs, command output, todo lists)
- Add metadata-driven content display using preview, diff, output, and todos from tool state
- Implement status-based rendering (pending, running, completed, error) with animations
- Create global state store for expandable items (tool calls and reasoning sections)
- Fix state persistence: expanded tool calls and reasoning sections remain expanded when new messages arrive
- Fix scroll position preservation during live message updates
- Fix reasoning toggle loop by replacing native details element with custom expandable
- Add comprehensive documentation in TOOL_CALL_IMPLEMENTATION.md
- Reduce font sizes for better readability in expanded tool content
- Add proper keying to For loops to prevent component recreation
- Match TUI patterns for tool names, actions, and content formatting
This commit is contained in:
Shantur Rathore
2025-10-23 01:18:25 +01:00
parent fa77b4e82e
commit d7f619486e
15 changed files with 3059 additions and 106 deletions

View File

@@ -59,9 +59,17 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<For each={props.message.parts}>{(part) => <MessagePart part={part} />}</For>
<For each={props.message.parts}>
{(part) => <MessagePart part={part} key={part.id || `${part.type}-${Math.random()}`} />}
</For>
</div>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<Show when={props.message.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>

View File

@@ -1,5 +1,6 @@
import { Show, Match, Switch } from "solid-js"
import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
interface MessagePartProps {
part: any
@@ -7,6 +8,13 @@ interface MessagePartProps {
export default function MessagePart(props: MessagePartProps) {
const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}`
const isReasoningExpanded = () => isItemExpanded(reasoningId())
function handleReasoningClick(e: Event) {
e.preventDefault()
toggleItemExpanded(reasoningId())
}
return (
<Switch>
@@ -17,7 +25,7 @@ export default function MessagePart(props: MessagePartProps) {
</Match>
<Match when={partType() === "tool"}>
<ToolCall toolCall={props.part} />
<ToolCall toolCall={props.part} toolCallId={props.part?.id} />
</Match>
<Match when={partType() === "error"}>
@@ -26,10 +34,15 @@ export default function MessagePart(props: MessagePartProps) {
<Match when={partType() === "reasoning"}>
<div class="message-reasoning">
<details>
<summary class="text-sm text-gray-500 cursor-pointer">Reasoning</summary>
<div class="message-text mt-2">{props.part.text || ""}</div>
</details>
<div class="reasoning-container">
<div class="reasoning-header" onClick={handleReasoningClick}>
<span class="reasoning-icon">{isReasoningExpanded() ? "▼" : ""}</span>
<span class="reasoning-label">Reasoning</span>
</div>
<Show when={isReasoningExpanded()}>
<div class="message-text mt-2">{props.part.text || ""}</div>
</Show>
</div>
</div>
</Match>
</Switch>

View File

@@ -2,8 +2,10 @@ import { For, Show, createSignal, createEffect, createMemo } from "solid-js"
import type { Message } from "../types/message"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import { sseManager } from "../lib/sse-manager"
interface MessageStreamProps {
instanceId: string
sessionId: string
messages: Message[]
messagesInfo?: Map<string, any>
@@ -21,6 +23,8 @@ export default function MessageStream(props: MessageStreamProps) {
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollButton, setShowScrollButton] = createSignal(false)
const connectionStatus = () => sseManager.getStatus(props.instanceId)
function scrollToBottom() {
if (containerRef) {
containerRef.scrollTop = containerRef.scrollHeight
@@ -81,6 +85,26 @@ export default function MessageStream(props: MessageStreamProps) {
return (
<div class="message-stream-container">
<div class="connection-status">
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />
Connected
</span>
</Show>
<Show when={connectionStatus() === "connecting"}>
<span class="status-indicator connecting">
<span class="status-dot" />
Connecting...
</span>
</Show>
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
<span class="status-indicator disconnected">
<span class="status-dot" />
Disconnected
</span>
</Show>
</div>
<div ref={containerRef} class="message-stream" onScroll={handleScroll}>
<Show when={!props.loading && displayItems().length === 0}>
<div class="empty-state">
@@ -107,24 +131,27 @@ export default function MessageStream(props: MessageStreamProps) {
</div>
</Show>
<For each={displayItems()}>
{(item) => (
<Show
when={item.type === "message"}
fallback={
<div class="tool-call-message">
<div class="tool-call-header-label">
<span class="tool-call-icon">🔧</span>
<span>Tool Call</span>
<span class="tool-name">{item.data?.tool || "unknown"}</span>
<For each={displayItems()} fallback={null}>
{(item, index) => {
const key = item.type === "message" ? `msg-${item.data.id}` : `tool-${item.data.id}`
return (
<Show
when={item.type === "message"}
fallback={
<div class="tool-call-message" data-key={key}>
<div class="tool-call-header-label">
<span class="tool-call-icon">🔧</span>
<span>Tool Call</span>
<span class="tool-name">{item.data?.tool || "unknown"}</span>
</div>
<ToolCall toolCall={item.data} toolCallId={item.data.id} />
</div>
<ToolCall toolCall={item.data} />
</div>
}
>
<MessageItem message={item.data} messageInfo={item.messageInfo} />
</Show>
)}
}
>
<MessageItem message={item.data} messageInfo={item.messageInfo} />
</Show>
)
}}
</For>
</div>

View File

@@ -0,0 +1,79 @@
import { createSignal, Show } from "solid-js"
interface PromptInputProps {
instanceId: string
sessionId: string
onSend: (prompt: string) => Promise<void>
disabled?: boolean
}
export default function PromptInput(props: PromptInputProps) {
const [prompt, setPrompt] = createSignal("")
const [sending, setSending] = createSignal(false)
let textareaRef: HTMLTextAreaElement | undefined
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
async function handleSend() {
const text = prompt().trim()
if (!text || sending() || props.disabled) return
setSending(true)
try {
await props.onSend(text)
setPrompt("")
if (textareaRef) {
textareaRef.style.height = "auto"
}
} catch (error) {
console.error("Failed to send message:", error)
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
} finally {
setSending(false)
textareaRef?.focus()
}
}
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement
setPrompt(target.value)
target.style.height = "auto"
target.style.height = Math.min(target.scrollHeight, 200) + "px"
}
const canSend = () => prompt().trim().length > 0 && !sending() && !props.disabled
return (
<div class="prompt-input-container">
<div class="prompt-input-wrapper">
<textarea
ref={textareaRef}
class="prompt-input"
placeholder="Type your message or /command..."
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
disabled={sending() || props.disabled}
rows={1}
/>
<button class="send-button" onClick={handleSend} disabled={!canSend()} aria-label="Send message">
<Show when={sending()} fallback={<span class="send-icon"></span>}>
<span class="spinner-small" />
</Show>
</button>
</div>
<div class="prompt-input-hints">
<span class="hint">
<kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for new line
</span>
</div>
</div>
)
}

View File

@@ -1,17 +1,73 @@
import { createSignal, Show } from "solid-js"
import { createSignal, Show, For, createEffect } from "solid-js"
import { isToolCallExpanded, toggleToolCallExpanded } from "../stores/tool-call-state"
interface ToolCallProps {
toolCall: any
toolCallId?: string
}
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
}
export default function ToolCall(props: ToolCallProps) {
const [expanded, setExpanded] = createSignal(false)
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
const expanded = () => isToolCallExpanded(toolCallId())
const statusIcon = () => {
const status = props.toolCall?.state?.status || ""
switch (status) {
case "pending":
return ""
return ""
case "running":
return "⏳"
case "completed":
@@ -28,108 +84,393 @@ export default function ToolCall(props: ToolCallProps) {
return `tool-call-status-${status}`
}
function toggleExpanded() {
setExpanded(!expanded())
function toggle() {
toggleToolCallExpanded(toolCallId())
}
function formatToolSummary() {
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 || {}
const input = state.input || {}
if (state.status === "pending") {
return renderToolAction()
}
if (state.title) {
return state.title
}
const name = getToolName(toolName)
switch (toolName) {
case "bash":
return `bash: ${input.command || ""}`
case "edit":
return `edit ${input.filePath || ""}`
case "read":
return `read ${input.filePath || ""}`
if (input.filePath) {
return `${name} ${getRelativePath(input.filePath)}`
}
return name
case "edit":
case "write":
return `write ${input.filePath || ""}`
case "glob":
return `glob ${input.pattern || ""}`
case "grep":
return `grep ${input.pattern || ""}`
if (input.filePath) {
return `${name} ${getRelativePath(input.filePath)}`
}
return name
case "bash":
if (input.description) {
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 (input.tool) {
return getToolName(input.tool)
}
return name
default:
return toolName || "Unknown tool"
return name
}
}
function formatToolOutput() {
const state = props.toolCall?.state || {}
if (state.error) {
return `Error: ${state.error}`
}
if (state.output) {
return state.output
}
return "No output"
}
function formatOutputPreview() {
const state = props.toolCall?.state || {}
if (state.error) {
return state.error
}
if (state.output) {
const output = state.output
const lines = output.split("\n")
if (lines.length <= 10) {
return output
}
const firstTenLines = lines.slice(0, 10).join("\n")
return firstTenLines + "\n..."
}
return "No output"
}
const hasResult = () => {
const status = props.toolCall?.state?.status || ""
return status === "completed" || status === "error"
}
const renderToolBody = () => {
const toolName = props.toolCall?.tool || ""
const state = props.toolCall?.state || {}
const input = state.input || {}
const metadata = state.metadata || {}
if (toolName === "todoread") {
return null
}
if (state.status === "pending") {
return null
}
switch (toolName) {
case "read":
return renderReadTool()
case "edit":
return renderEditTool()
case "write":
return renderWriteTool()
case "bash":
return renderBashTool()
case "webfetch":
return renderWebfetchTool()
case "todowrite":
return renderTodowriteTool()
case "task":
return renderTaskTool()
default:
return renderDefaultTool()
}
}
const renderReadTool = () => {
const state = props.toolCall?.state || {}
const metadata = state.metadata || {}
const input = state.input || {}
const preview = metadata.preview
if (preview && input.filePath) {
const lines = preview.split("\n")
const truncated = lines.slice(0, 6).join("\n")
return (
<pre class="tool-call-content">
<code>{truncated}</code>
</pre>
)
}
return null
}
const renderEditTool = () => {
const state = props.toolCall?.state || {}
const metadata = state.metadata || {}
const diff = metadata.diff
if (diff) {
return (
<div class="tool-call-diff">
<pre class="tool-call-content">
<code>{diff}</code>
</pre>
</div>
)
}
return null
}
const renderWriteTool = () => {
const state = props.toolCall?.state || {}
const input = state.input || {}
if (input.content && input.filePath) {
const lines = input.content.split("\n")
const truncated = lines.slice(0, 10).join("\n")
return (
<pre class="tool-call-content">
<code>{truncated}</code>
</pre>
)
}
return null
}
const renderBashTool = () => {
const state = props.toolCall?.state || {}
const input = state.input || {}
const metadata = state.metadata || {}
const output = metadata.output
if (input.command) {
return (
<div class="tool-call-bash">
<pre class="tool-call-content">
<code>
$ {input.command}
{output && "\n"}
{output}
</code>
</pre>
</div>
)
}
return null
}
const renderWebfetchTool = () => {
const state = props.toolCall?.state || {}
const output = state.output
if (output) {
const lines = output.split("\n")
const truncated = lines.slice(0, 10).join("\n")
return (
<pre class="tool-call-content">
<code>{truncated}</code>
</pre>
)
}
return null
}
const renderTodowriteTool = () => {
const state = props.toolCall?.state || {}
const metadata = state.metadata || {}
const todos = metadata.todos || []
if (!Array.isArray(todos) || todos.length === 0) {
return null
}
return (
<div class="tool-call-todos">
<For each={todos}>
{(todo) => {
const content = todo.content
if (!content) return null
return (
<div class="tool-call-todo-item">
{todo.status === "completed" && "- [x] "}
{todo.status !== "completed" && "- [ ] "}
{todo.status === "cancelled" && <s>{content}</s>}
{todo.status === "in_progress" && <code>{content}</code>}
{todo.status !== "cancelled" && todo.status !== "in_progress" && content}
</div>
)
}}
</For>
</div>
)
}
const renderTaskTool = () => {
const state = props.toolCall?.state || {}
const metadata = state.metadata || {}
const summary = metadata.summary || []
if (!Array.isArray(summary) || summary.length === 0) {
return null
}
return (
<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>
)
}
const renderDefaultTool = () => {
const state = props.toolCall?.state || {}
const output = state.output
if (output) {
const lines = output.split("\n")
const truncated = lines.slice(0, 10).join("\n")
return (
<pre class="tool-call-content">
<code>{truncated}</code>
</pre>
)
}
return null
}
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={toggleExpanded} aria-expanded={expanded()}>
<button class="tool-call-header" onClick={toggle} aria-expanded={expanded()}>
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
<span class="tool-call-summary">{formatToolSummary()}</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() && hasResult()}>
<div class="tool-call-preview">
<span class="tool-call-preview-label">Output:</span>
<span class="tool-call-preview-text">{formatOutputPreview()}</span>
</div>
</Show>
<Show when={expanded()}>
<div class="tool-call-details">
<div class="tool-call-section">
<h4>Input:</h4>
<pre>
<code>{JSON.stringify(props.toolCall?.state?.input || {}, null, 2)}</code>
</pre>
</div>
{renderToolBody()}
{renderError()}
<Show when={hasResult()}>
<div class="tool-call-section">
<h4>Output:</h4>
<pre>
<code>{formatToolOutput()}</code>
</pre>
<Show when={status() === "pending"}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>Waiting for permission...</span>
</div>
</Show>
</div>