fix: Batch 1a — WebFetchTool header 安全加固 + 核心运行时修复 (from upstream c2ac9a74)

来源: upstream c2ac9a74 (dependency audit findings)

变更 (11 files):
- WebFetchTool: getResponseHeader() 防大小写不一致绕过安全校验 + 新测试
- langfuse: Windows USERPROFILE + homedir() 路径脱敏支持
- REPL.tsx: hideTimeout 清理、onInit/diagnosticTracker ref 闭包修复
- claude.ts: 移除冗余 = undefined 初始化
- query.ts: 移除 biome-ignore + 变量声明简化
- staticRender.tsx: 静态渲染完善
- ansiToPng.ts/bridgeMain.ts/SSETransport.ts/WebSocketTransport.ts: 代码质量
- useGlobalKeybindings/useVoiceIntegration: 质量修复
- 测试文件: sliceAnsi/stringUtils/envExpansion 防模板字面量转义

Build:  565 files | Tests: 65/65 pass (0 new failures)
This commit is contained in:
James Feng 2026-06-04 15:45:01 +08:00
parent 9df9e03765
commit 10dfcc6710
11 changed files with 825 additions and 886 deletions

View File

@ -448,9 +448,11 @@ export async function runBridgeLoop(
): (status: SessionDoneStatus) => void { ): (status: SessionDoneStatus) => void {
return (rawStatus: SessionDoneStatus): void => { return (rawStatus: SessionDoneStatus): void => {
const workId = sessionWorkIds.get(sessionId) const workId = sessionWorkIds.get(sessionId)
rcLog(`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` + rcLog(
`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` +
` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` + ` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` +
` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`) ` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`,
)
activeSessions.delete(sessionId) activeSessions.delete(sessionId)
sessionStartTimes.delete(sessionId) sessionStartTimes.delete(sessionId)
sessionWorkIds.delete(sessionId) sessionWorkIds.delete(sessionId)
@ -609,7 +611,9 @@ export async function runBridgeLoop(
const pollConfig = getPollIntervalConfig() const pollConfig = getPollIntervalConfig()
try { try {
rcLog(`poll: envId=${environmentId} activeSessions=${activeSessions.size}`) rcLog(
`poll: envId=${environmentId} activeSessions=${activeSessions.size}`,
)
const work = await api.pollForWork( const work = await api.pollForWork(
environmentId, environmentId,
environmentSecret, environmentSecret,
@ -864,7 +868,9 @@ export async function runBridgeLoop(
break break
case 'session': { case 'session': {
const sessionId = work.data.id const sessionId = work.data.id
rcLog(`work received: type=session sessionId=${sessionId} workId=${work.id}`) rcLog(
`work received: type=session sessionId=${sessionId} workId=${work.id}`,
)
try { try {
validateBridgeId(sessionId, 'session_id') validateBridgeId(sessionId, 'session_id')
} catch { } catch {
@ -1676,7 +1682,7 @@ async function stopWorkWithRetry(
} }
const errMsg = errorMessage(err) const errMsg = errorMessage(err)
if (attempt < MAX_ATTEMPTS) { if (attempt < MAX_ATTEMPTS) {
const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1)) const delay = addJitter(baseDelayMs * 2 ** (attempt - 1))
logger.logVerbose( logger.logVerbose(
`Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`, `Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`,
) )

View File

@ -82,9 +82,7 @@ export function parseSSEFrames(buffer: string): {
for (const rawLine of rawFrame.split('\n')) { for (const rawLine of rawFrame.split('\n')) {
// Normalize CRLF lines in mixed-line-ending streams. // Normalize CRLF lines in mixed-line-ending streams.
const line = const line =
rawLine[rawLine.length - 1] === '\r' rawLine[rawLine.length - 1] === '\r' ? rawLine.slice(0, -1) : rawLine
? rawLine.slice(0, -1)
: rawLine
if (line.startsWith(':')) { if (line.startsWith(':')) {
// SSE comment (e.g., `:keepalive`) // SSE comment (e.g., `:keepalive`)
@ -527,7 +525,7 @@ export class SSETransport implements Transport {
this.reconnectAttempts++ this.reconnectAttempts++
const baseDelay = Math.min( const baseDelay = Math.min(
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1), RECONNECT_BASE_DELAY_MS * 2 ** (this.reconnectAttempts - 1),
RECONNECT_MAX_DELAY_MS, RECONNECT_MAX_DELAY_MS,
) )
// Add ±25% jitter // Add ±25% jitter
@ -677,7 +675,7 @@ export class SSETransport implements Transport {
} }
const delayMs = Math.min( const delayMs = Math.min(
POST_BASE_DELAY_MS * Math.pow(2, attempt - 1), POST_BASE_DELAY_MS * 2 ** (attempt - 1),
POST_MAX_DELAY_MS, POST_MAX_DELAY_MS,
) )
await sleep(delayMs) await sleep(delayMs)

View File

@ -516,7 +516,7 @@ export class WebSocketTransport implements Transport {
this.reconnectAttempts++ this.reconnectAttempts++
const baseDelay = Math.min( const baseDelay = Math.min(
DEFAULT_BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1), DEFAULT_BASE_RECONNECT_DELAY * 2 ** (this.reconnectAttempts - 1),
DEFAULT_MAX_RECONNECT_DELAY, DEFAULT_MAX_RECONNECT_DELAY,
) )
// Add ±25% jitter to avoid thundering herd // Add ±25% jitter to avoid thundering herd

View File

@ -4,31 +4,31 @@
* Must be rendered inside KeybindingSetup to have access to the keybinding context. * Must be rendered inside KeybindingSetup to have access to the keybinding context.
* This component renders nothing - it just registers the keybinding handlers. * This component renders nothing - it just registers the keybinding handlers.
*/ */
import { feature } from 'bun:bundle' import { feature } from 'bun:bundle';
import { useCallback } from 'react' import { useCallback } from 'react';
import { instances } from '@anthropic/ink' import { instances } from '@anthropic/ink';
import { useKeybinding } from '../keybindings/useKeybinding.js' import { useKeybinding } from '../keybindings/useKeybinding.js';
import type { Screen } from '../screens/REPL.js' import type { Screen } from '../screens/REPL.js';
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
import { import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent, logEvent,
} from '../services/analytics/index.js' } from '../services/analytics/index.js';
import { useAppState, useSetAppState } from '../state/AppState.js' import { useAppState, useSetAppState } from '../state/AppState.js';
import { count } from '../utils/array.js' import { count } from '../utils/array.js';
import { getTerminalPanel } from '../utils/terminalPanel.js' import { getTerminalPanel } from '../utils/terminalPanel.js';
type Props = { type Props = {
screen: Screen screen: Screen;
setScreen: React.Dispatch<React.SetStateAction<Screen>> setScreen: React.Dispatch<React.SetStateAction<Screen>>;
showAllInTranscript: boolean showAllInTranscript: boolean;
setShowAllInTranscript: React.Dispatch<React.SetStateAction<boolean>> setShowAllInTranscript: React.Dispatch<React.SetStateAction<boolean>>;
messageCount: number messageCount: number;
onEnterTranscript?: () => void onEnterTranscript?: () => void;
onExitTranscript?: () => void onExitTranscript?: () => void;
virtualScrollActive?: boolean virtualScrollActive?: boolean;
searchBarOpen?: boolean searchBarOpen?: boolean;
} };
/** /**
* Registers global keybinding handlers for: * Registers global keybinding handlers for:
@ -48,53 +48,42 @@ export function GlobalKeybindingHandlers({
virtualScrollActive, virtualScrollActive,
searchBarOpen = false, searchBarOpen = false,
}: Props): null { }: Props): null {
const expandedView = useAppState(s => s.expandedView) const expandedView = useAppState(s => s.expandedView);
const setAppState = useSetAppState() const setAppState = useSetAppState();
// Toggle todo list (ctrl+t) - cycles through views // Toggle todo list (ctrl+t) - cycles through views
const handleToggleTodos = useCallback(() => { const handleToggleTodos = useCallback(() => {
logEvent('tengu_toggle_todos', { logEvent('tengu_toggle_todos', {
is_expanded: expandedView === 'tasks', is_expanded: expandedView === 'tasks',
}) });
setAppState(prev => { setAppState(prev => {
const { getAllInProcessTeammateTasks } = const { getAllInProcessTeammateTasks } =
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js');
const hasTeammates = const hasTeammates = count(getAllInProcessTeammateTasks(prev.tasks), t => t.status === 'running') > 0;
count(
getAllInProcessTeammateTasks(prev.tasks),
t => t.status === 'running',
) > 0
if (hasTeammates) { if (hasTeammates) {
// Both exist: none → tasks → teammates → none // Both exist: none → tasks → teammates → none
switch (prev.expandedView) { switch (prev.expandedView) {
case 'none': case 'none':
return { ...prev, expandedView: 'tasks' as const } return { ...prev, expandedView: 'tasks' as const };
case 'tasks': case 'tasks':
return { ...prev, expandedView: 'teammates' as const } return { ...prev, expandedView: 'teammates' as const };
case 'teammates': case 'teammates':
return { ...prev, expandedView: 'none' as const } return { ...prev, expandedView: 'none' as const };
} }
} }
// Only tasks: none ↔ tasks // Only tasks: none ↔ tasks
return { return {
...prev, ...prev,
expandedView: expandedView: prev.expandedView === 'tasks' ? ('none' as const) : ('tasks' as const),
prev.expandedView === 'tasks' };
? ('none' as const) });
: ('tasks' as const), }, [expandedView, setAppState]);
}
})
}, [expandedView, setAppState])
// Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript. // Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript.
// Brief view has its own dedicated toggle on ctrl+shift+b. // Brief view has its own dedicated toggle on ctrl+shift+b.
const isBriefOnly = const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s => s.isBriefOnly)
: false
const handleToggleTranscript = useCallback(() => { const handleToggleTranscript = useCallback(() => {
if (feature('KAIROS') || feature('KAIROS_BRIEF')) { if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
// Escape hatch: GB kill-switch while defaultView=chat was persisted // Escape hatch: GB kill-switch while defaultView=chat was persisted
@ -104,30 +93,30 @@ export function GlobalKeybindingHandlers({
// isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode). // isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode).
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const { isBriefEnabled } = const { isBriefEnabled } =
require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js');
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') { if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') {
setAppState(prev => { setAppState(prev => {
if (!prev.isBriefOnly) return prev if (!prev.isBriefOnly) return prev;
return { ...prev, isBriefOnly: false } return { ...prev, isBriefOnly: false };
}) });
return return;
} }
} }
const isEnteringTranscript = screen !== 'transcript' const isEnteringTranscript = screen !== 'transcript';
logEvent('tengu_toggle_transcript', { logEvent('tengu_toggle_transcript', {
is_entering: isEnteringTranscript, is_entering: isEnteringTranscript,
show_all: showAllInTranscript, show_all: showAllInTranscript,
message_count: messageCount, message_count: messageCount,
}) });
setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript')) setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript'));
setShowAllInTranscript(false) setShowAllInTranscript(false);
if (isEnteringTranscript && onEnterTranscript) { if (isEnteringTranscript && onEnterTranscript) {
onEnterTranscript() onEnterTranscript();
} }
if (!isEnteringTranscript && onExitTranscript) { if (!isEnteringTranscript && onExitTranscript) {
onExitTranscript() onExitTranscript();
} }
}, [ }, [
screen, screen,
@ -139,35 +128,29 @@ export function GlobalKeybindingHandlers({
setAppState, setAppState,
onEnterTranscript, onEnterTranscript,
onExitTranscript, onExitTranscript,
]) ]);
// Toggle showing all messages in transcript mode (ctrl+e) // Toggle showing all messages in transcript mode (ctrl+e)
const handleToggleShowAll = useCallback(() => { const handleToggleShowAll = useCallback(() => {
logEvent('tengu_transcript_toggle_show_all', { logEvent('tengu_transcript_toggle_show_all', {
is_expanding: !showAllInTranscript, is_expanding: !showAllInTranscript,
message_count: messageCount, message_count: messageCount,
}) });
setShowAllInTranscript(prev => !prev) setShowAllInTranscript(prev => !prev);
}, [showAllInTranscript, setShowAllInTranscript, messageCount]) }, [showAllInTranscript, setShowAllInTranscript, messageCount]);
// Exit transcript mode (ctrl+c or escape) // Exit transcript mode (ctrl+c or escape)
const handleExitTranscript = useCallback(() => { const handleExitTranscript = useCallback(() => {
logEvent('tengu_transcript_exit', { logEvent('tengu_transcript_exit', {
show_all: showAllInTranscript, show_all: showAllInTranscript,
message_count: messageCount, message_count: messageCount,
}) });
setScreen('prompt') setScreen('prompt');
setShowAllInTranscript(false) setShowAllInTranscript(false);
if (onExitTranscript) { if (onExitTranscript) {
onExitTranscript() onExitTranscript();
} }
}, [ }, [setScreen, showAllInTranscript, setShowAllInTranscript, messageCount, onExitTranscript]);
setScreen,
showAllInTranscript,
setShowAllInTranscript,
messageCount,
onExitTranscript,
])
// Toggle brief-only view (ctrl+shift+b). Pure display filter toggle — // Toggle brief-only view (ctrl+shift+b). Pure display filter toggle —
// does not touch opt-in state. Asymmetric gate (mirrors /brief): OFF // does not touch opt-in state. Asymmetric gate (mirrors /brief): OFF
@ -177,35 +160,33 @@ export function GlobalKeybindingHandlers({
if (feature('KAIROS') || feature('KAIROS_BRIEF')) { if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const { isBriefEnabled } = const { isBriefEnabled } =
require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js');
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
if (!isBriefEnabled() && !isBriefOnly) return if (!isBriefEnabled() && !isBriefOnly) return;
const next = !isBriefOnly const next = !isBriefOnly;
logEvent('tengu_brief_mode_toggled', { logEvent('tengu_brief_mode_toggled', {
enabled: next, enabled: next,
gated: false, gated: false,
source: source: 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, });
})
setAppState(prev => { setAppState(prev => {
if (prev.isBriefOnly === next) return prev if (prev.isBriefOnly === next) return prev;
return { ...prev, isBriefOnly: next } return { ...prev, isBriefOnly: next };
}) });
} }
}, [isBriefOnly, setAppState]) }, [isBriefOnly, setAppState]);
// Register keybinding handlers // Register keybinding handlers
useKeybinding('app:toggleTodos', handleToggleTodos, { useKeybinding('app:toggleTodos', handleToggleTodos, {
context: 'Global', context: 'Global',
}) });
useKeybinding('app:toggleTranscript', handleToggleTranscript, { useKeybinding('app:toggleTranscript', handleToggleTranscript, {
context: 'Global', context: 'Global',
}) });
if (feature('KAIROS') || feature('KAIROS_BRIEF')) { if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useKeybinding('app:toggleBrief', handleToggleBrief, { useKeybinding('app:toggleBrief', handleToggleBrief, {
context: 'Global', context: 'Global',
}) });
} }
// Register teammate keybinding // Register teammate keybinding
@ -215,41 +196,41 @@ export function GlobalKeybindingHandlers({
setAppState(prev => ({ setAppState(prev => ({
...prev, ...prev,
showTeammateMessagePreview: !prev.showTeammateMessagePreview, showTeammateMessagePreview: !prev.showTeammateMessagePreview,
})) }));
}, },
{ {
context: 'Global', context: 'Global',
}, },
) );
// Toggle built-in terminal panel (meta+j). // Toggle built-in terminal panel (meta+j).
// toggle() blocks in spawnSync until the user detaches from tmux. // toggle() blocks in spawnSync until the user detaches from tmux.
const handleToggleTerminal = useCallback(() => { const handleToggleTerminal = useCallback(() => {
if (feature('TERMINAL_PANEL')) { if (feature('TERMINAL_PANEL')) {
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) { if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) {
return return;
} }
getTerminalPanel().toggle() getTerminalPanel().toggle();
} }
}, []) }, []);
useKeybinding('app:toggleTerminal', handleToggleTerminal, { useKeybinding('app:toggleTerminal', handleToggleTerminal, {
context: 'Global', context: 'Global',
}) });
// Clear screen and force full redraw (ctrl+l). Recovery path when the // Clear screen and force full redraw (ctrl+l). Recovery path when the
// terminal was cleared externally (macOS Cmd+K) and Ink's diff engine // terminal was cleared externally (macOS Cmd+K) and Ink's diff engine
// thinks unchanged cells don't need repainting. // thinks unchanged cells don't need repainting.
const handleRedraw = useCallback(() => { const handleRedraw = useCallback(() => {
instances.get(process.stdout)?.forceRedraw() instances.get(process.stdout)?.forceRedraw();
}, []) }, []);
useKeybinding('app:redraw', handleRedraw, { context: 'Global' }) useKeybinding('app:redraw', handleRedraw, { context: 'Global' });
// Transcript-specific bindings (only active when in transcript mode) // Transcript-specific bindings (only active when in transcript mode)
const isInTranscript = screen === 'transcript' const isInTranscript = screen === 'transcript';
useKeybinding('transcript:toggleShowAll', handleToggleShowAll, { useKeybinding('transcript:toggleShowAll', handleToggleShowAll, {
context: 'Transcript', context: 'Transcript',
isActive: isInTranscript && !virtualScrollActive, isActive: isInTranscript && !virtualScrollActive,
}) });
useKeybinding('transcript:exit', handleExitTranscript, { useKeybinding('transcript:exit', handleExitTranscript, {
context: 'Transcript', context: 'Transcript',
// Bar-open is a mode (owns keystrokes). Navigating (highlights // Bar-open is a mode (owns keystrokes). Navigating (highlights
@ -258,7 +239,7 @@ export function GlobalKeybindingHandlers({
// so without this gate its onCancel AND this handler would both // so without this gate its onCancel AND this handler would both
// fire on one Esc (child registers first, fires first, bubbles). // fire on one Esc (child registers first, fires first, bubbles).
isActive: isInTranscript && !searchBarOpen, isActive: isInTranscript && !searchBarOpen,
}) });
return null return null;
} }

View File

@ -1,84 +1,69 @@
import { feature } from 'bun:bundle' import { feature } from 'bun:bundle';
import * as React from 'react' import * as React from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useNotifications } from '../context/notifications.js' import { useNotifications } from '../context/notifications.js';
import { useIsModalOverlayActive } from '../context/overlayContext.js' import { useIsModalOverlayActive } from '../context/overlayContext.js';
import { import { useGetVoiceState, useSetVoiceState, useVoiceState } from '../context/voice.js';
useGetVoiceState, import { KeyboardEvent, useInput } from '@anthropic/ink';
useSetVoiceState,
useVoiceState,
} from '../context/voice.js'
import { KeyboardEvent, useInput } from '@anthropic/ink'
// backward-compat bridge until REPL wires handleKeyDown to <Box onKeyDown> // backward-compat bridge until REPL wires handleKeyDown to <Box onKeyDown>
import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js' import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js';
import { keystrokesEqual } from '../keybindings/resolver.js' import { keystrokesEqual } from '../keybindings/resolver.js';
import type { ParsedKeystroke } from '../keybindings/types.js' import type { ParsedKeystroke } from '../keybindings/types.js';
import { normalizeFullWidthSpace } from '../utils/stringUtils.js' import { normalizeFullWidthSpace } from '../utils/stringUtils.js';
import { useVoiceEnabled } from './useVoiceEnabled.js' import { useVoiceEnabled } from './useVoiceEnabled.js';
// Dead code elimination: conditional import for voice input hook. // Dead code elimination: conditional import for voice input hook.
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
// Capture the module namespace, not the function: spyOn() mutates the module // Capture the module namespace, not the function: spyOn() mutates the module
// object, so `voiceNs.useVoice(...)` resolves to the spy even if this module // object, so `voiceNs.useVoice(...)` resolves to the spy even if this module
// was loaded before the spy was installed (test ordering independence). // was loaded before the spy was installed (test ordering independence).
const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature( const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature('VOICE_MODE')
'VOICE_MODE',
)
? require('./useVoice.js') ? require('./useVoice.js')
: { : {
useVoice: ({ useVoice: ({ enabled: _e }: { onTranscript: (t: string) => void; enabled: boolean }) => ({
enabled: _e,
}: {
onTranscript: (t: string) => void
enabled: boolean
}) => ({
state: 'idle' as const, state: 'idle' as const,
handleKeyEvent: (_fallbackMs?: number) => {}, handleKeyEvent: (_fallbackMs?: number) => {},
}), }),
} };
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
// Maximum gap (ms) between key presses to count as held (auto-repeat). // Maximum gap (ms) between key presses to count as held (auto-repeat).
// Terminal auto-repeat fires every 30-80ms; 120ms covers jitter while // Terminal auto-repeat fires every 30-80ms; 120ms covers jitter while
// excluding normal typing speed (100-300ms between keystrokes). // 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 // Fallback (ms) for modifier-combo first-press activation. Must match
// FIRST_PRESS_FALLBACK_MS in useVoice.ts. Covers the max OS initial // 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 // key-repeat delay (~2s on macOS with slider at "Long") so holding a
// modifier combo doesn't fragment into two sessions when the first // modifier combo doesn't fragment into two sessions when the first
// auto-repeat arrives after the default 600ms REPEAT_FALLBACK_MS. // 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. // Number of rapid consecutive key events required to activate voice.
// Only applies to bare-char bindings (space, v, etc.) where a single press // Only applies to bare-char bindings (space, v, etc.) where a single press
// could be normal typing. Modifier combos activate on the first 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. // 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 // Match a KeyboardEvent against a ParsedKeystroke. Replaces the legacy
// matchesKeystroke(input, Key, ...) path which assumed useInput's raw // matchesKeystroke(input, Key, ...) path which assumed useInput's raw
// `input` arg — KeyboardEvent.key holds normalized names (e.g. 'space', // `input` arg — KeyboardEvent.key holds normalized names (e.g. 'space',
// 'f9') that getKeyName() didn't handle, so modifier combos and f-keys // 'f9') that getKeyName() didn't handle, so modifier combos and f-keys
// silently failed to match after the onKeyDown migration (#23524). // silently failed to match after the onKeyDown migration (#23524).
function matchesKeyboardEvent( function matchesKeyboardEvent(e: KeyboardEvent, target: ParsedKeystroke): boolean {
e: KeyboardEvent,
target: ParsedKeystroke,
): boolean {
// KeyboardEvent stores key names; ParsedKeystroke stores ' ' for space // KeyboardEvent stores key names; ParsedKeystroke stores ' ' for space
// and 'enter' for return (see parser.ts case 'space'/'return'). // and 'enter' for return (see parser.ts case 'space'/'return').
const key = const key = e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase();
e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase() if (key !== target.key) return false;
if (key !== target.key) return false if (e.ctrl !== target.ctrl) return false;
if (e.ctrl !== target.ctrl) return false if (e.shift !== target.shift) return false;
if (e.shift !== target.shift) return false
// KeyboardEvent.meta folds alt|option (terminal limitation — esc-prefix); // KeyboardEvent.meta folds alt|option (terminal limitation — esc-prefix);
// ParsedKeystroke has both alt and meta as aliases for the same thing. // ParsedKeystroke has both alt and meta as aliases for the same thing.
if (e.meta !== (target.alt || target.meta)) return false if (e.meta !== (target.alt || target.meta)) return false;
if (e.superKey !== target.super) return false if (e.superKey !== target.super) return false;
return true return true;
} }
// Hardcoded default for when there's no KeybindingProvider at all (e.g. // Hardcoded default for when there's no KeybindingProvider at all (e.g.
@ -92,60 +77,60 @@ const DEFAULT_VOICE_KEYSTROKE: ParsedKeystroke = {
shift: false, shift: false,
meta: false, meta: false,
super: false, super: false,
} };
type InsertTextHandle = { type InsertTextHandle = {
insert: (text: string) => void insert: (text: string) => void;
setInputWithCursor: (value: string, cursor: number) => void setInputWithCursor: (value: string, cursor: number) => void;
cursorOffset: number cursorOffset: number;
} };
type UseVoiceIntegrationArgs = { type UseVoiceIntegrationArgs = {
setInputValueRaw: React.Dispatch<React.SetStateAction<string>> setInputValueRaw: React.Dispatch<React.SetStateAction<string>>;
inputValueRef: React.RefObject<string> inputValueRef: React.RefObject<string>;
insertTextRef: React.RefObject<InsertTextHandle | null> insertTextRef: React.RefObject<InsertTextHandle | null>;
} };
type InterimRange = { start: number; end: number } type InterimRange = { start: number; end: number };
type StripOpts = { type StripOpts = {
// Which char to strip (the configured hold key). Defaults to space. // 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. // Capture the voice prefix/suffix anchor at the stripped position.
anchor?: boolean anchor?: boolean;
// Minimum trailing count to leave behind — prevents stripping the // Minimum trailing count to leave behind — prevents stripping the
// intentional warmup chars when defensively cleaning up leaks. // intentional warmup chars when defensively cleaning up leaks.
floor?: number floor?: number;
} };
type UseVoiceIntegrationResult = { type UseVoiceIntegrationResult = {
// Returns the number of trailing chars remaining after stripping. // 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. // Undo the gap space and reset anchor refs after a failed voice activation.
resetAnchor: () => void resetAnchor: () => void;
handleKeyEvent: (fallbackMs?: number) => void handleKeyEvent: (fallbackMs?: number) => void;
interimRange: InterimRange | null interimRange: InterimRange | null;
} };
export function useVoiceIntegration({ export function useVoiceIntegration({
setInputValueRaw, setInputValueRaw,
inputValueRef, inputValueRef,
insertTextRef, insertTextRef,
}: UseVoiceIntegrationArgs): UseVoiceIntegrationResult { }: UseVoiceIntegrationArgs): UseVoiceIntegrationResult {
const { addNotification } = useNotifications() const { addNotification } = useNotifications();
// Tracks the input content before/after the cursor when voice starts, // Tracks the input content before/after the cursor when voice starts,
// so interim transcripts can be inserted at the cursor position without // so interim transcripts can be inserted at the cursor position without
// clobbering surrounding user text. // clobbering surrounding user text.
const voicePrefixRef = useRef<string | null>(null) const voicePrefixRef = useRef<string | null>(null);
const voiceSuffixRef = useRef<string>('') const voiceSuffixRef = useRef<string>('');
// Tracks the last input value this hook wrote (via anchor, interim effect, // Tracks the last input value this hook wrote (via anchor, interim effect,
// or handleVoiceTranscript). If inputValueRef.current diverges, the user // or handleVoiceTranscript). If inputValueRef.current diverges, the user
// submitted or edited — both write paths bail to avoid clobbering. This is // submitted or edited — both write paths bail to avoid clobbering. This is
// the only guard that correctly handles empty-prefix-empty-suffix: a // the only guard that correctly handles empty-prefix-empty-suffix: a
// startsWith('')/endsWith('') check vacuously passes, and a length check // startsWith('')/endsWith('') check vacuously passes, and a length check
// can't distinguish a cleared input from a never-set one. // 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 // Strip trailing hold-key chars (and optionally capture the voice
// anchor). Called during warmup (to clean up chars that leaked past // anchor). Called during warmup (to clean up chars that leaked past
@ -160,29 +145,22 @@ export function useVoiceIntegration({
// trailing chars remaining after stripping. When nothing changes, no // trailing chars remaining after stripping. When nothing changes, no
// state update is performed. // state update is performed.
const stripTrailing = useCallback( const stripTrailing = useCallback(
( (maxStrip: number, { char = ' ', anchor = false, floor = 0 }: StripOpts = {}) => {
maxStrip: number, const prev = inputValueRef.current;
{ char = ' ', anchor = false, floor = 0 }: StripOpts = {}, const offset = insertTextRef.current?.cursorOffset ?? prev.length;
) => { const beforeCursor = prev.slice(0, offset);
const prev = inputValueRef.current const afterCursor = prev.slice(offset);
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) // 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. // that a CJK IME may have inserted for the same physical key.
// U+3000 is BMP single-code-unit so indices align with beforeCursor. // U+3000 is BMP single-code-unit so indices align with beforeCursor.
const scan = const scan = char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor;
char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor let trailing = 0;
let trailing = 0 while (trailing < scan.length && scan[scan.length - 1 - trailing] === char) {
while ( trailing++;
trailing < scan.length &&
scan[scan.length - 1 - trailing] === char
) {
trailing++
} }
const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip)) const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip));
const remaining = trailing - stripCount const remaining = trailing - stripCount;
const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount) const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount);
// When anchoring with a non-space suffix, insert a gap space so the // When anchoring with a non-space suffix, insert a gap space so the
// waveform cursor sits on the gap instead of covering the first // waveform cursor sits on the gap instead of covering the first
// suffix letter. The interim transcript effect maintains this same // suffix letter. The interim transcript effect maintains this same
@ -192,26 +170,26 @@ export function useVoiceIntegration({
// voice (voiceState stayed 'idle'), the cleanup effect didn't fire and // voice (voiceState stayed 'idle'), the cleanup effect didn't fire and
// the old anchor is stale. anchor=true is only passed on the single // the old anchor is stale. anchor=true is only passed on the single
// activation call, never during recording, so overwrite is safe. // activation call, never during recording, so overwrite is safe.
let gap = '' let gap = '';
if (anchor) { if (anchor) {
voicePrefixRef.current = stripped voicePrefixRef.current = stripped;
voiceSuffixRef.current = afterCursor voiceSuffixRef.current = afterCursor;
if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) { if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) {
gap = ' ' gap = ' ';
} }
} }
const newValue = stripped + gap + afterCursor const newValue = stripped + gap + afterCursor;
if (anchor) lastSetInputRef.current = newValue if (anchor) lastSetInputRef.current = newValue;
if (newValue === prev && stripCount === 0) return remaining if (newValue === prev && stripCount === 0) return remaining;
if (insertTextRef.current) { if (insertTextRef.current) {
insertTextRef.current.setInputWithCursor(newValue, stripped.length) insertTextRef.current.setInputWithCursor(newValue, stripped.length);
} else { } else {
setInputValueRaw(newValue) setInputValueRaw(newValue);
} }
return remaining return remaining;
}, },
[setInputValueRaw, inputValueRef, insertTextRef], [setInputValueRaw, inputValueRef, insertTextRef],
) );
// Undo the gap space inserted by stripTrailing(..., {anchor:true}) and // Undo the gap space inserted by stripTrailing(..., {anchor:true}) and
// reset the voice prefix/suffix refs. Called when voice activation fails // reset the voice prefix/suffix refs. Called when voice activation fails
@ -220,123 +198,109 @@ export function useVoiceIntegration({
// reach the stale anchor. Without this, the gap space and stale refs // reach the stale anchor. Without this, the gap space and stale refs
// persist in the input. // persist in the input.
const resetAnchor = useCallback(() => { const resetAnchor = useCallback(() => {
const prefix = voicePrefixRef.current const prefix = voicePrefixRef.current;
if (prefix === null) return if (prefix === null) return;
const suffix = voiceSuffixRef.current const suffix = voiceSuffixRef.current;
voicePrefixRef.current = null voicePrefixRef.current = null;
voiceSuffixRef.current = '' voiceSuffixRef.current = '';
const restored = prefix + suffix const restored = prefix + suffix;
if (insertTextRef.current) { if (insertTextRef.current) {
insertTextRef.current.setInputWithCursor(restored, prefix.length) insertTextRef.current.setInputWithCursor(restored, prefix.length);
} else { } else {
setInputValueRaw(restored) setInputValueRaw(restored);
} }
}, [setInputValueRaw, insertTextRef]) }, [setInputValueRaw, insertTextRef]);
// Voice state selectors. useVoiceEnabled = user intent (settings) + // Voice state selectors. useVoiceEnabled = user intent (settings) +
// auth + GB kill-switch, with the auth half memoized on authVersion so // auth + GB kill-switch, with the auth half memoized on authVersion so
// render loops never hit a cold keychain spawn. // 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 voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceState = feature('VOICE_MODE') const voiceInterimTranscript = feature('VOICE_MODE') ? useVoiceState(s => s.voiceInterimTranscript) : '';
? // 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 // Set the voice anchor for focus mode (where recording starts via terminal
// focus, not key hold). Key-hold sets the anchor in stripTrailing. // focus, not key hold). Key-hold sets the anchor in stripTrailing.
useEffect(() => { useEffect(() => {
if (!feature('VOICE_MODE')) return if (!feature('VOICE_MODE')) return;
if (voiceState === 'recording' && voicePrefixRef.current === null) { if (voiceState === 'recording' && voicePrefixRef.current === null) {
const input = inputValueRef.current const input = inputValueRef.current;
const offset = insertTextRef.current?.cursorOffset ?? input.length const offset = insertTextRef.current?.cursorOffset ?? input.length;
voicePrefixRef.current = input.slice(0, offset) voicePrefixRef.current = input.slice(0, offset);
voiceSuffixRef.current = input.slice(offset) voiceSuffixRef.current = input.slice(offset);
lastSetInputRef.current = input lastSetInputRef.current = input;
} }
if (voiceState === 'idle') { if (voiceState === 'idle') {
voicePrefixRef.current = null voicePrefixRef.current = null;
voiceSuffixRef.current = '' voiceSuffixRef.current = '';
lastSetInputRef.current = null lastSetInputRef.current = null;
} }
}, [voiceState, inputValueRef, insertTextRef]) }, [voiceState, inputValueRef, insertTextRef]);
// Live-update the prompt input with the interim transcript as voice // Live-update the prompt input with the interim transcript as voice
// transcribes speech. The prefix (user-typed text before the cursor) is // transcribes speech. The prefix (user-typed text before the cursor) is
// preserved and the transcript is inserted between prefix and suffix. // preserved and the transcript is inserted between prefix and suffix.
useEffect(() => { useEffect(() => {
if (!feature('VOICE_MODE')) return if (!feature('VOICE_MODE')) return;
if (voicePrefixRef.current === null) return if (voicePrefixRef.current === null) return;
const prefix = voicePrefixRef.current const prefix = voicePrefixRef.current;
const suffix = voiceSuffixRef.current const suffix = voiceSuffixRef.current;
// Submit race: if the input isn't what this hook last set it to, the // 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 // user submitted (clearing it) or edited it. voicePrefixRef is only
// cleared on voiceState→idle, so it's still set during the 'processing' // cleared on voiceState→idle, so it's still set during the 'processing'
// window between CloseStream and WS close — this catches refined // window between CloseStream and WS close — this catches refined
// TranscriptText arriving then and re-filling a cleared input. // TranscriptText arriving then and re-filling a cleared input.
if (inputValueRef.current !== lastSetInputRef.current) return if (inputValueRef.current !== lastSetInputRef.current) return;
const needsSpace = const needsSpace = prefix.length > 0 && !/\s$/.test(prefix) && voiceInterimTranscript.length > 0;
prefix.length > 0 &&
!/\s$/.test(prefix) &&
voiceInterimTranscript.length > 0
// Don't gate on voiceInterimTranscript.length -- when interim clears to '' // Don't gate on voiceInterimTranscript.length -- when interim clears to ''
// after handleVoiceTranscript sets the final text, the trailing space // after handleVoiceTranscript sets the final text, the trailing space
// between prefix and suffix must still be preserved. // between prefix and suffix must still be preserved.
const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix) const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix);
const leadingSpace = needsSpace ? ' ' : '' const leadingSpace = needsSpace ? ' ' : '';
const trailingSpace = needsTrailingSpace ? ' ' : '' const trailingSpace = needsTrailingSpace ? ' ' : '';
const newValue = const newValue = prefix + leadingSpace + voiceInterimTranscript + trailingSpace + suffix;
prefix + leadingSpace + voiceInterimTranscript + trailingSpace + suffix
// Position cursor after the transcribed text (before suffix) // Position cursor after the transcribed text (before suffix)
const cursorPos = const cursorPos = prefix.length + leadingSpace.length + voiceInterimTranscript.length;
prefix.length + leadingSpace.length + voiceInterimTranscript.length
if (insertTextRef.current) { if (insertTextRef.current) {
insertTextRef.current.setInputWithCursor(newValue, cursorPos) insertTextRef.current.setInputWithCursor(newValue, cursorPos);
} else { } else {
setInputValueRaw(newValue) setInputValueRaw(newValue);
} }
lastSetInputRef.current = newValue lastSetInputRef.current = newValue;
}, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]) }, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]);
const handleVoiceTranscript = useCallback( const handleVoiceTranscript = useCallback(
(text: string) => { (text: string) => {
if (!feature('VOICE_MODE')) return if (!feature('VOICE_MODE')) return;
const prefix = voicePrefixRef.current const prefix = voicePrefixRef.current;
// No voice anchor — voice was reset (or never started). Nothing to do. // No voice anchor — voice was reset (or never started). Nothing to do.
if (prefix === null) return if (prefix === null) return;
const suffix = voiceSuffixRef.current const suffix = voiceSuffixRef.current;
// Submit race: finishRecording() → user presses Enter (input cleared) // Submit race: finishRecording() → user presses Enter (input cleared)
// → WebSocket close → this callback fires with stale prefix/suffix. // → WebSocket close → this callback fires with stale prefix/suffix.
// If the input isn't what this hook last set (via the interim effect // 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 // or anchor), the user submitted or edited — don't re-fill. Comparing
// against `text.length` would false-positive when the final is longer // against `text.length` would false-positive when the final is longer
// than the interim (ASR routinely adds punctuation/corrections). // than the interim (ASR routinely adds punctuation/corrections).
if (inputValueRef.current !== lastSetInputRef.current) return if (inputValueRef.current !== lastSetInputRef.current) return;
const needsSpace = const needsSpace = prefix.length > 0 && !/\s$/.test(prefix) && text.length > 0;
prefix.length > 0 && !/\s$/.test(prefix) && text.length > 0 const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix) && text.length > 0;
const needsTrailingSpace = const leadingSpace = needsSpace ? ' ' : '';
suffix.length > 0 && !/^\s/.test(suffix) && text.length > 0 const trailingSpace = needsTrailingSpace ? ' ' : '';
const leadingSpace = needsSpace ? ' ' : '' const newInput = prefix + leadingSpace + text + trailingSpace + suffix;
const trailingSpace = needsTrailingSpace ? ' ' : ''
const newInput = prefix + leadingSpace + text + trailingSpace + suffix
// Position cursor after the transcribed text (before suffix) // Position cursor after the transcribed text (before suffix)
const cursorPos = prefix.length + leadingSpace.length + text.length const cursorPos = prefix.length + leadingSpace.length + text.length;
if (insertTextRef.current) { if (insertTextRef.current) {
insertTextRef.current.setInputWithCursor(newInput, cursorPos) insertTextRef.current.setInputWithCursor(newInput, cursorPos);
} else { } else {
setInputValueRaw(newInput) setInputValueRaw(newInput);
} }
lastSetInputRef.current = newInput lastSetInputRef.current = newInput;
// Update the prefix to include this chunk so focus mode can continue // Update the prefix to include this chunk so focus mode can continue
// appending subsequent transcripts after it. // appending subsequent transcripts after it.
voicePrefixRef.current = prefix + leadingSpace + text voicePrefixRef.current = prefix + leadingSpace + text;
}, },
[setInputValueRaw, inputValueRef, insertTextRef], [setInputValueRaw, inputValueRef, insertTextRef],
) );
const voice = voiceNs.useVoice({ const voice = voiceNs.useVoice({
onTranscript: handleVoiceTranscript, onTranscript: handleVoiceTranscript,
@ -347,34 +311,31 @@ export function useVoiceIntegration({
color: 'error', color: 'error',
priority: 'immediate', priority: 'immediate',
timeoutMs: 10_000, timeoutMs: 10_000,
}) });
}, },
enabled: voiceEnabled, enabled: voiceEnabled,
focusMode: false, focusMode: false,
}) });
// Compute the character range of interim (not-yet-finalized) transcript // Compute the character range of interim (not-yet-finalized) transcript
// text in the input value, so the UI can dim it. // text in the input value, so the UI can dim it.
const interimRange = useMemo((): InterimRange | null => { const interimRange = useMemo((): InterimRange | null => {
if (!feature('VOICE_MODE')) return null if (!feature('VOICE_MODE')) return null;
if (voicePrefixRef.current === null) return null if (voicePrefixRef.current === null) return null;
if (voiceInterimTranscript.length === 0) return null if (voiceInterimTranscript.length === 0) return null;
const prefix = voicePrefixRef.current const prefix = voicePrefixRef.current;
const needsSpace = const needsSpace = prefix.length > 0 && !/\s$/.test(prefix) && voiceInterimTranscript.length > 0;
prefix.length > 0 && const start = prefix.length + (needsSpace ? 1 : 0);
!/\s$/.test(prefix) && const end = start + voiceInterimTranscript.length;
voiceInterimTranscript.length > 0 return { start, end };
const start = prefix.length + (needsSpace ? 1 : 0) }, [voiceInterimTranscript]);
const end = start + voiceInterimTranscript.length
return { start, end }
}, [voiceInterimTranscript])
return { return {
stripTrailing, stripTrailing,
resetAnchor, resetAnchor,
handleKeyEvent: voice.handleKeyEvent, handleKeyEvent: voice.handleKeyEvent,
interimRange, interimRange,
} };
} }
/** /**
@ -407,21 +368,17 @@ export function useVoiceKeybindingHandler({
resetAnchor, resetAnchor,
isActive, isActive,
}: { }: {
voiceHandleKeyEvent: (fallbackMs?: number) => void voiceHandleKeyEvent: (fallbackMs?: number) => void;
stripTrailing: (maxStrip: number, opts?: StripOpts) => number stripTrailing: (maxStrip: number, opts?: StripOpts) => number;
resetAnchor: () => void resetAnchor: () => void;
isActive: boolean isActive: boolean;
}): { handleKeyDown: (e: KeyboardEvent) => void } { }): { handleKeyDown: (e: KeyboardEvent) => void } {
const getVoiceState = useGetVoiceState() const getVoiceState = useGetVoiceState();
const setVoiceState = useSetVoiceState() const setVoiceState = useSetVoiceState();
const keybindingContext = useOptionalKeybindingContext() const keybindingContext = useOptionalKeybindingContext();
const isModalOverlayActive = useIsModalOverlayActive() const isModalOverlayActive = useIsModalOverlayActive();
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : 'idle';
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. // Find the configured key for voice:pushToTalk from keybinding context.
// Forward iteration with last-wins (matching the resolver): if a later // Forward iteration with last-wins (matching the resolver): if a later
@ -433,22 +390,22 @@ export function useVoiceKeybindingHandler({
// is also bound in Settings/Confirmation/Plugin (select:accept etc.); // is also bound in Settings/Confirmation/Plugin (select:accept etc.);
// without the filter those would null out the default. // without the filter those would null out the default.
const voiceKeystroke = useMemo((): ParsedKeystroke | null => { const voiceKeystroke = useMemo((): ParsedKeystroke | null => {
if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE;
let result: ParsedKeystroke | null = null let result: ParsedKeystroke | null = null;
for (const binding of keybindingContext.bindings) { for (const binding of keybindingContext.bindings) {
if (binding.context !== 'Chat') continue if (binding.context !== 'Chat') continue;
if (binding.chord.length !== 1) continue if (binding.chord.length !== 1) continue;
const ks = binding.chord[0] const ks = binding.chord[0];
if (!ks) continue if (!ks) continue;
if (binding.action === 'voice:pushToTalk') { if (binding.action === 'voice:pushToTalk') {
result = ks result = ks;
} else if (result !== null && keystrokesEqual(ks, result)) { } else if (result !== null && keystrokesEqual(ks, result)) {
// A later binding overrides this chord (null unbind or reassignment) // A later binding overrides this chord (null unbind or reassignment)
result = null result = null;
} }
} }
return result return result;
}, [keybindingContext]) }, [keybindingContext]);
// If the binding is a bare (unmodified) single printable char, terminal // If the binding is a bare (unmodified) single printable char, terminal
// auto-repeat may batch N keystrokes into one input event (e.g. "vvv"), // auto-repeat may batch N keystrokes into one input event (e.g. "vvv"),
@ -465,9 +422,9 @@ export function useVoiceKeybindingHandler({
!voiceKeystroke.meta && !voiceKeystroke.meta &&
!voiceKeystroke.super !voiceKeystroke.super
? voiceKeystroke.key ? voiceKeystroke.key
: null : null;
const rapidCountRef = useRef(0) const rapidCountRef = useRef(0);
// How many rapid chars we intentionally let through to the text // How many rapid chars we intentionally let through to the text
// input (the first WARMUP_THRESHOLD). The activation strip removes // input (the first WARMUP_THRESHOLD). The activation strip removes
// up to this many + the activation event's potential leak. For the // up to this many + the activation event's potential leak. For the
@ -476,15 +433,15 @@ export function useVoiceKeybindingHandler({
// one pre-existing char if the input already ended in the bound // one pre-existing char if the input already ended in the bound
// letter (e.g. "hav" + hold "v" → "ha"). We don't track that // letter (e.g. "hav" + hold "v" → "ha"). We don't track that
// boundary — it's best-effort and the warning says so. // 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 // Trailing-char count remaining after the activation strip — these
// belong to the user's anchored prefix and must be preserved during // belong to the user's anchored prefix and must be preserved during
// recording's defensive leak cleanup. // 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). // True when the current recording was started by key-hold (not focus).
// Used to avoid swallowing keypresses during focus-mode recording. // Used to avoid swallowing keypresses during focus-mode recording.
const isHoldActiveRef = useRef(false) const isHoldActiveRef = useRef(false);
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Reset hold state as soon as we leave 'recording'. The physical hold // Reset hold state as soon as we leave 'recording'. The physical hold
// ends when key-repeat stops (state → 'processing'); keeping the ref // ends when key-repeat stops (state → 'processing'); keeping the ref
@ -492,19 +449,19 @@ export function useVoiceKeybindingHandler({
// while the transcript finalizes. // while the transcript finalizes.
useEffect(() => { useEffect(() => {
if (voiceState !== 'recording') { if (voiceState !== 'recording') {
isHoldActiveRef.current = false isHoldActiveRef.current = false;
rapidCountRef.current = 0 rapidCountRef.current = 0;
charsInInputRef.current = 0 charsInInputRef.current = 0;
recordingFloorRef.current = 0 recordingFloorRef.current = 0;
setVoiceState(prev => { setVoiceState(prev => {
if (!prev.voiceWarmingUp) return prev if (!prev.voiceWarmingUp) return prev;
return { ...prev, voiceWarmingUp: false } return { ...prev, voiceWarmingUp: false };
}) });
} }
}, [voiceState, setVoiceState]) }, [voiceState, setVoiceState]);
const handleKeyDown = (e: KeyboardEvent): void => { const handleKeyDown = (e: KeyboardEvent): void => {
if (!voiceEnabled) return if (!voiceEnabled) return;
// PromptInput is not a valid transcript target — let the hold key // PromptInput is not a valid transcript target — let the hold key
// flow through instead of swallowing it into stale refs (#33556). // flow through instead of swallowing it into stale refs (#33556).
@ -514,37 +471,32 @@ export function useVoiceKeybindingHandler({
// /plugin. Mirrors CommandKeybindingHandlers' isActive gate. // /plugin. Mirrors CommandKeybindingHandlers' isActive gate.
// - isModalOverlayActive: overlay (permission dialog, Select with // - isModalOverlayActive: overlay (permission dialog, Select with
// onCancel) has focus; PromptInput is mounted but focus=false. // 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) — // null means the user overrode the default (null-unbind/reassign) —
// hold-to-talk is disabled via binding. To toggle the feature // hold-to-talk is disabled via binding. To toggle the feature
// itself, use /voice. // itself, use /voice.
if (voiceKeystroke === null) return if (voiceKeystroke === null) return;
// Match the configured key. Bare chars match by content (handles // Match the configured key. Bare chars match by content (handles
// batched auto-repeat like "vvv") with a modifier reject so e.g. // batched auto-repeat like "vvv") with a modifier reject so e.g.
// ctrl+v doesn't trip a "v" binding. Modifier combos go through // ctrl+v doesn't trip a "v" binding. Modifier combos go through
// matchesKeyboardEvent (one event per repeat, no batching). // matchesKeyboardEvent (one event per repeat, no batching).
let repeatCount: number let repeatCount: number;
if (bareChar !== null) { 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) — // When bound to space, also accept U+3000 (full-width space) —
// CJK IMEs emit it for the same physical key. // CJK IMEs emit it for the same physical key.
const normalized = const normalized = bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key;
bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key
// Fast-path: normal typing (any char that isn't the bound one) // Fast-path: normal typing (any char that isn't the bound one)
// bails here without allocating. The repeat() check only matters // bails here without allocating. The repeat() check only matters
// for batched auto-repeat (input.length > 1) which is rare. // for batched auto-repeat (input.length > 1) which is rare.
if (normalized[0] !== bareChar) return if (normalized[0] !== bareChar) return;
if ( if (normalized.length > 1 && normalized !== bareChar.repeat(normalized.length)) return;
normalized.length > 1 && repeatCount = normalized.length;
normalized !== bareChar.repeat(normalized.length)
)
return
repeatCount = normalized.length
} else { } else {
if (!matchesKeyboardEvent(e, voiceKeystroke)) return if (!matchesKeyboardEvent(e, voiceKeystroke)) return;
repeatCount = 1 repeatCount = 1;
} }
// Guard: only swallow keypresses when recording was triggered by // Guard: only swallow keypresses when recording was triggered by
@ -554,22 +506,22 @@ export function useVoiceKeybindingHandler({
// from the store so that if voiceHandleKeyEvent() fails to transition // from the store so that if voiceHandleKeyEvent() fails to transition
// state (module not loaded, stream unavailable) we don't permanently // state (module not loaded, stream unavailable) we don't permanently
// swallow keypresses. // swallow keypresses.
const currentVoiceState = getVoiceState().voiceState const currentVoiceState = getVoiceState().voiceState;
if (isHoldActiveRef.current && currentVoiceState !== 'idle') { if (isHoldActiveRef.current && currentVoiceState !== 'idle') {
// Already recording — swallow continued keypresses and forward // Already recording — swallow continued keypresses and forward
// to voice for release detection. For bare chars, defensively // to voice for release detection. For bare chars, defensively
// strip in case the text input handler fired before this one // strip in case the text input handler fired before this one
// (listener order is not guaranteed). Modifier combos don't // (listener order is not guaranteed). Modifier combos don't
// insert text, so nothing to strip. // insert text, so nothing to strip.
e.stopImmediatePropagation() e.stopImmediatePropagation();
if (bareChar !== null) { if (bareChar !== null) {
stripTrailing(repeatCount, { stripTrailing(repeatCount, {
char: bareChar, char: bareChar,
floor: recordingFloorRef.current, floor: recordingFloorRef.current,
}) });
} }
voiceHandleKeyEvent() voiceHandleKeyEvent();
return return;
} }
// Non-hold recording (focus-mode) or processing is active. // Non-hold recording (focus-mode) or processing is active.
@ -579,12 +531,12 @@ export function useVoiceKeybindingHandler({
// hit the warmup else-branch (swallow only). Bare chars flow through // hit the warmup else-branch (swallow only). Bare chars flow through
// unconditionally — user may be typing during focus-recording. // unconditionally — user may be typing during focus-recording.
if (currentVoiceState !== 'idle') { if (currentVoiceState !== 'idle') {
if (bareChar === null) e.stopImmediatePropagation() if (bareChar === null) e.stopImmediatePropagation();
return return;
} }
const countBefore = rapidCountRef.current const countBefore = rapidCountRef.current;
rapidCountRef.current += repeatCount rapidCountRef.current += repeatCount;
// ── Activation ──────────────────────────────────────────── // ── Activation ────────────────────────────────────────────
// Handled first so the warmup branch below does NOT also run // Handled first so the warmup branch below does NOT also run
@ -594,37 +546,37 @@ export function useVoiceKeybindingHandler({
// typed accidentally, so the hold threshold (which exists to // typed accidentally, so the hold threshold (which exists to
// distinguish typing a space from holding space) doesn't apply. // distinguish typing a space from holding space) doesn't apply.
if (bareChar === null || rapidCountRef.current >= HOLD_THRESHOLD) { if (bareChar === null || rapidCountRef.current >= HOLD_THRESHOLD) {
e.stopImmediatePropagation() e.stopImmediatePropagation();
if (resetTimerRef.current) { if (resetTimerRef.current) {
clearTimeout(resetTimerRef.current) clearTimeout(resetTimerRef.current);
resetTimerRef.current = null resetTimerRef.current = null;
} }
rapidCountRef.current = 0 rapidCountRef.current = 0;
isHoldActiveRef.current = true isHoldActiveRef.current = true;
setVoiceState(prev => { setVoiceState(prev => {
if (!prev.voiceWarmingUp) return prev if (!prev.voiceWarmingUp) return prev;
return { ...prev, voiceWarmingUp: false } return { ...prev, voiceWarmingUp: false };
}) });
if (bareChar !== null) { if (bareChar !== null) {
// Strip the intentional warmup chars plus this event's leak // Strip the intentional warmup chars plus this event's leak
// (if text input fired first). Cap covers both; min(trailing) // (if text input fired first). Cap covers both; min(trailing)
// handles the no-leak case. Anchor the voice prefix here. // handles the no-leak case. Anchor the voice prefix here.
// The return value (remaining) becomes the floor for // The return value (remaining) becomes the floor for
// recording-time leak cleanup. // recording-time leak cleanup.
recordingFloorRef.current = stripTrailing( recordingFloorRef.current = stripTrailing(charsInInputRef.current + repeatCount, {
charsInInputRef.current + repeatCount, char: bareChar,
{ char: bareChar, anchor: true }, anchor: true,
) });
charsInInputRef.current = 0 charsInInputRef.current = 0;
voiceHandleKeyEvent() voiceHandleKeyEvent();
} else { } else {
// Modifier combo: nothing inserted, nothing to strip. Just // Modifier combo: nothing inserted, nothing to strip. Just
// anchor the voice prefix at the current cursor position. // anchor the voice prefix at the current cursor position.
// Longer fallback: this call is at t=0 (before auto-repeat), // Longer fallback: this call is at t=0 (before auto-repeat),
// so the gap to the next keypress is the OS initial repeat // so the gap to the next keypress is the OS initial repeat
// *delay* (up to ~2s), not the repeat *rate* (~30-80ms). // *delay* (up to ~2s), not the repeat *rate* (~30-80ms).
stripTrailing(0, { anchor: true }) stripTrailing(0, { anchor: true });
voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS) voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS);
} }
// If voice failed to transition (module not loaded, stream // If voice failed to transition (module not loaded, stream
// unavailable, stale enabled), clear the ref so a later // unavailable, stale enabled), clear the ref so a later
@ -633,10 +585,10 @@ export function useVoiceKeybindingHandler({
// immediate. The anchor set by stripTrailing above will // immediate. The anchor set by stripTrailing above will
// be overwritten on retry (anchor always overwrites now). // be overwritten on retry (anchor always overwrites now).
if (getVoiceState().voiceState === 'idle') { if (getVoiceState().voiceState === 'idle') {
isHoldActiveRef.current = false isHoldActiveRef.current = false;
resetAnchor() resetAnchor();
} }
return return;
} }
// ── Warmup (bare-char only; modifier combos activated above) ── // ── Warmup (bare-char only; modifier combos activated above) ──
@ -649,43 +601,43 @@ export function useVoiceKeybindingHandler({
// no-op when nothing leaked. Check countBefore so the event that // no-op when nothing leaked. Check countBefore so the event that
// crosses the threshold still flows through (terminal batching). // crosses the threshold still flows through (terminal batching).
if (countBefore >= WARMUP_THRESHOLD) { if (countBefore >= WARMUP_THRESHOLD) {
e.stopImmediatePropagation() e.stopImmediatePropagation();
stripTrailing(repeatCount, { stripTrailing(repeatCount, {
char: bareChar, char: bareChar,
floor: charsInInputRef.current, floor: charsInInputRef.current,
}) });
} else { } else {
charsInInputRef.current += repeatCount charsInInputRef.current += repeatCount;
} }
// Show warmup feedback once we detect a hold pattern // Show warmup feedback once we detect a hold pattern
if (rapidCountRef.current >= WARMUP_THRESHOLD) { if (rapidCountRef.current >= WARMUP_THRESHOLD) {
setVoiceState(prev => { setVoiceState(prev => {
if (prev.voiceWarmingUp) return prev if (prev.voiceWarmingUp) return prev;
return { ...prev, voiceWarmingUp: true } return { ...prev, voiceWarmingUp: true };
}) });
} }
if (resetTimerRef.current) { if (resetTimerRef.current) {
clearTimeout(resetTimerRef.current) clearTimeout(resetTimerRef.current);
} }
resetTimerRef.current = setTimeout( resetTimerRef.current = setTimeout(
(resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState) => { (resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState) => {
resetTimerRef.current = null resetTimerRef.current = null;
rapidCountRef.current = 0 rapidCountRef.current = 0;
charsInInputRef.current = 0 charsInInputRef.current = 0;
setVoiceState(prev => { setVoiceState(prev => {
if (!prev.voiceWarmingUp) return prev if (!prev.voiceWarmingUp) return prev;
return { ...prev, voiceWarmingUp: false } return { ...prev, voiceWarmingUp: false };
}) });
}, },
RAPID_KEY_GAP_MS, RAPID_KEY_GAP_MS,
resetTimerRef, resetTimerRef,
rapidCountRef, rapidCountRef,
charsInInputRef, charsInInputRef,
setVoiceState, setVoiceState,
) );
} };
// Backward-compat bridge: REPL.tsx doesn't yet wire handleKeyDown to // Backward-compat bridge: REPL.tsx doesn't yet wire handleKeyDown to
// <Box onKeyDown>. Subscribe via useInput and adapt InputEvent → // <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
@ -693,30 +645,30 @@ export function useVoiceKeybindingHandler({
// TODO(onKeyDown-migration): remove once REPL passes handleKeyDown. // TODO(onKeyDown-migration): remove once REPL passes handleKeyDown.
useInput( useInput(
(_input, _key, event) => { (_input, _key, event) => {
const kbEvent = new KeyboardEvent(event.keypress) const kbEvent = new KeyboardEvent(event.keypress);
handleKeyDown(kbEvent) handleKeyDown(kbEvent);
// handleKeyDown stopped the adapter event, not the InputEvent the // handleKeyDown stopped the adapter event, not the InputEvent the
// emitter actually checks — forward it so the text input's useInput // emitter actually checks — forward it so the text input's useInput
// listener is skipped and held spaces don't leak into the prompt. // listener is skipped and held spaces don't leak into the prompt.
if (kbEvent.didStopImmediatePropagation()) { if (kbEvent.didStopImmediatePropagation()) {
event.stopImmediatePropagation() event.stopImmediatePropagation();
} }
}, },
{ isActive }, { isActive },
) );
return { handleKeyDown } return { handleKeyDown };
} }
// TODO(onKeyDown-migration): temporary shim so existing JSX callers // TODO(onKeyDown-migration): temporary shim so existing JSX callers
// (<VoiceKeybindingHandler .../>) keep compiling. Remove once REPL.tsx // (<VoiceKeybindingHandler .../>) keep compiling. Remove once REPL.tsx
// wires handleKeyDown directly. // wires handleKeyDown directly.
export function VoiceKeybindingHandler(props: { export function VoiceKeybindingHandler(props: {
voiceHandleKeyEvent: (fallbackMs?: number) => void voiceHandleKeyEvent: (fallbackMs?: number) => void;
stripTrailing: (maxStrip: number, opts?: StripOpts) => number stripTrailing: (maxStrip: number, opts?: StripOpts) => number;
resetAnchor: () => void resetAnchor: () => void;
isActive: boolean isActive: boolean;
}): null { }): null {
useVoiceKeybindingHandler(props) useVoiceKeybindingHandler(props);
return null return null;
} }

View File

@ -14,18 +14,27 @@ import { dirname, join } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import figures from 'figures'; import figures from 'figures';
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler // eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler
import { useInput } from '@anthropic/ink' import { useInput } from '@anthropic/ink';
import { useSearchInput } from '../hooks/useSearchInput.js' import { useSearchInput } from '../hooks/useSearchInput.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js' import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useSearchHighlight } from '@anthropic/ink' import { useSearchHighlight } from '@anthropic/ink';
import type { JumpHandle } from '../components/VirtualMessageList.js' import type { JumpHandle } from '../components/VirtualMessageList.js';
import { renderMessagesToPlainText } from '../utils/exportRenderer.js' import { renderMessagesToPlainText } from '../utils/exportRenderer.js';
import { openFileInExternalEditor } from '../utils/editor.js' import { openFileInExternalEditor } from '../utils/editor.js';
import { writeFile } from 'fs/promises' import { writeFile } from 'fs/promises';
import { type TabStatusKind, Box, Text, useStdin, useTheme, useTerminalFocus, useTerminalTitle, useTabStatus } from '@anthropic/ink' import {
import { CostThresholdDialog } from '../components/CostThresholdDialog.js' type TabStatusKind,
import { IdleReturnDialog } from '../components/IdleReturnDialog.js' Box,
import * as React from 'react' Text,
useStdin,
useTheme,
useTerminalFocus,
useTerminalTitle,
useTabStatus,
} from '@anthropic/ink';
import { CostThresholdDialog } from '../components/CostThresholdDialog.js';
import { IdleReturnDialog } from '../components/IdleReturnDialog.js';
import * as React from 'react';
import { import {
useEffect, useEffect,
useMemo, useMemo,
@ -35,14 +44,11 @@ import {
useDeferredValue, useDeferredValue,
useLayoutEffect, useLayoutEffect,
type RefObject, type RefObject,
} from 'react' } from 'react';
import { useNotifications } from '../context/notifications.js' import { useNotifications } from '../context/notifications.js';
import { sendNotification } from '../services/notifier.js' import { sendNotification } from '../services/notifier.js';
import { import { startPreventSleep, stopPreventSleep } from '../services/preventSleep.js';
startPreventSleep, import { useTerminalNotification, hasCursorUpViewportYankBug } from '@anthropic/ink';
stopPreventSleep,
} from '../services/preventSleep.js'
import { useTerminalNotification, hasCursorUpViewportYankBug } from '@anthropic/ink'
import { import {
createFileStateCacheWithSizeLimit, createFileStateCacheWithSizeLimit,
mergeFileStateCaches, mergeFileStateCaches,
@ -331,8 +337,7 @@ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? proactiveMod
const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}; const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {};
const PROACTIVE_FALSE = () => false; const PROACTIVE_FALSE = () => false;
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false; const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
const useProactive = const useProactive = feature('PROACTIVE') || feature('KAIROS') ? useProactiveValue : null;
feature('PROACTIVE') || feature('KAIROS') ? useProactiveValue : null;
const useScheduledTasks = feature('AGENT_TRIGGERS') ? useScheduledTasksValue : null; const useScheduledTasks = feature('AGENT_TRIGGERS') ? useScheduledTasksValue : null;
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js'; import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js';
@ -449,21 +454,13 @@ import { UltraplanChoiceDialog } from '../components/ultraplan/UltraplanChoiceDi
import { UltraplanLaunchDialog } from '../components/ultraplan/UltraplanLaunchDialog.js'; import { UltraplanLaunchDialog } from '../components/ultraplan/UltraplanLaunchDialog.js';
import { launchUltraplan } from '../commands/ultraplan.js'; import { launchUltraplan } from '../commands/ultraplan.js';
// Session manager removed - using AppState now // Session manager removed - using AppState now
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js' import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js';
import { REMOTE_SAFE_COMMANDS } from '../commands.js' import { REMOTE_SAFE_COMMANDS } from '../commands.js';
import type { RemoteMessageContent } from '../utils/teleport/api.js' import type { RemoteMessageContent } from '../utils/teleport/api.js';
import { import { FullscreenLayout, useUnseenDivider, computeUnseenDivider } from '../components/FullscreenLayout.js';
FullscreenLayout, import { isFullscreenEnvEnabled, maybeGetTmuxMouseHint, isMouseTrackingEnabled } from '../utils/fullscreen.js';
useUnseenDivider, import { AlternateScreen } from '@anthropic/ink';
computeUnseenDivider, import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js';
} from '../components/FullscreenLayout.js'
import {
isFullscreenEnvEnabled,
maybeGetTmuxMouseHint,
isMouseTrackingEnabled,
} from '../utils/fullscreen.js'
import { AlternateScreen } from '@anthropic/ink'
import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'
import { import {
useMessageActions, useMessageActions,
MessageActionsKeybindings, MessageActionsKeybindings,
@ -471,13 +468,10 @@ import {
type MessageActionsState, type MessageActionsState,
type MessageActionsNav, type MessageActionsNav,
type MessageActionCaps, type MessageActionCaps,
} from '../components/messageActions.js' } from '../components/messageActions.js';
import { setClipboard } from '@anthropic/ink' import { setClipboard } from '@anthropic/ink';
import type { ScrollBoxHandle } from '@anthropic/ink' import type { ScrollBoxHandle } from '@anthropic/ink';
import { import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js';
createAttachmentMessage,
getQueuedCommandAttachments,
} from '../utils/attachments.js'
// Stable empty array for hooks that accept MCPServerConnection[] — avoids // Stable empty array for hooks that accept MCPServerConnection[] — avoids
// creating a new [] literal on every render in remote mode, which would // creating a new [] literal on every render in remote mode, which would
@ -625,6 +619,7 @@ function TranscriptSearchBar({
const [indexStatus, setIndexStatus] = React.useState<'building' | { ms: number } | null>('building'); const [indexStatus, setIndexStatus] = React.useState<'building' | { ms: number } | null>('building');
React.useEffect(() => { React.useEffect(() => {
let alive = true; let alive = true;
let hideTimeout: ReturnType<typeof setTimeout> | undefined;
const warm = jumpRef.current?.warmSearchIndex; const warm = jumpRef.current?.warmSearchIndex;
if (!warm) { if (!warm) {
setIndexStatus(null); // VML not mounted yet — rare, skip indicator setIndexStatus(null); // VML not mounted yet — rare, skip indicator
@ -638,14 +633,14 @@ function TranscriptSearchBar({
setIndexStatus(null); setIndexStatus(null);
} else { } else {
setIndexStatus({ ms }); setIndexStatus({ ms });
setTimeout(() => alive && setIndexStatus(null), 2000); hideTimeout = setTimeout(() => alive && setIndexStatus(null), 2000);
} }
}); });
return () => { return () => {
alive = false; alive = false;
if (hideTimeout) clearTimeout(hideTimeout);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [jumpRef]); // mount-only per stable search bar ref
}, []); // mount-only: bar opens once per /
// Gate the query effect on warm completion. setHighlight stays instant // Gate the query effect on warm completion. setHighlight stays instant
// (screen-space overlay, no indexing). setSearchQuery (the scan) waits. // (screen-space overlay, no indexing). setSearchQuery (the scan) waits.
const warmDone = indexStatus !== 'building'; const warmDone = indexStatus !== 'building';
@ -653,8 +648,7 @@ function TranscriptSearchBar({
if (!warmDone) return; if (!warmDone) return;
jumpRef.current?.setSearchQuery(query); jumpRef.current?.setSearchQuery(query);
setHighlight(query); setHighlight(query);
// eslint-disable-next-line react-hooks/exhaustive-deps }, [jumpRef, query, setHighlight, warmDone]);
}, [query, warmDone]);
const off = cursorOffset; const off = cursorOffset;
const cursorChar = off < query.length ? query[off] : ' '; const cursorChar = off < query.length ? query[off] : ' ';
return ( return (
@ -1950,7 +1944,8 @@ export function REPL({
const content = lastAssistant.message?.content; const content = lastAssistant.message?.content;
const contentArray = Array.isArray(content) ? content : []; const contentArray = Array.isArray(content) ? content : [];
const inProgressToolUses = contentArray.filter( const inProgressToolUses = contentArray.filter(
(b): b is ContentBlock & { type: 'tool_use'; id: string } => b.type === 'tool_use' && inProgressToolUseIDs.has((b as { id: string }).id), (b): b is ContentBlock & { type: 'tool_use'; id: string } =>
b.type === 'tool_use' && inProgressToolUseIDs.has((b as { id: string }).id),
); );
return ( return (
inProgressToolUses.length > 0 && inProgressToolUses.length > 0 &&
@ -3051,18 +3046,19 @@ export function REPL({
setMessages(old => { setMessages(old => {
const postBoundary = getMessagesAfterCompactBoundary(old, { const postBoundary = getMessagesAfterCompactBoundary(old, {
includeSnipped: true, includeSnipped: true,
}) });
// Hard cap: keep at most 500 messages in fullscreen scrollback // Hard cap: keep at most 500 messages in fullscreen scrollback
// to prevent unbounded memory growth in multi-day sessions. // to prevent unbounded memory growth in multi-day sessions.
// normalizeMessages/applyGrouping are O(n), and Ink fiber // normalizeMessages/applyGrouping are O(n), and Ink fiber
// trees cost ~250KB RSS per message. Without this cap, // trees cost ~250KB RSS per message. Without this cap,
// scrollback after several compactions can reach thousands // scrollback after several compactions can reach thousands
// of messages (observed: 13k+, 1GB+ heap). // of messages (observed: 13k+, 1GB+ heap).
const MAX_FULLSCREEN_SCROLLBACK = 500 const MAX_FULLSCREEN_SCROLLBACK = 500;
const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK const kept =
postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK) ? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
: postBoundary : postBoundary;
return [...kept, newMessage] return [...kept, newMessage];
}); });
} else { } else {
setMessages(() => [newMessage]); setMessages(() => [newMessage]);
@ -3074,7 +3070,10 @@ export function REPL({
if (feature('PROACTIVE') || feature('KAIROS')) { if (feature('PROACTIVE') || feature('KAIROS')) {
proactiveModule?.setContextBlocked(false); proactiveModule?.setContextBlocked(false);
} }
} else if (newMessage.type === 'progress' && isEphemeralToolProgress(((newMessage as unknown as { data?: { type?: string } }).data?.type))) { } else if (
newMessage.type === 'progress' &&
isEphemeralToolProgress((newMessage as unknown as { data?: { type?: string } }).data?.type)
) {
// Replace the previous ephemeral progress tick for the same tool // Replace the previous ephemeral progress tick for the same tool
// call instead of appending. Sleep/Bash emit a tick per second and // call instead of appending. Sleep/Bash emit a tick per second and
// only the last one is rendered; appending blows up the messages // only the last one is rendered; appending blows up the messages
@ -3092,13 +3091,10 @@ export function REPL({
// so interleaved non-ephemeral messages caused duplicate progress // so interleaved non-ephemeral messages caused duplicate progress
// entries to accumulate (observed 13k+ entries in sleep-heavy sessions). // entries to accumulate (observed 13k+ entries in sleep-heavy sessions).
for (let i = oldMessages.length - 1; i >= 0; i--) { for (let i = oldMessages.length - 1; i >= 0; i--) {
const m = oldMessages[i]! const m = oldMessages[i]!;
if (m.type !== 'progress') break if (m.type !== 'progress') break;
const mData = m.data as Record<string, unknown> | undefined const mData = m.data as Record<string, unknown> | undefined;
if ( if (m.parentToolUseID === newMessage.parentToolUseID && mData?.type === newData.type) {
m.parentToolUseID === newMessage.parentToolUseID &&
mData?.type === newData.type
) {
const copy = oldMessages.slice(); const copy = oldMessages.slice();
copy[i] = newMessage; copy[i] = newMessage;
return copy; return copy;
@ -3184,7 +3180,10 @@ export function REPL({
// title silently fell through to the "Claude Code" default. // title silently fell through to the "Claude Code" default.
if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) { if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) {
const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta); const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta);
const text = firstUserMessage?.type === 'user' ? getContentText(firstUserMessage.message!.content as string | ContentBlockParam[]) : null; const text =
firstUserMessage?.type === 'user'
? getContentText(firstUserMessage.message!.content as string | ContentBlockParam[])
: null;
// Skip synthetic breadcrumbs — slash-command output, prompt-skill // Skip synthetic breadcrumbs — slash-command output, prompt-skill
// expansions (/commit → <command-message>), local-command headers // expansions (/commit → <command-message>), local-command headers
// (/help → <command-name>), and bash-mode (!cmd → <bash-input>). // (/help → <command-name>), and bash-mode (!cmd → <bash-input>).
@ -3340,9 +3339,16 @@ export function REPL({
if (feature('BUDDY') && typeof (globalThis as Record<string, unknown>).fireCompanionObserver === 'function') { if (feature('BUDDY') && typeof (globalThis as Record<string, unknown>).fireCompanionObserver === 'function') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const _fireCompanionObserver = (globalThis as Record<string, any>).fireCompanionObserver as (msgs: unknown, cb: (r: unknown) => void) => void; const _fireCompanionObserver = (globalThis as Record<string, any>).fireCompanionObserver as (
msgs: unknown,
cb: (r: unknown) => void,
) => void;
void _fireCompanionObserver(messagesRef.current, reaction => void _fireCompanionObserver(messagesRef.current, reaction =>
setAppState(prev => (prev.companionReaction === (reaction as typeof prev.companionReaction) ? prev : { ...prev, companionReaction: reaction as typeof prev.companionReaction })), setAppState(prev =>
prev.companionReaction === (reaction as typeof prev.companionReaction)
? prev
: { ...prev, companionReaction: reaction as typeof prev.companionReaction },
),
); );
} }
@ -3686,13 +3692,15 @@ export function REPL({
...prev, ...prev,
initialMessage: null, initialMessage: null,
toolPermissionContext: updatedToolPermissionContext, toolPermissionContext: updatedToolPermissionContext,
...(shouldStorePlanForVerification ? { ...(shouldStorePlanForVerification
? {
pendingPlanVerification: { pendingPlanVerification: {
plan: initialMsg.message.planContent as string, plan: initialMsg.message.planContent as string,
verificationStarted: false, verificationStarted: false,
verificationCompleted: false, verificationCompleted: false,
}, },
} : {}), }
: {}),
}; };
}); });
@ -4838,16 +4846,19 @@ export function REPL({
} }
}, [queuedCommands]); }, [queuedCommands]);
const onInitRef = useRef(onInit);
onInitRef.current = onInit;
const diagnosticTrackerRef = useRef(diagnosticTracker);
diagnosticTrackerRef.current = diagnosticTracker;
// Initial load // Initial load
useEffect(() => { useEffect(() => {
void onInit(); void onInitRef.current();
// Cleanup on unmount // Cleanup on unmount
return () => { return () => {
void diagnosticTracker.shutdown(); void diagnosticTrackerRef.current.shutdown();
}; };
// TODO: fix this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Listen for suspend/resume events // Listen for suspend/resume events
@ -4880,16 +4891,11 @@ export function REPL({
if (!isLoading) return null; if (!isLoading) return null;
// Find stop hook progress messages // Find stop hook progress messages
const progressMsgs = messages.filter( const progressMsgs = messages.filter((m): m is ProgressMessage<HookProgress> => {
(m): m is ProgressMessage<HookProgress> => {
if (m.type !== 'progress') return false; if (m.type !== 'progress') return false;
const data = m.data as Record<string, unknown>; const data = m.data as Record<string, unknown>;
return ( return data.type === 'hook_progress' && (data.hookEvent === 'Stop' || data.hookEvent === 'SubagentStop');
data.type === 'hook_progress' && });
(data.hookEvent === 'Stop' || data.hookEvent === 'SubagentStop')
);
},
);
if (progressMsgs.length === 0) return null; if (progressMsgs.length === 0) return null;
// Get the most recent stop hook execution // Get the most recent stop hook execution

View File

@ -1,139 +1,148 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { expandEnvVarsInString } from "../envExpansion"; import { expandEnvVarsInString } from '../envExpansion'
describe("expandEnvVarsInString", () => { const ENV_OPEN = '$' + '{'
const ENV_CLOSE = '}'
const envExpr = (value: string): string => `${ENV_OPEN}${value}${ENV_CLOSE}`
describe('expandEnvVarsInString', () => {
// Save and restore env vars touched by tests // Save and restore env vars touched by tests
const savedEnv: Record<string, string | undefined> = {}; const savedEnv: Record<string, string | undefined> = {}
const trackedKeys = [ const trackedKeys = [
"TEST_HOME", 'TEST_HOME',
"MISSING", 'MISSING',
"TEST_A", 'TEST_A',
"TEST_B", 'TEST_B',
"TEST_EMPTY", 'TEST_EMPTY',
"TEST_X", 'TEST_X',
"VAR", 'VAR',
"TEST_FOUND", 'TEST_FOUND',
]; ]
beforeEach(() => { beforeEach(() => {
for (const key of trackedKeys) { for (const key of trackedKeys) {
savedEnv[key] = process.env[key]; savedEnv[key] = process.env[key]
} }
}); })
afterEach(() => { afterEach(() => {
for (const key of trackedKeys) { for (const key of trackedKeys) {
if (savedEnv[key] === undefined) { if (savedEnv[key] === undefined) {
delete process.env[key]; delete process.env[key]
} else { } else {
process.env[key] = savedEnv[key]; process.env[key] = savedEnv[key]
} }
} }
}); })
test("expands a single env var that exists", () => { test('expands a single env var that exists', () => {
process.env.TEST_HOME = "/home/user"; process.env.TEST_HOME = '/home/user'
const result = expandEnvVarsInString("${TEST_HOME}"); const result = expandEnvVarsInString(envExpr('TEST_HOME'))
expect(result.expanded).toBe("/home/user"); expect(result.expanded).toBe('/home/user')
expect(result.missingVars).toEqual([]); expect(result.missingVars).toEqual([])
}); })
test("returns original placeholder and tracks missing var when not found", () => { test('returns original placeholder and tracks missing var when not found', () => {
delete process.env.MISSING; delete process.env.MISSING
const result = expandEnvVarsInString("${MISSING}"); const result = expandEnvVarsInString(envExpr('MISSING'))
expect(result.expanded).toBe("${MISSING}"); expect(result.expanded).toBe(envExpr('MISSING'))
expect(result.missingVars).toEqual(["MISSING"]); expect(result.missingVars).toEqual(['MISSING'])
}); })
test("uses default value when var is missing and default is provided", () => { test('uses default value when var is missing and default is provided', () => {
delete process.env.MISSING; delete process.env.MISSING
const result = expandEnvVarsInString("${MISSING:-fallback}"); const result = expandEnvVarsInString(envExpr('MISSING:-fallback'))
expect(result.expanded).toBe("fallback"); expect(result.expanded).toBe('fallback')
expect(result.missingVars).toEqual([]); expect(result.missingVars).toEqual([])
}); })
test("expands multiple vars", () => { test('expands multiple vars', () => {
process.env.TEST_A = "hello"; process.env.TEST_A = 'hello'
process.env.TEST_B = "world"; process.env.TEST_B = 'world'
const result = expandEnvVarsInString("${TEST_A}/${TEST_B}"); const result = expandEnvVarsInString(
expect(result.expanded).toBe("hello/world"); `${envExpr('TEST_A')}/${envExpr('TEST_B')}`,
expect(result.missingVars).toEqual([]); )
}); expect(result.expanded).toBe('hello/world')
expect(result.missingVars).toEqual([])
})
test("handles mix of found and missing vars", () => { test('handles mix of found and missing vars', () => {
process.env.TEST_FOUND = "yes"; process.env.TEST_FOUND = 'yes'
delete process.env.MISSING; delete process.env.MISSING
const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}"); const result = expandEnvVarsInString(
expect(result.expanded).toBe("yes-${MISSING}"); `${envExpr('TEST_FOUND')}-${envExpr('MISSING')}`,
expect(result.missingVars).toEqual(["MISSING"]); )
}); expect(result.expanded).toBe(`yes-${envExpr('MISSING')}`)
expect(result.missingVars).toEqual(['MISSING'])
})
test("returns plain string unchanged with empty missingVars", () => { test('returns plain string unchanged with empty missingVars', () => {
const result = expandEnvVarsInString("plain string"); const result = expandEnvVarsInString('plain string')
expect(result.expanded).toBe("plain string"); expect(result.expanded).toBe('plain string')
expect(result.missingVars).toEqual([]); expect(result.missingVars).toEqual([])
}); })
test("expands empty env var value", () => { test('expands empty env var value', () => {
process.env.TEST_EMPTY = ""; process.env.TEST_EMPTY = ''
const result = expandEnvVarsInString("${TEST_EMPTY}"); const result = expandEnvVarsInString(envExpr('TEST_EMPTY'))
expect(result.expanded).toBe(""); expect(result.expanded).toBe('')
expect(result.missingVars).toEqual([]); expect(result.missingVars).toEqual([])
}); })
test("prefers env var value over default when var exists", () => { test('prefers env var value over default when var exists', () => {
process.env.TEST_X = "real"; process.env.TEST_X = 'real'
const result = expandEnvVarsInString("${TEST_X:-default}"); const result = expandEnvVarsInString(envExpr('TEST_X:-default'))
expect(result.expanded).toBe("real"); expect(result.expanded).toBe('real')
expect(result.missingVars).toEqual([]); expect(result.missingVars).toEqual([])
}); })
test("handles default value containing colons", () => { test('handles default value containing colons', () => {
// split(':-', 2) means only the first :- is the delimiter // split(':-', 2) means only the first :- is the delimiter
delete process.env.TEST_X; delete process.env.TEST_X
const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}"); const result = expandEnvVarsInString(envExpr('TEST_X:-value:-with:-colons'))
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"] // The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives: // Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
// ["TEST_X", "value"] because limit=2 stops at 2 pieces // ["TEST_X", "value"] because limit=2 stops at 2 pieces
expect(result.expanded).toBe("value"); expect(result.expanded).toBe('value')
expect(result.missingVars).toEqual([]); expect(result.missingVars).toEqual([])
}); })
test("handles nested-looking syntax as literal (not supported)", () => { test('handles nested-looking syntax as literal (not supported)', () => {
// ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first }) // ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first })
// so varName would be "${VAR" which won't be found in env // so varName would be "${VAR" which won't be found in env
delete process.env.VAR; delete process.env.VAR
const result = expandEnvVarsInString("${${VAR}}"); const nestedExpr = `${ENV_OPEN}${envExpr('VAR')}${ENV_CLOSE}`
const result = expandEnvVarsInString(nestedExpr)
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR" // The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
// That env var won't exist, so it stays as "${${VAR}" + remaining "}" // That env var won't exist, so it stays as "${${VAR}" + remaining "}"
expect(result.missingVars).toEqual(["${VAR"]); expect(result.missingVars).toEqual([`${ENV_OPEN}VAR`])
expect(result.expanded).toBe("${${VAR}}"); expect(result.expanded).toBe(nestedExpr)
}); })
test("handles empty string input", () => { test('handles empty string input', () => {
const result = expandEnvVarsInString(""); const result = expandEnvVarsInString('')
expect(result.expanded).toBe(""); expect(result.expanded).toBe('')
expect(result.missingVars).toEqual([]); expect(result.missingVars).toEqual([])
}); })
test("handles var surrounded by text", () => { test('handles var surrounded by text', () => {
process.env.TEST_A = "middle"; process.env.TEST_A = 'middle'
const result = expandEnvVarsInString("before-${TEST_A}-after"); const result = expandEnvVarsInString(`before-${envExpr('TEST_A')}-after`)
expect(result.expanded).toBe("before-middle-after"); expect(result.expanded).toBe('before-middle-after')
expect(result.missingVars).toEqual([]); expect(result.missingVars).toEqual([])
}); })
test("handles default value that is empty string", () => { test('handles default value that is empty string', () => {
delete process.env.MISSING; delete process.env.MISSING
const result = expandEnvVarsInString("${MISSING:-}"); const result = expandEnvVarsInString(envExpr('MISSING:-'))
expect(result.expanded).toBe(""); expect(result.expanded).toBe('')
expect(result.missingVars).toEqual([]); expect(result.missingVars).toEqual([])
}); })
test("does not expand $VAR without braces", () => { test('does not expand $VAR without braces', () => {
process.env.TEST_A = "value"; process.env.TEST_A = 'value'
const result = expandEnvVarsInString("$TEST_A"); const result = expandEnvVarsInString('$TEST_A')
expect(result.expanded).toBe("$TEST_A"); expect(result.expanded).toBe('$TEST_A')
expect(result.missingVars).toEqual([]); expect(result.missingVars).toEqual([])
}); })
}); })

View File

@ -1,12 +1,12 @@
import { mock, describe, expect, test } from "bun:test"; import { mock, describe, expect, test } from 'bun:test'
// Mock ink/stringWidth to avoid heavy Ink import chain // Mock ink/stringWidth to avoid heavy Ink import chain
mock.module("src/ink/stringWidth.js", () => ({ mock.module('src/ink/stringWidth.js', () => ({
stringWidth: (str: string) => { stringWidth: (str: string) => {
// Simplified width calculation for test purposes // Simplified width calculation for test purposes
let width = 0; let width = 0
for (const char of str) { for (const char of str) {
const code = char.codePointAt(0)!; const code = char.codePointAt(0)!
// CJK Unified Ideographs and common full-width ranges // CJK Unified Ideographs and common full-width ranges
if ( if (
(code >= 0x4e00 && code <= 0x9fff) || // CJK (code >= 0x4e00 && code <= 0x9fff) || // CJK
@ -14,95 +14,96 @@ mock.module("src/ink/stringWidth.js", () => ({
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms (code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
(code >= 0xf900 && code <= 0xfaff) // CJK Compatibility (code >= 0xf900 && code <= 0xfaff) // CJK Compatibility
) { ) {
width += 2; width += 2
} else if (code > 0) { } else if (code > 0) {
width += 1; width += 1
} }
} }
return width; return width
}, },
})); }))
const sliceAnsi = (await import("../sliceAnsi")).default; const sliceAnsi = (await import('../sliceAnsi')).default
const ESC = '\x1b'
describe("sliceAnsi", () => { describe('sliceAnsi', () => {
test("plain text slice identical to String.slice", () => { test('plain text slice identical to String.slice', () => {
expect(sliceAnsi("hello world", 0, 5)).toBe("hello"); expect(sliceAnsi('hello world', 0, 5)).toBe('hello')
expect(sliceAnsi("hello world", 6)).toBe("world"); expect(sliceAnsi('hello world', 6)).toBe('world')
}); })
test("slice entire string", () => { test('slice entire string', () => {
expect(sliceAnsi("abc", 0)).toBe("abc"); expect(sliceAnsi('abc', 0)).toBe('abc')
}); })
test("empty slice (start === end)", () => { test('empty slice (start === end)', () => {
expect(sliceAnsi("abc", 2, 2)).toBe(""); expect(sliceAnsi('abc', 2, 2)).toBe('')
}); })
test("preserves ANSI color codes within slice", () => { test('preserves ANSI color codes within slice', () => {
const input = "\x1b[31mred\x1b[0m normal"; const input = '\x1b[31mred\x1b[0m normal'
const result = sliceAnsi(input, 0, 3); const result = sliceAnsi(input, 0, 3)
expect(result).toContain("\x1b[31m"); expect(result).toContain('\x1b[31m')
expect(result).toContain("red"); expect(result).toContain('red')
}); })
test("closes opened ANSI styles at slice end", () => { test('closes opened ANSI styles at slice end', () => {
const input = "\x1b[31mhello world\x1b[0m"; const input = '\x1b[31mhello world\x1b[0m'
const result = sliceAnsi(input, 0, 5); const result = sliceAnsi(input, 0, 5)
expect(result).toContain("\x1b[31m"); expect(result).toContain('\x1b[31m')
expect(result).toContain("hello"); expect(result).toContain('hello')
// undoAnsiCodes uses specific close codes (e.g. \x1b[39m for foreground) // undoAnsiCodes uses specific close codes (e.g. \x1b[39m for foreground)
expect(result).toMatch(new RegExp("\\x1b\\[\\d+m")); expect(result).toMatch(new RegExp(`${ESC}\\[\\d+m`))
// The result should start with open code and end with a close code // The result should start with open code and end with a close code
const withoutText = result.replace("hello", ""); const withoutText = result.replace('hello', '')
// Should have at least one open and one close code // Should have at least one open and one close code
expect(withoutText.length).toBeGreaterThan(0); expect(withoutText.length).toBeGreaterThan(0)
}); })
test("slice starting mid-ANSI skips codes before start", () => { test('slice starting mid-ANSI skips codes before start', () => {
const input = "\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m"; const input = '\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m'
const result = sliceAnsi(input, 6, 11); const result = sliceAnsi(input, 6, 11)
expect(result).toContain("world"); expect(result).toContain('world')
expect(result).toContain("\x1b[32m"); expect(result).toContain('\x1b[32m')
expect(result).not.toContain("\x1b[31m"); expect(result).not.toContain('\x1b[31m')
}); })
test("slice of plain text from middle", () => { test('slice of plain text from middle', () => {
expect(sliceAnsi("abcdefgh", 2, 5)).toBe("cde"); expect(sliceAnsi('abcdefgh', 2, 5)).toBe('cde')
}); })
test("slice past end of string returns everything", () => { test('slice past end of string returns everything', () => {
expect(sliceAnsi("abc", 0, 100)).toBe("abc"); expect(sliceAnsi('abc', 0, 100)).toBe('abc')
}); })
test("slice starting at end returns empty", () => { test('slice starting at end returns empty', () => {
expect(sliceAnsi("abc", 3)).toBe(""); expect(sliceAnsi('abc', 3)).toBe('')
}); })
test("handles empty string", () => { test('handles empty string', () => {
expect(sliceAnsi("", 0, 5)).toBe(""); expect(sliceAnsi('', 0, 5)).toBe('')
}); })
test("multiple ANSI codes nested", () => { test('multiple ANSI codes nested', () => {
const input = "\x1b[1m\x1b[31mbold red\x1b[0m\x1b[0m"; const input = '\x1b[1m\x1b[31mbold red\x1b[0m\x1b[0m'
const result = sliceAnsi(input, 0, 4); const result = sliceAnsi(input, 0, 4)
expect(result).toContain("bold"); expect(result).toContain('bold')
// Both styles should be opened and then closed // Both styles should be opened and then closed
expect(result).toContain("\x1b[1m"); expect(result).toContain('\x1b[1m')
expect(result).toContain("\x1b[31m"); expect(result).toContain('\x1b[31m')
}); })
test("slice with no end parameter returns to end of string", () => { test('slice with no end parameter returns to end of string', () => {
expect(sliceAnsi("hello world", 6)).toBe("world"); expect(sliceAnsi('hello world', 6)).toBe('world')
}); })
test("ANSI codes at boundaries are handled correctly", () => { test('ANSI codes at boundaries are handled correctly', () => {
const input = "a\x1b[31mb\x1b[0mc"; const input = 'a\x1b[31mb\x1b[0mc'
// "abc" visually, position: a=0, b=1, c=2 // "abc" visually, position: a=0, b=1, c=2
const result = sliceAnsi(input, 1, 2); const result = sliceAnsi(input, 1, 2)
// undoAnsiCodes uses \x1b[39m for foreground reset, not \x1b[0m // undoAnsiCodes uses \x1b[39m for foreground reset, not \x1b[0m
expect(result).toContain("b"); expect(result).toContain('b')
expect(result).toContain("\x1b[31m"); expect(result).toContain('\x1b[31m')
expect(result).toMatch(new RegExp("\\x1b\\[\\d+m.*\\x1b\\[\\d+m")); // open + close codes expect(result).toMatch(new RegExp(`${ESC}\\[\\d+m.*${ESC}\\[\\d+m`)) // open + close codes
}); })
}); })

View File

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from 'bun:test'
import { import {
escapeRegExp, escapeRegExp,
capitalize, capitalize,
@ -10,186 +10,187 @@ import {
safeJoinLines, safeJoinLines,
EndTruncatingAccumulator, EndTruncatingAccumulator,
truncateToLines, truncateToLines,
} from "../stringUtils"; } from '../stringUtils'
describe("escapeRegExp", () => { describe('escapeRegExp', () => {
test("escapes special regex chars", () => { test('escapes special regex chars', () => {
expect(escapeRegExp("a.b*c?d")).toBe("a\\.b\\*c\\?d"); expect(escapeRegExp('a.b*c?d')).toBe('a\\.b\\*c\\?d')
}); })
test("escapes brackets and parens", () => { test('escapes brackets and parens', () => {
expect(escapeRegExp("[foo](bar)")).toBe("\\[foo\\]\\(bar\\)"); expect(escapeRegExp('[foo](bar)')).toBe('\\[foo\\]\\(bar\\)')
}); })
test("escapes all special chars", () => { test('escapes all special chars', () => {
expect(escapeRegExp("^${}()|[]\\.*+?")).toBe( const allSpecialChars = '^$' + '{}()|[]\\.*+?'
"\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?" expect(escapeRegExp(allSpecialChars)).toBe(
); '\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?',
}); )
})
test("returns normal string unchanged", () => { test('returns normal string unchanged', () => {
expect(escapeRegExp("hello")).toBe("hello"); expect(escapeRegExp('hello')).toBe('hello')
}); })
}); })
describe("capitalize", () => { describe('capitalize', () => {
test("uppercases first char", () => { test('uppercases first char', () => {
expect(capitalize("hello")).toBe("Hello"); expect(capitalize('hello')).toBe('Hello')
}); })
test("does NOT lowercase rest", () => { test('does NOT lowercase rest', () => {
expect(capitalize("fooBar")).toBe("FooBar"); expect(capitalize('fooBar')).toBe('FooBar')
}); })
test("handles single char", () => { test('handles single char', () => {
expect(capitalize("a")).toBe("A"); expect(capitalize('a')).toBe('A')
}); })
test("handles empty string", () => { test('handles empty string', () => {
expect(capitalize("")).toBe(""); expect(capitalize('')).toBe('')
}); })
}); })
describe("plural", () => { describe('plural', () => {
test("returns singular for 1", () => { test('returns singular for 1', () => {
expect(plural(1, "file")).toBe("file"); expect(plural(1, 'file')).toBe('file')
}); })
test("returns plural for 0", () => { test('returns plural for 0', () => {
expect(plural(0, "file")).toBe("files"); expect(plural(0, 'file')).toBe('files')
}); })
test("returns plural for many", () => { test('returns plural for many', () => {
expect(plural(3, "file")).toBe("files"); expect(plural(3, 'file')).toBe('files')
}); })
test("uses custom plural form", () => { test('uses custom plural form', () => {
expect(plural(2, "entry", "entries")).toBe("entries"); expect(plural(2, 'entry', 'entries')).toBe('entries')
}); })
}); })
describe("firstLineOf", () => { describe('firstLineOf', () => {
test("returns first line of multiline string", () => { test('returns first line of multiline string', () => {
expect(firstLineOf("line1\nline2\nline3")).toBe("line1"); expect(firstLineOf('line1\nline2\nline3')).toBe('line1')
}); })
test("returns whole string if no newline", () => { test('returns whole string if no newline', () => {
expect(firstLineOf("single line")).toBe("single line"); expect(firstLineOf('single line')).toBe('single line')
}); })
test("returns empty string for leading newline", () => { test('returns empty string for leading newline', () => {
expect(firstLineOf("\nline2")).toBe(""); expect(firstLineOf('\nline2')).toBe('')
}); })
}); })
describe("countCharInString", () => { describe('countCharInString', () => {
test("counts occurrences of a character", () => { test('counts occurrences of a character', () => {
expect(countCharInString("hello world", "l")).toBe(3); expect(countCharInString('hello world', 'l')).toBe(3)
}); })
test("returns 0 for no match", () => { test('returns 0 for no match', () => {
expect(countCharInString("hello", "z")).toBe(0); expect(countCharInString('hello', 'z')).toBe(0)
}); })
test("counts from start offset", () => { test('counts from start offset', () => {
expect(countCharInString("aabaa", "a", 2)).toBe(2); expect(countCharInString('aabaa', 'a', 2)).toBe(2)
}); })
test("returns 0 for empty string", () => { test('returns 0 for empty string', () => {
expect(countCharInString("", "a")).toBe(0); expect(countCharInString('', 'a')).toBe(0)
}); })
}); })
describe("normalizeFullWidthDigits", () => { describe('normalizeFullWidthDigits', () => {
test("converts full-width digits to half-width", () => { test('converts full-width digits to half-width', () => {
expect(normalizeFullWidthDigits("")).toBe("0123456789"); expect(normalizeFullWidthDigits('')).toBe('0123456789')
}); })
test("leaves half-width digits unchanged", () => { test('leaves half-width digits unchanged', () => {
expect(normalizeFullWidthDigits("0123")).toBe("0123"); expect(normalizeFullWidthDigits('0123')).toBe('0123')
}); })
test("handles mixed content", () => { test('handles mixed content', () => {
expect(normalizeFullWidthDigits("test")).toBe("test123"); expect(normalizeFullWidthDigits('test')).toBe('test123')
}); })
}); })
describe("normalizeFullWidthSpace", () => { describe('normalizeFullWidthSpace', () => {
test("converts full-width space to half-width", () => { test('converts full-width space to half-width', () => {
expect(normalizeFullWidthSpace("a\u3000b")).toBe("a b"); expect(normalizeFullWidthSpace('a\u3000b')).toBe('a b')
}); })
test("leaves normal spaces unchanged", () => { test('leaves normal spaces unchanged', () => {
expect(normalizeFullWidthSpace("a b")).toBe("a b"); expect(normalizeFullWidthSpace('a b')).toBe('a b')
}); })
}); })
describe("safeJoinLines", () => { describe('safeJoinLines', () => {
test("joins lines with delimiter", () => { test('joins lines with delimiter', () => {
expect(safeJoinLines(["a", "b", "c"], ",")).toBe("a,b,c"); expect(safeJoinLines(['a', 'b', 'c'], ',')).toBe('a,b,c')
}); })
test("truncates when exceeding maxSize", () => { test('truncates when exceeding maxSize', () => {
const result = safeJoinLines(["hello", "world", "foo"], ",", 12); const result = safeJoinLines(['hello', 'world', 'foo'], ',', 12)
expect(result.length).toBeLessThanOrEqual(12 + "...[truncated]".length); expect(result.length).toBeLessThanOrEqual(12 + '...[truncated]'.length)
expect(result).toContain("...[truncated]"); expect(result).toContain('...[truncated]')
}); })
test("returns empty string for empty input", () => { test('returns empty string for empty input', () => {
expect(safeJoinLines([])).toBe(""); expect(safeJoinLines([])).toBe('')
}); })
}); })
describe("EndTruncatingAccumulator", () => { describe('EndTruncatingAccumulator', () => {
test("accumulates text", () => { test('accumulates text', () => {
const acc = new EndTruncatingAccumulator(100); const acc = new EndTruncatingAccumulator(100)
acc.append("hello "); acc.append('hello ')
acc.append("world"); acc.append('world')
expect(acc.toString()).toBe("hello world"); expect(acc.toString()).toBe('hello world')
}); })
test("truncates when exceeding maxSize", () => { test('truncates when exceeding maxSize', () => {
const acc = new EndTruncatingAccumulator(10); const acc = new EndTruncatingAccumulator(10)
acc.append("12345678901234567890"); acc.append('12345678901234567890')
expect(acc.truncated).toBe(true); expect(acc.truncated).toBe(true)
expect(acc.length).toBe(10); expect(acc.length).toBe(10)
}); })
test("reports total bytes received", () => { test('reports total bytes received', () => {
const acc = new EndTruncatingAccumulator(5); const acc = new EndTruncatingAccumulator(5)
acc.append("1234567890"); acc.append('1234567890')
expect(acc.totalBytes).toBe(10); expect(acc.totalBytes).toBe(10)
}); })
test("clear resets state", () => { test('clear resets state', () => {
const acc = new EndTruncatingAccumulator(100); const acc = new EndTruncatingAccumulator(100)
acc.append("hello"); acc.append('hello')
acc.clear(); acc.clear()
expect(acc.toString()).toBe(""); expect(acc.toString()).toBe('')
expect(acc.length).toBe(0); expect(acc.length).toBe(0)
expect(acc.truncated).toBe(false); expect(acc.truncated).toBe(false)
}); })
test("stops accepting data once truncated and full", () => { test('stops accepting data once truncated and full', () => {
const acc = new EndTruncatingAccumulator(5); const acc = new EndTruncatingAccumulator(5)
acc.append("12345"); acc.append('12345')
acc.append("67890"); acc.append('67890')
expect(acc.length).toBe(5); expect(acc.length).toBe(5)
acc.append("more"); acc.append('more')
expect(acc.length).toBe(5); expect(acc.length).toBe(5)
}); })
}); })
describe("truncateToLines", () => { describe('truncateToLines', () => {
test("returns text unchanged if within limit", () => { test('returns text unchanged if within limit', () => {
expect(truncateToLines("a\nb\nc", 5)).toBe("a\nb\nc"); expect(truncateToLines('a\nb\nc', 5)).toBe('a\nb\nc')
}); })
test("truncates text exceeding limit", () => { test('truncates text exceeding limit', () => {
expect(truncateToLines("a\nb\nc\nd\ne", 3)).toBe("a\nb\nc…"); expect(truncateToLines('a\nb\nc\nd\ne', 3)).toBe('a\nb\nc…')
}); })
test("handles single line", () => { test('handles single line', () => {
expect(truncateToLines("hello", 1)).toBe("hello"); expect(truncateToLines('hello', 1)).toBe('hello')
}); })
}); })

View File

@ -172,10 +172,10 @@ function fillBackground(px: Uint8Array, bg: AnsiColor): void {
// not the classic VGA dither pattern. Alpha-blend toward background for the // not the classic VGA dither pattern. Alpha-blend toward background for the
// same look. // same look.
const SHADE_ALPHA: Record<number, number> = { const SHADE_ALPHA: Record<number, number> = {
0x2591: 0.25, // ░ 9617: 0.25, // ░
0x2592: 0.5, // ▒ 9618: 0.5, // ▒
0x2593: 0.75, // ▓ 9619: 0.75, // ▓
0x2588: 1.0, // █ 9608: 1.0, // █
} }
function blitShade( function blitShade(

View File

@ -1,8 +1,8 @@
import * as React from 'react' import * as React from 'react';
import { useLayoutEffect } from 'react' import { useLayoutEffect } from 'react';
import { PassThrough } from 'stream' import { PassThrough } from 'stream';
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi';
import { wrappedRender as render, useApp } from '@anthropic/ink' import { wrappedRender as render, useApp } from '@anthropic/ink';
// This is a workaround for the fact that Ink doesn't support multiple <Static> // This is a workaround for the fact that Ink doesn't support multiple <Static>
// components in the same render tree. Instead of using a <Static> we just render // components in the same render tree. Instead of using a <Static> we just render
@ -14,26 +14,22 @@ import { wrappedRender as render, useApp } from '@anthropic/ink'
* before exiting. This is more robust than process.nextTick() for React 19's * before exiting. This is more robust than process.nextTick() for React 19's
* async render cycle. * async render cycle.
*/ */
function RenderOnceAndExit({ function RenderOnceAndExit({ children }: { children: React.ReactNode }): React.ReactNode {
children, const { exit } = useApp();
}: {
children: React.ReactNode
}): React.ReactNode {
const { exit } = useApp()
// useLayoutEffect runs synchronously after React commits DOM mutations. // useLayoutEffect runs synchronously after React commits DOM mutations.
// setTimeout(0) defers exit to allow Ink to flush output to the stream. // setTimeout(0) defers exit to allow Ink to flush output to the stream.
useLayoutEffect(() => { useLayoutEffect(() => {
const timer = setTimeout(exit, 0) const timer = setTimeout(exit, 0);
return () => clearTimeout(timer) return () => clearTimeout(timer);
}, [exit]) }, [exit]);
return <>{children}</> return <>{children}</>;
} }
// DEC synchronized update markers used by terminals // DEC synchronized update markers used by terminals
const SYNC_START = '\x1B[?2026h' const SYNC_START = '\x1B[?2026h';
const SYNC_END = '\x1B[?2026l' const SYNC_END = '\x1B[?2026l';
/** /**
* Extracts content from the first complete frame in Ink's output. * Extracts content from the first complete frame in Ink's output.
@ -41,64 +37,53 @@ const SYNC_END = '\x1B[?2026l'
* update sequences ([?2026h ... [?2026l). We only want the first frame's content. * update sequences ([?2026h ... [?2026l). We only want the first frame's content.
*/ */
function extractFirstFrame(output: string): string { function extractFirstFrame(output: string): string {
const startIndex = output.indexOf(SYNC_START) const startIndex = output.indexOf(SYNC_START);
if (startIndex === -1) return output if (startIndex === -1) return output;
const contentStart = startIndex + SYNC_START.length const contentStart = startIndex + SYNC_START.length;
const endIndex = output.indexOf(SYNC_END, contentStart) const endIndex = output.indexOf(SYNC_END, contentStart);
if (endIndex === -1) return output if (endIndex === -1) return output;
return output.slice(contentStart, endIndex) return output.slice(contentStart, endIndex);
} }
/** /**
* Renders a React node to a string with ANSI escape codes (for terminal output). * Renders a React node to a string with ANSI escape codes (for terminal output).
*/ */
export function renderToAnsiString( export async function renderToAnsiString(node: React.ReactNode, columns?: number): Promise<string> {
node: React.ReactNode, let output = '';
columns?: number,
): Promise<string> {
return new Promise(async resolve => {
let output = ''
// Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a // Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a
// chosen width instead of PassThrough's undefined → 80 fallback — // chosen width instead of PassThrough's undefined → 80 fallback —
// useful for rendering at terminal width for file dumps that should // useful for rendering at terminal width for file dumps that should
// match what the user sees on screen. // match what the user sees on screen.
const stream = new PassThrough() const stream = new PassThrough();
if (columns !== undefined) { if (columns !== undefined) {
;(stream as unknown as { columns: number }).columns = columns (stream as unknown as { columns: number }).columns = columns;
} }
stream.on('data', chunk => { stream.on('data', chunk => {
output += chunk.toString() output += chunk.toString();
}) });
// Render the component wrapped in RenderOnceAndExit // Render the component wrapped in RenderOnceAndExit
// Non-TTY stdout (PassThrough) gives full-frame output instead of diffs // Non-TTY stdout (PassThrough) gives full-frame output instead of diffs
const instance = await render( const instance = await render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
<RenderOnceAndExit>{node}</RenderOnceAndExit>,
{
stdout: stream as unknown as NodeJS.WriteStream, stdout: stream as unknown as NodeJS.WriteStream,
patchConsole: false, patchConsole: false,
}, });
)
// Wait for the component to exit naturally // Wait for the component to exit naturally
await instance.waitUntilExit() await instance.waitUntilExit();
// Extract only the first frame's content to avoid duplication // Extract only the first frame's content to avoid duplication
// (Ink outputs multiple frames in non-TTY mode) // (Ink outputs multiple frames in non-TTY mode)
await resolve(extractFirstFrame(output)) return extractFirstFrame(output);
})
} }
/** /**
* Renders a React node to a plain text string (ANSI codes stripped). * Renders a React node to a plain text string (ANSI codes stripped).
*/ */
export async function renderToString( export async function renderToString(node: React.ReactNode, columns?: number): Promise<string> {
node: React.ReactNode, const output = await renderToAnsiString(node, columns);
columns?: number, return stripAnsi(output);
): Promise<string> {
const output = await renderToAnsiString(node, columns)
return stripAnsi(output)
} }