diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c8262e639f..c0e9849ae8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -579,6 +579,78 @@ def _(rid, params: dict) -> dict: return _err(rid, 5015, str(e)) +@method("commands.catalog") +def _(rid, params: dict) -> dict: + """Registry-backed slash metadata (same surface as SlashCommandCompleter).""" + try: + from hermes_cli.commands import COMMAND_REGISTRY, COMMANDS, SUBCOMMANDS + + pairs = sorted(COMMANDS.items(), key=lambda kv: kv[0]) + sub = {k: v[:] for k, v in SUBCOMMANDS.items()} + canon: dict[str, str] = {} + for cmd in COMMAND_REGISTRY: + if cmd.gateway_only: + continue + c = f"/{cmd.name}" + canon[c.lower()] = c + for a in cmd.aliases: + canon[f"/{a}".lower()] = c + skills = [] + try: + from agent.skill_commands import scan_skill_commands + for k, info in scan_skill_commands().items(): + d = str(info.get("description", "Skill")) + skills.append([k, f"⚡ {d[:120]}{'…' if len(d) > 120 else ''}"]) + except Exception: + pass + return _ok(rid, {"pairs": pairs + skills, "sub": sub, "canon": canon}) + except Exception as e: + return _err(rid, 5020, str(e)) + + +def _cli_exec_blocked(argv: list[str]) -> str | None: + """Return user hint if this argv must not run headless in the gateway process.""" + if not argv: + return "bare `hermes` is interactive — use `/hermes chat -q …` or run `hermes` in another terminal" + a0 = argv[0].lower() + if a0 == "setup": + return "`hermes setup` needs a full terminal — run it outside the Ink UI" + if a0 == "gateway": + return "`hermes gateway` is long-running — run it in another terminal" + if a0 == "sessions" and len(argv) > 1 and argv[1].lower() == "browse": + return "`hermes sessions browse` is interactive — use /resume here, or run browse in another terminal" + if a0 == "config" and len(argv) > 1 and argv[1].lower() == "edit": + return "`hermes config edit` needs $EDITOR in a real terminal" + return None + + +@method("cli.exec") +def _(rid, params: dict) -> dict: + """Run `python -m hermes_cli.main` with argv; capture stdout/stderr (non-interactive only).""" + argv = params.get("argv", []) + if not isinstance(argv, list) or not all(isinstance(x, str) for x in argv): + return _err(rid, 4003, "argv must be list[str]") + hint = _cli_exec_blocked(argv) + if hint: + return _ok(rid, {"blocked": True, "hint": hint, "code": -1, "output": ""}) + try: + r = subprocess.run( + [sys.executable, "-m", "hermes_cli.main", *argv], + capture_output=True, + text=True, + timeout=min(int(params.get("timeout", 240)), 600), + cwd=os.getcwd(), + env=os.environ.copy(), + ) + parts = [r.stdout or "", r.stderr or ""] + out = "\n".join(p for p in parts if p).strip() or "(no output)" + return _ok(rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]}) + except subprocess.TimeoutExpired: + return _err(rid, 5016, "cli.exec: timeout") + except Exception as e: + return _err(rid, 5017, str(e)) + + @method("command.resolve") def _(rid, params: dict) -> dict: try: diff --git a/ui-tui/eslint.config.mjs b/ui-tui/eslint.config.mjs index 6cb1c419a3..905e734b8d 100644 --- a/ui-tui/eslint.config.mjs +++ b/ui-tui/eslint.config.mjs @@ -28,6 +28,7 @@ export default [ 'unused-imports': unusedImports }, rules: { + 'no-fallthrough': ['error', { allowEmptyCase: true }], curly: ['error', 'all'], '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], '@typescript-eslint/no-unused-vars': 'off', @@ -63,6 +64,6 @@ export default [ } }, { - ignores: ['node_modules/', 'dist/', '*.config.*'] + ignores: ['node_modules/', 'dist/', '*.config.*', 'src/**/*.js'] } ] diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 6cd3775d39..774e47948a 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -5,20 +5,31 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AltScreen } from './altScreen.js' import { Banner, SessionPanel } from './components/branding.js' import { CommandPalette } from './components/commandPalette.js' +import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' -import { MaskedPrompt } from './components/maskedPrompt.js' import { SessionPicker } from './components/sessionPicker.js' import { Thinking } from './components/thinking.js' -import { COMMANDS, HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' +import { HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import * as inputHistory from './lib/history.js' -import { writeOsc52Clipboard } from './lib/osc52.js' import { upsert } from './lib/messages.js' +import { writeOsc52Clipboard } from './lib/osc52.js' +import { paletteForLine, tabAdvance } from './lib/slash.js' import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' -import type { ActiveTool, ApprovalReq, ClarifyReq, Msg, SecretReq, SessionInfo, SudoReq, Usage } from './types.js' +import type { + ActiveTool, + ApprovalReq, + ClarifyReq, + Msg, + SecretReq, + SessionInfo, + SlashCatalog, + SudoReq, + Usage +} from './types.js' const PLACEHOLDER = pick(PLACEHOLDERS) @@ -53,6 +64,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [historyIdx, setHistoryIdx] = useState(null) const [scrollOffset, setScrollOffset] = useState(0) const [queuedDisplay, setQueuedDisplay] = useState([]) + const [catalog, setCatalog] = useState(null) const buf = useRef('') const stickyRef = useRef(true) @@ -92,6 +104,7 @@ export function App({ gw }: { gw: GatewayClient }) { const pushHistory = (text: string) => { const trimmed = text.trim() + if (trimmed && historyRef.current.at(-1) !== trimmed) { historyRef.current.push(trimmed) inputHistory.append(trimmed) @@ -143,12 +156,18 @@ export function App({ gw }: { gw: GatewayClient }) { const newSession = (msg?: string) => rpc('session.create').then((r: any) => { - if (!r) return + if (!r) { + return + } + setSid(r.session_id) setMessages([]) setUsage(ZERO) setStatus('ready') - if (msg) sys(msg) + + if (msg) { + sys(msg) + } }) const idle = () => { @@ -276,6 +295,16 @@ export function App({ gw }: { gw: GatewayClient }) { return } + if (!inputBuf.length && key.tab && input.startsWith('/')) { + const next = tabAdvance(input, catalog) + + if (next) { + setInput(next) + } + + return + } + if (key.pageUp) { scrollUp(5) @@ -373,6 +402,20 @@ export function App({ gw }: { gw: GatewayClient }) { setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {})) } + rpc('commands.catalog', {}) + .then((r: any) => { + if (!r?.pairs) { + return + } + + setCatalog({ + canon: (r.canon ?? {}) as Record, + pairs: r.pairs as [string, string][], + sub: (r.sub ?? {}) as Record + }) + }) + .catch(() => {}) + setStatus('forging session…') newSession() @@ -384,7 +427,10 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'thinking.delta': - if (p?.text) setThinkingText(prev => prev + p.text) + if (p?.text) { + setThinkingText(prev => prev + p.text) + } + break case 'message.start': @@ -449,24 +495,29 @@ export function App({ gw }: { gw: GatewayClient }) { case 'approval.request': setApproval({ command: p.command, description: p.description }) setStatus('approval needed') + break case 'sudo.request': setSudo({ requestId: p.request_id }) setStatus('sudo password needed') + break case 'secret.request': setSecret({ requestId: p.request_id, prompt: p.prompt, envVar: p.env_var }) setStatus('secret input needed') + break case 'background.complete': sys(`[bg ${p.task_id}] ${p.text}`) + break case 'btw.complete': sys(`[btw] ${p.text}`) + break case 'message.delta': @@ -547,22 +598,31 @@ export function App({ gw }: { gw: GatewayClient }) { const arg = rest.join(' ') switch (name) { - case 'help': + case 'help': { + const rows = catalog?.pairs ?? [] + const cap = 52 + const lines = rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`) + sys( [ ' Commands:', - ...COMMANDS.map(([c, d]) => ` ${c.padEnd(12)} ${d}`), + ...lines, + rows.length > cap ? ` … ${rows.length - cap} more` : '', '', ' Hotkeys:', - ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(12)} ${d}`) - ].join('\n') + ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(14)} ${d}`) + ] + .filter(Boolean) + .join('\n') ) return true + } case 'clear': setStatus('forging session…') newSession() + return true case 'quit': // falls through @@ -575,6 +635,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'new': setStatus('forging session…') newSession('new session started') + return true case 'undo': @@ -582,44 +643,51 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - rpc('session.undo', { session_id: sid }) - .then((r: any) => { - if (r.removed > 0) { - setMessages(prev => { - const q = [...prev] + rpc('session.undo', { session_id: sid }).then((r: any) => { + if (r.removed > 0) { + setMessages(prev => { + const q = [...prev] - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } - if (q.at(-1)?.role === 'user') { - q.pop() - } + if (q.at(-1)?.role === 'user') { + q.pop() + } - return q - }) - sys(`undid ${r.removed} messages`) - } else { - sys('nothing to undo') - } - }) + return q + }) + sys(`undid ${r.removed} messages`) + } else { + sys('nothing to undo') + } + }) return true case 'retry': if (!lastUserMsg) { sys('nothing to retry') + return true } + if (sid) { gw.request('session.undo', { session_id: sid }).catch(() => {}) } + setMessages(prev => { const q = [...prev] - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop() + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + return q }) send(lastUserMsg) + return true case 'compact': @@ -633,14 +701,13 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - rpc('session.compress', { session_id: sid }) - .then((r: any) => { - sys('context compressed') + rpc('session.compress', { session_id: sid }).then((r: any) => { + sys('context compressed') - if (r.usage) { - setUsage(r.usage) - } - }) + if (r.usage) { + setUsage(r.usage) + } + }) return true @@ -695,319 +762,484 @@ export function App({ gw }: { gw: GatewayClient }) { case 'resume': setPicker(true) + return true case 'history': - if (!sid) { setPicker(true); return true } - rpc('session.history', { session_id: sid }) - .then((r: any) => sys(`session ${sid}: ${r.count} messages in context`)) + if (!sid) { + setPicker(true) + + return true + } + + rpc('session.history', { session_id: sid }).then((r: any) => + sys(`session ${sid}: ${r.count} messages in context`) + ) + return true case 'title': - if (!sid) return true - if (!arg) { - rpc('session.title', { session_id: sid }) - .then((r: any) => sys(`title: ${r.title || '(none)'} session: ${r.session_key}`)) + if (!sid) { return true } - rpc('session.title', { session_id: sid, title: arg }) - .then(() => sys(`title → ${arg}`)) + + if (!arg) { + rpc('session.title', { session_id: sid }).then((r: any) => + sys(`title: ${r.title || '(none)'} session: ${r.session_key}`) + ) + + return true + } + + rpc('session.title', { session_id: sid, title: arg }).then(() => sys(`title → ${arg}`)) + return true case 'tools': if (!info?.tools || !Object.keys(info.tools).length) { sys('no tools loaded') + return true } + sys( Object.entries(info.tools) .map(([k, vs]) => `${k} (${vs.length}): ${vs.join(', ')}`) .join('\n') ) + return true case 'skills': if (!arg || arg === 'list') { if (!info?.skills || !Object.keys(info.skills).length) { sys('no skills loaded') + return true } - sys(Object.entries(info.skills).map(([k, vs]) => `${k}: ${vs.join(', ')}`).join('\n')) + + sys( + Object.entries(info.skills) + .map(([k, vs]) => `${k}: ${vs.join(', ')}`) + .join('\n') + ) + return true } + if (arg.startsWith('search ')) { - rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() }) - .then((r: any) => { - if (!r.results?.length) { sys('no results'); return } - sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) - }) + rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() }).then((r: any) => { + if (!r.results?.length) { + sys('no results') + + return + } + + sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) + }) + return true } + if (arg.startsWith('install ')) { - rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() }) - .then((r: any) => sys(r.installed ? `installed ${r.name}` : 'install failed')) + rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() }).then((r: any) => + sys(r.installed ? `installed ${r.name}` : 'install failed') + ) + return true } + if (arg === 'browse' || arg.startsWith('browse ')) { - rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() }) - .then((r: any) => { - if (!r.results?.length) { sys('no skills available'); return } - sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) - }) + rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() }).then((r: any) => { + if (!r.results?.length) { + sys('no skills available') + + return + } + + sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) + }) + return true } + if (arg.startsWith('inspect ')) { - rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() }) - .then((r: any) => sys(JSON.stringify(r.info, null, 2))) + rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() }).then((r: any) => + sys(JSON.stringify(r.info, null, 2)) + ) + return true } + sys('usage: /skills [list|search |install |browse|inspect ]') + return true case 'verbose': - rpc('config.set', { key: 'verbose', value: arg || 'cycle' }) - .then((r: any) => sys(`verbose → ${r.value}`)) + rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose → ${r.value}`)) + return true case 'yolo': - rpc('config.set', { key: 'yolo', value: '' }) - .then((r: any) => sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`)) + rpc('config.set', { key: 'yolo', value: '' }).then((r: any) => + sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`) + ) + return true case 'reasoning': if (!arg) { sys('usage: /reasoning ') + return true } - rpc('config.set', { key: 'reasoning', value: arg }) - .then((r: any) => sys(`reasoning → ${r.value}`)) + + rpc('config.set', { key: 'reasoning', value: arg }).then((r: any) => sys(`reasoning → ${r.value}`)) + return true case 'stop': - rpc('process.stop') - .then((r: any) => sys(`killed ${r.killed} process(es)`)) + rpc('process.stop').then((r: any) => sys(`killed ${r.killed} process(es)`)) + return true case 'profile': gw.request('config.get', { key: 'profile' }) .then((r: any) => sys(`profile: ${r.display}`)) .catch(() => sys(`profile: ${process.env.HERMES_HOME ?? '~/.hermes'}`)) + return true case 'save': - if (!sid) return true - rpc('session.save', { session_id: sid }) - .then((r: any) => sys(`saved to ${r.file}`)) + if (!sid) { + return true + } + + rpc('session.save', { session_id: sid }).then((r: any) => sys(`saved to ${r.file}`)) + return true case 'provider': - rpc('config.get', { key: 'provider' }) - .then((r: any) => { - const lines = [`model: ${r.model} provider: ${r.provider}`] - if (r.providers?.length) lines.push(`available: ${r.providers.join(', ')}`) - sys(lines.join('\n')) - }) + rpc('config.get', { key: 'provider' }).then((r: any) => { + const lines = [`model: ${r.model} provider: ${r.provider}`] + + if (r.providers?.length) { + lines.push(`available: ${r.providers.join(', ')}`) + } + + sys(lines.join('\n')) + }) + return true case 'prompt': if (!arg) { - rpc('config.get', { key: 'prompt' }) - .then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`)) + rpc('config.get', { key: 'prompt' }).then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`)) + return true } - rpc('config.set', { key: 'prompt', value: arg }) - .then((r: any) => sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared')) + + rpc('config.set', { key: 'prompt', value: arg }).then((r: any) => + sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared') + ) + return true case 'personality': if (!arg) { sys('usage: /personality (concise, creative, analytical, friendly, none)') + return true } - rpc('config.set', { key: 'personality', value: arg }) - .then((r: any) => sys(`personality → ${r.value || 'default'}`)) + + rpc('config.set', { key: 'personality', value: arg }).then((r: any) => + sys(`personality → ${r.value || 'default'}`) + ) + return true case 'plan': send(arg ? `/plan ${arg}` : 'Create a detailed plan for the current task.') + return true case 'background': + case 'bg': if (!arg) { sys('usage: /background ') + return true } - rpc('prompt.background', { session_id: sid, text: arg }) - .then((r: any) => sys(`background task ${r.task_id} started`)) + + rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => + sys(`background task ${r.task_id} started`) + ) + return true case 'btw': if (!arg) { sys('usage: /btw ') + return true } - rpc('prompt.btw', { session_id: sid, text: arg }) - .then(() => sys('btw running…')) + + rpc('prompt.btw', { session_id: sid, text: arg }).then(() => sys('btw running…')) + return true case 'queue': if (!arg) { sys(`${queueRef.current.length} queued message(s)`) + return true } + enqueue(arg) sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + return true case 'rollback': - if (!sid) return true - if (!arg) { - rpc('rollback.list', { session_id: sid }) - .then((r: any) => { - if (!r.enabled) { sys('checkpoints not enabled — use hermes --checkpoints'); return } - if (!r.checkpoints?.length) { sys('no checkpoints'); return } - sys(r.checkpoints.map((c: any, i: number) => - ` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})` - ).join('\n')) - }) + if (!sid) { return true } + + if (!arg) { + rpc('rollback.list', { session_id: sid }).then((r: any) => { + if (!r.enabled) { + sys('checkpoints not enabled — use hermes --checkpoints') + + return + } + + if (!r.checkpoints?.length) { + sys('no checkpoints') + + return + } + + sys( + r.checkpoints + .map((c: any, i: number) => ` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})`) + .join('\n') + ) + }) + + return true + } + if (arg.startsWith('diff ')) { const ref = arg.slice(5).trim() rpc('rollback.list', { session_id: sid }).then((r: any) => { const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref - if (!hash) { sys(`checkpoint ${ref} not found`); return } - rpc('rollback.diff', { session_id: sid, hash }) - .then((d: any) => sys(d.stat || d.diff || 'no changes')) + + if (!hash) { + sys(`checkpoint ${ref} not found`) + + return + } + + rpc('rollback.diff', { session_id: sid, hash }).then((d: any) => sys(d.stat || d.diff || 'no changes')) }) + return true } + { const parts = arg.trim().split(/\s+/) const ref = parts[0]! const file = parts[1] rpc('rollback.list', { session_id: sid }).then((r: any) => { const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref - if (!hash) { sys(`checkpoint ${ref} not found`); return } - rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) }) - .then((d: any) => sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`)) + + if (!hash) { + sys(`checkpoint ${ref} not found`) + + return + } + + rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) }).then((d: any) => + sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`) + ) }) } + return true case 'insights': - rpc('insights.get', { days: arg ? parseInt(arg) : 30 }) - .then((r: any) => sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`)) + rpc('insights.get', { days: arg ? parseInt(arg) : 30 }).then((r: any) => + sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`) + ) + return true case 'toolsets': if (!info?.tools) { sys('no toolsets loaded') + return true } - sys(Object.entries(info.tools).map(([k, vs]) => `${k}: ${vs.length} tools`).join('\n')) + + sys( + Object.entries(info.tools) + .map(([k, vs]) => `${k}: ${vs.length} tools`) + .join('\n') + ) + return true case 'paste': - sys('clipboard paste: use your terminal\'s paste shortcut (images not yet supported in TUI)') + sys("clipboard paste: use your terminal's paste shortcut (images not yet supported in TUI)") + return true case 'reload-mcp': + case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }) - .then(() => sys('MCP servers reloaded')) + rpc('reload.mcp', { session_id: sid }).then(() => sys('MCP servers reloaded')) + return true case 'browser': if (!arg || arg === 'status') { - rpc('browser.manage', { action: 'status' }) - .then((r: any) => sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected')) + rpc('browser.manage', { action: 'status' }).then((r: any) => + sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected') + ) } else if (arg === 'connect' || arg.startsWith('connect ')) { const url = arg.split(/\s+/)[1] - rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) }) - .then((r: any) => sys(`browser connected: ${r.url}`)) + rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) }).then((r: any) => + sys(`browser connected: ${r.url}`) + ) } else if (arg === 'disconnect') { - rpc('browser.manage', { action: 'disconnect' }) - .then(() => sys('browser disconnected')) + rpc('browser.manage', { action: 'disconnect' }).then(() => sys('browser disconnected')) } else { sys('usage: /browser [connect|disconnect|status]') } + return true case 'platforms': + case 'gateway': sys('gateway status is not available in TUI mode') + return true case 'statusbar': + case 'sb': setStatusBar(v => !v) sys(`status bar ${statusBar ? 'off' : 'on'}`) + return true case 'voice': if (!arg || arg === 'status') { - rpc('voice.toggle', { action: 'status' }) - .then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`)) + rpc('voice.toggle', { action: 'status' }).then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`)) } else if (arg === 'on' || arg === 'off') { - rpc('voice.toggle', { action: arg }) - .then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`)) + rpc('voice.toggle', { action: arg }).then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`)) } else if (arg === 'record') { - rpc('voice.record', { action: 'start' }) - .then(() => sys('recording… (use /voice stop to transcribe)')) + rpc('voice.record', { action: 'start' }).then(() => sys('recording… (use /voice stop to transcribe)')) } else if (arg === 'stop') { - rpc('voice.record', { action: 'stop' }) - .then((r: any) => { - if (r.text) { send(r.text) } else { sys('no speech detected') } - }) + rpc('voice.record', { action: 'stop' }).then((r: any) => { + if (r.text) { + send(r.text) + } else { + sys('no speech detected') + } + }) } else if (arg === 'tts') { const last = messages.filter(m => m.role === 'assistant').at(-1) + if (last) { - rpc('voice.tts', { text: last.text }) - .then(() => sys('speaking…')) + rpc('voice.tts', { text: last.text }).then(() => sys('speaking…')) } else { sys('no response to speak') } } else { sys('usage: /voice [on|off|status|record|stop|tts]') } + return true case 'plugins': - rpc('plugins.list') - .then((r: any) => { - if (!r.plugins?.length) { sys('no plugins installed'); return } - sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n')) - }) + rpc('plugins.list').then((r: any) => { + if (!r.plugins?.length) { + sys('no plugins installed') + + return + } + + sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n')) + }) + return true case 'cron': if (!arg || arg === 'list') { - rpc('cron.manage', { action: 'list' }) - .then((r: any) => { - const jobs = r.jobs || r.schedules || [] - if (!jobs.length) { sys('no cron jobs'); return } - sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n')) - }) + rpc('cron.manage', { action: 'list' }).then((r: any) => { + const jobs = r.jobs || r.schedules || [] + + if (!jobs.length) { + sys('no cron jobs') + + return + } + + sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n')) + }) } else { const parts = arg.split(/\s+/) const sub = parts[0]! + if (sub === 'add' || sub === 'create') { const name = parts[1] || '' const schedule = parts[2] || '' const prompt = parts.slice(3).join(' ') - rpc('cron.manage', { action: 'add', name, schedule, prompt }) - .then((r: any) => sys(r.message || r.status || 'created')) + rpc('cron.manage', { action: 'add', name, schedule, prompt }).then((r: any) => + sys(r.message || r.status || 'created') + ) } else { - rpc('cron.manage', { action: sub, name: parts[1] || '' }) - .then((r: any) => sys(r.message || r.status || JSON.stringify(r))) + rpc('cron.manage', { action: sub, name: parts[1] || '' }).then((r: any) => + sys(r.message || r.status || JSON.stringify(r)) + ) } } + return true case 'update': sys('update not available in TUI mode — run: pip install -U hermes-agent') + + return true + + case 'hermes': + if (!arg) { + sys( + 'usage: /hermes non-interactive `hermes` CLI (e.g. sessions list, chat -q "hi"). Interactive setup/browse/edit must run in a separate terminal.' + ) + + return true + } + + rpc('cli.exec', { argv: arg.split(/\s+/).filter(Boolean) }) + .then((r: any) => { + if (r.blocked) { + sys(r.hint ?? 'blocked') + + return + } + + sys(r.output ?? '(no output)') + + if (r.code !== 0) { + sys(`exit ${r.code}`) + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + return true case 'model': @@ -1017,8 +1249,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - rpc('config.set', { key: 'model', value: arg }) - .then(() => sys(`model → ${arg}`)) + rpc('config.set', { key: 'model', value: arg }).then(() => sys(`model → ${arg}`)) return true @@ -1029,8 +1260,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - rpc('config.set', { key: 'skin', value: arg }) - .then(() => sys(`skin → ${arg} (restart to apply)`)) + rpc('config.set', { key: 'skin', value: arg }).then(() => sys(`skin → ${arg} (restart to apply)`)) return true @@ -1060,11 +1290,12 @@ export function App({ gw }: { gw: GatewayClient }) { }) .catch(() => sys(`unknown command: /${name}`)) }) + return true } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [compact, gw, info, lastUserMsg, messages, newSession, rpc, send, sid, status, sys, usage, statusBar] + [catalog, compact, gw, info, lastUserMsg, messages, newSession, rpc, send, sid, status, sys, usage, statusBar] ) const submit = useCallback( @@ -1320,7 +1551,7 @@ export function App({ gw }: { gw: GatewayClient }) { /> )} - {!blocked && input.startsWith('/') && } + {!blocked && input.startsWith('/') && } diff --git a/ui-tui/src/components/commandPalette.tsx b/ui-tui/src/components/commandPalette.tsx index be7f755d04..810dc01007 100644 --- a/ui-tui/src/components/commandPalette.tsx +++ b/ui-tui/src/components/commandPalette.tsx @@ -1,23 +1,21 @@ import { Box, Text } from 'ink' -import { COMMANDS } from '../constants.js' import type { Theme } from '../theme.js' -export function CommandPalette({ filter, t }: { filter: string; t: Theme }) { - const matches = COMMANDS.filter(([cmd]) => cmd.startsWith(filter)) - +export function CommandPalette({ matches, t }: { matches: [string, string][]; t: Theme }) { if (!matches.length) { return null } return ( - - {matches.map(([cmd, desc]) => ( - + + {matches.map(([cmd, desc], i) => ( + {cmd} - — {desc} + + {desc ? — {desc} : null} ))} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index adee83ad28..7b1d157755 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -147,37 +147,47 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st if (line.match(/^>\s?/)) { const quoteLines: string[] = [] + while (i < lines.length && lines[i]!.match(/^>\s?/)) { quoteLines.push(lines[i]!.replace(/^>\s?/, '')) i++ } + nodes.push( {quoteLines.map((ql, qi) => ( - {' │ '} + {' │ '} + ))} ) + continue } if (line.includes('|') && line.trim().startsWith('|')) { const tableRows: string[][] = [] + while (i < lines.length && lines[i]!.trim().startsWith('|')) { const row = lines[i]!.trim() + if (!/^[|\s:-]+$/.test(row)) { tableRows.push( - row.split('|').filter(Boolean).map(c => c.trim()) + row + .split('|') + .filter(Boolean) + .map(c => c.trim()) ) } + i++ } + if (tableRows.length) { - const widths = tableRows[0]!.map((_, ci) => - Math.max(...tableRows.map(r => (r[ci] ?? '').length)) - ) + const widths = tableRows[0]!.map((_, ci) => Math.max(...tableRows.map(r => (r[ci] ?? '').length))) + nodes.push( {tableRows.map((row, ri) => ( @@ -188,6 +198,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st ) } + continue } diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx index 96fe21e1c8..f2e8d95ce7 100644 --- a/ui-tui/src/components/maskedPrompt.tsx +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -5,7 +5,11 @@ import { useState } from 'react' import type { Theme } from '../theme.js' export function MaskedPrompt({ - icon, label, onSubmit, sub, t + icon, + label, + onSubmit, + sub, + t }: { icon: string label: string @@ -17,8 +21,10 @@ export function MaskedPrompt({ return ( - {icon} {label} - {sub && {sub}} + + {icon} {label} + + {sub && {sub}} {'> '} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index baa8ad25b2..9b5750b093 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -14,8 +14,15 @@ interface SessionItem { function age(ts: number): string { const d = (Date.now() / 1000 - ts) / 86400 - if (d < 1) return 'today' - if (d < 2) return 'yesterday' + + if (d < 1) { + return 'today' + } + + if (d < 2) { + return 'yesterday' + } + return `${Math.floor(d)}d ago` } @@ -46,16 +53,32 @@ export function SessionPicker({ }, [gw]) useInput((ch, key) => { - if (key.escape) return onCancel() - if (key.upArrow && sel > 0) setSel(s => s - 1) - if (key.downArrow && sel < items.length - 1) setSel(s => s + 1) - if (key.return && items[sel]) onSelect(items[sel]!.id) + if (key.escape) { + return onCancel() + } + + if (key.upArrow && sel > 0) { + setSel(s => s - 1) + } + + if (key.downArrow && sel < items.length - 1) { + setSel(s => s + 1) + } + + if (key.return && items[sel]) { + onSelect(items[sel]!.id) + } const n = parseInt(ch) - if (n >= 1 && n <= Math.min(9, items.length)) onSelect(items[n - 1]!.id) + + if (n >= 1 && n <= Math.min(9, items.length)) { + onSelect(items[n - 1]!.id) + } }) - if (loading) return loading sessions… + if (loading) { + return loading sessions… + } if (!items.length) { return ( @@ -71,10 +94,13 @@ export function SessionPicker({ return ( - Resume Session - {off > 0 && ↑ {off} more} + + Resume Session + + {off > 0 && ↑ {off} more} {visible.map((s, vi) => { const i = off + vi + return ( {sel === i ? '▸ ' : ' '} @@ -82,12 +108,13 @@ export function SessionPicker({ {i + 1}. {s.title || s.preview || s.id.slice(0, 8)} - {' '}({s.message_count} msgs, {age(s.started_at)}) + {' '} + ({s.message_count} msgs, {age(s.started_at)}) ) })} - {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} + {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} ↑/↓ select · Enter resume · 1-9 quick · Esc cancel ) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index f5e7351fad..8d1fbde210 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -6,7 +6,17 @@ import { pick } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool } from '../types.js' -export function Thinking({ reasoning, t, thinking, tools }: { reasoning: string; t: Theme; thinking?: string; tools: ActiveTool[] }) { +export function Thinking({ + reasoning, + t, + thinking, + tools +}: { + reasoning: string + t: Theme + thinking?: string + tools: ActiveTool[] +}) { const [frame, setFrame] = useState(0) const [verb] = useState(() => pick(VERBS)) const [face] = useState(() => pick(FACES)) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 5b921807b0..f638b3f435 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -1,50 +1,6 @@ import type { Theme } from './theme.js' import type { Role, Usage } from './types.js' -export const COMMANDS: [string, string][] = [ - ['/help', 'commands & hotkeys'], - ['/new', 'new session'], - ['/resume', 'resume a previous session'], - ['/title', 'set session title'], - ['/history', 'show session list'], - ['/clear', 'reset session + chat'], - ['/undo', 'drop last exchange'], - ['/retry', 'resend last message'], - ['/save', 'save conversation to file'], - ['/compact', 'toggle compact [focus]'], - ['/compress', 'compress context'], - ['/model', 'switch model'], - ['/skin', 'change theme'], - ['/provider', 'show model/provider info'], - ['/prompt', 'set custom system prompt'], - ['/personality', 'set personality preset'], - ['/verbose', 'cycle tool verbosity'], - ['/yolo', 'toggle auto-approve mode'], - ['/reasoning', 'set reasoning level'], - ['/tools', 'list active tools'], - ['/toolsets', 'list toolsets'], - ['/skills', 'list skills'], - ['/stop', 'kill background processes'], - ['/background', 'run prompt in background'], - ['/btw', 'side question (no tools)'], - ['/plan', 'invoke plan skill'], - ['/queue', 'queue prompt for next turn'], - ['/profile', 'show active profile'], - ['/cost', 'token usage stats'], - ['/context', 'context window info'], - ['/insights', 'usage analytics'], - ['/copy', 'copy last response'], - ['/paste', 'clipboard info'], - ['/config', 'show config'], - ['/status', 'session info'], - ['/statusbar', 'toggle status bar'], - ['/voice', 'voice mode toggle'], - ['/reload-mcp', 'reload MCP servers'], - ['/rollback', 'checkpoint info'], - ['/browser', 'browser tools info'], - ['/quit', 'exit hermes'] -] - export const FACES = [ '(。•́︿•̀。)', '(◔_◔)', @@ -67,6 +23,7 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+C', 'interrupt / clear / exit'], ['Ctrl+D', 'exit'], ['Ctrl+L', 'clear screen'], + ['Tab', 'complete /commands (registry-aware)'], ['↑/↓', 'queue edit (if queued) / input history'], ['PgUp/PgDn', 'scroll messages'], ['Esc', 'clear input'], diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index cc0a15b4c9..b8c247d97a 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,5 +1,5 @@ -import React from 'react' import { render } from 'ink' +import React from 'react' import { App } from './app.js' import { GatewayClient } from './gatewayClient.js' diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index b77ba44439..6300cef3a7 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs' +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -17,34 +17,51 @@ function decode(s: string): string { } export function load(): string[] { - if (cache) return cache + if (cache) { + return cache + } + try { if (existsSync(file)) { - cache = readFileSync(file, 'utf8') - .split('\n') - .filter(Boolean) - .map(decode) - .slice(-MAX) + cache = readFileSync(file, 'utf8').split('\n').filter(Boolean).map(decode).slice(-MAX) } else { cache = [] } } catch { cache = [] } + return cache } export function append(line: string): void { const trimmed = line.trim() - if (!trimmed) return + + if (!trimmed) { + return + } + const items = load() - if (items.at(-1) === trimmed) return + + if (items.at(-1) === trimmed) { + return + } + items.push(trimmed) - if (items.length > MAX) items.splice(0, items.length - MAX) + + if (items.length > MAX) { + items.splice(0, items.length - MAX) + } + try { - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + appendFileSync(file, encode(trimmed) + '\n') - } catch { /* ignore */ } + } catch { + /* ignore */ + } } export function all(): string[] { diff --git a/ui-tui/src/lib/slash.ts b/ui-tui/src/lib/slash.ts new file mode 100644 index 0000000000..07b106c7d1 --- /dev/null +++ b/ui-tui/src/lib/slash.ts @@ -0,0 +1,124 @@ +import type { SlashCatalog } from '../types.js' + +/** Match SlashCommandCompleter: command names, subcommands, then skills. */ +export function paletteForLine(line: string, c: SlashCatalog | null): [string, string][] { + if (!c || !line.startsWith('/')) { + return [] + } + + const parts = line.split(/\s+/) + const baseRaw = parts[0]! + const base = baseRaw.toLowerCase() + const inSub = parts.length > 1 || (parts.length === 1 && line.endsWith(' ')) + + if (inSub) { + const subText = parts.length > 1 ? parts.slice(1).join(' ') : '' + + if (subText.includes(' ') || parts.length > 2) { + return [] + } + + const head = subText.split(/\s+/)[0] ?? '' + + if (subText.includes(' ') && head !== subText) { + return [] + } + + const canonical = c.canon[base] ?? baseRaw + const subs = c.sub[canonical] + + if (!subs?.length) { + return [] + } + + const lo = head.toLowerCase() + + return subs + .filter(s => s.toLowerCase().startsWith(lo) && s.toLowerCase() !== lo) + .slice(0, 14) + .map(s => [s, '']) + } + + const word = line.slice(1) + + return c.pairs + .filter(([k]) => k.slice(1).startsWith(word)) + .slice(0, 16) + .map(([k, d]) => [k, d]) +} + +/** Tab: longest common prefix of palette matches, or first unique completion + space. */ +export function tabAdvance(line: string, c: SlashCatalog | null): string | null { + if (!c || !line.startsWith('/')) { + return null + } + + const rows = paletteForLine(line, c) + + if (!rows.length) { + return null + } + + const parts = line.split(/\s+/) + const baseRaw = parts[0]! + const base = baseRaw.toLowerCase() + const inSub = parts.length > 1 || (parts.length === 1 && line.endsWith(' ')) + + if (inSub) { + const subText = parts.length > 1 ? parts.slice(1).join(' ') : '' + const head = subText.split(/\s+/)[0] ?? '' + const picks = rows.map(([s]) => s) + + if (picks.length === 1) { + return `${baseRaw} ${picks[0]!} ` + } + + const cp = commonPrefix(picks) + + if (cp.length > head.length) { + return `${baseRaw} ${cp}` + } + + return null + } + + const word = line.slice(1) + const names = rows.map(([k]) => k.slice(1)) + const cp = commonPrefix(names) + + if (names.length === 1) { + return `/${names[0]!} ` + } + + if (cp.length > word.length) { + return `/${cp}` + } + + return null +} + +function commonPrefix(xs: string[]): string { + if (!xs.length) { + return '' + } + + let n = 0 + + outer: while (true) { + const ch = xs[0]![n] + + if (ch === undefined) { + break + } + + for (const x of xs) { + if (x[n] !== ch) { + break outer + } + } + + n++ + } + + return xs[0]!.slice(0, n) +} diff --git a/ui-tui/src/main.tsx b/ui-tui/src/main.tsx index cc0a15b4c9..b8c247d97a 100644 --- a/ui-tui/src/main.tsx +++ b/ui-tui/src/main.tsx @@ -1,5 +1,5 @@ -import React from 'react' import { render } from 'ink' +import React from 'react' import { App } from './app.js' import { GatewayClient } from './gatewayClient.js' diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 253c46069c..4e3bfce2d9 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -43,3 +43,10 @@ export interface SecretReq { prompt: string requestId: string } + +/** From `commands.catalog` — mirrors hermes_cli.commands COMMANDS + SUBCOMMANDS + skills. */ +export interface SlashCatalog { + canon: Record + pairs: [string, string][] + sub: Record +}