From 523181585432f05c26e78bd2253f4180c7c49ca7 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Sun, 3 May 2026 09:14:38 +1000 Subject: [PATCH] fix(tui): respect voice.record_key config instead of hardcoded Ctrl+B MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classic CLI loaded ``voice.record_key`` from config.yaml and bound the prompt-toolkit handler dynamically (``cli.py`` paths). The new TUI hard- coded ``Ctrl+B`` everywhere — ``isVoiceToggleKey`` (input handler), ``/voice status`` ("Record key: Ctrl+B"), and ``/voice on`` ("Ctrl+B to start/stop recording"). A user who set ``voice.record_key: ctrl+o`` (or any other key) saw the documented config silently ignored — only Ctrl+B worked, the displayed shortcut lied about it. Wire the configured key end to end through the existing channels: * **Backend** (``tui_gateway/server.py``): ``voice.toggle`` action=status AND action=on/off responses now include ``record_key``, sourced from ``config.get('voice', {}).get('record_key', 'ctrl+b')``. * **Backend types** (``ui-tui/src/gatewayTypes.ts``): ``ConfigFullResponse`` now exposes ``config.voice.record_key`` and ``VoiceToggleResponse`` carries ``record_key`` so the TUI can both bind and display it. * **Frontend parser/formatter** (``ui-tui/src/lib/platform.ts``): ``parseVoiceRecordKey()`` accepts ``ctrl+b`` / ``alt+r`` / ``cmd+space`` and the common aliases (``option``, ``cmd``, ``win``, …); falls back to the documented Ctrl+B for empty / multi-character / malformed input so a typo never silently disables the shortcut. ``formatVoiceRecordKey()`` renders for status text. ``isVoiceToggleKey`` now takes a parsed ``ParsedVoiceRecordKey`` argument; the hardcoded ``ch === 'b'`` is gone. Default arg keeps existing call sites back-compat. * **Hydration** (``ui-tui/src/app/useConfigSync.ts``, ``useMainApp.ts``): startup ``config.get full`` already runs; extract ``cfg.voice.record_key`` from it, parse, push into a new ``voiceRecordKey`` state, and forward to the input handler ctx (``InputHandlerContext.voice.recordKey``). Mtime-poll path also re-applies the parsed key so a hand-edit of config.yaml takes effect the next tick — matches existing behaviour for display options. * **Input handler** (``ui-tui/src/app/useInputHandlers.ts``): ``isVoiceToggleKey(key, ch, voice.recordKey)`` so the configured binding fires. * **Slash command** (``ui-tui/src/app/slash/commands/session.ts``): ``/voice status`` and ``/voice on`` use ``formatVoiceRecordKey`` on the response's ``record_key`` instead of the hardcoded label. Tests: * ``parseVoiceRecordKey`` covers ctrl/alt/cmd/super aliases, multi-char rejection, and empty fallback. * ``formatVoiceRecordKey`` covers the doc examples (``Ctrl+B``, ``Ctrl+O``, ``Alt+R``, ``Cmd+B``). * ``isVoiceToggleKey`` regression: ``ctrl+o`` configured → only ``o`` matches, not ``b``; ``alt+r`` matches both alt-bit and meta-bit encodings (terminal protocol parity); omitted-arg call still binds Ctrl+B for back-compat. Full TUI suite (555 tests) passes; ``tsc --noEmit`` clean. Fixes #18994 Co-authored-by: asheriif --- tui_gateway/server.py | 18 +++- ui-tui/src/__tests__/platform.test.ts | 68 +++++++++++++ ui-tui/src/app/interfaces.ts | 2 + ui-tui/src/app/slash/commands/session.ts | 13 ++- ui-tui/src/app/useConfigSync.ts | 41 ++++++-- ui-tui/src/app/useInputHandlers.ts | 2 +- ui-tui/src/app/useMainApp.ts | 6 +- ui-tui/src/gatewayTypes.ts | 7 +- ui-tui/src/lib/platform.ts | 120 +++++++++++++++++++++-- 9 files changed, 255 insertions(+), 22 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 01407308da..b5576649e3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5298,8 +5298,15 @@ def _(rid, params: dict) -> dict: # Mirror CLI's _show_voice_status: include STT/TTS provider # availability so the user can tell at a glance *why* voice mode # isn't working ("STT provider: MISSING ..." is the common case). + # ``record_key`` mirrors the configured ``voice.record_key`` so the + # TUI can both bind it (frontend ``isVoiceToggleKey``) and display + # it in /voice status — previously the TUI hardcoded Ctrl+B and + # ignored the config (#18994). payload: dict = { "enabled": _voice_mode_enabled(), + "record_key": str( + (_load_cfg().get("voice") or {}).get("record_key") or "ctrl+b" + ), "tts": _voice_tts_enabled(), } try: @@ -5336,7 +5343,16 @@ def _(rid, params: dict) -> dict: except Exception as e: logger.warning("voice: stop_continuous failed during toggle off: %s", e) - return _ok(rid, {"enabled": enabled, "tts": _voice_tts_enabled()}) + return _ok( + rid, + { + "enabled": enabled, + "record_key": str( + (_load_cfg().get("voice") or {}).get("record_key") or "ctrl+b" + ), + "tts": _voice_tts_enabled(), + }, + ) if action == "tts": if not _voice_mode_enabled(): diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts index 4166f0b71f..ace9cec721 100644 --- a/ui-tui/src/__tests__/platform.test.ts +++ b/ui-tui/src/__tests__/platform.test.ts @@ -89,6 +89,74 @@ describe('isVoiceToggleKey', () => { }) }) +describe('parseVoiceRecordKey (#18994)', () => { + it('falls back to Ctrl+B for empty / malformed input', async () => { + const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux') + + expect(parseVoiceRecordKey('')).toEqual(DEFAULT_VOICE_RECORD_KEY) + // Multi-character chunks are unsupported (CLI binds single keys), so a + // typo like "ctrl+space" falls back to the doc default. + expect(parseVoiceRecordKey('ctrl+space')).toEqual(DEFAULT_VOICE_RECORD_KEY) + }) + + it('parses ctrl+ bindings', async () => { + const { parseVoiceRecordKey } = await importPlatform('linux') + + expect(parseVoiceRecordKey('ctrl+o')).toEqual({ ch: 'o', mod: 'ctrl', raw: 'ctrl+o' }) + expect(parseVoiceRecordKey('Ctrl+R')).toEqual({ ch: 'r', mod: 'ctrl', raw: 'ctrl+r' }) + }) + + it('parses alt/cmd/super aliases', async () => { + const { parseVoiceRecordKey } = await importPlatform('linux') + + expect(parseVoiceRecordKey('alt+b').mod).toBe('alt') + expect(parseVoiceRecordKey('option+b').mod).toBe('alt') + expect(parseVoiceRecordKey('cmd+b').mod).toBe('meta') + expect(parseVoiceRecordKey('command+b').mod).toBe('meta') + expect(parseVoiceRecordKey('super+b').mod).toBe('super') + expect(parseVoiceRecordKey('win+b').mod).toBe('super') + }) +}) + +describe('formatVoiceRecordKey (#18994)', () => { + it('renders as the user expects in /voice status', async () => { + const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux') + + expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+b'))).toBe('Ctrl+B') + expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+o'))).toBe('Ctrl+O') + expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+r'))).toBe('Alt+R') + expect(formatVoiceRecordKey(parseVoiceRecordKey('cmd+b'))).toBe('Cmd+B') + }) +}) + +describe('isVoiceToggleKey honours configured record key (#18994)', () => { + it('binds the configured letter, not hardcoded b', async () => { + const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux') + const ctrlO = parseVoiceRecordKey('ctrl+o') + + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true) + // The old hardcoded 'b' must NOT match when the user configured 'o'. + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', ctrlO)).toBe(false) + }) + + it('alt+ binding matches alt OR meta (terminal-protocol parity)', async () => { + const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux') + const altR = parseVoiceRecordKey('alt+r') + + expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: false }, 'r', altR)).toBe(true) + expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'r', altR)).toBe(true) + expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, 'r', altR)).toBe(false) + }) + + it('omitted configured key falls back to ctrl+b (back-compat)', async () => { + const { isVoiceToggleKey } = await importPlatform('linux') + + // No third arg → DEFAULT_VOICE_RECORD_KEY → Ctrl+B behaviour. + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true) + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o')).toBe(false) + }) +}) + describe('isMacActionFallback', () => { it('routes raw Ctrl+K and Ctrl+W to readline kill-to-end / delete-word on macOS', async () => { const { isMacActionFallback } = await importPlatform('darwin') diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index baf637aa25..9be5acf5c9 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -4,6 +4,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea import type { PasteEvent } from '../components/textInput.js' import type { GatewayClient } from '../gatewayClient.js' import type { ImageAttachResponse } from '../gatewayTypes.js' +import type { ParsedVoiceRecordKey } from '../lib/platform.js' import type { RpcResult } from '../lib/rpc.js' import type { Theme } from '../theme.js' import type { @@ -210,6 +211,7 @@ export interface InputHandlerContext { } voice: { enabled: boolean + recordKey: ParsedVoiceRecordKey recording: boolean setProcessing: StateSetter setRecording: StateSetter diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 0a5324ef55..65baea55df 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -10,6 +10,7 @@ import type { SessionUsageResponse, VoiceToggleResponse } from '../../../gatewayTypes.js' +import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js' import { fmtK } from '../../../lib/text.js' import type { PanelSection } from '../../../types.js' import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js' @@ -221,6 +222,12 @@ export const sessionCommands: SlashCommand[] = [ ctx.guarded(r => { ctx.voice.setVoiceEnabled(!!r.enabled) + // Render the configured record key (config.yaml ``voice.record_key``) + // instead of hardcoded "Ctrl+B" — the gateway response carries the + // current value so /voice status and /voice on stay in sync with + // both the CLI and the TUI's actual binding (#18994). + const recordKeyLabel = formatVoiceRecordKey(parseVoiceRecordKey(r.record_key ?? 'ctrl+b')) + // Match CLI's _show_voice_status / _enable_voice_mode / // _toggle_voice_tts output shape so users don't have to learn // two vocabularies. @@ -230,11 +237,11 @@ export const sessionCommands: SlashCommand[] = [ ctx.transcript.sys('Voice Mode Status') ctx.transcript.sys(` Mode: ${mode}`) ctx.transcript.sys(` TTS: ${tts}`) - ctx.transcript.sys(' Record key: Ctrl+B') + ctx.transcript.sys(` Record key: ${recordKeyLabel}`) // CLI's "Requirements:" block — surfaces STT/audio setup issues // so the user sees "STT provider: MISSING ..." instead of - // silently failing on every Ctrl+B press. + // silently failing on every record-key press. if (r.details) { ctx.transcript.sys('') ctx.transcript.sys(' Requirements:') @@ -259,7 +266,7 @@ export const sessionCommands: SlashCommand[] = [ if (r.enabled) { const tts = r.tts ? ' (TTS enabled)' : '' ctx.transcript.sys(`Voice mode enabled${tts}`) - ctx.transcript.sys(' Ctrl+B to start/stop recording') + ctx.transcript.sys(` ${recordKeyLabel} to start/stop recording`) ctx.transcript.sys(' /voice tts to toggle speech output') ctx.transcript.sys(' /voice off to disable voice mode') } else { diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index ad8f52f148..ece08640a5 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -7,6 +7,11 @@ import type { ConfigMtimeResponse, ReloadMcpResponse } from '../gatewayTypes.js' +import { + DEFAULT_VOICE_RECORD_KEY, + parseVoiceRecordKey, + type ParsedVoiceRecordKey +} from '../lib/platform.js' import { asRpcResult } from '../lib/rpc.js' import { @@ -89,10 +94,23 @@ const quietRpc = async = Record>( } } -export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => { +const _voiceRecordKeyFromConfig = (cfg: ConfigFullResponse | null): ParsedVoiceRecordKey => { + const raw = cfg?.config?.voice?.record_key + + return raw ? parseVoiceRecordKey(raw) : DEFAULT_VOICE_RECORD_KEY +} + +export const applyDisplay = ( + cfg: ConfigFullResponse | null, + setBell: (v: boolean) => void, + setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void +) => { const d = cfg?.config?.display ?? {} setBell(!!d.bell_on_complete) + if (setVoiceRecordKey) { + setVoiceRecordKey(_voiceRecordKeyFromConfig(cfg)) + } patchUiState({ busyInputMode: normalizeBusyInputMode(d.busy_input_mode), compact: !!d.tui_compact, @@ -109,7 +127,13 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea }) } -export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) { +export function useConfigSync({ + gw, + setBellOnComplete, + setVoiceEnabled, + setVoiceRecordKey, + sid +}: UseConfigSyncOptions) { const mtimeRef = useRef(0) useEffect(() => { @@ -125,8 +149,10 @@ export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: U quietRpc(gw, 'config.get', { key: 'mtime' }).then(r => { mtimeRef.current = Number(r?.mtime ?? 0) }) - quietRpc(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) - }, [gw, setBellOnComplete, setVoiceEnabled, sid]) + quietRpc(gw, 'config.get', { key: 'full' }).then(r => + applyDisplay(r, setBellOnComplete, setVoiceRecordKey) + ) + }, [gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid]) useEffect(() => { if (!sid) { @@ -154,17 +180,20 @@ export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: U quietRpc(gw, 'reload.mcp', { session_id: sid, confirm: true }).then( r => r && turnController.pushActivity('MCP reloaded after config change') ) - quietRpc(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) + quietRpc(gw, 'config.get', { key: 'full' }).then(r => + applyDisplay(r, setBellOnComplete, setVoiceRecordKey) + ) }) }, MTIME_POLL_MS) return () => clearInterval(id) - }, [gw, setBellOnComplete, sid]) + }, [gw, setBellOnComplete, setVoiceRecordKey, sid]) } export interface UseConfigSyncOptions { gw: GatewayClient setBellOnComplete: (v: boolean) => void setVoiceEnabled: (v: boolean) => void + setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void sid: null | string } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index a74c9e8431..0ae9012902 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -439,7 +439,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if (isVoiceToggleKey(key, ch)) { + if (isVoiceToggleKey(key, ch, voice.recordKey)) { return voiceRecordToggle() } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 218654f531..ad62fb3314 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -18,7 +18,7 @@ import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { appendTranscriptMessage } from '../lib/messages.js' import { composerPromptWidth } from '../lib/inputMetrics.js' -import { isMac } from '../lib/platform.js' +import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' @@ -104,6 +104,7 @@ export function useMainApp(gw: GatewayClient) { const [voiceEnabled, setVoiceEnabled] = useState(false) const [voiceRecording, setVoiceRecording] = useState(false) const [voiceProcessing, setVoiceProcessing] = useState(false) + const [voiceRecordKey, setVoiceRecordKey] = useState(DEFAULT_VOICE_RECORD_KEY) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [turnStartedAt, setTurnStartedAt] = useState(null) const [goodVibesTick, setGoodVibesTick] = useState(0) @@ -394,7 +395,7 @@ export function useMainApp(gw: GatewayClient) { } }, [ui.busy]) - useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) + useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid }) // Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle. const model = ui.info?.model?.replace(/^.*\//, '') ?? '' @@ -539,6 +540,7 @@ export function useMainApp(gw: GatewayClient) { terminal: { hasSelection, scrollRef, scrollWithSelection, selection, stdout }, voice: { enabled: voiceEnabled, + recordKey: voiceRecordKey, recording: voiceRecording, setProcessing: setVoiceProcessing, setRecording: setVoiceRecording, diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index a1513d2a6e..6dba5f3c87 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -75,8 +75,12 @@ export interface ConfigDisplayConfig { tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean } +export interface ConfigVoiceConfig { + record_key?: string +} + export interface ConfigFullResponse { - config?: { display?: ConfigDisplayConfig } + config?: { display?: ConfigDisplayConfig; voice?: ConfigVoiceConfig } } export interface ConfigMtimeResponse { @@ -279,6 +283,7 @@ export interface VoiceToggleResponse { available?: boolean details?: string enabled?: boolean + record_key?: string stt_available?: boolean tts?: boolean } diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index 343d8f8683..ae41d1cc52 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -51,13 +51,117 @@ export const isCopyShortcut = ( (isMac && key.ctrl && (key.meta || key.super === true))) /** - * Voice recording toggle key (Ctrl+B). + * Voice recording toggle key — configurable via ``voice.record_key`` in + * ``config.yaml`` (default ``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. + * Documented in tips.py, the Python CLI prompt_toolkit handler, and the + * config.yaml default. The TUI honours the same config knob (#18994); + * when ``voice.record_key`` is e.g. ``ctrl+o`` the TUI binds Ctrl+O. + * + * On macOS we additionally accept the platform action modifier (Cmd) for + * the configured letter so existing macOS muscle memory keeps working + * alongside the documented Ctrl+ shortcut. */ -export const isVoiceToggleKey = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string): boolean => - (key.ctrl || isActionMod(key)) && ch.toLowerCase() === 'b' +export type VoiceRecordKeyMod = 'alt' | 'ctrl' | 'meta' | 'super' + +export interface ParsedVoiceRecordKey { + ch: string + mod: VoiceRecordKeyMod + raw: string +} + +export const DEFAULT_VOICE_RECORD_KEY: ParsedVoiceRecordKey = { + ch: 'b', + mod: 'ctrl', + raw: 'ctrl+b' +} + +const _MOD_ALIASES: Record = { + alt: 'alt', + cmd: 'meta', + command: 'meta', + control: 'ctrl', + ctrl: 'ctrl', + meta: 'meta', + option: 'alt', + opt: 'alt', + super: 'super', + win: 'super', + windows: 'super' +} + +/** + * Parse a config-string voice record key like ``ctrl+b`` / ``alt+r`` / + * ``cmd+space`` into ``{mod, ch}``. Falls back to the documented Ctrl+B + * default for empty / malformed input so a typo never silently disables + * the shortcut. + */ +export const parseVoiceRecordKey = (raw: string): ParsedVoiceRecordKey => { + const lower = (raw ?? '').trim().toLowerCase() + + if (!lower) { + return DEFAULT_VOICE_RECORD_KEY + } + + const parts = lower.split('+').map(p => p.trim()).filter(Boolean) + + if (!parts.length) { + return DEFAULT_VOICE_RECORD_KEY + } + + const ch = parts[parts.length - 1] + const modCandidates = parts.slice(0, -1) + + let mod: VoiceRecordKeyMod = 'ctrl' + + for (const cand of modCandidates) { + const norm = _MOD_ALIASES[cand] + + if (norm) { + mod = norm + break + } + } + + // Reject multi-character chunks (e.g. "ctrl+space" → ch="space" — we + // only support single-character bindings, matching the Python side's + // prompt_toolkit binding shape). + if (ch.length !== 1) { + return DEFAULT_VOICE_RECORD_KEY + } + + return { ch, mod, raw: lower } +} + +/** Render a parsed key back as ``Ctrl+B`` for status text. */ +export const formatVoiceRecordKey = (parsed: ParsedVoiceRecordKey): string => { + const modLabel = parsed.mod === 'meta' ? 'Cmd' : parsed.mod[0].toUpperCase() + parsed.mod.slice(1) + + return `${modLabel}+${parsed.ch.toUpperCase()}` +} + +export const isVoiceToggleKey = ( + key: { alt?: boolean; ctrl: boolean; meta: boolean; super?: boolean }, + ch: string, + configured: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY +): boolean => { + if (ch.toLowerCase() !== configured.ch) { + return false + } + + switch (configured.mod) { + case 'alt': + // Most terminals surface Alt as either ``alt`` or ``meta``; accept + // both so the binding works across xterm-style and kitty-style + // protocols. + return key.alt === true || key.meta + case 'ctrl': + // Doc default — also accept the platform action modifier so macOS + // Cmd+ muscle memory keeps working alongside Ctrl+. + return key.ctrl || isActionMod(key) + case 'meta': + return key.meta || key.super === true + case 'super': + return key.super === true + } +}