diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1121211d9c..f5b3ad73ac 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4,6 +4,7 @@ import os import subprocess import sys import threading +import time import uuid from datetime import datetime from pathlib import Path @@ -279,6 +280,10 @@ def _session_tool_progress_mode(sid: str) -> str: return str(_sessions.get(sid, {}).get("tool_progress_mode", "all") or "all") +def _reasoning_visible(sid: str) -> bool: + return _session_show_reasoning(sid) or _session_tool_progress_mode(sid) == "verbose" + + def _tool_progress_enabled(sid: str) -> bool: return _session_tool_progress_mode(sid) != "off" @@ -436,6 +441,49 @@ def _tool_ctx(name: str, args: dict) -> str: return "" +def _fmt_tool_duration(seconds: float | None) -> str: + if seconds is None: + return "" + if seconds < 10: + return f"{seconds:.1f}s" + if seconds < 60: + return f"{round(seconds)}s" + mins, secs = divmod(int(round(seconds)), 60) + return f"{mins}m {secs}s" if secs else f"{mins}m" + + +def _count_list(obj: object, *path: str) -> int | None: + cur = obj + for key in path: + if not isinstance(cur, dict): + return None + cur = cur.get(key) + return len(cur) if isinstance(cur, list) else None + + +def _tool_summary(name: str, result: str, duration_s: float | None) -> str | None: + try: + data = json.loads(result) + except Exception: + data = None + + dur = _fmt_tool_duration(duration_s) + suffix = f" in {dur}" if dur else "" + text = None + + if name == "web_search" and isinstance(data, dict): + n = _count_list(data, "data", "web") + if n is not None: + text = f"Did {n} {'search' if n == 1 else 'searches'}" + + elif name == "web_extract" and isinstance(data, dict): + n = _count_list(data, "results") or _count_list(data, "data", "results") + if n is not None: + text = f"Extracted {n} {'page' if n == 1 else 'pages'}" + + return f"{text or 'Completed'}{suffix}" if (text or dur) else None + + def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): session = _sessions.get(sid) if session is not None: @@ -447,6 +495,7 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): session.setdefault("edit_snapshots", {})[tool_call_id] = snapshot except Exception: pass + session.setdefault("tool_started_at", {})[tool_call_id] = time.time() if _tool_progress_enabled(sid): _emit("tool.start", sid, {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}) @@ -455,8 +504,16 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result payload = {"tool_id": tool_call_id, "name": name} session = _sessions.get(sid) snapshot = None + started_at = None if session is not None: snapshot = session.setdefault("edit_snapshots", {}).pop(tool_call_id, None) + started_at = session.setdefault("tool_started_at", {}).pop(tool_call_id, None) + duration_s = time.time() - started_at if started_at else None + if duration_s is not None: + payload["duration_s"] = duration_s + summary = _tool_summary(name, result, duration_s) + if summary: + payload["summary"] = summary try: from agent.display import render_edit_diff_with_delta @@ -469,15 +526,29 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result _emit("tool.complete", sid, payload) +def _on_tool_progress( + sid: str, + event_type: str, + name: str | None = None, + preview: str | None = None, + _args: dict | None = None, + **_kwargs, +): + if not _tool_progress_enabled(sid) or event_type != "tool.started" or not name: + return + _emit("tool.progress", sid, {"name": name, "preview": preview or ""}) + + def _agent_cbs(sid: str) -> dict: return dict( tool_start_callback=lambda tc_id, name, args: _on_tool_start(sid, tc_id, name, args), tool_complete_callback=lambda tc_id, name, args, result: _on_tool_complete(sid, tc_id, name, args, result), - tool_progress_callback=lambda name, preview, args: _tool_progress_enabled(sid) - and _emit("tool.progress", sid, {"name": name, "preview": preview}), + tool_progress_callback=lambda event_type, name=None, preview=None, args=None, **kwargs: _on_tool_progress( + sid, event_type, name, preview, args, **kwargs + ), 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: _session_show_reasoning(sid) and _emit("reasoning.delta", sid, {"text": text}), + reasoning_callback=lambda text: _reasoning_visible(sid) and _emit("reasoning.delta", sid, {"text": text}), status_callback=lambda kind, text=None: _status_update(sid, str(kind), None if text is None else str(text)), clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}), ) @@ -559,6 +630,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): "show_reasoning": _load_show_reasoning(), "tool_progress_mode": _load_tool_progress_mode(), "edit_snapshots": {}, + "tool_started_at": {}, } try: _sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index bdb1c82798..0cf63fd53c 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -13,8 +13,8 @@ import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' import { type PasteEvent, TextInput } from './components/textInput.js' -import { Thinking, ToolTrail } from './components/thinking.js' -import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' +import { ToolTrail } from './components/thinking.js' +import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' @@ -28,7 +28,8 @@ import { isToolTrailResultLine, isTransientTrailLine, pick, - sameToolTrailGroup + sameToolTrailGroup, + toolTrailLabel } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { @@ -324,8 +325,6 @@ export function App({ gw }: { gw: GatewayClient }) { const [sid, setSid] = useState(null) const [theme, setTheme] = useState(DEFAULT_THEME) const [info, setInfo] = useState(null) - const [thinking, setThinking] = useState(false) - const [turnKey, setTurnKey] = useState(0) const [activity, setActivity] = useState([]) const [tools, setTools] = useState([]) const [busy, setBusy] = useState(false) @@ -489,7 +488,7 @@ export function App({ gw }: { gw: GatewayClient }) { return } - const label = TOOL_VERBS.clarify ?? 'clarify' + const label = toolTrailLabel('clarify') setTrail(turnToolsRef.current.filter(l => !sameToolTrailGroup(label, l))) setTurnTrail(turnToolsRef.current) @@ -554,7 +553,6 @@ export function App({ gw }: { gw: GatewayClient }) { }, [pushActivity, rpc, sid]) const idle = () => { - setThinking(false) setTools([]) setTurnTrail([]) setBusy(false) @@ -1297,8 +1295,6 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'message.start': - setThinking(true) - setTurnKey(k => k + 1) setBusy(true) setReasoning('') setActivity([]) @@ -1384,9 +1380,14 @@ export function App({ gw }: { gw: GatewayClient }) { setTools(prev => { const done = prev.find(t => t.id === p.tool_id) const name = done?.name ?? p.name - const ctx = (p.error as string) || done?.context || '' - const label = TOOL_VERBS[name] ?? name - const line = buildToolTrailLine(name, ctx, !!p.error) + const label = toolTrailLabel(name) + + const line = buildToolTrailLine( + name, + done?.context || '', + !!p.error, + (p.error as string) || (p.summary as string) || '' + ) toolCompleteRibbonRef.current = { label, line } const remaining = prev.filter(t => t.id !== p.tool_id) @@ -2400,6 +2401,10 @@ export function App({ gw }: { gw: GatewayClient }) { const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : '' const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` + const showProgressArea = Boolean( + (busy && !streaming) || (busy ? activity.length : 0) || tools.length || turnTrail.length + ) + const showStreamingArea = Boolean(streaming) // ── Render ─────────────────────────────────────────────────────── @@ -2421,18 +2426,23 @@ export function App({ gw }: { gw: GatewayClient }) { ))} - + {showProgressArea && ( + + + + )} - {busy && !tools.length && !streaming && } - - {streaming && ( - + {showStreamingArea && ( + + + )} {pasteReview && ( diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 9ec53ddc0d..a5f876da1f 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,16 +1,17 @@ -import { Text } from '@hermes/ink' -import { memo, useEffect, useState } from 'react' +import { Box, Text } from '@hermes/ink' +import { memo, type ReactNode, useEffect, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' -import { FACES, TOOL_VERBS, VERBS } from '../constants.js' +import { FACES, VERBS } from '../constants.js' import { - isToolTrailResultLine, - lastCotTrailIndex, + formatToolCall, + parseToolTrailResultLine, pick, scaleHex, THINKING_COT_FADE, THINKING_COT_MAX, - thinkingCotTail + thinkingCotTail, + toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool, ActivityItem } from '../types.js' @@ -18,19 +19,14 @@ import type { ActiveTool, ActivityItem } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] -const tone = (item: ActivityItem, t: Theme) => - item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim - -const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·') - -const TreeFork = ({ last }: { last: boolean }) => {last ? '└─ ' : '├─ '} - const fmtElapsed = (ms: number) => { const sec = Math.max(0, ms) / 1000 return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` } +// ── Spinner ────────────────────────────────────────────────────────── + export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { const [spin] = useState(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] @@ -49,100 +45,20 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?: return {spin.frames[frame]} } -export const ToolTrail = memo(function ToolTrail({ - t, - tools = [], - trail = [], - activity = [], - animateCot = false -}: { - t: Theme - tools?: ActiveTool[] - trail?: string[] - activity?: ActivityItem[] - animateCot?: boolean -}) { - const [now, setNow] = useState(() => Date.now()) +// ── Detail row ─────────────────────────────────────────────────────── - useEffect(() => { - if (!tools.length) { - return - } - - const id = setInterval(() => setNow(Date.now()), 200) - - return () => clearInterval(id) - }, [tools.length]) - - if (!trail.length && !tools.length && !activity.length) { - return null - } - - const act = activity.slice(-4) - const rowCount = trail.length + tools.length + act.length - const activeCotIdx = animateCot && !tools.length ? lastCotTrailIndex(trail) : -1 +type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string } +function Detail({ color, content, dimColor, t }: DetailRow & { t: Theme }) { return ( - <> - {trail.map((line, i) => { - const lastInBlock = i === rowCount - 1 - - if (isToolTrailResultLine(line)) { - return ( - - - {line} - - ) - } - - if (i === activeCotIdx) { - return ( - - - {line} - - ) - } - - return ( - - - {line} - - ) - })} - - {tools.map((tool, j) => { - const lastInBlock = trail.length + j === rowCount - 1 - - return ( - - - {TOOL_VERBS[tool.name] ?? tool.name} - {tool.context ? `: ${tool.context}` : ''} - {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} - - ) - })} - - {act.map((item, k) => { - const lastInBlock = trail.length + tools.length + k === rowCount - 1 - - return ( - - - {activityGlyph(item)} {item.text} - - ) - })} - + + + {content} + ) -}) +} + +// ── Thinking (pre-tool fallback) ───────────────────────────────────── export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: string; t: Theme }) { const [tick, setTick] = useState(0) @@ -157,7 +73,7 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st const clipped = reasoning.length > THINKING_COT_MAX return ( - <> + {FACES[tick % FACES.length] ?? '(•_•)'}{' '} {VERBS[tick % VERBS.length] ?? 'thinking'}… @@ -177,6 +93,166 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st ) : null} - + + ) +}) + +// ── ToolTrail (canonical progress block) ───────────────────────────── + +type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string } + +export const ToolTrail = memo(function ToolTrail({ + busy = false, + reasoning = '', + t, + tools = [], + trail = [], + activity = [] +}: { + busy?: boolean + reasoning?: string + t: Theme + tools?: ActiveTool[] + trail?: string[] + activity?: ActivityItem[] +}) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (!tools.length) { + return + } + const id = setInterval(() => setNow(Date.now()), 200) + + return () => clearInterval(id) + }, [tools.length]) + + if (!busy && !trail.length && !tools.length && !activity.length) { + return null + } + + const groups: Group[] = [] + const meta: DetailRow[] = [] + + const detail = (row: DetailRow) => { + const g = groups.at(-1) + g ? g.details.push(row) : meta.push(row) + } + + // ── trail → groups + details ──────────────────────────────────── + + for (const [i, line] of trail.entries()) { + const parsed = parseToolTrailResultLine(line) + + if (parsed) { + groups.push({ + color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk, + content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`, + details: [], + key: `tr-${i}` + }) + + if (parsed.detail) { + detail({ + color: parsed.mark === '✗' ? t.color.error : t.color.dim, + content: parsed.detail, + dimColor: parsed.mark !== '✗', + key: `tr-${i}-d` + }) + } + + continue + } + + if (line.startsWith('drafting ')) { + groups.push({ + color: t.color.cornsilk, + content: toolTrailLabel(line.slice(9).replace(/…$/, '').trim()), + details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }], + key: `tr-${i}` + }) + + continue + } + + if (line === 'analyzing tool output…') { + detail({ + color: t.color.dim, + content: groups.length ? ( + <> + {line} + + ) : ( + line + ), + dimColor: true, + key: `tr-${i}` + }) + + continue + } + + meta.push({ color: t.color.dim, content: line, dimColor: true, key: `tr-${i}` }) + } + + // ── live tools → groups ───────────────────────────────────────── + + for (const tool of tools) { + groups.push({ + color: t.color.cornsilk, + content: ( + <> + {formatToolCall(tool.name, tool.context || '')} + {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} + + ), + details: [], + key: tool.id + }) + } + + // ── reasoning tail → child of last group ──────────────────────── + + const reasoningTail = thinkingCotTail(reasoning) + + if (groups.length && reasoningTail) { + detail({ color: t.color.dim, content: reasoningTail, dimColor: true, key: 'cot' }) + } + + // ── activity → meta ───────────────────────────────────────────── + + for (const item of activity.slice(-4)) { + const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·' + const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim + + meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` }) + } + + // ── render ────────────────────────────────────────────────────── + + return ( + + {busy && !groups.length && } + + {groups.map(g => ( + + + + {g.content} + + + {g.details.map(d => ( + + ))} + + ))} + + {meta.map((row, i) => ( + + {i === meta.length - 1 ? '└ ' : '├ '} + {row.content} + + ))} + ) }) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 82a87c91f2..30dcd67e31 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,4 +1,4 @@ -import { INTERPOLATION_RE, LONG_MSG, TOOL_VERBS } from '../constants.js' +import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1b\[[0-9;]*m/g @@ -42,23 +42,60 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } -/** Build a single tool trail line — used by both live tool.complete and resume replay. */ -export const buildToolTrailLine = (name: string, context: string, error?: boolean): string => { - const label = TOOL_VERBS[name] ?? name - const mark = error ? '✗' : '✓' +export const toolTrailLabel = (name: string) => + name + .split('_') + .filter(Boolean) + .map(p => p[0]!.toUpperCase() + p.slice(1)) + .join(' ') || name - return `${label}${context ? ': ' + compactPreview(context, 72) : ''} ${mark}` +export const formatToolCall = (name: string, context = '') => { + const preview = compactPreview(context, 64) + + return preview ? `${toolTrailLabel(name)}("${preview}")` : toolTrailLabel(name) +} + +export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string): string => { + const detail = compactPreview(note ?? '', 72) + + return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}` } /** Tool completed / failed row in the inline trail (not CoT prose). */ export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') +export const parseToolTrailResultLine = (line: string) => { + if (!isToolTrailResultLine(line)) { + return null + } + + const mark = line.endsWith(' ✗') ? '✗' : '✓' + const body = line.slice(0, -2) + const [call, detail] = body.split(' :: ', 2) + + if (detail != null) { + return { call, detail, mark } + } + + const legacy = body.indexOf(': ') + + if (legacy > 0) { + return { call: body.slice(0, legacy), detail: body.slice(legacy + 2), mark } + } + + return { call: body, detail: '', mark } +} + /** Ephemeral status lines that should vanish once the next phase starts. */ export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…' /** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */ export const sameToolTrailGroup = (label: string, entry: string) => - entry === `${label} ✓` || entry === `${label} ✗` || entry.startsWith(`${label}:`) + entry === `${label} ✓` || + entry === `${label} ✗` || + entry.startsWith(`${label}(`) || + entry.startsWith(`${label} ::`) || + entry.startsWith(`${label}:`) /** Index of the last non-result trail line, or -1. */ export const lastCotTrailIndex = (trail: readonly string[]) => {