fix(tui): show tool activity in fullscreen mode via Ink-compatible callback
Replace process.stdout.write-based onToolUse callback (which corrupts Ink rendering) with a React state-driven approach that shows tool names, args, and completion status in the streaming content area.
This commit is contained in:
@@ -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 { Box, useApp, useInput } from 'ink';
|
||||||
import { StatusBar } from './StatusBar.js';
|
import { StatusBar } from './StatusBar.js';
|
||||||
import { MessageList } from './MessageList.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 { ModelRouter } from '../../../models/router.js';
|
||||||
import type { ManagedSession } from '../../../session/index.js';
|
import type { ManagedSession } from '../../../session/index.js';
|
||||||
import type { NativeAgent } from '../../../backends/native/agent.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<string, unknown>);
|
||||||
|
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 {
|
export interface AppProps {
|
||||||
session: ManagedSession;
|
session: ManagedSession;
|
||||||
@@ -37,6 +65,35 @@ export function App({
|
|||||||
const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ inputTokens: 0, outputTokens: 0 });
|
const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ inputTokens: 0, outputTokens: 0 });
|
||||||
const [currentModel, setCurrentModel] = useState(model);
|
const [currentModel, setCurrentModel] = useState(model);
|
||||||
const abortRef = useRef(false);
|
const abortRef = useRef(false);
|
||||||
|
const toolLinesRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
// 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) => {
|
useInput((inputChar, key) => {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
@@ -172,6 +229,7 @@ export function App({
|
|||||||
// Process response
|
// Process response
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingContent('');
|
setStreamingContent('');
|
||||||
|
toolLinesRef.current = [];
|
||||||
abortRef.current = false;
|
abortRef.current = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user