diff --git a/TOOL_CALL_IMPLEMENTATION.md b/TOOL_CALL_IMPLEMENTATION.md
new file mode 100644
index 00000000..8d368cfd
--- /dev/null
+++ b/TOOL_CALL_IMPLEMENTATION.md
@@ -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
+
+```
+
+## 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
diff --git a/src/App.tsx b/src/App.tsx
index b800b057..e7a7281c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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
instanceId: string
@@ -52,6 +54,10 @@ const SessionMessages: Component<{
}
})
+ async function handleSendMessage(prompt: string) {
+ await sendMessage(props.instanceId, props.sessionId, prompt)
+ }
+
return (
}
>
- {(s) => }
+ {(s) => (
+
+ )}
)
}
@@ -213,7 +229,7 @@ const App: Component = () => {
}
>
-
- {(part) => }
+
+ {(part) => }
+
+
+
+ ● Sending...
+
+
+
⚠ Message failed to send
diff --git a/src/components/message-part.tsx b/src/components/message-part.tsx
index 07e77b71..303b949d 100644
--- a/src/components/message-part.tsx
+++ b/src/components/message-part.tsx
@@ -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 (
@@ -17,7 +25,7 @@ export default function MessagePart(props: MessagePartProps) {
-
+
@@ -26,10 +34,15 @@ export default function MessagePart(props: MessagePartProps) {
-
- Reasoning
- {props.part.text || ""}
-
+
+
+
+ {props.part.text || ""}
+
+
diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx
index e5710751..3e5dc638 100644
--- a/src/components/message-stream.tsx
+++ b/src/components/message-stream.tsx
@@ -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
@@ -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 (
+
+
+
+
+ Connected
+
+
+
+
+
+ Connecting...
+
+
+
+
+
+ Disconnected
+
+
+
@@ -107,24 +131,27 @@ export default function MessageStream(props: MessageStreamProps) {
-
- {(item) => (
-
-
-
-
- }
- >
-
-
- )}
+ }
+ >
+
+
+ )
+ }}
diff --git a/src/components/prompt-input.tsx b/src/components/prompt-input.tsx
new file mode 100644
index 00000000..e9905102
--- /dev/null
+++ b/src/components/prompt-input.tsx
@@ -0,0 +1,79 @@
+import { createSignal, Show } from "solid-js"
+
+interface PromptInputProps {
+ instanceId: string
+ sessionId: string
+ onSend: (prompt: string) => Promise
+ 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 (
+
+ )
+}
diff --git a/src/components/tool-call.tsx b/src/components/tool-call.tsx
index 5a895679..7237918e 100644
--- a/src/components/tool-call.tsx
+++ b/src/components/tool-call.tsx
@@ -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 (
+
+ {truncated}
+
+ )
+ }
+
+ return null
+ }
+
+ const renderEditTool = () => {
+ const state = props.toolCall?.state || {}
+ const metadata = state.metadata || {}
+ const diff = metadata.diff
+
+ if (diff) {
+ return (
+
+ )
+ }
+
+ 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 (
+
+ {truncated}
+
+ )
+ }
+
+ 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 (
+
+ )
+ }
+
+ 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 (
+
+ {truncated}
+
+ )
+ }
+
+ 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 (
+
+ )
+ }
+
+ 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 (
+
+ )
+ }
+
+ 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 (
+
+ {truncated}
+
+ )
+ }
+
+ return null
+ }
+
+ const renderError = () => {
+ const state = props.toolCall?.state || {}
+ if (state.status === "error" && state.error) {
+ return (
+
+ Error: {state.error}
+
+ )
+ }
+ return null
+ }
+
+ const toolName = () => props.toolCall?.tool || ""
+ const status = () => props.toolCall?.state?.status || ""
+
return (
-