({
voiceHandleKeyEvent,
stripTrailing,
resetAnchor,
isActive
}: {
voiceHandleKeyEvent: (fallbackMs?: number) => void;
stripTrailing: (maxStrip: number, opts?: StripOpts) => number;
resetAnchor: () => void;
isActive: boolean;
})
| 371 | * (discrete sequences, no hold). Validation warns on these. |
| 372 | */ |
| 373 | export function useVoiceKeybindingHandler({ |
| 374 | voiceHandleKeyEvent, |
| 375 | stripTrailing, |
| 376 | resetAnchor, |
| 377 | isActive |
| 378 | }: { |
| 379 | voiceHandleKeyEvent: (fallbackMs?: number) => void; |
| 380 | stripTrailing: (maxStrip: number, opts?: StripOpts) => number; |
| 381 | resetAnchor: () => void; |
| 382 | isActive: boolean; |
| 383 | }): { |
| 384 | handleKeyDown: (e: KeyboardEvent) => void; |
| 385 | } { |
| 386 | const getVoiceState = useGetVoiceState(); |
| 387 | const setVoiceState = useSetVoiceState(); |
| 388 | const keybindingContext = useOptionalKeybindingContext(); |
| 389 | const isModalOverlayActive = useIsModalOverlayActive(); |
| 390 | // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant |
| 391 | const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; |
| 392 | const voiceState = feature('VOICE_MODE') ? |
| 393 | // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant |
| 394 | useVoiceState(s => s.voiceState) : 'idle'; |
| 395 | |
| 396 | // Find the configured key for voice:pushToTalk from keybinding context. |
| 397 | // Forward iteration with last-wins (matching the resolver): if a later |
| 398 | // Chat binding overrides the same chord with null or a different |
| 399 | // action, the voice binding is discarded and null is returned — the |
| 400 | // user explicitly disabled hold-to-talk via binding override, so |
| 401 | // don't second-guess them with a fallback. The DEFAULT is only used |
| 402 | // when there's no provider at all. Context filter is required — space |
| 403 | // is also bound in Settings/Confirmation/Plugin (select:accept etc.); |
| 404 | // without the filter those would null out the default. |
| 405 | const voiceKeystroke = useMemo((): ParsedKeystroke | null => { |
| 406 | if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE; |
| 407 | let result: ParsedKeystroke | null = null; |
| 408 | for (const binding of keybindingContext.bindings) { |
| 409 | if (binding.context !== 'Chat') continue; |
| 410 | if (binding.chord.length !== 1) continue; |
| 411 | const ks = binding.chord[0]; |
| 412 | if (!ks) continue; |
| 413 | if (binding.action === 'voice:pushToTalk') { |
| 414 | result = ks; |
| 415 | } else if (result !== null && keystrokesEqual(ks, result)) { |
| 416 | // A later binding overrides this chord (null unbind or reassignment) |
| 417 | result = null; |
| 418 | } |
| 419 | } |
| 420 | return result; |
| 421 | }, [keybindingContext]); |
| 422 | |
| 423 | // If the binding is a bare (unmodified) single printable char, terminal |
| 424 | // auto-repeat may batch N keystrokes into one input event (e.g. "vvv"), |
| 425 | // and the char flows into the text input — we need flow-through + strip. |
| 426 | // Modifier combos (meta+k, ctrl+x) also auto-repeat (the letter part |
| 427 | // repeats) but don't insert text, so they're swallowed from the first |
| 428 | // press with no stripping needed. matchesKeyboardEvent handles those. |
| 429 | const bareChar = voiceKeystroke !== null && voiceKeystroke.key.length === 1 && !voiceKeystroke.ctrl && !voiceKeystroke.alt && !voiceKeystroke.shift && !voiceKeystroke.meta && !voiceKeystroke.super ? voiceKeystroke.key : null; |
| 430 | const rapidCountRef = useRef(0); |
no test coverage detected