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 {
return (rawStatus: SessionDoneStatus): void => {
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` +
` 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)
sessionStartTimes.delete(sessionId)
sessionWorkIds.delete(sessionId)
@ -609,7 +611,9 @@ export async function runBridgeLoop(
const pollConfig = getPollIntervalConfig()
try {
rcLog(`poll: envId=${environmentId} activeSessions=${activeSessions.size}`)
rcLog(
`poll: envId=${environmentId} activeSessions=${activeSessions.size}`,
)
const work = await api.pollForWork(
environmentId,
environmentSecret,
@ -864,7 +868,9 @@ export async function runBridgeLoop(
break
case 'session': {
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 {
validateBridgeId(sessionId, 'session_id')
} catch {
@ -1676,7 +1682,7 @@ async function stopWorkWithRetry(
}
const errMsg = errorMessage(err)
if (attempt < MAX_ATTEMPTS) {
const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1))
const delay = addJitter(baseDelayMs * 2 ** (attempt - 1))
logger.logVerbose(
`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')) {
// Normalize CRLF lines in mixed-line-ending streams.
const line =
rawLine[rawLine.length - 1] === '\r'
? rawLine.slice(0, -1)
: rawLine
rawLine[rawLine.length - 1] === '\r' ? rawLine.slice(0, -1) : rawLine
if (line.startsWith(':')) {
// SSE comment (e.g., `:keepalive`)
@ -527,7 +525,7 @@ export class SSETransport implements Transport {
this.reconnectAttempts++
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,
)
// Add ±25% jitter
@ -677,7 +675,7 @@ export class SSETransport implements Transport {
}
const delayMs = Math.min(
POST_BASE_DELAY_MS * Math.pow(2, attempt - 1),
POST_BASE_DELAY_MS * 2 ** (attempt - 1),
POST_MAX_DELAY_MS,
)
await sleep(delayMs)

View File

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

View File

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

View File

@ -14,18 +14,27 @@ import { dirname, join } from 'path';
import { tmpdir } from 'os';
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
import { useInput } from '@anthropic/ink'
import { useSearchInput } from '../hooks/useSearchInput.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { useSearchHighlight } from '@anthropic/ink'
import type { JumpHandle } from '../components/VirtualMessageList.js'
import { renderMessagesToPlainText } from '../utils/exportRenderer.js'
import { openFileInExternalEditor } from '../utils/editor.js'
import { writeFile } from 'fs/promises'
import { type TabStatusKind, Box, 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 { useInput } from '@anthropic/ink';
import { useSearchInput } from '../hooks/useSearchInput.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useSearchHighlight } from '@anthropic/ink';
import type { JumpHandle } from '../components/VirtualMessageList.js';
import { renderMessagesToPlainText } from '../utils/exportRenderer.js';
import { openFileInExternalEditor } from '../utils/editor.js';
import { writeFile } from 'fs/promises';
import {
type TabStatusKind,
Box,
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 {
useEffect,
useMemo,
@ -35,14 +44,11 @@ import {
useDeferredValue,
useLayoutEffect,
type RefObject,
} from 'react'
import { useNotifications } from '../context/notifications.js'
import { sendNotification } from '../services/notifier.js'
import {
startPreventSleep,
stopPreventSleep,
} from '../services/preventSleep.js'
import { useTerminalNotification, hasCursorUpViewportYankBug } from '@anthropic/ink'
} from 'react';
import { useNotifications } from '../context/notifications.js';
import { sendNotification } from '../services/notifier.js';
import { startPreventSleep, stopPreventSleep } from '../services/preventSleep.js';
import { useTerminalNotification, hasCursorUpViewportYankBug } from '@anthropic/ink';
import {
createFileStateCacheWithSizeLimit,
mergeFileStateCaches,
@ -331,8 +337,7 @@ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? proactiveMod
const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {};
const PROACTIVE_FALSE = () => false;
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
const useProactive =
feature('PROACTIVE') || feature('KAIROS') ? useProactiveValue : null;
const useProactive = feature('PROACTIVE') || feature('KAIROS') ? useProactiveValue : null;
const useScheduledTasks = feature('AGENT_TRIGGERS') ? useScheduledTasksValue : null;
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.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 { launchUltraplan } from '../commands/ultraplan.js';
// Session manager removed - using AppState now
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'
import { REMOTE_SAFE_COMMANDS } from '../commands.js'
import type { RemoteMessageContent } from '../utils/teleport/api.js'
import {
FullscreenLayout,
useUnseenDivider,
computeUnseenDivider,
} from '../components/FullscreenLayout.js'
import {
isFullscreenEnvEnabled,
maybeGetTmuxMouseHint,
isMouseTrackingEnabled,
} from '../utils/fullscreen.js'
import { AlternateScreen } from '@anthropic/ink'
import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js';
import { REMOTE_SAFE_COMMANDS } from '../commands.js';
import type { RemoteMessageContent } from '../utils/teleport/api.js';
import { FullscreenLayout, useUnseenDivider, computeUnseenDivider } from '../components/FullscreenLayout.js';
import { isFullscreenEnvEnabled, maybeGetTmuxMouseHint, isMouseTrackingEnabled } from '../utils/fullscreen.js';
import { AlternateScreen } from '@anthropic/ink';
import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js';
import {
useMessageActions,
MessageActionsKeybindings,
@ -471,13 +468,10 @@ import {
type MessageActionsState,
type MessageActionsNav,
type MessageActionCaps,
} from '../components/messageActions.js'
import { setClipboard } from '@anthropic/ink'
import type { ScrollBoxHandle } from '@anthropic/ink'
import {
createAttachmentMessage,
getQueuedCommandAttachments,
} from '../utils/attachments.js'
} from '../components/messageActions.js';
import { setClipboard } from '@anthropic/ink';
import type { ScrollBoxHandle } from '@anthropic/ink';
import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js';
// Stable empty array for hooks that accept MCPServerConnection[] — avoids
// 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');
React.useEffect(() => {
let alive = true;
let hideTimeout: ReturnType<typeof setTimeout> | undefined;
const warm = jumpRef.current?.warmSearchIndex;
if (!warm) {
setIndexStatus(null); // VML not mounted yet — rare, skip indicator
@ -638,14 +633,14 @@ function TranscriptSearchBar({
setIndexStatus(null);
} else {
setIndexStatus({ ms });
setTimeout(() => alive && setIndexStatus(null), 2000);
hideTimeout = setTimeout(() => alive && setIndexStatus(null), 2000);
}
});
return () => {
alive = false;
if (hideTimeout) clearTimeout(hideTimeout);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // mount-only: bar opens once per /
}, [jumpRef]); // mount-only per stable search bar ref
// Gate the query effect on warm completion. setHighlight stays instant
// (screen-space overlay, no indexing). setSearchQuery (the scan) waits.
const warmDone = indexStatus !== 'building';
@ -653,8 +648,7 @@ function TranscriptSearchBar({
if (!warmDone) return;
jumpRef.current?.setSearchQuery(query);
setHighlight(query);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, warmDone]);
}, [jumpRef, query, setHighlight, warmDone]);
const off = cursorOffset;
const cursorChar = off < query.length ? query[off] : ' ';
return (
@ -1950,7 +1944,8 @@ export function REPL({
const content = lastAssistant.message?.content;
const contentArray = Array.isArray(content) ? content : [];
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 (
inProgressToolUses.length > 0 &&
@ -3051,18 +3046,19 @@ export function REPL({
setMessages(old => {
const postBoundary = getMessagesAfterCompactBoundary(old, {
includeSnipped: true,
})
});
// Hard cap: keep at most 500 messages in fullscreen scrollback
// to prevent unbounded memory growth in multi-day sessions.
// normalizeMessages/applyGrouping are O(n), and Ink fiber
// trees cost ~250KB RSS per message. Without this cap,
// scrollback after several compactions can reach thousands
// of messages (observed: 13k+, 1GB+ heap).
const MAX_FULLSCREEN_SCROLLBACK = 500
const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
const MAX_FULLSCREEN_SCROLLBACK = 500;
const kept =
postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
: postBoundary
return [...kept, newMessage]
: postBoundary;
return [...kept, newMessage];
});
} else {
setMessages(() => [newMessage]);
@ -3074,7 +3070,10 @@ export function REPL({
if (feature('PROACTIVE') || feature('KAIROS')) {
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
// call instead of appending. Sleep/Bash emit a tick per second and
// 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
// entries to accumulate (observed 13k+ entries in sleep-heavy sessions).
for (let i = oldMessages.length - 1; i >= 0; i--) {
const m = oldMessages[i]!
if (m.type !== 'progress') break
const mData = m.data as Record<string, unknown> | undefined
if (
m.parentToolUseID === newMessage.parentToolUseID &&
mData?.type === newData.type
) {
const m = oldMessages[i]!;
if (m.type !== 'progress') break;
const mData = m.data as Record<string, unknown> | undefined;
if (m.parentToolUseID === newMessage.parentToolUseID && mData?.type === newData.type) {
const copy = oldMessages.slice();
copy[i] = newMessage;
return copy;
@ -3184,7 +3180,10 @@ export function REPL({
// title silently fell through to the "Claude Code" default.
if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) {
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
// expansions (/commit → <command-message>), local-command headers
// (/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') {
// 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 =>
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,
initialMessage: null,
toolPermissionContext: updatedToolPermissionContext,
...(shouldStorePlanForVerification ? {
...(shouldStorePlanForVerification
? {
pendingPlanVerification: {
plan: initialMsg.message.planContent as string,
verificationStarted: false,
verificationCompleted: false,
},
} : {}),
}
: {}),
};
});
@ -4838,16 +4846,19 @@ export function REPL({
}
}, [queuedCommands]);
const onInitRef = useRef(onInit);
onInitRef.current = onInit;
const diagnosticTrackerRef = useRef(diagnosticTracker);
diagnosticTrackerRef.current = diagnosticTracker;
// Initial load
useEffect(() => {
void onInit();
void onInitRef.current();
// Cleanup on unmount
return () => {
void diagnosticTracker.shutdown();
void diagnosticTrackerRef.current.shutdown();
};
// TODO: fix this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Listen for suspend/resume events
@ -4880,16 +4891,11 @@ export function REPL({
if (!isLoading) return null;
// Find stop hook progress messages
const progressMsgs = messages.filter(
(m): m is ProgressMessage<HookProgress> => {
const progressMsgs = messages.filter((m): m is ProgressMessage<HookProgress> => {
if (m.type !== 'progress') return false;
const data = m.data as Record<string, unknown>;
return (
data.type === 'hook_progress' &&
(data.hookEvent === 'Stop' || data.hookEvent === 'SubagentStop')
);
},
);
return data.type === 'hook_progress' && (data.hookEvent === 'Stop' || data.hookEvent === 'SubagentStop');
});
if (progressMsgs.length === 0) return null;
// Get the most recent stop hook execution

View File

@ -1,139 +1,148 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { expandEnvVarsInString } from "../envExpansion";
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
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
const savedEnv: Record<string, string | undefined> = {};
const savedEnv: Record<string, string | undefined> = {}
const trackedKeys = [
"TEST_HOME",
"MISSING",
"TEST_A",
"TEST_B",
"TEST_EMPTY",
"TEST_X",
"VAR",
"TEST_FOUND",
];
'TEST_HOME',
'MISSING',
'TEST_A',
'TEST_B',
'TEST_EMPTY',
'TEST_X',
'VAR',
'TEST_FOUND',
]
beforeEach(() => {
for (const key of trackedKeys) {
savedEnv[key] = process.env[key];
savedEnv[key] = process.env[key]
}
});
})
afterEach(() => {
for (const key of trackedKeys) {
if (savedEnv[key] === undefined) {
delete process.env[key];
delete process.env[key]
} else {
process.env[key] = savedEnv[key];
process.env[key] = savedEnv[key]
}
}
});
})
test("expands a single env var that exists", () => {
process.env.TEST_HOME = "/home/user";
const result = expandEnvVarsInString("${TEST_HOME}");
expect(result.expanded).toBe("/home/user");
expect(result.missingVars).toEqual([]);
});
test('expands a single env var that exists', () => {
process.env.TEST_HOME = '/home/user'
const result = expandEnvVarsInString(envExpr('TEST_HOME'))
expect(result.expanded).toBe('/home/user')
expect(result.missingVars).toEqual([])
})
test("returns original placeholder and tracks missing var when not found", () => {
delete process.env.MISSING;
const result = expandEnvVarsInString("${MISSING}");
expect(result.expanded).toBe("${MISSING}");
expect(result.missingVars).toEqual(["MISSING"]);
});
test('returns original placeholder and tracks missing var when not found', () => {
delete process.env.MISSING
const result = expandEnvVarsInString(envExpr('MISSING'))
expect(result.expanded).toBe(envExpr('MISSING'))
expect(result.missingVars).toEqual(['MISSING'])
})
test("uses default value when var is missing and default is provided", () => {
delete process.env.MISSING;
const result = expandEnvVarsInString("${MISSING:-fallback}");
expect(result.expanded).toBe("fallback");
expect(result.missingVars).toEqual([]);
});
test('uses default value when var is missing and default is provided', () => {
delete process.env.MISSING
const result = expandEnvVarsInString(envExpr('MISSING:-fallback'))
expect(result.expanded).toBe('fallback')
expect(result.missingVars).toEqual([])
})
test("expands multiple vars", () => {
process.env.TEST_A = "hello";
process.env.TEST_B = "world";
const result = expandEnvVarsInString("${TEST_A}/${TEST_B}");
expect(result.expanded).toBe("hello/world");
expect(result.missingVars).toEqual([]);
});
test('expands multiple vars', () => {
process.env.TEST_A = 'hello'
process.env.TEST_B = 'world'
const result = expandEnvVarsInString(
`${envExpr('TEST_A')}/${envExpr('TEST_B')}`,
)
expect(result.expanded).toBe('hello/world')
expect(result.missingVars).toEqual([])
})
test("handles mix of found and missing vars", () => {
process.env.TEST_FOUND = "yes";
delete process.env.MISSING;
const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}");
expect(result.expanded).toBe("yes-${MISSING}");
expect(result.missingVars).toEqual(["MISSING"]);
});
test('handles mix of found and missing vars', () => {
process.env.TEST_FOUND = 'yes'
delete process.env.MISSING
const result = expandEnvVarsInString(
`${envExpr('TEST_FOUND')}-${envExpr('MISSING')}`,
)
expect(result.expanded).toBe(`yes-${envExpr('MISSING')}`)
expect(result.missingVars).toEqual(['MISSING'])
})
test("returns plain string unchanged with empty missingVars", () => {
const result = expandEnvVarsInString("plain string");
expect(result.expanded).toBe("plain string");
expect(result.missingVars).toEqual([]);
});
test('returns plain string unchanged with empty missingVars', () => {
const result = expandEnvVarsInString('plain string')
expect(result.expanded).toBe('plain string')
expect(result.missingVars).toEqual([])
})
test("expands empty env var value", () => {
process.env.TEST_EMPTY = "";
const result = expandEnvVarsInString("${TEST_EMPTY}");
expect(result.expanded).toBe("");
expect(result.missingVars).toEqual([]);
});
test('expands empty env var value', () => {
process.env.TEST_EMPTY = ''
const result = expandEnvVarsInString(envExpr('TEST_EMPTY'))
expect(result.expanded).toBe('')
expect(result.missingVars).toEqual([])
})
test("prefers env var value over default when var exists", () => {
process.env.TEST_X = "real";
const result = expandEnvVarsInString("${TEST_X:-default}");
expect(result.expanded).toBe("real");
expect(result.missingVars).toEqual([]);
});
test('prefers env var value over default when var exists', () => {
process.env.TEST_X = 'real'
const result = expandEnvVarsInString(envExpr('TEST_X:-default'))
expect(result.expanded).toBe('real')
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
delete process.env.TEST_X;
const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}");
delete process.env.TEST_X
const result = expandEnvVarsInString(envExpr('TEST_X:-value:-with:-colons'))
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
// ["TEST_X", "value"] because limit=2 stops at 2 pieces
expect(result.expanded).toBe("value");
expect(result.missingVars).toEqual([]);
});
expect(result.expanded).toBe('value')
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 })
// so varName would be "${VAR" which won't be found in env
delete process.env.VAR;
const result = expandEnvVarsInString("${${VAR}}");
delete process.env.VAR
const nestedExpr = `${ENV_OPEN}${envExpr('VAR')}${ENV_CLOSE}`
const result = expandEnvVarsInString(nestedExpr)
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
// That env var won't exist, so it stays as "${${VAR}" + remaining "}"
expect(result.missingVars).toEqual(["${VAR"]);
expect(result.expanded).toBe("${${VAR}}");
});
expect(result.missingVars).toEqual([`${ENV_OPEN}VAR`])
expect(result.expanded).toBe(nestedExpr)
})
test("handles empty string input", () => {
const result = expandEnvVarsInString("");
expect(result.expanded).toBe("");
expect(result.missingVars).toEqual([]);
});
test('handles empty string input', () => {
const result = expandEnvVarsInString('')
expect(result.expanded).toBe('')
expect(result.missingVars).toEqual([])
})
test("handles var surrounded by text", () => {
process.env.TEST_A = "middle";
const result = expandEnvVarsInString("before-${TEST_A}-after");
expect(result.expanded).toBe("before-middle-after");
expect(result.missingVars).toEqual([]);
});
test('handles var surrounded by text', () => {
process.env.TEST_A = 'middle'
const result = expandEnvVarsInString(`before-${envExpr('TEST_A')}-after`)
expect(result.expanded).toBe('before-middle-after')
expect(result.missingVars).toEqual([])
})
test("handles default value that is empty string", () => {
delete process.env.MISSING;
const result = expandEnvVarsInString("${MISSING:-}");
expect(result.expanded).toBe("");
expect(result.missingVars).toEqual([]);
});
test('handles default value that is empty string', () => {
delete process.env.MISSING
const result = expandEnvVarsInString(envExpr('MISSING:-'))
expect(result.expanded).toBe('')
expect(result.missingVars).toEqual([])
})
test("does not expand $VAR without braces", () => {
process.env.TEST_A = "value";
const result = expandEnvVarsInString("$TEST_A");
expect(result.expanded).toBe("$TEST_A");
expect(result.missingVars).toEqual([]);
});
});
test('does not expand $VAR without braces', () => {
process.env.TEST_A = 'value'
const result = expandEnvVarsInString('$TEST_A')
expect(result.expanded).toBe('$TEST_A')
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.module("src/ink/stringWidth.js", () => ({
mock.module('src/ink/stringWidth.js', () => ({
stringWidth: (str: string) => {
// Simplified width calculation for test purposes
let width = 0;
let width = 0
for (const char of str) {
const code = char.codePointAt(0)!;
const code = char.codePointAt(0)!
// CJK Unified Ideographs and common full-width ranges
if (
(code >= 0x4e00 && code <= 0x9fff) || // CJK
@ -14,95 +14,96 @@ mock.module("src/ink/stringWidth.js", () => ({
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
(code >= 0xf900 && code <= 0xfaff) // CJK Compatibility
) {
width += 2;
width += 2
} 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", () => {
test("plain text slice identical to String.slice", () => {
expect(sliceAnsi("hello world", 0, 5)).toBe("hello");
expect(sliceAnsi("hello world", 6)).toBe("world");
});
describe('sliceAnsi', () => {
test('plain text slice identical to String.slice', () => {
expect(sliceAnsi('hello world', 0, 5)).toBe('hello')
expect(sliceAnsi('hello world', 6)).toBe('world')
})
test("slice entire string", () => {
expect(sliceAnsi("abc", 0)).toBe("abc");
});
test('slice entire string', () => {
expect(sliceAnsi('abc', 0)).toBe('abc')
})
test("empty slice (start === end)", () => {
expect(sliceAnsi("abc", 2, 2)).toBe("");
});
test('empty slice (start === end)', () => {
expect(sliceAnsi('abc', 2, 2)).toBe('')
})
test("preserves ANSI color codes within slice", () => {
const input = "\x1b[31mred\x1b[0m normal";
const result = sliceAnsi(input, 0, 3);
expect(result).toContain("\x1b[31m");
expect(result).toContain("red");
});
test('preserves ANSI color codes within slice', () => {
const input = '\x1b[31mred\x1b[0m normal'
const result = sliceAnsi(input, 0, 3)
expect(result).toContain('\x1b[31m')
expect(result).toContain('red')
})
test("closes opened ANSI styles at slice end", () => {
const input = "\x1b[31mhello world\x1b[0m";
const result = sliceAnsi(input, 0, 5);
expect(result).toContain("\x1b[31m");
expect(result).toContain("hello");
test('closes opened ANSI styles at slice end', () => {
const input = '\x1b[31mhello world\x1b[0m'
const result = sliceAnsi(input, 0, 5)
expect(result).toContain('\x1b[31m')
expect(result).toContain('hello')
// 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
const withoutText = result.replace("hello", "");
const withoutText = result.replace('hello', '')
// 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", () => {
const input = "\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m";
const result = sliceAnsi(input, 6, 11);
expect(result).toContain("world");
expect(result).toContain("\x1b[32m");
expect(result).not.toContain("\x1b[31m");
});
test('slice starting mid-ANSI skips codes before start', () => {
const input = '\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m'
const result = sliceAnsi(input, 6, 11)
expect(result).toContain('world')
expect(result).toContain('\x1b[32m')
expect(result).not.toContain('\x1b[31m')
})
test("slice of plain text from middle", () => {
expect(sliceAnsi("abcdefgh", 2, 5)).toBe("cde");
});
test('slice of plain text from middle', () => {
expect(sliceAnsi('abcdefgh', 2, 5)).toBe('cde')
})
test("slice past end of string returns everything", () => {
expect(sliceAnsi("abc", 0, 100)).toBe("abc");
});
test('slice past end of string returns everything', () => {
expect(sliceAnsi('abc', 0, 100)).toBe('abc')
})
test("slice starting at end returns empty", () => {
expect(sliceAnsi("abc", 3)).toBe("");
});
test('slice starting at end returns empty', () => {
expect(sliceAnsi('abc', 3)).toBe('')
})
test("handles empty string", () => {
expect(sliceAnsi("", 0, 5)).toBe("");
});
test('handles empty string', () => {
expect(sliceAnsi('', 0, 5)).toBe('')
})
test("multiple ANSI codes nested", () => {
const input = "\x1b[1m\x1b[31mbold red\x1b[0m\x1b[0m";
const result = sliceAnsi(input, 0, 4);
expect(result).toContain("bold");
test('multiple ANSI codes nested', () => {
const input = '\x1b[1m\x1b[31mbold red\x1b[0m\x1b[0m'
const result = sliceAnsi(input, 0, 4)
expect(result).toContain('bold')
// Both styles should be opened and then closed
expect(result).toContain("\x1b[1m");
expect(result).toContain("\x1b[31m");
});
expect(result).toContain('\x1b[1m')
expect(result).toContain('\x1b[31m')
})
test("slice with no end parameter returns to end of string", () => {
expect(sliceAnsi("hello world", 6)).toBe("world");
});
test('slice with no end parameter returns to end of string', () => {
expect(sliceAnsi('hello world', 6)).toBe('world')
})
test("ANSI codes at boundaries are handled correctly", () => {
const input = "a\x1b[31mb\x1b[0mc";
test('ANSI codes at boundaries are handled correctly', () => {
const input = 'a\x1b[31mb\x1b[0mc'
// "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
expect(result).toContain("b");
expect(result).toContain("\x1b[31m");
expect(result).toMatch(new RegExp("\\x1b\\[\\d+m.*\\x1b\\[\\d+m")); // open + close codes
});
});
expect(result).toContain('b')
expect(result).toContain('\x1b[31m')
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 {
escapeRegExp,
capitalize,
@ -10,186 +10,187 @@ import {
safeJoinLines,
EndTruncatingAccumulator,
truncateToLines,
} from "../stringUtils";
} from '../stringUtils'
describe("escapeRegExp", () => {
test("escapes special regex chars", () => {
expect(escapeRegExp("a.b*c?d")).toBe("a\\.b\\*c\\?d");
});
describe('escapeRegExp', () => {
test('escapes special regex chars', () => {
expect(escapeRegExp('a.b*c?d')).toBe('a\\.b\\*c\\?d')
})
test("escapes brackets and parens", () => {
expect(escapeRegExp("[foo](bar)")).toBe("\\[foo\\]\\(bar\\)");
});
test('escapes brackets and parens', () => {
expect(escapeRegExp('[foo](bar)')).toBe('\\[foo\\]\\(bar\\)')
})
test("escapes all special chars", () => {
expect(escapeRegExp("^${}()|[]\\.*+?")).toBe(
"\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?"
);
});
test('escapes all special chars', () => {
const allSpecialChars = '^$' + '{}()|[]\\.*+?'
expect(escapeRegExp(allSpecialChars)).toBe(
'\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?',
)
})
test("returns normal string unchanged", () => {
expect(escapeRegExp("hello")).toBe("hello");
});
});
test('returns normal string unchanged', () => {
expect(escapeRegExp('hello')).toBe('hello')
})
})
describe("capitalize", () => {
test("uppercases first char", () => {
expect(capitalize("hello")).toBe("Hello");
});
describe('capitalize', () => {
test('uppercases first char', () => {
expect(capitalize('hello')).toBe('Hello')
})
test("does NOT lowercase rest", () => {
expect(capitalize("fooBar")).toBe("FooBar");
});
test('does NOT lowercase rest', () => {
expect(capitalize('fooBar')).toBe('FooBar')
})
test("handles single char", () => {
expect(capitalize("a")).toBe("A");
});
test('handles single char', () => {
expect(capitalize('a')).toBe('A')
})
test("handles empty string", () => {
expect(capitalize("")).toBe("");
});
});
test('handles empty string', () => {
expect(capitalize('')).toBe('')
})
})
describe("plural", () => {
test("returns singular for 1", () => {
expect(plural(1, "file")).toBe("file");
});
describe('plural', () => {
test('returns singular for 1', () => {
expect(plural(1, 'file')).toBe('file')
})
test("returns plural for 0", () => {
expect(plural(0, "file")).toBe("files");
});
test('returns plural for 0', () => {
expect(plural(0, 'file')).toBe('files')
})
test("returns plural for many", () => {
expect(plural(3, "file")).toBe("files");
});
test('returns plural for many', () => {
expect(plural(3, 'file')).toBe('files')
})
test("uses custom plural form", () => {
expect(plural(2, "entry", "entries")).toBe("entries");
});
});
test('uses custom plural form', () => {
expect(plural(2, 'entry', 'entries')).toBe('entries')
})
})
describe("firstLineOf", () => {
test("returns first line of multiline string", () => {
expect(firstLineOf("line1\nline2\nline3")).toBe("line1");
});
describe('firstLineOf', () => {
test('returns first line of multiline string', () => {
expect(firstLineOf('line1\nline2\nline3')).toBe('line1')
})
test("returns whole string if no newline", () => {
expect(firstLineOf("single line")).toBe("single line");
});
test('returns whole string if no newline', () => {
expect(firstLineOf('single line')).toBe('single line')
})
test("returns empty string for leading newline", () => {
expect(firstLineOf("\nline2")).toBe("");
});
});
test('returns empty string for leading newline', () => {
expect(firstLineOf('\nline2')).toBe('')
})
})
describe("countCharInString", () => {
test("counts occurrences of a character", () => {
expect(countCharInString("hello world", "l")).toBe(3);
});
describe('countCharInString', () => {
test('counts occurrences of a character', () => {
expect(countCharInString('hello world', 'l')).toBe(3)
})
test("returns 0 for no match", () => {
expect(countCharInString("hello", "z")).toBe(0);
});
test('returns 0 for no match', () => {
expect(countCharInString('hello', 'z')).toBe(0)
})
test("counts from start offset", () => {
expect(countCharInString("aabaa", "a", 2)).toBe(2);
});
test('counts from start offset', () => {
expect(countCharInString('aabaa', 'a', 2)).toBe(2)
})
test("returns 0 for empty string", () => {
expect(countCharInString("", "a")).toBe(0);
});
});
test('returns 0 for empty string', () => {
expect(countCharInString('', 'a')).toBe(0)
})
})
describe("normalizeFullWidthDigits", () => {
test("converts full-width digits to half-width", () => {
expect(normalizeFullWidthDigits("")).toBe("0123456789");
});
describe('normalizeFullWidthDigits', () => {
test('converts full-width digits to half-width', () => {
expect(normalizeFullWidthDigits('')).toBe('0123456789')
})
test("leaves half-width digits unchanged", () => {
expect(normalizeFullWidthDigits("0123")).toBe("0123");
});
test('leaves half-width digits unchanged', () => {
expect(normalizeFullWidthDigits('0123')).toBe('0123')
})
test("handles mixed content", () => {
expect(normalizeFullWidthDigits("test")).toBe("test123");
});
});
test('handles mixed content', () => {
expect(normalizeFullWidthDigits('test')).toBe('test123')
})
})
describe("normalizeFullWidthSpace", () => {
test("converts full-width space to half-width", () => {
expect(normalizeFullWidthSpace("a\u3000b")).toBe("a b");
});
describe('normalizeFullWidthSpace', () => {
test('converts full-width space to half-width', () => {
expect(normalizeFullWidthSpace('a\u3000b')).toBe('a b')
})
test("leaves normal spaces unchanged", () => {
expect(normalizeFullWidthSpace("a b")).toBe("a b");
});
});
test('leaves normal spaces unchanged', () => {
expect(normalizeFullWidthSpace('a b')).toBe('a b')
})
})
describe("safeJoinLines", () => {
test("joins lines with delimiter", () => {
expect(safeJoinLines(["a", "b", "c"], ",")).toBe("a,b,c");
});
describe('safeJoinLines', () => {
test('joins lines with delimiter', () => {
expect(safeJoinLines(['a', 'b', 'c'], ',')).toBe('a,b,c')
})
test("truncates when exceeding maxSize", () => {
const result = safeJoinLines(["hello", "world", "foo"], ",", 12);
expect(result.length).toBeLessThanOrEqual(12 + "...[truncated]".length);
expect(result).toContain("...[truncated]");
});
test('truncates when exceeding maxSize', () => {
const result = safeJoinLines(['hello', 'world', 'foo'], ',', 12)
expect(result.length).toBeLessThanOrEqual(12 + '...[truncated]'.length)
expect(result).toContain('...[truncated]')
})
test("returns empty string for empty input", () => {
expect(safeJoinLines([])).toBe("");
});
});
test('returns empty string for empty input', () => {
expect(safeJoinLines([])).toBe('')
})
})
describe("EndTruncatingAccumulator", () => {
test("accumulates text", () => {
const acc = new EndTruncatingAccumulator(100);
acc.append("hello ");
acc.append("world");
expect(acc.toString()).toBe("hello world");
});
describe('EndTruncatingAccumulator', () => {
test('accumulates text', () => {
const acc = new EndTruncatingAccumulator(100)
acc.append('hello ')
acc.append('world')
expect(acc.toString()).toBe('hello world')
})
test("truncates when exceeding maxSize", () => {
const acc = new EndTruncatingAccumulator(10);
acc.append("12345678901234567890");
expect(acc.truncated).toBe(true);
expect(acc.length).toBe(10);
});
test('truncates when exceeding maxSize', () => {
const acc = new EndTruncatingAccumulator(10)
acc.append('12345678901234567890')
expect(acc.truncated).toBe(true)
expect(acc.length).toBe(10)
})
test("reports total bytes received", () => {
const acc = new EndTruncatingAccumulator(5);
acc.append("1234567890");
expect(acc.totalBytes).toBe(10);
});
test('reports total bytes received', () => {
const acc = new EndTruncatingAccumulator(5)
acc.append('1234567890')
expect(acc.totalBytes).toBe(10)
})
test("clear resets state", () => {
const acc = new EndTruncatingAccumulator(100);
acc.append("hello");
acc.clear();
expect(acc.toString()).toBe("");
expect(acc.length).toBe(0);
expect(acc.truncated).toBe(false);
});
test('clear resets state', () => {
const acc = new EndTruncatingAccumulator(100)
acc.append('hello')
acc.clear()
expect(acc.toString()).toBe('')
expect(acc.length).toBe(0)
expect(acc.truncated).toBe(false)
})
test("stops accepting data once truncated and full", () => {
const acc = new EndTruncatingAccumulator(5);
acc.append("12345");
acc.append("67890");
expect(acc.length).toBe(5);
acc.append("more");
expect(acc.length).toBe(5);
});
});
test('stops accepting data once truncated and full', () => {
const acc = new EndTruncatingAccumulator(5)
acc.append('12345')
acc.append('67890')
expect(acc.length).toBe(5)
acc.append('more')
expect(acc.length).toBe(5)
})
})
describe("truncateToLines", () => {
test("returns text unchanged if within limit", () => {
expect(truncateToLines("a\nb\nc", 5)).toBe("a\nb\nc");
});
describe('truncateToLines', () => {
test('returns text unchanged if within limit', () => {
expect(truncateToLines('a\nb\nc', 5)).toBe('a\nb\nc')
})
test("truncates text exceeding limit", () => {
expect(truncateToLines("a\nb\nc\nd\ne", 3)).toBe("a\nb\nc…");
});
test('truncates text exceeding limit', () => {
expect(truncateToLines('a\nb\nc\nd\ne', 3)).toBe('a\nb\nc…')
})
test("handles single line", () => {
expect(truncateToLines("hello", 1)).toBe("hello");
});
});
test('handles single line', () => {
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
// same look.
const SHADE_ALPHA: Record<number, number> = {
0x2591: 0.25, // ░
0x2592: 0.5, // ▒
0x2593: 0.75, // ▓
0x2588: 1.0, // █
9617: 0.25, // ░
9618: 0.5, // ▒
9619: 0.75, // ▓
9608: 1.0, // █
}
function blitShade(

View File

@ -1,8 +1,8 @@
import * as React from 'react'
import { useLayoutEffect } from 'react'
import { PassThrough } from 'stream'
import stripAnsi from 'strip-ansi'
import { wrappedRender as render, useApp } from '@anthropic/ink'
import * as React from 'react';
import { useLayoutEffect } from 'react';
import { PassThrough } from 'stream';
import stripAnsi from 'strip-ansi';
import { wrappedRender as render, useApp } from '@anthropic/ink';
// 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
@ -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
* async render cycle.
*/
function RenderOnceAndExit({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const { exit } = useApp()
function RenderOnceAndExit({ children }: { children: React.ReactNode }): React.ReactNode {
const { exit } = useApp();
// useLayoutEffect runs synchronously after React commits DOM mutations.
// setTimeout(0) defers exit to allow Ink to flush output to the stream.
useLayoutEffect(() => {
const timer = setTimeout(exit, 0)
return () => clearTimeout(timer)
}, [exit])
const timer = setTimeout(exit, 0);
return () => clearTimeout(timer);
}, [exit]);
return <>{children}</>
return <>{children}</>;
}
// DEC synchronized update markers used by terminals
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
const SYNC_START = '\x1B[?2026h';
const SYNC_END = '\x1B[?2026l';
/**
* 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.
*/
function extractFirstFrame(output: string): string {
const startIndex = output.indexOf(SYNC_START)
if (startIndex === -1) return output
const startIndex = output.indexOf(SYNC_START);
if (startIndex === -1) return output;
const contentStart = startIndex + SYNC_START.length
const endIndex = output.indexOf(SYNC_END, contentStart)
if (endIndex === -1) return output
const contentStart = startIndex + SYNC_START.length;
const endIndex = output.indexOf(SYNC_END, contentStart);
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).
*/
export function renderToAnsiString(
node: React.ReactNode,
columns?: number,
): Promise<string> {
return new Promise(async resolve => {
let output = ''
export async function renderToAnsiString(node: React.ReactNode, columns?: number): Promise<string> {
let output = '';
// Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a
// chosen width instead of PassThrough's undefined → 80 fallback —
// useful for rendering at terminal width for file dumps that should
// match what the user sees on screen.
const stream = new PassThrough()
const stream = new PassThrough();
if (columns !== undefined) {
;(stream as unknown as { columns: number }).columns = columns
(stream as unknown as { columns: number }).columns = columns;
}
stream.on('data', chunk => {
output += chunk.toString()
})
output += chunk.toString();
});
// Render the component wrapped in RenderOnceAndExit
// Non-TTY stdout (PassThrough) gives full-frame output instead of diffs
const instance = await render(
<RenderOnceAndExit>{node}</RenderOnceAndExit>,
{
const instance = await render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
stdout: stream as unknown as NodeJS.WriteStream,
patchConsole: false,
},
)
});
// Wait for the component to exit naturally
await instance.waitUntilExit()
await instance.waitUntilExit();
// Extract only the first frame's content to avoid duplication
// (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).
*/
export async function renderToString(
node: React.ReactNode,
columns?: number,
): Promise<string> {
const output = await renderToAnsiString(node, columns)
return stripAnsi(output)
export async function renderToString(node: React.ReactNode, columns?: number): Promise<string> {
const output = await renderToAnsiString(node, columns);
return stripAnsi(output);
}