diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ab60f3a0b..8d58df25c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1824,6 +1824,109 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"plugins": []}) +@method("config.show") +def _(rid, params: dict) -> dict: + try: + cfg = _load_cfg() + model = _resolve_model() + api_key = os.environ.get("HERMES_API_KEY", "") or cfg.get("api_key", "") + masked = f"****{api_key[-4:]}" if len(api_key) > 4 else "(not set)" + base_url = os.environ.get("HERMES_BASE_URL", "") or cfg.get("base_url", "") + + sections = [{ + "title": "Model", + "rows": [ + ["Model", model], + ["Base URL", base_url or "(default)"], + ["API Key", masked], + ] + }, { + "title": "Agent", + "rows": [ + ["Max Turns", str(cfg.get("max_turns", 25))], + ["Toolsets", ", ".join(cfg.get("enabled_toolsets", [])) or "all"], + ["Verbose", str(cfg.get("verbose", False))], + ] + }, { + "title": "Environment", + "rows": [ + ["Working Dir", os.getcwd()], + ["Config File", str(_hermes_home / "config.yaml")], + ] + }] + return _ok(rid, {"sections": sections}) + except Exception as e: + return _err(rid, 5030, str(e)) + + +@method("tools.list") +def _(rid, params: dict) -> dict: + try: + from toolsets import get_all_toolsets, get_toolset_info + session = _sessions.get(params.get("session_id", "")) + enabled = set() + if session: + enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) + + items = [] + for name in sorted(get_all_toolsets().keys()): + info = get_toolset_info(name) + if not info: + continue + items.append({ + "name": name, + "description": info["description"], + "tool_count": info["tool_count"], + "enabled": name in enabled if enabled else True, + "tools": info["resolved_tools"], + }) + return _ok(rid, {"toolsets": items}) + except Exception as e: + return _err(rid, 5031, str(e)) + + +@method("toolsets.list") +def _(rid, params: dict) -> dict: + try: + from toolsets import get_all_toolsets, get_toolset_info + session = _sessions.get(params.get("session_id", "")) + enabled = set() + if session: + enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) + + items = [] + for name in sorted(get_all_toolsets().keys()): + info = get_toolset_info(name) + if not info: + continue + items.append({ + "name": name, + "description": info["description"], + "tool_count": info["tool_count"], + "enabled": name in enabled if enabled else True, + }) + return _ok(rid, {"toolsets": items}) + except Exception as e: + return _err(rid, 5032, str(e)) + + +@method("agents.list") +def _(rid, params: dict) -> dict: + try: + from tools.process_registry import ProcessRegistry + procs = ProcessRegistry().list_sessions() + return _ok(rid, { + "processes": [{ + "session_id": p["session_id"], + "command": p["command"][:80], + "status": p["status"], + "uptime": p["uptime_seconds"], + } for p in procs] + }) + except Exception as e: + return _err(rid, 5033, str(e)) + + @method("cron.manage") def _(rid, params: dict) -> dict: action, jid = params.get("action", "list"), params.get("name", "") diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index ec79588fe..a77ca0083 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -88,6 +88,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -317,29 +318,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1464,6 +1442,7 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1474,6 +1453,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1484,6 +1464,7 @@ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", @@ -1513,6 +1494,7 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -1830,6 +1812,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2165,6 +2148,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2850,6 +2834,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3745,6 +3730,7 @@ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" @@ -5085,6 +5071,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5184,6 +5171,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5956,6 +5944,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6082,6 +6071,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6191,6 +6181,7 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6599,6 +6590,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index cfe7b7d21..c32692210 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -6,7 +6,7 @@ import { join } from 'node:path' import { Box, Text, useApp, useInput, useStdout } from '@hermes/ink' import { useCallback, useEffect, useRef, useState } from 'react' -import { Banner, SessionPanel } from './components/branding.js' +import { Banner, Panel, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' @@ -36,6 +36,7 @@ import type { ApprovalReq, ClarifyReq, Msg, + PanelSection, PasteMode, PendingPaste, SecretReq, @@ -343,7 +344,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) - const [pager, setPager] = useState<{ lines: string[]; offset: number } | null>(null) + const [pager, setPager] = useState<{ lines: string[]; offset: number; title?: string } | null>(null) const [voiceEnabled, setVoiceEnabled] = useState(false) const [voiceRecording, setVoiceRecording] = useState(false) const [voiceProcessing, setVoiceProcessing] = useState(false) @@ -426,11 +427,18 @@ export function App({ gw }: { gw: GatewayClient }) { const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) - const page = useCallback((text: string) => { + const page = useCallback((text: string, title?: string) => { const lines = text.split('\n') - setPager({ lines, offset: 0 }) + setPager({ lines, offset: 0, title }) }, []) + const panel = useCallback( + (title: string, sections: PanelSection[]) => { + appendMessage({ role: 'system', text: '', kind: 'panel', panelData: { title, sections } }) + }, + [appendMessage] + ) + const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { setActivity(prev => { const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev @@ -1504,37 +1512,18 @@ export function App({ gw }: { gw: GatewayClient }) { switch (name) { case 'help': { - const cats = catalog?.categories ?? [] - const skills = catalog?.skillCount ?? 0 - const lines: string[] = [] + const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }) => ({ + title: catName, + rows: pairs + })) - for (const { name: catName, pairs } of cats) { - if (lines.length) { - lines.push('') - } - - lines.push(` ${catName}:`) - - for (const [c, d] of pairs) { - lines.push(` ${c.padEnd(18)} ${d}`) - } + if (catalog?.skillCount) { + sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` }) } - if (!lines.length) { - lines.push(' (no commands loaded)') - } + sections.push({ title: 'Hotkeys', rows: HOTKEYS }) - if (skills > 0) { - lines.push('', ` ${skills} skill commands available — /skills to browse`) - } - - lines.push('', ' Hotkeys:') - - for (const [k, d] of HOTKEYS) { - lines.push(` ${k.padEnd(14)} ${d}`) - } - - sys(lines.join('\n')) + panel('Commands', sections) return true } @@ -1598,16 +1587,16 @@ export function App({ gw }: { gw: GatewayClient }) { } if (arg === 'list') { - sys( - pastes.length - ? pastes - .map( - p => - `#${p.id} ${p.mode} · ${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` - ) - .join('\n') - : 'no text pastes' - ) + if (!pastes.length) { + sys('no text pastes') + } else { + panel('Paste Shelf', [{ + rows: pastes.map(p => [ + `#${p.id} ${p.mode}`, + `${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` + ] as [string, string]) + }]) + } return true } @@ -1660,10 +1649,12 @@ export function App({ gw }: { gw: GatewayClient }) { return true - case 'logs': - sys(gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) || 'no gateway logs') + case 'logs': { + const logText = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) + logText ? page(logText, 'Logs') : sys('no gateway logs') return true + } case 'statusbar': @@ -1769,7 +1760,9 @@ export function App({ gw }: { gw: GatewayClient }) { case 'model': if (!arg) { - rpc('config.get', { key: 'provider' }).then((r: any) => sys(`${r.model} (${r.provider})`)) + rpc('config.get', { key: 'provider' }).then((r: any) => + panel('Model', [{ rows: [['Model', r.model], ['Provider', r.provider]] }]) + ) } else { rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then( (r: any) => { @@ -1798,7 +1791,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'provider': gw.request('slash.exec', { command: 'provider', session_id: sid }) - .then((r: any) => page(r?.output || '(no output)')) + .then((r: any) => page(r?.output || '(no output)', 'Provider')) .catch(() => sys('provider command failed')) return true @@ -1840,7 +1833,7 @@ export function App({ gw }: { gw: GatewayClient }) { ) } else { gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => sys(r?.output || '(no output)')) + .then((r: any) => panel('Personality', [{ text: r?.output || '(no output)' }])) .catch(() => sys('personality command failed')) } @@ -1900,30 +1893,30 @@ export function App({ gw }: { gw: GatewayClient }) { } const f = (v: number) => (v ?? 0).toLocaleString() - const ln = (k: string, v: string) => ` ${k.padEnd(26)}${v.padStart(10)}` - const hr = ` ${'─'.repeat(36)}` - const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null - sys( - [ - hr, - ln('Model:', r.model ?? ''), - ln('Input tokens:', f(r.input)), - ln('Cache read tokens:', f(r.cache_read)), - ln('Cache write tokens:', f(r.cache_write)), - ln('Output tokens:', f(r.output)), - ln('Total tokens:', f(r.total)), - ln('API calls:', f(r.calls)), - cost && ln('Cost:', cost), - hr, - r.context_max && ` Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)`, - r.compressions && ` Compressions: ${r.compressions}` - ] - .filter(Boolean) - .join('\n') - ) + const rows: [string, string][] = [ + ['Model', r.model ?? ''], + ['Input tokens', f(r.input)], + ['Cache read tokens', f(r.cache_read)], + ['Cache write tokens', f(r.cache_write)], + ['Output tokens', f(r.output)], + ['Total tokens', f(r.total)], + ['API calls', f(r.calls)] + ] + + if (cost) rows.push(['Cost', cost]) + + const sections: PanelSection[] = [{ rows }] + + if (r.context_max) { + sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) + } + + if (r.compressions) sections.push({ text: `Compressions: ${r.compressions}` }) + + panel('Usage', sections) }) return true @@ -1939,7 +1932,16 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'profile': - rpc('config.get', { key: 'profile' }).then((r: any) => sys(r.display || r.home)) + rpc('config.get', { key: 'profile' }).then((r: any) => { + const text = r.display || r.home + const lines = text.split('\n').filter(Boolean) + + if (lines.length <= 2) { + panel('Profile', [{ text }]) + } else { + page(text, 'Profile') + } + }) return true @@ -1957,7 +1959,13 @@ export function App({ gw }: { gw: GatewayClient }) { case 'insights': rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => - sys(`${r.days}d: ${r.sessions} sessions, ${r.messages} messages`) + panel('Insights', [{ + rows: [ + ['Period', `${r.days} days`], + ['Sessions', `${r.sessions}`], + ['Messages', `${r.messages}`] + ] + }]) ) return true @@ -1970,7 +1978,12 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no checkpoints') } - sys(r.checkpoints.map((c: any, i: number) => ` ${i + 1} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n')) + panel('Checkpoints', [{ + rows: r.checkpoints.map((c: any, i: number) => [ + `${i + 1} ${c.hash?.slice(0, 8)}`, + c.message + ] as [string, string]) + }]) }) } else { const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub @@ -2003,7 +2016,9 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no plugins') } - sys(r.plugins.map((p: any) => ` ${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`).join('\n')) + panel('Plugins', [{ + items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) + }]) }) return true @@ -2018,43 +2033,31 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no skills installed') } - const lines: string[] = [] - - for (const [cat, names] of Object.entries(sk)) { - lines.push(` ${cat}: ${(names as string[]).join(', ')}`) - } - - sys(lines.join('\n')) + panel('Installed Skills', Object.entries(sk).map(([cat, names]) => ({ + title: cat, + items: names as string[] + }))) }) return true } if (sub === 'browse') { - const page = parseInt(sArgs[0] ?? '1', 10) || 1 - rpc('skills.manage', { action: 'browse', page }).then((r: any) => { - if (!r.items?.length) { - return sys('no skills found in the hub') - } + const pg = parseInt(sArgs[0] ?? '1', 10) || 1 + rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => { + if (!r.items?.length) return sys('no skills found in the hub') - const lines = [ - ` Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, - '', - ...r.items.map( - (s: any) => - ` ${(s.name ?? '').padEnd(28)} ${(s.description ?? '').slice(0, 60)}${s.description?.length > 60 ? '…' : ''}` - ) - ] + const sections: PanelSection[] = [{ + rows: r.items.map((s: any) => [ + s.name ?? '', + (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '') + ] as [string, string]) + }] - if (r.page < r.total_pages) { - lines.push('', ` /skills browse ${r.page + 1} → next page`) - } + if (r.page < r.total_pages) sections.push({ text: `/skills browse ${r.page + 1} → next page` }) + if (r.page > 1) sections.push({ text: `/skills browse ${r.page - 1} → prev page` }) - if (r.page > 1) { - lines.push(` /skills browse ${r.page - 1} → prev page`) - } - - sys(lines.join('\n')) + panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections) }) return true @@ -2067,6 +2070,94 @@ export function App({ gw }: { gw: GatewayClient }) { return true } + case 'agents': + + case 'tasks': + rpc('agents.list', {}).then((r: any) => { + const procs = r.processes ?? [] + const running = procs.filter((p: any) => p.status === 'running') + const finished = procs.filter((p: any) => p.status !== 'running') + const sections: PanelSection[] = [] + + if (running.length) { + sections.push({ + title: `Running (${running.length})`, + rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) + }) + } + + if (finished.length) { + sections.push({ + title: `Finished (${finished.length})`, + rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) + }) + } + + if (!sections.length) sections.push({ text: 'No active processes' }) + + panel('Agents', sections) + }).catch(() => sys('agents command failed')) + + return true + + case 'cron': + if (!arg || arg === 'list') { + rpc('cron.manage', { action: 'list' }).then((r: any) => { + const jobs = r.jobs ?? [] + + if (!jobs.length) return sys('no scheduled jobs') + + panel('Cron', [{ + rows: jobs.map((j: any) => [ + j.name || j.job_id?.slice(0, 12), + `${j.schedule} · ${j.state ?? 'active'}` + ] as [string, string]) + }]) + }).catch(() => sys('cron command failed')) + } else { + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) + .then((r: any) => sys(r?.output || '(no output)')) + .catch(() => sys('cron command failed')) + } + + return true + + case 'config': + rpc('config.show', {}).then((r: any) => { + panel('Config', (r.sections ?? []).map((s: any) => ({ + title: s.title, + rows: s.rows + }))) + }).catch(() => sys('config command failed')) + + return true + + case 'tools': + rpc('tools.list', { session_id: sid }).then((r: any) => { + if (!r.toolsets?.length) return sys('no tools') + + panel('Tools', r.toolsets.map((ts: any) => ({ + title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, + items: ts.tools + }))) + }).catch(() => sys('tools command failed')) + + return true + + case 'toolsets': + rpc('toolsets.list', { session_id: sid }).then((r: any) => { + if (!r.toolsets?.length) return sys('no toolsets') + + panel('Toolsets', [{ + rows: r.toolsets.map((ts: any) => [ + `${ts.enabled ? '(*)' : ' '} ${ts.name}`, + `[${ts.tool_count}] ${ts.description}` + ] as [string, string]) + }]) + }).catch(() => sys('toolsets command failed')) + + return true + default: gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => sys(r?.output || `/${name}: no output`)) @@ -2090,22 +2181,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } }, - [ - catalog, - compact, - gw, - lastUserMsg, - messages, - newSession, - page, - pastes, - pushActivity, - rpc, - send, - sid, - statusBar, - sys - ] + [catalog, compact, gw, lastUserMsg, messages, newSession, page, panel, pastes, pushActivity, rpc, send, sid, statusBar, sys] ) slashRef.current = slash @@ -2199,6 +2275,8 @@ export function App({ gw }: { gw: GatewayClient }) { + ) : m.kind === 'panel' && m.panelData ? ( + ) : ( )} @@ -2321,16 +2399,26 @@ export function App({ gw }: { gw: GatewayClient }) { )} {pager && ( - + + {pager.title && ( + + + {pager.title} + + + )} + {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( {line} ))} - - {pager.offset + pagerPageSize < pager.lines.length - ? `── Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length}) ──` - : `── end · q to close (${pager.lines.length} lines) ──`} - + + + {pager.offset + pagerPageSize < pager.lines.length + ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` + : `end · q to close (${pager.lines.length} lines)`} + + )} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 425bfa4d5..429996db7 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -3,7 +3,7 @@ import { Box, Text, useStdout } from '@hermes/ink' 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 { SessionInfo } from '../types.js' +import type { PanelSection, SessionInfo } from '../types.js' export function ArtLines({ lines }: { lines: [string, string][] }) { return ( @@ -142,3 +142,41 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string ) } + +export function Panel({ sections, t, title }: { sections: PanelSection[]; t: Theme; title: string }) { + 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}} + + ))} + + ) +} + diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 164123244..9507f41ca 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -25,8 +25,9 @@ export interface ClarifyReq { export interface Msg { role: Role text: string - kind?: 'intro' | 'slash' + kind?: 'intro' | 'panel' | 'slash' info?: SessionInfo + panelData?: PanelData thinking?: string tools?: string[] } @@ -63,6 +64,18 @@ export interface SecretReq { requestId: string } +export interface PanelData { + sections: PanelSection[] + title: string +} + +export interface PanelSection { + items?: string[] + rows?: [string, string][] + text?: string + title?: string +} + export type PasteKind = 'code' | 'log' | 'text' export type PasteMode = 'attach' | 'excerpt' | 'inline'