hermes-agent/ui-tui
brooklyn! 70b663504f
fix(tui): keep Ink displayCursor in sync with fast-echo writes so cursor stops drifting (#26717)
* fix(tui): keep Ink displayCursor in sync with fast-echo writes so cursor stops drifting

TextInput's fast-echo bypass writes characters directly to stdout to
avoid waiting on a React re-render for each keystroke. The hardware
cursor advances by text.length cells, but Ink's cached `displayCursor`
(the basis for the next frame's relative cursor-move preamble in
log-update) stayed unchanged. When ANY unrelated component re-rendered
between the fast-echo write and the deferred composer setCur/setParent
flush — status bar timer, streaming reasoning, etc. — the next frame's
preamble emitted a relative cursor move from a stale parked position
and the hardware cursor parked N cells offset from the actual caret.

Visible symptom: extra whitespace between the just-typed character and
the cursor block, intermittent, worse on long sessions during streaming.
Alt-screen was immune because frames begin with absolute CSI H.

This adds a small API in @hermes/ink:

  - `Ink.noteExternalCursorAdvance(dx, dy?)` — bumps displayCursor if
    set, otherwise seeds from frontFrame.cursor so the next preamble's
    relative move correctly cancels the external advance. No-op on
    alt-screen.
  - `CursorAdvanceContext` + `useCursorAdvance()` hook to expose it.

TextInput then calls `noteCursorAdvance(text.length)` after the
fast-echo `stdout.write(text)` append, and `noteCursorAdvance(-1)`
after the fast-backspace `\b \b` sequence.

Tests: 4 new vitest cases pin the API contract (bumps when set, seeds
from frontFrame.cursor when null, alt-screen no-op, zero-delta no-op).
All 751 ui-tui tests pass; tests/test_tui_gateway_server.py (177) pass.

* fix(tui): also advance cursorDeclaration so fast-echo survives deferred React state

Copilot review on PR #26717 flagged a gap in the original fix:
TextInput's fast-echo path defers the React `cur` state update by
16ms (perf optimization that batches re-renders during heavy typing).
Inside that window, `useDeclaredCursor` still publishes a target
computed from the PRE-keystroke `cur` — `cursorLayout(display, cur,
columns)`. Advancing only `displayCursor` would let any unrelated
re-render in that 16ms window run onRender's cursor-park branch with
the stale declaration and visually undo the fast-echo's advance.

The fix is symmetric: `noteExternalCursorAdvance` now bumps BOTH
`displayCursor` (the log-update relative-move basis) AND, if non-null,
`cursorDeclaration.relativeX/Y` (the target the cursor parks at after
every frame). When React finally flushes `setCur`, `useDeclaredCursor`
publishes a fresh declaration that supersedes our bumped one — exactly
what we want.

Adds two new vitest cases covering both halves:
  - active declaration advances in lock-step with displayCursor
  - null declaration stays null (no spurious bump)

All 753 ui-tui tests pass; tests/test_tui_gateway_server.py (177) pass.

Closes review threads:
  PRRT_kwDOPRF1G86ChKtD (textInput.tsx:1016 fast-echo append)
  PRRT_kwDOPRF1G86ChKtF (textInput.tsx:924 fast-backspace)
  PRRT_kwDOPRF1G86ChKtG (ink-cursor-advance.test.ts:57 missing coverage)

* fix(tui): make fast-echo survive TextInput rerenders + alt-screen (Copilot round 2)

Round 2 of PR #26717 review. Three real holes Copilot flagged after the
initial cursorDeclaration bump:

1. alt-screen early-return skipped BOTH halves of the notifier. But the
   default TUI wraps the composer in <AlternateScreen> — that IS the
   production path. CSI H resets log-update's relative-move basis, but
   the alt-screen park branch uses absolute CUP =
   `rect.x + decl.relativeX`, so a stale declaration there still parks
   the cursor at the pre-keystroke caret. Fix: skip ONLY the
   displayCursor half on alt-screen; still bump cursorDeclaration.

2. TextInput's own rerender could clobber the Ink-level bump. The fast-
   echo path defers setCur by 16ms; if a parent state change rerenders
   TextInput in that window, the layout effect inside useDeclaredCursor
   reads the stale React `cur` state and re-publishes a declaration at
   the OLD column. Fix:
   `cursorLayout(display, curRef.current, columns)` — read the always-
   up-to-date ref, not the deferred state. useMemo dropped (compute is
   cheap, single-line wrap-text in the common case).

3. Tests bypassed the production wiring. Added two structural tests:
   - `still advances cursorDeclaration on alt-screen` in the Ink-level
     suite, asserting displayCursor stays put but the declaration
     advances by the delta.
   - `textInputCursorSourceOfTruth.test.ts` pins three structural
     invariants: layout reads curRef.current, never the bare `cur`
     state, and the fast-echo stdout.write calls remain paired with
     noteCursorAdvance(±N). Source-grep invariants > flaky Ink mount
     tests for this kind of regression.

757/757 ui-tui tests pass (+3 over round 1). type-check clean. lint
introduces zero new errors on touched files. tests/test_tui_gateway_server.py
(177) pass.

Closes review threads:
  PRRT_kwDOPRF1G86ChOG2 (ink.tsx alt-screen guard)
  PRRT_kwDOPRF1G86ChOG9 (textInput.tsx fast-backspace rerender window)
  PRRT_kwDOPRF1G86ChOHC (textInput.tsx fast-append rerender window)
  PRRT_kwDOPRF1G86ChOHJ (alt-screen test asserts wrong invariant)
  PRRT_kwDOPRF1G86ChOHP (missing integration-style coverage)

* fix(tui): reject fast-backspace at soft-wrap boundary (Copilot round 3)

PR #26717 round 3. Copilot caught two real things:

1. `\b \b` cannot move the terminal cursor onto the previous visual
   row across a soft-wrap boundary. When the caret sits at visual
   column 0 of a wrapped row (e.g. value 'hello ' at width 6 →
   cursorLayout produces (line 1, col 0)), backspace would leave the
   physical cursor in place while the logical caret moves up to the
   end of the previous visual line. `noteCursorAdvance(-1)` would then
   feed Ink a wrong delta. Fix: `canFastBackspaceShape` now takes the
   composer width and rejects when `cursorLayout(value, cursor, columns).column === 0`.
   The fast path falls through to the normal Ink render, which
   correctly lays out the new caret position. The PR-description
   inconsistency about alt-screen is fixed in a separate gh pr edit.

Adds 4 new tests in textInputFastEcho.test.ts pinning the rejection at
exact-multiple wrap boundaries plus a positive control inside a
wrapped line and a back-compat case where `columns` is omitted.

761/761 ui-tui tests pass. type-check / lint clean. 177/177 Python
tests/test_tui_gateway_server.py pass.

Closes review threads:
  PRRT_kwDOPRF1G86ChxE5 (textInput.tsx:933 wrap-boundary regression)

* fix(tui): polish doc + tests after Copilot round 4

Three polish points Copilot raised:

1. canFastBackspaceShape doc comment overstated the legacy contract —
   said it conservatively rejects potential wrap boundaries when
   columns is omitted, but the implementation actually skips the
   wrap-boundary check entirely. Reworded to make the legacy behavior
   explicit and warn callers not to rely on protection they don't get.

2. ink-cursor-advance.test.ts rationale comment for the
   'advances cursorDeclaration in lock-step' case still referenced
   the pre-fix `cursorLayout(display, cur, columns)` expression. Now
   accurately describes the current source of truth — `curRef.current`
   in textInput.tsx — and explains the window the bump is bridging.

3. Removed the three `__get*ForTest` accessors from Ink. The test
   file already cast the instance to inspect private state in the
   couple of tests that needed declaration mutation; the rest now use
   a small `peek(ink)` helper that does the same cast for reads. No
   test-only API surface ships in production.

761/761 ui-tui tests pass. type-check clean. lint introduces zero new
errors on touched files. 177/177 tests/test_tui_gateway_server.py pass.

Closes review threads:
  PRRT_kwDOPRF1G86Ch23W (canFastBackspaceShape doc accuracy)
  PRRT_kwDOPRF1G86Ch23f (stale test rationale)
  PRRT_kwDOPRF1G86Ch23p (test-only API surface in production)

* fix(tui): tighten doc + add dy test coverage (Copilot round 5)

Two polish points from round 5:

1. canFastBackspaceShape doc had two paragraphs that conflicted —
   the main 'Additionally rejects when the physical cursor sits at
   visual column 0' was stated unconditionally, then the columns-param
   paragraph qualified that it only happens when columns is passed.
   Reworked into clear 'When supplied / When omitted' branches with a
   concrete example value ('hello ' returns true without columns even
   though it would be unsafe at width 6). No more inconsistency.

2. Added a test asserting cursorDeclaration.relativeY advances when dy
   is non-zero. Existing tests exercised dy on displayCursor only.
   Newlines in fast-echoed text don't currently hit the bypass
   (canFastAppendShape rejects '\n'), but dy is part of the public
   notifier contract and must propagate symmetrically with dx so
   future callers get a fully-implemented contract.

762/762 ui-tui tests pass (+1). type-check / lint / build clean.

Closes review threads:
  PRRT_kwDOPRF1G86Ch6Sz (doc inconsistency)
  PRRT_kwDOPRF1G86Ch6TE (missing dy coverage on declaration)

* fix(tui): doc polish (Copilot round 6)

Four small but valid points:

1. textInputCursorSourceOfTruth.test.ts used bare 'fs'/'path'/'url'
   imports; the rest of ui-tui consistently uses the 'node:' prefix
   (see src/__tests__/useSessionLifecycle.test.ts, src/lib/editor.test.ts).
   Switched to node:fs / node:path / node:url to match convention.

2. CursorAdvanceContext.ts type-level doc described only displayCursor.
   The notifier intentionally also mutates the active cursorDeclaration
   and that's the only part that matters on alt-screen. Reworked the
   doc into a two-part 'updates both' summary with the alt-screen
   asymmetry called out explicitly.

3. use-cursor-advance.ts hook doc had the same problem. Same fix —
   document both pieces of state, both screen modes.

4. App.tsx onCursorAdvance prop comment was incomplete. Same fix —
   describe both state updates and the screen-mode asymmetry.

No behavior change. 762/762 ui-tui tests pass. type-check / lint /
build clean.

Closes review threads (auto-resolved on PR but valid critiques):
  PRRT_kwDOPRF1G86Ch926 (node: prefix on built-in imports)
  PRRT_kwDOPRF1G86Ch92_ (use-cursor-advance.ts doc)
  PRRT_kwDOPRF1G86Ch93H (CursorAdvanceContext.ts type doc)
  PRRT_kwDOPRF1G86Ch93J (App.tsx prop comment)
2026-05-16 00:28:12 -05:00
..
packages/hermes-ink fix(tui): keep Ink displayCursor in sync with fast-echo writes so cursor stops drifting (#26717) 2026-05-16 00:28:12 -05:00
scripts refactor(tui): bundle with esbuild, drop runtime node_modules 2026-04-30 15:38:50 -04:00
src fix(tui): keep Ink displayCursor in sync with fast-echo writes so cursor stops drifting (#26717) 2026-05-16 00:28:12 -05:00
.gitignore fix(ui-tui): harden TUI error handling, model validation, command UX parity, and gateway lifecycle 2026-04-13 18:29:24 -05:00
.prettierrc feat: add prettier etc for ui-tui 2026-04-02 19:34:30 -05:00
babel.compiler.config.cjs chore(tui): clean remaining Ink perf scaffolding 2026-04-26 21:20:54 -05:00
eslint.config.mjs chore(tui): clean remaining Ink perf scaffolding 2026-04-26 21:20:54 -05:00
package-lock.json Merge remote-tracking branch 'origin/main' into fix/bundle-size 2026-05-11 16:01:04 -04:00
package.json Merge remote-tracking branch 'origin/main' into fix/bundle-size 2026-05-11 16:01:04 -04:00
README.md fix(tui): update README 2026-04-30 18:23:28 -04:00
tsconfig.build.json feat: fork ink and make it work nicely 2026-04-11 11:29:08 -05:00
tsconfig.json feat: fork ink and make it work nicely 2026-04-11 11:29:08 -05:00
vitest.config.ts chore: uptick 2026-04-15 23:29:00 -05:00

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_PYTHONPYTHON$VIRTUAL_ENV/bin/python./.venv/bin/python./venv/bin/pythonpython3 (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/:

  • createGatewayEventHandler.ts — maps gateway events to state updates
  • createSlashHandler.ts — local slash command dispatch
  • useComposerState.ts — draft, multiline buffer, queue editing
  • useInputHandlers.ts — keypress routing
  • useTurnState.ts — agent turn lifecycle
  • overlayStore.ts / uiStore.ts — nanostores for overlay and UI state
  • gatewayContext.tsx — React context for the gateway client
  • constants.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:

  • Tab only 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 / PgDn are 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 than components/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+C cancellation 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 !cmd do 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/Down prioritizes queued-message editing over history. History only activates when there is no queue to edit.
  • Queued drafts keep their original !cmd and {!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 / uses complete.slash. A trailing token that starts with ./, ../, ~/, /, or @ uses complete.path.
  • Text pastes are inserted inline directly into the draft. Nothing is newline-flattened.
  • 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.
  • Input history is stored in ~/.hermes/.hermes_history or under HERMES_HOME.

Rendering

Assistant output is rendered in one of two ways:

  • if the payload already contains ANSI, messageLine.tsx prints it directly
  • otherwise components/markdown.tsx renders 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 deny
  • clarify.request: pick from choices or type a custom answer
  • sudo.request: masked password entry
  • secret.request: masked value entry for a named env var
  • session.list: used by SessionPicker for /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:

  • /copy sends the selected assistant response through OSC 52.
  • /paste with no args asks the gateway to attach a clipboard image.
  • Text paste remains inline-only; 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:

  1. slash.exec
  2. command.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