mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-13 03:52:00 +00:00
The TUI SessionPanel banner now uses collapsible \u25b8/\u25be toggle sections matching the existing Chevron convention used for runtime agent details. Skills, system prompt, and MCP server lists are collapsed by default; tools remain expanded as the most actionable info. - tui_gateway/server.py: _session_info() now passes agent._cached_system_prompt through to the TUI frontend - ui-tui/src/types.ts: added system_prompt?: string to SessionInfo - ui-tui/src/components/branding.tsx: rewrote SessionPanel with CollapseToggle helper + per-section useState toggles Default states: tools=open, skills=collapsed, system=collapsed, mcp=collapsed. Clicking any \u25b8/\u25be header toggles that section.
383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
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 (
|
|
<Text color={t.color.muted} wrap="truncate">
|
|
<Text color={t.color.accent}>{frame}</Text> {label}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
export function ArtLines({ lines }: { lines: [string, string][] }) {
|
|
return (
|
|
<>
|
|
{lines.map(([c, text], i) => (
|
|
<Text color={c} key={i}>
|
|
{text}
|
|
</Text>
|
|
))}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function Banner({ t }: { t: Theme }) {
|
|
const cols = useStdout().stdout?.columns ?? 80
|
|
const logoLines = logo(t.color, t.bannerLogo || undefined)
|
|
|
|
return (
|
|
<Box flexDirection="column" marginBottom={1}>
|
|
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
|
|
<ArtLines lines={logoLines} />
|
|
) : (
|
|
<Text bold color={t.color.primary}>
|
|
{t.brand.icon} NOUS HERMES
|
|
</Text>
|
|
)}
|
|
|
|
<Text color={t.color.muted}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// ── Collapsible helpers ──────────────────────────────────────────────
|
|
|
|
function CollapseToggle({
|
|
count,
|
|
open,
|
|
suffix,
|
|
t,
|
|
title,
|
|
onToggle
|
|
}: {
|
|
count?: number
|
|
open: boolean
|
|
suffix?: string
|
|
t: Theme
|
|
title: string
|
|
onToggle: () => void
|
|
}) {
|
|
return (
|
|
<Box onClick={onToggle}>
|
|
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
|
|
<Text bold color={t.color.accent}>
|
|
{title}
|
|
</Text>
|
|
{typeof count === 'number' ? (
|
|
<Text color={t.color.muted}> ({count})</Text>
|
|
) : null}
|
|
{suffix ? (
|
|
<Text color={t.color.muted}> {suffix}</Text>
|
|
) : null}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// ── 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 <InlineLoader label="scanning skills" t={t} />
|
|
}
|
|
|
|
const shown = skillEntries.slice(0, SKILLS_MAX)
|
|
const overflow = skillEntries.length - SKILLS_MAX
|
|
|
|
return (
|
|
<>
|
|
{shown.map(([k, vs]) => (
|
|
<Text key={k} wrap="truncate">
|
|
<Text color={t.color.muted}>{strip(k)}: </Text>
|
|
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
|
|
</Text>
|
|
))}
|
|
{overflow > 0 && (
|
|
<Text color={t.color.muted}>(and {overflow} more categories…)</Text>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ── 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]) => (
|
|
<Text key={k} wrap="truncate">
|
|
<Text color={t.color.muted}>{strip(k)}: </Text>
|
|
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
|
|
</Text>
|
|
))}
|
|
{overflow > 0 && (
|
|
<Text color={t.color.muted}>(and {overflow} more toolsets…)</Text>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ── Collapsible MCP section ──
|
|
const mcpBody = () => (
|
|
<>
|
|
{(info.mcp_servers ?? []).map(s => (
|
|
<Text key={s.name} wrap="truncate">
|
|
<Text color={t.color.muted}>{` ${s.name} `}</Text>
|
|
<Text color={t.color.muted}>{`[${s.transport}]`}</Text>
|
|
<Text color={t.color.muted}>: </Text>
|
|
{s.connected ? (
|
|
<Text color={t.color.text}>
|
|
{s.tools} tool{s.tools === 1 ? '' : 's'}
|
|
</Text>
|
|
) : (
|
|
<Text color={t.color.error}>failed</Text>
|
|
)}
|
|
</Text>
|
|
))}
|
|
</>
|
|
)
|
|
|
|
// ── System prompt body ──
|
|
const sysPromptLen = (info.system_prompt ?? '').length
|
|
|
|
const systemBody = () => {
|
|
if (sysPromptLen === 0) {
|
|
return <Text color={t.color.muted}>No system prompt loaded.</Text>
|
|
}
|
|
|
|
return (
|
|
<Text color={t.color.muted}>
|
|
{info.system_prompt}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
|
|
{wide && (
|
|
<Box flexDirection="column" marginRight={2} width={leftW}>
|
|
<ArtLines lines={heroLines} />
|
|
<Text />
|
|
|
|
<Text color={t.color.accent}>
|
|
{info.model.split('/').pop()}
|
|
<Text color={t.color.muted}> · Nous Research</Text>
|
|
</Text>
|
|
|
|
<Text color={t.color.muted} wrap="truncate-end">
|
|
{info.cwd || process.cwd()}
|
|
</Text>
|
|
|
|
{sid && (
|
|
<Text>
|
|
<Text color={t.color.sessionLabel}>Session: </Text>
|
|
<Text color={t.color.sessionBorder}>{sid}</Text>
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
<Box flexDirection="column" width={w}>
|
|
<Box justifyContent="center" marginBottom={1}>
|
|
<Text bold color={t.color.primary}>
|
|
{t.brand.name}
|
|
{info.version ? ` v${info.version}` : ''}
|
|
{info.release_date ? ` (${info.release_date})` : ''}
|
|
</Text>
|
|
</Box>
|
|
|
|
{/* ── Tools (expanded by default) ── */}
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<CollapseToggle
|
|
onToggle={() => setToolsOpen(v => !v)}
|
|
open={toolsOpen}
|
|
t={t}
|
|
title="Available Tools"
|
|
/>
|
|
{toolsOpen && toolsBody()}
|
|
</Box>
|
|
|
|
{/* ── Skills (collapsed by default) ── */}
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<CollapseToggle
|
|
count={skillsTotal}
|
|
onToggle={() => setSkillsOpen(v => !v)}
|
|
open={skillsOpen}
|
|
suffix={skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined}
|
|
t={t}
|
|
title="Available Skills"
|
|
/>
|
|
{skillsOpen && skillsBody()}
|
|
</Box>
|
|
|
|
{/* ── System Prompt (collapsed by default) ── */}
|
|
{sysPromptLen > 0 && (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<CollapseToggle
|
|
onToggle={() => setSystemOpen(v => !v)}
|
|
open={systemOpen}
|
|
suffix={`— ${sysPromptLen.toLocaleString()} chars`}
|
|
t={t}
|
|
title="System Prompt"
|
|
/>
|
|
{systemOpen && systemBody()}
|
|
</Box>
|
|
)}
|
|
|
|
{/* ── MCP Servers (collapsed by default) ── */}
|
|
{info.mcp_servers && info.mcp_servers.length > 0 && (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<CollapseToggle
|
|
count={info.mcp_servers.length}
|
|
onToggle={() => setMcpOpen(v => !v)}
|
|
open={mcpOpen}
|
|
suffix="connected"
|
|
t={t}
|
|
title="MCP Servers"
|
|
/>
|
|
{mcpOpen && mcpBody()}
|
|
</Box>
|
|
)}
|
|
|
|
<Text />
|
|
|
|
<Text color={t.color.text}>
|
|
{toolsTotal} tools{' · '}
|
|
{skillsTotal} skills
|
|
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
|
|
{' · '}
|
|
<Text color={t.color.muted}>/help for commands</Text>
|
|
</Text>
|
|
|
|
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
|
|
<Text bold color={t.color.warn}>
|
|
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
|
|
<Text bold={false} color={t.color.warn} dimColor>
|
|
{' '}
|
|
- run{' '}
|
|
</Text>
|
|
<Text bold color={t.color.warn}>
|
|
{info.update_command || 'hermes update'}
|
|
</Text>
|
|
<Text bold={false} color={t.color.warn} dimColor>
|
|
{' '}
|
|
to update
|
|
</Text>
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export function Panel({ sections, t, title }: PanelProps) {
|
|
return (
|
|
<Box borderColor={t.color.border} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
|
<Box justifyContent="center" marginBottom={1}>
|
|
<Text bold color={t.color.primary}>
|
|
{title}
|
|
</Text>
|
|
</Box>
|
|
|
|
{sections.map((sec, si) => (
|
|
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
|
|
{sec.title && (
|
|
<Text bold color={t.color.accent}>
|
|
{sec.title}
|
|
</Text>
|
|
)}
|
|
|
|
{sec.rows?.map(([k, v], ri) => (
|
|
<Text key={ri} wrap="truncate">
|
|
<Text color={t.color.muted}>{k.padEnd(20)}</Text>
|
|
<Text color={t.color.text}>{v}</Text>
|
|
</Text>
|
|
))}
|
|
|
|
{sec.items?.map((item, ii) => (
|
|
<Text color={t.color.text} key={ii} wrap="truncate">
|
|
{item}
|
|
</Text>
|
|
))}
|
|
|
|
{sec.text && <Text color={t.color.muted}>{sec.text}</Text>}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
interface PanelProps {
|
|
sections: PanelSection[]
|
|
t: Theme
|
|
title: string
|
|
}
|
|
|
|
interface SessionPanelProps {
|
|
info: SessionInfo
|
|
sid?: string | null
|
|
t: Theme
|
|
}
|