* feat(desktop): revamp Billing page — plan card, in-app plans view, tier art
Reshape the desktop Billing settings per wayfinder ticket 09. New page order:
Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance
summary strip unchanged at the top.
- CurrentPlanCard replaces the old Subscription row: tier name + price + renewal
and at most one button — "View plans" (free/no-sub + can_change_plan),
"Change plan" (subscriber + can_change_plan), or none for teams / non-changers.
Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button
navigates in-app to the plans sub-view.
- bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam,
default overview). BillingPlansView renders a grid of PlanCard from live tiers[]
(is_enabled, sorted by tier_order, free tier included).
- PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo").
Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗"
(opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a
caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so
this PR intentionally links them out/disabled rather than wiring the money path.
- buildManageSubscriptionUrl gains an optional third arg (tierId) → appends
plan=<tierId>. Signature kept identical to draft PR #68666 for a trivial rebase;
NAS #748 validates the param server-side.
- Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well
with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase
tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox);
unknown name → text-only card. Imported via vite static imports for packaged
file:// + webSecurity.
- Top-up vs auto-refill disambiguated by section label + first sentence: "One-time
top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured
copy reads "Charges $X automatically when your balance falls below $Y.").
- Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two
$ fields with a pre-allocated error line) and the action column (Manage → Save/
Cancel) in place, with the row height reserved for the tallest state so the Usage
section never shifts. Fixes the spurious on-open validation error (errors now show
only after an edit or a save attempt). Save/disable API calls + confirm-disable
flow unchanged.
- Remove subscriptionTierChips and the subscription-row chips; reshape (not delete)
deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam.
- Dev fixtures: add free-personal and subscriber-personal (personal orgs, full
4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable.
Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts;
delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean.
* fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription
Visual verification caught a spec-fidelity bug: in the plans grid, an account with
no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗"
upgrade — clicking would deep-link the portal to "subscribe to Free".
Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier
is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order
tier as the stand-in current plan when there is no subscription, so the free card
renders exactly like is_current (inert, "Current plan") and — being the lowest order
— no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade.
CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is
unchanged (Free stays a disabled Downgrade below the current Plus tier).
Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three
Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier
is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean.
* chore(desktop): shrink bundled tier art to 128px thumbnails
The plan-card wells render the art at ~40px; shipping the full landing
images added 2.7 MB to the repo for no visible difference. 128px covers
2x displays; total is now 26 KB.
* fix(desktop): address 6 adversarial-review findings on the Billing revamp
1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier
is_enabled:false; the enabled-only filter dropped it, leaving currentOrder
undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers
now resolves current identity/ordering against the UNFILTERED tiers and keeps the
grandfathered current tier in the grid as the inert "Current plan" card; downgrades
classify against its tier_order. (Non-current disabled tiers are still dropped.)
2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on
can_change_plan, but the grid could be empty / current-only and showPlans refused,
so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1
actionable (non-current) tier; otherwise it falls back to the portal link.
3. Deep-link bypass. showPlans now gates on the same capability that renders the button
(view.plan?.action), so a team / non-changer deep-linking bview=plans always falls
back to overview instead of a grid of live Choose buttons.
4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers,
refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal
link built from subscription?.portal_url ?? billing.portal_url — the refusal caption
no longer promises a portal the UI didn't render.
5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url
as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the
hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan)
instead of a bare return, so a null portal_url never strips the routing params.
6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two
inputs stack below @2xl) with exact reservation: the edit form is always rendered and
both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden
when not editing — the row equals the tallest state at every width, no breakpoint math.
The refusal stays inside the reserved layer.
Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team &
personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback
org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4.
Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean.
* refactor(desktop): reuse the shared openExternalLink helper in the plans view
* fix(desktop): honor the auto_reload wire contract — null card + disable amounts
A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two
real desktop bugs in the auto-refill row:
A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None
for a missing/unknown-kind card and _serialize_billing_state emits `card: null`,
but the shared BillingAutoReload.card union had no null arm and use-billing-state
dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null`
to the shared union (contract honesty) and guard the read (`card?.kind`); null now
falls through to the default enabled path, same as a canonical card.
B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires
threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back
invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.)
disable() now sends the current threshold_usd/reload_to_usd from the autoReload
prop alongside enabled: false, matching the TUI.
Tests: enabled auto_reload with card:null renders the normal enabled row (derivation
+ render, no crash); disable call carries both current amounts. Billing suite 80/80
green; typecheck (app/electron/e2e) + lint clean.
* fix(tui): guard the nullable auto_reload card in the auto-reload screen
The shared BillingAutoReload.card union gained its honest null arm (the
gateway emits card: null for a missing/unknown card); the TUI's only bare
dereference follows the same default path as a canonical card.
* fix(desktop): align billing inputs to the sm control height
The three billing inputs used an ad-hoc h-8 (32px) next to size=sm
buttons (24px). They now use the control system's size=sm with a
py-[3px] compensation for the input's real 1px border — buttons draw
theirs as an inset shadow, so sm alone still sits 2px taller. All five
controls in the buy row now measure 24px.
* fix(desktop): plan-card actionability + billing view-model hardening
Code-quality review of the Billing revamp (PR #68722).
BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a
plans grid with zero enabled actions AND no portal link. The plan card gated its
in-app button on `tiers.some(state !== 'current')`, which counts the (disabled)
downgrade tiles. It now gates on an actual UPGRADE being present
(`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back
to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same
plan.action) falls back to overview.
Reviewer structural items:
- One "plans capability" verdict (personal + can_change_plan + subscription ok) is
derived once in deriveBillingView and threaded to BOTH derivePlanCard and
derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant
lives in one place.
- BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/
disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an
action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url`
defensive branches in the consumers.
- `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at
its three sites (plan card price, grid ordering, summary plan line).
- BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an
`accountRows[]` + three `.find(id)` lookups.
- The auto-refill row that edits in place carries an explicit `manageInApp: true`;
AutoReloadRow keys off it instead of sniffing the action label/url.
- tier-art header comment no longer cites an internal repo path; dead `?.` removed
from RowValue (via a destructured const) and the plan-card link handler.
Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck
(app/electron/e2e) + lint clean.
* refactor(desktop): adopt inline-review nits on the billing plan card
Resolves the inline suggestion threads:
- plan-card gate reads a named `hasActionableTier` = "a tile carries an action"
(union-safe `'action' in tier`, equivalent to the old upgrade-only check).
- re-narrow link/action inside the click callbacks (`plan.link && …`,
`tier.action && …`) rather than relying on outer narrowing.
Behavior unchanged; billing suite 82/82 green, typecheck + lint clean.
|
||
|---|---|---|
| .. | ||
| packages/hermes-ink | ||
| scripts | ||
| src | ||
| .gitignore | ||
| eslint.config.mjs | ||
| 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/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
App model
src/app.tsx is the center of the UI. Heavy logic is split into src/app/:
src/app/createGatewayEventHandler.ts— maps gateway events to state updatessrc/app/createSlashHandler.ts— local slash command dispatchsrc/app/useComposerState.ts— draft, multiline buffer, queue editingsrc/app/useInputHandlers.ts— keypress routingsrc/app/useMainApp.ts— top-level composition hook: wires all sub-hooks, manages transcript history, session polling, and exposes props consumed byapp.tsxsrc/app/useSessionLifecycle.ts— session create / resume / activate / close and visible-history resetsrc/app/useSubmission.ts— message send, shell exec (!cmd), inline interpolation ({!cmd}), and busy-input-mode dispatch (queue / steer / interrupt)src/app/turnController.ts— stateful class that drives the turn lifecycle: buffers streaming deltas, manages tool/reasoning state, handles interrupt and message-complete transitionssrc/app/turnStore.ts— nanostore for turn state (streaming text, tools, reasoning, subagents, todos, activity trail)src/app/useConfigSync.ts— fetchesconfig.get fullon session start and polls config mtime every 5 s; applies display settings and triggers MCP reload on changesrc/app/useLongRunToolCharms.ts— fires ambient activity messages for tools running longer than 8 ssrc/app/overlayStore.ts/src/app/uiStore.ts— nanostores for overlay and UI statesrc/app/delegationStore.ts— nanostore for subagent spawning caps and overlay accordion statesrc/app/spawnHistoryStore.ts— in-memory ring (last 10) of finished subagent fan-out snapshots; populated at turn end for/replaysrc/app/inputSelectionStore.ts— nanostore exposing the active text-input selection handlesrc/app/gatewayContext.tsx— React context for the gateway clientsrc/app/gatewayRecovery.ts— pure function that decides whether to respawn and resume after a gateway crash, with a 3-attempt / 60 s budgetsrc/app/setupHandoff.ts— launches externalhermes setup, suspends Ink while it runs, opens a new session on successsrc/app/scroll.ts— scrolls the viewport while keeping the text selection anchor in syncsrc/app/interfaces.ts— internal interfaces (ComposerActions, GatewayRpc, etc.)
Slash command subsystem (src/app/slash/)
types.ts—SlashCommandinterface andSlashRunCtxexecution context (gateway rpc, transcript helpers, session refs, stale-guard)registry.ts— assemblesSLASH_COMMANDSfrom all command files in registration order (core → billing → credits → session → ops → setup → debug) and exposesfindSlashCommand(name)for case-insensitive lookupcommands/core.ts— general TUI commandscommands/billing.ts—/billing: manage Nous remote spending — buy credits, auto-reload, limitscommands/credits.ts—/creditscommands/session.ts— session and agent commandscommands/ops.ts— operations commandscommands/setup.ts—/setupcommands/debug.ts—/heapdump,/mem
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 following commands are handled directly by the TUI client. Unrecognized commands fall through to the Python gateway via slash.exec and command.dispatch.
Core (core.ts)
/help, /quit (alias /exit), /update, /clear (alias /new),
/compact, /copy, /paste, /details (alias /detail),
/statusbar (alias /sb), /queue (alias /q), /logs, /history,
/save, /undo, /retry, /steer, /mouse (alias /scroll),
/status, /title, /fortune, /redraw, /terminal-setup
Billing (billing.ts)
/billing — manage Nous remote spending — buy credits, auto-reload, limits
Session (session.ts)
/model, /sessions (aliases /switch, /session, /resume),
/background (aliases /bg, /btw), /image, /personality,
/compress, /branch (alias /fork), /voice, /skin,
/indicator, /yolo, /reasoning, /fast, /busy, /verbose, /usage
Ops (ops.ts)
/stop, /reload-mcp (alias /reload_mcp), /reload, /browser,
/rollback, /agents (alias /tasks), /replay, /replay-diff,
/skills, /reload-skills (alias /reload_skills), /plugins, /tools
Credits (credits.ts)
/credits — Nous credit balance and browser top-up
Setup (setup.ts)
/setup — launches external hermes setup wizard, suspends Ink while it runs
Debug (debug.ts)
/heapdump, /mem — V8 memory diagnostics
Anything not matched above 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? } |
skin.changed |
{ 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, verbose? } |
reasoning.available |
{ text, verbose? } |
status.update |
{ kind, text } |
notification.show |
{ id, key, kind, level, text, ttl_ms? } |
notification.clear |
{ key } |
tool.start |
{ tool_id, name, context?, args_text? } |
tool.generating |
{ name } |
tool.progress |
{ name, preview } |
tool.complete |
{ tool_id, name, error?, summary?, duration_s?, inline_diff?, todos? } |
clarify.request |
{ question, choices?, request_id } |
approval.request |
{ command, description, allow_permanent? } |
sudo.request |
{ request_id } |
sudo.expire |
{ request_id } clears a timed-out sudo prompt |
secret.request |
{ prompt, env_var, request_id } |
secret.expire |
{ request_id } clears a timed-out secret prompt |
background.complete |
{ task_id, text } |
billing.step_up.verification |
{ verification_url, user_code } |
review.summary |
{ text } |
browser.progress |
{ message } |
voice.status |
{ state } |
voice.transcript |
{ text, no_speech_limit? } |
subagent.spawn_requested |
{ subagent_id?, task_index, goal?, depth?, parent_id? } |
subagent.start |
{ subagent_id?, task_index, goal?, depth?, parent_id? } |
subagent.thinking |
{ text } |
subagent.tool |
{ tool_name?, tool_preview?, text? } |
subagent.progress |
{ text } |
subagent.complete |
{ status, summary?, text?, duration_seconds? } |
error |
{ message } |
gateway.stderr |
synthesized from child stderr |
gateway.protocol_error |
synthesized from malformed stdout |
gateway.start_timeout |
{ cwd?, python?, stderr_tail? } |
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
gatewayTypes.ts gateway event and RPC response type definitions
theme.ts theme colors and skin merge
banner.ts ASCII art renderer (parses Rich color tags)
types.ts shared client-side types (ActiveTool, Msg, etc.)
app/
createGatewayEventHandler.ts event → state mapping
createSlashHandler.ts local slash dispatch
delegationStore.ts nanostore for subagent spawning caps and overlay accordion state
gatewayContext.tsx React context for gateway client
gatewayRecovery.ts crash-recovery budget: respawn+resume capped to 3 attempts / 60 s
inputSelectionStore.ts nanostore exposing the active text-input selection handle
interfaces.ts internal interfaces (ComposerActions, GatewayRpc, etc.)
overlayStore.ts nanostores for overlay state
scroll.ts viewport scroll with text-selection anchor sync
setupHandoff.ts launches external hermes setup, suspends Ink while it runs
spawnHistoryStore.ts ring buffer of finished subagent fan-out snapshots
turnController.ts stateful turn lifecycle driver (streaming, tools, reasoning)
turnStore.ts nanostore for turn state (streaming, tools, reasoning, subagents)
uiStore.ts nanostores for UI flags (busy, sid, mouseTracking, etc.)
useComposerState.ts draft + multiline buffer + queue editing
useConfigSync.ts config polling and MCP reload on mtime change
useInputHandlers.ts keypress routing
useLongRunToolCharms.ts ambient activity messages for tools running longer than 8 s
useMainApp.ts top-level composition hook
useSessionLifecycle.ts session create / resume / activate / close
useSubmission.ts message send, shell exec, interpolation, busy-input-mode dispatch
slash/
types.ts SlashCommand interface and SlashRunCtx execution context
registry.ts SLASH_COMMANDS assembly and findSlashCommand lookup
commands/
billing.ts /billing — manage Nous remote spending
core.ts general TUI commands
credits.ts /credits
debug.ts /heapdump, /mem
ops.ts operations commands
session.ts session and agent commands
setup.ts /setup wizard
components/
activeSessionSwitcher.tsx active session switch overlay
agentsOverlay.tsx subagent delegation overlay
appChrome.tsx status bar, input row, completions
appLayout.tsx top-level layout composition
appOverlays.tsx overlay routing (pickers, prompts)
billingOverlay.tsx billing overlay
branding.tsx banner + session summary
fpsOverlay.tsx FPS debug overlay
helpHint.tsx contextual help hint
markdown.tsx Markdown-to-Ink renderer
maskedPrompt.tsx masked input for sudo / secrets
messageLine.tsx transcript rows
modelPicker.tsx model switch picker
overlayControls.tsx shared overlay control buttons
pluginsHub.tsx plugins hub overlay
prompts.tsx approval + clarify flows
queuedMessages.tsx queued input preview
skillsHub.tsx skills hub overlay
streamingAssistant.tsx live streaming assistant row
streamingMarkdown.tsx streaming Markdown renderer
textInput.tsx custom line editor
themed.tsx theme-aware wrapper
thinking.tsx spinner, reasoning, tool activity
todoPanel.tsx todo list panel
config/
env.ts environment variable resolution and Termux/mouse defaults
limits.ts paste size, live-render and history limits
timing.ts streaming batch and debounce timing constants
content/
charms.ts ambient activity strings for long-running tools
faces.ts agent face / kaomoji pool
fortunes.ts /fortune quote pool
hotkeys.ts platform-aware hotkey display strings
placeholders.ts rotating input placeholder strings
setup.ts setup-required panel content
verbs.ts tool activity verb map (browser → browsing, etc.)
domain/
blockLayout.ts block layout and lead-gap helpers
details.ts details visibility mode resolution (hidden/collapsed/expanded)
messages.ts message formatting and transcript helpers
paths.ts cwd shortening and path display helpers
providers.ts provider display name helpers
roles.ts message role color and label helpers
slash.ts slash command parsing and TUI session model flag
usage.ts token usage zero value and helpers
viewport.ts viewport height estimation helpers
hooks/
useCompletion.ts tab completion (slash + path)
useGitBranch.ts current git branch via child_process execFile
useInputHistory.ts persistent history navigation
useQueue.ts queued message management
useVirtualHistory.ts virtual list scroll and height tracking
lib/
circularBuffer.ts fixed-size generic ring buffer
clipboard.ts clipboard read / write via child_process
editor.ts $EDITOR launch, PATH resolution, and Ink suspend
emoji.ts emoji and variation selector width helpers
externalCli.ts external CLI subprocess launcher
externalLink.ts open URLs in the system browser
forceTruecolor.ts 24-bit truecolor override before chalk imports
fpsStore.ts Ink frame FPS tracker nanostore
fuzzy.ts lightweight fuzzy subsequence scorer
gracefulExit.ts clean shutdown with failsafe timeout
history.ts persistent input history (read/append to disk)
inputMetrics.ts input width and wrap metrics
liveProgress.ts todo helpers and tool-shelf message assembly
mathUnicode.ts best-effort LaTeX → Unicode for inline math
memory.ts V8 heap snapshot and diagnostics helpers
memoryMonitor.ts automatic heap-dump trigger on high usage
messages.ts transcript message append helpers
openExternalUrl.ts platform-aware URL opener (macOS/Linux/Windows)
osc52.ts OSC 52 terminal clipboard copy sequence
parentLog.ts append-only log to ~/.hermes/tui-parent.log
perfPane.tsx FPS / render perf overlay pane
platform.ts platform-aware keybinding and SSH detection helpers
precisionWheel.ts high-precision scroll wheel with sticky-frame budget
prompt.ts composer prompt text helpers (Termux-safe)
reasoning.ts reasoning tag detection and split helpers
rpc.ts JSON-RPC result and command dispatch helpers
subagentTree.ts subagent tree flattening and aggregate helpers
syntax.ts syntax token types and theme-aware highlighting
terminalModes.ts terminal mode reset sequences (kitty, mouse, etc.)
terminalParity.ts VSCode-like terminal detection and hint helpers
terminalSetup.ts IDE keybinding config file install helpers
termux.ts Termux platform detection helpers
text.ts text helpers, ANSI detection, tool trail builders
todo.ts todo item tone and display helpers
viewportStore.ts viewport height nanostore via ScrollBoxHandle
virtualHeights.ts virtual list row height estimation
wheelAccel.ts scroll wheel acceleration state machine
protocol/
interpolation.ts {!cmd} inline shell interpolation regex and helpers
paste.ts bracketed paste snippet token regex
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