mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
"PT" was internal shorthand for prompt_toolkit that leaked into AGENTS.md and the TUI post-mortem. Spell it out. - AGENTS.md: "PT CLI" → "classic (prompt_toolkit) CLI" - docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md: both hits
6.9 KiB
6.9 KiB
Ink Gateway TUI Migration — Post-mortem
Planned: 2026-04-01 · Delivered: 2026-04 · Status: shipped, classic (prompt_toolkit) CLI still present
What Shipped
Three layers, same repo, Python runtime unchanged.
ui-tui (Node/TS) ──stdio JSON-RPC──▶ tui_gateway (Py) ──▶ AIAgent (run_agent.py)
Backend — tui_gateway/
tui_gateway/
├── entry.py # subprocess entrypoint, stdio read/write loop
├── server.py # everything: sessions dict, @method handlers, _emit
├── render.py # stream renderer, diff rendering, message rendering
├── slash_worker.py # subprocess that runs hermes_cli slash commands
└── __init__.py
server.py owns the full runtime-control surface: session store (_sessions: dict[str, dict]), method registry (@method("…") decorator), event emitter (_emit), agent lifecycle (_make_agent, _init_session, _wire_callbacks), approval/sudo/clarify round-trips, and JSON-RPC dispatch.
Protocol methods (@method(...) in server.py):
- session:
session.{create, resume, list, close, interrupt, usage, history, compress, branch, title, save, undo} - prompt:
prompt.{submit, background, btw} - tools:
tools.{list, show, configure} - slash:
slash.exec,command.{dispatch, resolve},commands.catalog,complete.{path, slash} - approvals:
approval.respond,sudo.respond,clarify.respond,secret.respond - config/state:
config.{get, set, show},model.options,reload.mcp - ops:
shell.exec,cli.exec,terminal.resize,input.detect_drop,clipboard.paste,paste.collapse,image.attach,process.stop - misc:
agents.list,skills.manage,plugins.list,cron.manage,insights.get,rollback.{list, diff, restore},browser.manage
Protocol events (_emit(…) → handled in ui-tui/src/app/createGatewayEventHandler.ts):
- lifecycle:
gateway.{ready, stderr},session.info,skin.changed - stream:
message.{start, delta, complete},thinking.delta,reasoning.{delta, available},status.update - tools:
tool.{start, progress, complete, generating},subagent.{start, thinking, tool, progress, complete} - interactive:
approval.request,sudo.request,clarify.request,secret.request - async:
background.complete,btw.complete,error
Frontend — ui-tui/src/
src/
├── entry.tsx # node bootstrap: bootBanner → spawn python → dynamic-import Ink → render(<App/>)
├── app.tsx # <GatewayProvider> wraps <AppLayout>
├── bootBanner.ts # raw-ANSI banner to stdout in ~2ms, pre-React
├── gatewayClient.ts # JSON-RPC client over child_process stdio
├── gatewayTypes.ts # typed RPC responses + GatewayEvent union
├── theme.ts # DEFAULT_THEME + fromSkin
│
├── app/ # hooks + stores — the orchestration layer
│ ├── uiStore.ts # nanostore: sid, info, busy, usage, theme, status…
│ ├── turnStore.ts # nanostore: per-turn activity / reasoning / tools
│ ├── turnController.ts # imperative singleton for stream-time operations
│ ├── overlayStore.ts # nanostore: modal/overlay state
│ ├── useMainApp.ts # top-level composition hook
│ ├── useSessionLifecycle.ts # session.create/resume/close/reset
│ ├── useSubmission.ts # shell/slash/prompt dispatch + interpolation
│ ├── useConfigSync.ts # config.get + mtime poll
│ ├── useComposerState.ts # input buffer, paste snippets, editor mode
│ ├── useInputHandlers.ts # key bindings
│ ├── createGatewayEventHandler.ts # event-stream dispatcher
│ ├── createSlashHandler.ts # slash command router (registry + python fallback)
│ └── slash/commands/ # core.ts, ops.ts, session.ts — TS-owned slash commands
│
├── components/ # AppLayout, AppChrome, AppOverlays, MessageLine, Thinking, Markdown, pickers, prompts, Banner, SessionPanel
├── config/ # env, limits, timing constants
├── content/ # charms, faces, fortunes, hotkeys, placeholders, verbs
├── domain/ # details, messages, paths, roles, slash, usage, viewport
├── protocol/ # interpolation, paste regex
├── hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory
└── lib/ # history, messages, osc52, rpc, text
CLI entry points — hermes_cli/main.py
hermes --tui→node dist/entry.js(auto-builds when.ts/.tsxnewer thandist/entry.js)hermes --tui --dev→tsx src/entry.tsx(skip build)HERMES_TUI_DIR=…→ external prebuilt dist (nix, distro packaging)
Diverged From Original Plan
| Plan | Reality | Why |
|---|---|---|
tui_gateway/{controller,session_state,events,protocol}.py |
all collapsed into server.py |
no second consumer ever emerged, keeping one file cheaper than four |
ui-tui/src/main.tsx |
split into entry.tsx (bootstrap) + app.tsx (shell) |
boot banner + early python spawn wanted a pre-React moment |
ui-tui/src/state/store.ts |
three nanostores (uiStore, turnStore, overlayStore) |
separate lifetimes: ui persists, turn resets per reply, overlay is modal |
approval.requested / sudo.requested / clarify.requested |
*.request (no -ed) |
cosmetic |
session.cancel |
dropped | session.interrupt covers it |
HERMES_EXPERIMENTAL_TUI=1, display.experimental_tui: true, /tui on/off/status |
none shipped | --tui went from opt-in to first-class without an experimental phase |
Post-migration Additions (not in original plan)
- Async
session.create— returns sid in ~1ms, agent builds on a background thread,session.infobroadcasts when ready;_wait_agent()gates every agent-touching handler via_sess bootBanner— raw-ANSI logo painted to stdout at T≈2ms, before Ink loads;<AlternateScreen>wipes it seamlessly when React mounts- Selection uniform bg —
theme.color.selectionBgwired viauseSelection().setSelectionBgColor; replaces SGR-inverse per-cell swap that fragmented over amber/gold fg - Slash command registry — TS-owned commands in
app/slash/commands/{core,ops,session}.ts, everything else falls through toslash.exec(python worker) - Turn store + controller split — imperative singleton (
turnController) holds refs/timers, nanostore (turnStore) holds render-visible state
What's Still Open
- Classic CLI not deleted.
cli.pystill has ~80prompt_toolkitreferences; classic REPL is still the default when--tuiis absent. The original plan's "Cut 4 · prompt_toolkit removal later" hasn't happened. - No config-file opt-in.
HERMES_EXPERIMENTAL_TUIanddisplay.experimental_tuiwere never built; only the CLI flag exists. Fine for now — if we want "default to TUI", a single line inmain.pyflips it.