ui-tui/README.md
React + Ink terminal UI for Hermes. TypeScript owns the screen. Python owns sessions, tools, model calls, and most command logic.
hermes --tui
The client entrypoint is src/entry.tsx. It exits early if stdin is not a TTY, starts GatewayClient, then renders App.
GatewayClient spawns:
python -m tui_gateway.entry
Interpreter resolution order is: HERMES_PYTHON → PYTHON → $VIRTUAL_ENV/bin/python → ./.venv/bin/python → ./venv/bin/python → python3 (or python on Windows).
The transport is newline-delimited JSON-RPC over stdio:
ui-tui/src tui_gateway/
----------- -------------
entry.tsx entry.py
-> GatewayClient -> request loop
-> App -> server.py RPC handlers
stdin/stdout: JSON-RPC requests, responses, events
stderr: captured into an in-memory log ring
Malformed stdout lines are treated as protocol noise and surfaced as gateway.protocol_error. Stderr lines become gateway.stderr. Neither writes directly into the terminal.
From the repo root, the normal path is:
hermes --tui
The CLI expects ui-tui/dist/entry.js to exist, or the whole source code available in which to run npm install and npm run dev.
cd ui-tui
npm install
Local package commands:
npm run dev
npm start
npm run build
npm run lint
npm run fmt
npm run fix
Tests use vitest:
npm test # single run
npm run test:watch
src/app.tsx is the center of the UI. Heavy logic is split into src/app/:
createGatewayEventHandler.ts — maps gateway events to state updatescreateSlashHandler.ts — local slash command dispatchuseComposerState.ts — draft, multiline buffer, queue editinguseInputHandlers.ts — keypress routinguseTurnState.ts — agent turn lifecycleoverlayStore.ts / uiStore.ts — nanostores for overlay and UI stategatewayContext.tsx — React context for the gateway clientconstants.ts, helpers.ts, interfaces.tsThe top-level app.tsx composes these into the Ink tree with Static transcript output, a live streaming assistant row, prompt overlays, queue preview, status rule, input line, and completion list.
State managed at the top level includes:
The UI renders as a normal Ink tree with Static transcript output, a live streaming assistant row, prompt overlays, queue preview, status rule, input line, and completion list.
The intro panel is driven by session.info and rendered through branding.tsx.
Current input behavior is split across app.tsx, components/textInput.tsx, and the prompt/picker components.
| Key | Behavior |
|---|---|
Enter | Submit the current draft |
empty Enter twice | If queued messages exist and the agent is busy, interrupt the current run. If queued messages exist and the agent is idle, send the next queued message |
Shift+Enter / Alt+Enter | Insert a newline in the current draft |
\ + Enter | Append the line to the multiline buffer (fallback for terminals without modifier support) |
Ctrl+C | Interrupt active run, or clear the current draft, or exit if nothing is pending |
Ctrl+D | Exit |
Cmd/Ctrl+G / Alt+G | Open $EDITOR with the current draft (use Alt+G in VSCode/Cursor — they bind the primary keystroke to Find Next) |
Ctrl+L | New session (same as /clear) |
Ctrl+V / Alt+V | Paste text first, then fall back to image/path attachment when applicable |
Tab | Apply the active completion |
Up/Down | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history |
Left/Right | Move the cursor |
modified Left/Right | Move by word when the terminal sends Ctrl or Meta with the arrow key |
Home / Ctrl+A | Start of line |
End / Ctrl+E | End of line |
Backspace | Delete the character to the left of the cursor |
Delete | Delete the character to the right of the cursor |
modified Backspace | Delete the previous word |
modified Delete | Delete the next word |
Ctrl+W | Delete the previous word |
Ctrl+U | Delete from the cursor back to the start of the line |
Ctrl+K | Delete from the cursor to the end of the line |
Meta+B / Meta+F | Move by word |
!cmd | Run a shell command through the gateway |
{!cmd} | Inline shell interpolation before send; queued drafts keep the raw text until they are sent |
Notes:
Tab only applies completions when completions are present and you are not in multiline mode.PgUp / PgDn are left to the terminal emulator; the TUI does not handle them.| Context | Keys | Behavior |
|---|---|---|
| approval prompt | Up/Down, Enter | Move and confirm the selected approval choice |
| approval prompt | o, s, a, d | Quick-pick once, session, always, deny |
| approval prompt | Esc, Ctrl+C | Deny |
| clarify prompt with choices | Up/Down, Enter | Move and confirm the selected choice |
| clarify prompt with choices | single-digit number | Quick-pick the matching numbered choice |
| clarify prompt with choices | Enter on "Other" | Switch into free-text entry |
| clarify free-text mode | Enter | Submit typed answer |
| sudo / secret prompt | Enter | Submit typed value |
| sudo / secret prompt | Ctrl+C | Cancel by sending an empty response |
| resume picker | Up/Down, Enter | Move and resume the selected session |
| resume picker | 1-9 | Quick-pick one of the first nine visible sessions |
| resume picker | Esc, Ctrl+C | Close the picker |
Notes:
ink-text-input, so text editing there follows the library's default bindings rather than components/textInput.tsx.Ctrl+C cancellation from the app-level blocked handler.!cmd do not queue; they execute immediately even while a run is active.Up/Down prioritizes queued-message editing over history. History only activates when there is no queue to edit.!cmd and {!cmd} text while you edit them. Shell commands and interpolation run when the queued item is actually sent./ uses complete.slash. A trailing token that starts with ./, ../, ~/, /, or @ uses complete.path.Cmd/Ctrl+G (or Alt+G in VSCode/Cursor, which intercept the primary keystroke for Find Next) writes the current draft, including any multiline buffer, to a temp file, suspends Ink, launches $EDITOR, then restores the TUI and submits the saved text if the editor exits cleanly.~/.hermes/.hermes_history or under HERMES_HOME.Assistant output is rendered in one of two ways:
messageLine.tsx prints it directlycomponents/markdown.tsx renders a small Markdown subset into Ink componentsThe Markdown renderer handles headings, lists, block quotes, tables, fenced code blocks, diff coloring, inline code, emphasis, links, and plain URLs.
Tool/status activity is shown in a live activity lane. Transcript rows stay focused on user/assistant turns.
The Python gateway can pause the main loop and request structured input:
approval.request: allow once, allow for session, allow always, or denyclarify.request: pick from choices or type a custom answersudo.request: masked password entrysecret.request: masked value entry for a named env varsession.list: used by SessionPicker for /resumeThese are stateful UI branches in app.tsx, not separate screens.
The local slash handler covers the built-ins that need direct client behavior:
/help/quit, /exit, /q/clear/new/compact/resume/copy/paste/details/logs/statusbar, /sb/queue/undo/retryNotes:
/copy sends the selected assistant response through OSC 52./paste with no args asks the gateway to attach a clipboard image.Cmd+V / Ctrl+V handle layered text/OSC52/image fallback before /paste is needed./details [hidden|collapsed|expanded|cycle] controls thinking/tool-detail visibility./statusbar toggles the status rule on/off.Anything else falls through to:
slash.execcommand.dispatchThat lets Python own aliases, plugins, skills, and registry-backed commands without duplicating the logic in the TUI.
Primary event types the client handles today:
| Event | Payload |
|---|---|
gateway.ready | { skin? } |
session.info | session metadata for banner + tool/skill panels |
message.start | start assistant streaming |
message.delta | { text, rendered? } |
message.complete | { text, rendered?, usage, status } |
thinking.delta | { text } |
reasoning.delta | { text } |
reasoning.available | { text } |
status.update | { kind, text } |
tool.start | { tool_id, name, context? } |
tool.progress | { name, preview } |
tool.complete | { tool_id, name } |
clarify.request | { question, choices?, request_id } |
approval.request | { command, description } |
sudo.request | { request_id } |
secret.request | { prompt, env_var, request_id } |
background.complete | { task_id, text } |
error | { message } |
gateway.stderr | synthesized from child stderr |
gateway.protocol_error | synthesized from malformed stdout |
The client starts with DEFAULT_THEME from theme.ts, then merges in gateway skin data from gateway.ready.
Current branding overrides:
Current color overrides:
branding.tsx uses those values for the logo, session panel, and update notice.
ui-tui/
packages/hermes-ink/ forked Ink renderer (local dep)
src/
entry.tsx TTY gate + render()
app.tsx top-level Ink tree, composes src/app/*
gatewayClient.ts child process + JSON-RPC bridge
theme.ts default palette + skin merge
constants.ts display constants, hotkeys, tool labels
types.ts shared client-side types
banner.ts ASCII art data
app/
createGatewayEventHandler.ts event → state mapping
createSlashHandler.ts local slash dispatch
useComposerState.ts draft + multiline + queue editing
useInputHandlers.ts keypress routing
useTurnState.ts agent turn lifecycle
overlayStore.ts nanostores for overlays
uiStore.ts nanostores for UI flags
gatewayContext.tsx React context for gateway client
constants.ts app-level constants
helpers.ts pure helpers
interfaces.ts internal interfaces
components/
appChrome.tsx status bar, input row, completions
appLayout.tsx top-level layout composition
appOverlays.tsx overlay routing (pickers, prompts)
branding.tsx banner + session summary
markdown.tsx Markdown-to-Ink renderer
maskedPrompt.tsx masked input for sudo / secrets
messageLine.tsx transcript rows
modelPicker.tsx model switch picker
prompts.tsx approval + clarify flows
queuedMessages.tsx queued input preview
sessionPicker.tsx session resume picker
textInput.tsx custom line editor
thinking.tsx spinner, reasoning, tool activity
hooks/
useCompletion.ts tab completion (slash + path)
useInputHistory.ts persistent history navigation
useQueue.ts queued message management
useVirtualHistory.ts in-memory history for pickers
lib/
history.ts persistent input history
messages.ts message formatting helpers
osc52.ts OSC 52 clipboard copy
rpc.ts JSON-RPC type helpers
text.ts text helpers, ANSI detection, previews
types/
hermes-ink.d.ts type declarations for @hermes/ink
__tests__/ vitest suite
Related Python side:
tui_gateway/
entry.py stdio entrypoint
server.py RPC handlers and session logic
render.py optional rich/ANSI bridge
slash_worker.py persistent HermesCLI subprocess for slash commands