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
)
}
// ── Collapsible helpers ──────────────────────────────────────────────
function CollapseToggle({
count,
open,
suffix,
t,
title,
onToggle
}: {
count?: number
open: boolean
suffix?: string
t: Theme
title: string
onToggle: () => void
}) {
return (
{open ? '▾ ' : '▸ '}
{title}
{typeof count === 'number' ? (
({count})
) : null}
{suffix ? (
{suffix}
) : null}
)
}
// ── SessionPanel ─────────────────────────────────────────────────────
const SKILLS_MAX = 8
const TOOLSETS_MAX = 8
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)
// ── 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
const toolsBody = () => {
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'}
) : (
failed
)}
))}
>
)
// ── System prompt body ──
const sysPromptLen = (info.system_prompt ?? '').length
const systemBody = () => {
if (sysPromptLen === 0) {
return No system prompt loaded.
}
return (
{info.system_prompt}
)
}
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})` : ''}
{/* ── Tools (expanded by default) ── */}
setToolsOpen(v => !v)}
open={toolsOpen}
t={t}
title="Available Tools"
/>
{toolsOpen && 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"
/>
{skillsOpen && skillsBody()}
{/* ── System Prompt (collapsed by default) ── */}
{sysPromptLen > 0 && (
setSystemOpen(v => !v)}
open={systemOpen}
suffix={`— ${sysPromptLen.toLocaleString()} chars`}
t={t}
title="System Prompt"
/>
{systemOpen && systemBody()}
)}
{/* ── MCP Servers (collapsed by default) ── */}
{info.mcp_servers && info.mcp_servers.length > 0 && (
setMcpOpen(v => !v)}
open={mcpOpen}
suffix="connected"
t={t}
title="MCP Servers"
/>
{mcpOpen && mcpBody()}
)}
{toolsTotal} tools{' · '}
{skillsTotal} 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
}