When the user runs /voice and then presses Ctrl+B in the TUI, three
handlers collaborate to consume the chord and none of them dispatch
voice.record:
- isAction() is platform-aware — on macOS it requires Cmd (meta/super),
so Ctrl+B fails the match in useInputHandlers and never triggers
voiceStart/voiceStop.
- TextInput's Ctrl+B pass-through list doesn't include 'b', so the
keystroke falls through to the wordMod backward-word branch on Linux
and to the printable-char insertion branch on macOS — the latter is
exactly what timmie reported ("enters a b into the tui").
- /voice emits "voice: on" with no hint, so the user has no way to
know Ctrl+B is the recording toggle.
Introduces isVoiceToggleKey(key, ch) in lib/platform.ts that matches
raw Ctrl+B on every platform (mirrors tips.py and config.yaml's
voice.record_key default) and additionally accepts Cmd+B on macOS so
existing muscle memory keeps working. Wires it into useInputHandlers,
adds Ctrl+B to TextInput's pass-through list so the global handler
actually receives the chord, and appends "press Ctrl+B to record" to
the /voice on message.
Empirically verified with hermes --tui: Ctrl+B no longer leaks 'b'
into the composer and now dispatches the voice.record RPC (the
downstream ImportError for hermes_cli.voice is a separate upstream
bug — follow-up patch).
- appLayout.tsx: restore the 1-row placeholder when `showStickyPrompt`
is false. Dropping it saved a row but the composer height shifted by
one as the prompt appeared/disappeared, jumping the input vertically
on scroll.
- useInputHandlers: gateway.rpc (from useMainApp) already catches errors
with its own sys() message and resolves to null. The previous `.catch`
was dead code and on RPC failures the user saw both 'error: ...' (from
rpc) and 'failed to toggle yolo'. Drop the catch and gate 'failed to
toggle yolo' on a non-null response so null (= rpc already spoke)
stays silent.
Copilot on #14145 flagged that the shift+tab yolo handler treated any
non-null RPC result as valid, so a response shape like {value: undefined}
or {value: 'weird'} would incorrectly echo 'yolo off'. Now only '1' and
'0' map to on/off; anything else (including missing value) surfaces as
'failed to toggle yolo', matching the null/catch branches.
- normalizeStatusBar: trim/lowercase + 'on' → 'top' alias so user-edited
YAML variants (Top, " bottom ", on) coerce correctly
- shift-tab yolo: no-op with sys note when no live session; success-gated
echo and catch fallback so RPC failures don't report as 'yolo off'
- tui_gateway config.set/get statusbar: isinstance(display, dict) guards
mirroring the compact branch so a malformed display scalar in config.yaml
can't raise
Tests: +1 vitest for trim/case/on, +2 pytest for non-dict display survival.
- normalizeStatusBar collapses to one ternary expression
- /statusbar slash hoists the toggle value and flattens the branch tree
- shift-tab yolo comment reduced to one line
- cursorLayout/offsetFromPosition lose paragraph-length comments
- appLayout collapses the three {!overlay.agents && …} into one fragment
- StatusRule drops redundant flexShrink={0} (Yoga default)
- server.py uses a walrus + frozenset and trims the compat helper
Net -43 LoC. 237 vitest + 46 pytest green, layouts unchanged.
- input wrap: add <Text wrap="wrap-char"> mode that drives wrap-ansi with
wordWrap:false, and align cursorLayout/offsetFromPosition to that same
boundary (w=cols, trailing-cell overflow). Word-wrap's whitespace
reshuffle was causing the cursor to jump a word left/right on each
keystroke near the right edge — blitz row 9
- shift-tab: toggle per-session yolo without submitting a turn (mirrors
Claude Code's in-place dangerously-approve); slash /yolo still works
for discoverability — blitz row 5 sub-item 11
- statusline: lift StatusRule out of ComposerPane to a new StatusRulePane
anchored at the bottom of AppLayout, below the input — blitz row 5
sub-item 12
Adds a live + post-hoc audit surface for recursive delegate_task fan-out.
None of cc/oc/oclaw tackle nested subagent trees inside an Ink overlay;
this ships a view-switched dashboard that handles arbitrary depth + width.
Python
- delegate_tool: every subagent event now carries subagent_id, parent_id,
depth, model, tool_count; subagent.complete also ships input/output/
reasoning tokens, cost, api_calls, files_read/files_written, and a
tail of tool-call outputs
- delegate_tool: new subagent.spawn_requested event + _active_subagents
registry so the overlay can kill a branch by id and pause new spawns
- tui_gateway: new RPCs delegation.status, delegation.pause,
subagent.interrupt, spawn_tree.save/list/load (disk under
\$HERMES_HOME/spawn-trees/<session>/<ts>.json)
TUI
- /agents overlay: full-width list mode (gantt strip + row picker) and
Enter-to-drill full-width scrollable detail mode; inverse+amber
selection, heat-coloured branch markers, wall-clock gantt with tick
ruler, per-branch rollups
- Detail pane: collapsible accordions (Budget, Files, Tool calls, Output,
Progress, Summary); open-state persists across agents + mode switches
via a shared atom
- /replay [N|last|list|load <path>] for in-memory + disk history;
/replay-diff <a> <b> for side-by-side tree comparison
- Status-bar SpawnHud warns as depth/concurrency approaches caps;
overlay auto-follows the just-finished turn onto history[1]
- Theme: bump DARK dim #B8860B → #CC9B1F for readable secondary text
globally; keep LIGHT untouched
Tests: +29 new subagentTree unit tests; 215/215 passing.
Follow-up on multiline arrow behavior: Up/Down now fall back to queue/history whenever there is no logical line above/below the caret (not only at absolute start/end character positions). This makes Up from the end of the top line cycle history, matching expected readline-ish behavior.
Follow-up on #13726 from blitz feedback: Up/Down history cycling should only trigger when the caret is at the start/end boundary (or the input is empty).\n\nPreviously useInputHandlers intercepted arrows whenever inputBuf was empty, which still stole Up/Down from normal multiline editing. textInput now publishes caret position through inputSelectionStore even with no active selection, and useInputHandlers gates history/queue cycling on those boundaries.
Two bugs that allow dangerous commands to execute without informed user consent.
TUI (Ink): useInputHandlers consumes the isBlocked return path, but Ink's
EventEmitter delivers keystrokes to ALL registered useInput listeners. The
ApprovalPrompt component receives arrow keys, number keys, and Enter even
though the overlay appears frozen. The user sees no visual feedback, but
keystrokes are processed — allowing blind approval, session-wide auto-approve
(choice "session"), or permanent allowlist writes (choice "always") without
the user knowing.
Discovered while replicating #13618 (TUI approval overlay freezes terminal).
Fix: in useInputHandlers, when overlay.approval/clarify/confirm is active,
only intercept Ctrl+C. All other keys pass through. This makes the overlay
visually responsive so the user can see what they are selecting.
CLI (prompt_toolkit): _callback_tls in terminal_tool.py is threading.local().
set_approval_callback() is called in the main thread during run(), but the
agent executes in a background thread. _get_approval_callback() returns None
in the agent thread, falling back to stdin input() which prompt_toolkit
blocks. The user sees the approval text but cannot respond — the terminal is
unusable until the 60s timeout expires with a default "deny".
Fix: set callbacks inside run_agent() (the thread target), matching the
pattern already used by acp_adapter/server.py. Clear on thread exit to avoid
stale references.
Closes#13618
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.
- 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)
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.
Extend OverlayState with a skillsHub flag, fold it into $isBlocked, and
teach Ctrl+C to close the overlay so later PRs can render the component
behind this slot.
- 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
Hoist turn state from a 286-line hook into $turnState atom + turnController
singleton. createGatewayEventHandler becomes a typed dispatch over the
controller; its ctx shrinks from 30 fields to 5. Event-handler refs and 16
threaded actions are gone.
Fold three createSlash*Handler factories into a data-driven SlashCommand[]
registry under slash/commands/{core,session,ops}.ts. Aliases are data;
findSlashCommand does name+alias lookup. Shared guarded/guardedErr combinator
in slash/guarded.ts.
Split constants.ts + app/helpers.ts into config/ (timing/limits/env),
content/ (faces/placeholders/hotkeys/verbs/charms/fortunes), domain/ (roles/
details/messages/paths/slash/viewport/usage), protocol/ (interpolation/paste).
Type every RPC response in gatewayTypes.ts (26 new interfaces); drop all
`(r: any)` across slash + main app.
Shrink useMainApp from 1216 -> 646 lines by extracting useSessionLifecycle,
useSubmission, useConfigSync. Add <Fg> themed primitive and strip ~50
`as any` color casts.
Tests: 50 passing. Build + type-check clean.