From 2e7bbf2ee75fca6d4d645c79c3710f1c95a6a4ec Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Sun, 3 May 2026 09:37:11 +1000 Subject: [PATCH] =?UTF-8?q?fix(tui):=20support=20named-key=20tokens=20in?= =?UTF-8?q?=20voice.record=5Fkey=20(space,=20enter,=20=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer caught that the round-1 parser in #18994 rejected every multi-character token, so a config value like ``ctrl+space`` (which the CLI happily binds via prompt_toolkit's ``c-space`` rewrite in ``cli.py``) silently fell back to the documented Ctrl+B default — re-introducing the same false-shortcut bug the PR was meant to fix, just at a different surface. Add explicit named-key support that mirrors what the CLI accepts: * ``space`` (alias: ``spc``) → matches ``ch === ' '`` * ``enter`` (alias: ``return``, ``ret``) → matches ``key.return`` * ``tab`` → matches ``key.tab`` * ``escape`` (alias: ``esc``) → matches ``key.escape`` * ``backspace`` (alias: ``bs``) → matches ``key.backspace`` * ``delete`` (alias: ``del``) → matches ``key.delete`` ``ParsedVoiceRecordKey`` gains an optional ``named`` field; ``ch`` holds either a single char (back-compat) or the canonical named token, and the runtime matcher dispatches on ``named`` before checking the modifier shape. Aliases collapse to one canonical name so ``ctrl+esc`` and ``ctrl+escape`` behave identically. Unrecognised multi-character tokens (e.g. ``ctrl+spcae`` typo, or unsupported keys like ``ctrl+f5``) still fall back to the Ctrl+B default rather than silently disabling the binding — keeps the "typo never silently kills the shortcut" guarantee. Tests: * ``parseVoiceRecordKey`` parametrised over every named token + each alias variant. * New ``isVoiceToggleKey`` cases for space (ch-based match), enter (``key.return``), tab, escape, backspace, delete, including modifier-mismatch negatives. * ``formatVoiceRecordKey`` renders named keys in title case (``Ctrl+Space``, ``Ctrl+Enter``). * Existing fall-back-to-Ctrl+B contract preserved for empty input AND unrecognised multi-char tokens. Full TUI suite: 559/559 pass; ``tsc --noEmit`` clean. Refs #18994 (round-1 review feedback) Co-authored-by: asheriif --- ui-tui/src/__tests__/platform.test.ts | 73 ++++++++++++++++- ui-tui/src/lib/platform.ts | 114 +++++++++++++++++++++++--- 2 files changed, 170 insertions(+), 17 deletions(-) diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts index ace9cec7211..a99f5c69756 100644 --- a/ui-tui/src/__tests__/platform.test.ts +++ b/ui-tui/src/__tests__/platform.test.ts @@ -90,13 +90,10 @@ describe('isVoiceToggleKey', () => { }) describe('parseVoiceRecordKey (#18994)', () => { - it('falls back to Ctrl+B for empty / malformed input', async () => { + it('falls back to Ctrl+B for empty 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 () => { @@ -116,6 +113,37 @@ describe('parseVoiceRecordKey (#18994)', () => { expect(parseVoiceRecordKey('super+b').mod).toBe('super') expect(parseVoiceRecordKey('win+b').mod).toBe('super') }) + + it('parses named keys (space, enter, tab, escape, backspace, delete)', async () => { + const { parseVoiceRecordKey } = await importPlatform('linux') + + // Every named token from the CLI's prompt_toolkit ``c-`` set is + // accepted with both the canonical name and its common alias. + expect(parseVoiceRecordKey('ctrl+space')).toEqual({ + ch: 'space', + mod: 'ctrl', + named: 'space', + raw: 'ctrl+space' + }) + expect(parseVoiceRecordKey('alt+enter').named).toBe('enter') + expect(parseVoiceRecordKey('alt+return').named).toBe('enter') // ``return`` ↔ ``enter`` + expect(parseVoiceRecordKey('ctrl+tab').named).toBe('tab') + expect(parseVoiceRecordKey('ctrl+escape').named).toBe('escape') + expect(parseVoiceRecordKey('ctrl+esc').named).toBe('escape') // ``esc`` alias + expect(parseVoiceRecordKey('ctrl+backspace').named).toBe('backspace') + expect(parseVoiceRecordKey('ctrl+delete').named).toBe('delete') + expect(parseVoiceRecordKey('ctrl+del').named).toBe('delete') // ``del`` alias + }) + + it('falls back to Ctrl+B for unrecognised multi-character tokens', async () => { + const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux') + + // Typos / unsupported names (``ctrl+spcae``, ``ctrl+f5``, …) fall back + // to the documented Ctrl+B default rather than silently disabling the + // binding. + expect(parseVoiceRecordKey('ctrl+spcae')).toEqual(DEFAULT_VOICE_RECORD_KEY) + expect(parseVoiceRecordKey('ctrl+f5')).toEqual(DEFAULT_VOICE_RECORD_KEY) + }) }) describe('formatVoiceRecordKey (#18994)', () => { @@ -127,6 +155,14 @@ describe('formatVoiceRecordKey (#18994)', () => { expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+r'))).toBe('Alt+R') expect(formatVoiceRecordKey(parseVoiceRecordKey('cmd+b'))).toBe('Cmd+B') }) + + it('renders named keys in title case (Ctrl+Space, Ctrl+Enter)', async () => { + const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux') + + expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+space'))).toBe('Ctrl+Space') + expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+enter'))).toBe('Alt+Enter') + expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+esc'))).toBe('Ctrl+Escape') + }) }) describe('isVoiceToggleKey honours configured record key (#18994)', () => { @@ -148,6 +184,35 @@ describe('isVoiceToggleKey honours configured record key (#18994)', () => { expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, 'r', altR)).toBe(false) }) + it('binds named keys via ink event flags (space → ch === " ", enter → key.return, …)', async () => { + const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux') + + const ctrlSpace = parseVoiceRecordKey('ctrl+space') + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, ' ', ctrlSpace)).toBe(true) + // Single-char ``b`` must NOT match a ``space``-configured binding. + expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', ctrlSpace)).toBe(false) + // Space without the configured modifier must not fire either. + expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, ' ', ctrlSpace)).toBe(false) + + const ctrlEnter = parseVoiceRecordKey('ctrl+enter') + expect(isVoiceToggleKey({ ctrl: true, meta: false, return: true, super: false }, '', ctrlEnter)).toBe(true) + expect(isVoiceToggleKey({ ctrl: true, meta: false, return: false, super: false }, '', ctrlEnter)).toBe(false) + + const altTab = parseVoiceRecordKey('alt+tab') + expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: false, tab: true }, '', altTab)).toBe(true) + expect(isVoiceToggleKey({ alt: false, ctrl: false, meta: false, super: false, tab: true }, '', altTab)).toBe(false) + + const ctrlEscape = parseVoiceRecordKey('ctrl+escape') + expect(isVoiceToggleKey({ ctrl: true, escape: true, meta: false, super: false }, '', ctrlEscape)).toBe(true) + expect(isVoiceToggleKey({ ctrl: true, escape: false, meta: false, super: false }, '', ctrlEscape)).toBe(false) + + const ctrlBackspace = parseVoiceRecordKey('ctrl+backspace') + expect(isVoiceToggleKey({ backspace: true, ctrl: true, meta: false, super: false }, '', ctrlBackspace)).toBe(true) + + const ctrlDelete = parseVoiceRecordKey('ctrl+delete') + expect(isVoiceToggleKey({ ctrl: true, delete: true, meta: false, super: false }, '', ctrlDelete)).toBe(true) + }) + it('omitted configured key falls back to ctrl+b (back-compat)', async () => { const { isVoiceToggleKey } = await importPlatform('linux') diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index ae41d1cc521..09c5be034b1 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -64,9 +64,18 @@ export const isCopyShortcut = ( */ export type VoiceRecordKeyMod = 'alt' | 'ctrl' | 'meta' | 'super' +/** Named (multi-character) keys we support, matching the CLI's + * prompt_toolkit binding shape (``c-space``, ``c-enter``, etc.) so a + * config value like ``ctrl+space`` binds in both runtimes. */ +export type VoiceRecordKeyNamed = 'backspace' | 'delete' | 'enter' | 'escape' | 'space' | 'tab' + export interface ParsedVoiceRecordKey { + /** Single character (``'b'``, ``'o'``) when ``named`` is undefined, + * otherwise the named-key token (``'space'``, ``'enter'``…). Kept as + * one field for back-compat with the v1 ``{ ch, mod, raw }`` shape. */ ch: string mod: VoiceRecordKeyMod + named?: VoiceRecordKeyNamed raw: string } @@ -90,10 +99,72 @@ const _MOD_ALIASES: Record = { windows: 'super' } +/** Map config-string named tokens to the canonical name used at match time. + * + * Aliases mirror what prompt_toolkit accepts (``return`` ↔ ``enter``, + * ``esc`` ↔ ``escape``) so a config that round-trips through the CLI also + * binds in the TUI. */ +const _NAMED_KEY_ALIASES: Record = { + backspace: 'backspace', + bs: 'backspace', + del: 'delete', + delete: 'delete', + enter: 'enter', + esc: 'escape', + escape: 'escape', + ret: 'enter', + return: 'enter', + space: 'space', + spc: 'space', + tab: 'tab' +} + +interface RuntimeKeyEvent { + alt?: boolean + backspace?: boolean + ctrl: boolean + delete?: boolean + escape?: boolean + meta: boolean + return?: boolean + super?: boolean + tab?: boolean +} + +/** Match an ink ``key`` event against a parsed named key. The ink runtime + * sets one boolean per named key; ``space`` is a printable char so it + * arrives as ``ch === ' '`` rather than a dedicated ``key.space`` flag. */ +const _matchesNamedKey = ( + named: VoiceRecordKeyNamed, + key: RuntimeKeyEvent, + ch: string +): boolean => { + switch (named) { + case 'backspace': + return key.backspace === true + case 'delete': + return key.delete === true + case 'enter': + return key.return === true + case 'escape': + return key.escape === true + case 'space': + return ch === ' ' + case 'tab': + return key.tab === true + } +} + /** * 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 + * ``ctrl+space`` into ``{mod, ch, named?}``. Accepts single characters + * AND the named tokens declared in ``_NAMED_KEY_ALIASES`` (``space``, + * ``enter``/``return``, ``tab``, ``escape``/``esc``, ``backspace``, + * ``delete``) — matching the keys prompt_toolkit accepts on the CLI + * side via the ``c-`` rewrite in ``cli.py``. + * + * Falls back to the documented Ctrl+B default for empty input or for + * unrecognised multi-character tokens so a typo never silently disables * the shortcut. */ export const parseVoiceRecordKey = (raw: string): ParsedVoiceRecordKey => { @@ -109,7 +180,7 @@ export const parseVoiceRecordKey = (raw: string): ParsedVoiceRecordKey => { return DEFAULT_VOICE_RECORD_KEY } - const ch = parts[parts.length - 1] + const last = parts[parts.length - 1] const modCandidates = parts.slice(0, -1) let mod: VoiceRecordKeyMod = 'ctrl' @@ -123,29 +194,46 @@ export const parseVoiceRecordKey = (raw: string): ParsedVoiceRecordKey => { } } - // 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 + if (last.length === 1) { + return { ch: last, mod, raw: lower } } - return { ch, mod, raw: lower } + const named = _NAMED_KEY_ALIASES[last] + + if (named) { + return { ch: named, mod, named, raw: lower } + } + + // Unknown multi-character token (e.g. typo'd ``ctrl+spcae``) — fall back + // to the doc default rather than silently disabling the binding. + return DEFAULT_VOICE_RECORD_KEY } -/** Render a parsed key back as ``Ctrl+B`` for status text. */ +/** Render a parsed key back as ``Ctrl+B`` / ``Ctrl+Space`` for status text. */ export const formatVoiceRecordKey = (parsed: ParsedVoiceRecordKey): string => { const modLabel = parsed.mod === 'meta' ? 'Cmd' : parsed.mod[0].toUpperCase() + parsed.mod.slice(1) + // Named tokens render in title case (Ctrl+Space, Ctrl+Enter); single + // chars render upper-case to match the existing Ctrl+B convention. + const keyLabel = parsed.named + ? parsed.named[0].toUpperCase() + parsed.named.slice(1) + : parsed.ch.toUpperCase() - return `${modLabel}+${parsed.ch.toUpperCase()}` + return `${modLabel}+${keyLabel}` } export const isVoiceToggleKey = ( - key: { alt?: boolean; ctrl: boolean; meta: boolean; super?: boolean }, + key: RuntimeKeyEvent, ch: string, configured: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY ): boolean => { - if (ch.toLowerCase() !== configured.ch) { + // Match the configured key first (single-char compare or named-key + // event-property check). Bail out before evaluating modifier shape + // so the wrong key never reaches the modifier guard. + if (configured.named) { + if (!_matchesNamedKey(configured.named, key, ch)) { + return false + } + } else if (ch.toLowerCase() !== configured.ch) { return false }