(e: KeyboardEvent)
| 466 | } |
| 467 | }, [voiceState, setVoiceState]); |
| 468 | const handleKeyDown = (e: KeyboardEvent): void => { |
| 469 | if (!voiceEnabled) return; |
| 470 | |
| 471 | // PromptInput is not a valid transcript target — let the hold key |
| 472 | // flow through instead of swallowing it into stale refs (#33556). |
| 473 | // Two distinct unmount/unfocus paths (both needed): |
| 474 | // - !isActive: local-jsx command hid PromptInput (shouldHidePromptInput) |
| 475 | // without registering an overlay — e.g. /install-github-app, |
| 476 | // /plugin. Mirrors CommandKeybindingHandlers' isActive gate. |
| 477 | // - isModalOverlayActive: overlay (permission dialog, Select with |
| 478 | // onCancel) has focus; PromptInput is mounted but focus=false. |
| 479 | if (!isActive || isModalOverlayActive) return; |
| 480 | |
| 481 | // null means the user overrode the default (null-unbind/reassign) — |
| 482 | // hold-to-talk is disabled via binding. To toggle the feature |
| 483 | // itself, use /voice. |
| 484 | if (voiceKeystroke === null) return; |
| 485 | |
| 486 | // Match the configured key. Bare chars match by content (handles |
| 487 | // batched auto-repeat like "vvv") with a modifier reject so e.g. |
| 488 | // ctrl+v doesn't trip a "v" binding. Modifier combos go through |
| 489 | // matchesKeyboardEvent (one event per repeat, no batching). |
| 490 | let repeatCount: number; |
| 491 | if (bareChar !== null) { |
| 492 | if (e.ctrl || e.meta || e.shift) return; |
| 493 | // When bound to space, also accept U+3000 (full-width space) — |
| 494 | // CJK IMEs emit it for the same physical key. |
| 495 | const normalized = bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key; |
| 496 | // Fast-path: normal typing (any char that isn't the bound one) |
| 497 | // bails here without allocating. The repeat() check only matters |
| 498 | // for batched auto-repeat (input.length > 1) which is rare. |
| 499 | if (normalized[0] !== bareChar) return; |
| 500 | if (normalized.length > 1 && normalized !== bareChar.repeat(normalized.length)) return; |
| 501 | repeatCount = normalized.length; |
| 502 | } else { |
| 503 | if (!matchesKeyboardEvent(e, voiceKeystroke)) return; |
| 504 | repeatCount = 1; |
| 505 | } |
| 506 | |
| 507 | // Guard: only swallow keypresses when recording was triggered by |
| 508 | // key-hold. Focus-mode recording also sets voiceState to 'recording', |
| 509 | // but keypresses should flow through normally (voiceHandleKeyEvent |
| 510 | // returns early for focus-triggered sessions). We also check voiceState |
| 511 | // from the store so that if voiceHandleKeyEvent() fails to transition |
| 512 | // state (module not loaded, stream unavailable) we don't permanently |
| 513 | // swallow keypresses. |
| 514 | const currentVoiceState = getVoiceState().voiceState; |
| 515 | if (isHoldActiveRef.current && currentVoiceState !== 'idle') { |
| 516 | // Already recording — swallow continued keypresses and forward |
| 517 | // to voice for release detection. For bare chars, defensively |
| 518 | // strip in case the text input handler fired before this one |
| 519 | // (listener order is not guaranteed). Modifier combos don't |
| 520 | // insert text, so nothing to strip. |
| 521 | e.stopImmediatePropagation(); |
| 522 | if (bareChar !== null) { |
| 523 | stripTrailing(repeatCount, { |
| 524 | char: bareChar, |
| 525 | floor: recordingFloorRef.current |
no test coverage detected