feat(tui): hierarchical tool progress with grouped parent/child rows and transient line pruning
This commit is contained in:
@ -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()))
|
||||
|
||||
@ -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<string | null>(null)
|
||||
const [theme, setTheme] = useState<Theme>(DEFAULT_THEME)
|
||||
const [info, setInfo] = useState<SessionInfo | null>(null)
|
||||
const [thinking, setThinking] = useState(false)
|
||||
const [turnKey, setTurnKey] = useState(0)
|
||||
const [activity, setActivity] = useState<ActivityItem[]>([])
|
||||
const [tools, setTools] = useState<ActiveTool[]>([])
|
||||
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 }) {
|
||||
))}
|
||||
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<ToolTrail
|
||||
activity={busy ? activity : []}
|
||||
animateCot={busy && !streaming}
|
||||
t={theme}
|
||||
tools={tools}
|
||||
trail={turnTrail}
|
||||
/>
|
||||
{showProgressArea && (
|
||||
<Box marginBottom={showStreamingArea ? 1 : 0}>
|
||||
<ToolTrail
|
||||
activity={busy ? activity : []}
|
||||
busy={busy && !streaming}
|
||||
reasoning={busy && !streaming ? reasoning : ''}
|
||||
t={theme}
|
||||
tools={tools}
|
||||
trail={turnTrail}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{busy && !tools.length && !streaming && <Thinking key={turnKey} reasoning={reasoning} t={theme} />}
|
||||
|
||||
{streaming && (
|
||||
<MessageLine cols={cols} compact={compact} msg={{ role: 'assistant', text: streaming }} t={theme} />
|
||||
{showStreamingArea && (
|
||||
<Box>
|
||||
<MessageLine cols={cols} compact={compact} msg={{ role: 'assistant', text: streaming }} t={theme} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{pasteReview && (
|
||||
|
||||
@ -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 }) => <Text dimColor>{last ? '└─ ' : '├─ '}</Text>
|
||||
|
||||
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 <Text color={color}>{spin.frames[frame]}</Text>
|
||||
}
|
||||
|
||||
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 (
|
||||
<Text
|
||||
color={line.endsWith(' ✗') ? t.color.error : t.color.dim}
|
||||
dimColor={!line.endsWith(' ✗')}
|
||||
key={`t-${i}`}
|
||||
>
|
||||
<TreeFork last={lastInBlock} />
|
||||
{line}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (i === activeCotIdx) {
|
||||
return (
|
||||
<Text color={t.color.dim} key={`c-${i}`}>
|
||||
<TreeFork last={lastInBlock} />
|
||||
<Spinner color={t.color.amber} variant="think" /> {line}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={t.color.dim} dimColor key={`c-${i}`}>
|
||||
<TreeFork last={lastInBlock} />
|
||||
{line}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{tools.map((tool, j) => {
|
||||
const lastInBlock = trail.length + j === rowCount - 1
|
||||
|
||||
return (
|
||||
<Text color={t.color.dim} key={tool.id}>
|
||||
<TreeFork last={lastInBlock} />
|
||||
<Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name}
|
||||
{tool.context ? `: ${tool.context}` : ''}
|
||||
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{act.map((item, k) => {
|
||||
const lastInBlock = trail.length + tools.length + k === rowCount - 1
|
||||
|
||||
return (
|
||||
<Text color={tone(item, t)} dimColor={item.tone === 'info'} key={`a-${item.id}`}>
|
||||
<TreeFork last={lastInBlock} />
|
||||
{activityGlyph(item)} {item.text}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
<Text color={color} dimColor={dimColor}>
|
||||
<Text dimColor> └ </Text>
|
||||
{content}
|
||||
</Text>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<>
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.dim}>
|
||||
<Spinner color={t.color.dim} /> {FACES[tick % FACES.length] ?? '(•_•)'}{' '}
|
||||
{VERBS[tick % VERBS.length] ?? 'thinking'}…
|
||||
@ -177,6 +93,166 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st
|
||||
</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
// ── 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 ? (
|
||||
<>
|
||||
<Spinner color={t.color.amber} variant="think" /> {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: (
|
||||
<>
|
||||
<Spinner color={t.color.amber} variant="tool" /> {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 (
|
||||
<Box flexDirection="column">
|
||||
{busy && !groups.length && <Thinking reasoning={reasoning} t={t} />}
|
||||
|
||||
{groups.map(g => (
|
||||
<Box flexDirection="column" key={g.key}>
|
||||
<Text color={g.color}>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
{g.content}
|
||||
</Text>
|
||||
|
||||
{g.details.map(d => (
|
||||
<Detail {...d} key={d.key} t={t} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{meta.map((row, i) => (
|
||||
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
|
||||
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
|
||||
{row.content}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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[]) => {
|
||||
|
||||
Reference in New Issue
Block a user