fix(tui): pass configured voice shortcut through TextInput layer

Thread the live parsed voiceRecordKey into TextInput so configured voice.record_key chords bubble to useInputHandlers instead of being consumed as editor input. This removes the last hardcoded Ctrl+B pass-through in the composer path while preserving existing global control chord behavior.
This commit is contained in:
Brooklyn Nicholson 2026-05-04 14:49:35 -05:00
parent bae5a1bbe0
commit ad579015b2
5 changed files with 75 additions and 16 deletions

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { shouldPassThroughToGlobalHandler } from '../components/textInput.js'
import { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } from '../lib/platform.js'
const key = (overrides: Record<string, unknown> = {}) =>
({ ctrl: false, meta: false, ...overrides }) as any
describe('shouldPassThroughToGlobalHandler', () => {
it('passes through the configured voice shortcut while composer is focused', () => {
expect(
shouldPassThroughToGlobalHandler('o', key({ ctrl: true }), parseVoiceRecordKey('ctrl+o'))
).toBe(true)
expect(
shouldPassThroughToGlobalHandler('r', key({ meta: true }), parseVoiceRecordKey('alt+r'))
).toBe(true)
expect(
shouldPassThroughToGlobalHandler(' ', key({ ctrl: true }), parseVoiceRecordKey('ctrl+space'))
).toBe(true)
expect(
shouldPassThroughToGlobalHandler('', key({ ctrl: true, return: true }), parseVoiceRecordKey('ctrl+enter'))
).toBe(true)
})
it('keeps the legacy default pass-through when no custom key is provided', () => {
expect(shouldPassThroughToGlobalHandler('b', key({ ctrl: true }), DEFAULT_VOICE_RECORD_KEY)).toBe(true)
expect(shouldPassThroughToGlobalHandler('b', key({ ctrl: true }))).toBe(true)
})
it('does not swallow ordinary typing keys', () => {
expect(shouldPassThroughToGlobalHandler('h', key(), parseVoiceRecordKey('ctrl+o'))).toBe(false)
expect(shouldPassThroughToGlobalHandler('o', key(), parseVoiceRecordKey('ctrl+o'))).toBe(false)
})
it('always passes through non-voice global control keys', () => {
expect(shouldPassThroughToGlobalHandler('c', key({ ctrl: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('x', key({ ctrl: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ escape: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ tab: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ pageUp: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ pageDown: true }))).toBe(true)
})
})

View file

@ -321,6 +321,7 @@ export interface AppLayoutComposerProps {
queuedDisplay: string[]
submit: (value: string) => void
updateInput: StateSetter<string>
voiceRecordKey: ParsedVoiceRecordKey
}
export interface AppLayoutProgressProps {

View file

@ -784,9 +784,10 @@ export function useMainApp(gw: GatewayClient) {
queueEditIdx: composerState.queueEditIdx,
queuedDisplay: composerState.queuedDisplay,
submit,
updateInput: composerActions.setInput
updateInput: composerActions.setInput,
voiceRecordKey
}),
[cols, composerActions, composerState, empty, pagerPageSize, submit]
[cols, composerActions, composerState, empty, pagerPageSize, submit, voiceRecordKey]
)
// Pass current progress through unfrozen — streaming update throttling

View file

@ -288,6 +288,7 @@ const ComposerPane = memo(function ComposerPane({
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input}
voiceRecordKey={composer.voiceRecordKey}
/>
</Box>

View file

@ -5,7 +5,14 @@ import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'rea
import { setInputSelection } from '../app/inputSelectionStore.js'
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
import { cursorLayout, offsetFromPosition } from '../lib/inputMetrics.js'
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
import {
DEFAULT_VOICE_RECORD_KEY,
isActionMod,
isMac,
isMacActionFallback,
isVoiceToggleKey,
type ParsedVoiceRecordKey
} from '../lib/platform.js'
type InkExt = typeof Ink & {
stringWidth: (s: string) => number
@ -239,6 +246,7 @@ export function TextInput({
onSubmit,
mask,
mouseApiRef,
voiceRecordKey = DEFAULT_VOICE_RECORD_KEY,
placeholder = '',
focus = true
}: TextInputProps) {
@ -744,19 +752,9 @@ export function TextInput({
return
}
// Ctrl chords claimed by useInputHandlers — pass through instead of
// letting them fall into readline-style nav or a literal char insert.
// Ctrl+B = voice toggle, Ctrl+X = delete queued message while editing.
if (
(k.ctrl && inp === 'c') ||
(k.ctrl && inp === 'b') ||
(k.ctrl && inp === 'x') ||
k.tab ||
(k.shift && k.tab) ||
k.pageUp ||
k.pageDown ||
k.escape
) {
// Chords claimed by useInputHandlers — pass through instead of letting
// them fall into text-editing behavior here.
if (shouldPassThroughToGlobalHandler(inp, k, voiceRecordKey)) {
return
}
@ -1041,8 +1039,23 @@ interface TextInputProps {
onSubmit?: (v: string) => void
placeholder?: string
value: string
voiceRecordKey?: ParsedVoiceRecordKey
}
export const shouldPassThroughToGlobalHandler = (
input: string,
key: Key,
voiceRecordKey: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY
): boolean =>
(key.ctrl && input === 'c') ||
(key.ctrl && input === 'x') ||
key.tab ||
(key.shift && key.tab) ||
key.pageUp ||
key.pageDown ||
key.escape ||
isVoiceToggleKey(key, input, voiceRecordKey)
export interface TextInputMouseApi {
dragAt: (row: number, col: number) => void
end: () => void