diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index 199162f..8d0e8c7 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; import { Box, useApp, useInput } from 'ink'; import { StatusBar } from './StatusBar.js'; import { MessageList } from './MessageList.js'; @@ -8,6 +8,34 @@ import type { Message, ModelClient, TokenUsage } from '../../../models/types.js' import type { ModelRouter } from '../../../models/router.js'; import type { ManagedSession } from '../../../session/index.js'; import type { NativeAgent } from '../../../backends/native/agent.js'; +import type { ToolUseEvent } from '../../../backends/native/agent.js'; + +/** Format a tool name like "gmail.list" -> "Gmail: List" */ +function formatToolName(name: string): string { + const parts = name.split('.'); + return parts.map((p, i) => { + const capitalized = p.charAt(0).toUpperCase() + p.slice(1); + return i === 0 && parts.length > 1 ? capitalized + ':' : capitalized; + }).join(' '); +} + +/** Format tool args as a compact, readable summary. */ +function formatToolArgs(args: unknown): string { + if (!args || typeof args !== 'object') return ''; + const entries = Object.entries(args as Record); + if (entries.length === 0) return ''; + const parts = entries.map(([key, value]) => { + if (typeof value === 'string') { + const display = value.length > 50 ? value.slice(0, 47) + '...' : value; + return `${key}: "${display}"`; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return `${key}: ${value}`; + } + return `${key}: ${JSON.stringify(value)}`; + }); + return parts.join(', '); +} export interface AppProps { session: ManagedSession; @@ -37,6 +65,35 @@ export function App({ const [tokenUsage, setTokenUsage] = useState({ inputTokens: 0, outputTokens: 0 }); const [currentModel, setCurrentModel] = useState(model); const abortRef = useRef(false); + const toolLinesRef = useRef([]); + + // Set up an Ink-compatible onToolUse callback for the agent. + // This replaces the process.stdout.write callback (which corrupts Ink rendering) + // with one that updates React state to show tool activity in the streaming area. + useEffect(() => { + if (!agent) return; + + const handleToolEvent = (event: ToolUseEvent) => { + if (event.type === 'start') { + const label = formatToolName(event.tool); + const argsStr = event.args ? ` (${formatToolArgs(event.args)})` : ''; + toolLinesRef.current = [...toolLinesRef.current, `> ${label}${argsStr}`]; + setStreamingContent(toolLinesRef.current.join('\n')); + } else if (event.type === 'end' && event.result) { + const icon = event.result.success ? 'done' : 'error'; + const detail = event.result.success + ? `(${event.result.output.split('\n').length} lines)` + : (event.result.error ?? 'unknown error'); + toolLinesRef.current = [...toolLinesRef.current, ` ${icon} ${detail}`]; + setStreamingContent(toolLinesRef.current.join('\n')); + } + }; + + agent.setOnToolUse(handleToolEvent); + return () => { + agent.setOnToolUse(undefined); + }; + }, [agent]); useInput((inputChar, key) => { if (key.escape) { @@ -172,6 +229,7 @@ export function App({ // Process response setIsStreaming(true); setStreamingContent(''); + toolLinesRef.current = []; abortRef.current = false; try {