Alt key voice record config crashes and fixes

NousResearch/hermes-agent · cluster 11387 · hermes cli

Data as of

Nothing open on GitHub. Fix merged in #19835. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #19835. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and pull requests concern crashes and errors at CLI/TUI startup caused by invalid or unsupported Alt+key combinations in the voice.record_key configuration, along with proposed fixes for validation, fallback behavior, and key binding support.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #19835 fix(tui): respect voice.record_key config (supersedes #19028, #19339)

    merged to main

    files: cli.py, hermes_cli/voice.py, tests/cli/test_cli_status_bar.py, tests/hermes_cli/test_voice_wrapper.py, tests/test_tui_gateway_server.py, tui_gateway/server.py, ui-tui/src/__tests__/createSlashHandler.test.ts, ui-tui/src/__tests__/platform.test.ts, ui-tui/src/__tests__/textInputPassThrough.test.ts, ui-tui/src/__tests__/useConfigSync.test.ts, ui-tui/src/app/interfaces.ts, ui-tui/src/app/slash/commands/session.ts, ui-tui/src/app/useConfigSync.ts, ui-tui/src/app/useInputHandlers.ts, ui-tui/src/app/useMainApp.ts, ui-tui/src/components/appLayout.tsx, ui-tui/src/components/textInput.tsx, ui-tui/src/gatewayTypes.ts, ui-tui/src/lib/platform.ts

    Show the diff
    • cli.py
      @@ -2579,23 +2579,59 @@ def _render_spinner_text(self) -> str:
                   return f"  {txt}  ({elapsed_str})"
               return f"  {txt}"
       
      +    def _voice_record_key_label(self) -> str:
      +        """Return the configured voice push-to-talk key formatted for UI.
      +
      +        Shared helper so every voice-facing status line / placeholder /
      +        recording hint advertises the SAME label as the registered
      +        prompt_toolkit binding.
      +
      +        Cached at startup (see ``set_voice_record_key_cache``) rather
      +        than re-read per render. Two reasons (Copilot round-13 on
      +        #19835):
      +
      +        * The prompt_toolkit binding is registered once at session
      +          start via ``@kb.add(_voice_key)``; re-reading config per
      +          render meant the status bar could advertise a new shortcut
      +          after a config edit while the actual binding was still the
      +          startup chord — exactly the display/binding drift this PR
      +          is trying to eliminate.
      +        * The label is on the hot render path (status bar + composer
      +          placeholder invalidated every 150ms during recording), so
      +          reading config on every call added avoidable UI overhead.
      +        """
      +        return getattr(self, "_voice_record_key_display_cache", None) or "Ctrl+B"
      +
      +    def set_voice_record_key_cache(self, raw_key: object) -> None:
      +        """Populate the voice label cache from a raw ``voice.record_key``.
      +
      +        Called at CLI startup after the prompt_toolkit binding is
      +        registered so the cached label always matches the live binding.
      +        """
      +        try:
      +            from hermes_cli.voice import format_voice_record_key_for_status
      +            self._voice_record_key_display_cache = format_voice_record_key_for_status(raw_key)
      +        except Exception:
      +            self._voice_record_key_display_cache = "Ctrl+B"
      +
           def _get_voice_status_fragments(self, width: Optional[int] = None):
               """Return the voice status bar fragments for the interactive TUI."""
               width = width or self._get_tui_terminal_width()
               compact = self._use_minimal_tui_chrome(width=width)
      +        label = self._voice_record_key_label()
               if self._voice_recording:
                   if compact:
                       return [("class:voice-status-recording", " ● REC ")]
      -            return [("class:voice-status-recording", " ● REC  Ctrl+B to stop ")]
      +            return [("class:voice-status-recording", f" ● REC  {label} to stop ")]
               if self._voice_processing:
                   if compact:
                       return [("class:voice-status", " ◉ STT ")]
                   return [("class:voice-status", " ◉ Transcribing... ")]
               if compact:
      -            return [("class:voice-status", " 🎤 Ctrl+B ")]
      +            return [("class:voice-status", f" 🎤 {label} ")]
               tts = " | TTS on" if self._voice_tts else ""
               cont = " | Continuous" if self._voice_continuous else ""
      -        return [("class:voice-status", f" 🎤 Voice mode{tts}{cont}  —  Ctrl+B to record ")]
      +        return [("class:voice-status", f" 🎤 Voice mode{tts}{cont}  —  {label} to record ")]
       
           def _build_status_bar_text(self, width: Optional[int] = None) -> str:
               """Return a compact one-line session status string for the TUI footer."""
      @@ -8270,20 +8306,38 @@ def _voice_start_recording(self):
                       return
                   self._voice_recording = True
       
      -        # Load silence detection params from config
      -        voice_cfg = {}
      +        # Load silence detection params from config. Shape-safe: a
      +        # hand-edited ``voice: true`` / ``voice: cmd+b`` leaves
      +        # ``load_config()['voice']`` as a non-dict; coerce to {} so
      +        # continuous recording falls back to the documented defaults
      +        # instead of crashing on ``.get()``.
      +        voice_cfg: dict = {}
               try:
                   from hermes_cli.config import load_config
      -            voice_cfg = load_config().get("voice", {})
      +            _cfg = load_config().get("voice")
      +            voice_cfg = _cfg if isinstance(_cfg, dict) else {}
               except Exception:
                   pass
       
               if self._voice_recorder is None:
                   self._voice_recorder = create_audio_recorder()
       
      -        # Apply config-driven silence params
      -        self._voice_recorder._silence_threshold = voice_cfg.get("silence_threshold", 200)
      -        self._voice_recorder._silence_duration = voice_cfg.get("silence_duration", 3.0)
      +        # Apply config-driven silence params (numeric-guarded so YAML
      +        # scalar corruption doesn't break recording start-up).
      +        #
      +        # ``bool`` is explicitly excluded from the numeric check — in
      +        # Python bool is a subclass of int, so a hand-edited
      +        # ``silence_threshold: true`` would otherwise be forwarded as
      +        # ``1`` instead of falling back to the 200 default (Copilot
      +        # round-12 on #19835).
      +        _threshold = voice_cfg.get("silence_threshold")
      +        _duration = voice_cfg.get("silence_duration")
      +        self._voice_recorder._silence_threshold = (
      +            _threshold if isinstance(_threshold, (int, float)) and not isinstance(_threshold, bool) else 200
      +        )
      +        self._voice_recorder._silence_duration = (
      +            _duration if isinstance(_duration, (int, float)) and not isinstance(_duration, bool) else 3.0
      +        )
       
               def _on_silence():
                   """Called by AudioRecorder when silence is detected after speech."""
      @@ -8309,12 +8363,13 @@ def _on_silence():
                   with self._voice_lock:
                       self._voice_recording = False
                   raise
      +        _label = self._voice_record_key_label()
               if getattr(self._voice_recorder, "supports_silence_autostop", True):
      -            _recording_hint = "auto-stops on silence | Ctrl+B to stop & exit continuous"
      +            _recording_hint = f"auto-stops on silence | {_label} to stop & exit continuous"
               elif _is_termux_environment():
      -            _recording_hint = "Termux:API capture | Ctrl+B to stop"
      +            _recording_hint = f"Termux:API capture | {_label} to stop"
               else:
      -            _recording_hint = "Ctrl+B to stop"
      +            _recording_hint = f"{_label} to stop"
               _cprint(f"\n{_ACCENT}● Recording...{_RST} {_DIM}({_recording_hint}){_RST}")
       
               # Periodically refresh prompt to update audio level indicator
      @@ -8559,10 +8614,12 @@ def _enable_voice_mode(self):
               with self._voice_lock:
                   self._voice_mode = True
       
      -        # Check config for auto_tts
      +        # Check config for auto_tts (shape-safe — malformed ``voice:`` YAML
      +        # leaves ``voice_config`` as a non-dict, so guard before .get()).
               try:
                   from hermes_cli.config import load_config
      -            voice_config = load_config().get("voice", {})
      +            _raw_voice = load_config().get("voice")
      +            voice_config = _raw_voice if isinstance(_raw_voice, dict) else {}
                   if voice_config.get("auto_tts", False):
                       with self._voice_lock:
                           self._voice_tts = True
      @@ -8574,13 +8631,11 @@ def _enable_voice_mode(self):
               # _voice_message_prefix property and its usage in _process_message().
       
               tts_status = " (TTS enabled)" if self._voice_tts else ""
      -        try:
      -            from hermes_cli.config import load_config
      -            _raw_ptt = load_config().get("voice", {}).get("record_key", "ctrl+b")
      -            _ptt_key = _raw_ptt.lower().replace("ctrl+", "c-").replace("alt+", "a-")
      -        except Exception:
      -            _ptt_key = "c-b"
      -        _ptt_display = _ptt_key.replace("c-", "Ctrl+").upper()
      +        # Use the startup-pinned cache so the advertised shortcut always
      +        # matches the live prompt_toolkit binding — reading live config
      +        # here would drift after a mid-session config edit (Copilot
      +        # round-14 on #19835, same class as round-13).
      +        _ptt_display = self._voice_record_key_label()
               _cprint(f"\n{_ACCENT}Voice mode enabled{tts_status}{_RST}")
               _cprint(f"  {_DIM}{_ptt_display} to start/stop recording{_RST}")
               _cprint(f"  {_DIM}/voice tts  to toggle speech output{_RST}")
      @@ -8637,7 +8692,6 @@ def _toggle_voice_tts(self):
       
           def _show_voice_status(self):
               """Show current voice mode status."""
      -        from hermes_cli.config import load_config
               from tools.voice_mode import check_voice_requirements
       
               reqs = check_voice_requirements()
      @@ -8646,9 +8700,11 @@ def _show_voice_status(self):
               _cprint(f"  Mode:      {'ON' if self._voice_mode else 'OFF'}")
               _cprint(f"  TTS:       {'ON' if self._voice_tts else 'OFF'}")
               _cprint(f"  Recording: {'YES' if self._voice_recording else 'no'}")
      -        _raw_key = load_config().get("voice", {}).get("record_key", "ctrl+b")
      -        _display_key = _raw_key.replace("ctrl+", "Ctrl+").upper() if "ctrl+" in _raw_key.lower() else _raw_key
      -        _cprint(f"  Record key: {_display_key}")
      +        # Display the startup-pinned label so /voice status always
      +        # matches the live prompt_toolkit binding (Copilot round-14 on
      +        # #19835, same class as round-13). Reading live config here
      +        # would drift after a mid-session config edit.
      +        _cprint(f"  Record key: {self._voice_record_key_label()}")
               _cprint(f"\n  {_BOLD}Requirements:{_RST}")
               for line in reqs["details"].split("\n"):
                   _cprint(f"    {line}")
      @@ -10537,15 +10593,44 @@ def _suspend():
                   run_in_terminal(_suspend)
       
               # Voice push-to-talk key: configurable via config.yaml (voice.record_key)
      -        # Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search)
      -        # Config uses "ctrl+b" format; prompt_toolkit expects "c-b" format.
      +        # Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search).
      +        # Config spellings (ctrl/control/alt/option/opt) are normalized to
      +        # prompt_toolkit's c-x / a-x format via ``normalize_voice_record_key_for_prompt_toolkit``
      +        # so the same config value binds identically in the TUI and CLI
      +        # (Copilot round-9 review on #19835). ``super``/``win``/``windows``
      +        # configs silently fall back to the default here since prompt_toolkit
      +        # has no super modifier — log a warning so users notice the
      +        # TUI/CLI split instead of a silent mismatch (round-11).
      +        _raw_key: object = "ctrl+b"
               try:
                   from hermes_cli.config import load_config
      -            _raw_key = load_config().get("voice", {}).get("record_key", "ctrl+b")
      -            _voice_key = _raw_key.lower().replace("ctrl+", "c-").replace("alt+", "a-")
      +            from hermes_cli.voice import (
      +                normalize_voice_record_key_for_prompt_toolkit,
      +                voice_record_key_from_config,
      +            )
      +            _raw_key = voice_record_key_from_config(load_config())
      +            _voice_key = normalize_voice_record_key_for_prompt_toolkit(_raw_key)
      +            if (
      +                isinstance(_raw_key, str)
      +                and _raw_key.strip().lower().split("+", 1)[0].strip() in {"super", "win", "windows"}
      +                and _voice_key == "c-b"
      +            ):
      +                logger.warning(
      +                    "voice.record_key %r uses a TUI-only modifier (super/win); "
      +                    "CLI fell back to Ctrl+B. Use ctrl+<key> or alt+<key> for "
      +                    "cross-runtime parity.",
      +                    _raw_key,
      +                )
               except Exception:
                   _voice_key = "c-b"
       
      +        # Cache the UI label here — same ``_raw_key`` that drives the
      +        # prompt_toolkit binding below. Every status / placeholder /
      +        # recording-hint render reads this cached value so display can
      +        # never drift from the live keybinding even if the user edits
      +        # voice.record_key mid-session (Copilot round-13 on #19835).
      +        self.set_voice_record_key_cache(_raw_key)
      +
               @kb.add(_voice_key)
               def handle_voice_record(event):
                   """Toggle voice recording when voice mode is active.
      @@ -10848,7 +10933,8 @@ def apply_transformation(self, ti):
       
               def _get_placeholder():
                   if cli_ref._voice_recording:
      -                return "recording... Ctrl+B to stop, Ctrl+C to cancel"
      +                _label = cli_ref._voice_record_key_label()
      +                return f"recording... {_label} to stop, Ctrl+C to cancel"
                   if cli_ref._voice_processing:
                       return "transcribing..."
                   if cli_ref._sudo_state:
      @@ -10868,7 +10954,8 @@ def _get_placeholder():
                   if cli_ref._agent_running:
                       return "msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel"
                   if cli_ref._voice_mode:
      -                return "type or Ctrl+B to record"
      +                _label = cli_ref._voice_record_key_label()
      +                return f"type or {_label} to record"
                   return ""
       
               input_area.control.input_processors.append(_PlaceholderProcessor(_get_placeholder))
    • hermes_cli/voice.py
      @@ -27,6 +27,192 @@
       import threading
       from typing import Any, Callable, Optional
       
      +# Modifier aliases mirrored from the TUI parser (``ui-tui/src/lib/platform.ts``)
      +# ``_MOD_ALIASES`` table — the contract that removes the cross-runtime
      +# mismatch Copilot flagged in round-9 on #19835.
      +#
      +# ``super``/``win``/``windows`` are intentionally absent: prompt_toolkit
      +# has no super/meta modifier for the Cmd key, so those spellings are
      +# TUI-only. The normalizer below returns the documented default
      +# (``c-b``) for them — a silent fallback was preferred to a hard
      +# startup crash (Copilot round-11). The CLI binding site
      +# (``_register_voice_handler`` in cli.py) logs a warning when that
      +# fallback fires so users see why their TUI-only shortcut isn't
      +# bound in the classic CLI.
      +_VOICE_MOD_ALIASES = {
      +    "ctrl": "c-",
      +    "control": "c-",
      +    "alt": "a-",
      +    "option": "a-",
      +    "opt": "a-",
      +}
      +
      +# Named keys prompt_toolkit accepts in ``c-<name>`` / ``a-<name>`` form.
      +# Aliases collapse to prompt_toolkit's canonical spelling so the same
      +# config value binds identically in both runtimes (Copilot round-10 on
      +# #19835).
      +_VOICE_NAMED_KEYS = {
      +    "space": "space",
      +    "spc": "space",
      +    "enter": "enter",
      +    "return": "enter",
      +    "ret": "enter",
      +    "tab": "tab",
      +    "escape": "escape",
      +    "esc": "escape",
      +    "backspace": "backspace",
      +    "bs": "backspace",
      +    "delete": "delete",
      +    "del": "delete",
      +}
      +
      +# ``useInputHandlers()`` intercepts these before the voice check runs,
      +# so a binding like ``ctrl+c`` (interrupt), ``ctrl+d`` (quit), or
      +# ``ctrl+l`` (clear screen) would be advertised in /voice status but
      +# never fire push-to-talk — the same blocklist the TUI parser uses.
      +_VOICE_RESERVED_CTRL_CHARS = frozenset({"c", "d", "l"})
      +
      +# On macOS the classic CLI's prompt_toolkit bindings for copy / exit /
      +# clear also claim ``a-c`` / ``a-d`` / ``a-l`` via the action-modifier
      +# lookup, and hermes-ink reports Alt as ``key.meta`` on many terminals.
      +# Mirror the TUI parser's darwin-only reservation so ``option+c`` etc.
      +# don't bind Alt+C in the CLI while the TUI silently falls back to
      +# Ctrl+B (Copilot round-14 on #19835).
      +_VOICE_RESERVED_ALT_CHARS_MAC = frozenset({"c", "d", "l"})
      +
      +_DEFAULT_PT_KEY = "c-b"
      +
      +
      +def voice_record_key_from_config(cfg: Any) -> Any:
      +    """Shape-safe ``cfg.voice.record_key`` lookup.
      +
      +    ``load_config()`` deep-merges raw YAML and preserves scalar
      +    overrides, so a hand-edited ``voice: true`` / ``voice: cmd+b``
      +    leaves ``cfg["voice"]`` as a bool/str instead of a dict, and the
      +    naive ``.get("voice", {}).get("record_key")`` chain raises
      +    AttributeError before voice can even start (Copilot round-11 on
      +    #19835). Return ``None`` for malformed shapes so call sites can
      +    feed the result straight into the normalizer/formatter and get
      +    the documented default.
      +    """
      +    if not isinstance(cfg, dict):
      +        return None
      +
      +    voice = cfg.get("voice")
      +    if not isinstance(voice, dict):
      +        return None
      +
      +    return voice.get("record_key")
      +
      +
      +def normalize_voice_record_key_for_prompt_toolkit(raw: Any) -> str:
      +    """Coerce ``voice.record_key`` into prompt_toolkit's ``c-x`` / ``a-x`` format.
      +
      +    Mirrors the TUI parser contract (``ui-tui/src/lib/platform.ts``)
      +    so one config value binds the same shortcut in both runtimes:
      +
      +    * non-string / empty / typo'd / bare-char / multi-modifier / reserved
      +      ``ctrl+c|d|l`` → documented default ``c-b``
      +    * single-char keys: ``ctrl+o`` → ``c-o``
      +    * named keys: ``ctrl+space`` → ``c-space`` (aliases collapse:
      +      ``ctrl+return`` → ``c-enter``)
      +    * ``super`` / ``win`` / ``windows`` → ``c-b`` (TUI-only modifiers —
      +      prompt_toolkit has no super mod; the CLI binding site is
      +      expected to warn when this fallback fires so users see the
      +      cross-runtime split, Copilot round-11 on #19835)
      +    """
      +    if not isinstance(raw, str):
      +        return _DEFAULT_PT_KEY
      +
      +    lowered = raw.strip().lower()
      +    if not lowered:
      +        return _DEFAULT_PT_KEY
      +
      +    parts = [p.strip() for p in lowered.split("+") if p.strip()]
      +    if not parts:
      +        return _DEFAULT_PT_KEY
      +
      +    # Multi-modifier chords like ``ctrl+alt+r`` bind different shortcuts
      +    # in prompt_toolkit (a-c-r form) and hermes-ink rejects them; collapse
      +    # to the documented default instead of silently diverging.
      +    if len(parts) > 2:
      +        return _DEFAULT_PT_KEY
      +
      +    # Bare char / bare named key (no explicit modifier) — the CLI's
      +    # prompt_toolkit binds the raw key without a modifier, which the TUI
      +    # parser refuses; reject here too so both runtimes agree.
      +    if len(parts) == 1:
      +        return _DEFAULT_PT_KEY
      +
      +    modifier_token, key_token = parts
      +
      +    # ``super`` / ``win`` / ``windows`` are TUI-only (prompt_toolkit has
      +    # no super modifier, so ``@kb.add(super+b)`` crashes the CLI at
      +    # startup). Fall back to the documented default here; the CLI
      +    # binding site is expected to log a warning when the configured
      +    # value is one of these spellings so users know the TUI+CLI
      +    # runtimes diverge on that shortcut (Copilot round-11 on #19835).
      +    if modifier_token in {"super", "win", "windows"}:
      +        return _DEFAULT_PT_KEY
      +
      +    normalized_mod = _VOICE_MOD_ALIASES.get(modifier_token)
      +    if not normalized_mod:
      +        return _DEFAULT_PT_KEY
      +
      +    # Single-char key: reject reserved-ctrl chords that the TUI would
      +    # also block at parse time, plus the mac-only alt reservation.
      +    if len(key_token) == 1:
      +        if normalized_mod == "c-" and key_token in _VOICE_RESERVED_CTRL_CHARS:
      +            return _DEFAULT_PT_KEY
      +        if (
      +            normalized_mod == "a-"
      +            and sys.platform == "darwin"
      +            and key_token in _VOICE_RESERVED_ALT_CHARS_MAC
      +        ):
      +            return _DEFAULT_PT_KEY
      +        return f"{normalized_mod}{key_token}"
      +
      +    # Multi-char key token must be a known named key; typos like
      +    # ``ctrl+spcae`` fall back to the default rather than being passed
      +    # through as ``c-spcae`` (which prompt_toolkit would reject).
      +    named = _VOICE_NAMED_KEYS.get(key_token)
      +    if not named:
      +        return _DEFAULT_PT_KEY
      +
      +    return f"{normalized_mod}{named}"
      +
      +
      +def format_voice_record_key_for_status(raw: Any) -> str:
      +    """Render ``voice.record_key`` for ``/voice status`` in CLI-friendly form.
      +
      +    Mirrors the TUI's ``formatVoiceRecordKey``: returns ``Ctrl+B`` /
      +    ``Alt+Space`` / ``Ctrl+Enter``. Malformed configs surface as the
      +    documented default so status never advertises a shortcut that
      +    won't bind (Copilot round-10 on #19835).
      +    """
      +    normalized = normalize_voice_record_key_for_prompt_toolkit(raw)
      +
      +    if normalized.startswith("c-"):
      +        prefix, key = "Ctrl+", normalized[2:]
      +    elif normalized.startswith("a-"):
      +        prefix, key = "Alt+", normalized[2:]
      +    elif "+" in normalized:
      +        # ``super+<key>`` / ``win+<key>`` — CLI won't bind them, but
      +        # render in title case so status output is still readable.
      +        mod, key = normalized.split("+", 1)
      +        prefix = mod[0].upper() + mod[1:] + "+"
      +    else:
      +        return "Ctrl+B"
      +
      +    if not key:
      +        return prefix.rstrip("+")
      +
      +    if len(key) == 1:
      +        return prefix + key.upper()
      +
      +    return prefix + key[0].upper() + key[1:]
      +
      +
       from tools.voice_mode import (
           create_audio_recorder,
           is_whisper_hallucination,
    • tests/cli/test_cli_status_bar.py
      @@ -266,6 +266,68 @@ def test_voice_recording_status_bar_compacts_on_narrow_terminals(self):
       
               assert fragments == [("class:voice-status-recording", " ● REC ")]
       
      +    # Round-13 Copilot review regressions on #19835. The label in voice
      +    # status bar / recording hint / placeholder must render the
      +    # configured ``voice.record_key`` — not hardcoded Ctrl+B. Pinning
      +    # the cache (``set_voice_record_key_cache``) keeps display in sync
      +    # with the prompt_toolkit binding without re-reading config on
      +    # every render.
      +    def test_voice_status_bar_renders_configured_ctrl_letter(self):
      +        cli_obj = _make_cli()
      +        cli_obj._voice_mode = True
      +        cli_obj._voice_recording = False
      +        cli_obj._voice_processing = False
      +        cli_obj._voice_tts = False
      +        cli_obj._voice_continuous = False
      +        cli_obj.set_voice_record_key_cache("ctrl+o")
      +
      +        wide = cli_obj._get_voice_status_fragments(width=120)
      +        assert any("Ctrl+O to record" in text for _cls, text in wide)
      +
      +        compact = cli_obj._get_voice_status_fragments(width=50)
      +        assert compact == [("class:voice-status", " 🎤 Ctrl+O ")]
      +
      +    def test_voice_recording_status_bar_renders_configured_named_key(self):
      +        cli_obj = _make_cli()
      +        cli_obj._voice_mode = True
      +        cli_obj._voice_recording = True
      +        cli_obj._voice_processing = False
      +        cli_obj.set_voice_record_key_cache("ctrl+space")
      +
      +        fragments = cli_obj._get_voice_status_fragments(width=120)
      +
      +        assert fragments == [("class:voice-status-recording", " ● REC  Ctrl+Space to stop ")]
      +
      +    def test_voice_status_bar_falls_back_to_ctrl_b_without_cache(self):
      +        cli_obj = _make_cli()
      +        cli_obj._voice_mode = True
      +        cli_obj._voice_recording = False
      +        cli_obj._voice_processing = False
      +        cli_obj._voice_tts = False
      +        cli_obj._voice_continuous = False
      +        # No cache set — mirrors pre-startup state; fall back to
      +        # documented Ctrl+B default (Copilot round-13 review).
      +
      +        compact = cli_obj._get_voice_status_fragments(width=50)
      +
      +        assert compact == [("class:voice-status", " 🎤 Ctrl+B ")]
      +
      +    def test_voice_status_bar_renders_malformed_config_as_default(self):
      +        cli_obj = _make_cli()
      +        cli_obj._voice_mode = True
      +        cli_obj._voice_recording = False
      +        cli_obj._voice_processing = False
      +        cli_obj._voice_tts = False
      +        cli_obj._voice_continuous = False
      +        # Non-string / typoed configs fall through the formatter to the
      +        # documented default so the status bar never advertises an
      +        # invalid shortcut.
      +        cli_obj.set_voice_record_key_cache(True)
      +
      +        compact = cli_obj._get_voice_status_fragments(width=50)
      +
      +        assert compact == [("class:voice-status", " 🎤 Ctrl+B ")]
      +
       
       class TestCLIUsageReport:
           def test_show_usage_includes_estimated_cost(self, capsys):
    • tests/hermes_cli/test_voice_wrapper.py
      @@ -31,6 +31,243 @@ def test_gateway_symbols_importable(self):
               assert callable(speak_text)
       
       
      +class TestNormalizeVoiceRecordKeyForPromptToolkit:
      +    """Round-9 Copilot review regression on #19835.
      +
      +    Classic CLI only normalized ``ctrl+`` / ``alt+``, so TUI-valid
      +    aliases like ``control+``, ``option+``, ``opt+`` silently bound a
      +    different (or no) shortcut in the CLI. Normalizer now maps the
      +    same set of aliases the TUI parser accepts, so one config value
      +    binds identically in both runtimes.
      +    """
      +
      +    def test_ctrl_and_alt_map_to_prompt_toolkit_form(self):
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl+b") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("alt+r") == "a-r"
      +
      +    def test_control_option_opt_aliases_match_tui_parser(self):
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("control+o") == "c-o"
      +        assert normalize_voice_record_key_for_prompt_toolkit("option+space") == "a-space"
      +        assert normalize_voice_record_key_for_prompt_toolkit("opt+enter") == "a-enter"
      +
      +    def test_case_insensitive(self):
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("Ctrl+B") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("CONTROL+O") == "c-o"
      +
      +    def test_non_string_falls_back_to_default(self):
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit(None) == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit(1) == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit(True) == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit({}) == "c-b"
      +
      +    def test_empty_string_falls_back(self):
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("") == "c-b"
      +
      +    def test_super_win_fall_back_to_default_in_cli(self):
      +        """prompt_toolkit has no super modifier, so ``super+b`` / ``win+o``
      +        would crash the classic CLI at startup if passed through. Fall
      +        back to the documented default; the CLI binding site is
      +        expected to warn so users know the shortcut is TUI-only
      +        (Copilot round-11 on #19835)."""
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("super+b") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("win+o") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("windows+o") == "c-b"
      +
      +    # Round-10 Copilot review regressions on #19835.
      +    def test_strips_whitespace_within_and_around(self):
      +        """``ctrl + b`` / ``  option + space  `` are accepted by the TUI
      +        parser; the CLI normalizer must mirror that or the same config
      +        binds different shortcuts across runtimes."""
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl + b") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("  option + space  ") == "a-space"
      +
      +    def test_named_key_aliases_collapse_to_prompt_toolkit_canonical(self):
      +        """TUI accepts ``return`` / ``esc`` / ``bs`` / ``del`` etc.;
      +        CLI must collapse to prompt_toolkit's canonical spelling
      +        (``enter`` / ``escape`` / ``backspace`` / ``delete``)."""
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl+return") == "c-enter"
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl+esc") == "c-escape"
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl+bs") == "c-backspace"
      +        assert normalize_voice_record_key_for_prompt_toolkit("alt+del") == "a-delete"
      +
      +    def test_typoed_named_keys_fall_back_to_default(self):
      +        """``ctrl+spcae`` would otherwise pass through as ``c-spcae`` and
      +        prompt_toolkit would reject it at startup — fall back instead."""
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl+spcae") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl+f5") == "c-b"
      +
      +    def test_bare_char_and_multi_modifier_fall_back(self):
      +        """TUI parser rejects bare-char (``o``) and multi-modifier
      +        (``ctrl+alt+r``) configs; the CLI normalizer must match."""
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("o") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("b") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl+alt+r") == "c-b"
      +
      +    def test_reserved_ctrl_chars_fall_back(self):
      +        """``ctrl+c`` / ``ctrl+d`` / ``ctrl+l`` are always claimed by
      +        the CLI's prompt_toolkit input layer or terminal driver; match
      +        the TUI parser's rejection to keep /voice status honest."""
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl+c") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl+d") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("ctrl+l") == "c-b"
      +
      +    def test_unknown_modifier_falls_back(self):
      +        """``meta+b`` is ambiguous on the wire (Alt on xterm, Cmd on
      +        legacy macOS), same class as the TUI parser's rejection."""
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("meta+b") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("shift+b") == "c-b"
      +
      +    # Round-14 Copilot review regression on #19835. On macOS the TUI
      +    # parser rejects alt+c/d/l because hermes-ink reports Alt as
      +    # ``key.meta`` and isActionMod(darwin) accepts it. The CLI
      +    # normalizer must mirror that platform-gated rejection so shared
      +    # configs like ``option+c`` don't bind Alt+C in the CLI while the
      +    # TUI falls back to Ctrl+B.
      +    def test_alt_cdl_rejected_on_macos(self, monkeypatch):
      +        monkeypatch.setattr("sys.platform", "darwin")
      +
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("alt+c") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("alt+d") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("alt+l") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("option+c") == "c-b"
      +        assert normalize_voice_record_key_for_prompt_toolkit("opt+d") == "c-b"
      +        # Other alt letters still bind on darwin.
      +        assert normalize_voice_record_key_for_prompt_toolkit("alt+r") == "a-r"
      +        assert normalize_voice_record_key_for_prompt_toolkit("alt+space") == "a-space"
      +
      +    def test_alt_cdl_allowed_on_non_macos(self, monkeypatch):
      +        monkeypatch.setattr("sys.platform", "linux")
      +
      +        from hermes_cli.voice import normalize_voice_record_key_for_prompt_toolkit
      +
      +        assert normalize_voice_record_key_for_prompt_toolkit("alt+c") == "a-c"
      +        assert normalize_voice_record_key_for_prompt_toolkit("alt+d") == "a-d"
      +        assert normalize_voice_record_key_for_prompt_toolkit("alt+l") == "a-l"
      +
      +
      +class TestVoiceRecordKeyFromConfig:
      +    """Round-11 Copilot review regression on #19835.
      +
      +    ``load_config()`` preserves YAML scalar overrides, so a hand-edited
      +    ``voice: true`` or ``voice: cmd+b`` made the naive
      +    ``cfg.get('voice', {}).get('record_key')`` chain raise
      +    AttributeError before voice could run. The shape-safe extractor
      +    returns None for every malformed shape so the call-site fallback
      +    (``normalize_…`` / ``format_…``) surfaces the documented default.
      +    """
      +
      +    def test_dict_voice_with_string_record_key(self):
      +        from hermes_cli.voice import voice_record_key_from_config
      +
      +        assert voice_record_key_from_config({"voice": {"record_key": "ctrl+o"}}) == "ctrl+o"
      +
      +    def test_non_dict_config_root(self):
      +        from hermes_cli.voice import voice_record_key_from_config
      +
      +        for bad_root in (None, True, 1, "ctrl+b", [], ["ctrl+b"]):
      +            assert voice_record_key_from_config(bad_root) is None, bad_root
      +
      +    def test_non_dict_voice_entry(self):
      +        from hermes_cli.voice import voice_record_key_from_config
      +
      +        for bad_voice in (None, True, "cmd+b", 42, ["ctrl+b"]):
      +            assert voice_record_key_from_config({"voice": bad_voice}) is None, bad_voice
      +
      +    def test_missing_record_key_returns_none(self):
      +        from hermes_cli.voice import voice_record_key_from_config
      +
      +        assert voice_record_key_from_config({"voice": {"beep_enabled": True}}) is None
      +        assert voice_record_key_from_config({}) is None
      +
      +    def test_normalizer_accepts_extractor_output_directly(self):
      +        """voice_record_key_from_config + normalize_… must compose —
      +        None / non-string scalars all fall back to c-b."""
      +        from hermes_cli.voice import (
      +            normalize_voice_record_key_for_prompt_toolkit,
      +            voice_record_key_from_config,
      +        )
      +
      +        for raw in (None, True, 1, "cmd+b", ["ctrl+b"]):
      +            extracted = voice_record_key_from_config({"voice": raw})
      +            assert normalize_voice_record_key_for_prompt_toolkit(extracted) == "c-b"
      +
      +
      +class TestFormatVoiceRecordKeyForStatus:
      +    """Round-10 Copilot review regression on #19835.
      +
      +    ``/voice status`` used to print the raw scalar (``True`` / ``1``)
      +    for non-string configs even though the actual binding falls back
      +    to Ctrl+B. The formatter routes through the same normalizer so
      +    status always matches what the CLI actually binds.
      +    """
      +
      +    def test_ctrl_and_alt_letter_keys_render_canonically(self):
      +        from hermes_cli.voice import format_voice_record_key_for_status
      +
      +        assert format_voice_record_key_for_status("ctrl+b") == "Ctrl+B"
      +        assert format_voice_record_key_for_status("ctrl+o") == "Ctrl+O"
      +        assert format_voice_record_key_for_status("alt+r") == "Alt+R"
      +
      +    def test_named_keys_render_in_title_case(self):
      +        from hermes_cli.voice import format_voice_record_key_for_status
      +
      +        assert format_voice_record_key_for_status("ctrl+space") == "Ctrl+Space"
      +        assert format_voice_record_key_for_status("alt+enter") == "Alt+Enter"
      +        assert format_voice_record_key_for_status("ctrl+esc") == "Ctrl+Escape"
      +
      +    def test_aliases_render_via_normalized_form(self):
      +        from hermes_cli.voice import format_voice_record_key_for_status
      +
      +        assert format_voice_record_key_for_status("control+o") == "Ctrl+O"
      +        assert format_voice_record_key_for_status("option+space") == "Alt+Space"
      +        assert format_voice_record_key_for_status("opt+enter") == "Alt+Enter"
      +
      +    def test_non_string_scalar_falls_back_to_ctrl_b_label(self):
      +        from hermes_cli.voice import format_voice_record_key_for_status
      +
      +        # Copilot round-10 regression: previously /voice status printed
      +        # the raw scalar ("True" / "1") even though the actual binding
      +        # fell back to Ctrl+B.
      +        assert format_voice_record_key_for_status(True) == "Ctrl+B"
      +        assert format_voice_record_key_for_status(1) == "Ctrl+B"
      +        assert format_voice_record_key_for_status(None) == "Ctrl+B"
      +        assert format_voice_record_key_for_status({}) == "Ctrl+B"
      +
      +    def test_malformed_configs_fall_back_to_ctrl_b(self):
      +        from hermes_cli.voice import format_voice_record_key_for_status
      +
      +        assert format_voice_record_key_for_status("ctrl+spcae") == "Ctrl+B"
      +        assert format_voice_record_key_for_status("ctrl+alt+r") == "Ctrl+B"
      +        assert format_voice_record_key_for_status("") == "Ctrl+B"
      +        assert format_voice_record_key_for_status("  ") == "Ctrl+B"
      +
      +
       class TestStopWithoutStart:
           def test_returns_none_when_no_recording_active(self, monkeypatch):
               """Idempotent no-op: stop before start must not raise or touch state."""
    • tests/test_tui_gateway_server.py
      @@ -81,6 +81,173 @@ def test_dispatch_rejects_non_object_params():
           }
       
       
      +def test_voice_toggle_returns_configured_record_key(monkeypatch):
      +    monkeypatch.setattr(
      +        server,
      +        "_load_cfg",
      +        lambda: {"voice": {"record_key": "ctrl+o"}},
      +    )
      +    monkeypatch.setitem(
      +        sys.modules,
      +        "tools.voice_mode",
      +        types.SimpleNamespace(
      +            check_voice_requirements=lambda: {"available": True, "details": ""}
      +        ),
      +    )
      +    # ``voice.toggle`` action=on mutates ``os.environ["HERMES_VOICE"]``
      +    # directly (CLI parity, runtime-only flag). Take monkeypatch
      +    # ownership of the var so the change is reverted at teardown and
      +    # later tests don't inherit a stale ON state (Copilot round-5
      +    # review on #19835).
      +    monkeypatch.setenv("HERMES_VOICE", "0")
      +
      +    on_resp = server.dispatch(
      +        {"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}}
      +    )
      +    status_resp = server.dispatch(
      +        {"id": "voice-status", "method": "voice.toggle", "params": {"action": "status"}}
      +    )
      +
      +    assert on_resp["result"]["record_key"] == "ctrl+o"
      +    assert status_resp["result"]["record_key"] == "ctrl+o"
      +
      +
      +def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
      +    """Round-3 Copilot review regression on #19835.
      +
      +    ``_load_cfg()`` is raw ``yaml.safe_load()`` output — a hand-edited
      +    ``voice: true`` / ``voice: cmd+b`` / ``voice: null`` leaves ``voice``
      +    as a bool/str/None, not a dict. Previously ``.get("record_key")``
      +    on a non-dict broke every ``voice.toggle`` branch. Now it falls
      +    back to the documented default.
      +    """
      +    monkeypatch.setitem(
      +        sys.modules,
      +        "tools.voice_mode",
      +        types.SimpleNamespace(
      +            check_voice_requirements=lambda: {"available": True, "details": ""}
      +        ),
      +    )
      +
      +    for bad in (True, "cmd+b", None, 42, ["ctrl+b"]):
      +        monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})
      +
      +        status_resp = server.dispatch(
      +            {"id": "voice-status", "method": "voice.toggle", "params": {"action": "status"}}
      +        )
      +
      +        assert status_resp["result"]["record_key"] == "ctrl+b", (
      +            f"voice.record_key fell back to default for voice={bad!r}"
      +        )
      +
      +    # Round-4 follow-up: the YAML root itself may be a non-dict. A
      +    # hand-edit that collapses config.yaml to a scalar / list would
      +    # otherwise crash ``.get("voice")`` before the inner isinstance
      +    # guard gets a chance to run.
      +    for bad_root in (True, None, [], "ctrl+b", 42):
      +        monkeypatch.setattr(server, "_load_cfg", lambda r=bad_root: r)
      +
      +        status_resp = server.dispatch(
      +            {"id": "voice-status-root", "method": "voice.toggle", "params": {"action": "status"}}
      +        )
      +
      +        assert status_resp["result"]["record_key"] == "ctrl+b", (
      +            f"voice.record_key fell back to default for root={bad_root!r}"
      +        )
      +
      +
      +def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
      +    """Round-7 Copilot review regression on #19835.
      +
      +    The ``voice.record`` start path previously read
      +    ``_load_cfg().get("voice", {}).get(...)`` without any shape checks.
      +    When ``voice`` is a non-dict (bool/scalar/list) ``get`` raises
      +    AttributeError and the handler returns 5025 instead of falling
      +    back to the VAD defaults. Now it uses ``_voice_cfg_dict()`` and
      +    non-numeric silence values are coerced to the documented defaults.
      +    """
      +    captured: dict = {}
      +
      +    def fake_start_continuous(**kwargs):
      +        captured.update(kwargs)
      +
      +    monkeypatch.setitem(
      +        sys.modules,
      +        "hermes_cli.voice",
      +        types.SimpleNamespace(start_continuous=fake_start_continuous, stop_continuous=lambda: None),
      +    )
      +    monkeypatch.setenv("HERMES_VOICE", "1")
      +
      +    for bad in (True, "cmd+b", None, 42, ["ctrl+b"], {"silence_threshold": "loud"}):
      +        captured.clear()
      +        monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})
      +
      +        resp = server.dispatch(
      +            {"id": "voice-record", "method": "voice.record", "params": {"action": "start"}}
      +        )
      +
      +        assert "result" in resp, f"voice.record raised for voice={bad!r}: {resp.get('error')}"
      +        assert resp["result"]["status"] == "recording"
      +        assert captured["silence_threshold"] == 200
      +        assert captured["silence_duration"] == 3.0
      +
      +    # Round-12 Copilot review regression on #19835: ``bool`` is a subclass
      +    # of ``int``, so the naive ``isinstance(threshold, (int, float))``
      +    # guard would forward ``silence_threshold: true`` as ``1`` instead
      +    # of falling back to the documented 200 default.
      +    for bad_bool_cfg in (
      +        {"silence_threshold": True, "silence_duration": False},
      +        {"silence_threshold": False},
      +        {"silence_duration": True},
      +    ):
      +        captured.clear()
      +        monkeypatch.setattr(server, "_load_cfg", lambda c=bad_bool_cfg: {"voice": c})
      +
      +        resp = server.dispatch(
      +            {"id": "voice-record-bool", "method": "voice.record", "params": {"action": "start"}}
      +        )
      +
      +        assert "result" in resp, f"voice.record raised for bool cfg={bad_bool_cfg!r}"
      +        assert captured["silence_threshold"] == 200, (
      +            f"bool silence_threshold leaked through for {bad_bool_cfg!r}"
      +        )
      +        assert captured["silence_duration"] == 3.0, (
      +            f"bool silence_duration leaked through for {bad_bool_cfg!r}"
      +        )
      +
      +
      +def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch):
      +    """Round-2 Copilot review regression on #19835.
      +
      +    The ``tts`` branch used to omit ``record_key`` from its response, so a
      +    TUI client would parse ``r.record_key ?? 'ctrl+b'`` and reset a
      +    custom binding to the default on every TTS toggle. Every branch of
      +    ``voice.toggle`` now carries the configured key so frontend state
      +    stays authoritative.
      +    """
      +    monkeypatch.setattr(
      +        server,
      +        "_load_cfg",
      +        lambda: {"voice": {"record_key": "ctrl+space"}},
      +    )
      +    monkeypatch.setitem(
      +        sys.modules,
      +        "tools.voice_mode",
      +        types.SimpleNamespace(
      +            check_voice_requirements=lambda: {"available": True, "details": ""}
      +        ),
      +    )
      +    monkeypatch.setenv("HERMES_VOICE", "1")
      +    monkeypatch.delenv("HERMES_VOICE_TTS", raising=False)
      +
      +    tts_resp = server.dispatch(
      +        {"id": "voice-tts", "method": "voice.toggle", "params": {"action": "tts"}}
      +    )
      +
      +    assert tts_resp["result"]["record_key"] == "ctrl+space"
      +    assert tts_resp["result"]["tts"] is True
      +
      +
       def test_load_enabled_toolsets_prefers_tui_env(monkeypatch):
           monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web, terminal, ,memory")
       
    • tui_gateway/server.py
      @@ -5278,6 +5278,30 @@ def _voice_tts_enabled() -> bool:
           return os.environ.get("HERMES_VOICE_TTS", "").strip() == "1"
       
       
      +def _voice_cfg_dict() -> dict:
      +    """Shape-safe accessor for the ``voice:`` block in config.yaml.
      +
      +    ``_load_cfg()`` returns raw ``yaml.safe_load()`` output, so both the
      +    root AND ``voice`` may be any YAML scalar / list / None. A hand-edit
      +    like ``voice: true`` or a malformed top-level config that parses to
      +    a scalar would otherwise break ``.get("…")`` and take every
      +    ``voice.*`` branch down with it (Copilot round-3..7 review on
      +    #19835). Coerce through ``isinstance`` at every level so malformed
      +    config falls back to an empty dict instead of crashing /voice.
      +    """
      +    cfg = _load_cfg()
      +    voice_cfg = cfg.get("voice") if isinstance(cfg, dict) else None
      +
      +    return voice_cfg if isinstance(voice_cfg, dict) else {}
      +
      +
      +def _voice_record_key() -> str:
      +    """Current ``voice.record_key`` value, documented default on error."""
      +    record_key = _voice_cfg_dict().get("record_key")
      +
      +    return str(record_key) if isinstance(record_key, str) and record_key else "ctrl+b"
      +
      +
       @method("voice.toggle")
       def _(rid, params: dict) -> dict:
           """CLI parity for the ``/voice`` slash command.
      @@ -5298,8 +5322,13 @@ def _(rid, params: dict) -> dict:
               # Mirror CLI's _show_voice_status: include STT/TTS provider
               # availability so the user can tell at a glance *why* voice mode
               # isn't working ("STT provider: MISSING ..." is the common case).
      +        # ``record_key`` mirrors the configured ``voice.record_key`` so the
      +        # TUI can both bind it (frontend ``isVoiceToggleKey``) and display
      +        # it in /voice status — previously the TUI hardcoded Ctrl+B and
      +        # ignored the config (#18994).
               payload: dict = {
                   "enabled": _voice_mode_enabled(),
      +            "record_key": _voice_record_key(),
                   "tts": _voice_tts_enabled(),
               }
               try:
      @@ -5336,7 +5365,14 @@ def _(rid, params: dict) -> dict:
                   except Exception as e:
                       logger.warning("voice: stop_continuous failed during toggle off: %s", e)
       
      -        return _ok(rid, {"enabled": enabled, "tts": _voice_tts_enabled()})
      +        return _ok(
      +            rid,
      +            {
      +                "enabled": enabled,
      +                "record_key": _voice_record_key(),
      +                "tts": _voice_tts_enabled(),
      +            },
      +        )
       
           if action == "tts":
               if not _voice_mode_enabled():
      @@ -5344,7 +5380,18 @@ def _(rid, params: dict) -> dict:
               new_value = not _voice_tts_enabled()
               # Runtime-only flag (CLI parity) — see voice.toggle on/off above.
               os.environ["HERMES_VOICE_TTS"] = "1" if new_value else "0"
      -        return _ok(rid, {"enabled": True, "tts": new_value})
      +        # Include ``record_key`` on every branch so a /voice tts toggle
      +        # doesn't reset the TUI's cached shortcut to the default when a
      +        # user has a custom binding configured (Copilot review, round 2
      +        # on #19835). Keeps parity with the status/on/off branches above.
      +        return _ok(
      +            rid,
      +            {
      +                "enabled": True,
      +                "record_key": _voice_record_key(),
      +                "tts": new_value,
      +            },
      +        )
       
           return _err(rid, 4013, f"unknown voice action: {action}")
       
      @@ -5376,15 +5423,26 @@ def _(rid, params: dict) -> dict:
       
                   from hermes_cli.voice import start_continuous
       
      -            voice_cfg = _load_cfg().get("voice", {})
      +            # Shape-safe lookups: malformed ``voice:`` YAML (bool/scalar/list)
      +            # must not crash /voice with a 5025 — fall back to VAD defaults.
      +            #
      +            # Exclude ``bool`` from the numeric check since Python's bool is
      +            # a subclass of int — a hand-edit like ``silence_threshold: true``
      +            # would otherwise forward as ``1`` instead of falling back to
      +            # the documented 200 / 3.0 defaults (Copilot round-12 on #19835).
      +            voice_cfg = _voice_cfg_dict()
      +            threshold = voice_cfg.get("silence_threshold")
      +            duration = voice_cfg.get("silence_duration")
      +            safe_threshold = threshold if isinstance(threshold, (int, float)) and not isinstance(threshold, bool) else 200
      +            safe_duration = duration if isinstance(duration, (int, float)) and not isinstance(duration, bool) else 3.0
                   start_continuous(
                       on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}),
                       on_status=lambda s: _voice_emit("voice.status", {"state": s}),
                       on_silent_limit=lambda: _voice_emit(
                           "voice.transcript", {"no_speech_limit": True}
                       ),
      -                silence_threshold=voice_cfg.get("silence_threshold", 200),
      -                silence_duration=voice_cfg.get("silence_duration", 3.0),
      +                silence_threshold=safe_threshold,
      +                silence_duration=safe_duration,
                   )
                   return _ok(rid, {"status": "recording"})
       
    • ui-tui/src/__tests__/createSlashHandler.test.ts
      @@ -173,6 +173,64 @@ describe('createSlashHandler', () => {
           expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills'))
         })
       
      +  // Regressions from Copilot review on #19835: /voice output + frontend
      +  // binding state must both track the gateway's fresh ``record_key`` on
      +  // every response, or a config edit shows the new shortcut in text
      +  // while push-to-talk still fires the old one until the next mtime
      +  // poll (~5s).
      +  it('/voice status renders the gateway record_key and pushes it into frontend state', async () => {
      +    const rpc = vi.fn(() => Promise.resolve({ enabled: true, record_key: 'ctrl+space', tts: false }))
      +    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
      +
      +    expect(createSlashHandler(ctx)('/voice status')).toBe(true)
      +    await vi.waitFor(() => {
      +      expect(ctx.transcript.sys).toHaveBeenCalledWith('  Record key: Ctrl+Space')
      +    })
      +    expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(
      +      expect.objectContaining({ ch: 'space', mod: 'ctrl', named: 'space' })
      +    )
      +  })
      +
      +  it('/voice on renders the configured binding for the start/stop hint', async () => {
      +    const rpc = vi.fn(() => Promise.resolve({ enabled: true, record_key: 'alt+r', tts: false }))
      +    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
      +
      +    expect(createSlashHandler(ctx)('/voice on')).toBe(true)
      +    await vi.waitFor(() => {
      +      expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice mode enabled')
      +      expect(ctx.transcript.sys).toHaveBeenCalledWith('  Alt+R to start/stop recording')
      +    })
      +    expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(
      +      expect.objectContaining({ ch: 'r', mod: 'alt' })
      +    )
      +  })
      +
      +  it('/voice falls back to Ctrl+B when the gateway response omits record_key', async () => {
      +    const rpc = vi.fn(() => Promise.resolve({ enabled: false, tts: false }))
      +    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
      +
      +    expect(createSlashHandler(ctx)('/voice status')).toBe(true)
      +    await vi.waitFor(() => {
      +      expect(ctx.transcript.sys).toHaveBeenCalledWith('  Record key: Ctrl+B')
      +    })
      +  })
      +
      +  // Round-2 Copilot review on #19835: a response missing ``record_key``
      +  // (e.g. the old tts branch, or any future branch that forgets to
      +  // include it) MUST NOT clobber the user's cached binding back to
      +  // Ctrl+B. The label still renders the default for display; the
      +  // frontend state keeps whatever was last authoritatively set.
      +  it('/voice tts without record_key does not clobber cached frontend binding', async () => {
      +    const rpc = vi.fn(() => Promise.resolve({ enabled: true, tts: true }))
      +    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
      +
      +    expect(createSlashHandler(ctx)('/voice tts')).toBe(true)
      +    await vi.waitFor(() => {
      +      expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice TTS enabled.')
      +    })
      +    expect(ctx.voice.setVoiceRecordKey).not.toHaveBeenCalled()
      +  })
      +
         it('cycles details mode and persists it', async () => {
           const ctx = buildCtx()
       
      @@ -648,7 +706,8 @@ const buildTranscript = () => ({
       })
       
       const buildVoice = () => ({
      -  setVoiceEnabled: vi.fn()
      +  setVoiceEnabled: vi.fn(),
      +  setVoiceRecordKey: vi.fn()
       })
       
       interface Ctx {
    • ui-tui/src/__tests__/platform.test.ts
      @@ -67,11 +67,15 @@ describe('isVoiceToggleKey', () => {
           expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'B')).toBe(true)
         })
       
      -  it('matches Cmd+B on macOS (preserve platform muscle memory)', async () => {
      +  it('matches kitty-style Cmd+B on macOS via key.super', async () => {
           const { isVoiceToggleKey } = await importPlatform('darwin')
       
      -    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b')).toBe(true)
           expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b')).toBe(true)
      +    // ``key.meta`` is NOT accepted as Cmd — hermes-ink uses meta for
      +    // Alt too, so accepting it leaked Alt+B into the default binding
      +    // (Copilot round-6 review on #19835). Legacy-terminal mac users
      +    // get strict Ctrl+B.
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b')).toBe(false)
         })
       
         it('matches Ctrl+B on non-macOS platforms', async () => {
      @@ -89,6 +93,449 @@ describe('isVoiceToggleKey', () => {
         })
       })
       
      +describe('parseVoiceRecordKey (#18994)', () => {
      +  it('falls back to Ctrl+B for empty input', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    expect(parseVoiceRecordKey('')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +  })
      +
      +  it('parses ctrl+<letter> bindings', async () => {
      +    const { parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    expect(parseVoiceRecordKey('ctrl+o')).toEqual({ ch: 'o', mod: 'ctrl', raw: 'ctrl+o' })
      +    expect(parseVoiceRecordKey('Ctrl+R')).toEqual({ ch: 'r', mod: 'ctrl', raw: 'ctrl+r' })
      +  })
      +
      +  it('parses alt/super aliases', async () => {
      +    const { parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    expect(parseVoiceRecordKey('alt+b').mod).toBe('alt')
      +    expect(parseVoiceRecordKey('option+b').mod).toBe('alt')
      +    expect(parseVoiceRecordKey('super+b').mod).toBe('super')
      +    expect(parseVoiceRecordKey('win+b').mod).toBe('super')
      +  })
      +
      +  it('treats ambiguous mac modifiers (meta / cmd / command) as unrecognised', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    // ``meta`` / ``cmd`` / ``command`` are ambiguous on the wire:
      +    // hermes-ink sets ``key.meta`` for plain Alt on every platform AND
      +    // for Cmd on legacy macOS terminals. Accepting any of them would
      +    // produce a display/binding mismatch (Copilot round-6 review on
      +    // #19835). Users on modern kitty-style terminals spell the
      +    // platform action modifier ``super`` / ``win``.
      +    expect(parseVoiceRecordKey('meta+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('cmd+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('command+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +  })
      +
      +  it('parses named keys (space, enter, tab, escape, backspace, delete)', async () => {
      +    const { parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    // Every named token from the CLI's prompt_toolkit ``c-<name>`` set is
      +    // accepted with both the canonical name and its common alias.
      +    expect(parseVoiceRecordKey('ctrl+space')).toEqual({
      +      ch: 'space',
      +      mod: 'ctrl',
      +      named: 'space',
      +      raw: 'ctrl+space'
      +    })
      +    expect(parseVoiceRecordKey('alt+enter').named).toBe('enter')
      +    expect(parseVoiceRecordKey('alt+return').named).toBe('enter') // ``return`` ↔ ``enter``
      +    expect(parseVoiceRecordKey('ctrl+tab').named).toBe('tab')
      +    expect(parseVoiceRecordKey('ctrl+escape').named).toBe('escape')
      +    expect(parseVoiceRecordKey('ctrl+esc').named).toBe('escape') // ``esc`` alias
      +    expect(parseVoiceRecordKey('ctrl+backspace').named).toBe('backspace')
      +    expect(parseVoiceRecordKey('ctrl+delete').named).toBe('delete')
      +    expect(parseVoiceRecordKey('ctrl+del').named).toBe('delete') // ``del`` alias
      +  })
      +
      +  it('falls back to Ctrl+B for unrecognised multi-character tokens', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    // Typos / unsupported names (``ctrl+spcae``, ``ctrl+f5``, …) fall back
      +    // to the documented Ctrl+B default rather than silently disabling the
      +    // binding.
      +    expect(parseVoiceRecordKey('ctrl+spcae')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('ctrl+f5')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +  })
      +
      +  // Round-3 Copilot review regressions on #19835.
      +  it('does not throw on non-string YAML scalars — falls back instead', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    // ``config.get full`` surfaces raw YAML values; ``voice.record_key: 1``
      +    // or ``voice.record_key: true`` would otherwise crash ``.trim()``.
      +    expect(parseVoiceRecordKey(1 as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey(true as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey(null as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey(undefined as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey({} as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +  })
      +
      +  it('rejects multi-modifier chords rather than silently dropping extras', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    // Previously ``ctrl+alt+r`` parsed as ``ctrl+r`` and ``cmd+ctrl+b`` as
      +    // ``super+b`` — a typo silently bound a different shortcut. Now a
      +    // multi-modifier spelling falls back to the documented default.
      +    expect(parseVoiceRecordKey('ctrl+alt+r')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('cmd+ctrl+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('alt+ctrl+space')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +  })
      +
      +  // Round-4 Copilot review regressions on #19835.
      +  it('rejects bare-char configs without an explicit modifier', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    // The classic CLI's prompt_toolkit binds raw-char configs to the key
      +    // itself (``c-o`` requires an explicit modifier); rewriting ``o``
      +    // → ``ctrl+o`` would silently diverge the two runtimes. Refuse.
      +    expect(parseVoiceRecordKey('o')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('space')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('escape')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +  })
      +
      +  it('rejects ctrl+c / ctrl+d / ctrl+l — reserved by the TUI input handler', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    // ``useInputHandlers()`` intercepts these before the voice check,
      +    // so a binding like ``ctrl+c`` would be advertised but never fire.
      +    // Fall back to the documented default instead of lying to the user.
      +    expect(parseVoiceRecordKey('ctrl+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('ctrl+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('ctrl+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    // Alt-modifier versions of those letters are NOT intercepted, so
      +    // they remain usable.
      +    expect(parseVoiceRecordKey('alt+c').mod).toBe('alt')
      +    // ``ctrl+x`` is intentionally allowed — only intercepted during
      +    // queue-edit (``queueEditIdx !== null``), so the voice binding
      +    // works for most of the session (Copilot round-8 review).
      +    expect(parseVoiceRecordKey('ctrl+x').mod).toBe('ctrl')
      +    expect(parseVoiceRecordKey('ctrl+x').ch).toBe('x')
      +  })
      +
      +  it('rejects super+{c,d,l,v} on macOS — action-mod chords are claimed before voice', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('darwin')
      +
      +    // On macOS super+c/d/l/v are copy / exit / clear / paste. Reject at
      +    // parse time so /voice status doesn't advertise dead bindings.
      +    expect(parseVoiceRecordKey('super+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('super+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('super+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('super+v')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    // Other super letters still work (no global chord claims them).
      +    expect(parseVoiceRecordKey('super+b').mod).toBe('super')
      +    expect(parseVoiceRecordKey('super+o').mod).toBe('super')
      +  })
      +
      +  it('allows super+{c,d,l,v} on Linux/Windows — those globals key off Ctrl, not Super', async () => {
      +    const { parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    // Kitty/CSI-u users on non-mac report Cmd/Super as ``key.super``,
      +    // but the TUI's global shortcuts (copy/exit/clear/paste) key off
      +    // Ctrl there, so ``super+<letter>`` doesn't collide. Reject would
      +    // silently coerce valid configs to Ctrl+B (Copilot round-8 review).
      +    expect(parseVoiceRecordKey('super+c').mod).toBe('super')
      +    expect(parseVoiceRecordKey('super+d').mod).toBe('super')
      +    expect(parseVoiceRecordKey('super+l').mod).toBe('super')
      +    expect(parseVoiceRecordKey('super+v').mod).toBe('super')
      +  })
      +
      +  it('rejects alt+{c,d,l} on macOS — meta-as-alt collides with isAction', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('darwin')
      +
      +    // hermes-ink reports Alt as ``key.meta`` on many terminals, and
      +    // ``isActionMod`` on darwin accepts ``key.meta`` as the action
      +    // modifier. So ``alt+c`` / ``alt+d`` / ``alt+l`` get claimed by
      +    // isCopyShortcut / isAction('d') / isAction('l') before voice
      +    // runs (Copilot round-12 on #19835).
      +    expect(parseVoiceRecordKey('alt+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('alt+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    expect(parseVoiceRecordKey('alt+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
      +    // Other alt letters stay usable on darwin.
      +    expect(parseVoiceRecordKey('alt+r').mod).toBe('alt')
      +    expect(parseVoiceRecordKey('alt+space').mod).toBe('alt')
      +  })
      +
      +  it('allows alt+{c,d,l} on Linux/Windows — non-mac isAction keys off Ctrl', async () => {
      +    const { parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    // On Linux/Windows ``isActionMod`` ignores key.meta, so alt+<letter>
      +    // doesn't collide with copy/exit/clear. Those configs stay usable.
      +    expect(parseVoiceRecordKey('alt+c').mod).toBe('alt')
      +    expect(parseVoiceRecordKey('alt+d').mod).toBe('alt')
      +    expect(parseVoiceRecordKey('alt+l').mod).toBe('alt')
      +  })
      +
      +  // Round-5 Copilot review regressions on #19835.
      +  it('super+<key> does NOT fire on key.meta-only events (Alt+X false-fire guard)', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
      +
      +    // hermes-ink sets ``key.meta`` for Alt/Option AND for bare Esc on
      +    // some macOS terminals. The super branch used to accept
      +    // ``isMac && key.meta`` as a Cmd fallback, which made super+<key>
      +    // bindings silently fire on Alt+<key> / bare Esc.
      +    const superB = parseVoiceRecordKey('super+b')
      +    const superSpace = parseVoiceRecordKey('super+space')
      +    const superEscape = parseVoiceRecordKey('super+escape')
      +
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', superB)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, ' ', superSpace)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', superEscape)).toBe(false)
      +  })
      +
      +  // Round-6 Copilot review regressions on #19835.
      +  it('default ctrl+b does NOT fire on Alt+B via isActionMod meta leak', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, isVoiceToggleKey } = await importPlatform('darwin')
      +
      +    // ``isActionMod(key)`` on darwin was accepting ``key.meta`` as the
      +    // action modifier, so Alt+B (key.meta=true) fired the default
      +    // ctrl+b binding. Now the Cmd-fallback path requires literal
      +    // ``key.super`` on macOS and rejects ``key.meta``.
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(false)
      +    // Literal Ctrl+B and Cmd+B (kitty-style) still work on darwin.
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
      +    expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
      +  })
      +
      +  it('ctrl+<key> rejects chords with extra alt / meta / super bits', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
      +    const ctrlO = parseVoiceRecordKey('ctrl+o')
      +
      +    // ``ctrl+o`` must fire ONLY on literal Ctrl+O, not on
      +    // Ctrl+Alt+O / Ctrl+Cmd+O / Ctrl+Meta+O — otherwise the runtime
      +    // matches a different chord than the parser would let you
      +    // configure.
      +    expect(isVoiceToggleKey({ alt: true, ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: true, meta: true, super: false }, 'o', ctrlO)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'o', ctrlO)).toBe(false)
      +    // Sanity: plain Ctrl+O still fires.
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
      +  })
      +
      +  it('super+<key> rejects chords with extra ctrl / alt / meta bits', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
      +    const superB = parseVoiceRecordKey('super+b')
      +
      +    expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: true }, 'b', superB)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: true }, 'b', superB)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'b', superB)).toBe(false)
      +    // Sanity: plain Super+B still fires.
      +    expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', superB)).toBe(true)
      +  })
      +
      +  it('alt+escape does not fire on bare Esc meta-shape', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
      +    const altEscape = parseVoiceRecordKey('alt+escape')
      +
      +    // Some terminals surface bare Esc as meta=true + escape=true.
      +    expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', altEscape)).toBe(false)
      +    // Explicit alt bit (kitty-style) still fires the configured chord.
      +    expect(isVoiceToggleKey({ alt: true, ctrl: false, escape: true, meta: false, super: false }, '', altEscape)).toBe(true)
      +  })
      +
      +  it('rejects matches when Shift is held (different chord than configured)', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    // Parser rejects multi-modifier configs like ``ctrl+shift+tab``,
      +    // so the runtime matcher must also reject Shift-held events —
      +    // otherwise ``ctrl+tab`` would fire on Ctrl+Shift+Tab.
      +    const ctrlTab = parseVoiceRecordKey('ctrl+tab')
      +    const altEnter = parseVoiceRecordKey('alt+enter')
      +    const ctrlO = parseVoiceRecordKey('ctrl+o')
      +
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: true, super: false, tab: true }, '', ctrlTab)).toBe(false)
      +    expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, return: true, shift: true, super: false }, '', altEnter)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: true, super: false }, 'o', ctrlO)).toBe(false)
      +
      +    // Sanity: same events without Shift still fire.
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: false, super: false, tab: true }, '', ctrlTab)).toBe(true)
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: false, super: false }, 'o', ctrlO)).toBe(true)
      +  })
      +})
      +
      +describe('formatVoiceRecordKey (#18994)', () => {
      +  it('renders as the user expects in /voice status', async () => {
      +    const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+b'))).toBe('Ctrl+B')
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+o'))).toBe('Ctrl+O')
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+r'))).toBe('Alt+R')
      +    // ``super``/``win`` render as ``Super`` on non-mac so the hint
      +    // doesn't tell Linux/Windows users to press a Cmd key they don't
      +    // have.
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Super+B')
      +  })
      +
      +  it('renders named keys in title case (Ctrl+Space, Ctrl+Enter)', async () => {
      +    const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+space'))).toBe('Ctrl+Space')
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+enter'))).toBe('Alt+Enter')
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+esc'))).toBe('Ctrl+Escape')
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('super+space'))).toBe('Super+Space')
      +  })
      +})
      +
      +describe('isVoiceToggleKey honours configured record key (#18994)', () => {
      +  it('binds the configured letter, not hardcoded b', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
      +    const ctrlO = parseVoiceRecordKey('ctrl+o')
      +
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
      +    // The old hardcoded 'b' must NOT match when the user configured 'o'.
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', ctrlO)).toBe(false)
      +  })
      +
      +  it('alt+<letter> binding matches alt OR meta (terminal-protocol parity)', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
      +    const altR = parseVoiceRecordKey('alt+r')
      +
      +    expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: false }, 'r', altR)).toBe(true)
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'r', altR)).toBe(true)
      +    expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, 'r', altR)).toBe(false)
      +  })
      +
      +  it('binds named keys via ink event flags (space → ch === " ", enter → key.return, …)', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    const ctrlSpace = parseVoiceRecordKey('ctrl+space')
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, ' ', ctrlSpace)).toBe(true)
      +    // Single-char ``b`` must NOT match a ``space``-configured binding.
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', ctrlSpace)).toBe(false)
      +    // Space without the configured modifier must not fire either.
      +    expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, ' ', ctrlSpace)).toBe(false)
      +
      +    const ctrlEnter = parseVoiceRecordKey('ctrl+enter')
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, return: true, super: false }, '', ctrlEnter)).toBe(true)
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, return: false, super: false }, '', ctrlEnter)).toBe(false)
      +
      +    const altTab = parseVoiceRecordKey('alt+tab')
      +    expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: false, tab: true }, '', altTab)).toBe(true)
      +    expect(isVoiceToggleKey({ alt: false, ctrl: false, meta: false, super: false, tab: true }, '', altTab)).toBe(false)
      +
      +    const ctrlEscape = parseVoiceRecordKey('ctrl+escape')
      +    expect(isVoiceToggleKey({ ctrl: true, escape: true, meta: false, super: false }, '', ctrlEscape)).toBe(true)
      +    expect(isVoiceToggleKey({ ctrl: true, escape: false, meta: false, super: false }, '', ctrlEscape)).toBe(false)
      +
      +    const ctrlBackspace = parseVoiceRecordKey('ctrl+backspace')
      +    expect(isVoiceToggleKey({ backspace: true, ctrl: true, meta: false, super: false }, '', ctrlBackspace)).toBe(true)
      +
      +    const ctrlDelete = parseVoiceRecordKey('ctrl+delete')
      +    expect(isVoiceToggleKey({ ctrl: true, delete: true, meta: false, super: false }, '', ctrlDelete)).toBe(true)
      +  })
      +
      +  it('omitted configured key falls back to ctrl+b (back-compat)', async () => {
      +    const { isVoiceToggleKey } = await importPlatform('linux')
      +
      +    // No third arg → DEFAULT_VOICE_RECORD_KEY → Ctrl+B behaviour.
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true)
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o')).toBe(false)
      +  })
      +
      +  // Regressions from Copilot review on #19835: the previous implementation
      +  // accepted ``isActionMod(key)`` in the ``ctrl`` branch for every
      +  // configured key, so bare Esc (which hermes-ink reports with
      +  // ``key.meta`` on some macOS terminals) fired ``ctrl+escape``, and
      +  // Alt+Space / Alt+Tab fired ``ctrl+space`` / ``ctrl+tab``. The fallback
      +  // is now gated to the documented default (``ctrl+b``) only.
      +  it('ctrl+escape does NOT fire on bare Esc via key.meta on macOS', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
      +    const ctrlEscape = parseVoiceRecordKey('ctrl+escape')
      +
      +    // Bare Esc on a legacy macOS terminal: ``key.meta: true``, ``key.escape: true``, no ctrl.
      +    expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', ctrlEscape)).toBe(false)
      +    // Real Ctrl+Esc still fires.
      +    expect(isVoiceToggleKey({ ctrl: true, escape: true, meta: false, super: false }, '', ctrlEscape)).toBe(true)
      +  })
      +
      +  it('ctrl+space does NOT fire on Alt+Space on macOS', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
      +    const ctrlSpace = parseVoiceRecordKey('ctrl+space')
      +
      +    // Alt+Space surfaces as ``key.meta: true`` with space char.
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, ' ', ctrlSpace)).toBe(false)
      +    // Real Ctrl+Space still fires.
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, ' ', ctrlSpace)).toBe(true)
      +  })
      +
      +  it('default ctrl+b accepts raw Ctrl+B and kitty-style Cmd+B on macOS', async () => {
      +    const { DEFAULT_VOICE_RECORD_KEY, isVoiceToggleKey } = await importPlatform('darwin')
      +
      +    // Raw Ctrl+B: always works.
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
      +    // Cmd+B via kitty-style ``key.super``: still works.
      +    expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
      +    // Cmd+B via legacy ``key.meta`` NO LONGER works — ``key.meta`` is
      +    // hermes-ink's Alt signal, so accepting it leaked Alt+B into the
      +    // default binding (Copilot round-6 review on #19835).
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(false)
      +  })
      +
      +  it('custom ctrl+<letter> does NOT accept Cmd fallback on macOS', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
      +    const ctrlO = parseVoiceRecordKey('ctrl+o')
      +
      +    // Only ``ctrl+b`` gets the action-modifier fallback; ``ctrl+o`` must
      +    // be a literal Ctrl bit — otherwise Cmd+O would steal the shortcut.
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'o', ctrlO)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'o', ctrlO)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
      +  })
      +
      +  it('super+b renders "Cmd+B" on darwin and requires the literal key.super bit', async () => {
      +    const { formatVoiceRecordKey, isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
      +    const superB = parseVoiceRecordKey('super+b')
      +
      +    expect(formatVoiceRecordKey(superB)).toBe('Cmd+B')
      +    // Kitty-style: key.super fires the binding.
      +    expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', superB)).toBe(true)
      +    // ``key.meta`` is NOT accepted — hermes-ink uses meta for Alt too,
      +    // so accepting it here would make super+b silently fire on Alt+B
      +    // (Copilot round-5 review on #19835).
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', superB)).toBe(false)
      +    // Ctrl held at the same time → reject (different chord).
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'b', superB)).toBe(false)
      +  })
      +
      +  // Round-2 Copilot review regressions on #19835.
      +  it('super+b renders "Super+B" on Linux (not "Cmd+B")', async () => {
      +    const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
      +
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Super+B')
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('win+b'))).toBe('Super+B')
      +  })
      +
      +  it('super+b still renders "Cmd+B" on macOS', async () => {
      +    const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('darwin')
      +
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Cmd+B')
      +    expect(formatVoiceRecordKey(parseVoiceRecordKey('win+b'))).toBe('Cmd+B')
      +  })
      +
      +  it('ctrl+b aliases (control+b, "ctrl + b") still accept Cmd+B fallback on macOS', async () => {
      +    const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
      +    const controlB = parseVoiceRecordKey('control+b')
      +    const spacedB = parseVoiceRecordKey('ctrl + b')
      +
      +    // Both parse to the documented default semantically; both must keep
      +    // the macOS Cmd+B muscle-memory fallback via kitty-style key.super.
      +    // ``key.meta`` is NOT accepted — that's hermes-ink's Alt signal
      +    // (round-6 review), so legacy-terminal users get strict Ctrl+B.
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', controlB)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', spacedB)).toBe(false)
      +    expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', controlB)).toBe(true)
      +    expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', spacedB)).toBe(true)
      +    // Literal Ctrl+B still fires.
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', controlB)).toBe(true)
      +    // And still reject a ctrl bit on a different letter.
      +    expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', controlB)).toBe(false)
      +  })
      +})
      +
       describe('isMacActionFallback', () => {
         it('routes raw Ctrl+K and Ctrl+W to readline kill-to-end / delete-word on macOS', async () => {
           const { isMacActionFallback } = await importPlatform('darwin')
    • ui-tui/src/__tests__/textInputPassThrough.test.ts
      @@ -0,0 +1,43 @@
      +import { describe, expect, it } from 'vitest'
      +
      +import { shouldPassThroughToGlobalHandler } from '../components/textInput.js'
      +import { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } from '../lib/platform.js'
      +
      +const key = (overrides: Record<string, unknown> = {}) =>
      +  ({ ctrl: false, meta: false, ...overrides }) as any
      +
      +describe('shouldPassThroughToGlobalHandler', () => {
      +  it('passes through the configured voice shortcut while composer is focused', () => {
      +    expect(
      +      shouldPassThroughToGlobalHandler('o', key({ ctrl: true }), parseVoiceRecordKey('ctrl+o'))
      +    ).toBe(true)
      +    expect(
      +      shouldPassThroughToGlobalHandler('r', key({ meta: true }), parseVoiceRecordKey('alt+r'))
      +    ).toBe(true)
      +    expect(
      +      shouldPassThroughToGlobalHandler(' ', key({ ctrl: true }), parseVoiceRecordKey('ctrl+space'))
      +    ).toBe(true)
      +    expect(
      +      shouldPassThroughToGlobalHandler('', key({ ctrl: true, return: true }), parseVoiceRecordKey('ctrl+enter'))
      +    ).toBe(true)
      +  })
      +
      +  it('keeps the legacy default pass-through when no custom key is provided', () => {
      +    expect(shouldPassThroughToGlobalHandler('b', key({ ctrl: true }), DEFAULT_VOICE_RECORD_KEY)).toBe(true)
      +    expect(shouldPassThroughToGlobalHandler('b', key({ ctrl: true }))).toBe(true)
      +  })
      +
      +  it('does not swallow ordinary typing keys', () => {
      +    expect(shouldPassThroughToGlobalHandler('h', key(), parseVoiceRecordKey('ctrl+o'))).toBe(false)
      +    expect(shouldPassThroughToGlobalHandler('o', key(), parseVoiceRecordKey('ctrl+o'))).toBe(false)
      +  })
      +
      +  it('always passes through non-voice global control keys', () => {
      +    expect(shouldPassThroughToGlobalHandler('c', key({ ctrl: true }))).toBe(true)
      +    expect(shouldPassThroughToGlobalHandler('x', key({ ctrl: true }))).toBe(true)
      +    expect(shouldPassThroughToGlobalHandler('', key({ escape: true }))).toBe(true)
      +    expect(shouldPassThroughToGlobalHandler('', key({ tab: true }))).toBe(true)
      +    expect(shouldPassThroughToGlobalHandler('', key({ pageUp: true }))).toBe(true)
      +    expect(shouldPassThroughToGlobalHandler('', key({ pageDown: true }))).toBe(true)
      +  })
      +})
    • ui-tui/src/__tests__/useConfigSync.test.ts
      @@ -1,13 +1,15 @@
      -import { beforeEach, describe, expect, it, vi } from 'vitest'
      +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
       
       import { $uiState, resetUiState } from '../app/uiStore.js'
       import {
         applyDisplay,
      +  hydrateFullConfig,
         normalizeBusyInputMode,
         normalizeIndicatorStyle,
         normalizeMouseTracking,
         normalizeStatusBar
       } from '../app/useConfigSync.js'
      +import type { ParsedVoiceRecordKey } from '../lib/platform.js'
       
       describe('applyDisplay', () => {
         beforeEach(() => {
      @@ -292,3 +294,139 @@ describe('applyDisplay → tui_status_indicator', () => {
           expect($uiState.get().indicatorStyle).toBe('kaomoji')
         })
       })
      +
      +// Regressions from Copilot review on #19835: the config-hydration path
      +// for voice.record_key was untested, so a future regression in the
      +// hydration or mtime-reapply wiring would slip past the suite.
      +describe('applyDisplay → voice.record_key (#18994)', () => {
      +  beforeEach(() => {
      +    resetUiState()
      +  })
      +
      +  it('parses voice.record_key and pushes it through the setter', () => {
      +    const setBell = vi.fn()
      +    const setVoiceRecordKey = vi.fn()
      +
      +    applyDisplay(
      +      { config: { display: {}, voice: { record_key: 'ctrl+space' } } },
      +      setBell,
      +      setVoiceRecordKey
      +    )
      +
      +    expect(setVoiceRecordKey).toHaveBeenCalledWith(
      +      expect.objectContaining({ ch: 'space', mod: 'ctrl', named: 'space', raw: 'ctrl+space' })
      +    )
      +  })
      +
      +  it('falls back to the documented default when voice.record_key is missing', () => {
      +    const setBell = vi.fn()
      +    const setVoiceRecordKey = vi.fn()
      +
      +    applyDisplay({ config: { display: {} } }, setBell, setVoiceRecordKey)
      +
      +    expect(setVoiceRecordKey).toHaveBeenCalledWith(
      +      expect.objectContaining({ ch: 'b', mod: 'ctrl', raw: 'ctrl+b' })
      +    )
      +  })
      +
      +  it('is a no-op when the voice setter is not passed (back-compat)', () => {
      +    const setBell = vi.fn()
      +
      +    // applyDisplay is used in the setVoiceEnabled-less init path too;
      +    // omitting the third arg must not throw.
      +    expect(() =>
      +      applyDisplay({ config: { display: {}, voice: { record_key: 'alt+r' } } }, setBell)
      +    ).not.toThrow()
      +  })
      +
      +  it('does not reset voiceRecordKey when cfg is null (transient RPC failure)', () => {
      +    const setBell = vi.fn()
      +    const setVoiceRecordKey = vi.fn()
      +
      +    // quietRpc() collapses request failures to null. Resetting the
      +    // cached shortcut on every null would clobber a custom binding
      +    // after one transient error until the next successful poll
      +    // (Copilot round-8 review on #19835).
      +    applyDisplay(null, setBell, setVoiceRecordKey)
      +
      +    expect(setVoiceRecordKey).not.toHaveBeenCalled()
      +    // bell is still applied (defaults to false on null), so the setter
      +    // runs — we specifically only skip voiceRecordKey.
      +    expect(setBell).toHaveBeenCalledWith(false)
      +  })
      +})
      +
      +// Round-12 Copilot review regression on #19835: the live mtime-reload
      +// path was previously untested, so a regression in the polling/RPC
      +// wiring to applyDisplay would only be visible at runtime. The fetch
      +// + apply body is now shared as ``hydrateFullConfig()``, exercised
      +// directly from both the initial hydration and the poll-tick body.
      +describe('hydrateFullConfig', () => {
      +  beforeEach(() => {
      +    resetUiState()
      +  })
      +
      +  const makeFakeGw = (payload: unknown) =>
      +    ({
      +      request: vi.fn(() => Promise.resolve(payload)),
      +      on: vi.fn(),
      +      off: vi.fn()
      +    }) as any
      +
      +  it('re-applies voice.record_key from a fresh config.get full response', async () => {
      +    const gw = makeFakeGw({ config: { display: {}, voice: { record_key: 'ctrl+o' } } })
      +    const setBell = vi.fn()
      +    const setVoiceRecordKey = vi.fn()
      +
      +    await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
      +
      +    expect(gw.request).toHaveBeenCalledWith('config.get', { key: 'full' })
      +    expect(setVoiceRecordKey).toHaveBeenCalledWith(
      +      expect.objectContaining({ ch: 'o', mod: 'ctrl', raw: 'ctrl+o' })
      +    )
      +    expect(setBell).toHaveBeenCalledWith(false)
      +  })
      +
      +  it('reapplies the latest value on each invocation (mtime-reload semantics)', async () => {
      +    const gw = makeFakeGw({ config: { display: {}, voice: { record_key: 'ctrl+b' } } })
      +    const setBell = vi.fn()
      +    const setVoiceRecordKey = vi.fn()
      +
      +    await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
      +    expect(setVoiceRecordKey).toHaveBeenLastCalledWith(expect.objectContaining({ ch: 'b' }))
      +
      +    // Simulate a config edit: gw now returns a new shortcut.
      +    gw.request = vi.fn(() => Promise.resolve({ config: { display: {}, voice: { record_key: 'alt+space' } } }))
      +
      +    await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
      +    expect(setVoiceRecordKey).toHaveBeenLastCalledWith(
      +      expect.objectContaining({ ch: 'space', mod: 'alt', named: 'space' })
      +    )
      +  })
      +
      +  it('leaves cached voiceRecordKey untouched when the RPC fails', async () => {
      +    const gw = { request: vi.fn(() => Promise.reject(new Error('boom'))), on: vi.fn(), off: vi.fn() } as any
      +    const setBell = vi.fn()
      +    const setVoiceRecordKey = vi.fn()
      +
      +    const result = await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
      +
      +    // quietRpc() swallows the error and returns null; applyDisplay
      +    // sees cfg=null and skips the voice setter (Copilot round-8).
      +    expect(result).toBeNull()
      +    expect(setVoiceRecordKey).not.toHaveBeenCalled()
      +    // bell setter still fires — applyDisplay's null-cfg path applies
      +    // the documented bell default (false).
      +    expect(setBell).toHaveBeenCalledWith(false)
      +  })
      +
      +  it('threads through without a voice setter (back-compat call sites)', async () => {
      +    const gw = makeFakeGw({ config: { display: { bell_on_complete: true } } })
      +    const setBell = vi.fn()
      +
      +    // No third arg — applyDisplay must not throw and must still apply
      +    // display flags (round-2 / round-8 invariant).
      +    await expect(hydrateFullConfig(gw, setBell)).resolves.toBeTruthy()
      +    expect(setBell).toHaveBeenCalledWith(true)
      +  })
      +})
    • ui-tui/src/app/interfaces.ts
      @@ -4,6 +4,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea
       import type { PasteEvent } from '../components/textInput.js'
       import type { GatewayClient } from '../gatewayClient.js'
       import type { ImageAttachResponse } from '../gatewayTypes.js'
      +import type { ParsedVoiceRecordKey } from '../lib/platform.js'
       import type { RpcResult } from '../lib/rpc.js'
       import type { Theme } from '../theme.js'
       import type {
      @@ -210,6 +211,7 @@ export interface InputHandlerContext {
         }
         voice: {
           enabled: boolean
      +    recordKey: ParsedVoiceRecordKey
           recording: boolean
           setProcessing: StateSetter<boolean>
           setRecording: StateSetter<boolean>
      @@ -291,6 +293,7 @@ export interface SlashHandlerContext {
         }
         voice: {
           setVoiceEnabled: StateSetter<boolean>
      +    setVoiceRecordKey: (v: ParsedVoiceRecordKey) => void
         }
       }
       
      @@ -318,6 +321,7 @@ export interface AppLayoutComposerProps {
         queuedDisplay: string[]
         submit: (value: string) => void
         updateInput: StateSetter<string>
      +  voiceRecordKey: ParsedVoiceRecordKey
       }
       
       export interface AppLayoutProgressProps {
    • ui-tui/src/app/slash/commands/session.ts
      @@ -10,6 +10,7 @@ import type {
         SessionUsageResponse,
         VoiceToggleResponse
       } from '../../../gatewayTypes.js'
      +import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js'
       import { fmtK } from '../../../lib/text.js'
       import type { PanelSection } from '../../../types.js'
       import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
      @@ -221,6 +222,30 @@ export const sessionCommands: SlashCommand[] = [
               ctx.guarded<VoiceToggleResponse>(r => {
                 ctx.voice.setVoiceEnabled(!!r.enabled)
       
      +          // Render the configured record key (config.yaml ``voice.record_key``)
      +          // instead of hardcoded "Ctrl+B" — the gateway response carries the
      +          // current value so /voice status and /voice on stay in sync with
      +          // both the CLI and the TUI's actual binding (#18994).
      +          //
      +          // Copilot review on #19835 caught that rendering from the fresh
      +          // backend response WITHOUT updating the frontend ``voice.recordKey``
      +          // state would skew display and binding between config-edit and
      +          // the next ``mtime`` poll (~5s). Parse once, push into state so
      +          // ``useInputHandlers()`` picks up the new binding immediately.
      +          //
      +          // Round-2 follow-up: only push state when the response actually
      +          // carries ``record_key`` — otherwise an older gateway (or a future
      +          // branch that forgets to include it) would clobber a custom user
      +          // binding back to the default on every /voice invocation. The
      +          // label still falls back to the documented default for display.
      +          const parsed = r.record_key ? parseVoiceRecordKey(r.record_key) : undefined
      +
      +          if (parsed) {
      +            ctx.voice.setVoiceRecordKey(parsed)
      +          }
      +
      +          const recordKeyLabel = formatVoiceRecordKey(parsed ?? parseVoiceRecordKey('ctrl+b'))
      +
                 // Match CLI's _show_voice_status / _enable_voice_mode /
                 // _toggle_voice_tts output shape so users don't have to learn
                 // two vocabularies.
      @@ -230,11 +255,11 @@ export const sessionCommands: SlashCommand[] = [
                   ctx.transcript.sys('Voice Mode Status')
                   ctx.transcript.sys(`  Mode:       ${mode}`)
                   ctx.transcript.sys(`  TTS:        ${tts}`)
      -            ctx.transcript.sys('  Record key: Ctrl+B')
      +            ctx.transcript.sys(`  Record key: ${recordKeyLabel}`)
       
                   // CLI's "Requirements:" block — surfaces STT/audio setup issues
                   // so the user sees "STT provider: MISSING ..." instead of
      -            // silently failing on every Ctrl+B press.
      +            // silently failing on every record-key press.
                   if (r.details) {
                     ctx.transcript.sys('')
                     ctx.transcript.sys('  Requirements:')
      @@ -259,7 +284,7 @@ export const sessionCommands: SlashCommand[] = [
                 if (r.enabled) {
                   const tts = r.tts ? ' (TTS enabled)' : ''
                   ctx.transcript.sys(`Voice mode enabled${tts}`)
      -            ctx.transcript.sys('  Ctrl+B to start/stop recording')
      +            ctx.transcript.sys(`  ${recordKeyLabel} to start/stop recording`)
                   ctx.transcript.sys('  /voice tts  to toggle speech output')
                   ctx.transcript.sys('  /voice off  to disable voice mode')
                 } else {
    • ui-tui/src/app/useConfigSync.ts
      @@ -7,6 +7,11 @@ import type {
         ConfigMtimeResponse,
         ReloadMcpResponse
       } from '../gatewayTypes.js'
      +import {
      +  DEFAULT_VOICE_RECORD_KEY,
      +  parseVoiceRecordKey,
      +  type ParsedVoiceRecordKey
      +} from '../lib/platform.js'
       import { asRpcResult } from '../lib/rpc.js'
       
       import {
      @@ -89,10 +94,47 @@ const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
         }
       }
       
      -export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
      +const _voiceRecordKeyFromConfig = (cfg: ConfigFullResponse | null): ParsedVoiceRecordKey => {
      +  const raw = cfg?.config?.voice?.record_key
      +
      +  return raw ? parseVoiceRecordKey(raw) : DEFAULT_VOICE_RECORD_KEY
      +}
      +
      +/** Fetch ``config.get full`` and fan the result through ``applyDisplay``.
      + *
      + * Extracted so the mtime-reload path can be exercised by the test
      + * suite without a React runtime (Copilot round-12 review on #19835).
      + * Both the initial hydration and the mtime poller use this shared
      + * helper, so a regression in the fetch/apply plumbing now fails the
      + * useConfigSync tests instead of only being visible at runtime. */
      +export async function hydrateFullConfig(
      +  gw: GatewayClient,
      +  setBell: (v: boolean) => void,
      +  setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
      +): Promise<ConfigFullResponse | null> {
      +  const cfg = await quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' })
      +  applyDisplay(cfg, setBell, setVoiceRecordKey)
      +  return cfg
      +}
      +
      +export const applyDisplay = (
      +  cfg: ConfigFullResponse | null,
      +  setBell: (v: boolean) => void,
      +  setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
      +) => {
         const d = cfg?.config?.display ?? {}
       
         setBell(!!d.bell_on_complete)
      +  // Only push the voice record key when the RPC actually returned a
      +  // config payload. ``quietRpc()`` collapses failures to ``null``; if we
      +  // reset the cached shortcut on every null we would clobber a custom
      +  // binding after one transient RPC error until the next config edit
      +  // (Copilot round-8 review on #19835). The mtime-poll loop advances
      +  // ``mtimeRef`` before this call, so staying silent on null preserves
      +  // the last-good state and lets the next successful poll refresh it.
      +  if (setVoiceRecordKey && cfg) {
      +    setVoiceRecordKey(_voiceRecordKeyFromConfig(cfg))
      +  }
         patchUiState({
           busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
           compact: !!d.tui_compact,
      @@ -109,7 +151,13 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
         })
       }
       
      -export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
      +export function useConfigSync({
      +  gw,
      +  setBellOnComplete,
      +  setVoiceEnabled,
      +  setVoiceRecordKey,
      +  sid
      +}: UseConfigSyncOptions) {
         const mtimeRef = useRef(0)
       
         useEffect(() => {
      @@ -125,8 +173,8 @@ export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: U
           quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
             mtimeRef.current = Number(r?.mtime ?? 0)
           })
      -    quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
      -  }, [gw, setBellOnComplete, setVoiceEnabled, sid])
      +    void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
      +  }, [gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid])
       
         useEffect(() => {
           if (!sid) {
      @@ -154,17 +202,18 @@ export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: U
               quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid, confirm: true }).then(
                 r => r && turnController.pushActivity('MCP reloaded after config change')
               )
      -        quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
      +        void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
             })
           }, MTIME_POLL_MS)
       
           return () => clearInterval(id)
      -  }, [gw, setBellOnComplete, sid])
      +  }, [gw, setBellOnComplete, setVoiceRecordKey, sid])
       }
       
       export interface UseConfigSyncOptions {
         gw: GatewayClient
         setBellOnComplete: (v: boolean) => void
         setVoiceEnabled: (v: boolean) => void
      +  setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
         sid: null | string
       }
    • ui-tui/src/app/useInputHandlers.ts
      @@ -348,9 +348,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
             return scrollTranscript(key.pageUp ? -step : step)
           }
       
      -    // Queue-edit cancel beats selection-clear: the queue header explicitly
      -    // promises "Esc cancel", so honoring it takes priority over the implicit
      -    // selection-dismissal convention. Without an active edit, fall through.
      +    // Escape-based voice bindings (ctrl/alt/super+escape) must win before the
      +    // generic Esc handlers below; otherwise queue-edit cancel / selection-clear
      +    // would swallow the chord and /voice would advertise a shortcut that never
      +    // actually toggles recording in those UI states.
      +    if (key.escape && isVoiceToggleKey(key, ch, voice.recordKey)) {
      +      return voiceRecordToggle()
      +    }
      +
      +    // Queue-edit cancel beats selection-clear for plain Esc: the queue header
      +    // explicitly promises "Esc cancel", so honoring it takes priority over the
      +    // implicit selection-dismissal convention. Without an active edit, fall through.
           if (key.escape && cState.queueEditIdx !== null) {
             return cActions.clearIn()
           }
      @@ -439,7 +447,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
             return
           }
       
      -    if (isVoiceToggleKey(key, ch)) {
      +    if (isVoiceToggleKey(key, ch, voice.recordKey)) {
             return voiceRecordToggle()
           }
       
    • ui-tui/src/app/useMainApp.ts
      @@ -18,7 +18,7 @@ import { useGitBranch } from '../hooks/useGitBranch.js'
       import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
       import { appendTranscriptMessage } from '../lib/messages.js'
       import { composerPromptWidth } from '../lib/inputMetrics.js'
      -import { isMac } from '../lib/platform.js'
      +import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
       import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
       import { terminalParityHints } from '../lib/terminalParity.js'
       import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
      @@ -104,6 +104,7 @@ export function useMainApp(gw: GatewayClient) {
         const [voiceEnabled, setVoiceEnabled] = useState(false)
         const [voiceRecording, setVoiceRecording] = useState(false)
         const [voiceProcessing, setVoiceProcessing] = useState(false)
      +  const [voiceRecordKey, setVoiceRecordKey] = useState<ParsedVoiceRecordKey>(DEFAULT_VOICE_RECORD_KEY)
         const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
         const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
         const [goodVibesTick, setGoodVibesTick] = useState(0)
      @@ -394,7 +395,7 @@ export function useMainApp(gw: GatewayClient) {
           }
         }, [ui.busy])
       
      -  useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
      +  useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid })
       
         // Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
         const model = ui.info?.model?.replace(/^.*\//, '') ?? ''
      @@ -539,6 +540,7 @@ export function useMainApp(gw: GatewayClient) {
           terminal: { hasSelection, scrollRef, scrollWithSelection, selection, stdout },
           voice: {
             enabled: voiceEnabled,
      +      recordKey: voiceRecordKey,
             recording: voiceRecording,
             setProcessing: setVoiceProcessing,
             setRecording: setVoiceRecording,
      @@ -642,7 +644,7 @@ export function useMainApp(gw: GatewayClient) {
               },
               slashFlightRef,
               transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange },
      -        voice: { setVoiceEnabled }
      +        voice: { setVoiceEnabled, setVoiceRecordKey }
             }),
           [
             catalog,
      @@ -782,9 +784,10 @@ export function useMainApp(gw: GatewayClient) {
             queueEditIdx: composerState.queueEditIdx,
             queuedDisplay: composerState.queuedDisplay,
             submit,
      -      updateInput: composerActions.setInput
      +      updateInput: composerActions.setInput,
      +      voiceRecordKey
           }),
      -    [cols, composerActions, composerState, empty, pagerPageSize, submit]
      +    [cols, composerActions, composerState, empty, pagerPageSize, submit, voiceRecordKey]
         )
       
         // Pass current progress through unfrozen — streaming update throttling
    • ui-tui/src/components/appLayout.tsx
      @@ -288,6 +288,7 @@ const ComposerPane = memo(function ComposerPane({
                         onSubmit={composer.submit}
                         placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
                         value={composer.input}
      +                  voiceRecordKey={composer.voiceRecordKey}
                       />
                     </Box>
       
    • ui-tui/src/components/textInput.tsx
      @@ -5,7 +5,14 @@ import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'rea
       import { setInputSelection } from '../app/inputSelectionStore.js'
       import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
       import { cursorLayout, offsetFromPosition } from '../lib/inputMetrics.js'
      -import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
      +import {
      +  DEFAULT_VOICE_RECORD_KEY,
      +  isActionMod,
      +  isMac,
      +  isMacActionFallback,
      +  isVoiceToggleKey,
      +  type ParsedVoiceRecordKey
      +} from '../lib/platform.js'
       
       type InkExt = typeof Ink & {
         stringWidth: (s: string) => number
      @@ -239,6 +246,7 @@ export function TextInput({
         onSubmit,
         mask,
         mouseApiRef,
      +  voiceRecordKey = DEFAULT_VOICE_RECORD_KEY,
         placeholder = '',
         focus = true
       }: TextInputProps) {
      @@ -699,6 +707,15 @@ export function TextInput({
           (inp: string, k: Key, event: InputEvent) => {
             const eventRaw = event.keypress.raw
       
      +      // Configured voice shortcut wins over composer-level defaults like
      +      // paste/copy so users who bind voice to ctrl+v / alt+v / cmd+v
      +      // actually get voice toggled instead of a paste (Copilot round-7
      +      // follow-up on #19835). The pass-through predicate is a no-op for
      +      // ordinary typing and plain paste when voice is unbound to 'v'.
      +      if (shouldPassThroughToGlobalHandler(inp, k, voiceRecordKey)) {
      +        return
      +      }
      +
             if (
               eventRaw === '\x1bv' ||
               eventRaw === '\x1bV' ||
      @@ -744,22 +761,6 @@ export function TextInput({
               return
             }
       
      -      // Ctrl chords claimed by useInputHandlers — pass through instead of
      -      // letting them fall into readline-style nav or a literal char insert.
      -      // Ctrl+B = voice toggle, Ctrl+X = delete queued message while editing.
      -      if (
      -        (k.ctrl && inp === 'c') ||
      -        (k.ctrl && inp === 'b') ||
      -        (k.ctrl && inp === 'x') ||
      -        k.tab ||
      -        (k.shift && k.tab) ||
      -        k.pageUp ||
      -        k.pageDown ||
      -        k.escape
      -      ) {
      -        return
      -      }
      -
             if (k.return) {
               if (k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)) {
                 flushParentChange()
      @@ -1041,8 +1042,23 @@ interface TextInputProps {
         onSubmit?: (v: string) => void
         placeholder?: string
         value: string
      +  voiceRecordKey?: ParsedVoiceRecordKey
       }
       
      +export const shouldPassThroughToGlobalHandler = (
      +  input: string,
      +  key: Key,
      +  voiceRecordKey: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY
      +): boolean =>
      +  (key.ctrl && input === 'c') ||
      +  (key.ctrl && input === 'x') ||
      +  key.tab ||
      +  (key.shift && key.tab) ||
      +  key.pageUp ||
      +  key.pageDown ||
      +  key.escape ||
      +  isVoiceToggleKey(key, input, voiceRecordKey)
      +
       export interface TextInputMouseApi {
         dragAt: (row: number, col: number) => void
         end: () => void
    • ui-tui/src/gatewayTypes.ts
      @@ -75,8 +75,14 @@ export interface ConfigDisplayConfig {
         tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
       }
       
      +export interface ConfigVoiceConfig {
      +  // Raw `yaml.safe_load()` value from config; may be non-string if hand-edited.
      +  // Callers must normalize/validate at runtime (parseVoiceRecordKey()).
      +  record_key?: unknown
      +}
      +
       export interface ConfigFullResponse {
      -  config?: { display?: ConfigDisplayConfig }
      +  config?: { display?: ConfigDisplayConfig; voice?: ConfigVoiceConfig }
       }
       
       export interface ConfigMtimeResponse {
      @@ -279,6 +285,7 @@ export interface VoiceToggleResponse {
         available?: boolean
         details?: string
         enabled?: boolean
      +  record_key?: string
         stt_available?: boolean
         tts?: boolean
       }
    • ui-tui/src/lib/platform.ts
      @@ -51,13 +51,359 @@ export const isCopyShortcut = (
           (isMac && key.ctrl && (key.meta || key.super === true)))
       
       /**
      - * Voice recording toggle key (Ctrl+B).
      + * Voice recording toggle key — configurable via ``voice.record_key`` in
      + * ``config.yaml`` (default ``ctrl+b``).
        *
      - * Documented as "Ctrl+B" everywhere: tips.py, config.yaml's voice.record_key
      - * default, and the Python CLI prompt_toolkit handler. We accept raw Ctrl+B on
      - * every platform so the TUI matches those docs. On macOS we additionally
      - * accept Cmd+B (the platform action modifier) so existing macOS muscle memory
      - * keeps working.
      + * Documented in tips.py, the Python CLI prompt_toolkit handler, and the
      + * config.yaml default. The TUI honours the same config knob (#18994);
      + * when ``voice.record_key`` is e.g. ``ctrl+o`` the TUI binds Ctrl+O.
      + *
      + * Only the documented default (``ctrl+b``) additionally accepts the
      + * macOS action modifier (Cmd+B) — custom bindings like ``ctrl+o``
      + * require the literal Ctrl bit so Cmd+O can't steal the shortcut.
        */
      -export const isVoiceToggleKey = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string): boolean =>
      -  (key.ctrl || isActionMod(key)) && ch.toLowerCase() === 'b'
      +export type VoiceRecordKeyMod = 'alt' | 'ctrl' | 'super'
      +
      +/** Named (multi-character) keys we support, matching the CLI's
      + * prompt_toolkit binding shape (``c-space``, ``c-enter``, etc.) so a
      + * config value like ``ctrl+space`` binds in both runtimes. */
      +export type VoiceRecordKeyNamed = 'backspace' | 'delete' | 'enter' | 'escape' | 'space' | 'tab'
      +
      +export interface ParsedVoiceRecordKey {
      +  /** Single character (``'b'``, ``'o'``) when ``named`` is undefined,
      +   * otherwise the named-key token (``'space'``, ``'enter'``…). Kept as
      +   * one field for back-compat with the v1 ``{ ch, mod, raw }`` shape. */
      +  ch: string
      +  mod: VoiceRecordKeyMod
      +  named?: VoiceRecordKeyNamed
      +  raw: string
      +}
      +
      +export const DEFAULT_VOICE_RECORD_KEY: ParsedVoiceRecordKey = {
      +  ch: 'b',
      +  mod: 'ctrl',
      +  raw: 'ctrl+b'
      +}
      +
      +/** Modifier aliases.
      + *
      + * ``meta`` / ``cmd`` / ``command`` are intentionally absent.
      + * hermes-ink sets ``key.meta`` for plain Alt/Option on every platform
      + * AND for Cmd on some legacy macOS terminals (Terminal.app without
      + * kitty-protocol passthrough). Accepting any of those as a literal
      + * modifier would produce a display/binding mismatch — a config like
      + * ``cmd+b`` would render as ``Cmd+B`` but silently fire on Alt+B, or
      + * never fire at all on legacy terminals even though the UI advertises
      + * it (Copilot round-6 review on #19835). Users on modern kitty-style
      + * terminals (iTerm2 CSI-u, Ghostty, Kitty, WezTerm, Alacritty) spell
      + * the platform action modifier ``super`` / ``win``, which match the
      + * unambiguous ``key.super`` bit. macOS users on Terminal.app stick
      + * with the documented ``ctrl+b``.
      + *
      + * Cross-runtime parity: the ``ctrl`` / ``control`` / ``alt`` / ``option`` /
      + * ``opt`` spellings are normalized identically in the classic CLI
      + * (``hermes_cli/voice.py::normalize_voice_record_key_for_prompt_toolkit``)
      + * so one ``voice.record_key`` value binds the same shortcut in both
      + * runtimes (Copilot round-9 review on #19835). The ``super`` /
      + * ``win`` / ``windows`` spellings are TUI-only — prompt_toolkit has no
      + * super modifier, so the CLI falls back to the documented default and
      + * logs a warning at startup (Copilot round-11 review on #19835). */
      +const _MOD_ALIASES: Record<string, VoiceRecordKeyMod> = {
      +  alt: 'alt',
      +  control: 'ctrl',
      +  ctrl: 'ctrl',
      +  option: 'alt',
      +  opt: 'alt',
      +  super: 'super',
      +  win: 'super',
      +  windows: 'super'
      +}
      +
      +/** Map config-string named tokens to the canonical name used at match time.
      + *
      + * Aliases mirror what prompt_toolkit accepts (``return`` ↔ ``enter``,
      + * ``esc`` ↔ ``escape``) so a config that round-trips through the CLI also
      + * binds in the TUI. */
      +const _NAMED_KEY_ALIASES: Record<string, VoiceRecordKeyNamed> = {
      +  backspace: 'backspace',
      +  bs: 'backspace',
      +  del: 'delete',
      +  delete: 'delete',
      +  enter: 'enter',
      +  esc: 'escape',
      +  escape: 'escape',
      +  ret: 'enter',
      +  return: 'enter',
      +  space: 'space',
      +  spc: 'space',
      +  tab: 'tab'
      +}
      +
      +/** ``useInputHandlers()`` intercepts these unconditionally before the
      + * voice check runs, so a binding like ``ctrl+c`` (interrupt),
      + * ``ctrl+d`` (quit), or ``ctrl+l`` (clear screen) would be advertised
      + * in /voice status but never fire push-to-talk. Reject at parse time
      + * so the user gets the documented Ctrl+B instead of a dead shortcut
      + * (Copilot round-4 review on #19835).
      + *
      + * ``ctrl+x`` is intentionally NOT here — it's only claimed during
      + * queue-edit (``queueEditIdx !== null``), so the voice binding works
      + * for most of the session and matches CLI parity for ``ctrl+<letter>``
      + * bindings (Copilot round-8 review on #19835). */
      +const _RESERVED_CTRL_CHARS = new Set(['c', 'd', 'l'])
      +
      +/** On macOS the action-modifier intercepts these editor chords via
      + * ``isCopyShortcut`` / ``isAction`` in ``useInputHandlers()``:
      + *  - super+c → copy
      + *  - super+d → exit
      + *  - super+l → clear screen
      + *  - super+v → paste (also claimed at the TextInput layer)
      + * On Linux/Windows those globals key off Ctrl instead of Super, so
      + * super+<letter> bindings don't collide. Gate the rejection to darwin
      + * at parse time so kitty/CSI-u ``super+<key>`` configs still work for
      + * non-mac users (Copilot round-8 review on #19835). */
      +const _RESERVED_SUPER_CHARS = new Set(['c', 'd', 'l', 'v'])
      +
      +/** On macOS ``isActionMod`` accepts ``key.meta`` as the action
      + * modifier — but hermes-ink reports Alt as ``key.meta`` on many
      + * terminals. So on darwin a configured ``alt+c`` / ``alt+d`` / ``alt+l``
      + * gets swallowed by ``isCopyShortcut`` / ``isAction`` before the voice
      + * check runs. Block at parse time so /voice status doesn't advertise
      + * a shortcut that actually copies / quits / clears (Copilot round-12
      + * review on #19835). */
      +const _RESERVED_ALT_CHARS_MAC = new Set(['c', 'd', 'l'])
      +
      +interface RuntimeKeyEvent {
      +  alt?: boolean
      +  backspace?: boolean
      +  ctrl: boolean
      +  delete?: boolean
      +  escape?: boolean
      +  meta: boolean
      +  return?: boolean
      +  shift?: boolean
      +  super?: boolean
      +  tab?: boolean
      +}
      +
      +/** Match an ink ``key`` event against a parsed named key. The ink runtime
      + * sets one boolean per named key; ``space`` is a printable char so it
      + * arrives as ``ch === ' '`` rather than a dedicated ``key.space`` flag. */
      +const _matchesNamedKey = (
      +  named: VoiceRecordKeyNamed,
      +  key: RuntimeKeyEvent,
      +  ch: string
      +): boolean => {
      +  switch (named) {
      +    case 'backspace':
      +      return key.backspace === true
      +    case 'delete':
      +      return key.delete === true
      +    case 'enter':
      +      return key.return === true
      +    case 'escape':
      +      return key.escape === true
      +    case 'space':
      +      return ch === ' '
      +    case 'tab':
      +      return key.tab === true
      +  }
      +}
      +
      +/**
      + * Parse a config-string voice record key like ``ctrl+b`` / ``alt+r`` /
      + * ``ctrl+space`` into ``{mod, ch, named?}``. Accepts single characters
      + * AND the named tokens declared in ``_NAMED_KEY_ALIASES`` (``space``,
      + * ``enter``/``return``, ``tab``, ``escape``/``esc``, ``backspace``,
      + * ``delete``) — matching the keys prompt_toolkit accepts on the CLI
      + * side via the ``c-<name>`` rewrite in ``cli.py``.
      + *
      + * Accepts ``unknown`` because the source is raw YAML via
      + * ``config.get full`` — a hand-edited ``voice.record_key: 1`` or
      + * ``voice.record_key: true`` would otherwise crash ``.trim()`` on a
      + * non-string scalar (Copilot round-3 review on #19835). Non-string /
      + * empty / unrecognised values fall back to the documented Ctrl+B
      + * default so a typo never silently disables the shortcut.
      + */
      +export const parseVoiceRecordKey = (raw: unknown): ParsedVoiceRecordKey => {
      +  if (typeof raw !== 'string') {
      +    return DEFAULT_VOICE_RECORD_KEY
      +  }
      +
      +  const lower = raw.trim().toLowerCase()
      +
      +  if (!lower) {
      +    return DEFAULT_VOICE_RECORD_KEY
      +  }
      +
      +  const parts = lower.split('+').map(p => p.trim()).filter(Boolean)
      +
      +  if (!parts.length) {
      +    return DEFAULT_VOICE_RECORD_KEY
      +  }
      +
      +  const last = parts[parts.length - 1]
      +  const modCandidates = parts.slice(0, -1)
      +
      +  // Reject multi-modifier chords (``ctrl+alt+r``, ``cmd+ctrl+b``) rather
      +  // than silently dropping the extra modifier — the previous
      +  // single-token validator made a typo bind a different shortcut than
      +  // the user configured (Copilot round-3 review on #19835). The classic
      +  // CLI only supports single-modifier bindings via prompt_toolkit's
      +  // ``c-x`` / ``a-x`` rewrite in ``cli.py``, so this matches CLI parity.
      +  if (modCandidates.length > 1) {
      +    return DEFAULT_VOICE_RECORD_KEY
      +  }
      +
      +  // Require an explicit modifier. A bare ``o`` / ``space`` / ``escape``
      +  // has no sensible mapping: the CLI's prompt_toolkit binds the raw
      +  // key (no rewrite) so bare-char configs would silently diverge
      +  // between the two runtimes (Copilot round-4 review on #19835).
      +  // Fall back to the documented default.
      +  if (modCandidates.length === 0) {
      +    return DEFAULT_VOICE_RECORD_KEY
      +  }
      +
      +  const norm = _MOD_ALIASES[modCandidates[0]]
      +
      +  // Unknown modifier token (e.g. bare ``meta+b`` which is ambiguous on
      +  // the wire) falls back to the documented default rather than
      +  // silently coercing to Ctrl and producing a misleading bind.
      +  if (!norm) {
      +    return DEFAULT_VOICE_RECORD_KEY
      +  }
      +
      +  const mod = norm
      +
      +  // Block bindings the TUI input handler intercepts before the voice
      +  // check — ``ctrl+c`` / ``ctrl+d`` / ``ctrl+l`` would never actually
      +  // fire push-to-talk, so advertising them in /voice status is a lie.
      +  if (mod === 'ctrl' && last.length === 1 && _RESERVED_CTRL_CHARS.has(last)) {
      +    return DEFAULT_VOICE_RECORD_KEY
      +  }
      +
      +  // Same for ``super+c`` / ``super+d`` / ``super+l`` / ``super+v`` on
      +  // macOS only — those are copy / exit / clear / paste and get claimed
      +  // by ``isCopyShortcut`` / ``isAction`` / the TextInput paste layer
      +  // before voice has a chance to toggle. On Linux/Windows the TUI
      +  // globals key off Ctrl (not Super), so kitty/CSI-u ``super+<letter>``
      +  // bindings stay usable for non-mac users.
      +  if (isMac && mod === 'super' && last.length === 1 && _RESERVED_SUPER_CHARS.has(last)) {
      +    return DEFAULT_VOICE_RECORD_KEY
      +  }
      +
      +  // On macOS hermes-ink reports Alt as ``key.meta``, which ``isActionMod``
      +  // accepts as the mac action modifier. So ``alt+c`` / ``alt+d`` / ``alt+l``
      +  // collide with copy / exit / clear in ``useInputHandlers()`` before the
      +  // voice check. Reject at parse time on darwin only — non-mac ``alt+<letter>``
      +  // bindings are still usable (Copilot round-12 review on #19835).
      +  if (isMac && mod === 'alt' && last.length === 1 && _RESERVED_ALT_CHARS_MAC.has(last)) {
      +    return DEFAULT_VOICE_RECORD_KEY
      +  }
      +
      +  if (last.length === 1) {
      +    return { ch: last, mod, raw: lower }
      +  }
      +
      +  const named = _NAMED_KEY_ALIASES[last]
      +
      +  if (named) {
      +    return { ch: named, mod, named, raw: lower }
      +  }
      +
      +  // Unknown multi-character token (e.g. typo'd ``ctrl+spcae``) — fall back
      +  // to the doc default rather than silently disabling the binding.
      +  return DEFAULT_VOICE_RECORD_KEY
      +}
      +
      +/** Render a parsed key back as ``Ctrl+B`` / ``Ctrl+Space`` for status text.
      + *
      + * Platform-aware for the ``super`` modifier: renders ``Cmd`` on macOS and
      + * ``Super`` elsewhere. Previously rendered ``Cmd`` universally, which told
      + * Linux/Windows users the wrong modifier to press (Copilot review, round
      + * 2 on #19835). */
      +export const formatVoiceRecordKey = (parsed: ParsedVoiceRecordKey): string => {
      +  const modLabel =
      +    parsed.mod === 'super' ? (isMac ? 'Cmd' : 'Super') : parsed.mod[0].toUpperCase() + parsed.mod.slice(1)
      +  // Named tokens render in title case (Ctrl+Space, Ctrl+Enter); single
      +  // chars render upper-case to match the existing Ctrl+B convention.
      +  const keyLabel = parsed.named
      +    ? parsed.named[0].toUpperCase() + parsed.named.slice(1)
      +    : parsed.ch.toUpperCase()
      +
      +  return `${modLabel}+${keyLabel}`
      +}
      +
      +/** Whether the parsed binding is the documented default (ctrl+b).
      + *
      + * Compare on the parsed spec rather than ``raw`` so semantically-equal
      + * aliases (``control+b``, ``ctrl + b``) still get the macOS Cmd+B
      + * muscle-memory fallback (Copilot review, round 2 on #19835). */
      +const _isDefaultVoiceKey = (parsed: ParsedVoiceRecordKey): boolean =>
      +  parsed.mod === DEFAULT_VOICE_RECORD_KEY.mod &&
      +  parsed.ch === DEFAULT_VOICE_RECORD_KEY.ch &&
      +  parsed.named === DEFAULT_VOICE_RECORD_KEY.named
      +
      +export const isVoiceToggleKey = (
      +  key: RuntimeKeyEvent,
      +  ch: string,
      +  configured: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY
      +): boolean => {
      +  // Match the configured key first (single-char compare or named-key
      +  // event-property check). Bail out before evaluating modifier shape
      +  // so the wrong key never reaches the modifier guard.
      +  if (configured.named) {
      +    if (!_matchesNamedKey(configured.named, key, ch)) {
      +      return false
      +    }
      +  } else if (ch.toLowerCase() !== configured.ch) {
      +    return false
      +  }
      +
      +  // The parser rejects multi-modifier configs (``ctrl+shift+b`` etc.),
      +  // so at match time Shift must always be clear — otherwise
      +  // ``ctrl+tab`` would also fire on Ctrl+Shift+Tab and ``alt+enter``
      +  // on Alt+Shift+Enter, triggering a different chord than configured
      +  // (Copilot round-5 review on #19835).
      +  if (key.shift === true) {
      +    return false
      +  }
      +
      +  switch (configured.mod) {
      +    case 'alt':
      +      // Most terminals surface Alt as either ``alt`` or ``meta``; accept
      +      // both so the binding works across xterm-style and kitty-style
      +      // protocols. Guard against ctrl/super bits so a chord like
      +      // Ctrl+Alt+<key> or Cmd+Alt+<key> doesn't spuriously fire the
      +      // alt binding.
      +      //
      +      // Bare Escape on hermes-ink can arrive as ``key.meta=true`` on some
      +      // terminals, so a configured ``alt+escape`` must not match that shape;
      +      // require an explicit alt bit for escape chords (Copilot round-7
      +      // follow-up on #19835).
      +      return (key.alt === true || (key.meta && key.escape !== true)) && !key.ctrl && key.super !== true
      +    case 'ctrl':
      +      // Require the Ctrl bit AND a clear Alt/Super so a chord like
      +      // Ctrl+Alt+<key> / Ctrl+Cmd+<key> doesn't spuriously match
      +      // ``ctrl+<key>`` (Copilot round-6 review on #19835).
      +      //
      +      // The documented default (``ctrl+b``) additionally accepts the
      +      // explicit ``key.super`` bit on macOS for Cmd+B muscle memory —
      +      // but ONLY ``key.super`` (kitty-style), never ``key.meta``, since
      +      // ``key.meta`` is hermes-ink's Alt signal and accepting it would
      +      // fire the binding on Alt+B.
      +      if (key.ctrl) {
      +        return !key.alt && !key.meta && key.super !== true
      +      }
      +
      +      return _isDefaultVoiceKey(configured) && isMac && key.super === true && !key.alt && !key.meta
      +    case 'super':
      +      // Require the explicit ``key.super`` bit (kitty-style protocol)
      +      // AND clear Ctrl/Alt/Meta so Ctrl+Cmd+X or Alt+Cmd+X don't
      +      // spuriously fire the super binding (Copilot round-6 review on
      +      // #19835). Legacy-terminal users whose Cmd arrives as
      +      // ``key.meta`` need a kitty-protocol terminal — see the
      +      // _MOD_ALIASES doc-comment for the rationale.
      +      return key.super === true && !key.ctrl && !key.alt && !key.meta
      +  }
      +}

4 PRs closed without merging

From the close comments: done elsewhere 3 · other 1

  • #11397 fix(voice): reject alt+ modifier in voice.record_key, fall back to ctrl+b · done elsewhere → #19835, #11387
  • #11440 fix(cli): support alt push-to-talk record keys · done elsewhere → #19835, #11387
  • #11859 fix(voice): alt+ record_key crashes on startup with ValueError · other → #11440
  • #44230 fix(cli): bind alt voice record keys with escape sequences · done elsewhere → #11387
Closed issues · 1
  • Closed#11387voice.record_key: alt+* crashes startup — prompt_toolkit rejects a- prefix
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.33: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#19835
Closed issues
1
Attempts
4 PRs closed without merging
Fix link checked
4 May 2026

For your agent

The same facts, typed.