mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-11 03:31:55 +00:00
Parity with the classic CLI status bar (PR #18579). The Python backend already exposes 'compressions' on SessionUsageResponse; this wires it through the Ink Usage type and renders 'cmp N' next to the duration segment of StatusRule. - types.ts Usage: add optional compressions field - appChrome.tsx StatusRule: render 'cmp N' when > 0, color-tiered by pressure (muted <5, warn 5-9, error 10+) - Plain text 'cmp' token (no emoji) matches PR #18579's original author rationale and avoids Ink layout drift from VS16 emoji width
486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
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 (
|
|
<Text color={color}>
|
|
{frame}
|
|
{verbSegment}
|
|
{durationSegment}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Text color={color}>
|
|
{atCap ? ' │ ⚠ ' : ' │ '}
|
|
{pieces.join(' ')}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
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 <Text color={color}>♥</Text>
|
|
}
|
|
|
|
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 (
|
|
<Box height={1}>
|
|
<Box flexShrink={1} width={leftWidth}>
|
|
<Text color={t.color.border} wrap="truncate-end">
|
|
{'─ '}
|
|
{busy ? (
|
|
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
|
|
) : (
|
|
<Text color={statusColor}>{status}</Text>
|
|
)}
|
|
<Text color={t.color.muted}> │ {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
|
|
{ctxLabel ? <Text color={t.color.muted}> │ {ctxLabel}</Text> : null}
|
|
{bar ? (
|
|
<Text color={t.color.muted}>
|
|
{' │ '}
|
|
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
|
</Text>
|
|
) : null}
|
|
{sessionStartedAt ? (
|
|
<Text color={t.color.muted}>
|
|
{' │ '}
|
|
<SessionDuration startedAt={sessionStartedAt} />
|
|
</Text>
|
|
) : null}
|
|
{typeof usage.compressions === 'number' && usage.compressions > 0 ? (
|
|
<Text color={t.color.muted}>
|
|
{' │ '}
|
|
<Text color={usage.compressions >= 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted}>
|
|
cmp {usage.compressions}
|
|
</Text>
|
|
</Text>
|
|
) : null}
|
|
<SpawnHud t={t} />
|
|
{voiceLabel ? (
|
|
<Text
|
|
color={
|
|
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.muted
|
|
}
|
|
>
|
|
{' │ '}
|
|
{voiceLabel}
|
|
</Text>
|
|
) : null}
|
|
{bgCount > 0 ? <Text color={t.color.muted}> │ {bgCount} bg</Text> : null}
|
|
{showCost && typeof usage.cost_usd === 'number' ? (
|
|
<Text color={t.color.muted}> │ ${usage.cost_usd.toFixed(4)}</Text>
|
|
) : null}
|
|
</Text>
|
|
</Box>
|
|
|
|
<Text color={t.color.border}> ─ </Text>
|
|
<Text color={t.color.label}>{cwdLabel}</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export function FloatBox({ children, color }: { children: ReactNode; color: string }) {
|
|
return (
|
|
<Box
|
|
alignSelf="flex-start"
|
|
borderColor={color}
|
|
borderStyle="double"
|
|
flexDirection="column"
|
|
marginTop={1}
|
|
opaque
|
|
paddingX={1}
|
|
>
|
|
{children}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
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<number | null>(null)
|
|
const grabRef = useRef<number | null>(null)
|
|
const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef)
|
|
|
|
if (!vp) {
|
|
return <Box width={1} />
|
|
}
|
|
|
|
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 (
|
|
<Box
|
|
flexDirection="column"
|
|
onMouseDown={(e: { localRow?: number }) => {
|
|
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 ? (
|
|
<Text color={trackColor} dim>
|
|
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
|
|
</Text>
|
|
) : (
|
|
<>
|
|
{thumbTop > 0 ? (
|
|
<Text color={trackColor} dim={!hover}>
|
|
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
|
|
</Text>
|
|
) : null}
|
|
{thumb > 0 ? (
|
|
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
|
|
) : null}
|
|
{vp - thumbTop - thumb > 0 ? (
|
|
<Text color={trackColor} dim={!hover}>
|
|
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
|
|
</Text>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
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<number>
|
|
onChange: (text: string) => void
|
|
scrollRef: RefObject<ScrollBoxHandle | null>
|
|
}
|
|
|
|
interface TranscriptScrollbarProps {
|
|
scrollRef: RefObject<ScrollBoxHandle | null>
|
|
t: Theme
|
|
}
|