Hermes fork — CLI/TUI rendering
The one correction that matters
ui-tui/ is NOT a Python textual app. It is a React-Ink (TypeScript) TUI.
The Python CLI (cli.py + agent/) uses rich + prompt_toolkit. These are
two independent rendering stacks that share almost nothing:
- Python CLI:
rich for panels/tables/markdown/spinners, prompt_toolkit for the REPL + patch_stdout.
- TS TUI (
ui-tui/): ink/react/nanostores, plus a local @hermes/ink bundle (chalk, cli-boxes, wrap-ansi, strip-ansi). Talks to the Python agent via a gateway over events, not in-process.
If a brief says "the TUI uses textual", correct it before doing anything else.
Two stacks at a glance
| Concern |
Python CLI |
TS TUI (ui-tui/) |
| Libs |
rich, prompt_toolkit, Markdown |
ink@6, react@19, nanostores, ink-text-input, unicode-animations |
| Entry |
hermes_cli.main:main / run_agent:main |
ui-tui/src/entry.tsx |
| Build |
setuptools (pyproject) |
npm run build:ink (esbuild) then node scripts/build.mjs |
| Spinner |
agent/display.py::KawaiiSpinner |
unicode-animations braille frames |
| Tool-call UI |
agent/display.py + agent/tool_executor.py |
components/thinking.tsx (ToolTrail) + components/messageLine.tsx |
| Streaming |
patch_stdout REPL |
components/streamingMarkdown.tsx |
| Connects to agent |
in-process |
GatewayClient → Python gateway (undici) |
Launch / build
TUI: ui-tui/src/entry.tsx — new GatewayClient() (L51) → gw.start() (L53) → ink.render(<App gw={gw}/>) (L141). No TTY → exit (L17-20). resetTerminalModes() on start + process.on('exit') (L24, L38) so a killed TUI doesn't leave DEC mouse tracking armed in the parent shell (bug #28419). @hermes/ink is bundled via esbuild (packages/hermes-ink/package.json build L7).
Python CLI: cli.py builds a prompt_toolkit.Application + patch_stdout (imports L58-71) so a live spinner and streamed text coexist with the input box.
Event protocol (TUI ↔ gateway)
The TUI never renders directly off the model stream; the Python gateway emits
typed events that a handler reduces into store state. See
ui-tui/src/app/createGatewayEventHandler.ts:
tool.start (L728) → recordToolStart(tool_id, name, context, args_text)
tool.progress (L706) → recordToolProgress(name, preview)
tool.complete (L738) → recordToolComplete(...) or recordInlineDiffToolComplete(...) when inline_diff present
tool.generating (L713), reaction (L720), clarify/approval/sudo/secret.request (L776+)
Pattern: the renderer is a pure function of store state, not of the raw event. Keep that boundary — it's what makes the UI testable and lets the gateway be swapped.
Tool-call render path (TS)
streamingAssistant.tsx flattens live segments → messageLine.tsx. A trail msg
with tools/thoughts renders <ToolTrail> (thinking.tsx); a tool-role msg renders
a bordered rounded Box (messageLine.tsx L110-120). Trail line format lives in
lib/text.ts:
toolTrailLabel(name) snake→Title Case (L191)
formatToolCall(name, ctx) → Name("preview") (L198)
buildToolTrailLine(...) → Name("preview") (1.2s) :: detail ✓/✗ (L205)
Tree rails ├─ └─ in thinking.tsx::treeLead (L54). In-flight tools get a braille
Spinner from unicode-animations (thinking.tsx L3, Spinner L153); frame sets
THINK/TOOL (L40-41).
Streaming text (no flicker) — the key trick
streamingMarkdown.tsx does not re-tokenize the whole message per delta. It
splits text at the last stable top-level block boundary (blank line outside a
fenced code span): stablePrefix is passed to an inner <Md> memoized on its exact
text (grows monotonically, memo key matches → React reuses the subtree, zero
re-parse); unstableSuffix (the in-flight tail) is the only part re-parsed each
delta — O(unstable) not O(total). boundedLiveRenderText caps worst-case cost.
Reusable patterns (the durable lesson)
- Event-driven UI: agent emits
tool.start|progress|complete; renderer = pure fn of state. Decouples transport from paint.
- One line per call:
Name("preview") ✓/✗ — name + truncated arg + status glyph. Cheap, scannable.
- Braille spinners (
unicode-animations) for in-flight tools instead of redrawing.
- Incremental markdown: split at last block boundary, memoize stable prefix, only re-parse the tail.
- Bounded live text +
patch_stdout-style stdout routing so the render loop doesn't clobber the input box.
- Terminal cleanup contract: reset mouse/focus/paste modes on exit AND on a
process.on('exit') backstop — killed TUIs otherwise leak DEC mouse tracking into the shell (bug #28419).
Pitfalls
- Assuming
textual for ui-tui/ — wrong; it's React-Ink.
- Editing a
trail renderer without threading toolsMode/thinkingMode/activityMode section visibility → empty Box gutter bug (see messageLine.tsx L42-101 and domain/blockLayout.ts::hasLeadGap).
- Forgetting
sys_platform markers on Windows-only deps (pyproject.toml L98,140) — they won't install on macOS/Linux.
unicode-animations frames can be multi-codepoint; streamingAssistant/thinking collapse to [...f][0] to keep width stable (thinking.tsx L157).
References
references/fork-ui-map.md — full file:line dissection of packages, build, tool-call rendering, and streaming (the source of truth for "where is X").
references/ink-streaming-patterns.md — the reusable Ink/React streaming + tool-trail patterns, framework-agnostic, for building your own.
1---2name: hermes-fork-ui3description: Navigate, dissect, and modify the Hermes fork's CLI/TUI rendering — the two-stack architecture (Python `rich`+`prompt_toolkit` CLI vs TypeScript React-Ink TUI), the TUI↔Python-gateway event protocol, and the reusable streaming/tool-trail rendering patterns. Use when asked to "dissect the fork's UI", "how does the TUI render tool calls / streaming", "where is the tool-call UI", or when building a streaming agent TUI.4---5
6# Hermes fork — CLI/TUI rendering
7
8## The one correction that matters
9
10`ui-tui/` is **NOT** a Python `textual` app. It is a **React-Ink (TypeScript)** TUI.
11The Python CLI (`cli.py` + `agent/`) uses **`rich` + `prompt_toolkit`**. These are
12**two independent rendering stacks** that share almost nothing:
13
14- Python CLI: `rich` for panels/tables/markdown/spinners, `prompt_toolkit` for the REPL + `patch_stdout`.
15- TS TUI (`ui-tui/`): `ink`/`react`/`nanostores`, plus a local `@hermes/ink` bundle (chalk, cli-boxes, wrap-ansi, strip-ansi). Talks to the Python agent via a **gateway** over events, not in-process.
16
17If a brief says "the TUI uses textual", correct it before doing anything else.
18
19## Two stacks at a glance
20
21| Concern | Python CLI | TS TUI (`ui-tui/`) |
22|---|---|---|
23| Libs | `rich`, `prompt_toolkit`, `Markdown` | `ink@6`, `react@19`, `nanostores`, `ink-text-input`, `unicode-animations` |
24| Entry | `hermes_cli.main:main` / `run_agent:main` | `ui-tui/src/entry.tsx` |
25| Build | setuptools (pyproject) | `npm run build:ink` (esbuild) then `node scripts/build.mjs` |
26| Spinner | `agent/display.py::KawaiiSpinner` | `unicode-animations` braille frames |
27| Tool-call UI | `agent/display.py` + `agent/tool_executor.py` | `components/thinking.tsx` (`ToolTrail`) + `components/messageLine.tsx` |
28| Streaming | `patch_stdout` REPL | `components/streamingMarkdown.tsx` |
29| Connects to agent | in-process | `GatewayClient` → Python gateway (undici) |
30
31## Launch / build
32
33TUI: `ui-tui/src/entry.tsx` — `new GatewayClient()` (L51) → `gw.start()` (L53) → `ink.render(<App gw={gw}/>)` (L141). No TTY → exit (L17-20). `resetTerminalModes()` on start + `process.on('exit')` (L24, L38) so a killed TUI doesn't leave DEC mouse tracking armed in the parent shell (bug #28419). `@hermes/ink` is bundled via esbuild (`packages/hermes-ink/package.json` build L7).
34
35Python CLI: `cli.py` builds a `prompt_toolkit.Application` + `patch_stdout` (imports L58-71) so a live spinner and streamed text coexist with the input box.
36
37## Event protocol (TUI ↔ gateway)
38
39The TUI never renders directly off the model stream; the Python gateway emits
40typed events that a handler reduces into store state. See
41`ui-tui/src/app/createGatewayEventHandler.ts`:
42- `tool.start` (L728) → `recordToolStart(tool_id, name, context, args_text)`
43- `tool.progress` (L706) → `recordToolProgress(name, preview)`
44- `tool.complete` (L738) → `recordToolComplete(...)` or `recordInlineDiffToolComplete(...)` when `inline_diff` present
45- `tool.generating` (L713), `reaction` (L720), `clarify/approval/sudo/secret.request` (L776+)
46
47**Pattern: the renderer is a pure function of store state**, not of the raw event. Keep that boundary — it's what makes the UI testable and lets the gateway be swapped.
48
49## Tool-call render path (TS)
50
51`streamingAssistant.tsx` flattens live segments → `messageLine.tsx`. A `trail` msg
52with tools/thoughts renders `<ToolTrail>` (`thinking.tsx`); a `tool`-role msg renders
53a bordered rounded `Box` (`messageLine.tsx` L110-120). Trail line format lives in
54`lib/text.ts`:
55- `toolTrailLabel(name)` snake→Title Case (L191)
56- `formatToolCall(name, ctx)` → `Name("preview")` (L198)
57- `buildToolTrailLine(...)` → `Name("preview") (1.2s) :: detail ✓/✗` (L205)
58
59Tree rails `├─ └─` in `thinking.tsx::treeLead` (L54). In-flight tools get a braille
60`Spinner` from `unicode-animations` (`thinking.tsx` L3, `Spinner` L153); frame sets
61`THINK`/`TOOL` (L40-41).
62
63## Streaming text (no flicker) — the key trick
64
65`streamingMarkdown.tsx` does **not** re-tokenize the whole message per delta. It
66splits `text` at the last stable top-level block boundary (blank line outside a
67fenced code span): `stablePrefix` is passed to an inner `<Md>` memoized on its exact
68text (grows monotonically, memo key matches → React reuses the subtree, zero
69re-parse); `unstableSuffix` (the in-flight tail) is the only part re-parsed each
70delta — O(unstable) not O(total). `boundedLiveRenderText` caps worst-case cost.
71
72## Reusable patterns (the durable lesson)
73
741. **Event-driven UI**: agent emits `tool.start|progress|complete`; renderer = pure fn of state. Decouples transport from paint.
752. **One line per call**: `Name("preview") ✓/✗` — name + truncated arg + status glyph. Cheap, scannable.
763. **Braille spinners** (`unicode-animations`) for in-flight tools instead of redrawing.
774. **Incremental markdown**: split at last block boundary, memoize stable prefix, only re-parse the tail.
785. **Bounded live text** + `patch_stdout`-style stdout routing so the render loop doesn't clobber the input box.
796. **Terminal cleanup contract**: reset mouse/focus/paste modes on exit AND on a `process.on('exit')` backstop — killed TUIs otherwise leak DEC mouse tracking into the shell (bug #28419).
80
81## Pitfalls
82
83- Assuming `textual` for `ui-tui/` — wrong; it's React-Ink.
84- Editing a `trail` renderer without threading `toolsMode`/`thinkingMode`/`activityMode` section visibility → empty `Box` gutter bug (see `messageLine.tsx` L42-101 and `domain/blockLayout.ts::hasLeadGap`).
85- Forgetting `sys_platform` markers on Windows-only deps (`pyproject.toml` L98,140) — they won't install on macOS/Linux.
86- `unicode-animations` frames can be multi-codepoint; `streamingAssistant`/`thinking` collapse to `[...f][0]` to keep width stable (thinking.tsx L157).
87
88## References
89
90- `references/fork-ui-map.md` — full file:line dissection of packages, build, tool-call rendering, and streaming (the source of truth for "where is X").
91- `references/ink-streaming-patterns.md` — the reusable Ink/React streaming + tool-trail patterns, framework-agnostic, for building your own.