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

228
TOOL_CALL_IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,228 @@
# Tool Call Rendering Implementation
This document describes how tool calls are rendered in the OpenCode Client, following the patterns established in the TUI.
## Overview
Each tool type has specialized rendering logic that displays the most relevant information for that tool. This matches the TUI's approach of providing context-specific displays rather than generic input/output dumps.
## Tool-Specific Rendering
### 1. **read** - File Reading
- **Title**: `Read {filename}`
- **Body**: Preview of file content (first 6 lines) from `metadata.preview`
- **Use case**: Shows what file content the assistant is reading
### 2. **edit** - File Editing
- **Title**: `Edit {filename}`
- **Body**: Diff/patch showing changes from `metadata.diff`
- **Special**: Shows diagnostics if available in metadata
- **Use case**: Shows what changes are being made to files
### 3. **write** - File Writing
- **Title**: `Write {filename}`
- **Body**: File content being written (first 10 lines)
- **Special**: Shows diagnostics if available in metadata
- **Use case**: Shows new file content being created
### 4. **bash** - Shell Commands
- **Title**: `Shell {description}` (or command if no description)
- **Body**: Console-style display with `$ command` and output
```
$ npm install vitest
added 50 packages...
```
- **Output from**: `metadata.output`
- **Use case**: Shows command execution and results
### 5. **webfetch** - Web Fetching
- **Title**: `Fetch {url}`
- **Body**: Fetched content (first 10 lines)
- **Use case**: Shows web content being retrieved
### 6. **todowrite** - Task Planning
- **Title**: Dynamic based on todo phase:
- All pending: "Creating plan"
- All completed: "Completing plan"
- Mixed: "Updating plan"
- **Body**: Formatted todo list:
- `- [x] Completed task`
- `- [ ] Pending task`
- `- [ ] ~~Cancelled task~~`
- `- [ ] In progress task` (highlighted)
- **Use case**: Shows the AI's task planning
### 7. **task** - Delegated Tasks
- **Title**: `Task[subagent_type] {description}`
- **Body**: List of delegated tool calls with icons:
```
⚡ bash: npm install
📖 read package.json
✏️ edit src/app.ts
```
- **Special**: In TUI, includes navigation hints for session tree
- **Use case**: Shows what the delegated agent is doing
### 8. **todoread** - Plan Reading
- **Special**: Hidden in TUI, returns empty string
- **Use case**: Internal tool, not displayed to user
### 9. **glob** - File Pattern Matching
- **Title**: `Glob {pattern}`
- **Use case**: Shows file search patterns
### 10. **grep** - Content Search
- **Title**: `Grep "{pattern}"`
- **Use case**: Shows what content is being searched
### 11. **list** - Directory Listing
- **Title**: `List`
- **Use case**: Shows directory operations
### 12. **patch** - Patching Files
- **Title**: `Patch`
- **Use case**: Shows patch operations
### 13. **invalid** - Invalid Tool Calls
- **Title**: Name of the actual tool attempted
- **Use case**: Shows validation errors
### 14. **Default** - Unknown Tools
- **Title**: Capitalized tool name
- **Body**: Output truncated to 10 lines
- **Use case**: Fallback for any new or custom tools
## Status States
### Pending
- **Icon**: ⏸ (pause symbol)
- **Title**: Action text (e.g., "Writing command...", "Preparing edit...")
- **Border**: Accent color
- **Animation**: Shimmer effect on title
- **Expandable**: Shows "Waiting for permission..." message
### Running
- **Icon**: ⏳ (hourglass)
- **Title**: Same as completed state
- **Border**: Warning color (yellow/orange)
- **Animation**: Pulse on status icon
### Completed
- **Icon**: ✓ (checkmark)
- **Title**: Tool-specific title with arguments
- **Border**: Success color (green)
- **Body**: Tool-specific rendered content
### Error
- **Icon**: ✗ (X mark)
- **Title**: Same format but in error color
- **Border**: Error color (red)
- **Body**: Error message in highlighted box
## Title Rendering Logic
The title follows this pattern:
1. **Pending state**: Show action text
```
"Writing command..."
"Preparing edit..."
"Delegating..."
```
2. **Completed/Running/Error**: Show specific info
```
"Shell npm install"
"Edit src/app.ts"
"Read package.json"
"Task[general] Search for files"
```
3. **Special cases**:
- `todowrite`: Shows plan phase
- `todoread`: Just "Plan"
- `bash`: Uses description if available, otherwise shows command
## Metadata Usage
Tool calls use `metadata` for rich content:
- **read**: `metadata.preview` - file preview content
- **edit**: `metadata.diff` - patch/diff text
- **bash**: `metadata.output` - command output
- **todowrite**: `metadata.todos[]` - todo items with status
- **task**: `metadata.summary[]` - delegated tool calls
- **edit/write**: `metadata.diagnostics` - LSP diagnostics
## Design Principles
1. **Context-specific**: Each tool shows the most relevant information
2. **Progressive disclosure**: Collapsed by default, expand for details
3. **Visual hierarchy**: Icons, colors, and borders indicate status
4. **Truncation**: Long content is truncated (6-10 lines) to prevent overwhelming
5. **Consistency**: All tools follow same header/body/error structure
## Component Structure
```tsx
<div class="tool-call tool-call-status-{status}">
<button class="tool-call-header" onClick={toggle}>
<span class="tool-call-icon">▶/▼</span>
<span class="tool-call-emoji">{icon}</span>
<span class="tool-call-summary">{title}</span>
<span class="tool-call-status">{statusIcon}</span>
</button>
{expanded && (
<div class="tool-call-details">
{/* Tool-specific body content */}
{error && <div class="tool-call-error-content">{error}</div>}
</div>
)}
</div>
```
## CSS Classes
- `.tool-call` - Base container
- `.tool-call-status-{pending|running|completed|error}` - Status-specific styling
- `.tool-call-header` - Clickable header with expand/collapse
- `.tool-call-emoji` - Tool type icon
- `.tool-call-summary` - Tool title/description
- `.tool-call-details` - Expanded content area
- `.tool-call-content` - Code/output content (monospace)
- `.tool-call-todos` - Todo list container
- `.tool-call-task-summary` - Delegated task list
- `.tool-call-error-content` - Error message display
## Future Enhancements
1. **Syntax highlighting**: Use Shiki for code blocks in bash, read, write
2. **Diff rendering**: Better diff visualization for edit tool
3. **Copy buttons**: Quick copy for code/output
4. **File links**: Click filename to open in editor
5. **Diagnostics display**: Show LSP errors/warnings inline

View File

