From c9f78d110ad2f29b931fb9622fa14509809f6441 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 17:43:38 -0500 Subject: [PATCH] feat: good vibes indi --- ui-tui/src/app.tsx | 70 ++- ui-tui/src/app/createSlashHandler.ts | 72 ++- ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/components/appChrome.tsx | 55 +- ui-tui/src/components/appLayout.tsx | 26 +- ui-tui/src/components/thinking.tsx | 837 ++++++++++++++++++--------- 6 files changed, 763 insertions(+), 298 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 549314abdf..947af602a9 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -29,6 +29,13 @@ import { asRpcResult, rpcErrorMessage } from './lib/rpc.js' import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js' import type { Msg, PanelSection, SessionInfo, SlashCatalog } from './types.js' +const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i +const LONG_RUN_CHARM_DELAY_MS = 8_000 +const LONG_RUN_CHARM_INTERVAL_MS = 10_000 +const LONG_RUN_CHARM_MAX = 2 + +const LONG_RUN_CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] + // ── App ────────────────────────────────────────────────────────────── export function App({ gw }: { gw: GatewayClient }) { @@ -68,6 +75,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [voiceRecording, setVoiceRecording] = useState(false) const [voiceProcessing, setVoiceProcessing] = useState(false) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) + const [goodVibesTick, setGoodVibesTick] = useState(0) const [bellOnComplete, setBellOnComplete] = useState(false) const ui = useStore($uiState) const overlay = useStore($overlayState) @@ -85,6 +93,7 @@ export function App({ gw }: { gw: GatewayClient }) { const configMtimeRef = useRef(0) const historyItemsRef = useRef(historyItems) const lastUserMsgRef = useRef(lastUserMsg) + const longRunCharmRef = useRef(new Map()) const msgIdsRef = useRef(new WeakMap()) const nextMsgIdRef = useRef(0) colsRef.current = cols @@ -226,6 +235,17 @@ export function App({ gw }: { gw: GatewayClient }) { [sys] ) + const maybeGoodVibes = useCallback( + (text: string) => { + if (!GOOD_VIBES_RE.test(text)) { + return + } + + setGoodVibesTick(v => v + 1) + }, + [] + ) + const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { const display = cfg?.config?.display ?? {} @@ -571,6 +591,7 @@ export function App({ gw }: { gw: GatewayClient }) { turnRefs.statusTimerRef.current = null } + maybeGoodVibes(submitText) setLastUserMsg(text) appendMessage({ role: 'user', text: displayText }) patchUiState({ busy: true, status: 'running…' }) @@ -610,7 +631,7 @@ export function App({ gw }: { gw: GatewayClient }) { }) .catch(() => startSubmit(text, expandPasteSnips(text))) }, - [appendMessage, composerState.pasteSnips, gw, turnActions, sys, turnRefs] + [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, turnActions, sys, turnRefs] ) const shellExec = useCallback( @@ -909,6 +930,50 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [gw, turnActions, sys]) + useEffect(() => { + if (!ui.busy || !turnState.tools.length) { + longRunCharmRef.current.clear() + + return + } + + const tick = () => { + const now = Date.now() + const liveIds = new Set(turnState.tools.map(tool => tool.id)) + + for (const key of [...longRunCharmRef.current.keys()]) { + if (!liveIds.has(key)) { + longRunCharmRef.current.delete(key) + } + } + + for (const tool of turnState.tools) { + if (!tool.startedAt || now - tool.startedAt < LONG_RUN_CHARM_DELAY_MS) { + continue + } + + const slot = longRunCharmRef.current.get(tool.id) ?? { count: 0, lastAt: 0 } + + if (slot.count >= LONG_RUN_CHARM_MAX || now - slot.lastAt < LONG_RUN_CHARM_INTERVAL_MS) { + continue + } + + slot.count += 1 + slot.lastAt = now + longRunCharmRef.current.set(tool.id, slot) + + const charm = LONG_RUN_CHARMS[Math.floor(Math.random() * LONG_RUN_CHARMS.length)]! + const sec = Math.round((now - tool.startedAt) / 1000) + turnActions.pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`) + } + } + + tick() + const id = setInterval(tick, 1000) + + return () => clearInterval(id) + }, [turnActions, turnState.tools, ui.busy]) + // ── Slash commands ─────────────────────────────────────────────── const slash = useMemo( @@ -1198,13 +1263,14 @@ export function App({ gw }: { gw: GatewayClient }) { const appStatus = useMemo( () => ({ cwdLabel, + goodVibesTick, sessionStartedAt: sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel }), - [cwdLabel, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] + [cwdLabel, goodVibesTick, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] ) const appTranscript = useMemo( diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 55dbac86f2..eb5a03583a 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -17,6 +17,56 @@ import type { SlashHandlerContext } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' import { getUiState, patchUiState } from './uiStore.js' +const FORTUNES = [ + 'you are one clean refactor away from clarity', + 'a tiny rename today prevents a huge bug tomorrow', + 'your next commit message will be immaculate', + 'the edge case you are ignoring is already solved in your head', + 'minimal diff, maximal calm', + 'today favors bold deletions over new abstractions', + 'the right helper is already in your codebase', + 'you will ship before overthinking catches up', + 'tests are about to save your future self', + 'your instincts are correctly suspicious of that one branch' +] + +const LEGENDARY_FORTUNES = [ + 'legendary drop: one-line fix, first try', + 'legendary drop: every flaky test passes cleanly', + 'legendary drop: your diff teaches by itself' +] + +const hash = (input: string) => { + let out = 2166136261 + + for (let i = 0; i < input.length; i++) { + out ^= input.charCodeAt(i) + out = Math.imul(out, 16777619) + } + + return out >>> 0 +} + +const fortuneFromScore = (score: number) => { + const rare = score % 20 === 0 + const bag = rare ? LEGENDARY_FORTUNES : FORTUNES + + return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}` +} + +const randomFortune = () => { + const score = Math.floor(Math.random() * 0x7fffffff) + + return fortuneFromScore(score) +} + +const dailyFortune = (sid: string | null) => { + const seed = `${sid || 'anon'}|${new Date().toDateString()}` + const score = hash(seed) + + return fortuneFromScore(score) +} + export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer const { gw, rpc } = ctx.gateway @@ -71,7 +121,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b sections.push({ title: 'TUI', - rows: [['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode']] + rows: [ + ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], + ['/fortune [random|daily]', 'show a random or daily local fortune'] + ] }) sections.push({ title: 'Hotkeys', rows: HOTKEYS }) @@ -171,6 +224,23 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b sys(`details: ${next}`) } + return true + + case 'fortune': + if (!arg || arg.trim().toLowerCase() === 'random') { + sys(randomFortune()) + + return true + } + + if (['daily', 'today', 'stable'].includes(arg.trim().toLowerCase())) { + sys(dailyFortune(sid)) + + return true + } + + sys('usage: /fortune [random|daily]') + return true case 'copy': { if (!arg && hasSelection) { diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 19756e8d35..719116cb8d 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -399,6 +399,7 @@ export interface AppLayoutProgressProps { export interface AppLayoutStatusProps { cwdLabel: string + goodVibesTick: number sessionStartedAt: number | null showStickyPrompt: boolean statusColor: string diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index fff364689e..cad10f6489 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -46,6 +46,29 @@ function SessionDuration({ startedAt }: { startedAt: number }) { return fmtDuration(now - startedAt) } +export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { + const [active, setActive] = useState(false) + const [color, setColor] = useState(t.color.amber) + + useEffect(() => { + if (tick <= 0) { + return + } + + const options = ['#ff5fa2', '#ff4d6d', t.color.amber] + const picked = options[Math.floor(Math.random() * options.length)]! + + setColor(picked) + setActive(true) + + const id = setTimeout(() => setActive(false), 650) + + return () => clearTimeout(id) + }, [t.color.amber, tick]) + + return {active ? '♥' : ' '} +} + export function StatusRule({ cwdLabel, cols, @@ -85,29 +108,29 @@ export function StatusRule({ return ( - + {'─ '} - {status} - │ {model} - {ctxLabel ? │ {ctxLabel} : null} + {status} + │ {model} + {ctxLabel ? │ {ctxLabel} : null} {bar ? ( - + {' │ '} - [{bar}] {pctLabel} + [{bar}] {pctLabel} ) : null} {sessionStartedAt ? ( - + {' │ '} ) : null} - {voiceLabel ? │ {voiceLabel} : null} - {bgCount > 0 ? │ {bgCount} bg : null} + {voiceLabel ? │ {voiceLabel} : null} + {bgCount > 0 ? │ {bgCount} bg : null} - - {cwdLabel} + + {cwdLabel} ) } @@ -116,7 +139,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri return ( {!scrollable ? ( - + {' \n'.repeat(Math.max(0, vp - 1))}{' '} ) : ( <> {thumbTop > 0 ? ( - + {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} ) : null} {thumb > 0 ? ( - {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} + {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} ) : null} {vp - thumbTop - thumb > 0 ? ( - + {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} ) : null} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index b6b6dac572..3d80e5fb1d 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -7,7 +7,7 @@ import type { AppLayoutProps } from '../app/interfaces.js' import { $isBlocked } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' -import { StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' +import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { AppOverlays } from './appOverlays.js' import { Banner, Panel, SessionPanel } from './branding.js' import { MessageLine } from './messageLine.js' @@ -106,7 +106,6 @@ const ComposerPane = memo(function ComposerPane({ }: Pick) { const ui = useStore($uiState) const isBlocked = useStore($isBlocked) - const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') const pw = sh ? 2 : 3 @@ -177,7 +176,7 @@ const ComposerPane = memo(function ComposerPane({ ))} - + {sh ? ( $ @@ -188,14 +187,19 @@ const ComposerPane = memo(function ComposerPane({ )} - + + + + + + )} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 27bf5e0736..0b1d4e95b4 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from '@hermes/ink' +import { Box, NoSelect, Text } from '@hermes/ink' import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' @@ -25,24 +25,132 @@ const fmtElapsed = (ms: number) => { return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` } +type TreeBranch = 'mid' | 'last' +type TreeRails = readonly boolean[] + +const nextTreeRails = (rails: TreeRails, branch: TreeBranch) => [...rails, branch === 'mid'] + +const treeLead = (rails: TreeRails, branch: TreeBranch) => + `${rails.map(on => (on ? '│ ' : ' ')).join('')}${branch === 'mid' ? '├─ ' : '└─ '}` + // ── Primitives ─────────────────────────────────────────────────────── -export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { - const [spin] = useState(() => { +function TreeRow({ + branch, + children, + rails = [], + stemColor, + stemDim = true, + t +}: { + branch: TreeBranch + children: ReactNode + rails?: TreeRails + stemColor?: string + stemDim?: boolean + t: Theme +}) { + const lead = treeLead(rails, branch) + + return ( + + + + {lead} + + + + {children} + + + ) +} + +function TreeTextRow({ + branch, + color, + content, + dimColor, + rails = [], + t, + wrap = 'wrap-trim' +}: { + branch: TreeBranch + color: string + content: ReactNode + dimColor?: boolean + rails?: TreeRails + t: Theme + wrap?: 'truncate-end' | 'wrap' | 'wrap-trim' +}) { + const text = dimColor ? ( + + {content} + + ) : ( + + {content} + + ) + + return ( + + {text} + + ) +} + +function TreeNode({ + branch, + children, + header, + open, + rails = [], + t +}: { + branch: TreeBranch + children?: (rails: boolean[]) => ReactNode + header: ReactNode + open: boolean + rails?: TreeRails + t: Theme +}) { + return ( + + + {header} + + {open ? children?.(nextTreeRails(rails, branch)) : null} + + ) +} + +export function Spinner({ + color, + variant = 'think' +}: { + color: string + variant?: 'think' | 'tool' +}) { + const spin = useMemo(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') } - }) + }, [variant]) const [frame, setFrame] = useState(0) + useEffect(() => { + setFrame(0) + }, [spin]) + useEffect(() => { const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) return () => clearInterval(id) }, [spin]) - return {spin.frames[frame]} + return {spin.frames[frame]} } interface DetailRow { @@ -52,13 +160,15 @@ interface DetailRow { key: string } -function Detail({ color, content, dimColor }: DetailRow) { - return ( - - - {content} - - ) +function Detail({ + branch = 'last', + color, + content, + dimColor, + rails = [], + t +}: DetailRow & { branch?: TreeBranch; rails?: TreeRails; t: Theme }) { + return } function StreamCursor({ @@ -86,11 +196,17 @@ function StreamCursor({ return () => clearInterval(id) }, [streaming, visible]) - return visible ? ( - + if (!visible) { + return null + } + + return dimColor ? ( + {streaming && on ? '▍' : ' '} - ) : null + ) : ( + {streaming && on ? '▍' : ' '} + ) } function Chevron({ @@ -113,13 +229,13 @@ function Chevron({ const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim return ( - onClick(!!e?.shiftKey || !!e?.ctrlKey)}> - - {open ? '▾ ' : '▸ '} + onClick(!!e?.shiftKey || !!e?.ctrlKey)}> + + {open ? '▾ ' : '▸ '} {title} {typeof count === 'number' ? ` (${count})` : ''} {suffix ? ( - + {' '} {suffix} @@ -129,7 +245,19 @@ function Chevron({ ) } -function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: SubagentProgress; t: Theme }) { +function SubagentAccordion({ + branch, + expanded, + item, + rails = [], + t +}: { + branch: TreeBranch + expanded: boolean + item: SubagentProgress + rails?: TreeRails + t: Theme +}) { const [open, setOpen] = useState(expanded) const [deep, setDeep] = useState(expanded) const [openThinking, setOpenThinking] = useState(expanded) @@ -175,95 +303,175 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub const noteRows = [...(summary ? [summary] : []), ...item.notes] const hasNotes = noteRows.length > 0 const showChildren = expanded || deep + const noteColor = statusTone === 'error' ? t.color.error : statusTone === 'warn' ? t.color.warn : t.color.dim + + const sections: { + header: ReactNode + key: string + open: boolean + render: (rails: boolean[]) => ReactNode + }[] = [] + + if (hasThinking) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenThinking(v => !v) + } + }} + open={showChildren || openThinking} + t={t} + title="Thinking" + /> + ), + key: 'thinking', + open: showChildren || openThinking, + render: childRails => ( + + ) + }) + } + + if (hasTools) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenTools(v => !v) + } + }} + open={showChildren || openTools} + t={t} + title="Tool calls" + /> + ), + key: 'tools', + open: showChildren || openTools, + render: childRails => ( + + {item.tools.map((line, index) => ( + + + {line} + + } + key={`${item.id}-tool-${index}`} + rails={childRails} + t={t} + /> + ))} + + ) + }) + } + + if (hasNotes) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenNotes(v => !v) + } + }} + open={showChildren || openNotes} + t={t} + title="Progress" + tone={statusTone} + /> + ), + key: 'notes', + open: showChildren || openNotes, + render: childRails => ( + + {noteRows.map((line, index) => ( + + ))} + + ) + }) + } return ( - - shift ? expandAll() : setOpen(v => { if (!v) setDeep(false); return !v })} - open={open} - suffix={suffix} - t={t} - title={title} - tone={statusTone} - /> + { + if (shift) { + expandAll() - {open && ( - - {hasThinking && ( - <> - { if (shift) expandAll(); else setOpenThinking(v => !v) }} - open={showChildren || openThinking} - t={t} - title="Thinking" - /> + return + } - {(showChildren || openThinking) && ( - - )} - - )} + setOpen(v => { + if (!v) { + setDeep(false) + } - {hasTools && ( - <> - { if (shift) expandAll(); else setOpenTools(v => !v) }} - open={showChildren || openTools} - t={t} - title="Tool calls" - /> - - {(showChildren || openTools) && ( - - {item.tools.map((line, index) => ( - - - {line} - - ))} - - )} - - )} - - {hasNotes && ( - <> - { if (shift) expandAll(); else setOpenNotes(v => !v) }} - open={showChildren || openNotes} - t={t} - title="Progress" - tone={statusTone} - /> - - {(showChildren || openNotes) && ( - - {noteRows.map((line, index) => ( - - {index === noteRows.length - 1 ? '└ ' : '├ '} - {line} - - ))} - - )} - - )} + return !v + }) + }} + open={open} + suffix={suffix} + t={t} + title={title} + tone={statusTone} + /> + } + open={open} + rails={rails} + t={t} + > + {childRails => ( + + {sections.map((section, index) => ( + + {section.render} + + ))} )} - + ) } @@ -271,13 +479,17 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub export const Thinking = memo(function Thinking({ active = false, + branch = 'last', mode = 'truncated', + rails = [], reasoning, streaming = false, t }: { active?: boolean + branch?: TreeBranch mode?: ThinkingMode + rails?: TreeRails reasoning: string streaming?: boolean t: Theme @@ -285,39 +497,36 @@ export const Thinking = memo(function Thinking({ const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning]) const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview]) + if (!preview && !active) { + return null + } + return ( - - {preview ? ( - mode === 'full' ? ( - - - └{' '} + + + {preview ? ( + mode === 'full' ? ( + lines.map((line, index) => ( + + {line || ' '} + {index === lines.length - 1 ? ( + + ) : null} + + )) + ) : ( + + {preview} + - - {lines.map((line, index) => ( - - {line || ' '} - {index === lines.length - 1 ? ( - - ) : null} - - ))} - - + ) ) : ( - - - {preview} + - ) - ) : active ? ( - - - - - ) : null} - + )} + + ) }) @@ -328,6 +537,7 @@ interface Group { content: ReactNode details: DetailRow[] key: string + label: string } export const ToolTrail = memo(function ToolTrail({ @@ -410,7 +620,8 @@ export const ToolTrail = memo(function ToolTrail({ color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk, content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`, details: [], - key: `tr-${i}` + key: `tr-${i}`, + label: parsed.call }) if (parsed.detail) { @@ -426,11 +637,14 @@ export const ToolTrail = memo(function ToolTrail({ } if (line.startsWith('drafting ')) { + const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim()) + groups.push({ color: t.color.cornsilk, - content: toolTrailLabel(line.slice(9).replace(/…$/, '').trim()), + content: label, details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }], - key: `tr-${i}` + key: `tr-${i}`, + label }) continue @@ -457,13 +671,16 @@ export const ToolTrail = memo(function ToolTrail({ } for (const tool of tools) { + const label = formatToolCall(tool.name, tool.context || '') + groups.push({ color: t.color.cornsilk, key: tool.id, + label, details: [], content: ( <> - {formatToolCall(tool.name, tool.context || '')} + {label} {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} ) @@ -493,6 +710,8 @@ export const ToolTrail = memo(function ToolTrail({ const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null + const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task')) + const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null // ── Hidden: errors/warnings only ────────────────────────────── @@ -502,7 +721,7 @@ export const ToolTrail = memo(function ToolTrail({ return alerts.length ? ( {alerts.map(i => ( - + {i.tone === 'error' ? '✗' : '!'} {i.text} ))} @@ -510,74 +729,7 @@ export const ToolTrail = memo(function ToolTrail({ ) : null } - // ── Shared render fragments ──────────────────────────────────── - - const thinkingBlock = hasThinking ? ( - busy ? ( - - ) : cot ? ( - - ) : ( - } - dimColor - key="cot" - /> - ) - ) : null - - const toolBlock = hasTools - ? groups.map(g => ( - - - - {g.content} - - {g.details.map(d => ( - - ))} - - )) - : null - - const subagentBlock = hasSubagents - ? subagents.map(item => ( - - )) - : null - - const metaBlock = hasMeta - ? meta.map((row, i) => ( - - {i === meta.length - 1 ? '└ ' : '├ '} - {row.content} - - )) - : null - - const totalBlock = totalTokensLabel ? ( - - Σ - {totalTokensLabel} - - ) : null - - // ── Expanded: flat, no accordions ────────────────────────────── - - if (detailsMode === 'expanded') { - return ( - - {thinkingBlock} - {toolBlock} - {subagentBlock} - {metaBlock} - {totalBlock} - - ) - } - - // ── Collapsed: clickable accordions ──────────────────────────── + // ── Tree render fragments ────────────────────────────────────── const expandAll = () => { setOpenThinking(true) @@ -593,78 +745,227 @@ export const ToolTrail = memo(function ToolTrail({ ? 'warn' : 'dim' - return ( + const renderSubagentList = (rails: boolean[]) => ( - {hasThinking && ( - <> - (e?.shiftKey || e?.ctrlKey) ? expandAll() : setOpenThinking(v => !v)}> - - {openThinking ? '▾ ' : '▸ '} - + {subagents.map((item, index) => ( + + ))} + + ) + + const sections: { + header: ReactNode + key: string + open: boolean + render: (rails: boolean[]) => ReactNode + }[] = [] + + if (hasThinking) { + sections.push({ + header: ( + { + if (e?.shiftKey || e?.ctrlKey) { + expandAll() + } else { + setOpenThinking(v => !v) + } + }} + > + + {detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '} + {thinkingLive ? ( + Thinking - {thinkingTokensLabel ? ( - - {' '} - {thinkingTokensLabel} - - ) : null} - - - {openThinking && thinkingBlock} - - )} + ) : ( + + Thinking + + )} + {thinkingTokensLabel ? ( + + {' '} + {thinkingTokensLabel} + + ) : null} + + + ), + key: 'thinking', + open: detailsMode === 'expanded' || openThinking, + render: rails => ( + + ) + }) + } - {hasTools && ( - <> - shift ? expandAll() : setOpenTools(v => !v)} - open={openTools} - suffix={toolTokensLabel} - t={t} - title="Tool calls" - /> - {openTools && toolBlock} - - )} + if (hasTools) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenTools(v => !v) + } + }} + open={detailsMode === 'expanded' || openTools} + suffix={toolTokensLabel} + t={t} + title="Tool calls" + /> + ), + key: 'tools', + open: detailsMode === 'expanded' || openTools, + render: rails => ( + + {groups.map((group, index) => { + const branch: TreeBranch = index === groups.length - 1 ? 'last' : 'mid' + const childRails = nextTreeRails(rails, branch) + const hasInlineSubagents = inlineDelegateKey === group.key - {hasSubagents && ( - <> - { - if (shift) { - expandAll() - setDeepSubagents(true) - } else { - setOpenSubagents(v => !v) - setDeepSubagents(false) - } - }} - open={openSubagents} - t={t} - title="Subagents" - /> - {openSubagents && subagentBlock} - - )} + return ( + + + + {group.content} + + } + rails={rails} + t={t} + /> + {group.details.map((detail, detailIndex) => ( + + ))} + {hasInlineSubagents ? renderSubagentList(childRails) : null} + + ) + })} + + ) + }) + } - {hasMeta && ( - <> - shift ? expandAll() : setOpenMeta(v => !v)} - open={openMeta} - t={t} - title="Activity" - tone={metaTone} - /> - {openMeta && metaBlock} - - )} + if (hasSubagents && !inlineDelegateKey) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + setDeepSubagents(true) + } else { + setOpenSubagents(v => !v) + setDeepSubagents(false) + } + }} + open={detailsMode === 'expanded' || openSubagents} + t={t} + title="Subagents" + /> + ), + key: 'subagents', + open: detailsMode === 'expanded' || openSubagents, + render: renderSubagentList + }) + } - {totalBlock} + if (hasMeta) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenMeta(v => !v) + } + }} + open={detailsMode === 'expanded' || openMeta} + t={t} + title="Activity" + tone={metaTone} + /> + ), + key: 'meta', + open: detailsMode === 'expanded' || openMeta, + render: rails => ( + + {meta.map((row, index) => ( + + ))} + + ) + }) + } + + const topCount = sections.length + (totalTokensLabel ? 1 : 0) + + return ( + + {sections.map((section, index) => ( + + {section.render} + + ))} + {totalTokensLabel ? ( + + Σ + {totalTokensLabel} + + } + dimColor + t={t} + /> + ) : null} ) })