diff --git a/src/bridge/bridgeMain.ts b/src/bridge/bridgeMain.ts index c0b172386..7a9eb6fe4 100644 --- a/src/bridge/bridgeMain.ts +++ b/src/bridge/bridgeMain.ts @@ -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}` + - ` 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)'}`) + 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)'}`, + ) 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 { @@ -1032,9 +1038,9 @@ export async function runBridgeLoop( rcLog( `spawning session: sessionId=${sessionId} sdkUrl=${sdkUrl}` + - ` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` + - ` dir=${sessionDir}` + - ` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`, + ` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` + + ` dir=${sessionDir}` + + ` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`, ) const spawnResult = safeSpawn( spawner, @@ -1281,8 +1287,8 @@ export async function runBridgeLoop( const errMsg = describeAxiosError(err) rcLog( `poll error: ${errMsg}` + - ` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` + - ` activeSessions=${activeSessions.size}`, + ` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` + + ` activeSessions=${activeSessions.size}`, ) if (isConnectionError(err) || isServerError(err)) { @@ -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}`, ) diff --git a/src/cli/transports/SSETransport.ts b/src/cli/transports/SSETransport.ts index 4b82a1c37..110f71957 100644 --- a/src/cli/transports/SSETransport.ts +++ b/src/cli/transports/SSETransport.ts @@ -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`) @@ -491,9 +489,9 @@ export class SSETransport implements Transport { private handleConnectionError(): void { rcLog( `SSE handleConnectionError: state=${this.state}` + - ` lastSeqNum=${this.getLastSequenceNum()}` + - ` reconnectAttempts=${this.reconnectAttempts}` + - ` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}`, + ` lastSeqNum=${this.getLastSequenceNum()}` + + ` reconnectAttempts=${this.reconnectAttempts}` + + ` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}`, ) this.clearLivenessTimer() @@ -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 @@ -570,8 +568,8 @@ export class SSETransport implements Transport { this.livenessTimer = null rcLog( `SSE liveness timeout (${LIVENESS_TIMEOUT_MS}ms)` + - ` lastSeqNum=${this.getLastSequenceNum()}` + - ` state=${this.state}`, + ` lastSeqNum=${this.getLastSequenceNum()}` + + ` state=${this.state}`, ) logForDebugging('SSETransport: Liveness timeout, reconnecting', { level: 'error', @@ -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) diff --git a/src/cli/transports/WebSocketTransport.ts b/src/cli/transports/WebSocketTransport.ts index 5d5d8fd75..d4af1de03 100644 --- a/src/cli/transports/WebSocketTransport.ts +++ b/src/cli/transports/WebSocketTransport.ts @@ -398,10 +398,10 @@ export class WebSocketTransport implements Transport { private handleConnectionError(closeCode?: number): void { rcLog( `WS handleConnectionError: code=${closeCode}` + - ` state=${this.state}` + - ` url=${this.url.href.replace(/token=[^&]+/, 'token=***')}` + - ` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}` + - ` reconnectAttempts=${this.reconnectAttempts}`, + ` state=${this.state}` + + ` url=${this.url.href.replace(/token=[^&]+/, 'token=***')}` + + ` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}` + + ` reconnectAttempts=${this.reconnectAttempts}`, ) logForDebugging( `WebSocketTransport: Disconnected from ${this.url.href}` + @@ -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 diff --git a/src/hooks/useGlobalKeybindings.tsx b/src/hooks/useGlobalKeybindings.tsx index 5668748fc..36a3fb102 100644 --- a/src/hooks/useGlobalKeybindings.tsx +++ b/src/hooks/useGlobalKeybindings.tsx @@ -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> - showAllInTranscript: boolean - setShowAllInTranscript: React.Dispatch> - messageCount: number - onEnterTranscript?: () => void - onExitTranscript?: () => void - virtualScrollActive?: boolean - searchBarOpen?: boolean -} + screen: Screen; + setScreen: React.Dispatch>; + showAllInTranscript: boolean; + setShowAllInTranscript: React.Dispatch>; + 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; } diff --git a/src/hooks/useVoiceIntegration.tsx b/src/hooks/useVoiceIntegration.tsx index b798a61ae..11c172b31 100644 --- a/src/hooks/useVoiceIntegration.tsx +++ b/src/hooks/useVoiceIntegration.tsx @@ -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 -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> - inputValueRef: React.RefObject - insertTextRef: React.RefObject -} + setInputValueRaw: React.Dispatch>; + inputValueRef: React.RefObject; + insertTextRef: React.RefObject; +}; -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(null) - const voiceSuffixRef = useRef('') + const voicePrefixRef = useRef(null); + const voiceSuffixRef = useRef(''); // 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(null) + const lastSetInputRef = useRef(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 | null>(null) + const isHoldActiveRef = useRef(false); + const resetTimerRef = useRef | 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 // . 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 // () 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; } diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 14418f4de..ddbc52c9b 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -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 | 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 - ? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK) - : postBoundary - return [...kept, newMessage] + const MAX_FULLSCREEN_SCROLLBACK = 500; + const kept = + postBoundary.length > MAX_FULLSCREEN_SCROLLBACK + ? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK) + : 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 | 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 | 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 → ), local-command headers // (/help → ), and bash-mode (!cmd → ). @@ -3340,9 +3339,16 @@ export function REPL({ if (feature('BUDDY') && typeof (globalThis as Record).fireCompanionObserver === 'function') { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _fireCompanionObserver = (globalThis as Record).fireCompanionObserver as (msgs: unknown, cb: (r: unknown) => void) => void; + const _fireCompanionObserver = (globalThis as Record).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 ? { - pendingPlanVerification: { - plan: initialMsg.message.planContent as string, - verificationStarted: false, - verificationCompleted: false, - }, - } : {}), + ...(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 => { - if (m.type !== 'progress') return false; - const data = m.data as Record; - return ( - data.type === 'hook_progress' && - (data.hookEvent === 'Stop' || data.hookEvent === 'SubagentStop') - ); - }, - ); + const progressMsgs = messages.filter((m): m is ProgressMessage => { + if (m.type !== 'progress') return false; + const data = m.data as Record; + return data.type === 'hook_progress' && (data.hookEvent === 'Stop' || data.hookEvent === 'SubagentStop'); + }); if (progressMsgs.length === 0) return null; // Get the most recent stop hook execution diff --git a/src/services/mcp/__tests__/envExpansion.test.ts b/src/services/mcp/__tests__/envExpansion.test.ts index fe2032f2e..125a9ab4e 100644 --- a/src/services/mcp/__tests__/envExpansion.test.ts +++ b/src/services/mcp/__tests__/envExpansion.test.ts @@ -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 = {}; + const savedEnv: Record = {} 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([]) + }) +}) diff --git a/src/utils/__tests__/sliceAnsi.test.ts b/src/utils/__tests__/sliceAnsi.test.ts index 340cbbe7d..38506b068 100644 --- a/src/utils/__tests__/sliceAnsi.test.ts +++ b/src/utils/__tests__/sliceAnsi.test.ts @@ -1,108 +1,109 @@ -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 (code >= 0x3000 && code <= 0x303f) || // CJK Symbols (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) { - 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 + }) +}) diff --git a/src/utils/__tests__/stringUtils.test.ts b/src/utils/__tests__/stringUtils.test.ts index 730374daf..3276e20a6 100644 --- a/src/utils/__tests__/stringUtils.test.ts +++ b/src/utils/__tests__/stringUtils.test.ts @@ -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("0123456789")).toBe("0123456789"); - }); +describe('normalizeFullWidthDigits', () => { + test('converts full-width digits to half-width', () => { + expect(normalizeFullWidthDigits('0123456789')).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("test123")).toBe("test123"); - }); -}); + test('handles mixed content', () => { + expect(normalizeFullWidthDigits('test123')).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') + }) +}) diff --git a/src/utils/ansiToPng.ts b/src/utils/ansiToPng.ts index 499ecedc6..452afab50 100644 --- a/src/utils/ansiToPng.ts +++ b/src/utils/ansiToPng.ts @@ -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 = { - 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( diff --git a/src/utils/staticRender.tsx b/src/utils/staticRender.tsx index 2f066fe96..1481cc4ea 100644 --- a/src/utils/staticRender.tsx +++ b/src/utils/staticRender.tsx @@ -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 // components in the same render tree. Instead of using a 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 { - return new Promise(async resolve => { - let output = '' +export async function renderToAnsiString(node: React.ReactNode, columns?: number): Promise { + 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() - if (columns !== undefined) { - ;(stream as unknown as { columns: number }).columns = columns - } - stream.on('data', chunk => { - output += chunk.toString() - }) + // 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(); + if (columns !== undefined) { + (stream as unknown as { columns: number }).columns = columns; + } + stream.on('data', chunk => { + output += chunk.toString(); + }); - // Render the component wrapped in RenderOnceAndExit - // Non-TTY stdout (PassThrough) gives full-frame output instead of diffs - const instance = await render( - {node}, - { - stdout: stream as unknown as NodeJS.WriteStream, - patchConsole: false, - }, - ) + // Render the component wrapped in RenderOnceAndExit + // Non-TTY stdout (PassThrough) gives full-frame output instead of diffs + const instance = await render({node}, { + stdout: stream as unknown as NodeJS.WriteStream, + patchConsole: false, + }); - // Wait for the component to exit naturally - await instance.waitUntilExit() + // Wait for the component to exit naturally + 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)) - }) + // Extract only the first frame's content to avoid duplication + // (Ink outputs multiple frames in non-TTY mode) + 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 { - const output = await renderToAnsiString(node, columns) - return stripAnsi(output) +export async function renderToString(node: React.ReactNode, columns?: number): Promise { + const output = await renderToAnsiString(node, columns); + return stripAnsi(output); }