@@ -5,6 +5,7 @@ import SessionPicker from "./components/session-picker"
import InstanceTabs from "./components/instance-tabs"
import SessionTabs from "./components/session-tabs"
import MessageStream from "./components/message-stream"
import PromptInput from "./components/prompt-input"
import {
hasInstances,
isSelectingFolder,
@@ -35,10 +36,11 @@ import {
activeParentSessionId,
getParentSessions,
loadMessages,
sendMessage,
} from "./stores/sessions"
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
const SessionMessages: Component<{
const SessionView: Component<{
sessionId: string
activeSessions: Map<string, Session>
instanceId: string
@@ -52,6 +54,10 @@ const SessionMessages: Component<{
}
})
async function handleSendMessage(prompt: string) {
await sendMessage(props.instanceId, props.sessionId, prompt)
}
return (
<Show
when={session()}
@@ -61,7 +67,17 @@ const SessionMessages: Component<{
</div>
}
>
{(s) => <MessageStream sessionId={s().id} messages={s().messages || []} messagesInfo={s().messagesInfo} />}
{(s) => (
<div class="session-view">
<MessageStream
instanceId={props.instanceId}
sessionId={s().id}
messages={s().messages || []}
messagesInfo={s().messagesInfo}
/>
<PromptInput instanceId={props.instanceId} sessionId={s().id} onSend={handleSendMessage} />
</div>
)}
</Show>
)
}
@@ -213,7 +229,7 @@ const App: Component = () => {
</div>
}
>
<SessionMessages
<SessionView
sessionId={activeSessionIdForInstance()!}
activeSessions={activeSessions()}
instanceId={activeInstance()!.id}

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>

View File

