From 53438228eefc28bc296074955c9ab25eb4a96fa5 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 9 Jun 2026 05:19:21 +0000 Subject: [PATCH] opentui(v5b/item1): Ink-parity startup banner panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt the home screen to match hermes --tui: the HERMES-AGENT banner + tagline, then a session info block (model · Nous Research / dir (branch) / Session: ), then SEPARATE collapsible sections — Available Tools (enabled toolsets each as 'name: tool1, tool2', capped + '(and N more toolsets…)'), Available Skills (N) in M categories, MCP Servers (N) connected — and a '… /help for commands' summary. Previously it was one combined '▶ N tools · M skills · K MCP' dropdown that only listed tools and showed no model/dir/session. - gateway startup.catalog now returns per-toolset {enabled, tools} (resolved_tools, session-aware enabled set — mirrors tools.list); py_compile OK. - store Catalog gains toolset.enabled/tools; new sessionId field + setSessionId, set on session create/resume (alongside the active-session-file write). - homeHint takes the store, reads info (model/cwd/branch) + sessionId + catalog. 85 pass; verified live (model·Nous·dir·session + enabled toolsets w/ tools). --- tui_gateway/server.py | 17 +- ui-tui-opentui-v2/src/entry/main.tsx | 2 + ui-tui-opentui-v2/src/logic/store.ts | 26 ++- ui-tui-opentui-v2/src/test/render.test.tsx | 7 +- ui-tui-opentui-v2/src/test/store.test.ts | 14 +- ui-tui-opentui-v2/src/view/homeHint.tsx | 180 +++++++++++++-------- ui-tui-opentui-v2/src/view/transcript.tsx | 2 +- 7 files changed, 164 insertions(+), 84 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 53e9ae9801e..018d2b8b9f4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -8528,12 +8528,25 @@ def _(rid, params: dict) -> dict: try: from toolsets import get_all_toolsets, get_toolset_info + # enabled toolsets for THIS session (or the config default), mirroring tools.list + session = _sessions.get(params.get("session_id", "")) + enabled = ( + set(getattr(session["agent"], "enabled_toolsets", []) or []) + if session + else set(_load_enabled_toolsets() or []) + ) 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"]) + is_on = name in enabled if enabled else True + # the startup panel lists ENABLED toolsets with their tools (Ink parity) + tool_names = [str(t) for t in (info.get("resolved_tools") or [])] + tools["toolsets"].append( + {"name": name, "count": int(info["tool_count"]), "enabled": is_on, "tools": tool_names} + ) + if is_on: + tools["total"] += int(info["tool_count"]) except Exception: pass diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index ca39f9461d9..00381179018 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -82,6 +82,7 @@ const writeActiveSession = (sid: string | undefined) => { const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: string, cols: number) => Effect.gen(function* () { writeActiveSession(sid) // the session we're switching to is now the active one (#5) + store.setSessionId(sid) store.beginBuffer() const t0 = Date.now() const resumed = yield* gateway.request<{ messages?: unknown; info?: Record }>('session.resume', { @@ -148,6 +149,7 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp } if (created?.info) store.applyInfo(created.info) writeActiveSession(sid) // record the new session for the launcher's exit epilogue (#5) + store.setSessionId(sid) log.info('bootstrap', 'session created', { sid }) } diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 7a769799b52..881a129a617 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -140,9 +140,12 @@ export interface SessionInfo { compressions?: number } -/** Startup catalog (tools/skills/MCP) for the home-screen panel (item 9). */ +/** Startup catalog (tools/skills/MCP) for the home-screen panel (item 9 / banner parity). */ export interface Catalog { - readonly tools: { readonly total: number; readonly toolsets: ReadonlyArray<{ name: string; count: number }> } + readonly tools: { + readonly total: number + readonly toolsets: ReadonlyArray<{ name: string; count: number; enabled: boolean; tools: ReadonlyArray }> + } readonly skills: { readonly total: number; readonly categories: ReadonlyArray<{ name: string; count: number }> } readonly mcp: { readonly servers: ReadonlyArray } } @@ -178,6 +181,8 @@ export interface StoreState { hint: string | undefined /** Startup tools/skills/MCP catalog (from `startup.catalog`) for the home panel (item 9). */ catalog: Catalog | undefined + /** The current session id (shown in the home panel; updated on create/resume). */ + sessionId: string | undefined } const LRU_LIMIT = 1000 @@ -264,7 +269,8 @@ export function createSessionStore() { status: undefined, info: {}, hint: undefined, - catalog: undefined + catalog: undefined, + sessionId: undefined }) // Monotonic part id (stable `key` per part so a new tool part below a streaming @@ -669,26 +675,36 @@ export function createSessionStore() { 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 strs = (v: unknown): string[] => list(v).filter((s): s is string => typeof s === 'string') const pair = (v: unknown) => { const o = obj(v) return { count: num(o.count), name: readStr(o, 'name') ?? '' } } + const toolset = (v: unknown) => { + const o = obj(v) + return { count: num(o.count), enabled: o.enabled !== false, name: readStr(o, 'name') ?? '', tools: strs(o.tools) } + } 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') }, + mcp: { servers: strs(mcp.servers) }, 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) } + tools: { toolsets: list(tools.toolsets).map(toolset).filter(p => p.name), total: num(tools.total) } }) } + function setSessionId(sid: string | undefined): void { + setState('sessionId', sid) + } + return { state, apply, pushUser, pushSystem, setCatalog, + setSessionId, 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 e5dcfbc4a4f..38696a80ec2 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -241,16 +241,15 @@ describe('App render (Phase 1, themed)', () => { ), - { until: '/help', width: 72, height: 20 } + { until: 'Nous Research', width: 72, height: 20 } ) // (theme-independent assertions — testRender reuses a global root, so a prior // test's skin/brand can bleed; the real app has one store. The home hint's // content is what matters here.) - expect(frame).toContain('/help') // common command - expect(frame).toContain('/agents') - expect(frame).toContain('resume a session') + expect(frame).toContain('Nous Research') // the tagline expect(frame).toContain('to mention') // the input tips line + expect(frame).toContain('Ctrl+C to stop/quit') }) test('the home screen shows a collapsible tools/skills/MCP catalog panel (item 9)', async () => { diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index 00dc9bed871..536d18132cf 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -137,14 +137,24 @@ describe('session store — ordered parts (Phase 2b)', () => { 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 }] }, + tools: { + total: 42, + toolsets: [ + { name: 'core', count: 12, enabled: true, tools: ['a', 'b', 3] }, + { name: 'off', count: 5, enabled: false, tools: [] }, + { 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.tools.toolsets).toEqual([ + { name: 'core', count: 12, enabled: true, tools: ['a', 'b'] }, // non-string tool dropped + { name: 'off', count: 5, enabled: false, tools: [] } // enabled flag preserved + ]) // nameless entry dropped expect(c.skills.total).toBe(7) expect(c.mcp.servers).toEqual(['railway', 'beeper']) // non-string dropped }) diff --git a/ui-tui-opentui-v2/src/view/homeHint.tsx b/ui-tui-opentui-v2/src/view/homeHint.tsx index 755b4b1c5dd..922682fc07e 100644 --- a/ui-tui-opentui-v2/src/view/homeHint.tsx +++ b/ui-tui-opentui-v2/src/view/homeHint.tsx @@ -1,25 +1,17 @@ /** - * 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). + * HomeHint — the empty-transcript home screen (items 12 + 9; Ink `branding.tsx` + * parity). The HERMES-AGENT banner + a tagline, then a session info block + * (model · Nous Research / dir / Session id), then SEPARATE collapsible sections — + * Available Tools (enabled toolsets + their tools), Available Skills, MCP Servers — + * and a summary line. Fully themed; decorative, so `selectable={false}` (item 4). */ +import { createSignal, For, type JSX, Show } from 'solid-js' + +import type { SessionStore } from '../logic/store.ts' +import { truncate } from '../logic/toolOutput.ts' import { useDimensions } from './dimensions.tsx' -import { createSignal, For, Show } from 'solid-js' - -import type { Catalog } from '../logic/store.ts' import { useTheme } from './theme.tsx' -const COMMANDS: ReadonlyArray = [ - ['/help', 'list all commands'], - ['/model', 'switch model'], - ['/sessions', 'resume a session'], - ['/skills', 'browse skills'], - ['/agents', 'live delegation trace'], - ['/clear', 'clear the transcript'] -] - // The canonical HERMES-AGENT block logo (hermes_cli/banner.py), gold→amber→bronze. const BANNER: ReadonlyArray = [ ['██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', 'primary'], @@ -30,13 +22,47 @@ const BANNER: ReadonlyArray ['╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝', 'border'] ] const BANNER_W = 102 +const TOOLSETS_MAX = 10 -export function HomeHint(props: { catalog: Catalog | undefined }) { +/** `anthropic/claude-opus-4-8` → `claude-opus-4-8`. */ +const shortModel = (m: string) => (m.includes('/') ? (m.split('/').at(-1) ?? m) : m) +const HOME = process.env.HOME ?? '' +const shortCwd = (cwd: string) => (HOME && cwd.startsWith(HOME) ? '~' + cwd.slice(HOME.length) : cwd) + +export function HomeHint(props: { store: SessionStore }) { const theme = useTheme() const dims = useDimensions() - const [open, setOpen] = createSignal(false) const wide = () => dims().width >= BANNER_W - const cat = () => props.catalog + const cat = () => props.store.state.catalog + const info = () => props.store.state.info + const enabledToolsets = () => (cat()?.tools.toolsets ?? []).filter(t => t.enabled) + + // A collapsible section: ▸/▾ accent chevron + label title + optional muted suffix. + function Section(p: { title: string; suffix?: string; open?: boolean; children: JSX.Element }) { + const [open, setOpen] = createSignal(p.open ?? false) + return ( + + setOpen(o => !o)}> + + {open() ? '▾ ' : '▸ '} + {p.title} + + {` ${p.suffix}`} + + + + + + {p.children} + + + + ) + } return ( @@ -60,69 +86,83 @@ export function HomeHint(props: { catalog: Catalog | undefined }) { )} + + {`${theme().brand.icon} `} + Nous Research · Messenger of the Digital Gods + - - - {theme().brand.welcome} - + {/* session info block: model · Nous Research / dir / Session id */} + + + + {shortModel(info().model!)} + · Nous Research + + + + + {shortCwd(info().cwd!)} + + {` (${info().branch})`} + + + + + + Session: + {props.store.state.sessionId} + + - {/* collapsible tools / skills / MCP catalog (item 9) */} + {/* SEPARATE collapsible sections (Ink parity) + summary */} {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}> + +
+ + {ts => ( - skills + {`${ts.name}: `} - {c().skills.categories.map(s => `${s.name}(${s.count})`).join(' ')} + {truncate(ts.tools.join(', ') || `${ts.count} tools`, Math.max(20, dims().width - ts.name.length - 8))} - - 0}> - - mcp - {c().mcp.servers.join(' ')} - - - - + )} + + TOOLSETS_MAX}> + + {`(and ${enabledToolsets().length - TOOLSETS_MAX} more toolsets…)`} + + +
+ +
+ + + {c().skills.categories.map(s => `${s.name} (${s.count})`).join(' ')} + + +
+ +
+ + {c().mcp.servers.join(' ') || 'none configured'} + +
+ + + + {`${c().tools.total} tools`} + {` · ${c().skills.total} skills · ${c().mcp.servers.length} MCP · `} + /help + for commands + +
)}
- - - {([cmd, desc]) => ( - - {cmd.padEnd(12)} - {desc} - - )} - - diff --git a/ui-tui-opentui-v2/src/view/transcript.tsx b/ui-tui-opentui-v2/src/view/transcript.tsx index 631f163e7a1..3f8f01047db 100644 --- a/ui-tui-opentui-v2/src/view/transcript.tsx +++ b/ui-tui-opentui-v2/src/view/transcript.tsx @@ -30,7 +30,7 @@ export function Transcript(props: { store: SessionStore }) { {/* empty-transcript home screen (item 12); replaced by messages on the first turn */} - + {message => }