# 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.tsx # wraps ├── 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`/`.tsx` newer than `dist/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.info` broadcasts when ready; `_wait_agent()` gates every agent-touching handler via `_sess` - **`bootBanner`** — raw-ANSI logo painted to stdout at T≈2ms, before Ink loads; `` wipes it seamlessly when React mounts - **Selection uniform bg** — `theme.color.selectionBg` wired via `useSelection().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 to `slash.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.py` still has ~80 `prompt_toolkit` references; classic REPL is still the default when `--tui` is absent. The original plan's "Cut 4 · prompt_toolkit removal later" hasn't happened. - **No config-file opt-in.** `HERMES_EXPERIMENTAL_TUI` and `display.experimental_tui` were never built; only the CLI flag exists. Fine for now — if we want "default to TUI", a single line in `main.py` flips it.