import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react' import unicodeSpinners from 'unicode-animations' import { $delegationState } from '../app/delegationStore.js' import type { IndicatorStyle } from '../app/interfaces.js' import { useTurnSelector } from '../app/turnStore.js' import { $uiState } from '../app/uiStore.js' import { FACES } from '../content/faces.js' import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' import { stickyPromptFromViewport } from '../domain/viewport.js' import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js' import { fmtK } from '../lib/text.js' import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js' import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' const FACE_TICK_MS = 2500 const HEART_COLORS = ['#ff5fa2', '#ff4d6d'] // Keep verb segment width stable so status-bar content to the right doesn't // jitter when the ticker rotates between short/long verbs. export const VERB_PAD_LEN = VERBS.reduce((max, v) => Math.max(max, v.length), 0) + 1 // + ellipsis export const DURATION_PAD_LEN = 7 // e.g. " 9s", "1m 05s", "59m 59s" export const padVerb = (verb: string) => `${verb}โ€ฆ`.padEnd(VERB_PAD_LEN, ' ') export const padTickerDuration = (ms: number) => fmtDuration(ms).padStart(DURATION_PAD_LEN, ' ') // Compact alternates for the `emoji` and `ascii` indicator styles. // Each entry is a fixed-width (display-width) glyph. const EMOJI_FRAMES = ['โš• ', '๐ŸŒ€', '๐Ÿค”', 'โœจ', '๐Ÿต', '๐Ÿ”ฎ'] const ASCII_FRAMES = ['|', '/', '-', '\\'] // Faster tick for spinner-style indicators โ€” they read as motion only // at frame rates closer to their authored interval. const SPINNER_TICK_MS = 100 interface IndicatorRender { frame: string intervalMs: number // When false, FaceTicker hides the rotating verb and just shows the // glyph + duration. Lets `unicode` stay minimal while the other // styles keep the verb-rotation flavour users associate with the // runningโ€ฆ status. showVerb: boolean } const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender => { if (style === 'kaomoji') { return { frame: FACES[tick % FACES.length] ?? '', intervalMs: FACE_TICK_MS, showVerb: true } } if (style === 'emoji') { return { frame: EMOJI_FRAMES[tick % EMOJI_FRAMES.length] ?? 'โš• ', intervalMs: SPINNER_TICK_MS * 6, showVerb: true } } if (style === 'ascii') { return { frame: ASCII_FRAMES[tick % ASCII_FRAMES.length] ?? '|', intervalMs: SPINNER_TICK_MS, showVerb: true } } // 'unicode' โ€” braille spinner (fixed 1-col). Authored interval is // ~80ms; honour it but bound below at a safe minimum so React // re-renders stay reasonable. This style is for users who want // the cleanest possible status, so no verb rotation either. const spinner = unicodeSpinners.braille const frame = spinner.frames[tick % spinner.frames.length] ?? 'โ ‹' return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false } } function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) { const ui = useStore($uiState) const style = ui.indicatorStyle const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000)) const [verbTick, setVerbTick] = useState(() => Math.floor(Math.random() * VERBS.length)) const [now, setNow] = useState(() => Date.now()) // Pre-compute cadence + verb-visibility for the active style so an // `/indicator` switch re-arms the interval (and skips the verb timer // for verb-less styles like `unicode`) without leaving the previous // timer dangling. const { intervalMs, showVerb } = renderIndicator(style, 0) useEffect(() => { const glyph = setInterval(() => setTick(n => n + 1), intervalMs) const clock = setInterval(() => setNow(Date.now()), 1000) // Verb timer is gated on `showVerb` โ€” `unicode` style hides the verb // entirely, so cycling `verbTick` would be an avoidable re-render. const verb = showVerb ? setInterval(() => setVerbTick(n => n + 1), FACE_TICK_MS) : null return () => { clearInterval(glyph) clearInterval(clock) if (verb !== null) { clearInterval(verb) } } }, [intervalMs, showVerb]) const { frame } = renderIndicator(style, tick) const verb = VERBS[verbTick % VERBS.length] ?? '' const verbSegment = showVerb ? ` ${padVerb(verb)}` : '' // Leading space keeps a gap between the frame and the duration when the // verb segment is hidden (e.g. `unicode` spinner style). When the verb // IS shown, its trailing padding already provides the gap, so the extra // space is harmless. const durationSegment = startedAt ? ` ยท ${padTickerDuration(now - startedAt)}` : '' return ( {frame} {verbSegment} {durationSegment} ) } function ctxBarColor(pct: number | undefined, t: Theme) { if (pct == null) { return t.color.muted } if (pct >= 95) { return t.color.statusCritical } if (pct > 80) { return t.color.statusBad } if (pct >= 50) { return t.color.statusWarn } return t.color.statusGood } function ctxBar(pct: number | undefined, w = 10) { const p = Math.max(0, Math.min(100, pct ?? 0)) const filled = Math.round((p / 100) * w) return 'โ–ˆ'.repeat(filled) + 'โ–‘'.repeat(w - filled) } function SpawnHud({ t }: { t: Theme }) { // Tight HUD that only appears when the session is actually fanning out. // Colour escalates to warn/error as depth or concurrency approaches the cap. const delegation = useStore($delegationState) const subagents = useTurnSelector(state => state.subagents) const tree = useMemo(() => buildSubagentTree(subagents), [subagents]) const totals = useMemo(() => treeTotals(tree), [tree]) if (!totals.descendantCount && !delegation.paused) { return null } const maxDepth = delegation.maxSpawnDepth const maxConc = delegation.maxConcurrentChildren const depth = Math.max(0, totals.maxDepthFromHere) const active = totals.activeCount // `max_concurrent_children` is a per-parent cap, not a global one. // `activeCount` sums every running agent across the tree and would // over-warn for multi-orchestrator runs. The widest level of the tree // is a closer proxy to "most concurrent spawns that could be hitting a // single parent's slot budget". const widestLevel = widthByDepth(tree).reduce((a, b) => Math.max(a, b), 0) const depthRatio = maxDepth ? depth / maxDepth : 0 const concRatio = maxConc ? widestLevel / maxConc : 0 const ratio = Math.max(depthRatio, concRatio) const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.muted const pieces: string[] = [] if (delegation.paused) { pieces.push('โธ paused') } if (totals.descendantCount > 0) { const depthLabel = maxDepth ? `${depth}/${maxDepth}` : `${depth}` pieces.push(`d${depthLabel}`) if (active > 0) { // Label pairs the widest-level count (drives concRatio above) with // the total active count for context. `W/cap` triggers the warn, // `+N` is everything else currently running across the tree. const extra = Math.max(0, active - widestLevel) const widthLabel = maxConc ? `${widestLevel}/${maxConc}` : `${widestLevel}` const suffix = extra > 0 ? `+${extra}` : '' pieces.push(`โšก${widthLabel}${suffix}`) } } const atCap = depthRatio >= 1 || concRatio >= 1 return ( {atCap ? ' โ”‚ โš  ' : ' โ”‚ '} {pieces.join(' ')} ) } function SessionDuration({ startedAt }: { startedAt: number }) { const [now, setNow] = useState(() => Date.now()) useEffect(() => { setNow(Date.now()) const id = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(id) }, [startedAt]) return fmtDuration(now - startedAt) } const effortLabel = (effort?: string) => { const value = String(effort ?? '') .trim() .toLowerCase() return value && value !== 'medium' && value !== 'normal' && value !== 'default' ? value : '' } const shortModelLabel = (model: string) => model .split('/') .pop()! .replace(/^claude[-_]/, '') .replace(/^anthropic[-_]/, '') .replace(/[-_]/g, ' ') .replace(/\b(\d+)\s+(\d+)\b/g, '$1.$2') .trim() const modelLabel = (model: string, effort?: string, fast?: boolean) => [shortModelLabel(model), effortLabel(effort), fast ? 'fast' : ''].filter(Boolean).join(' ') export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { const [active, setActive] = useState(false) const [color, setColor] = useState(t.color.accent) useEffect(() => { if (tick <= 0) { return } const palette = [t.color.error, t.color.warn, t.color.accent] setColor(palette[Math.floor(Math.random() * palette.length)]!) setActive(true) const id = setTimeout(() => setActive(false), 650) return () => clearTimeout(id) }, [t.color.accent, tick]) if (!active) { return null } return โ™ฅ } export function StatusRule({ cwdLabel, cols, busy, status, statusColor, model, modelFast, modelReasoningEffort, usage, bgCount, sessionStartedAt, showCost, turnStartedAt, voiceLabel, t }: StatusRuleProps) { const pct = usage.context_percent const barColor = ctxBarColor(pct, t) const ctxLabel = usage.context_max ? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}` : usage.total > 0 ? `${fmtK(usage.total)} tok` : '' const bar = usage.context_max ? ctxBar(pct) : '' const leftWidth = Math.max(12, cols - cwdLabel.length - 3) return ( {'โ”€ '} {busy ? ( ) : ( {status} )} โ”‚ {modelLabel(model, modelReasoningEffort, modelFast)} {ctxLabel ? โ”‚ {ctxLabel} : null} {bar ? ( {' โ”‚ '} [{bar}] {pct != null ? `${pct}%` : ''} ) : null} {sessionStartedAt ? ( {' โ”‚ '} ) : null} {typeof usage.compressions === 'number' && usage.compressions > 0 ? ( {' โ”‚ '} = 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted}> cmp {usage.compressions} ) : null} {voiceLabel ? ( {' โ”‚ '} {voiceLabel} ) : null} {bgCount > 0 ? โ”‚ {bgCount} bg : null} {showCost && typeof usage.cost_usd === 'number' ? ( โ”‚ ${usage.cost_usd.toFixed(4)} ) : null} โ”€ {cwdLabel} ) } export function FloatBox({ children, color }: { children: ReactNode; color: string }) { return ( {children} ) } export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) { const { atBottom, bottom, top } = useViewportSnapshot(scrollRef) const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom) useEffect(() => onChange(text), [onChange, text]) return null } export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) { const [hover, setHover] = useState(false) const [grab, setGrab] = useState(null) const grabRef = useRef(null) const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef) if (!vp) { return } const s = scrollRef.current const scrollable = total > vp const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp const travel = Math.max(1, vp - thumb) const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 const thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border const trackColor = hover ? t.color.border : t.color.muted const jump = (row: number, offset: number) => { if (!s || !scrollable) { return } s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp))) } return ( { const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0)) const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2) grabRef.current = off setGrab(off) jump(row, off) }} onMouseDrag={(e: { localRow?: number }) => jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grabRef.current ?? Math.floor(thumb / 2)) } onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onMouseUp={() => { grabRef.current = null setGrab(null) }} width={1} > {!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 ? 'โ”ƒ' : ''}`} ) : null} {vp - thumbTop - thumb > 0 ? ( {`${'โ”‚\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? 'โ”‚' : ''}`} ) : null} )} ) } interface StatusRuleProps { bgCount: number busy: boolean cols: number cwdLabel: string model: string modelFast?: boolean modelReasoningEffort?: string sessionStartedAt?: null | number showCost: boolean status: string statusColor: string t: Theme turnStartedAt?: null | number usage: Usage voiceLabel?: string } interface StickyPromptTrackerProps { messages: readonly Msg[] offsets: ArrayLike onChange: (text: string) => void scrollRef: RefObject } interface TranscriptScrollbarProps { scrollRef: RefObject t: Theme }