From df9ab4bd42e87ddf42cfb59bc384d4a2fe152468 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Tue, 19 May 2026 19:02:46 -0600 Subject: [PATCH] fix(tui): surface verbose tool details --- tui_gateway/server.py | 57 ++++++++++++++++++--- ui-tui/src/__tests__/text.test.ts | 24 ++++++++- ui-tui/src/app/createGatewayEventHandler.ts | 14 +++-- ui-tui/src/app/turnController.ts | 45 ++++++++++------ ui-tui/src/components/thinking.tsx | 11 +++- ui-tui/src/gatewayTypes.ts | 5 +- ui-tui/src/lib/text.ts | 26 ++++++++-- ui-tui/src/types.ts | 1 + 8 files changed, 149 insertions(+), 34 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7b9f286380cb..95c005f8846e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1030,6 +1030,10 @@ def _session_tool_progress_mode(sid: str) -> str: return str(_sessions.get(sid, {}).get("tool_progress_mode", "all") or "all") +def _session_verbose(sid: str) -> bool: + return _session_tool_progress_mode(sid) == "verbose" + + def _tool_progress_enabled(sid: str) -> bool: return _session_tool_progress_mode(sid) != "off" @@ -1461,6 +1465,33 @@ def _tool_ctx(name: str, args: dict) -> str: return "" +def _redact_tui_verbose_text(text: str) -> str: + try: + from agent.redact import redact_sensitive_text + + return redact_sensitive_text(str(text), force=True) + except Exception: + return str(text) + + +def _tool_args_text(args: dict) -> str: + try: + raw = json.dumps(args or {}, indent=2, ensure_ascii=False, default=str) + except Exception: + raw = str(args or {}) + return _redact_tui_verbose_text(raw) + + +def _tool_result_text(result: object) -> str: + try: + from agent.tool_dispatch_helpers import _multimodal_text_summary + + raw = _multimodal_text_summary(result) + except Exception: + raw = str(result) + return _redact_tui_verbose_text(raw) + + def _fmt_tool_duration(seconds: float | None) -> str: if seconds is None: return "" @@ -1522,13 +1553,16 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): pass session.setdefault("tool_started_at", {})[tool_call_id] = time.time() if _tool_progress_enabled(sid): + payload = { + "tool_id": tool_call_id, + "name": name, + "context": _tool_ctx(name, args), + } + if _session_verbose(sid): + payload["args_text"] = _tool_args_text(args) # tool.complete is the source of truth for todos (full list from the # tool result). args.todos here may be a partial merge update. - _emit( - "tool.start", - sid, - {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}, - ) + _emit("tool.start", sid, payload) def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str): @@ -1545,6 +1579,8 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result summary = _tool_summary(name, result, duration_s) if summary: payload["summary"] = summary + if _session_verbose(sid): + payload["result_text"] = _tool_result_text(result) if name == "todo": try: data = json.loads(result) @@ -1584,7 +1620,10 @@ def _on_tool_progress( _emit("tool.progress", sid, {"name": name, "preview": preview or ""}) return if event_type == "reasoning.available" and preview: - _emit("reasoning.available", sid, {"text": str(preview)}) + payload = {"text": str(preview)} + if _session_verbose(sid): + payload["verbose"] = True + _emit("reasoning.available", sid, payload) return if event_type.startswith("subagent."): payload = { @@ -1660,7 +1699,11 @@ def _agent_cbs(sid: str) -> dict: "tool_gen_callback": lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), "thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}), - "reasoning_callback": lambda text: _emit("reasoning.delta", sid, {"text": text}), + "reasoning_callback": lambda text: _emit( + "reasoning.delta", + sid, + {"text": text, **({"verbose": True} if _session_verbose(sid) else {})}, + ), "status_callback": lambda kind, text=None: _status_update( sid, str(kind), None if text is None else str(text) ), diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 306324d353d8..e5a0a732e023 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { boundedLiveRenderText, buildToolTrailLine, + buildVerboseToolTrailLine, edgePreview, estimateRows, estimateTokensRough, @@ -12,8 +13,8 @@ import { lastCotTrailIndex, parseToolTrailResultLine, pasteTokenLabel, - sanitizeAnsiForRender, sameToolTrailGroup, + sanitizeAnsiForRender, splitToolDuration, stripAnsi, thinkingPreview @@ -37,6 +38,27 @@ describe('buildToolTrailLine', () => { }) }) +describe('buildVerboseToolTrailLine', () => { + it('preserves multiline args and result details', () => { + const line = buildVerboseToolTrailLine( + 'terminal', + 'npm test', + false, + 1.25, + '{\n "cmd": "npm test"\n}', + 'first line\nsecond :: line' + ) + + expect(line).toContain('Args:\n{') + expect(line).toContain('Result:\nfirst line\nsecond :: line') + expect(parseToolTrailResultLine(line)).toEqual({ + call: 'Terminal("npm test") (1.3s)', + detail: 'Args:\n{\n "cmd": "npm test"\n}\nResult:\nfirst line\nsecond :: line', + mark: '✓' + }) + }) +}) + describe('lastCotTrailIndex', () => { it('finds last non-result line', () => { expect(lastCotTrailIndex(['a ✓', 'thinking…'])).toBe(1) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 267334bfd72c..1c2e384998a2 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -491,13 +491,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'reasoning.delta': if (ev.payload?.text) { - turnController.recordReasoningDelta(ev.payload.text) + turnController.recordReasoningDelta(ev.payload.text, Boolean(ev.payload.verbose)) } return case 'reasoning.available': - turnController.recordReasoningAvailable(String(ev.payload?.text ?? '')) + turnController.recordReasoningAvailable(String(ev.payload?.text ?? ''), Boolean(ev.payload?.verbose)) return @@ -517,7 +517,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'tool.start': turnController.recordTodos(ev.payload.todos) - turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '') + turnController.recordToolStart( + ev.payload.tool_id, + ev.payload.name ?? 'tool', + ev.payload.context ?? '', + ev.payload.args_text ? stripAnsi(String(ev.payload.args_text)) : undefined + ) return case 'tool.complete': { @@ -539,7 +544,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: ev.payload.error, ev.payload.summary, ev.payload.duration_s, - ev.payload.todos + ev.payload.todos, + ev.payload.result_text ? stripAnsi(String(ev.payload.result_text)) : undefined ) } diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index b9e0aa04c196..0794eb1f06b8 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -11,6 +11,7 @@ import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js' import { boundedLiveRenderText, buildToolTrailLine, + buildVerboseToolTrailLine, estimateTokensRough, isTransientTrailLine, sameToolTrailGroup, @@ -542,8 +543,8 @@ class TurnController { } } - recordReasoningAvailable(text: string) { - if (this.interrupted || !getUiState().showReasoning) { + recordReasoningAvailable(text: string, force = false) { + if (this.interrupted || (!force && !getUiState().showReasoning)) { return } @@ -560,8 +561,8 @@ class TurnController { this.pulseReasoningStreaming() } - recordReasoningDelta(text: string) { - if (this.interrupted || !getUiState().showReasoning) { + recordReasoningDelta(text: string, force = false) { + if (this.interrupted || (!force && !getUiState().showReasoning)) { return } @@ -587,14 +588,15 @@ class TurnController { error?: string, summary?: string, duration?: number, - todos?: unknown + todos?: unknown, + resultText?: string ) { if (this.interrupted) { return } this.recordTodos(todos) - const line = this.completeTool(toolId, fallbackName, error, summary, duration) + const line = this.completeTool(toolId, fallbackName, error, summary, duration, resultText) this.pendingSegmentTools = [...this.pendingSegmentTools, line] this.flushPendingToolsIntoLastSegment() @@ -617,19 +619,30 @@ class TurnController { this.publishToolState() } - private completeTool(toolId: string, fallbackName?: string, error?: string, summary?: string, duration?: number) { + private completeTool( + toolId: string, + fallbackName?: string, + error?: string, + summary?: string, + duration?: number, + resultText?: string + ) { const done = this.activeTools.find(tool => tool.id === toolId) const name = done?.name ?? fallbackName ?? 'tool' const label = toolTrailLabel(name) const fallbackDuration = done?.startedAt ? (Date.now() - done.startedAt) / 1000 : undefined - const line = buildToolTrailLine( - name, - done?.context || '', - Boolean(error), - error || summary || '', - duration ?? fallbackDuration - ) + const line = + done?.verboseArgs || resultText + ? buildVerboseToolTrailLine( + name, + done?.context || '', + Boolean(error), + duration ?? fallbackDuration, + done?.verboseArgs, + error || resultText || summary || '' + ) + : buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '', duration ?? fallbackDuration) this.activeTools = this.activeTools.filter(tool => tool.id !== toolId) @@ -675,7 +688,7 @@ class TurnController { }, STREAM_BATCH_MS) } - recordToolStart(toolId: string, name: string, context: string) { + recordToolStart(toolId: string, name: string, context: string, verboseArgs?: string) { if (this.interrupted) { return } @@ -688,7 +701,7 @@ class TurnController { const sample = `${name} ${context}`.trim() this.toolTokenAcc += sample ? estimateTokensRough(sample) : 0 - this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now() }] + this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now(), verboseArgs }] patchTurnState({ toolTokens: this.toolTokenAcc, tools: this.activeTools }) } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 6908795f6211..0d9ecee87c9f 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -856,7 +856,16 @@ export const ToolTrail = memo(function ToolTrail({ color: t.color.text, key: tool.id, label, - details: [], + details: tool.verboseArgs + ? [ + { + color: t.color.muted, + content: `Args:\n${boundedLiveRenderText(tool.verboseArgs)}`, + dimColor: true, + key: `${tool.id}-args` + } + ] + : [], content: ( <> {label} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ab85c39fbddf..9de1c85112d6 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -477,11 +477,11 @@ export type GatewayEvent = type: 'gateway.start_timeout' } | { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' } - | { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' } + | { payload?: { text?: string; verbose?: boolean }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' } | { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' } | { payload: { name?: string }; session_id?: string; type: 'tool.generating' } | { - payload: { context?: string; name?: string; tool_id: string; todos?: unknown[] } + payload: { args_text?: string; context?: string; name?: string; tool_id: string; todos?: unknown[] } session_id?: string type: 'tool.start' } @@ -491,6 +491,7 @@ export type GatewayEvent = error?: string inline_diff?: string name?: string + result_text?: string summary?: string tool_id: string todos?: unknown[] diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 5b52c2367199..0bebe57bd685 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -212,6 +212,26 @@ export const buildToolTrailLine = ( return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}` } +const verboseToolBlock = (label: string, text?: string) => { + const body = (text ?? '').trim() + + return body ? `${label}:\n${boundedLiveRenderText(body)}` : '' +} + +export const buildVerboseToolTrailLine = ( + name: string, + context: string, + error?: boolean, + duration?: number, + argsText?: string, + resultText?: string +) => { + const detail = [verboseToolBlock('Args', argsText), verboseToolBlock('Result', resultText)].filter(Boolean).join('\n') + const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : '' + + return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}` +} + export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') export const parseToolTrailResultLine = (line: string) => { @@ -221,10 +241,10 @@ export const parseToolTrailResultLine = (line: string) => { const mark = line.endsWith(' ✗') ? '✗' : '✓' const body = line.slice(0, -2) - const [call, detail] = body.split(' :: ', 2) + const sep = body.indexOf(' :: ') - if (detail != null) { - return { call, detail, mark } + if (sep >= 0) { + return { call: body.slice(0, sep), detail: body.slice(sep + 4), mark } } const legacy = body.indexOf(': ') diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index f0651bef9c50..0bfab6c271da 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -2,6 +2,7 @@ export interface ActiveTool { context?: string id: string name: string + verboseArgs?: string startedAt?: number }