From 793462a3950edd48b58c145ee51897a5e5cc51eb Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 9 Jun 2026 04:04:43 +0000 Subject: [PATCH] opentui(v5/item9): startup HERMES banner + collapsible tools/skills/MCP panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- tui_gateway/server.py | 48 +++++++++ ui-tui-opentui-v2/src/entry/main.tsx | 7 ++ ui-tui-opentui-v2/src/logic/store.ts | 35 ++++++- ui-tui-opentui-v2/src/test/render.test.tsx | 23 +++++ ui-tui-opentui-v2/src/test/store.test.ts | 15 +++ ui-tui-opentui-v2/src/view/homeHint.tsx | 113 ++++++++++++++++++--- ui-tui-opentui-v2/src/view/transcript.tsx | 2 +- 7 files changed, 226 insertions(+), 17 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1a55bfc652b..53e9ae9801e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -8518,6 +8518,54 @@ def _(rid, params: dict) -> dict: 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") def _(rid, params: dict) -> dict: try: diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index 9787ab0e37e..62510590ef6 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -130,6 +130,13 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp 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('startup.catalog', { session_id: sid }) + .pipe(Effect.catchCause(() => Effect.succeed(undefined))) + if (catalog) store.setCatalog(catalog) + const prompt = input.initialPrompt?.trim() if (prompt) { store.pushUser(prompt) diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 9a768d29942..7a769799b52 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -140,6 +140,13 @@ export interface SessionInfo { 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 } +} + export interface StoreState { ready: boolean messages: Message[] @@ -169,6 +176,8 @@ export interface StoreState { /** 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. */ hint: string | undefined + /** Startup tools/skills/MCP catalog (from `startup.catalog`) for the home panel (item 9). */ + catalog: Catalog | undefined } const LRU_LIMIT = 1000 @@ -254,7 +263,8 @@ export function createSessionStore() { dashboard: false, status: undefined, info: {}, - hint: undefined + hint: undefined, + catalog: undefined }) // Monotonic part id (stable `key` per part so a new tool part below a streaming @@ -651,11 +661,34 @@ export function createSessionStore() { 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 { state, apply, pushUser, pushSystem, + setCatalog, clearTranscript, setConfirm, openPager, diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index 3560ecc0957..e5dcfbc4a4f 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -253,6 +253,29 @@ describe('App render (Phase 1, themed)', () => { 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( + () => ( + store.state.theme}> + + + ), + { 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 () => { const store = createSessionStore() store.apply({ type: 'gateway.ready' }) diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index cf9093ac5a7..00dc9bed871 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -134,6 +134,21 @@ describe('session store — ordered parts (Phase 2b)', () => { 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', () => { const store = createSessionStore() store.apply({ type: 'message.start' }) diff --git a/ui-tui-opentui-v2/src/view/homeHint.tsx b/ui-tui-opentui-v2/src/view/homeHint.tsx index f35b6d88e6b..dd99914dc81 100644 --- a/ui-tui-opentui-v2/src/view/homeHint.tsx +++ b/ui-tui-opentui-v2/src/view/homeHint.tsx @@ -1,11 +1,14 @@ /** - * HomeHint — the empty-transcript home screen (item 12; Ink's `helpHint.tsx`). - * Shown when there are no messages yet: the brand line, a few common commands, - * and the key input tips. Replaced by the transcript as soon as a turn lands. - * Fully themed; decorative, so `selectable={false}` (item 4). + * HomeHint — the empty-transcript home screen (items 12 + 9). Shows the full + * HERMES-AGENT banner (the canonical CLI logo, width-guarded → compact brand on + * narrow terminals), the welcome line, a COLLAPSIBLE tools/skills/MCP catalog + * 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' const COMMANDS: ReadonlyArray = [ @@ -17,19 +20,99 @@ const COMMANDS: ReadonlyArray = [ ['/clear', 'clear the transcript'] ] -export function HomeHint() { +// The canonical HERMES-AGENT block logo (hermes_cli/banner.py), gold→amber→bronze. +const BANNER: ReadonlyArray = [ + ['██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', 'primary'], + ['██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', 'primary'], + ['███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║', 'accent'], + ['██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║', 'accent'], + ['██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║', 'border'], + ['╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝', 'border'] +] +const BANNER_W = 102 + +export function HomeHint(props: { catalog: Catalog | undefined }) { const theme = useTheme() + const dims = useTerminalDimensions() + const [open, setOpen] = createSignal(false) + const wide = () => dims().width >= BANNER_W + const cat = () => props.catalog + return ( - - {theme().brand.icon} - - {theme().brand.name} - - - - {theme().brand.welcome} - + {/* banner — full block logo when there's room, else a compact brand line */} + + {theme().brand.icon} + + {theme().brand.name} + + + } + > + + {([line, tone]) => ( + + {line} + + )} + + + + + + {theme().brand.welcome} + + + + {/* collapsible tools / skills / MCP catalog (item 9) */} + + {c => ( + + setOpen(o => !o)}> + + {open() ? '▼ ' : '▶ '} + {`${c().tools.total} tools`} + {' · '} + {`${c().skills.total} skills`} + {' · '} + {`${c().mcp.servers.length} MCP`} + + + + + + toolsets + + {c().tools.toolsets.map(t => `${t.name}(${t.count})`).join(' ')} + + + 0}> + + skills + + {c().skills.categories.map(s => `${s.name}(${s.count})`).join(' ')} + + + + 0}> + + mcp + {c().mcp.servers.join(' ')} + + + + + + )} + + {([cmd, desc]) => ( diff --git a/ui-tui-opentui-v2/src/view/transcript.tsx b/ui-tui-opentui-v2/src/view/transcript.tsx index 7bf8415ed65..d9878dedc4e 100644 --- a/ui-tui-opentui-v2/src/view/transcript.tsx +++ b/ui-tui-opentui-v2/src/view/transcript.tsx @@ -23,7 +23,7 @@ export function Transcript(props: { store: SessionStore }) { {/* empty-transcript home screen (item 12); replaced by messages on the first turn */} - + {message => }