fix(tui): address PR #13231 review comments
Six small fixes, all valid review feedback:
- gatewayClient: onTimeout is now a class-field arrow so setTimeout gets a
stable reference — no per-request bind allocation (the whole point of
the original refactor).
- memory: growth rate was lifetime average of rss/uptime, which reports
phantom growth for stable processes. Now computed as delta since a
module-load baseline (STARTED_AT). Sanity-checked: 0.00 MB/hr at
steady-state, non-zero after an allocation.
- hermes_cli: NODE_OPTIONS merge is now token-aware — respects a
user-supplied --max-old-space-size (don't downgrade a deliberate 16GB
setting) and avoids duplicating --expose-gc.
- useVirtualHistory: if items shrink past the frozen range's start
mid-freeze (/clear, compaction), drop the freeze and fall through to
the normal range calc instead of collapsing to an empty mount.
- circularBuffer: throw on non-positive capacity instead of silently
producing NaN indices.
- debug slash help: /heapdump mentions HERMES_HEAPDUMP_DIR override
instead of hardcoding the default path.
Validation: tsc clean, eslint clean, vitest 102/102, growth-rate smoke
test confirms baseline=0 → post-alloc>0.
docs: clarify wrapForFrac and streaming math-fence rationale
Address two Copilot review comments on PR #17175.
- wrapForFrac doc said "additive operators or whitespace" but the
implementation also matches * and /. The wider behaviour is the
one we want (nested products and fractions need parens to disambiguate
inline /), so the doc is updated to match instead of tightening the
regex.
- fenceOpenAt was flagged as "overly conservative" vs. markdown.tsx,
which falls back to paragraph rendering for unclosed $$ openers.
Mirroring that fallback in the streaming chunker would prematurely
commit a paragraph rendering of the unclosed opener to the monotonic
stable prefix, where it would be frozen and become wrong the moment
the closer streams in. The asymmetry is deliberate; document why so
it isn't "fixed" again later.
Made-with: Cursor
fix(tui): address PR #13231 review comments
Six small fixes, all valid review feedback:
- gatewayClient: onTimeout is now a class-field arrow so setTimeout gets a
stable reference — no per-request bind allocation (the whole point of
the original refactor).
- memory: growth rate was lifetime average of rss/uptime, which reports
phantom growth for stable processes. Now computed as delta since a
module-load baseline (STARTED_AT). Sanity-checked: 0.00 MB/hr at
steady-state, non-zero after an allocation.
- hermes_cli: NODE_OPTIONS merge is now token-aware — respects a
user-supplied --max-old-space-size (don't downgrade a deliberate 16GB
setting) and avoids duplicating --expose-gc.
- useVirtualHistory: if items shrink past the frozen range's start
mid-freeze (/clear, compaction), drop the freeze and fall through to
the normal range calc instead of collapsing to an empty mount.
- circularBuffer: throw on non-positive capacity instead of silently
producing NaN indices.
- debug slash help: /heapdump mentions HERMES_HEAPDUMP_DIR override
instead of hardcoding the default path.
Validation: tsc clean, eslint clean, vitest 102/102, growth-rate smoke
test confirms baseline=0 → post-alloc>0.
tui: make URLs clickable + hover-highlight in any terminal (#25071)
* tui: make URLs clickable + hover-highlight in any terminal
Problem
-------
URLs printed by hermes --tui were not clickable in basic macOS Terminal.app.
Cmd+click did nothing, the cursor didn't change shape — like nothing was
detected — even though arrow buttons and other Box onClick handlers worked
fine.
Root cause
----------
Two layers of dead plumbing:
1. <Link> only emitted the underlying <ink-link> (which carries the
hyperlink metadata into the screen buffer) when supportsHyperlinks()
said yes. On Apple_Terminal that's false, so the per-cell hyperlink
field stayed empty, so Ink.getHyperlinkAt() had nothing to return on
click. The visible underline was just decorative.
2. Ink.openHyperlink() calls this.onHyperlinkClick?.(url), but
onHyperlinkClick was never assigned anywhere in the codebase. The
click pipeline (App.tsx → onOpenHyperlink → Ink.openHyperlink) ran
but bailed silently on the optional chain.
Bonus discovery: even when wired up, there was no hover affordance —
terminal apps can't change the system mouse cursor, so users had no
visual signal that a cell was clickable. Arrow buttons in the chrome
worked because they had explicit <Box onClick> styling; inline link
URLs didn't.
Fix
---
- Link.tsx: always emit <ink-link> regardless of terminal capability.
The renderer's wrapWithOsc8Link already gates the actual OSC 8 escape
on supportsHyperlinks() further down — so terminals that don't
understand OSC 8 still don't see the escape, but the screen-buffer
metadata (which the click dispatcher reads) is now populated everywhere.
- ink.tsx + root.ts: add onHyperlinkClick?: (url: string) => void to
Options / RenderOptions, wire it to the existing Ink.onHyperlinkClick
field in the constructor.
- src/lib/openExternalUrl.ts: small platform-aware opener using
child_process.spawn with arg-array (no shell) — http(s) only, rejects
file:, javascript:, data:, etc., so a hostile model can't trigger
arbitrary local handlers via <Link url="file:///...">. Detached + stdio
ignore so closing the TUI doesn't kill the browser and Chrome stderr
doesn't leak into the alt screen.
- entry.tsx: pass onHyperlinkClick: openExternalUrl to ink.render.
- hyperlinkHover.ts + Ink hover wiring: track the URL under the pointer
in Ink.hoveredHyperlink, update it from dispatchHover, and inverse-
highlight every cell of the matching link in the render-pass overlay
(same pattern as applySearchHighlight). This is the cursor-hover
affordance for clickable links — terminals don't expose cursor shape,
so we light up the link itself.
- types/hermes-ink.d.ts: add onHyperlinkClick to the RenderOptions
shim so consumers (entry.tsx) type-check against the new option.
Tests
-----
- src/lib/openExternalUrl.test.ts (15 cases): http(s) accepted; file/js/
data/mailto/ftp/ssh rejected; macOS open(1), Windows cmd.exe start with
empty title slot, Linux xdg-open dispatch; shell-metacharacter URLs
pass through unmolested as a single argv element; synchronous spawn
failure returns false.
Verified empirically in Apple Terminal 455.1 (macOS 15.7.3): clicking a
URL opens in default browser, hovering inverts the link cells, and
moving away clears the highlight. Full TUI suite: 713 passing, 0
type errors.
Reverts
-------
The earlier attempt that version-gated Apple_Terminal in
supports-hyperlinks.ts was based on a wrong assumption — Terminal.app
silently strips OSC 8 sequences but does not render them as clickable
hyperlinks. Reverted to the original allowlist.
* tui: address Copilot review — explorer.exe on win32 + comment fixes
- openExternalUrl: switch win32 from cmd.exe /c start to explorer.exe.
cmd.exe's start builtin reparses the URL through cmd's tokenizer, so
&, |, ^, <, > either split the command or get reinterpreted —
breaking both the protocol-allowlist safety story AND plain http(s) URLs
with & in query strings. explorer.exe <url> invokes the registered
protocol handler directly with no shell.
- openExternalUrl.test.ts: rename the win32 test to reflect the new
contract and add two regression tests — one with &|^<> metachars,
one with the common analytics-URL & query-param pattern — both pinned
to single-argv-element delivery via explorer.exe.
- Link.tsx: fix misleading comment. OSC 8 escapes are emitted
unconditionally by the renderer (wrapWithOsc8Link in
render-node-to-output.ts, oscLink in log-update.ts). Non-supporting
terminals silently strip the sequence, which is why hover/click
affordance has to come from the in-process overlay rather than the
terminal's own link rendering.
Verified: 715/715 tests pass, type-check + build clean.
* tui: address Copilot review #2 — async spawn errors + hover scope + docs
1. openExternalUrl: attach a no-op 'error' listener on the spawned
child BEFORE unref(). spawn() returns a ChildProcess synchronously
even when the binary is missing (ENOENT on xdg-open / explorer.exe),
unreachable, or otherwise unusable; the failure surfaces later as
an 'error' event. An unhandled 'error' on an EventEmitter crashes
Node, which would tear down the whole TUI. The listener is a
deliberate no-op — we already returned true synchronously and the
user just doesn't see the browser pop.
2. openExternalUrl.test.ts: add a regression test using a real
EventEmitter to simulate the async-error path. Pins both the
listener-attached contract and the "doesn't throw on emit" behavior.
Was 17/17, now 18/18.
3. ink.tsx dispatchHover: bypass getHyperlinkAt() and read
cellAt(...).hyperlink directly. getHyperlinkAt falls back to
findPlainTextUrlAt for cells without an OSC 8 hyperlink, but the
render-pass overlay (applyHyperlinkHoverHighlight) only matches on
cell.hyperlink === hoveredUrl — so plain-text URLs would burn
re-renders without ever producing the highlight. Hover is now a
strictly 1:1 fit for what the overlay can paint. Plain-text URLs
still get the click action via the existing dispatch path.
4. root.ts + ink.tsx doc comments: replace the misleading "typically
open / xdg-open / start shell" wording with the actual safe
recipe — argv-array spawn into open / xdg-open / explorer.exe,
with an explicit warning that cmd.exe /c start reparses the URL
through cmd's tokenizer and is unsafe + breaks &-query URLs.
Verified: 716/716 tests pass, type-check + build clean.
* tui: address Copilot review #3 — hover damage, alt-screen cleanup, opener allowlist
1. ink.tsx onRender: stop folding steady-state hover into hlActive.
hlActive forces a full-screen damage diff so previous-frame inverted
cells get re-emitted when the highlight set changes. The transition
IS the trigger — enter / leave / change-to-other-link. While the
pointer just sits on a link the painted cells don't change and the
per-cell diff handles the no-op. Folding the steady state in would
burn a full-screen diff on every frame. Added a
lastRenderedHoveredHyperlink tracker and gate the hlActive bump on
hovered !== lastRendered.
2. ink.tsx setAltScreenActive: clear hoveredHyperlink (and the tracker)
when toggling alt-screen state. Hover dispatch is alt-screen-gated,
so once we leave there's no path to clear it. Without this, remounting
<AlternateScreen> would paint a phantom hover from the previous
session until the next mouse-move arrived.
3. openExternalUrl.ts openCommand: allowlist linux + the BSD family for
xdg-open and return null for everything else (aix, sunos, cygwin,
haiku, etc.). Previously the default-fallback always returned
xdg-open, which made the caller's if (!command) return false dead
and yielded a misleading true on platforms that probably don't
have xdg-open. New tests cover the null path AND the
openExternalUrl-returns-false-without-spawning behavior.
Verified: 718/718 tests pass, type-check + build clean.
* tui: address Copilot review #4 — doc comment accuracy
1. openExternalUrl return-value doc: now lists all three false paths
(URL rejected / no opener for platform / synchronous spawn throw)
plus a note that async 'error' events still return true because the
spawn was attempted.
2. ink.tsx onHyperlinkClick field doc: clarifies the callback receives
either an OSC 8 hyperlink OR a plain-text URL detected by
findPlainTextUrlAt — App.tsx routes both into the same callback.
3. hyperlinkHover applyHyperlinkHoverHighlight doc: drops the misleading
'caller forces full-frame damage' promise. Caller decides; for hover
the current caller only forces full damage on transitions.
No behavior change. 718/718 tests pass.
* tui: address Copilot review #5 — lint fixes
1. ink.tsx: reorder ./hyperlinkHover.js import before ./screen.js to
satisfy perfectionist/sort-imports.
2. Link.tsx: drop unused fallback parameter destructuring + the
trailing void (null as ...) dead-statement (would trip
no-unused-expressions). Kept fallback?: ReactNode on the Props
interface as a documented compat shim so existing call sites still
compile, with a comment explaining why it's no longer wired up.
3. openExternalUrl.test.ts: replace typeof import('node:child_process').spawn
inline annotations (forbidden by @typescript-eslint/consistent-type-imports)
with a SpawnLike type alias backed by a real import type { spawn as SpawnFn }.
No behavior change. 718/718 tests pass, type-check clean, lint clean on
all modified files.
tui: make URLs clickable + hover-highlight in any terminal (#25071)
* tui: make URLs clickable + hover-highlight in any terminal
Problem
-------
URLs printed by hermes --tui were not clickable in basic macOS Terminal.app.
Cmd+click did nothing, the cursor didn't change shape — like nothing was
detected — even though arrow buttons and other Box onClick handlers worked
fine.
Root cause
----------
Two layers of dead plumbing:
1. <Link> only emitted the underlying <ink-link> (which carries the
hyperlink metadata into the screen buffer) when supportsHyperlinks()
said yes. On Apple_Terminal that's false, so the per-cell hyperlink
field stayed empty, so Ink.getHyperlinkAt() had nothing to return on
click. The visible underline was just decorative.
2. Ink.openHyperlink() calls this.onHyperlinkClick?.(url), but
onHyperlinkClick was never assigned anywhere in the codebase. The
click pipeline (App.tsx → onOpenHyperlink → Ink.openHyperlink) ran
but bailed silently on the optional chain.
Bonus discovery: even when wired up, there was no hover affordance —
terminal apps can't change the system mouse cursor, so users had no
visual signal that a cell was clickable. Arrow buttons in the chrome
worked because they had explicit <Box onClick> styling; inline link
URLs didn't.
Fix
---
- Link.tsx: always emit <ink-link> regardless of terminal capability.
The renderer's wrapWithOsc8Link already gates the actual OSC 8 escape
on supportsHyperlinks() further down — so terminals that don't
understand OSC 8 still don't see the escape, but the screen-buffer
metadata (which the click dispatcher reads) is now populated everywhere.
- ink.tsx + root.ts: add onHyperlinkClick?: (url: string) => void to
Options / RenderOptions, wire it to the existing Ink.onHyperlinkClick
field in the constructor.
- src/lib/openExternalUrl.ts: small platform-aware opener using
child_process.spawn with arg-array (no shell) — http(s) only, rejects
file:, javascript:, data:, etc., so a hostile model can't trigger
arbitrary local handlers via <Link url="file:///...">. Detached + stdio
ignore so closing the TUI doesn't kill the browser and Chrome stderr
doesn't leak into the alt screen.
- entry.tsx: pass onHyperlinkClick: openExternalUrl to ink.render.
- hyperlinkHover.ts + Ink hover wiring: track the URL under the pointer
in Ink.hoveredHyperlink, update it from dispatchHover, and inverse-
highlight every cell of the matching link in the render-pass overlay
(same pattern as applySearchHighlight). This is the cursor-hover
affordance for clickable links — terminals don't expose cursor shape,
so we light up the link itself.
- types/hermes-ink.d.ts: add onHyperlinkClick to the RenderOptions
shim so consumers (entry.tsx) type-check against the new option.
Tests
-----
- src/lib/openExternalUrl.test.ts (15 cases): http(s) accepted; file/js/
data/mailto/ftp/ssh rejected; macOS open(1), Windows cmd.exe start with
empty title slot, Linux xdg-open dispatch; shell-metacharacter URLs
pass through unmolested as a single argv element; synchronous spawn
failure returns false.
Verified empirically in Apple Terminal 455.1 (macOS 15.7.3): clicking a
URL opens in default browser, hovering inverts the link cells, and
moving away clears the highlight. Full TUI suite: 713 passing, 0
type errors.
Reverts
-------
The earlier attempt that version-gated Apple_Terminal in
supports-hyperlinks.ts was based on a wrong assumption — Terminal.app
silently strips OSC 8 sequences but does not render them as clickable
hyperlinks. Reverted to the original allowlist.
* tui: address Copilot review — explorer.exe on win32 + comment fixes
- openExternalUrl: switch win32 from cmd.exe /c start to explorer.exe.
cmd.exe's start builtin reparses the URL through cmd's tokenizer, so
&, |, ^, <, > either split the command or get reinterpreted —
breaking both the protocol-allowlist safety story AND plain http(s) URLs
with & in query strings. explorer.exe <url> invokes the registered
protocol handler directly with no shell.
- openExternalUrl.test.ts: rename the win32 test to reflect the new
contract and add two regression tests — one with &|^<> metachars,
one with the common analytics-URL & query-param pattern — both pinned
to single-argv-element delivery via explorer.exe.
- Link.tsx: fix misleading comment. OSC 8 escapes are emitted
unconditionally by the renderer (wrapWithOsc8Link in
render-node-to-output.ts, oscLink in log-update.ts). Non-supporting
terminals silently strip the sequence, which is why hover/click
affordance has to come from the in-process overlay rather than the
terminal's own link rendering.
Verified: 715/715 tests pass, type-check + build clean.
* tui: address Copilot review #2 — async spawn errors + hover scope + docs
1. openExternalUrl: attach a no-op 'error' listener on the spawned
child BEFORE unref(). spawn() returns a ChildProcess synchronously
even when the binary is missing (ENOENT on xdg-open / explorer.exe),
unreachable, or otherwise unusable; the failure surfaces later as
an 'error' event. An unhandled 'error' on an EventEmitter crashes
Node, which would tear down the whole TUI. The listener is a
deliberate no-op — we already returned true synchronously and the
user just doesn't see the browser pop.
2. openExternalUrl.test.ts: add a regression test using a real
EventEmitter to simulate the async-error path. Pins both the
listener-attached contract and the "doesn't throw on emit" behavior.
Was 17/17, now 18/18.
3. ink.tsx dispatchHover: bypass getHyperlinkAt() and read
cellAt(...).hyperlink directly. getHyperlinkAt falls back to
findPlainTextUrlAt for cells without an OSC 8 hyperlink, but the
render-pass overlay (applyHyperlinkHoverHighlight) only matches on
cell.hyperlink === hoveredUrl — so plain-text URLs would burn
re-renders without ever producing the highlight. Hover is now a
strictly 1:1 fit for what the overlay can paint. Plain-text URLs
still get the click action via the existing dispatch path.
4. root.ts + ink.tsx doc comments: replace the misleading "typically
open / xdg-open / start shell" wording with the actual safe
recipe — argv-array spawn into open / xdg-open / explorer.exe,
with an explicit warning that cmd.exe /c start reparses the URL
through cmd's tokenizer and is unsafe + breaks &-query URLs.
Verified: 716/716 tests pass, type-check + build clean.
* tui: address Copilot review #3 — hover damage, alt-screen cleanup, opener allowlist
1. ink.tsx onRender: stop folding steady-state hover into hlActive.
hlActive forces a full-screen damage diff so previous-frame inverted
cells get re-emitted when the highlight set changes. The transition
IS the trigger — enter / leave / change-to-other-link. While the
pointer just sits on a link the painted cells don't change and the
per-cell diff handles the no-op. Folding the steady state in would
burn a full-screen diff on every frame. Added a
lastRenderedHoveredHyperlink tracker and gate the hlActive bump on
hovered !== lastRendered.
2. ink.tsx setAltScreenActive: clear hoveredHyperlink (and the tracker)
when toggling alt-screen state. Hover dispatch is alt-screen-gated,
so once we leave there's no path to clear it. Without this, remounting
<AlternateScreen> would paint a phantom hover from the previous
session until the next mouse-move arrived.
3. openExternalUrl.ts openCommand: allowlist linux + the BSD family for
xdg-open and return null for everything else (aix, sunos, cygwin,
haiku, etc.). Previously the default-fallback always returned
xdg-open, which made the caller's if (!command) return false dead
and yielded a misleading true on platforms that probably don't
have xdg-open. New tests cover the null path AND the
openExternalUrl-returns-false-without-spawning behavior.
Verified: 718/718 tests pass, type-check + build clean.
* tui: address Copilot review #4 — doc comment accuracy
1. openExternalUrl return-value doc: now lists all three false paths
(URL rejected / no opener for platform / synchronous spawn throw)
plus a note that async 'error' events still return true because the
spawn was attempted.
2. ink.tsx onHyperlinkClick field doc: clarifies the callback receives
either an OSC 8 hyperlink OR a plain-text URL detected by
findPlainTextUrlAt — App.tsx routes both into the same callback.
3. hyperlinkHover applyHyperlinkHoverHighlight doc: drops the misleading
'caller forces full-frame damage' promise. Caller decides; for hover
the current caller only forces full damage on transitions.
No behavior change. 718/718 tests pass.
* tui: address Copilot review #5 — lint fixes
1. ink.tsx: reorder ./hyperlinkHover.js import before ./screen.js to
satisfy perfectionist/sort-imports.
2. Link.tsx: drop unused fallback parameter destructuring + the
trailing void (null as ...) dead-statement (would trip
no-unused-expressions). Kept fallback?: ReactNode on the Props
interface as a documented compat shim so existing call sites still
compile, with a comment explaining why it's no longer wired up.
3. openExternalUrl.test.ts: replace typeof import('node:child_process').spawn
inline annotations (forbidden by @typescript-eslint/consistent-type-imports)
with a SpawnLike type alias backed by a real import type { spawn as SpawnFn }.
No behavior change. 718/718 tests pass, type-check clean, lint clean on
all modified files.
fix(tui): respect voice.record_key config (supersedes #19028, #19339) (#19835)
* fix(tui): respect voice.record_key config instead of hardcoded Ctrl+B
Classic CLI loaded voice.record_key from config.yaml and bound the
prompt-toolkit handler dynamically (cli.py paths). The new TUI hard-
coded Ctrl+B everywhere — isVoiceToggleKey (input handler),
/voice status ("Record key: Ctrl+B"), and /voice on ("Ctrl+B to
start/stop recording"). A user who set voice.record_key: ctrl+o
(or any other key) saw the documented config silently ignored — only
Ctrl+B worked, the displayed shortcut lied about it.
Wire the configured key end to end through the existing channels:
* **Backend** (tui_gateway/server.py): voice.toggle action=status
AND action=on/off responses now include record_key, sourced from
config.get('voice', {}).get('record_key', 'ctrl+b').
* **Backend types** (ui-tui/src/gatewayTypes.ts): ConfigFullResponse
now exposes config.voice.record_key and VoiceToggleResponse
carries record_key so the TUI can both bind and display it.
* **Frontend parser/formatter** (ui-tui/src/lib/platform.ts):
parseVoiceRecordKey() accepts ctrl+b / alt+r / cmd+space
and the common aliases (option, cmd, win, …); falls back to
the documented Ctrl+B for empty / multi-character / malformed input so
a typo never silently disables the shortcut. formatVoiceRecordKey()
renders for status text. isVoiceToggleKey now takes a parsed
ParsedVoiceRecordKey argument; the hardcoded ch === 'b' is
gone. Default arg keeps existing call sites back-compat.
* **Hydration** (ui-tui/src/app/useConfigSync.ts,
useMainApp.ts): startup config.get full already runs; extract
cfg.voice.record_key from it, parse, push into a new
voiceRecordKey state, and forward to the input handler ctx
(InputHandlerContext.voice.recordKey). Mtime-poll path also
re-applies the parsed key so a hand-edit of config.yaml takes effect
the next tick — matches existing behaviour for display options.
* **Input handler** (ui-tui/src/app/useInputHandlers.ts):
isVoiceToggleKey(key, ch, voice.recordKey) so the configured
binding fires.
* **Slash command** (ui-tui/src/app/slash/commands/session.ts):
/voice status and /voice on use formatVoiceRecordKey on
the response's record_key instead of the hardcoded label.
Tests:
* parseVoiceRecordKey covers ctrl/alt/cmd/super aliases, multi-char
rejection, and empty fallback.
* formatVoiceRecordKey covers the doc examples (Ctrl+B,
Ctrl+O, Alt+R, Cmd+B).
* isVoiceToggleKey regression: ctrl+o configured → only o
matches, not b; alt+r matches both alt-bit and meta-bit
encodings (terminal protocol parity); omitted-arg call still binds
Ctrl+B for back-compat.
Full TUI suite (555 tests) passes; tsc --noEmit clean.
Fixes #18994
Co-authored-by: asheriif <ahmedsherif95@gmail.com>
* fix(tui): support named-key tokens in voice.record_key (space, enter, …)
Reviewer caught that the round-1 parser in #18994 rejected every
multi-character token, so a config value like ctrl+space (which the
CLI happily binds via prompt_toolkit's c-space rewrite in
cli.py) silently fell back to the documented Ctrl+B default —
re-introducing the same false-shortcut bug the PR was meant to fix,
just at a different surface.
Add explicit named-key support that mirrors what the CLI accepts:
* space (alias: spc) → matches ch === ' '
* enter (alias: return, ret) → matches key.return
* tab → matches key.tab
* escape (alias: esc) → matches key.escape
* backspace (alias: bs) → matches key.backspace
* delete (alias: del) → matches key.deleteParsedVoiceRecordKey gains an optional named field; ch
holds either a single char (back-compat) or the canonical named token,
and the runtime matcher dispatches on named before checking the
modifier shape. Aliases collapse to one canonical name so
ctrl+esc and ctrl+escape behave identically.
Unrecognised multi-character tokens (e.g. ctrl+spcae typo, or
unsupported keys like ctrl+f5) still fall back to the Ctrl+B
default rather than silently disabling the binding — keeps the "typo
never silently kills the shortcut" guarantee.
Tests:
* parseVoiceRecordKey parametrised over every named token + each
alias variant.
* New isVoiceToggleKey cases for space (ch-based match), enter
(key.return), tab, escape, backspace, delete, including
modifier-mismatch negatives.
* formatVoiceRecordKey renders named keys in title case
(Ctrl+Space, Ctrl+Enter).
* Existing fall-back-to-Ctrl+B contract preserved for empty input
AND unrecognised multi-char tokens.
Full TUI suite: 559/559 pass; tsc --noEmit clean.
Refs #18994 (round-1 review feedback)
Co-authored-by: asheriif <ahmedsherif95@gmail.com>
* test(tui): assert voice.toggle returns configured record_key
Salvage the backend regression from #19339 — asserts voice.toggle
action=on AND action=status responses carry the configured
voice.record_key end-to-end through _load_cfg(). Keeps the
CLI→TUI parity contract visible in the Python test suite alongside
the existing frontend parser/matcher/formatter coverage from #19028.
* fix(tui): address Copilot review on #19835 voice.record_key wiring
Five tightenings on the parser + matcher + hydration surface, all
caught by the Copilot review on the PR — each one turns a silent
false-fire or display/binding skew into a deterministic behaviour.
* **isVoiceToggleKey ctrl branch was too permissive for named keys.**
The doc-default macOS Cmd+B muscle-memory fallback
(isActionMod(key) on top of key.ctrl) fired for every
configured key, so bare Esc — which hermes-ink reports with
key.meta on some macOS terminals — triggered ctrl+escape,
and Alt+Space / Alt+Tab triggered ctrl+space / ctrl+tab.
Gate the fallback to the literal ctrl+b binding so any custom
chord requires the real Ctrl bit.
* **Alt branch guarded against Ctrl/Cmd co-press.** Without this,
Ctrl+Alt+<letter> and Cmd+Alt+<letter> also fired alt+<letter>.
* **Dropped the meta modifier variant and its alias.** In
hermes-ink key.meta is Alt on xterm-style terminals and Cmd on
legacy macOS ones, so a literal meta+b config displayed as
Cmd+B while matching Alt+B — exactly the kind of false
shortcut the PR was meant to remove. cmd / command now
collapse onto super (kitty-style key.super, with a macOS
key.meta fallback) and render as Cmd+B. Unknown modifier
tokens fall back to the documented Ctrl+B default rather than
silently coercing to Ctrl.
* **Slash-command display/binding skew.** /voice status and
/voice on rendered from the fresh gateway record_key
response, but useInputHandlers() still bound the old key
until the next 5s mtime poll. Thread setVoiceRecordKey
through SlashHandlerContext.voice and push the parsed spec
into frontend state on every response so text and binding stay
consistent.
* **Test coverage for the two paths Copilot flagged.** Added
vitest coverage for (a) the three-case /voice slash output
in createSlashHandler.test.ts and (b) the
applyDisplay → voice.record_key hydration + omit-setter
back-compat paths in useConfigSync.test.ts. Plus regression
cases for every false-fire scenario above.
Suite: 575/575 green, tsc --noEmit clean.
* fix(tui): address Copilot round-2 review on #19835
Three tightenings on the surface introduced in the round-1 fix:
* **/voice tts reset custom bindings to Ctrl+B.** The tts branch
of voice.toggle omitted record_key from its response, so the
frontend's r.record_key ?? 'ctrl+b' coerced a user's custom
binding back to the default on every TTS toggle. Two-sided fix:
the backend now includes record_key on the tts branch (parity
with status/on/off), and the slash handler only pushes
frontend state when the response actually carries record_key —
belt-and-suspenders against any future branch forgetting to include
it.
* **super+b / win+b / cmd+b displayed "Cmd+B" on Linux and
Windows.** formatVoiceRecordKey rendered mod === 'super' as
Cmd universally, which told non-mac users the wrong modifier to
press even though isVoiceToggleKey matched the right event bits.
Gate the label to isMac so non-mac renders Super+B.
* **control+b / ctrl + b lost the macOS Cmd+B fallback.**
_isDefaultVoiceKey keyed off parsed.raw — so
semantically-equal aliases of the documented default dropped into
the strict branch even though they bind Ctrl+B. Compare on the
parsed spec (mod + ch + named) instead.
Coverage added: Linux Super+B rendering (and macOS Cmd+B),
control+b / ctrl + b accepting the Cmd+B fallback on darwin,
/voice tts without record_key not clobbering cached binding,
and a backend regression asserting every voice.toggle branch
carries the configured key.
Suite: 579/579 TUI vitest green, 2/2 backend voice tests green,
tsc --noEmit clean.
* fix(tui): address Copilot round-3 review on #19835
Three classes of robustness issue caught on the second pass — all
revolve around malformed YAML tipping parseVoiceRecordKey or
_voice_record_key into a crash instead of the documented
fallback.
* **Parser crashed on non-string YAML scalars.** config.get full
returns raw yaml.safe_load output, so voice.record_key: 1
or voice.record_key: true in a hand-edited config would hit
.trim() on a number/bool and throw, breaking startup and
every mtime re-apply. Accept unknown at the signature, guard
with typeof raw !== 'string', and fall back to the default.
* **Backend blew up on non-dict voice:.** Same YAML hazard on
the gateway side: voice: true / voice: cmd+b left
_load_cfg().get("voice") as a bool/str, so .get("record_key")
raised AttributeError and took every voice.toggle branch down
with it. Centralised the lookup in a single
_voice_record_key() helper that isinstance-guards both
voice and record_key and falls back to ctrl+b.
* **Multi-modifier chords silently dropped extras.** The previous
validator only checked the first modifier token, so ctrl+alt+r
silently parsed as ctrl+r and cmd+ctrl+b as super+b —
a typo bound a different shortcut than the user configured.
Reject multi-modifier spellings outright; the classic CLI only
supports single-modifier bindings via prompt_toolkit's c-x /
a-x rewrite, so this matches CLI parity.
Coverage added:
* parseVoiceRecordKey fallback on 1 / true / null /
undefined / {}.
* parseVoiceRecordKey fallback on ctrl+alt+r /
cmd+ctrl+b / alt+ctrl+space.
* test_voice_toggle_handles_non_dict_voice_cfg exercises
every non-dict voice: shape (bool, str, None, int, list) and
asserts each falls back to record_key: 'ctrl+b'.
Suite: 581/581 TUI vitest green, 3/3 backend voice tests green,
tsc --noEmit clean.
* fix(tui): address Copilot round-4 review on #19835
Four final corners of the voice.record_key surface:
* **Bare-char configs silently coerced to ctrl+<key>.** A config
like voice.record_key: o / space / escape fell through
to the default mod = 'ctrl' and silently bound Ctrl+O, while
the classic CLI's prompt_toolkit would bind the raw key (no
rewrite) — so the two runtimes silently disagreed on what "o"
means. Require an explicit modifier; bare-char configs fall back
to the documented Ctrl+B default.
* **Reserved ctrl+<letter> bindings would never fire.**
useInputHandlers() intercepts ctrl+c (interrupt),
ctrl+d (quit), and ctrl+l (clear screen) before the voice
check runs, so those configs would be advertised in /voice
status but the advertised shortcut never actually triggers
push-to-talk. Added _RESERVED_CTRL_CHARS at parse time so
the user gets the documented default instead of a dead shortcut.
(alt+c, cmd+l, etc. are not intercepted and stay usable.)
* **_load_cfg() root itself may be a non-dict.**
_voice_record_key() isinstance-guarded the voice subkey
but not the root — a malformed config.yaml that collapsed to a
scalar/list at the top level (config.yaml: true or [])
would still raise on .get("voice"). Added the top-level
guard too so every malformed shape falls back to ctrl+b.
* **Stale header comment on isVoiceToggleKey.** The doc-comment
still claimed "On macOS we additionally accept the platform
action modifier (Cmd) for the configured letter" even though the
implementation gates the Cmd fallback to the documented default
only. Rewrote to match.
Coverage added:
* parseVoiceRecordKey fallback on bare chars (o, b,
space, escape).
* parseVoiceRecordKey fallback on ctrl+c / ctrl+d /
ctrl+l; positive case for alt+c / cmd+l still usable.
* Backend test_voice_toggle_handles_non_dict_voice_cfg now
exercises 5 non-dict shapes at the YAML root too.
Suite: 583/583 TUI vitest green, 3/3 backend voice tests green,
tsc --noEmit clean.
* fix(tui): address Copilot round-5 review on #19835
Three follow-ups on the voice matcher's modifier + shift discipline:
* **super branch falsely fired on Alt+<key> / bare Esc on macOS.**
isVoiceToggleKey accepted isMac && key.meta as a Cmd
fallback for the super modifier — but hermes-ink sets
key.meta for plain Alt/Option AND for bare Escape on some
macOS terminals. A cmd+b config silently fired on Alt+B;
cmd+space on Alt+Space; cmd+escape on bare Esc. Drop the
fallback and require the literal key.super bit. Legacy-
terminal users who need Cmd should upgrade to a kitty-protocol
terminal or bind alt+X explicitly.
* **Shift bit was never checked.** The parser rejects multi-
modifier configs like ctrl+shift+tab, but the runtime
matcher didn't check key.shift — so ctrl+tab also fired
on Ctrl+Shift+Tab and alt+enter on Alt+Shift+Enter.
Early-return on key.shift === true so the runtime only fires
the exact chord the user configured.
* **Test leaked HERMES_VOICE=1 into later tests.**
voice.toggle action=on writes to os.environ directly
(CLI parity, runtime-only flag); ``test_voice_toggle_returns_
configured_record_key`` dispatched action=on without letting
monkeypatch take ownership of the var first. Any later test
that read voice mode in the same Python process could inherit a
stale enabled state. Added ``monkeypatch.setenv("HERMES_VOICE",
"0")`` up front so monkeypatch restores the original value at
teardown.
Coverage added:
* cmd+b / cmd+space / cmd+escape do NOT fire on
key.meta-only events on darwin.
* ctrl+tab / alt+enter / ctrl+o reject matches when
key.shift is held; sanity cases without Shift still fire.
Suite: 585/585 TUI vitest green, 3/3 backend voice tests green,
tsc --noEmit clean.
* fix(tui): address Copilot round-6 review on #19835
Three classes of modifier-discipline tightening + one config-surface
honesty fix:
* **Default ctrl+b Cmd fallback leaked Alt+B.** The default's
macOS Cmd+B muscle-memory path used isActionMod(key), which
returns key.meta || key.super on darwin. hermes-ink also
reports plain Alt as key.meta, so Alt+B silently fired the
default binding. Replaced with strict ``isMac && key.super ===
true`` — kitty-style Cmd+B still works, Alt+B correctly
rejected. Legacy-terminal mac users (Terminal.app without
CSI-u) now get raw Ctrl+B only; the documented default still
works everywhere.
* **ctrl / super branches accepted extra modifier bits.** The
parser rejects multi-modifier configs like ctrl+alt+o, but
the runtime matcher was permissive — ctrl+o fired on
Ctrl+Alt+O / Ctrl+Cmd+O, and super+b fired on Cmd+Alt+B /
Ctrl+Cmd+B. Added strict ``!key.alt && !key.meta && key.super
!== true on ctrl, and !key.ctrl && !key.alt && !key.meta``
on super, so the runtime only fires the exact chord the parser
would let you configure.
* **Dropped cmd / command aliases.** They parsed to
super and rendered as Cmd+X, but legacy macOS terminals
report Cmd as key.meta (same signal as Alt), so a
cmd+o config was advertised as working but never actually
fired on Terminal.app-without-CSI-u. That recreated the
"displayed shortcut does not work" problem this PR was meant to
remove. Users who want the platform action modifier spell it
super / win — that matches the unambiguous key.super
bit, and kitty-style macOS terminals render it as Cmd+X via
platform-aware formatter.
Coverage updated:
* Default ctrl+b no longer fires on Alt+B via key.meta leak;
raw Ctrl+B and kitty-style Cmd+B still fire.
* ctrl+o rejects Ctrl+Alt+O / Ctrl+Cmd+O / Ctrl+Meta+O chords.
* super+b rejects Cmd+Alt+B / Cmd+Meta+B / Ctrl+Cmd+B chords.
* cmd+b / command+b / meta+b all fall back to the
documented default at parse time (joined the ambiguous-mac-mod
rejection class).
* Round-2 expectations that asserted cmd+b parsed as super
and accepted key.meta on darwin updated to reflect the new
stricter contract.
Suite: 588/588 TUI vitest green, 3/3 backend voice tests green,
tsc --noEmit clean.
* fix(tui): address Copilot follow-up on wire typing + escape precedence
Two follow-ups from the latest Copilot pass:
* **Config wire typing honesty (gatewayTypes.ts)**
config.get full forwards raw yaml.safe_load() output, so
voice.record_key can be any scalar/container when hand-edited.
Typing it as string suggests a normalized contract that the
backend does not guarantee and makes unsafe callers more likely.
Change ConfigVoiceConfig.record_key to unknown with an
explicit comment that callers must normalize at runtime.
* **Escape-based voice bindings were swallowed before voice check**
useInputHandlers() handled key.escape for queue-edit cancel and
selection clear before isVoiceToggleKey(...), so configured
ctrl+escape / alt+escape / super+escape chords were advertised
but never toggled recording in those UI states.
Add an early escape+voice check before generic Esc handlers so
escape-based voice bindings win when configured, while plain Esc
behavior remains unchanged.
Also updated PR #19835 description text to remove stale cmd/command
alias claims and match the current parser contract.
* fix(tui): pass configured voice shortcut through TextInput layer
Thread the live parsed voiceRecordKey into TextInput so configured voice.record_key chords bubble to useInputHandlers instead of being consumed as editor input. This removes the last hardcoded Ctrl+B pass-through in the composer path while preserving existing global control chord behavior.
* fix(tui): require explicit alt bit for escape-based alt chords
Hermes-ink reports bare Escape as meta=true+escape=true on some terminals, so a configured alt+escape binding was firing on bare Esc. Require an explicit key.alt bit when the configured named key is escape so plain Esc stays plain Esc; kitty-style alt+escape still fires.
* fix(tui): harden voice.record + TextInput paste + super-mod reserved list
Three round-7 Copilot follow-ups on #19835:
- voice.record start handler used _load_cfg().get('voice', {}).get(...) without
shape checks, so malformed YAML (bool/scalar/list) returned 5025 instead of
using VAD defaults. Centralized _voice_cfg_dict() helper and type-guarded
silence_threshold/silence_duration with numeric fallbacks.
- TextInput pass-through check moved above paste/copy handling so configured
voice chords (ctrl+v / alt+v / cmd+v) beat the composer's paste/copy
defaults.
- parser now also rejects super+{c,d,l,v} — on macOS those are
copy/exit/clear/paste and would be advertised in /voice status but never
actually toggle recording.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(tui): round-8 Copilot review — allow ctrl+x, gate super reservations to macOS, preserve voice key on transient RPC failure
Three round-8 Copilot follow-ups on #19835:
- Revert ctrl+x addition to _RESERVED_CTRL_CHARS (landed via Copilot Autofix
commit 731ec86): ctrl+x is only claimed during queue-edit
(queueEditIdx !== null), so voice works the rest of the session and
matches CLI ctrl+<letter> parity.
- Gate super+{c,d,l,v} reservation to isMac. Linux/Windows TUI globals key
off Ctrl, so kitty/CSI-u super+<letter> configs don't collide on non-mac
and should stay usable.
- applyDisplay() now skips setVoiceRecordKey when cfg is null so one
transient quietRpc() failure after a config edit doesn't clobber the
cached binding back to Ctrl+B until the next successful poll.
New coverage:
- parseVoiceRecordKey preserves ctrl+x on linux
- super+{c,d,l,v} rejected on darwin, allowed on linux
- applyDisplay(null, ...) leaves voiceRecordKey untouched
* fix(cli,tui): normalize voice.record_key aliases across CLI + TUI for parity
Round-9 Copilot review on #19835: TUI accepted control+/option+/opt+/super+/win+ aliases but the classic CLI only rewrote literal ctrl+/alt+ before handing to prompt_toolkit, so a TUI-valid config silently bound a different (or no) shortcut in the CLI.
- Added normalize_voice_record_key_for_prompt_toolkit() in hermes_cli/voice.py with a single alias table (ctrl/control/alt/option/opt → c-/a-).
- Wired it into all three cli.py sites (_enable_voice_mode hint, _show_voice_status display, and the prompt_toolkit binding in _register_voice_handler).
- /voice status display now renders control+x as Ctrl+X and option+x as Alt+X (canonical casing) to match TUI formatVoiceRecordKey.
- super/win/windows are intentionally left unchanged: prompt_toolkit has no super modifier, so the CLI will reject them loudly at startup rather than silently binding Ctrl+B. Documented this split at both the TUI _MOD_ALIASES comment and the CLI normalizer docstring.
- Added tests covering ctrl/control/alt/option/opt mapping, case-insensitivity, non-string fallback, empty-string fallback, and super/win pass-through.
* fix(cli): port TUI parser contract into CLI voice.record_key normalizer
Round-10 Copilot review on #19835.
hermes_cli/voice.py's normalize_voice_record_key_for_prompt_toolkit() previously did blind substring replacement with no trim/validate step, so the CLI diverged from the TUI parser on:
- whitespace ('ctrl + b' -> 'c- b' instead of 'c-b')
- typoed named keys ('ctrl+spcae' passed through as 'c-spcae' and prompt_toolkit would reject at startup)
- bare-char configs ('o' should fall back, not pass through as 'o')
- multi-modifier chords ('ctrl+alt+r')
- reserved ctrl chars ('ctrl+c/d/l')
- unknown modifiers ('meta+b' / 'shift+b')
- named-key aliases ('return'/'esc'/'bs'/'del' not collapsed to prompt_toolkit canonicals)
Port the TUI parser contract into Python (_VOICE_MOD_ALIASES, _VOICE_NAMED_KEYS, _VOICE_RESERVED_CTRL_CHARS) so one config value binds the same shortcut in both runtimes.
Also added format_voice_record_key_for_status() shared between the PTT hint and /voice status display. Non-string scalars (voice.record_key: true / 1) now surface as 'Ctrl+B' instead of the raw scalar — /voice status no longer advertises a shortcut that can never bind.
Tests: 29/29 in test_voice_wrapper.py, including 11 new regressions covering whitespace, named-key aliases, typos, bare-char, multi-modifier, reserved ctrl, unknown mods, non-string fallback, and formatter contract.
* fix(cli): shape-safe voice config read + graceful super/win fallback
Round-11 Copilot review on #19835.
Two remaining cross-runtime gaps:
1. load_config().get('voice', {}) still assumed voice was a dict, so a hand-edited voice: true / voice: cmd+b at the top level raised AttributeError before the voice UI could start. Added voice_record_key_from_config(cfg) to hermes_cli/voice.py that isinstance-guards both the root and the voice subkey. All three cli.py read sites (_enable_voice_mode hint, _show_voice_status, PTT binding) now use it.
2. The CLI normalizer previously passed super+/win+/windows+ through unrewritten so prompt_toolkit would reject them loudly at startup — but that crash was a worse UX than a silent fallback. Normalizer now returns c-b for those spellings, and the PTT binding site logs a warning so users see why their TUI-only shortcut isn't binding in the CLI.
Coverage: 34/34 in tests/hermes_cli/test_voice_wrapper.py (5 new cases for voice_record_key_from_config + malformed-root + malformed-voice + extractor/normalizer composition).
* fix(cli): self-audit cleanup — remaining voice-config shape safety + doc drift
Self-review of the voice.record_key change set turned up four remaining items Copilot would very likely flag next round:
1. cli.py _voice_start_continuous still read load_config().get('voice', {}).get('silence_threshold') without an isinstance guard, so a hand-edited voice: true / voice: cmd+b (non-dict) raised AttributeError on VAD recording start. Shape-safe coerce the voice dict and numeric-guard silence_threshold/silence_duration.
2. cli.py _enable_voice_mode's auto_tts check had the same bug — fixed with the same isinstance guard.
3. hermes_cli/voice.py module comment on _VOICE_MOD_ALIASES still said super/win/windows 'pass through unchanged and prompt_toolkit's add() call loudly rejects them at startup'. Round 11 changed the normalizer to silently fall back to c-b with a warning at the binding site; updated the comment to match.
4. ui-tui/src/lib/platform.ts header comment had the same stale 'CLI will loudly reject them at startup' claim; updated to 'falls back to the documented default and logs a warning'.
No behavior change on the code paths already covered by test_voice_wrapper.py; the two cli.py fixes are defensive against malformed YAML that previous rounds already hardened in tui_gateway/server.py but missed in the classic CLI.
* fix(cli,tui): round-12 Copilot review — alt-collide on mac, bool-in-int guards, voice UI hardcodes, mtime-reload test
Five round-12 Copilot review items on #19835:
1. platform.ts: hermes-ink reports Alt as key.meta on many terminals; isActionMod on darwin accepts key.meta as the action modifier. So alt+c/d/l get claimed by isCopyShortcut / isAction('d')/'l') before the voice check. Reject those configs at parse time on macOS only (non-mac keeps them usable).
2. cli.py: four remaining hardcoded 'Ctrl+B' sites in voice-facing UI (_get_voice_status_fragments status bar, _voice_start_recording hints, _get_placeholder composer text) were still lying about non-default configs. Added self._voice_record_key_label() shared helper and wired it into all three sites.
3. server.py + cli.py: bool is a subclass of int, so isinstance(silence_threshold, (int, float)) accepted True/False from malformed YAML and forwarded 1/0 to the VAD engine. Exclude bool explicitly so boolean typos fall back to the documented 200 / 3.0 defaults.
4. useConfigSync.ts: extracted the config.get-full fetch+apply body into a shared hydrateFullConfig() helper. Both the initial hydration and mtime-reload paths now use it, so the polling/RPC wiring is exercised by direct unit tests (4 new cases: fresh apply, reapply on new value, transient RPC failure preserves cache, back-compat without voice setter).
5. Added alt+{c,d,l} rejection regressions on darwin + allow on linux, and bool-leak regressions for both silence_threshold and silence_duration in tests/test_tui_gateway_server.py.
Suite: 602/602 TUI vitest, 38/38 backend voice tests, typecheck + lints clean.
* fix(cli): cache voice record-key label at binding time + status-bar coverage
Round-13 Copilot review on #19835.
_voice_record_key_label() was reading live config on every render, which caused two problems:
1. prompt_toolkit registers the push-to-talk binding once at session start (@kb.add(_voice_key)); the binding does NOT re-read config. Editing voice.record_key mid-session would switch the status-bar / placeholder / recording-hint label to the new shortcut while the actual keybinding stayed on the startup chord — reintroducing the display/binding drift this whole PR is fighting.
2. Hot render path: during recording the UI is invalidated every 150ms, so re-loading + deep-merging config on every call added avoidable UI overhead.
Fix: cache the label at the same site that registers the prompt_toolkit binding via new set_voice_record_key_cache(raw_key). _voice_record_key_label() now just returns the cached value (falls back to 'Ctrl+B' before startup). Status/placeholder/hint are always in sync with the live binding; no config reload per render.
Also added 4 regression cases to tests/cli/test_cli_status_bar.py: configured ctrl+<letter> renders in both wide and compact status bars, configured named key (ctrl+space) renders in the recording hint, pre-startup absent cache falls back to Ctrl+B, and malformed configs (bool True) fall through the formatter to Ctrl+B.
Suite: 60/60 test_cli_status_bar + test_voice_wrapper, typecheck + lints clean.
* fix(cli): route /voice on + /voice status through startup-pinned label; mac alt+cdl parity
Round-14 Copilot review on #19835. All three comments legit:
1. _enable_voice_mode still formatted label from live load_config() — mid-session config edit would make /voice on announce the new shortcut while the prompt_toolkit binding stayed the startup chord. Use self._voice_record_key_label() (cached at binding time, round-13) so /voice on cannot drift from the live binding.
2. _show_voice_status had the same bug — /voice status reported live config instead of the pinned startup binding. Fixed the same way.
3. CLI normalizer accepted alt+c/alt+d/alt+l even though the TUI parser rejects them on macOS (Copilot round-12 — hermes-ink reports Alt as key.meta, isActionMod on darwin accepts it, collides with isCopyShortcut / isAction). Added _VOICE_RESERVED_ALT_CHARS_MAC = {c,d,l} gated to sys.platform == 'darwin' so a shared config like option+c falls back to c-b on both runtimes on macOS; non-mac still binds a-c.
Coverage: 4 new tests in test_voice_wrapper.py covering mac alt+cdl rejection, linux alt+cdl allowed, option/opt alias forms, and mac-specific exclusions for other alt letters. 62/62 in voice wrapper + status bar suites.
---------
Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
Co-authored-by: asheriif <ahmedsherif95@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
fix(tui): steady transcript scrollbar (#20917)
* fix(tui): steady transcript scrollbar
Keep the visible scrollbar tied to committed viewport position while virtual history can still prefetch against pending scroll targets, and preserve drag grab offset synchronously for native-feeling scrollbar drags.
* fix(tui): smooth precision wheel scroll
Replace the opt-scroll throttle with frame-sized coalescing so modifier wheel gestures stay line-precise without stepping.
fix(tui): anchor splitReasoning unclosed-tag regex to start of input (#29426)
splitReasoning() strips paired <think>…</think> blocks first, then runs
an unclosed-trailing regex to catch reasoning that hasn't yet streamed its
closer. That second regex was unanchored and greedy:
new RegExp(<${tag}>([\\s\\S]*)$, 'i')
So any literal <think> somewhere in prose — a model quoting the tag, a
code example, or a stream-mid-tag before the closer arrives — consumed
every paragraph after it to EOF. User-visible symptom: "TUI eats last
paragraph of output," both during streaming and on settled turns.
Real reasoning streams always lead the message (that's the only place an
unclosed opener can legitimately appear during streaming). Anchor the
regex to ^\s* so mid-prose mentions of the tag are preserved.
Empirical repro before the fix:
splitReasoning('final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.')
→ text: 'final answer paragraph one.' ← paragraph two GONE
After:
→ text: 'final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.'
Updated the existing trailing-unclosed test to lead with <think> (the
real-world shape) and added a regression test pinning the mid-text case.
ui-tui type-check clean, 808/808 vitest pass.
fix(goals): make /goal work in TUI and fix gateway verdict delivery (#19209)
/goal was silently broken outside the classic CLI.
TUI: /goal was routed through the HermesCLI slash-worker subprocess,
which set the goal row in SessionDB but then called
_pending_input.put(state.goal) — the subprocess has no reader for that
queue, so the kickoff message was discarded. No post-turn judge was
wired into prompt.submit either, so even a manual kickoff would not
continue the goal loop. Intercept /goal in command.dispatch instead,
drive GoalManager directly, and return {type: send, notice, message}
so the TUI client renders the Goal-set notice and fires the kickoff.
Run the judge in _run_prompt_submit after message.complete, surface
the verdict via status.update {kind: goal}, and chain the continuation
turn after the running guard is released.
Gateway: _post_turn_goal_continuation was gated on
hasattr(adapter, 'send_message'), but adapters only expose send().
That branch was dead on every platform — users never saw
'✓ Goal achieved', 'Continuing toward goal', or budget-exhausted
messages. Replace the dead call with adapter.send(chat_id, content,
metadata) and drop a broken reference to self._loop.
Tests:
- tests/tui_gateway/test_goal_command.py — full /goal dispatch matrix
(set / status / pause / resume / clear / stop / done / whitespace)
plus regressions for slash.exec → 4018 and 'goal' staying in
_PENDING_INPUT_COMMANDS.
- tests/gateway/test_goal_verdict_send.py — locks in the adapter.send
path for done / continue / budget-exhausted and verifies the hook
no-ops when no goal is set or the adapter lacks send().
fix(tui): restore macOS copy behavior and theme polish (#17131)
This PR groups the TUI fixes that restore macOS Terminal usability and clean up the theme/composer regressions:
- copy transcript selections on macOS drag-release so Terminal.app users can copy while mouse tracking is enabled
- copy composer selections on macOS drag-release; composer selection is internal to TextInput and does not use the global Ink selection bus
- keep IDE Cmd+C forwarding setup macOS-only, and make keybinding conflict checks respect simple when-clause overlap/negation
- force truecolor before chalk initializes (unless NO_COLOR / FORCE_COLOR / HERMES_TUI_TRUECOLOR opt-outs apply) so the default banner keeps its gold/amber/bronze gradient in Terminal.app
- move TUI surfaces onto semantic theme tokens and preserve skin prompt symbols as bare tokens with renderer-owned spacing
- render focused placeholders as dim hint text in TTY mode instead of inverse/selected-looking synthetic cursor text
fix(tui): steady transcript scrollbar (#20917)
* fix(tui): steady transcript scrollbar
Keep the visible scrollbar tied to committed viewport position while virtual history can still prefetch against pending scroll targets, and preserve drag grab offset synchronously for native-feeling scrollbar drags.
* fix(tui): smooth precision wheel scroll
Replace the opt-scroll throttle with frame-sized coalescing so modifier wheel gestures stay line-precise without stepping.