opentui(v5/item9): startup HERMES banner + collapsible tools/skills/MCP panel
Home screen now shows the canonical HERMES-AGENT block logo (hermes_cli/banner.py, gold->amber->bronze via primary/accent/border tokens; width-guarded to a compact brand line under 102 cols) plus a collapsible '▶ N tools · M skills · K MCP' panel that expands to per-toolset / per-category / per-server detail. Data comes from a new opt-in gateway RPC 'startup.catalog' (aggregates get_all_toolsets + banner.get_available_skills + config mcp_servers); the native engine fetches it best-effort on session start (Effect.catchCause swallows it on old gateways). Opt-in => Ink path untouched. py_compile OK. Store gains a typed Catalog + defensive setCatalog mapper. +2 tests (90 pass); verified live (1185 tools / 196 skills / 2 MCP, expand shows the full lists).
This commit is contained in:
parent
741a4c23ca
commit
4061e635d3
@ -8518,6 +8518,54 @@ def _(rid, params: dict) -> dict:
|
|||||||
return _err(rid, 5031, str(e))
|
return _err(rid, 5031, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@method("startup.catalog")
|
||||||
|
def _(rid, params: dict) -> dict:
|
||||||
|
# Aggregate tools / skills / MCP servers for the native engine's startup panel
|
||||||
|
# (item 9). Opt-in RPC — only the opentui home screen calls it, so the Ink path
|
||||||
|
# is untouched. Each section is best-effort: a failing source yields an empty
|
||||||
|
# section rather than erroring the whole call.
|
||||||
|
tools: dict = {"total": 0, "toolsets": []}
|
||||||
|
try:
|
||||||
|
from toolsets import get_all_toolsets, get_toolset_info
|
||||||
|
|
||||||
|
for name in sorted(get_all_toolsets().keys()):
|
||||||
|
info = get_toolset_info(name)
|
||||||
|
if not info:
|
||||||
|
continue
|
||||||
|
tools["toolsets"].append({"name": name, "count": int(info["tool_count"])})
|
||||||
|
tools["total"] += int(info["tool_count"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
skills: dict = {"total": 0, "categories": []}
|
||||||
|
try:
|
||||||
|
from hermes_cli.banner import get_available_skills
|
||||||
|
|
||||||
|
by_cat = get_available_skills() or {}
|
||||||
|
for cat in sorted(by_cat.keys()):
|
||||||
|
names = by_cat[cat] or []
|
||||||
|
skills["categories"].append({"name": cat, "count": len(names)})
|
||||||
|
skills["total"] += len(names)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mcp_servers: list = []
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import read_raw_config
|
||||||
|
from hermes_cli.tools_config import _parse_enabled_flag
|
||||||
|
|
||||||
|
raw_cfg = read_raw_config() or {}
|
||||||
|
servers = raw_cfg.get("mcp_servers")
|
||||||
|
if isinstance(servers, dict):
|
||||||
|
for name, cfg in servers.items():
|
||||||
|
if isinstance(cfg, dict) and _parse_enabled_flag(cfg.get("enabled", True), default=True):
|
||||||
|
mcp_servers.append(str(name))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _ok(rid, {"tools": tools, "skills": skills, "mcp": {"servers": sorted(mcp_servers)}})
|
||||||
|
|
||||||
|
|
||||||
@method("tools.show")
|
@method("tools.show")
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -130,6 +130,13 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp
|
|||||||
log.info('bootstrap', 'session created', { sid })
|
log.info('bootstrap', 'session created', { sid })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tools/skills/MCP catalog for the home-screen panel (item 9) — best-effort,
|
||||||
|
// never blocks startup if the RPC is missing/old.
|
||||||
|
const catalog = yield* gateway
|
||||||
|
.request<unknown>('startup.catalog', { session_id: sid })
|
||||||
|
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||||
|
if (catalog) store.setCatalog(catalog)
|
||||||
|
|
||||||
const prompt = input.initialPrompt?.trim()
|
const prompt = input.initialPrompt?.trim()
|
||||||
if (prompt) {
|
if (prompt) {
|
||||||
store.pushUser(prompt)
|
store.pushUser(prompt)
|
||||||
|
|||||||
@ -140,6 +140,13 @@ export interface SessionInfo {
|
|||||||
compressions?: number
|
compressions?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Startup catalog (tools/skills/MCP) for the home-screen panel (item 9). */
|
||||||
|
export interface Catalog {
|
||||||
|
readonly tools: { readonly total: number; readonly toolsets: ReadonlyArray<{ name: string; count: number }> }
|
||||||
|
readonly skills: { readonly total: number; readonly categories: ReadonlyArray<{ name: string; count: number }> }
|
||||||
|
readonly mcp: { readonly servers: ReadonlyArray<string> }
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreState {
|
export interface StoreState {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
@ -169,6 +176,8 @@ export interface StoreState {
|
|||||||
/** Transient hint shown above the composer (e.g. "Ctrl+C again to quit" — item 11);
|
/** Transient hint shown above the composer (e.g. "Ctrl+C again to quit" — item 11);
|
||||||
* takes visual priority over the busy `status` face. Undefined when none. */
|
* takes visual priority over the busy `status` face. Undefined when none. */
|
||||||
hint: string | undefined
|
hint: string | undefined
|
||||||
|
/** Startup tools/skills/MCP catalog (from `startup.catalog`) for the home panel (item 9). */
|
||||||
|
catalog: Catalog | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const LRU_LIMIT = 1000
|
const LRU_LIMIT = 1000
|
||||||
@ -254,7 +263,8 @@ export function createSessionStore() {
|
|||||||
dashboard: false,
|
dashboard: false,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
info: {},
|
info: {},
|
||||||
hint: undefined
|
hint: undefined,
|
||||||
|
catalog: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
||||||
@ -651,11 +661,34 @@ export function createSessionStore() {
|
|||||||
commitSnapshot(loadSnapshot())
|
commitSnapshot(loadSnapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map the loose `startup.catalog` response into the typed Catalog (item 9). */
|
||||||
|
function setCatalog(raw: unknown): void {
|
||||||
|
if (!raw || typeof raw !== 'object') return
|
||||||
|
const root = raw as { readonly [k: string]: unknown }
|
||||||
|
const obj = (v: unknown): { readonly [k: string]: unknown } =>
|
||||||
|
v && typeof v === 'object' ? (v as { readonly [k: string]: unknown }) : {}
|
||||||
|
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||||||
|
const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
|
||||||
|
const pair = (v: unknown) => {
|
||||||
|
const o = obj(v)
|
||||||
|
return { count: num(o.count), name: readStr(o, 'name') ?? '' }
|
||||||
|
}
|
||||||
|
const tools = obj(root.tools)
|
||||||
|
const skills = obj(root.skills)
|
||||||
|
const mcp = obj(root.mcp)
|
||||||
|
setState('catalog', {
|
||||||
|
mcp: { servers: list(mcp.servers).filter((s): s is string => typeof s === 'string') },
|
||||||
|
skills: { categories: list(skills.categories).map(pair).filter(p => p.name), total: num(skills.total) },
|
||||||
|
tools: { toolsets: list(tools.toolsets).map(pair).filter(p => p.name), total: num(tools.total) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
apply,
|
apply,
|
||||||
pushUser,
|
pushUser,
|
||||||
pushSystem,
|
pushSystem,
|
||||||
|
setCatalog,
|
||||||
clearTranscript,
|
clearTranscript,
|
||||||
setConfirm,
|
setConfirm,
|
||||||
openPager,
|
openPager,
|
||||||
|
|||||||
@ -253,6 +253,29 @@ describe('App render (Phase 1, themed)', () => {
|
|||||||
expect(frame).toContain('to mention') // the input tips line
|
expect(frame).toContain('to mention') // the input tips line
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the home screen shows a collapsible tools/skills/MCP catalog panel (item 9)', async () => {
|
||||||
|
const store = createSessionStore()
|
||||||
|
store.apply({ type: 'gateway.ready' })
|
||||||
|
store.setCatalog({
|
||||||
|
tools: { total: 42, toolsets: [{ name: 'core', count: 12 }] },
|
||||||
|
skills: { total: 7, categories: [{ name: 'dev', count: 7 }] },
|
||||||
|
mcp: { servers: ['railway', 'beeper'] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const frame = await captureFrame(
|
||||||
|
() => (
|
||||||
|
<ThemeProvider theme={() => store.state.theme}>
|
||||||
|
<App store={store} />
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
{ until: '42 tools', width: 72, height: 20 }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(frame).toContain('42 tools')
|
||||||
|
expect(frame).toContain('7 skills')
|
||||||
|
expect(frame).toContain('2 MCP') // mcp.servers.length
|
||||||
|
})
|
||||||
|
|
||||||
test('the status bar renders model · context% · cwd (item 14)', async () => {
|
test('the status bar renders model · context% · cwd (item 14)', async () => {
|
||||||
const store = createSessionStore()
|
const store = createSessionStore()
|
||||||
store.apply({ type: 'gateway.ready' })
|
store.apply({ type: 'gateway.ready' })
|
||||||
|
|||||||
@ -134,6 +134,21 @@ describe('session store — ordered parts (Phase 2b)', () => {
|
|||||||
expect(tool.lineCount).toBe(2)
|
expect(tool.lineCount).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('setCatalog maps the loose startup.catalog response defensively (item 9)', () => {
|
||||||
|
const store = createSessionStore()
|
||||||
|
store.setCatalog({
|
||||||
|
tools: { total: 42, toolsets: [{ name: 'core', count: 12 }, { name: '', count: 1 }] },
|
||||||
|
skills: { total: 7, categories: [{ name: 'dev', count: 7 }] },
|
||||||
|
mcp: { servers: ['railway', 123, 'beeper'] },
|
||||||
|
junk: 'ignored'
|
||||||
|
})
|
||||||
|
const c = store.state.catalog!
|
||||||
|
expect(c.tools.total).toBe(42)
|
||||||
|
expect(c.tools.toolsets).toEqual([{ name: 'core', count: 12 }]) // nameless entry dropped
|
||||||
|
expect(c.skills.total).toBe(7)
|
||||||
|
expect(c.mcp.servers).toEqual(['railway', 'beeper']) // non-string dropped
|
||||||
|
})
|
||||||
|
|
||||||
test('reasoning.delta accumulates into a reasoning part', () => {
|
test('reasoning.delta accumulates into a reasoning part', () => {
|
||||||
const store = createSessionStore()
|
const store = createSessionStore()
|
||||||
store.apply({ type: 'message.start' })
|
store.apply({ type: 'message.start' })
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* HomeHint — the empty-transcript home screen (item 12; Ink's `helpHint.tsx`).
|
* HomeHint — the empty-transcript home screen (items 12 + 9). Shows the full
|
||||||
* Shown when there are no messages yet: the brand line, a few common commands,
|
* HERMES-AGENT banner (the canonical CLI logo, width-guarded → compact brand on
|
||||||
* and the key input tips. Replaced by the transcript as soon as a turn lands.
|
* narrow terminals), the welcome line, a COLLAPSIBLE tools/skills/MCP catalog
|
||||||
* Fully themed; decorative, so `selectable={false}` (item 4).
|
* panel (item 9), the common commands, and the key tips. Fully themed; decorative,
|
||||||
|
* so `selectable={false}` (item 4).
|
||||||
*/
|
*/
|
||||||
import { For } from 'solid-js'
|
import { useTerminalDimensions } from '@opentui/solid'
|
||||||
|
import { createSignal, For, Show } from 'solid-js'
|
||||||
|
|
||||||
|
import type { Catalog } from '../logic/store.ts'
|
||||||
import { useTheme } from './theme.tsx'
|
import { useTheme } from './theme.tsx'
|
||||||
|
|
||||||
const COMMANDS: ReadonlyArray<readonly [string, string]> = [
|
const COMMANDS: ReadonlyArray<readonly [string, string]> = [
|
||||||
@ -17,19 +20,99 @@ const COMMANDS: ReadonlyArray<readonly [string, string]> = [
|
|||||||
['/clear', 'clear the transcript']
|
['/clear', 'clear the transcript']
|
||||||
]
|
]
|
||||||
|
|
||||||
export function HomeHint() {
|
// The canonical HERMES-AGENT block logo (hermes_cli/banner.py), gold→amber→bronze.
|
||||||
|
const BANNER: ReadonlyArray<readonly [string, 'primary' | 'accent' | 'border']> = [
|
||||||
|
['██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', 'primary'],
|
||||||
|
['██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', 'primary'],
|
||||||
|
['███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║', 'accent'],
|
||||||
|
['██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║', 'accent'],
|
||||||
|
['██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║', 'border'],
|
||||||
|
['╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝', 'border']
|
||||||
|
]
|
||||||
|
const BANNER_W = 102
|
||||||
|
|
||||||
|
export function HomeHint(props: { catalog: Catalog | undefined }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
const dims = useTerminalDimensions()
|
||||||
|
const [open, setOpen] = createSignal(false)
|
||||||
|
const wide = () => dims().width >= BANNER_W
|
||||||
|
const cat = () => props.catalog
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: 'column', flexShrink: 0, paddingLeft: 1, marginTop: 1 }}>
|
<box style={{ flexDirection: 'column', flexShrink: 0, paddingLeft: 1, marginTop: 1 }}>
|
||||||
|
{/* banner — full block logo when there's room, else a compact brand line */}
|
||||||
|
<Show
|
||||||
|
when={wide()}
|
||||||
|
fallback={
|
||||||
<text selectable={false}>
|
<text selectable={false}>
|
||||||
<span style={{ fg: theme().color.accent }}>{theme().brand.icon} </span>
|
<span style={{ fg: theme().color.accent }}>{theme().brand.icon} </span>
|
||||||
<span style={{ fg: theme().color.primary }}>
|
<span style={{ fg: theme().color.primary }}>
|
||||||
<b>{theme().brand.name}</b>
|
<b>{theme().brand.name}</b>
|
||||||
</span>
|
</span>
|
||||||
</text>
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={BANNER}>
|
||||||
|
{([line, tone]) => (
|
||||||
|
<text selectable={false}>
|
||||||
|
<span style={{ fg: theme().color[tone] }}>{line}</span>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<box style={{ marginTop: 1 }}>
|
||||||
<text selectable={false}>
|
<text selectable={false}>
|
||||||
<span style={{ fg: theme().color.muted }}>{theme().brand.welcome}</span>
|
<span style={{ fg: theme().color.muted }}>{theme().brand.welcome}</span>
|
||||||
</text>
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* collapsible tools / skills / MCP catalog (item 9) */}
|
||||||
|
<Show when={cat()}>
|
||||||
|
{c => (
|
||||||
|
<box style={{ flexDirection: 'column', marginTop: 1 }}>
|
||||||
|
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => setOpen(o => !o)}>
|
||||||
|
<text selectable={false}>
|
||||||
|
<span style={{ fg: theme().color.muted }}>{open() ? '▼ ' : '▶ '}</span>
|
||||||
|
<span style={{ fg: theme().color.label }}>{`${c().tools.total} tools`}</span>
|
||||||
|
<span style={{ fg: theme().color.muted }}>{' · '}</span>
|
||||||
|
<span style={{ fg: theme().color.label }}>{`${c().skills.total} skills`}</span>
|
||||||
|
<span style={{ fg: theme().color.muted }}>{' · '}</span>
|
||||||
|
<span style={{ fg: theme().color.label }}>{`${c().mcp.servers.length} MCP`}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<Show when={open()}>
|
||||||
|
<box
|
||||||
|
style={{ flexDirection: 'column', marginLeft: 2, paddingLeft: 1 }}
|
||||||
|
border={['left']}
|
||||||
|
borderColor={theme().color.border}
|
||||||
|
>
|
||||||
|
<text selectable={false}>
|
||||||
|
<span style={{ fg: theme().color.label }}>toolsets </span>
|
||||||
|
<span style={{ fg: theme().color.muted }}>
|
||||||
|
{c().tools.toolsets.map(t => `${t.name}(${t.count})`).join(' ')}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
<Show when={c().skills.categories.length > 0}>
|
||||||
|
<text selectable={false}>
|
||||||
|
<span style={{ fg: theme().color.label }}>skills </span>
|
||||||
|
<span style={{ fg: theme().color.muted }}>
|
||||||
|
{c().skills.categories.map(s => `${s.name}(${s.count})`).join(' ')}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={c().mcp.servers.length > 0}>
|
||||||
|
<text selectable={false}>
|
||||||
|
<span style={{ fg: theme().color.label }}>mcp </span>
|
||||||
|
<span style={{ fg: theme().color.muted }}>{c().mcp.servers.join(' ')}</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
<box style={{ flexDirection: 'column', marginTop: 1 }}>
|
<box style={{ flexDirection: 'column', marginTop: 1 }}>
|
||||||
<For each={COMMANDS}>
|
<For each={COMMANDS}>
|
||||||
{([cmd, desc]) => (
|
{([cmd, desc]) => (
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export function Transcript(props: { store: SessionStore }) {
|
|||||||
<scrollbox style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
|
<scrollbox style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
|
||||||
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
|
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
|
||||||
<Show when={props.store.state.messages.length === 0}>
|
<Show when={props.store.state.messages.length === 0}>
|
||||||
<HomeHint />
|
<HomeHint catalog={props.store.state.catalog} />
|
||||||
</Show>
|
</Show>
|
||||||
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
|
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user