mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)
* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-2): 格式化 commands (79 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-5): 格式化 components其余 + hooks + tools (232 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md - README.md: 大幅重写,更详细版本历史和配置示例 - Run.ps1: 新增 Windows 启动脚本 - TODO.md: 新增包完成清单 - V6.md: 删除(架构重构规划已不适用) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复以前的问题 * fix: 修复 login 面板的问题 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,75 +1,85 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNotifications } from '../context/notifications.js';
|
||||
import { useIsModalOverlayActive } from '../context/overlayContext.js';
|
||||
import { useGetVoiceState, useSetVoiceState, useVoiceState } from '../context/voice.js';
|
||||
import { KeyboardEvent } from '../ink/events/keyboard-event.js';
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useNotifications } from '../context/notifications.js'
|
||||
import { useIsModalOverlayActive } from '../context/overlayContext.js'
|
||||
import {
|
||||
useGetVoiceState,
|
||||
useSetVoiceState,
|
||||
useVoiceState,
|
||||
} from '../context/voice.js'
|
||||
import { KeyboardEvent } from '../ink/events/keyboard-event.js'
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until REPL wires handleKeyDown to <Box onKeyDown>
|
||||
import { useInput } from '../ink.js';
|
||||
import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js';
|
||||
import { keystrokesEqual } from '../keybindings/resolver.js';
|
||||
import type { ParsedKeystroke } from '../keybindings/types.js';
|
||||
import { normalizeFullWidthSpace } from '../utils/stringUtils.js';
|
||||
import { useVoiceEnabled } from './useVoiceEnabled.js';
|
||||
import { useInput } from '../ink.js'
|
||||
import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'
|
||||
import { keystrokesEqual } from '../keybindings/resolver.js'
|
||||
import type { ParsedKeystroke } from '../keybindings/types.js'
|
||||
import { normalizeFullWidthSpace } from '../utils/stringUtils.js'
|
||||
import { useVoiceEnabled } from './useVoiceEnabled.js'
|
||||
|
||||
// Dead code elimination: conditional import for voice input hook.
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
// Capture the module namespace, not the function: spyOn() mutates the module
|
||||
// object, so `voiceNs.useVoice(...)` resolves to the spy even if this module
|
||||
// was loaded before the spy was installed (test ordering independence).
|
||||
const voiceNs: {
|
||||
useVoice: typeof import('./useVoice.js').useVoice;
|
||||
} = feature('VOICE_MODE') ? require('./useVoice.js') : {
|
||||
useVoice: ({
|
||||
enabled: _e
|
||||
}: {
|
||||
onTranscript: (t: string) => void;
|
||||
enabled: boolean;
|
||||
}) => ({
|
||||
state: 'idle' as const,
|
||||
handleKeyEvent: (_fallbackMs?: number) => {}
|
||||
})
|
||||
};
|
||||
const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature(
|
||||
'VOICE_MODE',
|
||||
)
|
||||
? require('./useVoice.js')
|
||||
: {
|
||||
useVoice: ({
|
||||
enabled: _e,
|
||||
}: {
|
||||
onTranscript: (t: string) => void
|
||||
enabled: boolean
|
||||
}) => ({
|
||||
state: 'idle' as const,
|
||||
handleKeyEvent: (_fallbackMs?: number) => {},
|
||||
}),
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
// Maximum gap (ms) between key presses to count as held (auto-repeat).
|
||||
// Terminal auto-repeat fires every 30-80ms; 120ms covers jitter while
|
||||
// excluding normal typing speed (100-300ms between keystrokes).
|
||||
const RAPID_KEY_GAP_MS = 120;
|
||||
const RAPID_KEY_GAP_MS = 120
|
||||
|
||||
// Fallback (ms) for modifier-combo first-press activation. Must match
|
||||
// FIRST_PRESS_FALLBACK_MS in useVoice.ts. Covers the max OS initial
|
||||
// key-repeat delay (~2s on macOS with slider at "Long") so holding a
|
||||
// modifier combo doesn't fragment into two sessions when the first
|
||||
// auto-repeat arrives after the default 600ms REPEAT_FALLBACK_MS.
|
||||
const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000;
|
||||
const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000
|
||||
|
||||
// Number of rapid consecutive key events required to activate voice.
|
||||
// Only applies to bare-char bindings (space, v, etc.) where a single press
|
||||
// could be normal typing. Modifier combos activate on the first press.
|
||||
const HOLD_THRESHOLD = 5;
|
||||
const HOLD_THRESHOLD = 5
|
||||
|
||||
// Number of rapid key events to start showing warmup feedback.
|
||||
const WARMUP_THRESHOLD = 2;
|
||||
const WARMUP_THRESHOLD = 2
|
||||
|
||||
// Match a KeyboardEvent against a ParsedKeystroke. Replaces the legacy
|
||||
// matchesKeystroke(input, Key, ...) path which assumed useInput's raw
|
||||
// `input` arg — KeyboardEvent.key holds normalized names (e.g. 'space',
|
||||
// 'f9') that getKeyName() didn't handle, so modifier combos and f-keys
|
||||
// silently failed to match after the onKeyDown migration (#23524).
|
||||
function matchesKeyboardEvent(e: KeyboardEvent, target: ParsedKeystroke): boolean {
|
||||
function matchesKeyboardEvent(
|
||||
e: KeyboardEvent,
|
||||
target: ParsedKeystroke,
|
||||
): boolean {
|
||||
// KeyboardEvent stores key names; ParsedKeystroke stores ' ' for space
|
||||
// and 'enter' for return (see parser.ts case 'space'/'return').
|
||||
const key = e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase();
|
||||
if (key !== target.key) return false;
|
||||
if (e.ctrl !== target.ctrl) return false;
|
||||
if (e.shift !== target.shift) return false;
|
||||
const key =
|
||||
e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase()
|
||||
if (key !== target.key) return false
|
||||
if (e.ctrl !== target.ctrl) return false
|
||||
if (e.shift !== target.shift) return false
|
||||
// KeyboardEvent.meta folds alt|option (terminal limitation — esc-prefix);
|
||||
// ParsedKeystroke has both alt and meta as aliases for the same thing.
|
||||
if (e.meta !== (target.alt || target.meta)) return false;
|
||||
if (e.superKey !== target.super) return false;
|
||||
return true;
|
||||
if (e.meta !== (target.alt || target.meta)) return false
|
||||
if (e.superKey !== target.super) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Hardcoded default for when there's no KeybindingProvider at all (e.g.
|
||||
@@ -82,60 +92,61 @@ const DEFAULT_VOICE_KEYSTROKE: ParsedKeystroke = {
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
super: false
|
||||
};
|
||||
super: false,
|
||||
}
|
||||
|
||||
type InsertTextHandle = {
|
||||
insert: (text: string) => void;
|
||||
setInputWithCursor: (value: string, cursor: number) => void;
|
||||
cursorOffset: number;
|
||||
};
|
||||
insert: (text: string) => void
|
||||
setInputWithCursor: (value: string, cursor: number) => void
|
||||
cursorOffset: number
|
||||
}
|
||||
|
||||
type UseVoiceIntegrationArgs = {
|
||||
setInputValueRaw: React.Dispatch<React.SetStateAction<string>>;
|
||||
inputValueRef: React.RefObject<string>;
|
||||
insertTextRef: React.RefObject<InsertTextHandle | null>;
|
||||
};
|
||||
type InterimRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
setInputValueRaw: React.Dispatch<React.SetStateAction<string>>
|
||||
inputValueRef: React.RefObject<string>
|
||||
insertTextRef: React.RefObject<InsertTextHandle | null>
|
||||
}
|
||||
|
||||
type InterimRange = { start: number; end: number }
|
||||
|
||||
type StripOpts = {
|
||||
// Which char to strip (the configured hold key). Defaults to space.
|
||||
char?: string;
|
||||
char?: string
|
||||
// Capture the voice prefix/suffix anchor at the stripped position.
|
||||
anchor?: boolean;
|
||||
anchor?: boolean
|
||||
// Minimum trailing count to leave behind — prevents stripping the
|
||||
// intentional warmup chars when defensively cleaning up leaks.
|
||||
floor?: number;
|
||||
};
|
||||
floor?: number
|
||||
}
|
||||
|
||||
type UseVoiceIntegrationResult = {
|
||||
// Returns the number of trailing chars remaining after stripping.
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number;
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number
|
||||
// Undo the gap space and reset anchor refs after a failed voice activation.
|
||||
resetAnchor: () => void;
|
||||
handleKeyEvent: (fallbackMs?: number) => void;
|
||||
interimRange: InterimRange | null;
|
||||
};
|
||||
resetAnchor: () => void
|
||||
handleKeyEvent: (fallbackMs?: number) => void
|
||||
interimRange: InterimRange | null
|
||||
}
|
||||
|
||||
export function useVoiceIntegration({
|
||||
setInputValueRaw,
|
||||
inputValueRef,
|
||||
insertTextRef
|
||||
insertTextRef,
|
||||
}: UseVoiceIntegrationArgs): UseVoiceIntegrationResult {
|
||||
const {
|
||||
addNotification
|
||||
} = useNotifications();
|
||||
const { addNotification } = useNotifications()
|
||||
|
||||
// Tracks the input content before/after the cursor when voice starts,
|
||||
// so interim transcripts can be inserted at the cursor position without
|
||||
// clobbering surrounding user text.
|
||||
const voicePrefixRef = useRef<string | null>(null);
|
||||
const voiceSuffixRef = useRef<string>('');
|
||||
const voicePrefixRef = useRef<string | null>(null)
|
||||
const voiceSuffixRef = useRef<string>('')
|
||||
// Tracks the last input value this hook wrote (via anchor, interim effect,
|
||||
// or handleVoiceTranscript). If inputValueRef.current diverges, the user
|
||||
// submitted or edited — both write paths bail to avoid clobbering. This is
|
||||
// the only guard that correctly handles empty-prefix-empty-suffix: a
|
||||
// startsWith('')/endsWith('') check vacuously passes, and a length check
|
||||
// can't distinguish a cleared input from a never-set one.
|
||||
const lastSetInputRef = useRef<string | null>(null);
|
||||
const lastSetInputRef = useRef<string | null>(null)
|
||||
|
||||
// Strip trailing hold-key chars (and optionally capture the voice
|
||||
// anchor). Called during warmup (to clean up chars that leaked past
|
||||
@@ -149,53 +160,59 @@ export function useVoiceIntegration({
|
||||
// defensive cleanup only removes leaks). Returns the number of
|
||||
// trailing chars remaining after stripping. When nothing changes, no
|
||||
// state update is performed.
|
||||
const stripTrailing = useCallback((maxStrip: number, {
|
||||
char = ' ',
|
||||
anchor = false,
|
||||
floor = 0
|
||||
}: StripOpts = {}) => {
|
||||
const prev = inputValueRef.current;
|
||||
const offset = insertTextRef.current?.cursorOffset ?? prev.length;
|
||||
const beforeCursor = prev.slice(0, offset);
|
||||
const afterCursor = prev.slice(offset);
|
||||
// When the hold key is space, also count full-width spaces (U+3000)
|
||||
// that a CJK IME may have inserted for the same physical key.
|
||||
// U+3000 is BMP single-code-unit so indices align with beforeCursor.
|
||||
const scan = char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor;
|
||||
let trailing = 0;
|
||||
while (trailing < scan.length && scan[scan.length - 1 - trailing] === char) {
|
||||
trailing++;
|
||||
}
|
||||
const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip));
|
||||
const remaining = trailing - stripCount;
|
||||
const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount);
|
||||
// When anchoring with a non-space suffix, insert a gap space so the
|
||||
// waveform cursor sits on the gap instead of covering the first
|
||||
// suffix letter. The interim transcript effect maintains this same
|
||||
// structure (prefix + leading + interim + trailing + suffix), so
|
||||
// the gap is seamless once transcript text arrives.
|
||||
// Always overwrite on anchor — if a prior activation failed to start
|
||||
// voice (voiceState stayed 'idle'), the cleanup effect didn't fire and
|
||||
// the old anchor is stale. anchor=true is only passed on the single
|
||||
// activation call, never during recording, so overwrite is safe.
|
||||
let gap = '';
|
||||
if (anchor) {
|
||||
voicePrefixRef.current = stripped;
|
||||
voiceSuffixRef.current = afterCursor;
|
||||
if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) {
|
||||
gap = ' ';
|
||||
const stripTrailing = useCallback(
|
||||
(
|
||||
maxStrip: number,
|
||||
{ char = ' ', anchor = false, floor = 0 }: StripOpts = {},
|
||||
) => {
|
||||
const prev = inputValueRef.current
|
||||
const offset = insertTextRef.current?.cursorOffset ?? prev.length
|
||||
const beforeCursor = prev.slice(0, offset)
|
||||
const afterCursor = prev.slice(offset)
|
||||
// When the hold key is space, also count full-width spaces (U+3000)
|
||||
// that a CJK IME may have inserted for the same physical key.
|
||||
// U+3000 is BMP single-code-unit so indices align with beforeCursor.
|
||||
const scan =
|
||||
char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor
|
||||
let trailing = 0
|
||||
while (
|
||||
trailing < scan.length &&
|
||||
scan[scan.length - 1 - trailing] === char
|
||||
) {
|
||||
trailing++
|
||||
}
|
||||
}
|
||||
const newValue = stripped + gap + afterCursor;
|
||||
if (anchor) lastSetInputRef.current = newValue;
|
||||
if (newValue === prev && stripCount === 0) return remaining;
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newValue, stripped.length);
|
||||
} else {
|
||||
setInputValueRaw(newValue);
|
||||
}
|
||||
return remaining;
|
||||
}, [setInputValueRaw, inputValueRef, insertTextRef]);
|
||||
const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip))
|
||||
const remaining = trailing - stripCount
|
||||
const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount)
|
||||
// When anchoring with a non-space suffix, insert a gap space so the
|
||||
// waveform cursor sits on the gap instead of covering the first
|
||||
// suffix letter. The interim transcript effect maintains this same
|
||||
// structure (prefix + leading + interim + trailing + suffix), so
|
||||
// the gap is seamless once transcript text arrives.
|
||||
// Always overwrite on anchor — if a prior activation failed to start
|
||||
// voice (voiceState stayed 'idle'), the cleanup effect didn't fire and
|
||||
// the old anchor is stale. anchor=true is only passed on the single
|
||||
// activation call, never during recording, so overwrite is safe.
|
||||
let gap = ''
|
||||
if (anchor) {
|
||||
voicePrefixRef.current = stripped
|
||||
voiceSuffixRef.current = afterCursor
|
||||
if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) {
|
||||
gap = ' '
|
||||
}
|
||||
}
|
||||
const newValue = stripped + gap + afterCursor
|
||||
if (anchor) lastSetInputRef.current = newValue
|
||||
if (newValue === prev && stripCount === 0) return remaining
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newValue, stripped.length)
|
||||
} else {
|
||||
setInputValueRaw(newValue)
|
||||
}
|
||||
return remaining
|
||||
},
|
||||
[setInputValueRaw, inputValueRef, insertTextRef],
|
||||
)
|
||||
|
||||
// Undo the gap space inserted by stripTrailing(..., {anchor:true}) and
|
||||
// reset the voice prefix/suffix refs. Called when voice activation fails
|
||||
@@ -204,110 +221,124 @@ export function useVoiceIntegration({
|
||||
// reach the stale anchor. Without this, the gap space and stale refs
|
||||
// persist in the input.
|
||||
const resetAnchor = useCallback(() => {
|
||||
const prefix = voicePrefixRef.current;
|
||||
if (prefix === null) return;
|
||||
const suffix = voiceSuffixRef.current;
|
||||
voicePrefixRef.current = null;
|
||||
voiceSuffixRef.current = '';
|
||||
const restored = prefix + suffix;
|
||||
const prefix = voicePrefixRef.current
|
||||
if (prefix === null) return
|
||||
const suffix = voiceSuffixRef.current
|
||||
voicePrefixRef.current = null
|
||||
voiceSuffixRef.current = ''
|
||||
const restored = prefix + suffix
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(restored, prefix.length);
|
||||
insertTextRef.current.setInputWithCursor(restored, prefix.length)
|
||||
} else {
|
||||
setInputValueRaw(restored);
|
||||
setInputValueRaw(restored)
|
||||
}
|
||||
}, [setInputValueRaw, insertTextRef]);
|
||||
}, [setInputValueRaw, insertTextRef])
|
||||
|
||||
// Voice state selectors. useVoiceEnabled = user intent (settings) +
|
||||
// auth + GB kill-switch, with the auth half memoized on authVersion so
|
||||
// render loops never hit a cold keychain spawn.
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
||||
const voiceState = feature('VOICE_MODE') ?
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState) : 'idle' as const;
|
||||
const voiceInterimTranscript: string = feature('VOICE_MODE') ?
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s_0 => s_0.voiceInterimTranscript) as string : '';
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
const voiceInterimTranscript = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceInterimTranscript)
|
||||
: ''
|
||||
|
||||
// Set the voice anchor for focus mode (where recording starts via terminal
|
||||
// focus, not key hold). Key-hold sets the anchor in stripTrailing.
|
||||
useEffect(() => {
|
||||
if (!feature('VOICE_MODE')) return;
|
||||
if (!feature('VOICE_MODE')) return
|
||||
if (voiceState === 'recording' && voicePrefixRef.current === null) {
|
||||
const input = inputValueRef.current;
|
||||
const offset_0 = insertTextRef.current?.cursorOffset ?? input.length;
|
||||
voicePrefixRef.current = input.slice(0, offset_0);
|
||||
voiceSuffixRef.current = input.slice(offset_0);
|
||||
lastSetInputRef.current = input;
|
||||
const input = inputValueRef.current
|
||||
const offset = insertTextRef.current?.cursorOffset ?? input.length
|
||||
voicePrefixRef.current = input.slice(0, offset)
|
||||
voiceSuffixRef.current = input.slice(offset)
|
||||
lastSetInputRef.current = input
|
||||
}
|
||||
if (voiceState === 'idle') {
|
||||
voicePrefixRef.current = null;
|
||||
voiceSuffixRef.current = '';
|
||||
lastSetInputRef.current = null;
|
||||
voicePrefixRef.current = null
|
||||
voiceSuffixRef.current = ''
|
||||
lastSetInputRef.current = null
|
||||
}
|
||||
}, [voiceState, inputValueRef, insertTextRef]);
|
||||
}, [voiceState, inputValueRef, insertTextRef])
|
||||
|
||||
// Live-update the prompt input with the interim transcript as voice
|
||||
// transcribes speech. The prefix (user-typed text before the cursor) is
|
||||
// preserved and the transcript is inserted between prefix and suffix.
|
||||
useEffect(() => {
|
||||
if (!feature('VOICE_MODE')) return;
|
||||
if (voicePrefixRef.current === null) return;
|
||||
const prefix_0 = voicePrefixRef.current;
|
||||
const suffix_0 = voiceSuffixRef.current;
|
||||
if (!feature('VOICE_MODE')) return
|
||||
if (voicePrefixRef.current === null) return
|
||||
const prefix = voicePrefixRef.current
|
||||
const suffix = voiceSuffixRef.current
|
||||
// Submit race: if the input isn't what this hook last set it to, the
|
||||
// user submitted (clearing it) or edited it. voicePrefixRef is only
|
||||
// cleared on voiceState→idle, so it's still set during the 'processing'
|
||||
// window between CloseStream and WS close — this catches refined
|
||||
// TranscriptText arriving then and re-filling a cleared input.
|
||||
if (inputValueRef.current !== lastSetInputRef.current) return;
|
||||
const needsSpace = prefix_0.length > 0 && !/\s$/.test(prefix_0) && voiceInterimTranscript.length > 0;
|
||||
if (inputValueRef.current !== lastSetInputRef.current) return
|
||||
const needsSpace =
|
||||
prefix.length > 0 &&
|
||||
!/\s$/.test(prefix) &&
|
||||
voiceInterimTranscript.length > 0
|
||||
// Don't gate on voiceInterimTranscript.length -- when interim clears to ''
|
||||
// after handleVoiceTranscript sets the final text, the trailing space
|
||||
// between prefix and suffix must still be preserved.
|
||||
const needsTrailingSpace = suffix_0.length > 0 && !/^\s/.test(suffix_0);
|
||||
const leadingSpace = needsSpace ? ' ' : '';
|
||||
const trailingSpace = needsTrailingSpace ? ' ' : '';
|
||||
const newValue_0 = prefix_0 + leadingSpace + voiceInterimTranscript + trailingSpace + suffix_0;
|
||||
const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix)
|
||||
const leadingSpace = needsSpace ? ' ' : ''
|
||||
const trailingSpace = needsTrailingSpace ? ' ' : ''
|
||||
const newValue =
|
||||
prefix + leadingSpace + voiceInterimTranscript + trailingSpace + suffix
|
||||
// Position cursor after the transcribed text (before suffix)
|
||||
const cursorPos = prefix_0.length + leadingSpace.length + voiceInterimTranscript.length;
|
||||
const cursorPos =
|
||||
prefix.length + leadingSpace.length + voiceInterimTranscript.length
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newValue_0, cursorPos);
|
||||
insertTextRef.current.setInputWithCursor(newValue, cursorPos)
|
||||
} else {
|
||||
setInputValueRaw(newValue_0);
|
||||
setInputValueRaw(newValue)
|
||||
}
|
||||
lastSetInputRef.current = newValue_0;
|
||||
}, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]);
|
||||
const handleVoiceTranscript = useCallback((text: string) => {
|
||||
if (!feature('VOICE_MODE')) return;
|
||||
const prefix_1 = voicePrefixRef.current;
|
||||
// No voice anchor — voice was reset (or never started). Nothing to do.
|
||||
if (prefix_1 === null) return;
|
||||
const suffix_1 = voiceSuffixRef.current;
|
||||
// Submit race: finishRecording() → user presses Enter (input cleared)
|
||||
// → WebSocket close → this callback fires with stale prefix/suffix.
|
||||
// If the input isn't what this hook last set (via the interim effect
|
||||
// or anchor), the user submitted or edited — don't re-fill. Comparing
|
||||
// against `text.length` would false-positive when the final is longer
|
||||
// than the interim (ASR routinely adds punctuation/corrections).
|
||||
if (inputValueRef.current !== lastSetInputRef.current) return;
|
||||
const needsSpace_0 = prefix_1.length > 0 && !/\s$/.test(prefix_1) && text.length > 0;
|
||||
const needsTrailingSpace_0 = suffix_1.length > 0 && !/^\s/.test(suffix_1) && text.length > 0;
|
||||
const leadingSpace_0 = needsSpace_0 ? ' ' : '';
|
||||
const trailingSpace_0 = needsTrailingSpace_0 ? ' ' : '';
|
||||
const newInput = prefix_1 + leadingSpace_0 + text + trailingSpace_0 + suffix_1;
|
||||
// Position cursor after the transcribed text (before suffix)
|
||||
const cursorPos_0 = prefix_1.length + leadingSpace_0.length + text.length;
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newInput, cursorPos_0);
|
||||
} else {
|
||||
setInputValueRaw(newInput);
|
||||
}
|
||||
lastSetInputRef.current = newInput;
|
||||
// Update the prefix to include this chunk so focus mode can continue
|
||||
// appending subsequent transcripts after it.
|
||||
voicePrefixRef.current = prefix_1 + leadingSpace_0 + text;
|
||||
}, [setInputValueRaw, inputValueRef, insertTextRef]);
|
||||
lastSetInputRef.current = newValue
|
||||
}, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef])
|
||||
|
||||
const handleVoiceTranscript = useCallback(
|
||||
(text: string) => {
|
||||
if (!feature('VOICE_MODE')) return
|
||||
const prefix = voicePrefixRef.current
|
||||
// No voice anchor — voice was reset (or never started). Nothing to do.
|
||||
if (prefix === null) return
|
||||
const suffix = voiceSuffixRef.current
|
||||
// Submit race: finishRecording() → user presses Enter (input cleared)
|
||||
// → WebSocket close → this callback fires with stale prefix/suffix.
|
||||
// If the input isn't what this hook last set (via the interim effect
|
||||
// or anchor), the user submitted or edited — don't re-fill. Comparing
|
||||
// against `text.length` would false-positive when the final is longer
|
||||
// than the interim (ASR routinely adds punctuation/corrections).
|
||||
if (inputValueRef.current !== lastSetInputRef.current) return
|
||||
const needsSpace =
|
||||
prefix.length > 0 && !/\s$/.test(prefix) && text.length > 0
|
||||
const needsTrailingSpace =
|
||||
suffix.length > 0 && !/^\s/.test(suffix) && text.length > 0
|
||||
const leadingSpace = needsSpace ? ' ' : ''
|
||||
const trailingSpace = needsTrailingSpace ? ' ' : ''
|
||||
const newInput = prefix + leadingSpace + text + trailingSpace + suffix
|
||||
// Position cursor after the transcribed text (before suffix)
|
||||
const cursorPos = prefix.length + leadingSpace.length + text.length
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newInput, cursorPos)
|
||||
} else {
|
||||
setInputValueRaw(newInput)
|
||||
}
|
||||
lastSetInputRef.current = newInput
|
||||
// Update the prefix to include this chunk so focus mode can continue
|
||||
// appending subsequent transcripts after it.
|
||||
voicePrefixRef.current = prefix + leadingSpace + text
|
||||
},
|
||||
[setInputValueRaw, inputValueRef, insertTextRef],
|
||||
)
|
||||
|
||||
const voice = voiceNs.useVoice({
|
||||
onTranscript: handleVoiceTranscript,
|
||||
onError: (message: string) => {
|
||||
@@ -316,34 +347,35 @@ export function useVoiceIntegration({
|
||||
text: message,
|
||||
color: 'error',
|
||||
priority: 'immediate',
|
||||
timeoutMs: 10_000
|
||||
});
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
},
|
||||
enabled: voiceEnabled,
|
||||
focusMode: false
|
||||
});
|
||||
focusMode: false,
|
||||
})
|
||||
|
||||
// Compute the character range of interim (not-yet-finalized) transcript
|
||||
// text in the input value, so the UI can dim it.
|
||||
const interimRange = useMemo((): InterimRange | null => {
|
||||
if (!feature('VOICE_MODE')) return null;
|
||||
if (voicePrefixRef.current === null) return null;
|
||||
if (voiceInterimTranscript.length === 0) return null;
|
||||
const prefix_2 = voicePrefixRef.current;
|
||||
const needsSpace_1 = prefix_2.length > 0 && !/\s$/.test(prefix_2) && voiceInterimTranscript.length > 0;
|
||||
const start = prefix_2.length + (needsSpace_1 ? 1 : 0);
|
||||
const end = start + voiceInterimTranscript.length;
|
||||
return {
|
||||
start,
|
||||
end
|
||||
};
|
||||
}, [voiceInterimTranscript]);
|
||||
if (!feature('VOICE_MODE')) return null
|
||||
if (voicePrefixRef.current === null) return null
|
||||
if (voiceInterimTranscript.length === 0) return null
|
||||
const prefix = voicePrefixRef.current
|
||||
const needsSpace =
|
||||
prefix.length > 0 &&
|
||||
!/\s$/.test(prefix) &&
|
||||
voiceInterimTranscript.length > 0
|
||||
const start = prefix.length + (needsSpace ? 1 : 0)
|
||||
const end = start + voiceInterimTranscript.length
|
||||
return { start, end }
|
||||
}, [voiceInterimTranscript])
|
||||
|
||||
return {
|
||||
stripTrailing,
|
||||
resetAnchor,
|
||||
handleKeyEvent: voice.handleKeyEvent,
|
||||
interimRange
|
||||
};
|
||||
interimRange,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,24 +406,23 @@ export function useVoiceKeybindingHandler({
|
||||
voiceHandleKeyEvent,
|
||||
stripTrailing,
|
||||
resetAnchor,
|
||||
isActive
|
||||
isActive,
|
||||
}: {
|
||||
voiceHandleKeyEvent: (fallbackMs?: number) => void;
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number;
|
||||
resetAnchor: () => void;
|
||||
isActive: boolean;
|
||||
}): {
|
||||
handleKeyDown: (e: KeyboardEvent) => void;
|
||||
} {
|
||||
const getVoiceState = useGetVoiceState();
|
||||
const setVoiceState = useSetVoiceState();
|
||||
const keybindingContext = useOptionalKeybindingContext();
|
||||
const isModalOverlayActive = useIsModalOverlayActive();
|
||||
voiceHandleKeyEvent: (fallbackMs?: number) => void
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number
|
||||
resetAnchor: () => void
|
||||
isActive: boolean
|
||||
}): { handleKeyDown: (e: KeyboardEvent) => void } {
|
||||
const getVoiceState = useGetVoiceState()
|
||||
const setVoiceState = useSetVoiceState()
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
const isModalOverlayActive = useIsModalOverlayActive()
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
||||
const voiceState = feature('VOICE_MODE') ?
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState) : 'idle';
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState)
|
||||
: 'idle'
|
||||
|
||||
// Find the configured key for voice:pushToTalk from keybinding context.
|
||||
// Forward iteration with last-wins (matching the resolver): if a later
|
||||
@@ -403,22 +434,22 @@ export function useVoiceKeybindingHandler({
|
||||
// is also bound in Settings/Confirmation/Plugin (select:accept etc.);
|
||||
// without the filter those would null out the default.
|
||||
const voiceKeystroke = useMemo((): ParsedKeystroke | null => {
|
||||
if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE;
|
||||
let result: ParsedKeystroke | null = null;
|
||||
if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE
|
||||
let result: ParsedKeystroke | null = null
|
||||
for (const binding of keybindingContext.bindings) {
|
||||
if (binding.context !== 'Chat') continue;
|
||||
if (binding.chord.length !== 1) continue;
|
||||
const ks = binding.chord[0];
|
||||
if (!ks) continue;
|
||||
if (binding.context !== 'Chat') continue
|
||||
if (binding.chord.length !== 1) continue
|
||||
const ks = binding.chord[0]
|
||||
if (!ks) continue
|
||||
if (binding.action === 'voice:pushToTalk') {
|
||||
result = ks;
|
||||
result = ks
|
||||
} else if (result !== null && keystrokesEqual(ks, result)) {
|
||||
// A later binding overrides this chord (null unbind or reassignment)
|
||||
result = null;
|
||||
result = null
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [keybindingContext]);
|
||||
return result
|
||||
}, [keybindingContext])
|
||||
|
||||
// If the binding is a bare (unmodified) single printable char, terminal
|
||||
// auto-repeat may batch N keystrokes into one input event (e.g. "vvv"),
|
||||
@@ -426,8 +457,18 @@ export function useVoiceKeybindingHandler({
|
||||
// Modifier combos (meta+k, ctrl+x) also auto-repeat (the letter part
|
||||
// repeats) but don't insert text, so they're swallowed from the first
|
||||
// press with no stripping needed. matchesKeyboardEvent handles those.
|
||||
const bareChar = voiceKeystroke !== null && voiceKeystroke.key.length === 1 && !voiceKeystroke.ctrl && !voiceKeystroke.alt && !voiceKeystroke.shift && !voiceKeystroke.meta && !voiceKeystroke.super ? voiceKeystroke.key : null;
|
||||
const rapidCountRef = useRef(0);
|
||||
const bareChar =
|
||||
voiceKeystroke !== null &&
|
||||
voiceKeystroke.key.length === 1 &&
|
||||
!voiceKeystroke.ctrl &&
|
||||
!voiceKeystroke.alt &&
|
||||
!voiceKeystroke.shift &&
|
||||
!voiceKeystroke.meta &&
|
||||
!voiceKeystroke.super
|
||||
? voiceKeystroke.key
|
||||
: null
|
||||
|
||||
const rapidCountRef = useRef(0)
|
||||
// How many rapid chars we intentionally let through to the text
|
||||
// input (the first WARMUP_THRESHOLD). The activation strip removes
|
||||
// up to this many + the activation event's potential leak. For the
|
||||
@@ -436,15 +477,15 @@ export function useVoiceKeybindingHandler({
|
||||
// one pre-existing char if the input already ended in the bound
|
||||
// letter (e.g. "hav" + hold "v" → "ha"). We don't track that
|
||||
// boundary — it's best-effort and the warning says so.
|
||||
const charsInInputRef = useRef(0);
|
||||
const charsInInputRef = useRef(0)
|
||||
// Trailing-char count remaining after the activation strip — these
|
||||
// belong to the user's anchored prefix and must be preserved during
|
||||
// recording's defensive leak cleanup.
|
||||
const recordingFloorRef = useRef(0);
|
||||
const recordingFloorRef = useRef(0)
|
||||
// True when the current recording was started by key-hold (not focus).
|
||||
// Used to avoid swallowing keypresses during focus-mode recording.
|
||||
const isHoldActiveRef = useRef(false);
|
||||
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isHoldActiveRef = useRef(false)
|
||||
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Reset hold state as soon as we leave 'recording'. The physical hold
|
||||
// ends when key-repeat stops (state → 'processing'); keeping the ref
|
||||
@@ -452,21 +493,19 @@ export function useVoiceKeybindingHandler({
|
||||
// while the transcript finalizes.
|
||||
useEffect(() => {
|
||||
if (voiceState !== 'recording') {
|
||||
isHoldActiveRef.current = false;
|
||||
rapidCountRef.current = 0;
|
||||
charsInInputRef.current = 0;
|
||||
recordingFloorRef.current = 0;
|
||||
isHoldActiveRef.current = false
|
||||
rapidCountRef.current = 0
|
||||
charsInInputRef.current = 0
|
||||
recordingFloorRef.current = 0
|
||||
setVoiceState(prev => {
|
||||
if (!prev.voiceWarmingUp) return prev;
|
||||
return {
|
||||
...prev,
|
||||
voiceWarmingUp: false
|
||||
};
|
||||
});
|
||||
if (!prev.voiceWarmingUp) return prev
|
||||
return { ...prev, voiceWarmingUp: false }
|
||||
})
|
||||
}
|
||||
}, [voiceState, setVoiceState]);
|
||||
}, [voiceState, setVoiceState])
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!voiceEnabled) return;
|
||||
if (!voiceEnabled) return
|
||||
|
||||
// PromptInput is not a valid transcript target — let the hold key
|
||||
// flow through instead of swallowing it into stale refs (#33556).
|
||||
@@ -476,32 +515,37 @@ export function useVoiceKeybindingHandler({
|
||||
// /plugin. Mirrors CommandKeybindingHandlers' isActive gate.
|
||||
// - isModalOverlayActive: overlay (permission dialog, Select with
|
||||
// onCancel) has focus; PromptInput is mounted but focus=false.
|
||||
if (!isActive || isModalOverlayActive) return;
|
||||
if (!isActive || isModalOverlayActive) return
|
||||
|
||||
// null means the user overrode the default (null-unbind/reassign) —
|
||||
// hold-to-talk is disabled via binding. To toggle the feature
|
||||
// itself, use /voice.
|
||||
if (voiceKeystroke === null) return;
|
||||
if (voiceKeystroke === null) return
|
||||
|
||||
// Match the configured key. Bare chars match by content (handles
|
||||
// batched auto-repeat like "vvv") with a modifier reject so e.g.
|
||||
// ctrl+v doesn't trip a "v" binding. Modifier combos go through
|
||||
// matchesKeyboardEvent (one event per repeat, no batching).
|
||||
let repeatCount: number;
|
||||
let repeatCount: number
|
||||
if (bareChar !== null) {
|
||||
if (e.ctrl || e.meta || e.shift) return;
|
||||
if (e.ctrl || e.meta || e.shift) return
|
||||
// When bound to space, also accept U+3000 (full-width space) —
|
||||
// CJK IMEs emit it for the same physical key.
|
||||
const normalized = bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key;
|
||||
const normalized =
|
||||
bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key
|
||||
// Fast-path: normal typing (any char that isn't the bound one)
|
||||
// bails here without allocating. The repeat() check only matters
|
||||
// for batched auto-repeat (input.length > 1) which is rare.
|
||||
if (normalized[0] !== bareChar) return;
|
||||
if (normalized.length > 1 && normalized !== bareChar.repeat(normalized.length)) return;
|
||||
repeatCount = normalized.length;
|
||||
if (normalized[0] !== bareChar) return
|
||||
if (
|
||||
normalized.length > 1 &&
|
||||
normalized !== bareChar.repeat(normalized.length)
|
||||
)
|
||||
return
|
||||
repeatCount = normalized.length
|
||||
} else {
|
||||
if (!matchesKeyboardEvent(e, voiceKeystroke)) return;
|
||||
repeatCount = 1;
|
||||
if (!matchesKeyboardEvent(e, voiceKeystroke)) return
|
||||
repeatCount = 1
|
||||
}
|
||||
|
||||
// Guard: only swallow keypresses when recording was triggered by
|
||||
@@ -511,22 +555,22 @@ export function useVoiceKeybindingHandler({
|
||||
// from the store so that if voiceHandleKeyEvent() fails to transition
|
||||
// state (module not loaded, stream unavailable) we don't permanently
|
||||
// swallow keypresses.
|
||||
const currentVoiceState = getVoiceState().voiceState;
|
||||
const currentVoiceState = getVoiceState().voiceState
|
||||
if (isHoldActiveRef.current && currentVoiceState !== 'idle') {
|
||||
// Already recording — swallow continued keypresses and forward
|
||||
// to voice for release detection. For bare chars, defensively
|
||||
// strip in case the text input handler fired before this one
|
||||
// (listener order is not guaranteed). Modifier combos don't
|
||||
// insert text, so nothing to strip.
|
||||
e.stopImmediatePropagation();
|
||||
e.stopImmediatePropagation()
|
||||
if (bareChar !== null) {
|
||||
stripTrailing(repeatCount, {
|
||||
char: bareChar,
|
||||
floor: recordingFloorRef.current
|
||||
});
|
||||
floor: recordingFloorRef.current,
|
||||
})
|
||||
}
|
||||
voiceHandleKeyEvent();
|
||||
return;
|
||||
voiceHandleKeyEvent()
|
||||
return
|
||||
}
|
||||
|
||||
// Non-hold recording (focus-mode) or processing is active.
|
||||
@@ -536,11 +580,12 @@ export function useVoiceKeybindingHandler({
|
||||
// hit the warmup else-branch (swallow only). Bare chars flow through
|
||||
// unconditionally — user may be typing during focus-recording.
|
||||
if (currentVoiceState !== 'idle') {
|
||||
if (bareChar === null) e.stopImmediatePropagation();
|
||||
return;
|
||||
if (bareChar === null) e.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
const countBefore = rapidCountRef.current;
|
||||
rapidCountRef.current += repeatCount;
|
||||
|
||||
const countBefore = rapidCountRef.current
|
||||
rapidCountRef.current += repeatCount
|
||||
|
||||
// ── Activation ────────────────────────────────────────────
|
||||
// Handled first so the warmup branch below does NOT also run
|
||||
@@ -550,42 +595,37 @@ export function useVoiceKeybindingHandler({
|
||||
// typed accidentally, so the hold threshold (which exists to
|
||||
// distinguish typing a space from holding space) doesn't apply.
|
||||
if (bareChar === null || rapidCountRef.current >= HOLD_THRESHOLD) {
|
||||
e.stopImmediatePropagation();
|
||||
e.stopImmediatePropagation()
|
||||
if (resetTimerRef.current) {
|
||||
clearTimeout(resetTimerRef.current);
|
||||
resetTimerRef.current = null;
|
||||
clearTimeout(resetTimerRef.current)
|
||||
resetTimerRef.current = null
|
||||
}
|
||||
rapidCountRef.current = 0;
|
||||
isHoldActiveRef.current = true;
|
||||
setVoiceState(prev_0 => {
|
||||
if (!prev_0.voiceWarmingUp) return prev_0;
|
||||
return {
|
||||
...prev_0,
|
||||
voiceWarmingUp: false
|
||||
};
|
||||
});
|
||||
rapidCountRef.current = 0
|
||||
isHoldActiveRef.current = true
|
||||
setVoiceState(prev => {
|
||||
if (!prev.voiceWarmingUp) return prev
|
||||
return { ...prev, voiceWarmingUp: false }
|
||||
})
|
||||
if (bareChar !== null) {
|
||||
// Strip the intentional warmup chars plus this event's leak
|
||||
// (if text input fired first). Cap covers both; min(trailing)
|
||||
// handles the no-leak case. Anchor the voice prefix here.
|
||||
// The return value (remaining) becomes the floor for
|
||||
// recording-time leak cleanup.
|
||||
recordingFloorRef.current = stripTrailing(charsInInputRef.current + repeatCount, {
|
||||
char: bareChar,
|
||||
anchor: true
|
||||
});
|
||||
charsInInputRef.current = 0;
|
||||
voiceHandleKeyEvent();
|
||||
recordingFloorRef.current = stripTrailing(
|
||||
charsInInputRef.current + repeatCount,
|
||||
{ char: bareChar, anchor: true },
|
||||
)
|
||||
charsInInputRef.current = 0
|
||||
voiceHandleKeyEvent()
|
||||
} else {
|
||||
// Modifier combo: nothing inserted, nothing to strip. Just
|
||||
// anchor the voice prefix at the current cursor position.
|
||||
// Longer fallback: this call is at t=0 (before auto-repeat),
|
||||
// so the gap to the next keypress is the OS initial repeat
|
||||
// *delay* (up to ~2s), not the repeat *rate* (~30-80ms).
|
||||
stripTrailing(0, {
|
||||
anchor: true
|
||||
});
|
||||
voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS);
|
||||
stripTrailing(0, { anchor: true })
|
||||
voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS)
|
||||
}
|
||||
// If voice failed to transition (module not loaded, stream
|
||||
// unavailable, stale enabled), clear the ref so a later
|
||||
@@ -594,10 +634,10 @@ export function useVoiceKeybindingHandler({
|
||||
// immediate. The anchor set by stripTrailing above will
|
||||
// be overwritten on retry (anchor always overwrites now).
|
||||
if (getVoiceState().voiceState === 'idle') {
|
||||
isHoldActiveRef.current = false;
|
||||
resetAnchor();
|
||||
isHoldActiveRef.current = false
|
||||
resetAnchor()
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// ── Warmup (bare-char only; modifier combos activated above) ──
|
||||
@@ -610,67 +650,74 @@ export function useVoiceKeybindingHandler({
|
||||
// no-op when nothing leaked. Check countBefore so the event that
|
||||
// crosses the threshold still flows through (terminal batching).
|
||||
if (countBefore >= WARMUP_THRESHOLD) {
|
||||
e.stopImmediatePropagation();
|
||||
e.stopImmediatePropagation()
|
||||
stripTrailing(repeatCount, {
|
||||
char: bareChar,
|
||||
floor: charsInInputRef.current
|
||||
});
|
||||
floor: charsInInputRef.current,
|
||||
})
|
||||
} else {
|
||||
charsInInputRef.current += repeatCount;
|
||||
charsInInputRef.current += repeatCount
|
||||
}
|
||||
|
||||
// Show warmup feedback once we detect a hold pattern
|
||||
if (rapidCountRef.current >= WARMUP_THRESHOLD) {
|
||||
setVoiceState(prev_1 => {
|
||||
if (prev_1.voiceWarmingUp) return prev_1;
|
||||
return {
|
||||
...prev_1,
|
||||
voiceWarmingUp: true
|
||||
};
|
||||
});
|
||||
setVoiceState(prev => {
|
||||
if (prev.voiceWarmingUp) return prev
|
||||
return { ...prev, voiceWarmingUp: true }
|
||||
})
|
||||
}
|
||||
|
||||
if (resetTimerRef.current) {
|
||||
clearTimeout(resetTimerRef.current);
|
||||
clearTimeout(resetTimerRef.current)
|
||||
}
|
||||
resetTimerRef.current = setTimeout((resetTimerRef_0, rapidCountRef_0, charsInInputRef_0, setVoiceState_0) => {
|
||||
resetTimerRef_0.current = null;
|
||||
rapidCountRef_0.current = 0;
|
||||
charsInInputRef_0.current = 0;
|
||||
setVoiceState_0(prev_2 => {
|
||||
if (!prev_2.voiceWarmingUp) return prev_2;
|
||||
return {
|
||||
...prev_2,
|
||||
voiceWarmingUp: false
|
||||
};
|
||||
});
|
||||
}, RAPID_KEY_GAP_MS, resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState);
|
||||
};
|
||||
resetTimerRef.current = setTimeout(
|
||||
(resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState) => {
|
||||
resetTimerRef.current = null
|
||||
rapidCountRef.current = 0
|
||||
charsInInputRef.current = 0
|
||||
setVoiceState(prev => {
|
||||
if (!prev.voiceWarmingUp) return prev
|
||||
return { ...prev, voiceWarmingUp: false }
|
||||
})
|
||||
},
|
||||
RAPID_KEY_GAP_MS,
|
||||
resetTimerRef,
|
||||
rapidCountRef,
|
||||
charsInInputRef,
|
||||
setVoiceState,
|
||||
)
|
||||
}
|
||||
|
||||
// Backward-compat bridge: REPL.tsx doesn't yet wire handleKeyDown to
|
||||
// <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
|
||||
// KeyboardEvent until the consumer is migrated (separate PR).
|
||||
// TODO(onKeyDown-migration): remove once REPL passes handleKeyDown.
|
||||
useInput((_input, _key, event) => {
|
||||
const kbEvent = new KeyboardEvent(event.keypress);
|
||||
handleKeyDown(kbEvent);
|
||||
// handleKeyDown stopped the adapter event, not the InputEvent the
|
||||
// emitter actually checks — forward it so the text input's useInput
|
||||
// listener is skipped and held spaces don't leak into the prompt.
|
||||
if (kbEvent.didStopImmediatePropagation()) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}, {
|
||||
isActive
|
||||
});
|
||||
return {
|
||||
handleKeyDown
|
||||
};
|
||||
useInput(
|
||||
(_input, _key, event) => {
|
||||
const kbEvent = new KeyboardEvent(event.keypress)
|
||||
handleKeyDown(kbEvent)
|
||||
// handleKeyDown stopped the adapter event, not the InputEvent the
|
||||
// emitter actually checks — forward it so the text input's useInput
|
||||
// listener is skipped and held spaces don't leak into the prompt.
|
||||
if (kbEvent.didStopImmediatePropagation()) {
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
)
|
||||
|
||||
return { handleKeyDown }
|
||||
}
|
||||
|
||||
// TODO(onKeyDown-migration): temporary shim so existing JSX callers
|
||||
// (<VoiceKeybindingHandler .../>) keep compiling. Remove once REPL.tsx
|
||||
// wires handleKeyDown directly.
|
||||
export function VoiceKeybindingHandler(props) {
|
||||
useVoiceKeybindingHandler(props);
|
||||
return null;
|
||||
export function VoiceKeybindingHandler(props: {
|
||||
voiceHandleKeyEvent: (fallbackMs?: number) => void
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number
|
||||
resetAnchor: () => void
|
||||
isActive: boolean
|
||||
}): null {
|
||||
useVoiceKeybindingHandler(props)
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user