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:
Tranquil-Flow
2026-05-04 12:04:03 -05:00
committed by Brooklyn Nicholson
co-authored by asheriif
parent 0ce1b9fe20
commit 5231815854
9 changed files with 255 additions and 22 deletions
+17 -1
View File
@@ -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():
+68
View File
@@ -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')
+2
View File
@@ -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 -3
View File
@@ -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 {
+35 -6
View File
@@ -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
}
+1 -1
View File
@@ -439,7 +439,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return
}
if (isVoiceToggleKey(key, ch)) {
if (isVoiceToggleKey(key, ch, voice.recordKey)) {
return voiceRecordToggle()
}
+4 -2
View File
@@ -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,
+6 -1
View File
@@ -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
View File
@@ -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
}
}