Models frequently emit bare codepoints like U+26A0 (⚠), U+2139 (ℹ),
U+2764 (❤), U+2714 (✔), U+2600 (☀), U+263A (☺) which, per Unicode, have
Emoji_Presentation=No and render as monochrome text-style glyphs in
terminals unless followed by VS16 (U+FE0F). Agent output leaked through
the TUI like `⚠ careful` instead of `⚠️ careful`.
Added `ensureEmojiPresentation` (lib/emoji.ts): scans for the curated
set of text-default codepoints and appends VS16 when the next char is
not already VS16, ZWJ, or a keycap-enclosing mark. Idempotent and
fast-pathed by a Unicode-range regex so ASCII-heavy text is untouched.
Applied once at the top of `Md`'s line parse. Hermes-ink's stringWidth
already accounts for VS16, so cursor/layout stays correct.
The pager overlay backing /history, /toolsets, /help and any paged slash
output only advanced with Enter/Space and closed at the end. Could not
scroll back, scroll line-by-line, or jump to endpoints.
Adds Up/Down (↑↓, j/k), PgUp (b), g/G for top/bottom, keeps existing
Enter/Space/PgDn forward-and-auto-close, and clamps offset so
over-scrolling past the last page is a no-op.
The completion popup (e.g. typing `/model`) grew from 8 rows at
compIdx=0 up to 16 rows at compIdx≥8 — the slice end was `compIdx + 8`
so every arrow-down added another rendered row until the window filled.
Reported during TUI v2 retest: "as i scroll and more options appear,
for some reason more options appear and it expands the height".
Fixed viewport (`COMPLETION_WINDOW = 16`) centered on compIdx, clamped
so it never slides past the array bounds. Renders exactly
`min(WINDOW, completions.length)` rows every frame.
A6 added a fixed-height grid (Array.from({length: VISIBLE})), but the
row <Text> itself had no wrap prop so Ink defaulted to wrap="wrap".
A sufficiently long model or provider name would wrap to a second
visual line and bounce the overall picker height right back — which
is exactly what reappeared during the TUI v2 blitz retest on /model.
Pin every picker row (and the empty-state / padding rows) to
wrap="truncate-end" so each slot is guaranteed one line. Applies
across modelPicker, sessionPicker, and skillsHub.
Selected rows in the model/session/skills pickers and approval/clarify
prompts only changed from dim gray to cornsilk, which reads as low
contrast on lighter themes and LCDs (reported during TUI v2 blitz).
Switch the selected row to `inverse bold` with the brand accent color
across modelPicker, sessionPicker, skillsHub, and prompts so the
highlight is terminal-portable and unambiguous. Unselected rows stay
dim. Also extends the sessionPicker middle meta column (which was
always dim) to inherit the row's selection state.
Warning row, "↑ N more" / "↓ N more" hints, and the items list were all
conditionally rendered, so the picker jumped in size as the selection
moved or providers without a warning slid into view.
Render every slot unconditionally: warning falls back to a blank line,
hints render an empty string when at the edge, and the items grid always
emits VISIBLE rows padded with blanks. Height is now constant across
providers, model counts, and scroll position.
Medium fixes:
- textInput.tsx: prevent silent data loss when async paste resolves
after user types — fall back to raw text insert at current cursor
instead of dropping the content entirely
- useComposerState.ts: tighten looksLikeDroppedPath to require a
second '/' or '.' for bare absolute paths, avoiding unnecessary
RPC round-trips for pasted text like /api or /help
- useComposerState.ts: add cross-reference comment linking to the
canonical _detect_file_drop() in cli.py
- osc52.ts: add 500ms timeout via Promise.race so terminals that
do not support OSC52 clipboard queries cannot hang paste
Low fixes:
- terminalSetup.ts: export isRemoteShellSession and reuse in
terminalParity.ts and useComposerState.ts (was inlined 3 times)
- useComposerState.ts: extract insertAtCursor helper, replacing 3
copies of the lead/tail spacing logic
- useComposerState.ts: remove redundant gw from handleTextPaste
useCallback dependency array
- terminalSetup.test.ts: add EACCES (read-only keybindings.json)
and unterminated block comment test coverage
Fixes from OutThisLife review:
1. Restore Linux Alt+Enter newline: textInput.tsx now uses
k.shift || (isMac ? isActionMod(k) : k.meta) so Alt+Enter
inserts a newline on Linux (was broken by isMac guard).
2. Fix image.attach response type: useComposerState.ts now uses
ImageAttachResponse (which already has remainder) instead of
InputDetectDropResponse with intersection.
3. Expand looksLikeDroppedPath test coverage with edge cases for
image extensions, file:// URIs, spaces, empty input, and
non-file URLs.
4. Make terminalParity.test.ts hermetic: terminalParityHints() now
accepts optional fileOps/homeDir and passes them through to
shouldPromptForTerminalSetup(), so tests inject mock readFile
instead of hitting the real filesystem.
Fixes from Copilot inline review:
5. Remove unused options.now parameter from configureTerminalKeybindings.
6. Replace naive stripJsonComments (full-line // only) with a proper
JSONC stripper that handles inline // comments, block comments,
trailing commas, and preserves comment-like sequences in strings.
7. Move backupFile() call from immediately after read to right before
write - backups are only created when changes will actually be
written, not on every /terminal-setup invocation.
The agent emits `MEDIA:<path>` to signal file delivery to the gateway,
and `[[audio_as_voice]]` as a voice-delivery hint. The gateway strips
both before sending to Telegram/Discord/Slack, but the TUI was rendering
them raw through markdown — which is also how the intraword underscore
bug originally surfaced (`browser_screenshot_ecc…`).
At the `Md` layer, detect both sentinels on their own line:
- `MEDIA:<path>` → `▸ <path>` with the path rendered literal and wrapped
in a `Link` for OSC 8 hyperlink support (absolute paths get a
`file://` URL, so modern terminals make them click-to-open).
- `[[audio_as_voice]]` → dropped silently; it has no meaning in TUI.
Covers tests for quoted/backticked MEDIA variants, Windows drive paths,
whitespace, and the inline-in-prose case (left untouched — still
protected by the intraword-underscore guard).
The Activity accordion in ToolTrail tints red (via metaTone) when an error
item is present, but stays collapsed — the error is invisible until the
user clicks. Track the latest error id and force-open openMeta whenever
it advances. Users can still manually collapse; a new error re-opens.
Drops `lastUserAt` plumbing and the right-edge idle ticker. Matches the
claude-code / opencode convention: elapsed rides with the busy indicator
(spinner verb), nothing at idle.
- `turnStartedAt` driven by a useEffect on `ui.busy` — stamps on rising
edge, clears on falling edge. Covers agent turns and !shell alike.
- FaceTicker renders ` · {fmtDuration}` while busy; 1 s clock for the
counter, existing 2500 ms cycle for face/verb rotation.
- On busy → idle, if the block ran ≥ 1 s, emit a one-shot
`done in {fmtDuration}` sys line (≡ claude-code's `thought for Ns`).
Status bar ticker was too hot in peripheral vision. The moment the elapsed
value matters is when the prompt returns — so surface it there. Dim
`fmtDuration` next to the GoodVibesHeart, idle-only (hidden while busy),
so quick turns and active streaming stay quiet.
StatusRule now renders `{sinceLastMsg}/{sinceSession}` (e.g. `12s/3m 45s`)
when a user has submitted in the current session; falls back to the total
alone otherwise. Wires `lastUserAt` through the state/session lifecycle:
- useSubmission stamps `setLastUserAt(Date.now())` on send
- useSessionLifecycle nulls it in reset/resetVisibleHistory
- /branch slash nulls it on fork
- branding.tsx: `color="yellow"` → `t.color.warn` so light-mode users get the
burnt-orange warn instead of unreadable bright yellow on white bg.
- theme.ts: replace HERMES_TUI_LIGHT regex with `detectLightMode(env)` that also
sniffs `COLORFGBG` (XFCE Terminal, rxvt, Terminal.app, iTerm2). Bg slot 7 or
15 → LIGHT_THEME. Explicit HERMES_TUI_LIGHT (on *or* off) still wins.
- tests: cover empty env, explicit on/off, COLORFGBG positions, and off-override.
- Fix critical regression: on Linux, Ctrl+C could not interrupt/clear/exit
because isAction(key,'c') shadowed the isCtrl block (both resolve to k.ctrl
on non-macOS). Restructured: isAction block now falls through to interrupt
logic on non-macOS when no selection exists.
- Remove double pbcopy: ink's copySelection() already calls setClipboard()
which handles pbcopy+tmux+OSC52. The extra writeClipboardText call in
useInputHandlers copySelection() was firing pbcopy a second time.
- Remove allowClipboardHotkeys prop from TextInput — every caller passed
isMac, and TextInput already imports isMac. Eliminated prop-drilling
through appLayout, maskedPrompt, and prompts.
- Remove dead code: the isCtrl copy paths (lines 277-288) were unreachable
on any platform after the isAction block changes.
- Simplify textInput Cmd+C: use writeClipboardText directly without the
redundant OSC52 fallback (this path is macOS-only where pbcopy works).
Make the Ink TUI match macOS keyboard expectations: Command handles copy and common editor/session shortcuts, while Control remains reserved for interrupt/cancel flows. Update the visible hotkey help to show platform-appropriate labels.
- providers.ts: drop the `dup` intermediate, fold the ternary inline
- paths.ts (fmtCwdBranch): inline `b` into the `tag` template
- prompts.tsx (ConfirmPrompt): hoist a single `lower = ch.toLowerCase()`,
collapse the three early-return branches into two, drop the
redundant bounds checks on arrow-key handlers (setSel is idempotent
at 0/1), inline the `confirmLabel`/`cancelLabel` defaults at the
use site
- modelPicker.tsx / config/env.ts / providers.test.ts: auto-formatter
reflows picked up by `npm run fix`
- useInputHandlers.ts: drop the stray blank line that was tripping
perfectionist/sort-imports (pre-existing lint error)
The time-window gate felt wrong — users would hit /clear, read the
prompt, retype, and consistently blow past the window. Swapping to a
real yes/no overlay that blocks input like the existing Approval and
Clarify prompts.
- add ConfirmReq type + OverlayState.confirm + $isBlocked coverage
- ConfirmPrompt component (prompts.tsx): cancel row on top as the
default, danger-coloured confirm row on the bottom, Y/N hotkeys,
Enter on default = cancel, Esc/Ctrl+C cancel
- wire into PromptZone (appOverlays.tsx)
- /clear + /new now push onto the overlay instead of arming a timer
- HERMES_TUI_NO_CONFIRM=1 still skips the prompt for scripting
- drop the destructiveGate + createSlashHandler reset wiring
(destructive.ts and its tests removed)
Refs #4069.
Use provider.slug (and a composite key for model rows) instead of the
rendered string, so dupes in the backend response can't collapse two
rows into one or trigger key-collision warnings.
If the gateway returns two providers that resolve to the same display name
(e.g. `kimi-coding` and `kimi-coding-cn` both → "Kimi For Coding"), the
picker now appends the slug so users can tell them apart, in both the
provider list and the selected-provider header. No-op when names are
already unique.
Refs #10526 — the Python backend dedupe from #10599 skips one alias, but
user-defined providers, canonical overlays, and future regressions can
still surface as indistinguishable rows in the picker. This is a
client-side safety net on top of that.
Previous fix in 9dbf1ec6 handled Ctrl+C inside textInput but the APP-level
useInputHandlers fires the same keypress in a separate React hook and ran
clearIn() regardless. Net effect: the OSC 52 copy succeeded but the input
wiped right after, so Brooklyn only noticed the wipe.
Lift the selection-aware Ctrl+C to a single place by threading input
selection state through a new nanostore (src/app/inputSelectionStore.ts).
textInput syncs its derived `selected` range + a clear() callback to the
store on every selection change, and the app-level Ctrl+C handler reads
the store before its clear/interrupt/die chain:
- terminal-level selection (scrollback) → copy, existing behavior
- in-input selection present → copy + clear selection, preserve input
- input has text, no selection → clearIn(), existing behavior
- empty + busy → interrupt turn
- empty + idle → die
textInput no longer has its own Ctrl+C block; keypress falls through to
app-level like it did before 9dbf1ec6.
Before: textInput explicitly ignored Ctrl+C so the app-level handler took
over — with no knowledge of the TextInput's own selection — and fell through
to clearIn() whenever input had text. Selecting part of the composer and
pressing Ctrl+C silently nuked everything you typed.
Now: Ctrl+C with an active in-input selection writes the selected substring
to the clipboard via OSC 52 and clears the selection. The original semantics
(Ctrl+C with no selection → app-level interrupt/clear/die chain) are
preserved by still returning early in that case.
renderLink was discarding the URL entirely — it rendered the label as amber
underlined text and dropped the href. Result: Cmd+Click / Ctrl+Click did
nothing in any terminal, including Ghostty.
Now both markdown links `[label](url)` and bare `https://…` URLs are wrapped
in @hermes/ink's Link component, which emits OSC 8 (\\x1b]8;;url\\x07label\\x1b]8;;\\x07)
when supportsHyperlinks() returns true. ADDITIONAL_HYPERLINK_TERMINALS already
includes ghostty, iTerm2, kitty, alacritty, Hyper.
Autolinks that look like bare emails (foo@bar.com) now prepend mailto: in the
href so they open the mail client correctly.
Also adds a typed declaration for Link in hermes-ink.d.ts.
Large inline scripts (e.g. Python code_execution bodies) rendered as a single
unbounded <Text> block, pushing the Allow/Deny options below the visible
viewport. Users had to scroll the terminal to vote.
Preview now shows the first 10 lines with truncate-end wrap per line and a
dim "… +N more lines" indicator. Full text remains in the transcript above.
- /retry: use session['history'] instead of non-existent
agent.conversation_history; truncate history at last user message
to match CLI retry_last() behavior; add history_lock safety
- /plan: pass user instruction (arg) to build_plan_path instead of
session_key; add runtime_note so agent knows where to save the plan
- ANSI tool results: render full text via <Ansi wrap=truncate-end>
instead of slicing raw ANSI through compactPreview (which cuts
mid-escape-sequence producing garbled output)
- Move _PENDING_INPUT_COMMANDS frozenset to module level
- Use get_skill_commands() (cached) instead of scan_skill_commands()
(rescans disk) in slash.exec skill interception
- Add 3 retry tests: happy path with history truncation verification,
empty history error, multipart content extraction
- Update test mock target from scan_skill_commands to get_skill_commands
Additional TUI fixes discovered in the same audit:
1. /plan slash command was silently lost — process_command() queues the
plan skill invocation onto _pending_input which nobody reads in the
slash worker subprocess. Now intercepted in slash.exec and routed
through command.dispatch with a new 'send' dispatch type.
Same interception added for /retry, /queue, /steer as safety nets
(these already have correct TUI-local handlers in core.ts, but the
server-side guard prevents regressions if the local handler is
bypassed).
2. Tool results were stripping ANSI escape codes — the messageLine
component used stripAnsi() + plain <Text> for tool role messages,
losing all color/styling from terminal, search_files, etc. Now
uses <Ansi> component (already imported) when ANSI is detected.
3. Terminal tab title now shows model + busy status via useTerminalTitle
hook from @hermes/ink (was never used). Users can identify Hermes
tabs and see at a glance whether the agent is busy or ready.
4. Added 'send' variant to CommandDispatchResponse type + asCommandDispatch
parser + createSlashHandler handler for commands that need to inject
a message into the conversation (plan, queue fallback, steer fallback).
Two TUI fixes:
1. Hyperlinks are now clickable (Cmd+Click / Ctrl+Click) in terminals
that support OSC 8. The markdown renderer was rendering links as
plain colored text — now wraps them in the existing <Link> component
from @hermes/ink which emits OSC 8 escape sequences.
2. Skill slash commands (e.g. /hermes-agent-dev) now work in the TUI.
The slash.exec handler was delegating to the _SlashWorker subprocess
which calls cli.process_command(). For skills, process_command()
queues the invocation message onto _pending_input — a Queue that
nobody reads in the worker subprocess. The skill message was lost.
Now slash.exec detects skill commands early and rejects them so
the TUI falls through to command.dispatch, which correctly builds
and returns the skill payload for the client to send().
Adds a minimal hand-rolled highlighter for ts/js/jsx/tsx, py, sh/bash, go, rust,
json, yaml, sql. Recognizes whole-line comments, single/double/backtick strings,
numbers, and per-language keyword sets. Unknown langs fall through to the current
plain rendering; the existing diff-specific colorization is preserved.
Closes the §8 "Markdown syntax highlighting is missing (only diff gets colored)"
finding from the TUI v2 audit without pulling in a highlighter library.
/skills install, inspect, search, browse, list now call the typed skills.manage RPC
and render results via panel/page. Previously they fell through to slash.exec which
invokes v1's curses code path — that hangs or crashes inside the Ink worker per the
§2 parity-audit finding.
Also drop Enter-as-install from the Skills Hub action stage since the Hub lists
locally installed skills; primary action is inspect-and-close. x still triggers a
manual reinstall for power users.
New SkillsHub mirrors ModelPicker's category → item → actions flow with
paginated 12-line lists, 1-9/0 quick-pick, Esc-back navigation, and
lazy skills.manage inspect/install calls. Mount it from appOverlays
when overlay.skillsHub is true.
- turnController gates scheduleStreaming / reasoning recorders on
streaming + showReasoning so disabling them keeps the buffer silent
until message.complete flushes
- createGatewayEventHandler only surfaces inline_diff previews when
inlineDiffs is on
- StatusRule takes a showCost prop and renders `· $X.XXXX` with the
same toFixed(4) formatting as /usage when usage.cost_usd is present
- Usage grows cost_usd?: number to match the gateway payload
- Existing handler tests flip showReasoning on in beforeEach so
reasoning-flow assertions keep their meaning
Live turn rendering used to show the streaming assistant text as one
blob with tool calls pooled in a separate section below, so the live
view drifted from the reload view (which threads tool rows inline via
toTranscriptMessages). Model now mirrors reload:
- turnStore gains streamSegments (completed assistant chunks, each
with any tool rows that landed between its predecessor and itself)
and streamPendingTools (tool rows waiting for the next chunk)
- turnController.flushStreamingSegment() seals the current bufRef into
a segment when a new tool.start fires; pending tools get attached to
that next chunk so order matches reload hydration
- recordMessageComplete returns finalMessages instead of one payload,
so appendMessage gets the same shape for live-ending turns as for
reloaded ones
- appLayout renders segments before the progress/streaming area, and
the streaming message + pending-tools fallback carry whatever tools
arrived after the last assistant chunk
- drop inline `import()` type annotation in useSessionLifecycle (import
`PanelSection` at the top like everything else)
- include `panel` and `session.resumeById` in the useMainApp useMemo
deps now that the event handler depends on them
- wrap the derived `selected` range in a useMemo so it has stable
identity and stops invalidating the TextInput `rendered` memo every
render
- prettier re-sorting of a couple of export/import lines
- tui_gateway: route approvals through gateway callback (HERMES_GATEWAY_SESSION/
HERMES_EXEC_ASK) so dangerous commands emit approval.request instead of
silently falling through the CLI input() path and auto-denying
- approval UX: dedicated PromptZone between transcript and composer, safer
defaults (sel=0, numeric quick-picks, no Esc=deny), activity trail line,
outcome footer under the cost row
- text input: Ctrl+A select-all, real forward Delete, Ctrl+W always consumed
(fixes Ctrl+Backspace at cursor 0 inserting literal w)
- hermes-ink selection: swap synchronous onRender() for throttled
scheduleRender() on drag, and only notify React subscribers on presence
change — no more per-cell paint/subscribe spam
- useConfigSync: silence config.get polling failures instead of surfacing
'error: timeout: config.get' in the transcript