import { Box, Text, useStdout } from '@hermes/ink' import { useEffect, useState } from 'react' import unicodeSpinners from 'unicode-animations' import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js' import { flat } from '../lib/text.js' import type { Theme } from '../theme.js' import type { PanelSection, SessionInfo } from '../types.js' const LOADER_TICK_MS = 120 function InlineLoader({ label, t }: { label: string; t: Theme }) { const [tick, setTick] = useState(0) const spinner = unicodeSpinners.braille const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋' useEffect(() => { const id = setInterval(() => setTick(n => n + 1), Math.max(LOADER_TICK_MS, spinner.interval)) return () => clearInterval(id) }, [spinner.interval]) return ( {frame} {label} ) } export function ArtLines({ lines }: { lines: [string, string][] }) { return ( <> {lines.map(([c, text], i) => ( {text} ))} ) } export function Banner({ t }: { t: Theme }) { const cols = useStdout().stdout?.columns ?? 80 const logoLines = logo(t.color, t.bannerLogo || undefined) return ( {cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? ( ) : ( {t.brand.icon} NOUS HERMES )} {t.brand.icon} Nous Research · Messenger of the Digital Gods ) } export function SessionPanel({ info, sid, t }: SessionPanelProps) { const cols = useStdout().stdout?.columns ?? 100 const heroLines = caduceus(t.color, t.bannerHero || undefined) const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4)) const wide = cols >= 90 && leftW + 40 < cols const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12) const lineBudget = Math.max(12, w - 2) const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) const truncLine = (pfx: string, items: string[]) => { let line = '' let shown = 0 for (const item of [...items].sort()) { const next = line ? `${line}, ${item}` : item if (pfx.length + next.length > lineBudget) { return line ? `${line}, …+${items.length - shown}` : `${item}, …` } line = next shown++ } return line } const section = (title: string, data: Record, max = 8, overflowLabel = 'more…') => { const entries = Object.entries(data).sort() const shown = entries.slice(0, max) const overflow = entries.length - max const skeleton = info.lazy && entries.length === 0 return ( Available {title} {skeleton ? ( ) : ( shown.map(([k, vs]) => ( {strip(k)}: {truncLine(strip(k) + ': ', vs)} )) )} {overflow > 0 && ( (and {overflow} {overflowLabel}) )} ) } return ( {wide && ( {info.model.split('/').pop()} · Nous Research {info.cwd || process.cwd()} {sid && ( Session: {sid} )} )} {t.brand.name} {info.version ? ` v${info.version}` : ''} {info.release_date ? ` (${info.release_date})` : ''} {section('Tools', info.tools, 8, 'more toolsets…')} {section('Skills', info.skills)} {info.mcp_servers && info.mcp_servers.length > 0 && ( MCP Servers {info.mcp_servers.map(s => ( {` ${s.name} `} {`[${s.transport}]`} : {s.connected ? ( {s.tools} tool{s.tools === 1 ? '' : 's'} ) : ( failed )} ))} )} {flat(info.tools).length} tools{' · '} {flat(info.skills).length} skills {info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''} {' · '} /help for commands {typeof info.update_behind === 'number' && info.update_behind > 0 && ( ! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind {' '} - run{' '} {info.update_command || 'hermes update'} {' '} to update )} ) } export function Panel({ sections, t, title }: PanelProps) { return ( {title} {sections.map((sec, si) => ( 0 ? 1 : 0}> {sec.title && ( {sec.title} )} {sec.rows?.map(([k, v], ri) => ( {k.padEnd(20)} {v} ))} {sec.items?.map((item, ii) => ( {item} ))} {sec.text && {sec.text}} ))} ) } interface PanelProps { sections: PanelSection[] t: Theme title: string } interface SessionPanelProps { info: SessionInfo sid?: string | null t: Theme }