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 { mix } from '../lib/color.js' import { flat } from '../lib/text.js' import type { Theme } from '../theme.js' import type { PanelSection, SessionInfo } from '../types.js' import { Accordion } from './accordion.js' import { ShimmerRows } from './loaders.js' import { WidgetGrid } from './widgetGrid.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][] }) { // No `opaque`: the banner is top-level content with nothing behind it, so // it never needs the opaque space-fill (that's for absolute overlays). On a // transparent terminal (terminal.background #00000000) the fill's "default // background" spaces composite to black bars instead of the intended // see-through — the reported ugly banner. Glyphs paint fine on their own. return ( {lines.map(([c, text], i) => ( {text} ))} ) } // Responsive Banner: full art → compact rule → text → hidden. // // Terminals can't scale glyphs, so "responsive" means picking a layout that // fits the available columns. Thresholds are picked so each tier reads // comfortably without forcing wrap or truncation drift on box-drawing edges. const TAG_FULL = 'Nous Research · Messenger of the Digital Gods' const TAG_MID = 'Messenger of the Digital Gods' const TAG_TINY = 'Nous Research' const HIDE_BELOW = 34 const COMPACT_FROM = 58 const clip = (s: string, w: number) => (w <= 0 ? '' : s.length > w ? `${s.slice(0, Math.max(0, w - 1))}…` : s) const centerIn = (s: string, w: number) => { const f = clip(s, w) const slack = Math.max(0, w - f.length) const left = slack >> 1 return `${' '.repeat(left)}${f}${' '.repeat(slack - left)}` } const ruleIn = (label: string, w: number) => { const f = clip(label, Math.max(1, w - 4)) const slack = Math.max(0, w - f.length - 2) const left = slack >> 1 return `${'─'.repeat(left)} ${f} ${'─'.repeat(slack - left)}` } function CompactBanner({ cols, t }: { cols: number; t: Theme }) { // -4 keeps a margin so exact-edge rows don't trip terminal pending-wrap. const w = Math.max(28, cols - 4) // No `opaque` (see ArtLines): the dashed rules are glyphs and the tagline's // centering spaces carry the text's own fg style, so every cell paints with // a real see-through background. The opaque fill was writing default-bg // spaces that a transparent terminal renders as black bars. // NOT bold: on Cursor's transparent-background terminal, a full-width run // of BOLD box-drawing dashes renders with an opaque black cell background // (the plain-dash rule right below renders clean — pixel-diffed live; the // only stylistic delta was bold). Bold on short label runs is fine; bold on // full-width box-drawing rows is what triggers the slab. return ( {ruleIn(t.brand.name, w)} {centerIn(TAG_FULL, w)} {'─'.repeat(w)} ) } export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) { const term = useStdout().stdout?.columns ?? 80 const cols = Math.max(1, Math.min(term, maxWidth ?? term)) if (cols < HIDE_BELOW) { return null } const logoLines = logo(t.color, t.bannerLogo || undefined) const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH // Each tier renders its rows through a single-column WidgetGrid sized to // the available columns — same visual output as the old plain flex column // (cells clip where truncate-end used to), but the banner is now a // layout-engine surface. if (cols >= logoW + 2) { return ( , id: 'banner-art' }, { children: ( {t.brand.icon} {TAG_FULL} ), id: 'banner-tagline' } ]} /> ) } if (cols >= COMPACT_FROM) { return ( , id: 'banner-compact' }]} /> ) } const name = cols >= 52 ? t.brand.name : (t.brand.name.split(' ')[0] ?? t.brand.name) const tag = cols >= 64 ? TAG_FULL : cols >= 46 ? TAG_MID : TAG_TINY return ( {t.brand.icon} {name} ), id: 'banner-name' }, { children: ( {t.brand.icon} {tag} ), id: 'banner-tag' } ]} /> ) } // ── Skeleton ───────────────────────────────────────────────────────── // // Lazy sections render shimmer rows shaped like the real content (label // block + value run) instead of a blank gap that pops when data lands. // Row widths mirror the typical toolsets listing. const SKELETON_ROWS: readonly (readonly [number, number])[] = [ [7, 30], [7, 9], [14, 12], [12, 12], [7, 7], [10, 13] ] // ── SessionPanel ───────────────────────────────────────────────────── const SKILLS_MAX = 8 const TOOLSETS_MAX = 8 export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { const term = useStdout().stdout?.columns ?? 100 const cols = Math.max(20, Math.min(term, maxWidth ?? term)) 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) // Hierarchy: labels lead in the label tone; member lists recede in the // muted/text midpoint. Anchoring on MUTED (mid-luminance by construction) // keeps the fade readable on both poles even when polarity detection is // wrong — surface-relative blends go invisible when text is already pale. const listFade = mix(t.color.muted, t.color.text, 0.5) // ── Local collapse state for each section ── const [toolsOpen, setToolsOpen] = useState(true) const [skillsOpen, setSkillsOpen] = useState(false) const [systemOpen, setSystemOpen] = useState(false) const [mcpOpen, setMcpOpen] = useState(false) 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 } // ── Collapsible skills section ── const skillEntries = Object.entries(info.skills).sort() const skillsTotal = flat(info.skills).length const skillsCatCount = skillEntries.length const skillsBody = () => { if (info.lazy && skillEntries.length === 0) { return } const shown = skillEntries.slice(0, SKILLS_MAX) const overflow = skillEntries.length - SKILLS_MAX return ( <> {shown.map(([k, vs]) => ( {strip(k)}: {truncLine(strip(k) + ': ', vs)} ))} {overflow > 0 && (and {overflow} more categories…)} ) } // ── Collapsible tools section ── const toolEntries = Object.entries(info.tools).sort() const toolsTotal = flat(info.tools).length // MCP headline counts *connected* servers, not configured-but-disabled ones, // so it matches the classic CLI banner (`sum(s.connected)` in // hermes_cli/banner.py) and the "connected" label on the collapse toggle. const mcpServers = info.mcp_servers ?? [] const mcpConnected = mcpServers.filter(s => s.connected).length const toolsBody = () => { if (info.lazy && toolEntries.length === 0) { return } const shown = toolEntries.slice(0, TOOLSETS_MAX) const overflow = toolEntries.length - TOOLSETS_MAX return ( <> {shown.map(([k, vs]) => ( {strip(k)}: {truncLine(strip(k) + ': ', vs)} ))} {overflow > 0 && (and {overflow} more toolsets…)} ) } // ── Collapsible MCP section ── const mcpBody = () => ( <> {(info.mcp_servers ?? []).map(s => ( {` ${s.name} `} {`[${s.transport}]`} : {s.connected ? ( {s.tools} tool{s.tools === 1 ? '' : 's'} ) : s.disabled || s.status === 'disabled' ? ( disabled ) : s.status === 'connecting' ? ( connecting ) : s.status === 'configured' ? ( configured ) : ( failed )} ))} ) // ── System prompt body ── const sysPromptLen = (info.system_prompt ?? '').length const systemBody = () => { if (sysPromptLen === 0) { return No system prompt loaded. } return {info.system_prompt} } // The wide layout is a real two-column grid: a fixed-width hero track and a // flexible info track (grid-template-columns: 1fr, gap 2) — the // terminal equivalent of the desktop pane shell's fixed-vs-flex tracks. // Narrow drops to a single flexible track. Track math reproduces the old // hand-rolled widths exactly: usable = (leftW + 2 + w) - gap = leftW + w. const heroColumn = wide ? ( {info.model.split('/').pop()} · Nous Research {info.cwd || process.cwd()} {sid && ( Session: {sid} )} ) : null const infoColumn = ( {wide ? ( {t.brand.name} {info.version ? ` v${info.version}` : ''} {info.release_date ? ` (${info.release_date})` : ''} ) : ( // Narrow layout hides the hero column; surface model/cwd/session // here so they aren't lost. {info.model.split('/').pop()} · Nous Research {info.cwd || process.cwd()} {sid && ( Session: {sid} )} )} {/* ── Tools (expanded by default) ── */} setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools"> {toolsBody()} {/* ── Skills (collapsed by default) ── */} setSkillsOpen(v => !v)} open={skillsOpen} suffix={skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined} t={t} title="Available Skills" > {skillsBody()} {/* ── System Prompt (collapsed by default) ── */} {sysPromptLen > 0 && ( setSystemOpen(v => !v)} open={systemOpen} suffix={`— ${sysPromptLen.toLocaleString()} chars`} t={t} title="System Prompt" > {systemBody()} )} {/* ── MCP Servers (collapsed by default) ── */} {mcpServers.length > 0 && ( setMcpOpen(v => !v)} open={mcpOpen} suffix="connected" t={t} title="MCP Servers" > {mcpBody()} )} {/* Lazy boot: never print "0 tools · 0 skills" while counts load. */} {info.lazy && !toolsTotal ? '… ' : `${toolsTotal} `}tools{' · '} {info.lazy && !skillsTotal ? '… ' : `${skillsTotal} `}skills {mcpConnected ? ` · ${mcpConnected} 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 )} {info.install_warning && ( ! {info.install_warning} )} ) return ( ) } 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 maxWidth?: number sid?: string | null t: Theme }