fix(tui): respect voice.record_key config instead of hardcoded Ctrl+B
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 <ahmedsherif95@gmail.com>
This commit is contained in:
committed by
Brooklyn Nicholson
co-authored by
asheriif
parent
0ce1b9fe20
commit
5231815854
+17
-1
@@ -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():
|
||||
|
||||
@@ -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+<letter> 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+<letter> 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')
|
||||
|
||||
@@ -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<boolean>
|
||||
setRecording: StateSetter<boolean>
|
||||
|
||||
@@ -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<VoiceToggleResponse>(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 {
|
||||
|
||||
@@ -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 <T extends Record<string, any> = Record<string, any>>(
|
||||
}
|
||||
}
|
||||
|
||||
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<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
|
||||
mtimeRef.current = Number(r?.mtime ?? 0)
|
||||
})
|
||||
quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
|
||||
}, [gw, setBellOnComplete, setVoiceEnabled, sid])
|
||||
quietRpc<ConfigFullResponse>(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<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid, confirm: true }).then(
|
||||
r => r && turnController.pushActivity('MCP reloaded after config change')
|
||||
)
|
||||
quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
|
||||
quietRpc<ConfigFullResponse>(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
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return
|
||||
}
|
||||
|
||||
if (isVoiceToggleKey(key, ch)) {
|
||||
if (isVoiceToggleKey(key, ch, voice.recordKey)) {
|
||||
return voiceRecordToggle()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ParsedVoiceRecordKey>(DEFAULT_VOICE_RECORD_KEY)
|
||||
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
|
||||
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+112
-8
@@ -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+<letter> 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<string, VoiceRecordKeyMod> = {
|
||||
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+<letter> muscle memory keeps working alongside Ctrl+<letter>.
|
||||
return key.ctrl || isActionMod(key)
|
||||
case 'meta':
|
||||
return key.meta || key.super === true
|
||||
case 'super':
|
||||
return key.super === true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user