* feat(tui): pluggable busy-indicator styles (kaomoji/emoji/unicode/ascii) The status-bar `FaceTicker` rotated through wide-and-variable kaomoji glyphs (`(。•́︿•̀。)`, `( ͡° ͜ʖ ͡°)`, …) every 2.5s. Real display widths range from ~5 to ~16 columns, so the rest of the bar (cwd, ctx %, voice, bg counter) shifted on every cycle. Padding the verb alone (#17116) helped but didn't address the dominant jitter source — the glyph itself. Add four indicator styles, configurable + hot-swappable: * `kaomoji` (default — preserves the existing vibe; verb is now pad-stable so the only width churn left is the kaomoji itself). * `emoji` — single 2-col emoji frame (`⚕ 🌀 🤔 ✨ 🍵 🔮`). * `unicode` — `unicode-animations` braille spinner (1-col, smooth). * `ascii` — `| / - \` (1-col, max compat). Wires: * `display.tui_status_indicator` in `DEFAULT_CONFIG` (default `kaomoji`). * New JSON-RPC `config.set/get indicator` keys, narrow allow-list. * `applyDisplay` reads the field and patches `UiState.indicatorStyle`, so the existing `mtime` poll picks up `~/.hermes/config.yaml` edits within ~5s without a TUI restart. * `/indicator [style]` slash command (alias `/indicator-style`, subcommand completion `kaomoji|emoji|unicode|ascii`). Bare form shows the current style; setter fires `config.set` and optimistically `patchUiState({ indicatorStyle })` so the live TUI swaps immediately, matching the `/skin` UX. * `CommandDef("indicator", ..., subcommands=...)` so classic CLI autocomplete + TUI `complete.slash` both surface it. * `FaceTicker` decouples spinner cadence from verb cadence — the glyph runs at the spinner's authored interval (or `FACE_TICK_MS` for kaomoji), the verb stays on the original 2.5s cycle, and both re-arm cleanly when style changes. Tests: * `normalizeIndicatorStyle` rejects unknown / non-string input. * `applyDisplay → tui_status_indicator` covers fan-out + fallback. * `/indicator <style>` hot-swaps `UiState.indicatorStyle` after a successful `config.set`. * `/indicator sparkle` rejects with the usage hint and never hits the gateway. * Slash-parity matrix gets `'/indicator'` → `config.get`. Validation: cd ui-tui && npm run type-check — clean; npm test --run — 398/398. scripts/run_tests.sh tests/test_tui_gateway_server.py tests/hermes_cli/test_commands.py — 220/220. * chore(tui): drop /indicator-style alias to declutter autocomplete * fix(tui): drop verb-width pad — /indicator handles glyph jitter directly * fix(tui): unicode indicator style hides the verb (cleanest option) * refactor(tui): single source of truth for INDICATOR_STYLES; cleaner error format Round 1 Copilot review on PR #17150: - Exported `INDICATOR_STYLES` const tuple from `interfaces.ts`; `IndicatorStyle` union type is derived from it. `useConfigSync` builds its validation Set from the tuple, and `session.ts` uses it for both the usage hint and the runtime allow-list — adding/removing a style now touches one line. - Backend `config.set indicator` error message: switched `sorted(allowed)` list repr to `pick one of ascii|emoji|kaomoji|unicode` (matches the TUI usage hint), and reports the normalized `raw` instead of the original `value`. Backend allowed tuple now has a comment pointing back at `INDICATOR_STYLES` so the two stay aligned. Note: kept the verb portion unpadded per design intent — fixed-width padding was the exact UX the `/indicator` command was added to remove. Stable width comes from the glyph; verbs cycling is part of the kawaii aesthetic. Reply on the verb thread will explain. * fix(tui): drop type collapse + gate verb timer + DEFAULT_INDICATOR_STYLE Round 2 Copilot review on PR #17150: - `tui_status_indicator?: 'ascii' | ... | string` collapses to `string` in TS — consumers got no narrowing. Documented as plain `string` with a comment about runtime validation via `normalizeIndicatorStyle`. - `FaceTicker` always started a 2.5s verb interval, even for the `unicode` style which hides the verb entirely. Now gated on `showVerb` from `renderIndicator` — `unicode` stays calm. Pre-emptive self-review (avoid round 3): - Three call sites duplicated the literal `'kaomoji'` default (uiStore, normalizeIndicatorStyle, slash command). Added `DEFAULT_INDICATOR_STYLE` to interfaces.ts and threaded it through so changing the default touches one line. * fix(tui-gateway): normalize config.get indicator output to match TUI render Round 4 Copilot review on PR #17150: `config.get` for `indicator` returned the raw `display.tui_status_indicator` value without validation, so a hand-edited config.yaml with stray casing or an unknown style would leave `/indicator` printing one thing while the TUI rendered the kaomoji default (frontend's `normalizeIndicatorStyle` does this normalization on receive). Lifted the allow-list to module scope as `_INDICATOR_STYLES` / `_INDICATOR_DEFAULT`, reused by both `config.set` and `config.get`. Comment notes the alignment with `INDICATOR_STYLES` / `DEFAULT_INDICATOR_STYLE` in interfaces.ts so adding/removing a style is a one-line change on each end. Tests cover: known value verbatim, casing/whitespace normalize, unknown→default, unset→default. * fix(tui-gateway): preserve falsy-input diagnostics in config.set indicator error Round 5 Copilot review on PR #17150: `raw = str(value or "").strip().lower()` collapsed any falsy non-string (`0`, `False`, `[]`) to empty string, so the error message read `unknown indicator: ` with nothing after — losing the original input. Switched to `("" if value is None else str(value)).strip().lower()` so only `None` (the genuine 'no value' case) becomes blank. Used `{raw!r}` in the error so the diagnostic is unambiguous (`'0'` vs `0`). Tests: - known-value happy path (`'EMOJI'` → `'emoji'`) - falsy non-string inputs (`0` / `False` / `[]`) surface meaningfully - `None` keeps the blank-repr error |
||
|---|---|---|
| .. | ||
| packages/hermes-ink | ||
| scripts | ||
| src | ||
| .gitignore | ||
| .prettierrc | ||
| babel.compiler.config.cjs | ||
| eslint.config.mjs | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
Hermes TUI
React + Ink terminal UI for Hermes. TypeScript owns the screen. Python owns sessions, tools, model calls, and most command logic.
hermes --tui
What runs
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.
Running it
From the repo root, the normal path is:
hermes --tui
The CLI expects ui-tui/node_modules to exist. If the TUI deps are missing:
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
App model
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.ts
The 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:
- transcript and streaming state
- queued messages and input history
- session lifecycle
- tool progress and reasoning text
- prompt flows for approval, clarify, sudo, and secret input
- slash command routing
- tab completion and path completion
- theme state from gateway skin data
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.
Hotkeys and interactions
Current input behavior is split across app.tsx, components/textInput.tsx, and the prompt/picker components.
Main chat input
| 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:
Tabonly applies completions when completions are present and you are not in multiline mode.- Queue/history navigation only applies when you are not in multiline mode.
PgUp/PgDnare left to the terminal emulator; the TUI does not handle them.
Prompt and picker modes
| 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:
- Clarify free-text mode and masked prompts use
ink-text-input, so text editing there follows the library's default bindings rather thancomponents/textInput.tsx. - When a blocking prompt is open, the main chat input hotkeys are suspended.
- Clarify mode has no dedicated cancel shortcut in the current client. Sudo and secret prompts only expose
Ctrl+Ccancellation from the app-level blocked handler.
Interaction rules
- Plain text entered while the agent is busy is queued instead of sent immediately.
- Slash commands and
!cmddo not queue; they execute immediately even while a run is active. - Queue auto-drains after each assistant response, unless a queued item is currently being edited.
Up/Downprioritizes queued-message editing over history. History only activates when there is no queue to edit.- Queued drafts keep their original
!cmdand{!cmd}text while you edit them. Shell commands and interpolation run when the queued item is actually sent. - If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and sent after the current run completes.
- Completion requests are debounced by 60 ms. Input starting with
/usescomplete.slash. A trailing token that starts with./,../,~/,/, or@usescomplete.path. - Text pastes are inserted inline directly into the draft. Nothing is newline-flattened.
Cmd/Ctrl+G(orAlt+Gin 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.- Input history is stored in
~/.hermes/.hermes_historyor underHERMES_HOME.
Rendering
Assistant output is rendered in one of two ways:
- if the payload already contains ANSI,
messageLine.tsxprints it directly - otherwise
components/markdown.tsxrenders a small Markdown subset into Ink components
The 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.
Prompt flows
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 bySessionPickerfor/resume
These are stateful UI branches in app.tsx, not separate screens.
Commands
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/retry
Notes:
/copysends the selected assistant response through OSC 52./pastewith no args asks the gateway to attach a clipboard image.- Text paste remains inline-only;
Cmd+V/Ctrl+Vhandle layered text/OSC52/image fallback before/pasteis needed. /details [hidden|collapsed|expanded|cycle]controls thinking/tool-detail visibility./statusbartoggles the status rule on/off.
Anything else falls through to:
slash.execcommand.dispatch
That lets Python own aliases, plugins, skills, and registry-backed commands without duplicating the logic in the TUI.
Event surface
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 |
Theme model
The client starts with DEFAULT_THEME from theme.ts, then merges in gateway skin data from gateway.ready.
Current branding overrides:
- agent name
- prompt symbol
- welcome text
- goodbye text
Current color overrides:
- banner title, accent, border, body, dim
- label, ok, error, warn
branding.tsx uses those values for the logo, session panel, and update notice.
File map
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