From ad579015b2e5f2ad939ba73b9311e895e9ee045d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 4 May 2026 14:49:35 -0500 Subject: [PATCH] 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. --- .../__tests__/textInputPassThrough.test.ts | 43 +++++++++++++++++++ ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/useMainApp.ts | 5 ++- ui-tui/src/components/appLayout.tsx | 1 + ui-tui/src/components/textInput.tsx | 41 ++++++++++++------ 5 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 ui-tui/src/__tests__/textInputPassThrough.test.ts diff --git a/ui-tui/src/__tests__/textInputPassThrough.test.ts b/ui-tui/src/__tests__/textInputPassThrough.test.ts new file mode 100644 index 00000000000..5988580f9b9 --- /dev/null +++ b/ui-tui/src/__tests__/textInputPassThrough.test.ts @@ -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 = {}) => + ({ 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) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 2ef0d849513..dfe88fc040e 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -321,6 +321,7 @@ export interface AppLayoutComposerProps { queuedDisplay: string[] submit: (value: string) => void updateInput: StateSetter + voiceRecordKey: ParsedVoiceRecordKey } export interface AppLayoutProgressProps { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 59b7bb1a3ad..b39cc29a323 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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 diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 8c2d210ca1a..ec60726ed3b 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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} /> diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 3008f0baf4c..c12ddeb3b1d 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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