Files
NomadArch/tasks/done/010-tool-call-rendering.md
Gemini AI 157449a9ad restore: recover deleted documentation, CI/CD, and infrastructure files
Restored from origin/main (b4663fb):
- .github/ workflows and issue templates
- .gitignore (proper exclusions)
- .opencode/agent/web_developer.md
- AGENTS.md, BUILD.md, PROGRESS.md
- dev-docs/ (9 architecture/implementation docs)
- docs/screenshots/ (4 UI screenshots)
- images/ (CodeNomad icons)
- package-lock.json (dependency lockfile)
- tasks/ (25+ project task files)

Also restored original source files that were modified:
- packages/ui/src/App.tsx
- packages/ui/src/lib/logger.ts
- packages/ui/src/stores/instances.ts
- packages/server/src/server/routes/workspaces.ts
- packages/server/src/workspaces/manager.ts
- packages/server/src/workspaces/runtime.ts
- packages/server/package.json

Kept new additions:
- Install-*.bat/.sh (enhanced installers)
- Launch-*.bat/.sh (new launchers)
- README.md (SEO optimized with GLM 4.7)
2025-12-23 13:03:48 +04:00

15 KiB

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:

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:

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:

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:

/* 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:

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:

// 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

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