From 3504bd401b8d95abb47e7ea705b373553dcc2a9b Mon Sep 17 00:00:00 2001 From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:19:50 +0300 Subject: [PATCH] fix(tui): route Ctrl+B to voice toggle, not composer input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user runs /voice and then presses Ctrl+B in the TUI, three handlers collaborate to consume the chord and none of them dispatch voice.record: - isAction() is platform-aware — on macOS it requires Cmd (meta/super), so Ctrl+B fails the match in useInputHandlers and never triggers voiceStart/voiceStop. - TextInput's Ctrl+B pass-through list doesn't include 'b', so the keystroke falls through to the wordMod backward-word branch on Linux and to the printable-char insertion branch on macOS — the latter is exactly what timmie reported ("enters a b into the tui"). - /voice emits "voice: on" with no hint, so the user has no way to know Ctrl+B is the recording toggle. Introduces isVoiceToggleKey(key, ch) in lib/platform.ts that matches raw Ctrl+B on every platform (mirrors tips.py and config.yaml's voice.record_key default) and additionally accepts Cmd+B on macOS so existing muscle memory keeps working. Wires it into useInputHandlers, adds Ctrl+B to TextInput's pass-through list so the global handler actually receives the chord, and appends "press Ctrl+B to record" to the /voice on message. Empirically verified with hermes --tui: Ctrl+B no longer leaks 'b' into the composer and now dispatches the voice.record RPC (the downstream ImportError for hermes_cli.voice is a separate upstream bug — follow-up patch). --- ui-tui/src/__tests__/platform.test.ts | 30 ++++++++++++++++++++++++ ui-tui/src/app/slash/commands/session.ts | 2 +- ui-tui/src/app/useInputHandlers.ts | 4 ++-- ui-tui/src/components/textInput.tsx | 14 ++++++++++- ui-tui/src/lib/platform.ts | 14 +++++++++++ 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts index dbb6f0fe6a..8995b9c6fc 100644 --- a/ui-tui/src/__tests__/platform.test.ts +++ b/ui-tui/src/__tests__/platform.test.ts @@ -31,6 +31,36 @@ describe('platform action modifier', () => { }) }) +describe('isVoiceToggleKey', () => { + it('matches raw Ctrl+B on macOS (doc-default across platforms)', async () => { + const { isVoiceToggleKey } = await importPlatform('darwin') + + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true) + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'B')).toBe(true) + }) + + it('matches Cmd+B on macOS (preserve platform muscle memory)', async () => { + const { isVoiceToggleKey } = await importPlatform('darwin') + + expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b')).toBe(true) + expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b')).toBe(true) + }) + + it('matches Ctrl+B on non-macOS platforms', async () => { + const { isVoiceToggleKey } = await importPlatform('linux') + + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true) + }) + + it('does not match unmodified b or other Ctrl combos', async () => { + const { isVoiceToggleKey } = await importPlatform('darwin') + + expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, 'b')).toBe(false) + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'a')).toBe(false) + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'c')).toBe(false) + }) +}) + describe('isMacActionFallback', () => { it('routes raw Ctrl+K and Ctrl+W to readline kill-to-end / delete-word on macOS', async () => { const { isMacActionFallback } = await importPlatform('darwin') diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 5f17667f03..90a1beb3f0 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -192,7 +192,7 @@ export const sessionCommands: SlashCommand[] = [ ctx.gateway.rpc('voice.toggle', { action }).then( ctx.guarded(r => { ctx.voice.setVoiceEnabled(!!r.enabled) - ctx.transcript.sys(`voice: ${r.enabled ? 'on' : 'off'}`) + ctx.transcript.sys(`voice: ${r.enabled ? 'on — press Ctrl+B to record' : 'off'}`) }) ) } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 72cd5b9e5a..cfc3eed7c8 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -8,7 +8,7 @@ import type { SudoRespondResponse, VoiceRecordResponse } from '../gatewayTypes.js' -import { isAction, isMac } from '../lib/platform.js' +import { isAction, isMac, isVoiceToggleKey } from '../lib/platform.js' import { getInputSelection } from './inputSelectionStore.js' import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' @@ -370,7 +370,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return actions.newSession() } - if (isAction(key, ch, 'b')) { + if (isVoiceToggleKey(key, ch)) { return voice.recording ? voiceStop() : voiceStart() } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index e91143c00b..394c3c67af 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -623,7 +623,19 @@ export function TextInput({ return } - if ((k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) { + // Ctrl+B is the documented voice-recording toggle (see platform.ts → + // isVoiceToggleKey). Pass it through so the app-level handler in + // useInputHandlers receives it instead of being swallowed here as + // either backward-word nav (line below) or a literal 'b' insertion. + if ( + (k.ctrl && inp === 'c') || + (k.ctrl && inp === 'b') || + k.tab || + (k.shift && k.tab) || + k.pageUp || + k.pageDown || + k.escape + ) { return } diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index ab694baaf7..9e85da16f8 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -33,3 +33,17 @@ export const isMacActionFallback = ( /** Match action-modifier + a single character (case-insensitive). */ export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string, target: string): boolean => isActionMod(key) && ch.toLowerCase() === target + +/** + * Voice recording toggle key (Ctrl+B). + * + * Documented as "Ctrl+B" everywhere: tips.py, config.yaml's voice.record_key + * default, and the Python CLI prompt_toolkit handler. We accept raw Ctrl+B on + * every platform so the TUI matches those docs. On macOS we additionally + * accept Cmd+B (the platform action modifier) so existing macOS muscle memory + * keeps working. + */ +export const isVoiceToggleKey = ( + key: { ctrl: boolean; meta: boolean; super?: boolean }, + ch: string +): boolean => (key.ctrl || isActionMod(key)) && ch.toLowerCase() === 'b'