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