@@ -58,6 +58,41 @@ body {
overflow: hidden;
}
.connection-status {
display: flex;
justify-content: flex-end;
padding: 8px 16px;
background-color: var(--secondary-bg);
border-bottom: 1px solid var(--border-color);
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-indicator.connected .status-dot {
background-color: var(--success-color);
}
.status-indicator.connecting .status-dot {
background-color: var(--warning-color);
animation: pulse 1.5s ease-in-out infinite;
}
.status-indicator.disconnected .status-dot {
background-color: var(--error-color);
}
.message-stream {
flex: 1;
overflow-y: auto;
@@ -226,21 +261,33 @@ body {
background-color: var(--secondary-bg);
}
.message-reasoning details {
.reasoning-container {
padding: 8px 12px;
}
.message-reasoning summary {
.reasoning-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
}
.message-reasoning summary:hover {
.reasoning-header:hover {
color: var(--accent-color);
}
.reasoning-icon {
font-size: 10px;
transition: transform 150ms ease;
}
.reasoning-label {
font-weight: 500;
}
.tool-call {
border: 1px solid var(--border-color);
border-radius: 6px;
@@ -292,11 +339,32 @@ body {
border-left: 3px solid var(--error-color);
}
.tool-call-status-running,
.tool-call-status-pending {
.tool-call-status-running {
border-left: 3px solid var(--warning-color);
}
.tool-call-status-running .tool-call-status {
animation: pulse 1.5s ease-in-out infinite;
}
.tool-call-status-pending {
border-left: 3px solid var(--accent-color);
}
.tool-call-status-pending .tool-call-summary {
animation: shimmer 2s ease-in-out infinite;
}
@keyframes shimmer {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
.tool-call-preview {
padding: 8px 12px;
background-color: var(--code-bg);
@@ -331,7 +399,8 @@ body {
background-color: var(--code-bg);
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
font-size: 12px;
}
.tool-call-section h4 {
@@ -376,6 +445,98 @@ body {
background: var(--text-muted);
}
.tool-call-pending-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
color: var(--text-muted);
font-size: 12px;
font-style: italic;
}
.tool-call-emoji {
font-size: 16px;
margin-right: 4px;
}
.tool-call-bash,
.tool-call-diff {
margin: 8px 0;
}
.tool-call-content {
background-color: var(--secondary-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px 12px;
font-family: monospace;
font-size: 12px;
line-height: 1.4;
overflow-x: auto;
margin: 0;
}
.tool-call-content code {
font-family: inherit;
background: none;
padding: 0;
font-size: inherit;
}
.tool-call-todos {
margin: 8px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.tool-call-todo-item {
font-family: monospace;
font-size: 12px;
line-height: 1.5;
}
.tool-call-todo-item code {
background-color: rgba(0, 100, 255, 0.1);
padding: 2px 4px;
border-radius: 2px;
font-family: monospace;
}
.tool-call-task-summary {
margin: 8px 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.tool-call-task-item {
font-size: 12px;
line-height: 1.5;
padding-left: 8px;
border-left: 2px solid var(--border-color);
}
.tool-call-task-item::before {
content: "∟ ";
color: var(--text-muted);
}
.tool-call-error-content {
background-color: rgba(244, 67, 54, 0.1);
border-left: 3px solid var(--error-color);
padding: 12px;
margin: 8px 0;
border-radius: 4px;
color: var(--error-color);
font-size: 12px;
}
.tool-call-error-content strong {
font-weight: 600;
}
.scroll-to-bottom {
position: absolute;
bottom: 16px;
@@ -468,3 +629,127 @@ body {
transform: rotate(360deg);
}
}
.prompt-input-container {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border-color);
background-color: var(--background);
}
.prompt-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
}
.prompt-input {
flex: 1;
min-height: 40px;
max-height: 200px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
resize: none;
background-color: var(--background);
color: inherit;
outline: none;
transition: border-color 150ms ease;
}
.prompt-input:focus {
border-color: var(--accent-color);
}
.prompt-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.prompt-input::placeholder {
color: var(--text-muted);
}
.send-button {
width: 40px;
height: 40px;
border-radius: 6px;
background-color: var(--accent-color);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
opacity 150ms ease,
transform 150ms ease;
flex-shrink: 0;
}
.send-button:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.05);
}
.send-button:active:not(:disabled) {
transform: scale(0.95);
}
.send-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.send-icon {
font-size: 16px;
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.prompt-input-hints {
padding: 0 16px 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.hint {
font-size: 12px;
color: var(--text-muted);
}
.hint kbd {
display: inline-block;
padding: 2px 6px;
font-size: 11px;
font-family: monospace;
background-color: var(--secondary-bg);
border: 1px solid var(--border-color);
border-radius: 3px;
margin: 0 2px;
}
.session-view {
display: flex;
flex-direction: column;
height: 100%;
}
.message-sending {
color: var(--text-muted);
font-size: 13px;
font-style: italic;
margin-top: 4px;
}

144
src/lib/sse-manager.ts Normal file
View File

@@ -0,0 +1,144 @@
import { createSignal } from "solid-js"
interface SSEConnection {
instanceId: string
eventSource: EventSource
reconnectAttempts: number
status: "connecting" | "connected" | "disconnected" | "error"
}
interface MessageUpdateEvent {
type: "message_updated"
sessionId: string
messageId: string
parts: any[]
status: string
}
interface SessionUpdateEvent {
type: "session_updated"
session: any
}
const [connectionStatus, setConnectionStatus] = createSignal<
Map<string, "connecting" | "connected" | "disconnected" | "error">
>(new Map())
class SSEManager {
private connections = new Map<string, SSEConnection>()
private maxReconnectAttempts = 5
private baseReconnectDelay = 1000
connect(instanceId: string, port: number): void {
if (this.connections.has(instanceId)) {
this.disconnect(instanceId)
}
const url = `http://localhost:${port}/event`
const eventSource = new EventSource(url)
const connection: SSEConnection = {
instanceId,
eventSource,
reconnectAttempts: 0,
status: "connecting",
}
this.connections.set(instanceId, connection)
this.updateConnectionStatus(instanceId, "connecting")
eventSource.onopen = () => {
connection.status = "connected"
connection.reconnectAttempts = 0
this.updateConnectionStatus(instanceId, "connected")
console.log(`[SSE] Connected to instance ${instanceId}`)
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
this.handleEvent(instanceId, data)
} catch (error) {
console.error("[SSE] Failed to parse event:", error)
}
}
eventSource.onerror = () => {
connection.status = "error"
this.updateConnectionStatus(instanceId, "error")
console.error(`[SSE] Connection error for instance ${instanceId}`)
this.handleReconnect(instanceId, port)
}
}
disconnect(instanceId: string): void {
const connection = this.connections.get(instanceId)
if (connection) {
connection.eventSource.close()
this.connections.delete(instanceId)
this.updateConnectionStatus(instanceId, "disconnected")
console.log(`[SSE] Disconnected from instance ${instanceId}`)
}
}
private handleEvent(instanceId: string, event: any): void {
console.log("[SSE] Received event:", event.type, event)
switch (event.type) {
case "message.updated":
case "message.part.updated":
this.onMessageUpdate?.(instanceId, event)
break
case "session.updated":
this.onSessionUpdate?.(instanceId, event)
break
case "session.idle":
console.log("[SSE] Session idle")
break
default:
console.warn("[SSE] Unknown event type:", event.type)
}
}
private handleReconnect(instanceId: string, port: number): void {
const connection = this.connections.get(instanceId)
if (!connection) return
if (connection.reconnectAttempts >= this.maxReconnectAttempts) {
console.error(`[SSE] Max reconnection attempts reached for ${instanceId}`)
connection.status = "disconnected"
this.updateConnectionStatus(instanceId, "disconnected")
return
}
const delay = this.baseReconnectDelay * Math.pow(2, connection.reconnectAttempts)
connection.reconnectAttempts++
console.log(`[SSE] Reconnecting to ${instanceId} in ${delay}ms (attempt ${connection.reconnectAttempts})`)
setTimeout(() => {
this.connect(instanceId, port)
}, delay)
}
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
setConnectionStatus((prev) => {
const next = new Map(prev)
next.set(instanceId, status)
return next
})
}
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
return connectionStatus().get(instanceId) ?? null
}
getStatuses() {
return connectionStatus()
}
}
export const sseManager = new SSEManager()

View File

@@ -1,6 +1,7 @@
import { createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager"
import { fetchSessions, fetchAgents, fetchProviders } from "./sessions"
import { showSessionPicker } from "./ui"
@@ -66,6 +67,8 @@ async function createInstance(folder: string): Promise<string> {
setActiveInstanceId(tempId)
sseManager.connect(tempId, port)
try {
await fetchSessions(tempId)
await fetchAgents(tempId)
@@ -90,6 +93,8 @@ async function stopInstance(id: string) {
const instance = instances().get(id)
if (!instance) return
sseManager.disconnect(id)
if (instance.port) {
sdkManager.destroyClient(instance.port)
}

View File

@@ -2,6 +2,7 @@ import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session"
import type { Message } from "../types/message"
import { instances } from "./instances"
import { sseManager } from "../lib/sse-manager"
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
@@ -394,6 +395,209 @@ async function loadMessages(instanceId: string, sessionId: string): Promise<void
}
}
function handleMessageUpdate(instanceId: string, event: any): void {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
if (event.type === "message.part.updated") {
const part = event.properties?.part
if (!part) return
const session = instanceSessions.get(part.sessionID)
if (!session) return
let message = session.messages.find((m) => m.id === part.messageID)
if (!message) {
message = {
id: part.messageID,
sessionId: part.sessionID,
type: "assistant",
parts: [part],
timestamp: Date.now(),
status: "streaming",
}
session.messages.push(message)
} else {
const partIndex = message.parts.findIndex((p: any) => p.id === part.id)
if (partIndex === -1) {
message.parts.push(part)
} else {
message.parts[partIndex] = part
}
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
instanceSessions.set(part.sessionID, { ...session })
next.set(instanceId, instanceSessions)
return next
})
} else if (event.type === "message.updated") {
const info = event.properties?.info
if (!info) return
const session = instanceSessions.get(info.sessionID)
if (!session) return
let message = session.messages.find((m) => m.id === info.id)
if (!message) {
message = {
id: info.id,
sessionId: info.sessionID,
type: info.role === "user" ? "user" : "assistant",
parts: [],
timestamp: info.time?.created || Date.now(),
status: "complete",
}
session.messages.push(message)
} else {
// Update existing message - replace temp message with real one
message.id = info.id
message.status = "complete"
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const updatedSession = instanceSessions.get(info.sessionID)
if (updatedSession) {
const messagesInfo = new Map(updatedSession.messagesInfo)
messagesInfo.set(info.id, info)
instanceSessions.set(info.sessionID, { ...updatedSession, messagesInfo })
}
next.set(instanceId, instanceSessions)
return next
})
}
}
function handleSessionUpdate(instanceId: string, event: any): void {
const info = event.properties?.info
if (!info) return
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const existingSession = instanceSessions.get(info.id)
if (!existingSession) {
const newSession: Session = {
id: info.id,
instanceId,
title: info.title || "Untitled",
parentId: info.parentID || null,
agent: info.agent || "",
model: {
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
},
time: {
created: info.time?.created || Date.now(),
updated: info.time?.updated || Date.now(),
},
messages: [],
messagesInfo: new Map(),
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
instanceSessions.set(newSession.id, newSession)
next.set(instanceId, instanceSessions)
return next
})
console.log(`[SSE] New session created: ${info.id}`, newSession)
} else {
const updatedSession = {
...existingSession,
title: info.title || existingSession.title,
agent: info.agent || existingSession.agent,
model: info.model
? {
providerId: info.model.providerID || existingSession.model.providerId,
modelId: info.model.modelID || existingSession.model.modelId,
}
: existingSession.model,
time: {
...existingSession.time,
updated: info.time?.updated || Date.now(),
},
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
instanceSessions.set(existingSession.id, updatedSession)
next.set(instanceId, instanceSessions)
return next
})
}
}
async function sendMessage(
instanceId: string,
sessionId: string,
prompt: string,
attachments: string[] = [],
): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
const requestBody = {
parts: [
{
type: "text" as const,
text: prompt,
},
],
...(session.agent && { agent: session.agent }),
...(session.model.providerId &&
session.model.modelId && {
model: {
providerID: session.model.providerId,
modelID: session.model.modelId,
},
}),
}
console.log("[sendMessage] Sending prompt:", {
sessionId,
requestBody,
})
try {
const response = await instance.client.session.prompt({
path: { id: sessionId },
body: requestBody,
})
console.log("[sendMessage] Response:", response)
if (response.error) {
console.error("[sendMessage] Server returned error:", response.error)
throw new Error(JSON.stringify(response.error) || "Failed to send message")
}
} catch (error) {
console.error("[sendMessage] Failed to send prompt:", error)
throw error
}
}
sseManager.onMessageUpdate = handleMessageUpdate
sseManager.onSessionUpdate = handleSessionUpdate
export {
sessions,
activeSessionId,
@@ -407,6 +611,7 @@ export {
fetchAgents,
fetchProviders,
loadMessages,
sendMessage,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,

View File

@@ -0,0 +1,36 @@
import { createSignal } from "solid-js"
const [expandedItems, setExpandedItems] = createSignal<Set<string>>(new Set())
export function isItemExpanded(itemId: string): boolean {
return expandedItems().has(itemId)
}
export function toggleItemExpanded(itemId: string): void {
setExpandedItems((prev) => {
const next = new Set(prev)
if (next.has(itemId)) {
next.delete(itemId)
} else {
next.add(itemId)
}
return next
})
}
export function setItemExpanded(itemId: string, expanded: boolean): void {
setExpandedItems((prev) => {
const next = new Set(prev)
if (expanded) {
next.add(itemId)
} else {
next.delete(itemId)
}
return next
})
}
// Backward compatibility aliases
export const isToolCallExpanded = isItemExpanded
export const toggleToolCallExpanded = toggleItemExpanded
export const setToolCallExpanded = setItemExpanded

View File

@@ -0,0 +1,443 @@
# Task 008: SSE Integration - Real-time Message Streaming
## Status: TODO
## Objective
Implement Server-Sent Events (SSE) integration to enable real-time message streaming from OpenCode servers. Each instance will maintain its own EventSource connection to receive live updates for sessions and messages.
## Prerequisites
- Task 006 (Instance/Session tabs) complete
- Task 007 (Message display) complete
- SDK client configured per instance
- Understanding of EventSource API
## Context
The OpenCode server emits events via SSE at the `/events` endpoint. These events include:
- Message updates (streaming content)
- Session updates (new sessions, title changes)
- Tool execution status updates
- Server status changes
We need to:
1. Create an SSE manager to handle connections
2. Connect one EventSource per instance
3. Route events to the correct instance/session
4. Update reactive state to trigger UI updates
5. Implement reconnection logic for dropped connections
## Implementation Steps
### Step 1: Create SSE Manager Module
Create `src/lib/sse-manager.ts`:
```typescript
import { createSignal } from "solid-js"
interface SSEConnection {
instanceId: string
eventSource: EventSource
reconnectAttempts: number
status: "connecting" | "connected" | "disconnected" | "error"
}
interface MessageUpdateEvent {
type: "message_updated"
sessionId: string
messageId: string
parts: any[]
status: string
}
interface SessionUpdateEvent {
type: "session_updated"
session: any
}
class SSEManager {
private connections = new Map<string, SSEConnection>()
private maxReconnectAttempts = 5
private baseReconnectDelay = 1000
connect(instanceId: string, port: number): void {
if (this.connections.has(instanceId)) {
this.disconnect(instanceId)
}
const url = `http://localhost:${port}/events`
const eventSource = new EventSource(url)
const connection: SSEConnection = {
instanceId,
eventSource,
reconnectAttempts: 0,
status: "connecting",
}
this.connections.set(instanceId, connection)
eventSource.onopen = () => {
connection.status = "connected"
connection.reconnectAttempts = 0
console.log(`[SSE] Connected to instance ${instanceId}`)
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
this.handleEvent(instanceId, data)
} catch (error) {
console.error("[SSE] Failed to parse event:", error)
}
}
eventSource.onerror = () => {
connection.status = "error"
console.error(`[SSE] Connection error for instance ${instanceId}`)
this.handleReconnect(instanceId, port)
}
}
disconnect(instanceId: string): void {
const connection = this.connections.get(instanceId)
if (connection) {
connection.eventSource.close()
this.connections.delete(instanceId)
console.log(`[SSE] Disconnected from instance ${instanceId}`)
}
}
private handleEvent(instanceId: string, event: any): void {
switch (event.type) {
case "message_updated":
this.onMessageUpdate?.(instanceId, event as MessageUpdateEvent)
break
case "session_updated":
this.onSessionUpdate?.(instanceId, event as SessionUpdateEvent)
break
default:
console.warn("[SSE] Unknown event type:", event.type)
}
}
private handleReconnect(instanceId: string, port: number): void {
const connection = this.connections.get(instanceId)
if (!connection) return
if (connection.reconnectAttempts >= this.maxReconnectAttempts) {
console.error(`[SSE] Max reconnection attempts reached for ${instanceId}`)
connection.status = "disconnected"
return
}
const delay = this.baseReconnectDelay * Math.pow(2, connection.reconnectAttempts)
connection.reconnectAttempts++
console.log(`[SSE] Reconnecting to ${instanceId} in ${delay}ms (attempt ${connection.reconnectAttempts})`)
setTimeout(() => {
this.connect(instanceId, port)
}, delay)
}
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void
getStatus(instanceId: string): SSEConnection["status"] | null {
return this.connections.get(instanceId)?.status ?? null
}
}
export const sseManager = new SSEManager()
```
### Step 2: Integrate SSE Manager with Instance Store
Update `src/stores/instances.ts` to use SSE manager:
```typescript
import { sseManager } from "../lib/sse-manager"
// In createInstance function, after SDK client is created:
async function createInstance(folder: string) {
// ... existing code to spawn server and create SDK client ...
// Connect SSE
sseManager.connect(instance.id, port)
// Set up event handlers
sseManager.onMessageUpdate = (instanceId, event) => {
handleMessageUpdate(instanceId, event)
}
sseManager.onSessionUpdate = (instanceId, event) => {
handleSessionUpdate(instanceId, event)
}
}
// In removeInstance function:
async function removeInstance(id: string) {
// Disconnect SSE before removing
sseManager.disconnect(id)
// ... existing cleanup code ...
}
```
### Step 3: Handle Message Update Events
Create message update handler in instance store:
```typescript
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent) {
const instance = instances.get(instanceId)
if (!instance) return
const session = instance.sessions.get(event.sessionId)
if (!session) return
// Find or create message
let message = session.messages.find((m) => m.id === event.messageId)
if (!message) {
// New message - add it
message = {
id: event.messageId,
sessionId: event.sessionId,
type: "assistant", // Determine from event
parts: event.parts,
timestamp: Date.now(),
status: event.status,
}
session.messages.push(message)
} else {
// Update existing message
message.parts = event.parts
message.status = event.status
}
// Trigger reactivity - update the map reference
instances.set(instanceId, { ...instance })
}
```
### Step 4: Handle Session Update Events
Create session update handler:
```typescript
function handleSessionUpdate(instanceId: string, event: SessionUpdateEvent) {
const instance = instances.get(instanceId)
if (!instance) return
const existingSession = instance.sessions.get(event.session.id)
if (!existingSession) {
// New session - add it
const newSession = {
id: event.session.id,
instanceId,
title: event.session.title || "Untitled",
parentId: event.session.parentId,
agent: event.session.agent,
model: event.session.model,
messages: [],
status: "idle",
createdAt: Date.now(),
updatedAt: Date.now(),
}
instance.sessions.set(event.session.id, newSession)
// Auto-create tab for child sessions
if (event.session.parentId) {
console.log(`[SSE] New child session created: ${event.session.id}`)
// Optionally auto-switch to new session
// instance.activeSessionId = event.session.id
}
} else {
// Update existing session
existingSession.title = event.session.title || existingSession.title
existingSession.agent = event.session.agent || existingSession.agent
existingSession.model = event.session.model || existingSession.model
existingSession.updatedAt = Date.now()
}
// Trigger reactivity
instances.set(instanceId, { ...instance })
}
```
### Step 5: Add Connection Status Indicator
Update `src/components/message-stream.tsx` to show connection status:
```typescript
import { sseManager } from "../lib/sse-manager"
function MessageStream(props) {
const connectionStatus = () => sseManager.getStatus(props.instanceId)
return (
<div class="flex flex-col h-full">
{/* Connection status indicator */}
<div class="flex items-center justify-end px-4 py-2 text-xs text-gray-500">
{connectionStatus() === "connected" && (
<span class="flex items-center gap-1">
<div class="w-2 h-2 bg-green-500 rounded-full" />
Connected
</span>
)}
{connectionStatus() === "connecting" && (
<span class="flex items-center gap-1">
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
Connecting...
</span>
)}
{connectionStatus() === "error" && (
<span class="flex items-center gap-1">
<div class="w-2 h-2 bg-red-500 rounded-full" />
Disconnected
</span>
)}
</div>
{/* Existing message list */}
{/* ... */}
</div>
)
}
```
### Step 6: Test SSE Connection
Create a test utility to verify SSE is working:
```typescript
// In browser console or test file:
async function testSSE() {
// Manually trigger a message
const response = await fetch("http://localhost:4096/session/SESSION_ID/message", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: "Hello, world!",
attachments: [],
}),
})
// Check console for SSE events
// Should see message_updated events arriving
}
```
### Step 7: Handle Edge Cases
Add error handling for:
```typescript
// Connection drops during message streaming
// - Reconnect logic should handle this automatically
// - Messages should resume from last known state
// Multiple instances with different ports
// - Each instance has its own EventSource
// - Events routed correctly via instanceId
// Instance removed while connected
// - EventSource closed before instance cleanup
// - No memory leaks
// Page visibility changes (browser tab inactive)
// - EventSource may pause, reconnect on focus
// - Consider using Page Visibility API to manage connections
```
## Testing Checklist
### Manual Testing
- [ ] Open instance, verify SSE connection established
- [ ] Send message, verify streaming events arrive
- [ ] Check browser DevTools Network tab for SSE connection
- [ ] Verify connection status indicator shows "Connected"
- [ ] Kill server process, verify reconnection attempts
- [ ] Restart server, verify successful reconnection
- [ ] Open multiple instances, verify independent connections
- [ ] Switch between instances, verify events route correctly
- [ ] Close instance tab, verify EventSource closed cleanly
### Testing Message Streaming
- [ ] Send message, watch events in console
- [ ] Verify message parts update in real-time
- [ ] Check assistant response streams character by character
- [ ] Verify tool calls appear as they execute
- [ ] Confirm message status updates (streaming → complete)
### Testing Child Sessions
- [ ] Trigger action that creates child session
- [ ] Verify session_updated event received
- [ ] Confirm new session tab appears
- [ ] Check parentId correctly set
### Testing Reconnection
- [ ] Disconnect network, verify reconnection attempts
- [ ] Reconnect network, verify successful reconnection
- [ ] Verify exponential backoff delays
- [ ] Confirm max attempts limit works
## Acceptance Criteria
- [ ] SSE connection established when instance created
- [ ] Message updates arrive in real-time
- [ ] Session updates handled correctly
- [ ] Child sessions auto-create tabs
- [ ] Connection status visible in UI
- [ ] Reconnection logic works with exponential backoff
- [ ] Multiple instances have independent connections
- [ ] EventSource closed when instance removed
- [ ] No console errors during normal operation
- [ ] Events route to correct instance/session
## Performance Considerations
**Note: Per MVP principles, don't over-optimize**
- Simple event handling - no batching needed
- Direct state updates trigger reactivity
- Reconnection uses exponential backoff
- Only optimize if lag occurs in testing
## Future Enhancements (Post-MVP)
- Event batching for high-frequency updates
- Delta updates instead of full message parts
- Offline queue for events missed during disconnect
- Page Visibility API integration
- Event compression for large payloads
## References
- [Technical Implementation - SSE Event Handling](../docs/technical-implementation.md#sse-event-handling)
- [Architecture - Communication Layer](../docs/architecture.md#communication-layer)
- [MDN - EventSource API](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
## Estimated Time
3-4 hours
## Notes
- Keep reconnection logic simple for MVP
- Log all SSE events to console for debugging
- Test with long-running streaming responses
- Verify memory usage doesn't grow over time
- Consider adding SSE event debugging panel (optional)

View File

@@ -0,0 +1,520 @@
# Task 009: Prompt Input Basic - Text Input with Send Functionality
## Status: TODO
## Objective
Implement a basic prompt input component that allows users to type messages and send them to the OpenCode server. This enables testing of the SSE integration and completes the core chat interface loop.
## Prerequisites
- Task 007 (Message display) complete
- Task 008 (SSE integration) complete
- Active session available
- SDK client configured
## Context
The prompt input is the primary way users interact with OpenCode. For the MVP, we need:
- Simple text input (multi-line textarea)
- Send button
- Basic keyboard shortcuts (Enter to send, Shift+Enter for new line)
- Loading state while assistant is responding
- Basic validation (empty message prevention)
Advanced features (slash commands, file attachments, @ mentions) will come in Task 021-024.
## Implementation Steps
### Step 1: Create Prompt Input Component
Create `src/components/prompt-input.tsx`:
```typescript
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("")
// Auto-resize textarea back to initial size
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)
// Auto-resize textarea
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>
)
}
```
### Step 2: Add Send Message Function to Sessions Store
Update `src/stores/sessions.ts` to add message sending:
```typescript
async function sendMessage(
instanceId: string,
sessionId: string,
prompt: string,
attachments: string[] = [],
): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
// Add user message optimistically
const userMessage: Message = {
id: `temp-${Date.now()}`,
sessionId,
type: "user",
parts: [{ type: "text", text: prompt }],
timestamp: Date.now(),
status: "sending",
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const updatedSession = instanceSessions.get(sessionId)
if (updatedSession) {
const newMessages = [...updatedSession.messages, userMessage]
instanceSessions.set(sessionId, { ...updatedSession, messages: newMessages })
}
next.set(instanceId, instanceSessions)
return next
})
try {
// Send to server using session.prompt (not session.message)
await instance.client.session.prompt({
path: { id: sessionId },
body: {
messageID: userMessage.id,
parts: [
{
type: "text",
text: prompt,
},
],
},
})
// Update user message status
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const updatedSession = instanceSessions.get(sessionId)
if (updatedSession) {
const messages = updatedSession.messages.map((m) =>
m.id === userMessage.id ? { ...m, status: "sent" as const } : m,
)
instanceSessions.set(sessionId, { ...updatedSession, messages })
}
next.set(instanceId, instanceSessions)
return next
})
} catch (error) {
// Update user message with error
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const updatedSession = instanceSessions.get(sessionId)
if (updatedSession) {
const messages = updatedSession.messages.map((m) =>
m.id === userMessage.id ? { ...m, status: "error" as const } : m,
)
instanceSessions.set(sessionId, { ...updatedSession, messages })
}
next.set(instanceId, instanceSessions)
return next
})
throw error
}
}
// Export it
export { sendMessage }
```
### Step 3: Integrate Prompt Input into App
Update `src/App.tsx` to add the prompt input:
```typescript
import PromptInput from "./components/prompt-input"
import { sendMessage } from "./stores/sessions"
// In the SessionMessages component or create a new wrapper component
const SessionView: Component<{
sessionId: string
activeSessions: Map<string, Session>
instanceId: string
}> = (props) => {
const session = () => props.activeSessions.get(props.sessionId)
createEffect(() => {
const currentSession = session()
if (currentSession) {
loadMessages(props.instanceId, currentSession.id).catch(console.error)
}
})
async function handleSendMessage(prompt: string) {
await sendMessage(props.instanceId, props.sessionId, prompt)
}
return (
<Show
when={session()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500">Session not found</div>
</div>
}
>
{(s) => (
<div class="session-view">
<MessageStream
instanceId={props.instanceId}
sessionId={s().id}
messages={s().messages || []}
messagesInfo={s().messagesInfo}
/>
<PromptInput
instanceId={props.instanceId}
sessionId={s().id}
onSend={handleSendMessage}
/>
</div>
)}
</Show>
)
}
// Replace SessionMessages usage with SessionView
```
### Step 4: Add Styling
Add to `src/index.css`:
```css
.prompt-input-container {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border-color);
background-color: var(--background);
}
.prompt-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
}
.prompt-input {
flex: 1;
min-height: 40px;
max-height: 200px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
resize: none;
background-color: var(--background);
color: inherit;
outline: none;
transition: border-color 150ms ease;
}
.prompt-input:focus {
border-color: var(--accent-color);
}
.prompt-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.prompt-input::placeholder {
color: var(--text-muted);
}
.send-button {
width: 40px;
height: 40px;
border-radius: 6px;
background-color: var(--accent-color);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
opacity 150ms ease,
transform 150ms ease;
flex-shrink: 0;
}
.send-button:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.05);
}
.send-button:active:not(:disabled) {
transform: scale(0.95);
}
.send-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.send-icon {
font-size: 16px;
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.prompt-input-hints {
padding: 0 16px 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.hint {
font-size: 12px;
color: var(--text-muted);
}
.hint kbd {
display: inline-block;
padding: 2px 6px;
font-size: 11px;
font-family: monospace;
background-color: var(--secondary-bg);
border: 1px solid var(--border-color);
border-radius: 3px;
margin: 0 2px;
}
.session-view {
display: flex;
flex-direction: column;
height: 100%;
}
```
### Step 5: Update Message Display for User Messages
Make sure user messages display correctly in `src/components/message-item.tsx`:
```typescript
// User messages should show with user styling
// Message status should be visible (sending, sent, error)
<Show when={props.message.status === "error"}>
<div class="message-error">Failed to send message</div>
</Show>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
```
### Step 6: Handle Real-time Response
The SSE integration from Task 008 should automatically:
1. Receive message_updated events
2. Create assistant message in the session
3. Stream message parts as they arrive
4. Update the UI in real-time
No additional code needed - this should "just work" if SSE is connected.
## Testing Checklist
### Basic Functionality
- [ ] Prompt input renders at bottom of session view
- [ ] Can type text in the textarea
- [ ] Textarea auto-expands as you type (up to max height)
- [ ] Send button is disabled when input is empty
- [ ] Send button is enabled when text is present
### Sending Messages
- [ ] Click send button - message appears in stream
- [ ] Press Enter - message sends
- [ ] Press Shift+Enter - adds new line (doesn't send)
- [ ] Input clears after sending
- [ ] Focus returns to input after sending
### User Message Display
- [ ] User message appears immediately (optimistic update)
- [ ] User message shows "Sending..." state briefly
- [ ] User message updates to "sent" after API confirms
- [ ] Error state shows if send fails
### Assistant Response
- [ ] After sending, SSE receives message updates
- [ ] Assistant message appears in stream
- [ ] Message parts stream in real-time
- [ ] Tool calls appear as they execute
- [ ] Connection status indicator shows "Connected"
### Edge Cases
- [ ] Can't send while previous message is processing
- [ ] Empty/whitespace-only messages don't send
- [ ] Very long messages work correctly
- [ ] Multiple rapid sends are queued properly
- [ ] Network error shows helpful message
## Acceptance Criteria
- [ ] Can type and send text messages
- [ ] Enter key sends message
- [ ] Shift+Enter creates new line
- [ ] Send button works correctly
- [ ] User messages appear immediately
- [ ] Assistant responses stream in real-time via SSE
- [ ] Input auto-expands up to max height
- [ ] Loading states are clear
- [ ] Error handling works
- [ ] No console errors during normal operation
## Performance Considerations
**Per MVP principles - keep it simple:**
- Direct API calls - no batching
- Optimistic updates for user messages
- SSE handles streaming automatically
- No debouncing or throttling needed
## Future Enhancements (Post-MVP)
- Slash command autocomplete (Task 021)
- File attachment support (Task 022)
- Drag & drop files (Task 023)
- Attachment chips (Task 024)
- Message history navigation (Task 025)
- Multi-line paste handling
- Rich text formatting
- Message drafts persistence
## References
- [User Interface - Prompt Input](../docs/user-interface.md#5-prompt-input)
- [Technical Implementation - Message Rendering](../docs/technical-implementation.md#message-rendering)
- [Task 008 - SSE Integration](./008-sse-integration.md)
## Estimated Time
2-3 hours
## Notes
- Focus on core functionality - no fancy features yet
- Test thoroughly with SSE to ensure real-time streaming works
- This completes the basic chat loop - users can now interact with OpenCode
- Keep error messages user-friendly and actionable
- Ensure keyboard shortcuts work as expected

View File

@@ -0,0 +1,603 @@
# Task 010: Tool Call Rendering - Display Tool Executions Inline
## Status: TODO
## Objective
Implement interactive tool call rendering that displays tool executions inline within assistant messages. Users should be able to expand/collapse tool calls to see input, output, and execution status.
## Prerequisites
- Task 007 (Message display) complete
- Task 008 (SSE integration) complete
- Task 009 (Prompt input) complete
- Messages streaming from API
- Tool call data available in message parts
## Context
When OpenCode executes tools (bash commands, file edits, etc.), these should be visible to the user in the message stream. Tool calls need:
- Collapsed state showing summary (tool name + brief description)
- Expanded state showing full input/output
- Status indicators (pending, running, success, error)
- Click to toggle expand/collapse
- Syntax highlighting for code in input/output
This provides transparency into what OpenCode is doing and helps users understand the assistant's actions.
## Implementation Steps
### Step 1: Define Tool Call Types
Create or update `src/types/message.ts`:
```typescript
export interface ToolCallPart {
type: "tool_call"
id: string
tool: string
input: any
output?: any
status: "pending" | "running" | "success" | "error"
error?: string
}
export interface MessagePart {
type: "text" | "tool_call"
text?: string
id?: string
tool?: string
input?: any
output?: any
status?: "pending" | "running" | "success" | "error"
error?: string
}
```
### Step 2: Create Tool Call Component
Create `src/components/tool-call.tsx`:
```typescript
import { createSignal, Show, Switch, Match } from "solid-js"
import type { ToolCallPart } from "../types/message"
interface ToolCallProps {
part: ToolCallPart
}
export default function ToolCall(props: ToolCallProps) {
const [expanded, setExpanded] = createSignal(false)
function toggleExpanded() {
setExpanded(!expanded())
}
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 "🔎"
default:
return "🔧"
}
}
function getStatusIcon(status: string): string {
switch (status) {
case "pending":
return "⏳"
case "running":
return "⟳"
case "success":
return "✓"
case "error":
return "✗"
default:
return ""
}
}
function getToolSummary(part: ToolCallPart): string {
const { tool, input } = part
switch (tool) {
case "bash":
return input?.command || "Execute command"
case "edit":
return `Edit ${input?.filePath || "file"}`
case "read":
return `Read ${input?.filePath || "file"}`
case "write":
return `Write ${input?.filePath || "file"}`
case "glob":
return `Find ${input?.pattern || "files"}`
case "grep":
return `Search for "${input?.pattern || "pattern"}"`
default:
return tool
}
}
function formatJson(obj: any): string {
if (typeof obj === "string") return obj
return JSON.stringify(obj, null, 2)
}
return (
<div
class="tool-call"
classList={{
"tool-call-expanded": expanded(),
"tool-call-error": props.part.status === "error",
"tool-call-success": props.part.status === "success",
"tool-call-running": props.part.status === "running",
}}
onClick={toggleExpanded}
>
<div class="tool-call-header">
<span class="tool-call-expand-icon">{expanded() ? "▼" : "▶"}</span>
<span class="tool-call-icon">{getToolIcon(props.part.tool)}</span>
<span class="tool-call-tool">{props.part.tool}:</span>
<span class="tool-call-summary">{getToolSummary(props.part)}</span>
<span class="tool-call-status">{getStatusIcon(props.part.status)}</span>
</div>
<Show when={expanded()}>
<div class="tool-call-body" onClick={(e) => e.stopPropagation()}>
<Show when={props.part.input}>
<div class="tool-call-section">
<div class="tool-call-section-title">Input:</div>
<pre class="tool-call-content">
<code>{formatJson(props.part.input)}</code>
</pre>
</div>
</Show>
<Show when={props.part.output !== undefined}>
<div class="tool-call-section">
<div class="tool-call-section-title">Output:</div>
<pre class="tool-call-content">
<code>{formatJson(props.part.output)}</code>
</pre>
</div>
</Show>
<Show when={props.part.error}>
<div class="tool-call-section tool-call-error-section">
<div class="tool-call-section-title">Error:</div>
<pre class="tool-call-content tool-call-error-content">
<code>{props.part.error}</code>
</pre>
</div>
</Show>
<Show when={props.part.status === "running"}>
<div class="tool-call-running-indicator">
<span class="spinner-small" />
<span>Executing...</span>
</div>
</Show>
</div>
</Show>
</div>
)
}
```
### Step 3: Update Message Item to Render Tool Calls
Update `src/components/message-item.tsx`:
```typescript
import { For, Show, Switch, Match } from "solid-js"
import type { Message, MessagePart } from "../types/message"
import ToolCall from "./tool-call"
interface MessageItemProps {
message: Message
}
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user"
return (
<div
class="message-item"
classList={{
"message-user": isUser(),
"message-assistant": !isUser(),
}}
>
<div class="message-header">
<span class="message-author">{isUser() ? "You" : "Assistant"}</span>
<span class="message-timestamp">
{new Date(props.message.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
<div class="message-content">
<For each={props.message.parts}>
{(part) => (
<Switch>
<Match when={part.type === "text"}>
<div class="message-text">{part.text}</div>
</Match>
<Match when={part.type === "tool_call"}>
<ToolCall part={part as any} />
</Match>
</Switch>
)}
</For>
</div>
<Show when={props.message.status === "error"}>
<div class="message-error">Failed to send message</div>
</Show>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
</div>
)
}
```
### Step 4: Add Tool Call Styling
Add to `src/index.css`:
```css
/* Tool Call Styles */
.tool-call {
margin: 8px 0;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--secondary-bg);
overflow: hidden;
cursor: pointer;
transition:
border-color 150ms ease,
background-color 150ms ease;
}
.tool-call:hover {
border-color: var(--accent-color);
}
.tool-call-expanded {
cursor: default;
}
.tool-call-success {
border-left: 3px solid #10b981;
}
.tool-call-error {
border-left: 3px solid #ef4444;
}
.tool-call-running {
border-left: 3px solid var(--accent-color);
}
.tool-call-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 13px;
}
.tool-call-expand-icon {
font-size: 10px;
color: var(--text-muted);
transition: transform 150ms ease;
}
.tool-call-expanded .tool-call-expand-icon {
transform: rotate(0deg);
}
.tool-call-icon {
font-size: 14px;
}
.tool-call-tool {
font-weight: 600;
color: var(--text);
}
.tool-call-summary {
flex: 1;
color: var(--text-muted);
font-family: monospace;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-call-status {
font-size: 14px;
margin-left: auto;
}
.tool-call-body {
border-top: 1px solid var(--border-color);
padding: 12px;
background-color: var(--background);
}
.tool-call-section {
margin-bottom: 12px;
}
.tool-call-section:last-child {
margin-bottom: 0;
}
.tool-call-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.tool-call-content {
background-color: var(--secondary-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px 12px;
font-family: monospace;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
margin: 0;
}
.tool-call-content code {
font-family: inherit;
background: none;
padding: 0;
}
.tool-call-error-section {
background-color: rgba(239, 68, 68, 0.05);
border-radius: 4px;
padding: 8px;
}
.tool-call-error-content {
background-color: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #dc2626;
}
.tool-call-running-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--secondary-bg);
border-radius: 4px;
font-size: 13px;
color: var(--text-muted);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.tool-call {
background-color: rgba(255, 255, 255, 0.03);
}
.tool-call-body {
background-color: rgba(0, 0, 0, 0.2);
}
.tool-call-content {
background-color: rgba(0, 0, 0, 0.3);
}
}
```
### Step 5: Update SSE Handler to Parse Tool Calls
Update `src/lib/sse-manager.ts` to correctly parse tool call parts from SSE events:
```typescript
function handleMessageUpdate(event: MessageUpdateEvent, instanceId: string) {
// When a message part arrives via SSE, check if it's a tool call
const part = event.part
if (part.type === "tool_call") {
// Parse tool call data
const toolCallPart: ToolCallPart = {
type: "tool_call",
id: part.id || `tool-${Date.now()}`,
tool: part.tool || "unknown",
input: part.input,
output: part.output,
status: part.status || "pending",
error: part.error,
}
// Add or update in messages
updateMessagePart(instanceId, event.sessionId, event.messageId, toolCallPart)
}
}
```
### Step 6: Handle Tool Call Updates
Ensure that tool calls can update their status as they execute:
```typescript
// In sessions store
function updateMessagePart(instanceId: string, sessionId: string, messageId: string, part: MessagePart) {
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const session = instanceSessions.get(sessionId)
if (session) {
const messages = session.messages.map((msg) => {
if (msg.id === messageId) {
// Find existing part by ID and update, or append
const partIndex = msg.parts.findIndex((p) => p.type === "tool_call" && p.id === part.id)
if (partIndex !== -1) {
const updatedParts = [...msg.parts]
updatedParts[partIndex] = part
return { ...msg, parts: updatedParts }
} else {
return { ...msg, parts: [...msg.parts, part] }
}
}
return msg
})
instanceSessions.set(sessionId, { ...session, messages })
}
next.set(instanceId, instanceSessions)
return next
})
}
```
## Testing Checklist
### Visual Rendering
- [ ] Tool calls render in collapsed state by default
- [ ] Tool icon displays correctly for each tool type
- [ ] Tool summary shows meaningful description
- [ ] Status icon displays correctly (pending, running, success, error)
- [ ] Styling is consistent with design
### Expand/Collapse
- [ ] Click tool call header - expands to show details
- [ ] Click again - collapses back to summary
- [ ] Expand icon rotates correctly
- [ ] Clicking inside expanded body doesn't collapse
- [ ] Multiple tool calls can be expanded independently
### Content Display
- [ ] Input section shows tool input data
- [ ] Output section shows tool output data
- [ ] JSON is formatted with proper indentation
- [ ] Code/text is displayed in monospace font
- [ ] Long output is scrollable horizontally
### Status Indicators
- [ ] Pending status shows waiting icon (⏳)
- [ ] Running status shows spinner and "Executing..."
- [ ] Success status shows checkmark (✓)
- [ ] Error status shows X (✗) and error message
- [ ] Border color changes based on status
### Real-time Updates
- [ ] Tool calls appear as SSE events arrive
- [ ] Status updates from pending → running → success
- [ ] Output appears when tool completes
- [ ] Error state shows if tool fails
- [ ] UI updates smoothly without flashing
### Different Tool Types
- [ ] Bash commands display correctly
- [ ] File edits show file path and changes
- [ ] File reads show file path
- [ ] Glob/grep show patterns
- [ ] Unknown tools have fallback icon
### Error Handling
- [ ] Tool errors display error message
- [ ] Error section has red styling
- [ ] Error state is clearly visible
- [ ] Can expand to see full error details
## Acceptance Criteria
- [ ] Tool calls render inline in assistant messages
- [ ] Default collapsed state shows summary
- [ ] Click to expand shows full input/output
- [ ] Status indicators work correctly
- [ ] Real-time updates via SSE work
- [ ] Multiple tool calls in one message work
- [ ] Error states are clear and helpful
- [ ] Styling matches design specifications
- [ ] No performance issues with many tool calls
- [ ] No console errors during normal operation
## Performance Considerations
**Per MVP principles - keep it simple:**
- Render all tool calls - no virtualization
- No lazy loading of tool content
- Simple JSON.stringify for formatting
- Direct DOM updates via SolidJS reactivity
- Add optimizations only if problems arise
## Future Enhancements (Post-MVP)
- Syntax highlighting for code in input/output (using Shiki)
- Diff view for file edits
- Copy button for tool output
- Link to file in file operations
- Collapsible sections within tool calls
- Tool execution time display
- Retry failed tools
- Export tool output
## References
- [User Interface - Tool Call Rendering](../docs/user-interface.md#3-messages-area)
- [Technical Implementation - Tool Call Rendering](../docs/technical-implementation.md#message-rendering)
- [Build Roadmap - Phase 2](../docs/build-roadmap.md#phase-2-core-chat-interface-week-2)
## Estimated Time
3-4 hours
## Notes
- Focus on clear visual hierarchy - collapsed view should be scannable
- Status indicators help users understand what's happening
- Errors should be prominent but not alarming
- Tool calls are a key differentiator - make them shine
- Test with real OpenCode responses to ensure data format matches
- Consider adding debug logging to verify SSE data structure