feat: good vibes indi

This commit is contained in:
Brooklyn Nicholson 2026-04-15 17:43:38 -05:00
parent baa0de7649
commit c9f78d110a
6 changed files with 763 additions and 298 deletions

View file

@ -29,6 +29,13 @@ import { asRpcResult, rpcErrorMessage } from './lib/rpc.js'
import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js' import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js'
import type { Msg, PanelSection, SessionInfo, SlashCatalog } from './types.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 ────────────────────────────────────────────────────────────── // ── App ──────────────────────────────────────────────────────────────
export function App({ gw }: { gw: GatewayClient }) { export function App({ gw }: { gw: GatewayClient }) {
@ -68,6 +75,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const [voiceRecording, setVoiceRecording] = useState(false) const [voiceRecording, setVoiceRecording] = useState(false)
const [voiceProcessing, setVoiceProcessing] = useState(false) const [voiceProcessing, setVoiceProcessing] = useState(false)
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [goodVibesTick, setGoodVibesTick] = useState(0)
const [bellOnComplete, setBellOnComplete] = useState(false) const [bellOnComplete, setBellOnComplete] = useState(false)
const ui = useStore($uiState) const ui = useStore($uiState)
const overlay = useStore($overlayState) const overlay = useStore($overlayState)
@ -85,6 +93,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const configMtimeRef = useRef(0) const configMtimeRef = useRef(0)
const historyItemsRef = useRef(historyItems) const historyItemsRef = useRef(historyItems)
const lastUserMsgRef = useRef(lastUserMsg) const lastUserMsgRef = useRef(lastUserMsg)
const longRunCharmRef = useRef(new Map<string, { count: number; lastAt: number }>())
const msgIdsRef = useRef(new WeakMap<Msg, string>()) const msgIdsRef = useRef(new WeakMap<Msg, string>())
const nextMsgIdRef = useRef(0) const nextMsgIdRef = useRef(0)
colsRef.current = cols colsRef.current = cols
@ -226,6 +235,17 @@ export function App({ gw }: { gw: GatewayClient }) {
[sys] [sys]
) )
const maybeGoodVibes = useCallback(
(text: string) => {
if (!GOOD_VIBES_RE.test(text)) {
return
}
setGoodVibesTick(v => v + 1)
},
[]
)
const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => {
const display = cfg?.config?.display ?? {} const display = cfg?.config?.display ?? {}
@ -571,6 +591,7 @@ export function App({ gw }: { gw: GatewayClient }) {
turnRefs.statusTimerRef.current = null turnRefs.statusTimerRef.current = null
} }
maybeGoodVibes(submitText)
setLastUserMsg(text) setLastUserMsg(text)
appendMessage({ role: 'user', text: displayText }) appendMessage({ role: 'user', text: displayText })
patchUiState({ busy: true, status: 'running…' }) patchUiState({ busy: true, status: 'running…' })
@ -610,7 +631,7 @@ export function App({ gw }: { gw: GatewayClient }) {
}) })
.catch(() => startSubmit(text, expandPasteSnips(text))) .catch(() => startSubmit(text, expandPasteSnips(text)))
}, },
[appendMessage, composerState.pasteSnips, gw, turnActions, sys, turnRefs] [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, turnActions, sys, turnRefs]
) )
const shellExec = useCallback( const shellExec = useCallback(
@ -909,6 +930,50 @@ export function App({ gw }: { gw: GatewayClient }) {
} }
}, [gw, turnActions, sys]) }, [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 ─────────────────────────────────────────────── // ── Slash commands ───────────────────────────────────────────────
const slash = useMemo( const slash = useMemo(
@ -1198,13 +1263,14 @@ export function App({ gw }: { gw: GatewayClient }) {
const appStatus = useMemo( const appStatus = useMemo(
() => ({ () => ({
cwdLabel, cwdLabel,
goodVibesTick,
sessionStartedAt: sessionStarted, sessionStartedAt: sessionStarted,
showStickyPrompt, showStickyPrompt,
statusColor, statusColor,
stickyPrompt, stickyPrompt,
voiceLabel voiceLabel
}), }),
[cwdLabel, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] [cwdLabel, goodVibesTick, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel]
) )
const appTranscript = useMemo( const appTranscript = useMemo(

View file

@ -17,6 +17,56 @@ import type { SlashHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js' import { patchOverlayState } from './overlayStore.js'
import { getUiState, patchUiState } from './uiStore.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 { export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer
const { gw, rpc } = ctx.gateway const { gw, rpc } = ctx.gateway
@ -71,7 +121,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
sections.push({ sections.push({
title: 'TUI', 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 }) sections.push({ title: 'Hotkeys', rows: HOTKEYS })
@ -171,6 +224,23 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
sys(`details: ${next}`) 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 return true
case 'copy': { case 'copy': {
if (!arg && hasSelection) { if (!arg && hasSelection) {

View file

@ -399,6 +399,7 @@ export interface AppLayoutProgressProps {
export interface AppLayoutStatusProps { export interface AppLayoutStatusProps {
cwdLabel: string cwdLabel: string
goodVibesTick: number
sessionStartedAt: number | null sessionStartedAt: number | null
showStickyPrompt: boolean showStickyPrompt: boolean
statusColor: string statusColor: string

View file

@ -46,6 +46,29 @@ function SessionDuration({ startedAt }: { startedAt: number }) {
return fmtDuration(now - startedAt) 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 <Text color={color as any}>{active ? '♥' : ' '}</Text>
}
export function StatusRule({ export function StatusRule({
cwdLabel, cwdLabel,
cols, cols,
@ -85,29 +108,29 @@ export function StatusRule({
return ( return (
<Box> <Box>
<Box flexShrink={1} width={leftWidth}> <Box flexShrink={1} width={leftWidth}>
<Text color={t.color.bronze} wrap="truncate-end"> <Text color={t.color.bronze as any} wrap="truncate-end">
{'─ '} {'─ '}
<Text color={statusColor}>{status}</Text> <Text color={statusColor as any}>{status}</Text>
<Text color={t.color.dim}> {model}</Text> <Text color={t.color.dim as any}> {model}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null} {ctxLabel ? <Text color={t.color.dim as any}> {ctxLabel}</Text> : null}
{bar ? ( {bar ? (
<Text color={t.color.dim}> <Text color={t.color.dim as any}>
{' │ '} {' │ '}
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text> <Text color={barColor as any}>[{bar}]</Text> <Text color={barColor as any}>{pctLabel}</Text>
</Text> </Text>
) : null} ) : null}
{sessionStartedAt ? ( {sessionStartedAt ? (
<Text color={t.color.dim}> <Text color={t.color.dim as any}>
{' │ '} {' │ '}
<SessionDuration startedAt={sessionStartedAt} /> <SessionDuration startedAt={sessionStartedAt} />
</Text> </Text>
) : null} ) : null}
{voiceLabel ? <Text color={t.color.dim}> {voiceLabel}</Text> : null} {voiceLabel ? <Text color={t.color.dim as any}> {voiceLabel}</Text> : null}
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null} {bgCount > 0 ? <Text color={t.color.dim as any}> {bgCount} bg</Text> : null}
</Text> </Text>
</Box> </Box>
<Text color={t.color.bronze}> </Text> <Text color={t.color.bronze as any}> </Text>
<Text color={t.color.label}>{cwdLabel}</Text> <Text color={t.color.label as any}>{cwdLabel}</Text>
</Box> </Box>
) )
} }
@ -116,7 +139,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri
return ( return (
<Box <Box
alignSelf="flex-start" alignSelf="flex-start"
borderColor={color} borderColor={color as any}
borderStyle="double" borderStyle="double"
flexDirection="column" flexDirection="column"
marginTop={1} marginTop={1}
@ -224,21 +247,21 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
width={1} width={1}
> >
{!scrollable ? ( {!scrollable ? (
<Text color={trackColor} dimColor> <Text color={trackColor as any} dim>
{' \n'.repeat(Math.max(0, vp - 1))}{' '} {' \n'.repeat(Math.max(0, vp - 1))}{' '}
</Text> </Text>
) : ( ) : (
<> <>
{thumbTop > 0 ? ( {thumbTop > 0 ? (
<Text color={trackColor} dimColor={!hover}> <Text color={trackColor as any} dim={!hover}>
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
</Text> </Text>
) : null} ) : null}
{thumb > 0 ? ( {thumb > 0 ? (
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text> <Text color={thumbColor as any}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
) : null} ) : null}
{vp - thumbTop - thumb > 0 ? ( {vp - thumbTop - thumb > 0 ? (
<Text color={trackColor} dimColor={!hover}> <Text color={trackColor as any} dim={!hover}>
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
</Text> </Text>
) : null} ) : null}

View file

@ -7,7 +7,7 @@ import type { AppLayoutProps } from '../app/interfaces.js'
import { $isBlocked } from '../app/overlayStore.js' import { $isBlocked } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.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 { AppOverlays } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js' import { Banner, Panel, SessionPanel } from './branding.js'
import { MessageLine } from './messageLine.js' import { MessageLine } from './messageLine.js'
@ -106,7 +106,6 @@ const ComposerPane = memo(function ComposerPane({
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'>) { }: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'>) {
const ui = useStore($uiState) const ui = useStore($uiState)
const isBlocked = useStore($isBlocked) const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
const pw = sh ? 2 : 3 const pw = sh ? 2 : 3
@ -177,7 +176,7 @@ const ComposerPane = memo(function ComposerPane({
</Box> </Box>
))} ))}
<Box> <Box position="relative">
<Box width={pw}> <Box width={pw}>
{sh ? ( {sh ? (
<Text color={ui.theme.color.shellDollar as any}>$ </Text> <Text color={ui.theme.color.shellDollar as any}>$ </Text>
@ -188,6 +187,7 @@ const ComposerPane = memo(function ComposerPane({
)} )}
</Box> </Box>
<Box flexGrow={1} position="relative">
<TextInput <TextInput
columns={Math.max(20, composer.cols - pw)} columns={Math.max(20, composer.cols - pw)}
onChange={composer.updateInput} onChange={composer.updateInput}
@ -196,6 +196,10 @@ const ComposerPane = memo(function ComposerPane({
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''} placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input} value={composer.input}
/> />
<Box position="absolute" right={0}>
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
</Box>
</Box>
</Box> </Box>
</Box> </Box>
)} )}

View file

@ -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 { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations' 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` 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 ─────────────────────────────────────────────────────── // ── Primitives ───────────────────────────────────────────────────────
export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { function TreeRow({
const [spin] = useState(() => { 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 (
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
<Text color={(stemColor ?? t.color.dim) as any} dim={stemDim}>
{lead}
</Text>
</NoSelect>
<Box flexDirection="column" flexGrow={1}>
{children}
</Box>
</Box>
)
}
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 ? (
<Text color={color as any} dim wrap={wrap}>
{content}
</Text>
) : (
<Text color={color as any} wrap={wrap}>
{content}
</Text>
)
return (
<TreeRow branch={branch} rails={rails} t={t}>
{text}
</TreeRow>
)
}
function TreeNode({
branch,
children,
header,
open,
rails = [],
t
}: {
branch: TreeBranch
children?: (rails: boolean[]) => ReactNode
header: ReactNode
open: boolean
rails?: TreeRails
t: Theme
}) {
return (
<Box flexDirection="column">
<TreeRow branch={branch} rails={rails} t={t}>
{header}
</TreeRow>
{open ? children?.(nextTreeRails(rails, branch)) : null}
</Box>
)
}
export function Spinner({
color,
variant = 'think'
}: {
color: string
variant?: 'think' | 'tool'
}) {
const spin = useMemo(() => {
const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)]
return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '') } return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '') }
}) }, [variant])
const [frame, setFrame] = useState(0) const [frame, setFrame] = useState(0)
useEffect(() => {
setFrame(0)
}, [spin])
useEffect(() => { useEffect(() => {
const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
return () => clearInterval(id) return () => clearInterval(id)
}, [spin]) }, [spin])
return <Text color={color}>{spin.frames[frame]}</Text> return <Text color={color as any}>{spin.frames[frame]}</Text>
} }
interface DetailRow { interface DetailRow {
@ -52,13 +160,15 @@ interface DetailRow {
key: string key: string
} }
function Detail({ color, content, dimColor }: DetailRow) { function Detail({
return ( branch = 'last',
<Text color={color} dimColor={dimColor} wrap="wrap-trim"> color,
<Text dimColor> </Text> content,
{content} dimColor,
</Text> rails = [],
) t
}: DetailRow & { branch?: TreeBranch; rails?: TreeRails; t: Theme }) {
return <TreeTextRow branch={branch} color={color} content={content} dimColor={dimColor} rails={rails} t={t} />
} }
function StreamCursor({ function StreamCursor({
@ -86,11 +196,17 @@ function StreamCursor({
return () => clearInterval(id) return () => clearInterval(id)
}, [streaming, visible]) }, [streaming, visible])
return visible ? ( if (!visible) {
<Text color={color} dimColor={dimColor}> return null
}
return dimColor ? (
<Text color={color as any} dim>
{streaming && on ? '▍' : ' '} {streaming && on ? '▍' : ' '}
</Text> </Text>
) : null ) : (
<Text color={color as any}>{streaming && on ? '▍' : ' '}</Text>
)
} }
function Chevron({ function Chevron({
@ -113,13 +229,13 @@ function Chevron({
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim
return ( return (
<Box onClick={(e: { ctrlKey?: boolean; shiftKey?: boolean }) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}> <Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
<Text color={color} dimColor={tone === 'dim'}> <Text color={color as any} dim={tone === 'dim'}>
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text> <Text color={t.color.amber as any}>{open ? '▾ ' : '▸ '}</Text>
{title} {title}
{typeof count === 'number' ? ` (${count})` : ''} {typeof count === 'number' ? ` (${count})` : ''}
{suffix ? ( {suffix ? (
<Text color={t.color.statusFg} dimColor> <Text color={t.color.statusFg as any} dim>
{' '} {' '}
{suffix} {suffix}
</Text> </Text>
@ -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 [open, setOpen] = useState(expanded)
const [deep, setDeep] = useState(expanded) const [deep, setDeep] = useState(expanded)
const [openThinking, setOpenThinking] = 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 noteRows = [...(summary ? [summary] : []), ...item.notes]
const hasNotes = noteRows.length > 0 const hasNotes = noteRows.length > 0
const showChildren = expanded || deep 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: (
<Chevron
count={item.thinking.length}
onClick={shift => {
if (shift) {
expandAll()
} else {
setOpenThinking(v => !v)
}
}}
open={showChildren || openThinking}
t={t}
title="Thinking"
/>
),
key: 'thinking',
open: showChildren || openThinking,
render: childRails => (
<Thinking
active={item.status === 'running'}
branch="last"
mode="full"
rails={childRails}
reasoning={thinkingText}
streaming={item.status === 'running'}
t={t}
/>
)
})
}
if (hasTools) {
sections.push({
header: (
<Chevron
count={item.tools.length}
onClick={shift => {
if (shift) {
expandAll()
} else {
setOpenTools(v => !v)
}
}}
open={showChildren || openTools}
t={t}
title="Tool calls"
/>
),
key: 'tools',
open: showChildren || openTools,
render: childRails => (
<Box flexDirection="column">
{item.tools.map((line, index) => (
<TreeTextRow
branch={index === item.tools.length - 1 ? 'last' : 'mid'}
color={t.color.cornsilk}
content={
<>
<Text color={t.color.amber as any}> </Text>
{line}
</>
}
key={`${item.id}-tool-${index}`}
rails={childRails}
t={t}
/>
))}
</Box>
)
})
}
if (hasNotes) {
sections.push({
header: (
<Chevron
count={noteRows.length}
onClick={shift => {
if (shift) {
expandAll()
} else {
setOpenNotes(v => !v)
}
}}
open={showChildren || openNotes}
t={t}
title="Progress"
tone={statusTone}
/>
),
key: 'notes',
open: showChildren || openNotes,
render: childRails => (
<Box flexDirection="column">
{noteRows.map((line, index) => (
<TreeTextRow
branch={index === noteRows.length - 1 ? 'last' : 'mid'}
color={noteColor}
content={line}
dimColor={statusTone === 'dim'}
key={`${item.id}-note-${index}`}
rails={childRails}
t={t}
/>
))}
</Box>
)
})
}
return ( return (
<Box flexDirection="column" paddingLeft={1}> <TreeNode
branch={branch}
header={
<Chevron <Chevron
onClick={shift => shift ? expandAll() : setOpen(v => { if (!v) setDeep(false); return !v })} onClick={shift => {
if (shift) {
expandAll()
return
}
setOpen(v => {
if (!v) {
setDeep(false)
}
return !v
})
}}
open={open} open={open}
suffix={suffix} suffix={suffix}
t={t} t={t}
title={title} title={title}
tone={statusTone} tone={statusTone}
/> />
}
{open && ( open={open}
<Box flexDirection="column" paddingLeft={2}> rails={rails}
{hasThinking && (
<>
<Chevron
count={item.thinking.length}
onClick={shift => { if (shift) expandAll(); else setOpenThinking(v => !v) }}
open={showChildren || openThinking}
t={t} t={t}
title="Thinking"
/>
{(showChildren || openThinking) && (
<Thinking
active={item.status === 'running'}
mode="full"
reasoning={thinkingText}
streaming={item.status === 'running'}
t={t}
/>
)}
</>
)}
{hasTools && (
<>
<Chevron
count={item.tools.length}
onClick={shift => { if (shift) expandAll(); else setOpenTools(v => !v) }}
open={showChildren || openTools}
t={t}
title="Tool calls"
/>
{(showChildren || openTools) && (
<Box flexDirection="column">
{item.tools.map((line, index) => (
<Text color={t.color.cornsilk} key={`${item.id}-tool-${index}`} wrap="wrap-trim">
<Text color={t.color.amber}> </Text>
{line}
</Text>
))}
</Box>
)}
</>
)}
{hasNotes && (
<>
<Chevron
count={noteRows.length}
onClick={shift => { if (shift) expandAll(); else setOpenNotes(v => !v) }}
open={showChildren || openNotes}
t={t}
title="Progress"
tone={statusTone}
/>
{(showChildren || openNotes) && (
<Box flexDirection="column">
{noteRows.map((line, index) => (
<Text
color={statusTone === 'error' ? t.color.error : t.color.dim}
dimColor
key={`${item.id}-note-${index}`}
> >
<Text dimColor>{index === noteRows.length - 1 ? '└ ' : '├ '}</Text> {childRails => (
{line} <Box flexDirection="column">
</Text> {sections.map((section, index) => (
<TreeNode
branch={index === sections.length - 1 ? 'last' : 'mid'}
header={section.header}
key={`${item.id}-${section.key}`}
open={section.open}
rails={childRails}
t={t}
>
{section.render}
</TreeNode>
))} ))}
</Box> </Box>
)} )}
</> </TreeNode>
)}
</Box>
)}
</Box>
) )
} }
@ -271,13 +479,17 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub
export const Thinking = memo(function Thinking({ export const Thinking = memo(function Thinking({
active = false, active = false,
branch = 'last',
mode = 'truncated', mode = 'truncated',
rails = [],
reasoning, reasoning,
streaming = false, streaming = false,
t t
}: { }: {
active?: boolean active?: boolean
branch?: TreeBranch
mode?: ThinkingMode mode?: ThinkingMode
rails?: TreeRails
reasoning: string reasoning: string
streaming?: boolean streaming?: boolean
t: Theme t: Theme
@ -285,39 +497,36 @@ export const Thinking = memo(function Thinking({
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning]) const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview]) const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
if (!preview && !active) {
return null
}
return ( return (
<Box flexDirection="column"> <TreeRow branch={branch} rails={rails} t={t}>
<Box flexDirection="column" flexGrow={1}>
{preview ? ( {preview ? (
mode === 'full' ? ( mode === 'full' ? (
<Box flexDirection="row"> lines.map((line, index) => (
<Text color={t.color.dim} dimColor> <Text color={t.color.dim as any} dim key={index} wrap="wrap-trim">
{' '}
</Text>
<Box flexDirection="column" flexGrow={1}>
{lines.map((line, index) => (
<Text color={t.color.dim} dimColor key={index} wrap="wrap-trim">
{line || ' '} {line || ' '}
{index === lines.length - 1 ? ( {index === lines.length - 1 ? (
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} /> <StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
) : null} ) : null}
</Text> </Text>
))} ))
</Box>
</Box>
) : ( ) : (
<Text color={t.color.dim} dimColor wrap="truncate-end"> <Text color={t.color.dim as any} dim wrap="truncate-end">
<Text dimColor> </Text>
{preview} {preview}
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} /> <StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text> </Text>
) )
) : active ? ( ) : (
<Text color={t.color.dim} dimColor> <Text color={t.color.dim as any} dim>
<Text dimColor> </Text>
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} /> <StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text> </Text>
) : null} )}
</Box> </Box>
</TreeRow>
) )
}) })
@ -328,6 +537,7 @@ interface Group {
content: ReactNode content: ReactNode
details: DetailRow[] details: DetailRow[]
key: string key: string
label: string
} }
export const ToolTrail = memo(function ToolTrail({ 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, color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk,
content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`, content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`,
details: [], details: [],
key: `tr-${i}` key: `tr-${i}`,
label: parsed.call
}) })
if (parsed.detail) { if (parsed.detail) {
@ -426,11 +637,14 @@ export const ToolTrail = memo(function ToolTrail({
} }
if (line.startsWith('drafting ')) { if (line.startsWith('drafting ')) {
const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim())
groups.push({ groups.push({
color: t.color.cornsilk, 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` }], details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
key: `tr-${i}` key: `tr-${i}`,
label
}) })
continue continue
@ -457,13 +671,16 @@ export const ToolTrail = memo(function ToolTrail({
} }
for (const tool of tools) { for (const tool of tools) {
const label = formatToolCall(tool.name, tool.context || '')
groups.push({ groups.push({
color: t.color.cornsilk, color: t.color.cornsilk,
key: tool.id, key: tool.id,
label,
details: [], details: [],
content: ( content: (
<> <>
<Spinner color={t.color.amber} variant="tool" /> {formatToolCall(tool.name, tool.context || '')} <Spinner color={t.color.amber} variant="tool" /> {label}
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} {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 toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined
const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null 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 ────────────────────────────── // ── Hidden: errors/warnings only ──────────────────────────────
@ -502,7 +721,7 @@ export const ToolTrail = memo(function ToolTrail({
return alerts.length ? ( return alerts.length ? (
<Box flexDirection="column"> <Box flexDirection="column">
{alerts.map(i => ( {alerts.map(i => (
<Text color={i.tone === 'error' ? t.color.error : t.color.warn} key={`ha-${i.id}`}> <Text color={(i.tone === 'error' ? t.color.error : t.color.warn) as any} key={`ha-${i.id}`}>
{i.tone === 'error' ? '✗' : '!'} {i.text} {i.tone === 'error' ? '✗' : '!'} {i.text}
</Text> </Text>
))} ))}
@ -510,74 +729,7 @@ export const ToolTrail = memo(function ToolTrail({
) : null ) : null
} }
// ── Shared render fragments ──────────────────────────────────── // ── Tree render fragments ──────────────────────────────────────
const thinkingBlock = hasThinking ? (
busy ? (
<Thinking active={reasoningActive} mode="full" reasoning={reasoning} streaming={reasoningStreaming} t={t} />
) : cot ? (
<Detail color={t.color.dim} content={cot} dimColor key="cot" />
) : (
<Detail
color={t.color.dim}
content={<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />}
dimColor
key="cot"
/>
)
) : null
const toolBlock = hasTools
? 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} />
))}
</Box>
))
: null
const subagentBlock = hasSubagents
? subagents.map(item => (
<SubagentAccordion expanded={detailsMode === 'expanded' || deepSubagents} item={item} key={item.id} t={t} />
))
: null
const metaBlock = hasMeta
? meta.map((row, i) => (
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
{row.content}
</Text>
))
: null
const totalBlock = totalTokensLabel ? (
<Text color={t.color.statusFg} dimColor>
<Text color={t.color.amber}>Σ </Text>
{totalTokensLabel}
</Text>
) : null
// ── Expanded: flat, no accordions ──────────────────────────────
if (detailsMode === 'expanded') {
return (
<Box flexDirection="column">
{thinkingBlock}
{toolBlock}
{subagentBlock}
{metaBlock}
{totalBlock}
</Box>
)
}
// ── Collapsed: clickable accordions ────────────────────────────
const expandAll = () => { const expandAll = () => {
setOpenThinking(true) setOpenThinking(true)
@ -593,44 +745,138 @@ export const ToolTrail = memo(function ToolTrail({
? 'warn' ? 'warn'
: 'dim' : 'dim'
return ( const renderSubagentList = (rails: boolean[]) => (
<Box flexDirection="column"> <Box flexDirection="column">
{hasThinking && ( {subagents.map((item, index) => (
<> <SubagentAccordion
<Box onClick={(e: { ctrlKey?: boolean; shiftKey?: boolean }) => (e?.shiftKey || e?.ctrlKey) ? expandAll() : setOpenThinking(v => !v)}> branch={index === subagents.length - 1 ? 'last' : 'mid'}
<Text color={t.color.dim} dimColor={!thinkingLive}> expanded={detailsMode === 'expanded' || deepSubagents}
<Text color={t.color.amber}>{openThinking ? '▾ ' : '▸ '}</Text> item={item}
<Text bold={thinkingLive} color={thinkingLive ? t.color.cornsilk : t.color.dim} dimColor={!thinkingLive}> key={item.id}
rails={rails}
t={t}
/>
))}
</Box>
)
const sections: {
header: ReactNode
key: string
open: boolean
render: (rails: boolean[]) => ReactNode
}[] = []
if (hasThinking) {
sections.push({
header: (
<Box
onClick={(e: any) => {
if (e?.shiftKey || e?.ctrlKey) {
expandAll()
} else {
setOpenThinking(v => !v)
}
}}
>
<Text color={t.color.dim as any} dim={!thinkingLive}>
<Text color={t.color.amber as any}>{detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
{thinkingLive ? (
<Text bold color={t.color.cornsilk as any}>
Thinking Thinking
</Text> </Text>
) : (
<Text color={t.color.dim as any} dim>
Thinking
</Text>
)}
{thinkingTokensLabel ? ( {thinkingTokensLabel ? (
<Text color={t.color.statusFg} dimColor> <Text color={t.color.statusFg as any} dim>
{' '} {' '}
{thinkingTokensLabel} {thinkingTokensLabel}
</Text> </Text>
) : null} ) : null}
</Text> </Text>
</Box> </Box>
{openThinking && thinkingBlock} ),
</> key: 'thinking',
)} open: detailsMode === 'expanded' || openThinking,
render: rails => (
<Thinking
active={reasoningActive}
branch="last"
mode="full"
rails={rails}
reasoning={busy ? reasoning : cot}
streaming={busy && reasoningStreaming}
t={t}
/>
)
})
}
{hasTools && ( if (hasTools) {
<> sections.push({
header: (
<Chevron <Chevron
count={groups.length} count={groups.length}
onClick={shift => shift ? expandAll() : setOpenTools(v => !v)} onClick={shift => {
open={openTools} if (shift) {
expandAll()
} else {
setOpenTools(v => !v)
}
}}
open={detailsMode === 'expanded' || openTools}
suffix={toolTokensLabel} suffix={toolTokensLabel}
t={t} t={t}
title="Tool calls" title="Tool calls"
/> />
{openTools && toolBlock} ),
</> key: 'tools',
)} open: detailsMode === 'expanded' || openTools,
render: rails => (
<Box flexDirection="column">
{groups.map((group, index) => {
const branch: TreeBranch = index === groups.length - 1 ? 'last' : 'mid'
const childRails = nextTreeRails(rails, branch)
const hasInlineSubagents = inlineDelegateKey === group.key
{hasSubagents && ( return (
<Box flexDirection="column" key={group.key}>
<TreeTextRow
branch={branch}
color={group.color}
content={
<> <>
<Text color={t.color.amber as any}> </Text>
{group.content}
</>
}
rails={rails}
t={t}
/>
{group.details.map((detail, detailIndex) => (
<Detail
{...detail}
branch={detailIndex === group.details.length - 1 && !hasInlineSubagents ? 'last' : 'mid'}
key={detail.key}
rails={childRails}
t={t}
/>
))}
{hasInlineSubagents ? renderSubagentList(childRails) : null}
</Box>
)
})}
</Box>
)
})
}
if (hasSubagents && !inlineDelegateKey) {
sections.push({
header: (
<Chevron <Chevron
count={subagents.length} count={subagents.length}
onClick={shift => { onClick={shift => {
@ -642,29 +888,84 @@ export const ToolTrail = memo(function ToolTrail({
setDeepSubagents(false) setDeepSubagents(false)
} }
}} }}
open={openSubagents} open={detailsMode === 'expanded' || openSubagents}
t={t} t={t}
title="Subagents" title="Subagents"
/> />
{openSubagents && subagentBlock} ),
</> key: 'subagents',
)} open: detailsMode === 'expanded' || openSubagents,
render: renderSubagentList
})
}
{hasMeta && ( if (hasMeta) {
<> sections.push({
header: (
<Chevron <Chevron
count={meta.length} count={meta.length}
onClick={shift => shift ? expandAll() : setOpenMeta(v => !v)} onClick={shift => {
open={openMeta} if (shift) {
expandAll()
} else {
setOpenMeta(v => !v)
}
}}
open={detailsMode === 'expanded' || openMeta}
t={t} t={t}
title="Activity" title="Activity"
tone={metaTone} tone={metaTone}
/> />
{openMeta && metaBlock} ),
</> key: 'meta',
)} open: detailsMode === 'expanded' || openMeta,
render: rails => (
<Box flexDirection="column">
{meta.map((row, index) => (
<TreeTextRow
branch={index === meta.length - 1 ? 'last' : 'mid'}
color={row.color}
content={row.content}
dimColor={row.dimColor}
key={row.key}
rails={rails}
t={t}
/>
))}
</Box>
)
})
}
{totalBlock} const topCount = sections.length + (totalTokensLabel ? 1 : 0)
return (
<Box flexDirection="column">
{sections.map((section, index) => (
<TreeNode
branch={index === topCount - 1 ? 'last' : 'mid'}
header={section.header}
key={section.key}
open={section.open}
t={t}
>
{section.render}
</TreeNode>
))}
{totalTokensLabel ? (
<TreeTextRow
branch="last"
color={t.color.statusFg}
content={
<>
<Text color={t.color.amber as any}>Σ </Text>
{totalTokensLabel}
</>
}
dimColor
t={t}
/>
) : null}
</Box> </Box>
) )
}) })