fix(tui): route Ctrl+B to voice toggle, not composer input
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).
This commit is contained in:
@@ -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', () => {
|
describe('isMacActionFallback', () => {
|
||||||
it('routes raw Ctrl+K and Ctrl+W to readline kill-to-end / delete-word on macOS', async () => {
|
it('routes raw Ctrl+K and Ctrl+W to readline kill-to-end / delete-word on macOS', async () => {
|
||||||
const { isMacActionFallback } = await importPlatform('darwin')
|
const { isMacActionFallback } = await importPlatform('darwin')
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export const sessionCommands: SlashCommand[] = [
|
|||||||
ctx.gateway.rpc<VoiceToggleResponse>('voice.toggle', { action }).then(
|
ctx.gateway.rpc<VoiceToggleResponse>('voice.toggle', { action }).then(
|
||||||
ctx.guarded<VoiceToggleResponse>(r => {
|
ctx.guarded<VoiceToggleResponse>(r => {
|
||||||
ctx.voice.setVoiceEnabled(!!r.enabled)
|
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'}`)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
SudoRespondResponse,
|
SudoRespondResponse,
|
||||||
VoiceRecordResponse
|
VoiceRecordResponse
|
||||||
} from '../gatewayTypes.js'
|
} from '../gatewayTypes.js'
|
||||||
import { isAction, isMac } from '../lib/platform.js'
|
import { isAction, isMac, isVoiceToggleKey } from '../lib/platform.js'
|
||||||
|
|
||||||
import { getInputSelection } from './inputSelectionStore.js'
|
import { getInputSelection } from './inputSelectionStore.js'
|
||||||
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
|
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
|
||||||
@@ -370,7 +370,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||||||
return actions.newSession()
|
return actions.newSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAction(key, ch, 'b')) {
|
if (isVoiceToggleKey(key, ch)) {
|
||||||
return voice.recording ? voiceStop() : voiceStart()
|
return voice.recording ? voiceStop() : voiceStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -623,7 +623,19 @@ export function TextInput({
|
|||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,3 +33,17 @@ export const isMacActionFallback = (
|
|||||||
/** Match action-modifier + a single character (case-insensitive). */
|
/** Match action-modifier + a single character (case-insensitive). */
|
||||||
export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string, target: string): boolean =>
|
export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string, target: string): boolean =>
|
||||||
isActionMod(key) && ch.toLowerCase() === target
|
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'
|
||||||
|
|||||||
Reference in New Issue
Block a user