fix: Batch 1a — WebFetchTool header 安全加固 + 核心运行时修复 (from upstream c2ac9a74)
来源: upstream c2ac9a74 (dependency audit findings)
变更 (11 files):
- WebFetchTool: getResponseHeader() 防大小写不一致绕过安全校验 + 新测试
- langfuse: Windows USERPROFILE + homedir() 路径脱敏支持
- REPL.tsx: hideTimeout 清理、onInit/diagnosticTracker ref 闭包修复
- claude.ts: 移除冗余 = undefined 初始化
- query.ts: 移除 biome-ignore + 变量声明简化
- staticRender.tsx: 静态渲染完善
- ansiToPng.ts/bridgeMain.ts/SSETransport.ts/WebSocketTransport.ts: 代码质量
- useGlobalKeybindings/useVoiceIntegration: 质量修复
- 测试文件: sliceAnsi/stringUtils/envExpansion 防模板字面量转义
Build: ✅ 565 files | Tests: 65/65 pass (0 new failures)
This commit is contained in:
parent
9df9e03765
commit
10dfcc6710
|
|
@ -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}`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,31 +4,31 @@
|
|||
* Must be rendered inside KeybindingSetup to have access to the keybinding context.
|
||||
* This component renders nothing - it just registers the keybinding handlers.
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import { useCallback } from 'react'
|
||||
import { instances } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import type { Screen } from '../screens/REPL.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import { useCallback } from 'react';
|
||||
import { instances } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
||||
import type { Screen } from '../screens/REPL.js';
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../services/analytics/index.js'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import { count } from '../utils/array.js'
|
||||
import { getTerminalPanel } from '../utils/terminalPanel.js'
|
||||
} from '../services/analytics/index.js';
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js';
|
||||
import { count } from '../utils/array.js';
|
||||
import { getTerminalPanel } from '../utils/terminalPanel.js';
|
||||
|
||||
type Props = {
|
||||
screen: Screen
|
||||
setScreen: React.Dispatch<React.SetStateAction<Screen>>
|
||||
showAllInTranscript: boolean
|
||||
setShowAllInTranscript: React.Dispatch<React.SetStateAction<boolean>>
|
||||
messageCount: number
|
||||
onEnterTranscript?: () => void
|
||||
onExitTranscript?: () => void
|
||||
virtualScrollActive?: boolean
|
||||
searchBarOpen?: boolean
|
||||
}
|
||||
screen: Screen;
|
||||
setScreen: React.Dispatch<React.SetStateAction<Screen>>;
|
||||
showAllInTranscript: boolean;
|
||||
setShowAllInTranscript: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
messageCount: number;
|
||||
onEnterTranscript?: () => void;
|
||||
onExitTranscript?: () => void;
|
||||
virtualScrollActive?: boolean;
|
||||
searchBarOpen?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers global keybinding handlers for:
|
||||
|
|
@ -48,53 +48,42 @@ export function GlobalKeybindingHandlers({
|
|||
virtualScrollActive,
|
||||
searchBarOpen = false,
|
||||
}: Props): null {
|
||||
const expandedView = useAppState(s => s.expandedView)
|
||||
const setAppState = useSetAppState()
|
||||
const expandedView = useAppState(s => s.expandedView);
|
||||
const setAppState = useSetAppState();
|
||||
|
||||
// Toggle todo list (ctrl+t) - cycles through views
|
||||
const handleToggleTodos = useCallback(() => {
|
||||
logEvent('tengu_toggle_todos', {
|
||||
is_expanded: expandedView === 'tasks',
|
||||
})
|
||||
});
|
||||
setAppState(prev => {
|
||||
const { getAllInProcessTeammateTasks } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js')
|
||||
const hasTeammates =
|
||||
count(
|
||||
getAllInProcessTeammateTasks(prev.tasks),
|
||||
t => t.status === 'running',
|
||||
) > 0
|
||||
require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js');
|
||||
const hasTeammates = count(getAllInProcessTeammateTasks(prev.tasks), t => t.status === 'running') > 0;
|
||||
|
||||
if (hasTeammates) {
|
||||
// Both exist: none → tasks → teammates → none
|
||||
switch (prev.expandedView) {
|
||||
case 'none':
|
||||
return { ...prev, expandedView: 'tasks' as const }
|
||||
return { ...prev, expandedView: 'tasks' as const };
|
||||
case 'tasks':
|
||||
return { ...prev, expandedView: 'teammates' as const }
|
||||
return { ...prev, expandedView: 'teammates' as const };
|
||||
case 'teammates':
|
||||
return { ...prev, expandedView: 'none' as const }
|
||||
return { ...prev, expandedView: 'none' as const };
|
||||
}
|
||||
}
|
||||
// Only tasks: none ↔ tasks
|
||||
return {
|
||||
...prev,
|
||||
expandedView:
|
||||
prev.expandedView === 'tasks'
|
||||
? ('none' as const)
|
||||
: ('tasks' as const),
|
||||
}
|
||||
})
|
||||
}, [expandedView, setAppState])
|
||||
expandedView: prev.expandedView === 'tasks' ? ('none' as const) : ('tasks' as const),
|
||||
};
|
||||
});
|
||||
}, [expandedView, setAppState]);
|
||||
|
||||
// Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript.
|
||||
// Brief view has its own dedicated toggle on ctrl+shift+b.
|
||||
const isBriefOnly =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
|
||||
const handleToggleTranscript = useCallback(() => {
|
||||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
||||
// Escape hatch: GB kill-switch while defaultView=chat was persisted
|
||||
|
|
@ -104,30 +93,30 @@ export function GlobalKeybindingHandlers({
|
|||
// isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode).
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { isBriefEnabled } =
|
||||
require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js')
|
||||
require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') {
|
||||
setAppState(prev => {
|
||||
if (!prev.isBriefOnly) return prev
|
||||
return { ...prev, isBriefOnly: false }
|
||||
})
|
||||
return
|
||||
if (!prev.isBriefOnly) return prev;
|
||||
return { ...prev, isBriefOnly: false };
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isEnteringTranscript = screen !== 'transcript'
|
||||
const isEnteringTranscript = screen !== 'transcript';
|
||||
logEvent('tengu_toggle_transcript', {
|
||||
is_entering: isEnteringTranscript,
|
||||
show_all: showAllInTranscript,
|
||||
message_count: messageCount,
|
||||
})
|
||||
setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript'))
|
||||
setShowAllInTranscript(false)
|
||||
});
|
||||
setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript'));
|
||||
setShowAllInTranscript(false);
|
||||
if (isEnteringTranscript && onEnterTranscript) {
|
||||
onEnterTranscript()
|
||||
onEnterTranscript();
|
||||
}
|
||||
if (!isEnteringTranscript && onExitTranscript) {
|
||||
onExitTranscript()
|
||||
onExitTranscript();
|
||||
}
|
||||
}, [
|
||||
screen,
|
||||
|
|
@ -139,35 +128,29 @@ export function GlobalKeybindingHandlers({
|
|||
setAppState,
|
||||
onEnterTranscript,
|
||||
onExitTranscript,
|
||||
])
|
||||
]);
|
||||
|
||||
// Toggle showing all messages in transcript mode (ctrl+e)
|
||||
const handleToggleShowAll = useCallback(() => {
|
||||
logEvent('tengu_transcript_toggle_show_all', {
|
||||
is_expanding: !showAllInTranscript,
|
||||
message_count: messageCount,
|
||||
})
|
||||
setShowAllInTranscript(prev => !prev)
|
||||
}, [showAllInTranscript, setShowAllInTranscript, messageCount])
|
||||
});
|
||||
setShowAllInTranscript(prev => !prev);
|
||||
}, [showAllInTranscript, setShowAllInTranscript, messageCount]);
|
||||
|
||||
// Exit transcript mode (ctrl+c or escape)
|
||||
const handleExitTranscript = useCallback(() => {
|
||||
logEvent('tengu_transcript_exit', {
|
||||
show_all: showAllInTranscript,
|
||||
message_count: messageCount,
|
||||
})
|
||||
setScreen('prompt')
|
||||
setShowAllInTranscript(false)
|
||||
});
|
||||
setScreen('prompt');
|
||||
setShowAllInTranscript(false);
|
||||
if (onExitTranscript) {
|
||||
onExitTranscript()
|
||||
onExitTranscript();
|
||||
}
|
||||
}, [
|
||||
setScreen,
|
||||
showAllInTranscript,
|
||||
setShowAllInTranscript,
|
||||
messageCount,
|
||||
onExitTranscript,
|
||||
])
|
||||
}, [setScreen, showAllInTranscript, setShowAllInTranscript, messageCount, onExitTranscript]);
|
||||
|
||||
// Toggle brief-only view (ctrl+shift+b). Pure display filter toggle —
|
||||
// does not touch opt-in state. Asymmetric gate (mirrors /brief): OFF
|
||||
|
|
@ -177,35 +160,33 @@ export function GlobalKeybindingHandlers({
|
|||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { isBriefEnabled } =
|
||||
require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js')
|
||||
require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
if (!isBriefEnabled() && !isBriefOnly) return
|
||||
const next = !isBriefOnly
|
||||
if (!isBriefEnabled() && !isBriefOnly) return;
|
||||
const next = !isBriefOnly;
|
||||
logEvent('tengu_brief_mode_toggled', {
|
||||
enabled: next,
|
||||
gated: false,
|
||||
source:
|
||||
'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
source: 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
setAppState(prev => {
|
||||
if (prev.isBriefOnly === next) return prev
|
||||
return { ...prev, isBriefOnly: next }
|
||||
})
|
||||
if (prev.isBriefOnly === next) return prev;
|
||||
return { ...prev, isBriefOnly: next };
|
||||
});
|
||||
}
|
||||
}, [isBriefOnly, setAppState])
|
||||
}, [isBriefOnly, setAppState]);
|
||||
|
||||
// Register keybinding handlers
|
||||
useKeybinding('app:toggleTodos', handleToggleTodos, {
|
||||
context: 'Global',
|
||||
})
|
||||
});
|
||||
useKeybinding('app:toggleTranscript', handleToggleTranscript, {
|
||||
context: 'Global',
|
||||
})
|
||||
});
|
||||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useKeybinding('app:toggleBrief', handleToggleBrief, {
|
||||
context: 'Global',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Register teammate keybinding
|
||||
|
|
@ -215,41 +196,41 @@ export function GlobalKeybindingHandlers({
|
|||
setAppState(prev => ({
|
||||
...prev,
|
||||
showTeammateMessagePreview: !prev.showTeammateMessagePreview,
|
||||
}))
|
||||
}));
|
||||
},
|
||||
{
|
||||
context: 'Global',
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
// Toggle built-in terminal panel (meta+j).
|
||||
// toggle() blocks in spawnSync until the user detaches from tmux.
|
||||
const handleToggleTerminal = useCallback(() => {
|
||||
if (feature('TERMINAL_PANEL')) {
|
||||
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
getTerminalPanel().toggle()
|
||||
getTerminalPanel().toggle();
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
useKeybinding('app:toggleTerminal', handleToggleTerminal, {
|
||||
context: 'Global',
|
||||
})
|
||||
});
|
||||
|
||||
// Clear screen and force full redraw (ctrl+l). Recovery path when the
|
||||
// terminal was cleared externally (macOS Cmd+K) and Ink's diff engine
|
||||
// thinks unchanged cells don't need repainting.
|
||||
const handleRedraw = useCallback(() => {
|
||||
instances.get(process.stdout)?.forceRedraw()
|
||||
}, [])
|
||||
useKeybinding('app:redraw', handleRedraw, { context: 'Global' })
|
||||
instances.get(process.stdout)?.forceRedraw();
|
||||
}, []);
|
||||
useKeybinding('app:redraw', handleRedraw, { context: 'Global' });
|
||||
|
||||
// Transcript-specific bindings (only active when in transcript mode)
|
||||
const isInTranscript = screen === 'transcript'
|
||||
const isInTranscript = screen === 'transcript';
|
||||
useKeybinding('transcript:toggleShowAll', handleToggleShowAll, {
|
||||
context: 'Transcript',
|
||||
isActive: isInTranscript && !virtualScrollActive,
|
||||
})
|
||||
});
|
||||
useKeybinding('transcript:exit', handleExitTranscript, {
|
||||
context: 'Transcript',
|
||||
// Bar-open is a mode (owns keystrokes). Navigating (highlights
|
||||
|
|
@ -258,7 +239,7 @@ export function GlobalKeybindingHandlers({
|
|||
// so without this gate its onCancel AND this handler would both
|
||||
// fire on one Esc (child registers first, fires first, bubbles).
|
||||
isActive: isInTranscript && !searchBarOpen,
|
||||
})
|
||||
});
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,84 +1,69 @@
|
|||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useNotifications } from '../context/notifications.js'
|
||||
import { useIsModalOverlayActive } from '../context/overlayContext.js'
|
||||
import {
|
||||
useGetVoiceState,
|
||||
useSetVoiceState,
|
||||
useVoiceState,
|
||||
} from '../context/voice.js'
|
||||
import { KeyboardEvent, useInput } from '@anthropic/ink'
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNotifications } from '../context/notifications.js';
|
||||
import { useIsModalOverlayActive } from '../context/overlayContext.js';
|
||||
import { useGetVoiceState, useSetVoiceState, useVoiceState } from '../context/voice.js';
|
||||
import { KeyboardEvent, useInput } from '@anthropic/ink';
|
||||
// backward-compat bridge until REPL wires handleKeyDown to <Box onKeyDown>
|
||||
import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'
|
||||
import { keystrokesEqual } from '../keybindings/resolver.js'
|
||||
import type { ParsedKeystroke } from '../keybindings/types.js'
|
||||
import { normalizeFullWidthSpace } from '../utils/stringUtils.js'
|
||||
import { useVoiceEnabled } from './useVoiceEnabled.js'
|
||||
import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js';
|
||||
import { keystrokesEqual } from '../keybindings/resolver.js';
|
||||
import type { ParsedKeystroke } from '../keybindings/types.js';
|
||||
import { normalizeFullWidthSpace } from '../utils/stringUtils.js';
|
||||
import { useVoiceEnabled } from './useVoiceEnabled.js';
|
||||
|
||||
// Dead code elimination: conditional import for voice input hook.
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
// Capture the module namespace, not the function: spyOn() mutates the module
|
||||
// object, so `voiceNs.useVoice(...)` resolves to the spy even if this module
|
||||
// was loaded before the spy was installed (test ordering independence).
|
||||
const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature(
|
||||
'VOICE_MODE',
|
||||
)
|
||||
const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature('VOICE_MODE')
|
||||
? require('./useVoice.js')
|
||||
: {
|
||||
useVoice: ({
|
||||
enabled: _e,
|
||||
}: {
|
||||
onTranscript: (t: string) => void
|
||||
enabled: boolean
|
||||
}) => ({
|
||||
useVoice: ({ enabled: _e }: { onTranscript: (t: string) => void; enabled: boolean }) => ({
|
||||
state: 'idle' as const,
|
||||
handleKeyEvent: (_fallbackMs?: number) => {},
|
||||
}),
|
||||
}
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
// Maximum gap (ms) between key presses to count as held (auto-repeat).
|
||||
// Terminal auto-repeat fires every 30-80ms; 120ms covers jitter while
|
||||
// excluding normal typing speed (100-300ms between keystrokes).
|
||||
const RAPID_KEY_GAP_MS = 120
|
||||
const RAPID_KEY_GAP_MS = 120;
|
||||
|
||||
// Fallback (ms) for modifier-combo first-press activation. Must match
|
||||
// FIRST_PRESS_FALLBACK_MS in useVoice.ts. Covers the max OS initial
|
||||
// key-repeat delay (~2s on macOS with slider at "Long") so holding a
|
||||
// modifier combo doesn't fragment into two sessions when the first
|
||||
// auto-repeat arrives after the default 600ms REPEAT_FALLBACK_MS.
|
||||
const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000
|
||||
const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000;
|
||||
|
||||
// Number of rapid consecutive key events required to activate voice.
|
||||
// Only applies to bare-char bindings (space, v, etc.) where a single press
|
||||
// could be normal typing. Modifier combos activate on the first press.
|
||||
const HOLD_THRESHOLD = 5
|
||||
const HOLD_THRESHOLD = 5;
|
||||
|
||||
// Number of rapid key events to start showing warmup feedback.
|
||||
const WARMUP_THRESHOLD = 2
|
||||
const WARMUP_THRESHOLD = 2;
|
||||
|
||||
// Match a KeyboardEvent against a ParsedKeystroke. Replaces the legacy
|
||||
// matchesKeystroke(input, Key, ...) path which assumed useInput's raw
|
||||
// `input` arg — KeyboardEvent.key holds normalized names (e.g. 'space',
|
||||
// 'f9') that getKeyName() didn't handle, so modifier combos and f-keys
|
||||
// silently failed to match after the onKeyDown migration (#23524).
|
||||
function matchesKeyboardEvent(
|
||||
e: KeyboardEvent,
|
||||
target: ParsedKeystroke,
|
||||
): boolean {
|
||||
function matchesKeyboardEvent(e: KeyboardEvent, target: ParsedKeystroke): boolean {
|
||||
// KeyboardEvent stores key names; ParsedKeystroke stores ' ' for space
|
||||
// and 'enter' for return (see parser.ts case 'space'/'return').
|
||||
const key =
|
||||
e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase()
|
||||
if (key !== target.key) return false
|
||||
if (e.ctrl !== target.ctrl) return false
|
||||
if (e.shift !== target.shift) return false
|
||||
const key = e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase();
|
||||
if (key !== target.key) return false;
|
||||
if (e.ctrl !== target.ctrl) return false;
|
||||
if (e.shift !== target.shift) return false;
|
||||
// KeyboardEvent.meta folds alt|option (terminal limitation — esc-prefix);
|
||||
// ParsedKeystroke has both alt and meta as aliases for the same thing.
|
||||
if (e.meta !== (target.alt || target.meta)) return false
|
||||
if (e.superKey !== target.super) return false
|
||||
return true
|
||||
if (e.meta !== (target.alt || target.meta)) return false;
|
||||
if (e.superKey !== target.super) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hardcoded default for when there's no KeybindingProvider at all (e.g.
|
||||
|
|
@ -92,60 +77,60 @@ const DEFAULT_VOICE_KEYSTROKE: ParsedKeystroke = {
|
|||
shift: false,
|
||||
meta: false,
|
||||
super: false,
|
||||
}
|
||||
};
|
||||
|
||||
type InsertTextHandle = {
|
||||
insert: (text: string) => void
|
||||
setInputWithCursor: (value: string, cursor: number) => void
|
||||
cursorOffset: number
|
||||
}
|
||||
insert: (text: string) => void;
|
||||
setInputWithCursor: (value: string, cursor: number) => void;
|
||||
cursorOffset: number;
|
||||
};
|
||||
|
||||
type UseVoiceIntegrationArgs = {
|
||||
setInputValueRaw: React.Dispatch<React.SetStateAction<string>>
|
||||
inputValueRef: React.RefObject<string>
|
||||
insertTextRef: React.RefObject<InsertTextHandle | null>
|
||||
}
|
||||
setInputValueRaw: React.Dispatch<React.SetStateAction<string>>;
|
||||
inputValueRef: React.RefObject<string>;
|
||||
insertTextRef: React.RefObject<InsertTextHandle | null>;
|
||||
};
|
||||
|
||||
type InterimRange = { start: number; end: number }
|
||||
type InterimRange = { start: number; end: number };
|
||||
|
||||
type StripOpts = {
|
||||
// Which char to strip (the configured hold key). Defaults to space.
|
||||
char?: string
|
||||
char?: string;
|
||||
// Capture the voice prefix/suffix anchor at the stripped position.
|
||||
anchor?: boolean
|
||||
anchor?: boolean;
|
||||
// Minimum trailing count to leave behind — prevents stripping the
|
||||
// intentional warmup chars when defensively cleaning up leaks.
|
||||
floor?: number
|
||||
}
|
||||
floor?: number;
|
||||
};
|
||||
|
||||
type UseVoiceIntegrationResult = {
|
||||
// Returns the number of trailing chars remaining after stripping.
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number;
|
||||
// Undo the gap space and reset anchor refs after a failed voice activation.
|
||||
resetAnchor: () => void
|
||||
handleKeyEvent: (fallbackMs?: number) => void
|
||||
interimRange: InterimRange | null
|
||||
}
|
||||
resetAnchor: () => void;
|
||||
handleKeyEvent: (fallbackMs?: number) => void;
|
||||
interimRange: InterimRange | null;
|
||||
};
|
||||
|
||||
export function useVoiceIntegration({
|
||||
setInputValueRaw,
|
||||
inputValueRef,
|
||||
insertTextRef,
|
||||
}: UseVoiceIntegrationArgs): UseVoiceIntegrationResult {
|
||||
const { addNotification } = useNotifications()
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
// Tracks the input content before/after the cursor when voice starts,
|
||||
// so interim transcripts can be inserted at the cursor position without
|
||||
// clobbering surrounding user text.
|
||||
const voicePrefixRef = useRef<string | null>(null)
|
||||
const voiceSuffixRef = useRef<string>('')
|
||||
const voicePrefixRef = useRef<string | null>(null);
|
||||
const voiceSuffixRef = useRef<string>('');
|
||||
// Tracks the last input value this hook wrote (via anchor, interim effect,
|
||||
// or handleVoiceTranscript). If inputValueRef.current diverges, the user
|
||||
// submitted or edited — both write paths bail to avoid clobbering. This is
|
||||
// the only guard that correctly handles empty-prefix-empty-suffix: a
|
||||
// startsWith('')/endsWith('') check vacuously passes, and a length check
|
||||
// can't distinguish a cleared input from a never-set one.
|
||||
const lastSetInputRef = useRef<string | null>(null)
|
||||
const lastSetInputRef = useRef<string | null>(null);
|
||||
|
||||
// Strip trailing hold-key chars (and optionally capture the voice
|
||||
// anchor). Called during warmup (to clean up chars that leaked past
|
||||
|
|
@ -160,29 +145,22 @@ export function useVoiceIntegration({
|
|||
// trailing chars remaining after stripping. When nothing changes, no
|
||||
// state update is performed.
|
||||
const stripTrailing = useCallback(
|
||||
(
|
||||
maxStrip: number,
|
||||
{ char = ' ', anchor = false, floor = 0 }: StripOpts = {},
|
||||
) => {
|
||||
const prev = inputValueRef.current
|
||||
const offset = insertTextRef.current?.cursorOffset ?? prev.length
|
||||
const beforeCursor = prev.slice(0, offset)
|
||||
const afterCursor = prev.slice(offset)
|
||||
(maxStrip: number, { char = ' ', anchor = false, floor = 0 }: StripOpts = {}) => {
|
||||
const prev = inputValueRef.current;
|
||||
const offset = insertTextRef.current?.cursorOffset ?? prev.length;
|
||||
const beforeCursor = prev.slice(0, offset);
|
||||
const afterCursor = prev.slice(offset);
|
||||
// When the hold key is space, also count full-width spaces (U+3000)
|
||||
// that a CJK IME may have inserted for the same physical key.
|
||||
// U+3000 is BMP single-code-unit so indices align with beforeCursor.
|
||||
const scan =
|
||||
char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor
|
||||
let trailing = 0
|
||||
while (
|
||||
trailing < scan.length &&
|
||||
scan[scan.length - 1 - trailing] === char
|
||||
) {
|
||||
trailing++
|
||||
const scan = char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor;
|
||||
let trailing = 0;
|
||||
while (trailing < scan.length && scan[scan.length - 1 - trailing] === char) {
|
||||
trailing++;
|
||||
}
|
||||
const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip))
|
||||
const remaining = trailing - stripCount
|
||||
const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount)
|
||||
const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip));
|
||||
const remaining = trailing - stripCount;
|
||||
const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount);
|
||||
// When anchoring with a non-space suffix, insert a gap space so the
|
||||
// waveform cursor sits on the gap instead of covering the first
|
||||
// suffix letter. The interim transcript effect maintains this same
|
||||
|
|
@ -192,26 +170,26 @@ export function useVoiceIntegration({
|
|||
// voice (voiceState stayed 'idle'), the cleanup effect didn't fire and
|
||||
// the old anchor is stale. anchor=true is only passed on the single
|
||||
// activation call, never during recording, so overwrite is safe.
|
||||
let gap = ''
|
||||
let gap = '';
|
||||
if (anchor) {
|
||||
voicePrefixRef.current = stripped
|
||||
voiceSuffixRef.current = afterCursor
|
||||
voicePrefixRef.current = stripped;
|
||||
voiceSuffixRef.current = afterCursor;
|
||||
if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) {
|
||||
gap = ' '
|
||||
gap = ' ';
|
||||
}
|
||||
}
|
||||
const newValue = stripped + gap + afterCursor
|
||||
if (anchor) lastSetInputRef.current = newValue
|
||||
if (newValue === prev && stripCount === 0) return remaining
|
||||
const newValue = stripped + gap + afterCursor;
|
||||
if (anchor) lastSetInputRef.current = newValue;
|
||||
if (newValue === prev && stripCount === 0) return remaining;
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newValue, stripped.length)
|
||||
insertTextRef.current.setInputWithCursor(newValue, stripped.length);
|
||||
} else {
|
||||
setInputValueRaw(newValue)
|
||||
setInputValueRaw(newValue);
|
||||
}
|
||||
return remaining
|
||||
return remaining;
|
||||
},
|
||||
[setInputValueRaw, inputValueRef, insertTextRef],
|
||||
)
|
||||
);
|
||||
|
||||
// Undo the gap space inserted by stripTrailing(..., {anchor:true}) and
|
||||
// reset the voice prefix/suffix refs. Called when voice activation fails
|
||||
|
|
@ -220,123 +198,109 @@ export function useVoiceIntegration({
|
|||
// reach the stale anchor. Without this, the gap space and stale refs
|
||||
// persist in the input.
|
||||
const resetAnchor = useCallback(() => {
|
||||
const prefix = voicePrefixRef.current
|
||||
if (prefix === null) return
|
||||
const suffix = voiceSuffixRef.current
|
||||
voicePrefixRef.current = null
|
||||
voiceSuffixRef.current = ''
|
||||
const restored = prefix + suffix
|
||||
const prefix = voicePrefixRef.current;
|
||||
if (prefix === null) return;
|
||||
const suffix = voiceSuffixRef.current;
|
||||
voicePrefixRef.current = null;
|
||||
voiceSuffixRef.current = '';
|
||||
const restored = prefix + suffix;
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(restored, prefix.length)
|
||||
insertTextRef.current.setInputWithCursor(restored, prefix.length);
|
||||
} else {
|
||||
setInputValueRaw(restored)
|
||||
setInputValueRaw(restored);
|
||||
}
|
||||
}, [setInputValueRaw, insertTextRef])
|
||||
}, [setInputValueRaw, insertTextRef]);
|
||||
|
||||
// Voice state selectors. useVoiceEnabled = user intent (settings) +
|
||||
// auth + GB kill-switch, with the auth half memoized on authVersion so
|
||||
// render loops never hit a cold keychain spawn.
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
const voiceInterimTranscript = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceInterimTranscript)
|
||||
: ''
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
||||
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
|
||||
const voiceInterimTranscript = feature('VOICE_MODE') ? useVoiceState(s => s.voiceInterimTranscript) : '';
|
||||
|
||||
// Set the voice anchor for focus mode (where recording starts via terminal
|
||||
// focus, not key hold). Key-hold sets the anchor in stripTrailing.
|
||||
useEffect(() => {
|
||||
if (!feature('VOICE_MODE')) return
|
||||
if (!feature('VOICE_MODE')) return;
|
||||
if (voiceState === 'recording' && voicePrefixRef.current === null) {
|
||||
const input = inputValueRef.current
|
||||
const offset = insertTextRef.current?.cursorOffset ?? input.length
|
||||
voicePrefixRef.current = input.slice(0, offset)
|
||||
voiceSuffixRef.current = input.slice(offset)
|
||||
lastSetInputRef.current = input
|
||||
const input = inputValueRef.current;
|
||||
const offset = insertTextRef.current?.cursorOffset ?? input.length;
|
||||
voicePrefixRef.current = input.slice(0, offset);
|
||||
voiceSuffixRef.current = input.slice(offset);
|
||||
lastSetInputRef.current = input;
|
||||
}
|
||||
if (voiceState === 'idle') {
|
||||
voicePrefixRef.current = null
|
||||
voiceSuffixRef.current = ''
|
||||
lastSetInputRef.current = null
|
||||
voicePrefixRef.current = null;
|
||||
voiceSuffixRef.current = '';
|
||||
lastSetInputRef.current = null;
|
||||
}
|
||||
}, [voiceState, inputValueRef, insertTextRef])
|
||||
}, [voiceState, inputValueRef, insertTextRef]);
|
||||
|
||||
// Live-update the prompt input with the interim transcript as voice
|
||||
// transcribes speech. The prefix (user-typed text before the cursor) is
|
||||
// preserved and the transcript is inserted between prefix and suffix.
|
||||
useEffect(() => {
|
||||
if (!feature('VOICE_MODE')) return
|
||||
if (voicePrefixRef.current === null) return
|
||||
const prefix = voicePrefixRef.current
|
||||
const suffix = voiceSuffixRef.current
|
||||
if (!feature('VOICE_MODE')) return;
|
||||
if (voicePrefixRef.current === null) return;
|
||||
const prefix = voicePrefixRef.current;
|
||||
const suffix = voiceSuffixRef.current;
|
||||
// Submit race: if the input isn't what this hook last set it to, the
|
||||
// user submitted (clearing it) or edited it. voicePrefixRef is only
|
||||
// cleared on voiceState→idle, so it's still set during the 'processing'
|
||||
// window between CloseStream and WS close — this catches refined
|
||||
// TranscriptText arriving then and re-filling a cleared input.
|
||||
if (inputValueRef.current !== lastSetInputRef.current) return
|
||||
const needsSpace =
|
||||
prefix.length > 0 &&
|
||||
!/\s$/.test(prefix) &&
|
||||
voiceInterimTranscript.length > 0
|
||||
if (inputValueRef.current !== lastSetInputRef.current) return;
|
||||
const needsSpace = prefix.length > 0 && !/\s$/.test(prefix) && voiceInterimTranscript.length > 0;
|
||||
// Don't gate on voiceInterimTranscript.length -- when interim clears to ''
|
||||
// after handleVoiceTranscript sets the final text, the trailing space
|
||||
// between prefix and suffix must still be preserved.
|
||||
const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix)
|
||||
const leadingSpace = needsSpace ? ' ' : ''
|
||||
const trailingSpace = needsTrailingSpace ? ' ' : ''
|
||||
const newValue =
|
||||
prefix + leadingSpace + voiceInterimTranscript + trailingSpace + suffix
|
||||
const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix);
|
||||
const leadingSpace = needsSpace ? ' ' : '';
|
||||
const trailingSpace = needsTrailingSpace ? ' ' : '';
|
||||
const newValue = prefix + leadingSpace + voiceInterimTranscript + trailingSpace + suffix;
|
||||
// Position cursor after the transcribed text (before suffix)
|
||||
const cursorPos =
|
||||
prefix.length + leadingSpace.length + voiceInterimTranscript.length
|
||||
const cursorPos = prefix.length + leadingSpace.length + voiceInterimTranscript.length;
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newValue, cursorPos)
|
||||
insertTextRef.current.setInputWithCursor(newValue, cursorPos);
|
||||
} else {
|
||||
setInputValueRaw(newValue)
|
||||
setInputValueRaw(newValue);
|
||||
}
|
||||
lastSetInputRef.current = newValue
|
||||
}, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef])
|
||||
lastSetInputRef.current = newValue;
|
||||
}, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]);
|
||||
|
||||
const handleVoiceTranscript = useCallback(
|
||||
(text: string) => {
|
||||
if (!feature('VOICE_MODE')) return
|
||||
const prefix = voicePrefixRef.current
|
||||
if (!feature('VOICE_MODE')) return;
|
||||
const prefix = voicePrefixRef.current;
|
||||
// No voice anchor — voice was reset (or never started). Nothing to do.
|
||||
if (prefix === null) return
|
||||
const suffix = voiceSuffixRef.current
|
||||
if (prefix === null) return;
|
||||
const suffix = voiceSuffixRef.current;
|
||||
// Submit race: finishRecording() → user presses Enter (input cleared)
|
||||
// → WebSocket close → this callback fires with stale prefix/suffix.
|
||||
// If the input isn't what this hook last set (via the interim effect
|
||||
// or anchor), the user submitted or edited — don't re-fill. Comparing
|
||||
// against `text.length` would false-positive when the final is longer
|
||||
// than the interim (ASR routinely adds punctuation/corrections).
|
||||
if (inputValueRef.current !== lastSetInputRef.current) return
|
||||
const needsSpace =
|
||||
prefix.length > 0 && !/\s$/.test(prefix) && text.length > 0
|
||||
const needsTrailingSpace =
|
||||
suffix.length > 0 && !/^\s/.test(suffix) && text.length > 0
|
||||
const leadingSpace = needsSpace ? ' ' : ''
|
||||
const trailingSpace = needsTrailingSpace ? ' ' : ''
|
||||
const newInput = prefix + leadingSpace + text + trailingSpace + suffix
|
||||
if (inputValueRef.current !== lastSetInputRef.current) return;
|
||||
const needsSpace = prefix.length > 0 && !/\s$/.test(prefix) && text.length > 0;
|
||||
const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix) && text.length > 0;
|
||||
const leadingSpace = needsSpace ? ' ' : '';
|
||||
const trailingSpace = needsTrailingSpace ? ' ' : '';
|
||||
const newInput = prefix + leadingSpace + text + trailingSpace + suffix;
|
||||
// Position cursor after the transcribed text (before suffix)
|
||||
const cursorPos = prefix.length + leadingSpace.length + text.length
|
||||
const cursorPos = prefix.length + leadingSpace.length + text.length;
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newInput, cursorPos)
|
||||
insertTextRef.current.setInputWithCursor(newInput, cursorPos);
|
||||
} else {
|
||||
setInputValueRaw(newInput)
|
||||
setInputValueRaw(newInput);
|
||||
}
|
||||
lastSetInputRef.current = newInput
|
||||
lastSetInputRef.current = newInput;
|
||||
// Update the prefix to include this chunk so focus mode can continue
|
||||
// appending subsequent transcripts after it.
|
||||
voicePrefixRef.current = prefix + leadingSpace + text
|
||||
voicePrefixRef.current = prefix + leadingSpace + text;
|
||||
},
|
||||
[setInputValueRaw, inputValueRef, insertTextRef],
|
||||
)
|
||||
);
|
||||
|
||||
const voice = voiceNs.useVoice({
|
||||
onTranscript: handleVoiceTranscript,
|
||||
|
|
@ -347,34 +311,31 @@ export function useVoiceIntegration({
|
|||
color: 'error',
|
||||
priority: 'immediate',
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
});
|
||||
},
|
||||
enabled: voiceEnabled,
|
||||
focusMode: false,
|
||||
})
|
||||
});
|
||||
|
||||
// Compute the character range of interim (not-yet-finalized) transcript
|
||||
// text in the input value, so the UI can dim it.
|
||||
const interimRange = useMemo((): InterimRange | null => {
|
||||
if (!feature('VOICE_MODE')) return null
|
||||
if (voicePrefixRef.current === null) return null
|
||||
if (voiceInterimTranscript.length === 0) return null
|
||||
const prefix = voicePrefixRef.current
|
||||
const needsSpace =
|
||||
prefix.length > 0 &&
|
||||
!/\s$/.test(prefix) &&
|
||||
voiceInterimTranscript.length > 0
|
||||
const start = prefix.length + (needsSpace ? 1 : 0)
|
||||
const end = start + voiceInterimTranscript.length
|
||||
return { start, end }
|
||||
}, [voiceInterimTranscript])
|
||||
if (!feature('VOICE_MODE')) return null;
|
||||
if (voicePrefixRef.current === null) return null;
|
||||
if (voiceInterimTranscript.length === 0) return null;
|
||||
const prefix = voicePrefixRef.current;
|
||||
const needsSpace = prefix.length > 0 && !/\s$/.test(prefix) && voiceInterimTranscript.length > 0;
|
||||
const start = prefix.length + (needsSpace ? 1 : 0);
|
||||
const end = start + voiceInterimTranscript.length;
|
||||
return { start, end };
|
||||
}, [voiceInterimTranscript]);
|
||||
|
||||
return {
|
||||
stripTrailing,
|
||||
resetAnchor,
|
||||
handleKeyEvent: voice.handleKeyEvent,
|
||||
interimRange,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -407,21 +368,17 @@ export function useVoiceKeybindingHandler({
|
|||
resetAnchor,
|
||||
isActive,
|
||||
}: {
|
||||
voiceHandleKeyEvent: (fallbackMs?: number) => void
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number
|
||||
resetAnchor: () => void
|
||||
isActive: boolean
|
||||
voiceHandleKeyEvent: (fallbackMs?: number) => void;
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number;
|
||||
resetAnchor: () => void;
|
||||
isActive: boolean;
|
||||
}): { handleKeyDown: (e: KeyboardEvent) => void } {
|
||||
const getVoiceState = useGetVoiceState()
|
||||
const setVoiceState = useSetVoiceState()
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
const isModalOverlayActive = useIsModalOverlayActive()
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState)
|
||||
: 'idle'
|
||||
const getVoiceState = useGetVoiceState();
|
||||
const setVoiceState = useSetVoiceState();
|
||||
const keybindingContext = useOptionalKeybindingContext();
|
||||
const isModalOverlayActive = useIsModalOverlayActive();
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
||||
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : 'idle';
|
||||
|
||||
// Find the configured key for voice:pushToTalk from keybinding context.
|
||||
// Forward iteration with last-wins (matching the resolver): if a later
|
||||
|
|
@ -433,22 +390,22 @@ export function useVoiceKeybindingHandler({
|
|||
// is also bound in Settings/Confirmation/Plugin (select:accept etc.);
|
||||
// without the filter those would null out the default.
|
||||
const voiceKeystroke = useMemo((): ParsedKeystroke | null => {
|
||||
if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE
|
||||
let result: ParsedKeystroke | null = null
|
||||
if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE;
|
||||
let result: ParsedKeystroke | null = null;
|
||||
for (const binding of keybindingContext.bindings) {
|
||||
if (binding.context !== 'Chat') continue
|
||||
if (binding.chord.length !== 1) continue
|
||||
const ks = binding.chord[0]
|
||||
if (!ks) continue
|
||||
if (binding.context !== 'Chat') continue;
|
||||
if (binding.chord.length !== 1) continue;
|
||||
const ks = binding.chord[0];
|
||||
if (!ks) continue;
|
||||
if (binding.action === 'voice:pushToTalk') {
|
||||
result = ks
|
||||
result = ks;
|
||||
} else if (result !== null && keystrokesEqual(ks, result)) {
|
||||
// A later binding overrides this chord (null unbind or reassignment)
|
||||
result = null
|
||||
result = null;
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [keybindingContext])
|
||||
return result;
|
||||
}, [keybindingContext]);
|
||||
|
||||
// If the binding is a bare (unmodified) single printable char, terminal
|
||||
// auto-repeat may batch N keystrokes into one input event (e.g. "vvv"),
|
||||
|
|
@ -465,9 +422,9 @@ export function useVoiceKeybindingHandler({
|
|||
!voiceKeystroke.meta &&
|
||||
!voiceKeystroke.super
|
||||
? voiceKeystroke.key
|
||||
: null
|
||||
: null;
|
||||
|
||||
const rapidCountRef = useRef(0)
|
||||
const rapidCountRef = useRef(0);
|
||||
// How many rapid chars we intentionally let through to the text
|
||||
// input (the first WARMUP_THRESHOLD). The activation strip removes
|
||||
// up to this many + the activation event's potential leak. For the
|
||||
|
|
@ -476,15 +433,15 @@ export function useVoiceKeybindingHandler({
|
|||
// one pre-existing char if the input already ended in the bound
|
||||
// letter (e.g. "hav" + hold "v" → "ha"). We don't track that
|
||||
// boundary — it's best-effort and the warning says so.
|
||||
const charsInInputRef = useRef(0)
|
||||
const charsInInputRef = useRef(0);
|
||||
// Trailing-char count remaining after the activation strip — these
|
||||
// belong to the user's anchored prefix and must be preserved during
|
||||
// recording's defensive leak cleanup.
|
||||
const recordingFloorRef = useRef(0)
|
||||
const recordingFloorRef = useRef(0);
|
||||
// True when the current recording was started by key-hold (not focus).
|
||||
// Used to avoid swallowing keypresses during focus-mode recording.
|
||||
const isHoldActiveRef = useRef(false)
|
||||
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isHoldActiveRef = useRef(false);
|
||||
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Reset hold state as soon as we leave 'recording'. The physical hold
|
||||
// ends when key-repeat stops (state → 'processing'); keeping the ref
|
||||
|
|
@ -492,19 +449,19 @@ export function useVoiceKeybindingHandler({
|
|||
// while the transcript finalizes.
|
||||
useEffect(() => {
|
||||
if (voiceState !== 'recording') {
|
||||
isHoldActiveRef.current = false
|
||||
rapidCountRef.current = 0
|
||||
charsInInputRef.current = 0
|
||||
recordingFloorRef.current = 0
|
||||
isHoldActiveRef.current = false;
|
||||
rapidCountRef.current = 0;
|
||||
charsInInputRef.current = 0;
|
||||
recordingFloorRef.current = 0;
|
||||
setVoiceState(prev => {
|
||||
if (!prev.voiceWarmingUp) return prev
|
||||
return { ...prev, voiceWarmingUp: false }
|
||||
})
|
||||
if (!prev.voiceWarmingUp) return prev;
|
||||
return { ...prev, voiceWarmingUp: false };
|
||||
});
|
||||
}
|
||||
}, [voiceState, setVoiceState])
|
||||
}, [voiceState, setVoiceState]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!voiceEnabled) return
|
||||
if (!voiceEnabled) return;
|
||||
|
||||
// PromptInput is not a valid transcript target — let the hold key
|
||||
// flow through instead of swallowing it into stale refs (#33556).
|
||||
|
|
@ -514,37 +471,32 @@ export function useVoiceKeybindingHandler({
|
|||
// /plugin. Mirrors CommandKeybindingHandlers' isActive gate.
|
||||
// - isModalOverlayActive: overlay (permission dialog, Select with
|
||||
// onCancel) has focus; PromptInput is mounted but focus=false.
|
||||
if (!isActive || isModalOverlayActive) return
|
||||
if (!isActive || isModalOverlayActive) return;
|
||||
|
||||
// null means the user overrode the default (null-unbind/reassign) —
|
||||
// hold-to-talk is disabled via binding. To toggle the feature
|
||||
// itself, use /voice.
|
||||
if (voiceKeystroke === null) return
|
||||
if (voiceKeystroke === null) return;
|
||||
|
||||
// Match the configured key. Bare chars match by content (handles
|
||||
// batched auto-repeat like "vvv") with a modifier reject so e.g.
|
||||
// ctrl+v doesn't trip a "v" binding. Modifier combos go through
|
||||
// matchesKeyboardEvent (one event per repeat, no batching).
|
||||
let repeatCount: number
|
||||
let repeatCount: number;
|
||||
if (bareChar !== null) {
|
||||
if (e.ctrl || e.meta || e.shift) return
|
||||
if (e.ctrl || e.meta || e.shift) return;
|
||||
// When bound to space, also accept U+3000 (full-width space) —
|
||||
// CJK IMEs emit it for the same physical key.
|
||||
const normalized =
|
||||
bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key
|
||||
const normalized = bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key;
|
||||
// Fast-path: normal typing (any char that isn't the bound one)
|
||||
// bails here without allocating. The repeat() check only matters
|
||||
// for batched auto-repeat (input.length > 1) which is rare.
|
||||
if (normalized[0] !== bareChar) return
|
||||
if (
|
||||
normalized.length > 1 &&
|
||||
normalized !== bareChar.repeat(normalized.length)
|
||||
)
|
||||
return
|
||||
repeatCount = normalized.length
|
||||
if (normalized[0] !== bareChar) return;
|
||||
if (normalized.length > 1 && normalized !== bareChar.repeat(normalized.length)) return;
|
||||
repeatCount = normalized.length;
|
||||
} else {
|
||||
if (!matchesKeyboardEvent(e, voiceKeystroke)) return
|
||||
repeatCount = 1
|
||||
if (!matchesKeyboardEvent(e, voiceKeystroke)) return;
|
||||
repeatCount = 1;
|
||||
}
|
||||
|
||||
// Guard: only swallow keypresses when recording was triggered by
|
||||
|
|
@ -554,22 +506,22 @@ export function useVoiceKeybindingHandler({
|
|||
// from the store so that if voiceHandleKeyEvent() fails to transition
|
||||
// state (module not loaded, stream unavailable) we don't permanently
|
||||
// swallow keypresses.
|
||||
const currentVoiceState = getVoiceState().voiceState
|
||||
const currentVoiceState = getVoiceState().voiceState;
|
||||
if (isHoldActiveRef.current && currentVoiceState !== 'idle') {
|
||||
// Already recording — swallow continued keypresses and forward
|
||||
// to voice for release detection. For bare chars, defensively
|
||||
// strip in case the text input handler fired before this one
|
||||
// (listener order is not guaranteed). Modifier combos don't
|
||||
// insert text, so nothing to strip.
|
||||
e.stopImmediatePropagation()
|
||||
e.stopImmediatePropagation();
|
||||
if (bareChar !== null) {
|
||||
stripTrailing(repeatCount, {
|
||||
char: bareChar,
|
||||
floor: recordingFloorRef.current,
|
||||
})
|
||||
});
|
||||
}
|
||||
voiceHandleKeyEvent()
|
||||
return
|
||||
voiceHandleKeyEvent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-hold recording (focus-mode) or processing is active.
|
||||
|
|
@ -579,12 +531,12 @@ export function useVoiceKeybindingHandler({
|
|||
// hit the warmup else-branch (swallow only). Bare chars flow through
|
||||
// unconditionally — user may be typing during focus-recording.
|
||||
if (currentVoiceState !== 'idle') {
|
||||
if (bareChar === null) e.stopImmediatePropagation()
|
||||
return
|
||||
if (bareChar === null) e.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const countBefore = rapidCountRef.current
|
||||
rapidCountRef.current += repeatCount
|
||||
const countBefore = rapidCountRef.current;
|
||||
rapidCountRef.current += repeatCount;
|
||||
|
||||
// ── Activation ────────────────────────────────────────────
|
||||
// Handled first so the warmup branch below does NOT also run
|
||||
|
|
@ -594,37 +546,37 @@ export function useVoiceKeybindingHandler({
|
|||
// typed accidentally, so the hold threshold (which exists to
|
||||
// distinguish typing a space from holding space) doesn't apply.
|
||||
if (bareChar === null || rapidCountRef.current >= HOLD_THRESHOLD) {
|
||||
e.stopImmediatePropagation()
|
||||
e.stopImmediatePropagation();
|
||||
if (resetTimerRef.current) {
|
||||
clearTimeout(resetTimerRef.current)
|
||||
resetTimerRef.current = null
|
||||
clearTimeout(resetTimerRef.current);
|
||||
resetTimerRef.current = null;
|
||||
}
|
||||
rapidCountRef.current = 0
|
||||
isHoldActiveRef.current = true
|
||||
rapidCountRef.current = 0;
|
||||
isHoldActiveRef.current = true;
|
||||
setVoiceState(prev => {
|
||||
if (!prev.voiceWarmingUp) return prev
|
||||
return { ...prev, voiceWarmingUp: false }
|
||||
})
|
||||
if (!prev.voiceWarmingUp) return prev;
|
||||
return { ...prev, voiceWarmingUp: false };
|
||||
});
|
||||
if (bareChar !== null) {
|
||||
// Strip the intentional warmup chars plus this event's leak
|
||||
// (if text input fired first). Cap covers both; min(trailing)
|
||||
// handles the no-leak case. Anchor the voice prefix here.
|
||||
// The return value (remaining) becomes the floor for
|
||||
// recording-time leak cleanup.
|
||||
recordingFloorRef.current = stripTrailing(
|
||||
charsInInputRef.current + repeatCount,
|
||||
{ char: bareChar, anchor: true },
|
||||
)
|
||||
charsInInputRef.current = 0
|
||||
voiceHandleKeyEvent()
|
||||
recordingFloorRef.current = stripTrailing(charsInInputRef.current + repeatCount, {
|
||||
char: bareChar,
|
||||
anchor: true,
|
||||
});
|
||||
charsInInputRef.current = 0;
|
||||
voiceHandleKeyEvent();
|
||||
} else {
|
||||
// Modifier combo: nothing inserted, nothing to strip. Just
|
||||
// anchor the voice prefix at the current cursor position.
|
||||
// Longer fallback: this call is at t=0 (before auto-repeat),
|
||||
// so the gap to the next keypress is the OS initial repeat
|
||||
// *delay* (up to ~2s), not the repeat *rate* (~30-80ms).
|
||||
stripTrailing(0, { anchor: true })
|
||||
voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS)
|
||||
stripTrailing(0, { anchor: true });
|
||||
voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS);
|
||||
}
|
||||
// If voice failed to transition (module not loaded, stream
|
||||
// unavailable, stale enabled), clear the ref so a later
|
||||
|
|
@ -633,10 +585,10 @@ export function useVoiceKeybindingHandler({
|
|||
// immediate. The anchor set by stripTrailing above will
|
||||
// be overwritten on retry (anchor always overwrites now).
|
||||
if (getVoiceState().voiceState === 'idle') {
|
||||
isHoldActiveRef.current = false
|
||||
resetAnchor()
|
||||
isHoldActiveRef.current = false;
|
||||
resetAnchor();
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Warmup (bare-char only; modifier combos activated above) ──
|
||||
|
|
@ -649,43 +601,43 @@ export function useVoiceKeybindingHandler({
|
|||
// no-op when nothing leaked. Check countBefore so the event that
|
||||
// crosses the threshold still flows through (terminal batching).
|
||||
if (countBefore >= WARMUP_THRESHOLD) {
|
||||
e.stopImmediatePropagation()
|
||||
e.stopImmediatePropagation();
|
||||
stripTrailing(repeatCount, {
|
||||
char: bareChar,
|
||||
floor: charsInInputRef.current,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
charsInInputRef.current += repeatCount
|
||||
charsInInputRef.current += repeatCount;
|
||||
}
|
||||
|
||||
// Show warmup feedback once we detect a hold pattern
|
||||
if (rapidCountRef.current >= WARMUP_THRESHOLD) {
|
||||
setVoiceState(prev => {
|
||||
if (prev.voiceWarmingUp) return prev
|
||||
return { ...prev, voiceWarmingUp: true }
|
||||
})
|
||||
if (prev.voiceWarmingUp) return prev;
|
||||
return { ...prev, voiceWarmingUp: true };
|
||||
});
|
||||
}
|
||||
|
||||
if (resetTimerRef.current) {
|
||||
clearTimeout(resetTimerRef.current)
|
||||
clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
resetTimerRef.current = setTimeout(
|
||||
(resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState) => {
|
||||
resetTimerRef.current = null
|
||||
rapidCountRef.current = 0
|
||||
charsInInputRef.current = 0
|
||||
resetTimerRef.current = null;
|
||||
rapidCountRef.current = 0;
|
||||
charsInInputRef.current = 0;
|
||||
setVoiceState(prev => {
|
||||
if (!prev.voiceWarmingUp) return prev
|
||||
return { ...prev, voiceWarmingUp: false }
|
||||
})
|
||||
if (!prev.voiceWarmingUp) return prev;
|
||||
return { ...prev, voiceWarmingUp: false };
|
||||
});
|
||||
},
|
||||
RAPID_KEY_GAP_MS,
|
||||
resetTimerRef,
|
||||
rapidCountRef,
|
||||
charsInInputRef,
|
||||
setVoiceState,
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Backward-compat bridge: REPL.tsx doesn't yet wire handleKeyDown to
|
||||
// <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
|
||||
|
|
@ -693,30 +645,30 @@ export function useVoiceKeybindingHandler({
|
|||
// TODO(onKeyDown-migration): remove once REPL passes handleKeyDown.
|
||||
useInput(
|
||||
(_input, _key, event) => {
|
||||
const kbEvent = new KeyboardEvent(event.keypress)
|
||||
handleKeyDown(kbEvent)
|
||||
const kbEvent = new KeyboardEvent(event.keypress);
|
||||
handleKeyDown(kbEvent);
|
||||
// handleKeyDown stopped the adapter event, not the InputEvent the
|
||||
// emitter actually checks — forward it so the text input's useInput
|
||||
// listener is skipped and held spaces don't leak into the prompt.
|
||||
if (kbEvent.didStopImmediatePropagation()) {
|
||||
event.stopImmediatePropagation()
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
)
|
||||
);
|
||||
|
||||
return { handleKeyDown }
|
||||
return { handleKeyDown };
|
||||
}
|
||||
|
||||
// TODO(onKeyDown-migration): temporary shim so existing JSX callers
|
||||
// (<VoiceKeybindingHandler .../>) keep compiling. Remove once REPL.tsx
|
||||
// wires handleKeyDown directly.
|
||||
export function VoiceKeybindingHandler(props: {
|
||||
voiceHandleKeyEvent: (fallbackMs?: number) => void
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number
|
||||
resetAnchor: () => void
|
||||
isActive: boolean
|
||||
voiceHandleKeyEvent: (fallbackMs?: number) => void;
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number;
|
||||
resetAnchor: () => void;
|
||||
isActive: boolean;
|
||||
}): null {
|
||||
useVoiceKeybindingHandler(props)
|
||||
return null
|
||||
useVoiceKeybindingHandler(props);
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,18 +14,27 @@ import { dirname, join } from 'path';
|
|||
import { tmpdir } from 'os';
|
||||
import figures from 'figures';
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler
|
||||
import { useInput } from '@anthropic/ink'
|
||||
import { useSearchInput } from '../hooks/useSearchInput.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { useSearchHighlight } from '@anthropic/ink'
|
||||
import type { JumpHandle } from '../components/VirtualMessageList.js'
|
||||
import { renderMessagesToPlainText } from '../utils/exportRenderer.js'
|
||||
import { openFileInExternalEditor } from '../utils/editor.js'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { type TabStatusKind, Box, Text, useStdin, useTheme, useTerminalFocus, useTerminalTitle, useTabStatus } from '@anthropic/ink'
|
||||
import { CostThresholdDialog } from '../components/CostThresholdDialog.js'
|
||||
import { IdleReturnDialog } from '../components/IdleReturnDialog.js'
|
||||
import * as React from 'react'
|
||||
import { useInput } from '@anthropic/ink';
|
||||
import { useSearchInput } from '../hooks/useSearchInput.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { useSearchHighlight } from '@anthropic/ink';
|
||||
import type { JumpHandle } from '../components/VirtualMessageList.js';
|
||||
import { renderMessagesToPlainText } from '../utils/exportRenderer.js';
|
||||
import { openFileInExternalEditor } from '../utils/editor.js';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import {
|
||||
type TabStatusKind,
|
||||
Box,
|
||||
Text,
|
||||
useStdin,
|
||||
useTheme,
|
||||
useTerminalFocus,
|
||||
useTerminalTitle,
|
||||
useTabStatus,
|
||||
} from '@anthropic/ink';
|
||||
import { CostThresholdDialog } from '../components/CostThresholdDialog.js';
|
||||
import { IdleReturnDialog } from '../components/IdleReturnDialog.js';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
|
|
@ -35,14 +44,11 @@ import {
|
|||
useDeferredValue,
|
||||
useLayoutEffect,
|
||||
type RefObject,
|
||||
} from 'react'
|
||||
import { useNotifications } from '../context/notifications.js'
|
||||
import { sendNotification } from '../services/notifier.js'
|
||||
import {
|
||||
startPreventSleep,
|
||||
stopPreventSleep,
|
||||
} from '../services/preventSleep.js'
|
||||
import { useTerminalNotification, hasCursorUpViewportYankBug } from '@anthropic/ink'
|
||||
} from 'react';
|
||||
import { useNotifications } from '../context/notifications.js';
|
||||
import { sendNotification } from '../services/notifier.js';
|
||||
import { startPreventSleep, stopPreventSleep } from '../services/preventSleep.js';
|
||||
import { useTerminalNotification, hasCursorUpViewportYankBug } from '@anthropic/ink';
|
||||
import {
|
||||
createFileStateCacheWithSizeLimit,
|
||||
mergeFileStateCaches,
|
||||
|
|
@ -331,8 +337,7 @@ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? proactiveMod
|
|||
const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {};
|
||||
const PROACTIVE_FALSE = () => false;
|
||||
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
|
||||
const useProactive =
|
||||
feature('PROACTIVE') || feature('KAIROS') ? useProactiveValue : null;
|
||||
const useProactive = feature('PROACTIVE') || feature('KAIROS') ? useProactiveValue : null;
|
||||
const useScheduledTasks = feature('AGENT_TRIGGERS') ? useScheduledTasksValue : null;
|
||||
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
|
||||
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js';
|
||||
|
|
@ -449,21 +454,13 @@ import { UltraplanChoiceDialog } from '../components/ultraplan/UltraplanChoiceDi
|
|||
import { UltraplanLaunchDialog } from '../components/ultraplan/UltraplanLaunchDialog.js';
|
||||
import { launchUltraplan } from '../commands/ultraplan.js';
|
||||
// Session manager removed - using AppState now
|
||||
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'
|
||||
import { REMOTE_SAFE_COMMANDS } from '../commands.js'
|
||||
import type { RemoteMessageContent } from '../utils/teleport/api.js'
|
||||
import {
|
||||
FullscreenLayout,
|
||||
useUnseenDivider,
|
||||
computeUnseenDivider,
|
||||
} from '../components/FullscreenLayout.js'
|
||||
import {
|
||||
isFullscreenEnvEnabled,
|
||||
maybeGetTmuxMouseHint,
|
||||
isMouseTrackingEnabled,
|
||||
} from '../utils/fullscreen.js'
|
||||
import { AlternateScreen } from '@anthropic/ink'
|
||||
import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'
|
||||
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js';
|
||||
import { REMOTE_SAFE_COMMANDS } from '../commands.js';
|
||||
import type { RemoteMessageContent } from '../utils/teleport/api.js';
|
||||
import { FullscreenLayout, useUnseenDivider, computeUnseenDivider } from '../components/FullscreenLayout.js';
|
||||
import { isFullscreenEnvEnabled, maybeGetTmuxMouseHint, isMouseTrackingEnabled } from '../utils/fullscreen.js';
|
||||
import { AlternateScreen } from '@anthropic/ink';
|
||||
import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js';
|
||||
import {
|
||||
useMessageActions,
|
||||
MessageActionsKeybindings,
|
||||
|
|
@ -471,13 +468,10 @@ import {
|
|||
type MessageActionsState,
|
||||
type MessageActionsNav,
|
||||
type MessageActionCaps,
|
||||
} from '../components/messageActions.js'
|
||||
import { setClipboard } from '@anthropic/ink'
|
||||
import type { ScrollBoxHandle } from '@anthropic/ink'
|
||||
import {
|
||||
createAttachmentMessage,
|
||||
getQueuedCommandAttachments,
|
||||
} from '../utils/attachments.js'
|
||||
} from '../components/messageActions.js';
|
||||
import { setClipboard } from '@anthropic/ink';
|
||||
import type { ScrollBoxHandle } from '@anthropic/ink';
|
||||
import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js';
|
||||
|
||||
// Stable empty array for hooks that accept MCPServerConnection[] — avoids
|
||||
// creating a new [] literal on every render in remote mode, which would
|
||||
|
|
@ -625,6 +619,7 @@ function TranscriptSearchBar({
|
|||
const [indexStatus, setIndexStatus] = React.useState<'building' | { ms: number } | null>('building');
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const warm = jumpRef.current?.warmSearchIndex;
|
||||
if (!warm) {
|
||||
setIndexStatus(null); // VML not mounted yet — rare, skip indicator
|
||||
|
|
@ -638,14 +633,14 @@ function TranscriptSearchBar({
|
|||
setIndexStatus(null);
|
||||
} else {
|
||||
setIndexStatus({ ms });
|
||||
setTimeout(() => alive && setIndexStatus(null), 2000);
|
||||
hideTimeout = setTimeout(() => alive && setIndexStatus(null), 2000);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // mount-only: bar opens once per /
|
||||
}, [jumpRef]); // mount-only per stable search bar ref
|
||||
// Gate the query effect on warm completion. setHighlight stays instant
|
||||
// (screen-space overlay, no indexing). setSearchQuery (the scan) waits.
|
||||
const warmDone = indexStatus !== 'building';
|
||||
|
|
@ -653,8 +648,7 @@ function TranscriptSearchBar({
|
|||
if (!warmDone) return;
|
||||
jumpRef.current?.setSearchQuery(query);
|
||||
setHighlight(query);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, warmDone]);
|
||||
}, [jumpRef, query, setHighlight, warmDone]);
|
||||
const off = cursorOffset;
|
||||
const cursorChar = off < query.length ? query[off] : ' ';
|
||||
return (
|
||||
|
|
@ -1950,7 +1944,8 @@ export function REPL({
|
|||
const content = lastAssistant.message?.content;
|
||||
const contentArray = Array.isArray(content) ? content : [];
|
||||
const inProgressToolUses = contentArray.filter(
|
||||
(b): b is ContentBlock & { type: 'tool_use'; id: string } => b.type === 'tool_use' && inProgressToolUseIDs.has((b as { id: string }).id),
|
||||
(b): b is ContentBlock & { type: 'tool_use'; id: string } =>
|
||||
b.type === 'tool_use' && inProgressToolUseIDs.has((b as { id: string }).id),
|
||||
);
|
||||
return (
|
||||
inProgressToolUses.length > 0 &&
|
||||
|
|
@ -3051,18 +3046,19 @@ export function REPL({
|
|||
setMessages(old => {
|
||||
const postBoundary = getMessagesAfterCompactBoundary(old, {
|
||||
includeSnipped: true,
|
||||
})
|
||||
});
|
||||
// Hard cap: keep at most 500 messages in fullscreen scrollback
|
||||
// to prevent unbounded memory growth in multi-day sessions.
|
||||
// normalizeMessages/applyGrouping are O(n), and Ink fiber
|
||||
// trees cost ~250KB RSS per message. Without this cap,
|
||||
// scrollback after several compactions can reach thousands
|
||||
// of messages (observed: 13k+, 1GB+ heap).
|
||||
const MAX_FULLSCREEN_SCROLLBACK = 500
|
||||
const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
|
||||
? 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<string, unknown> | undefined
|
||||
if (
|
||||
m.parentToolUseID === newMessage.parentToolUseID &&
|
||||
mData?.type === newData.type
|
||||
) {
|
||||
const m = oldMessages[i]!;
|
||||
if (m.type !== 'progress') break;
|
||||
const mData = m.data as Record<string, unknown> | undefined;
|
||||
if (m.parentToolUseID === newMessage.parentToolUseID && mData?.type === newData.type) {
|
||||
const copy = oldMessages.slice();
|
||||
copy[i] = newMessage;
|
||||
return copy;
|
||||
|
|
@ -3184,7 +3180,10 @@ export function REPL({
|
|||
// title silently fell through to the "Claude Code" default.
|
||||
if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) {
|
||||
const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta);
|
||||
const text = firstUserMessage?.type === 'user' ? getContentText(firstUserMessage.message!.content as string | ContentBlockParam[]) : null;
|
||||
const text =
|
||||
firstUserMessage?.type === 'user'
|
||||
? getContentText(firstUserMessage.message!.content as string | ContentBlockParam[])
|
||||
: null;
|
||||
// Skip synthetic breadcrumbs — slash-command output, prompt-skill
|
||||
// expansions (/commit → <command-message>), local-command headers
|
||||
// (/help → <command-name>), and bash-mode (!cmd → <bash-input>).
|
||||
|
|
@ -3340,9 +3339,16 @@ export function REPL({
|
|||
|
||||
if (feature('BUDDY') && typeof (globalThis as Record<string, unknown>).fireCompanionObserver === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const _fireCompanionObserver = (globalThis as Record<string, any>).fireCompanionObserver as (msgs: unknown, cb: (r: unknown) => void) => void;
|
||||
const _fireCompanionObserver = (globalThis as Record<string, any>).fireCompanionObserver as (
|
||||
msgs: unknown,
|
||||
cb: (r: unknown) => void,
|
||||
) => void;
|
||||
void _fireCompanionObserver(messagesRef.current, reaction =>
|
||||
setAppState(prev => (prev.companionReaction === (reaction as typeof prev.companionReaction) ? prev : { ...prev, companionReaction: reaction as typeof prev.companionReaction })),
|
||||
setAppState(prev =>
|
||||
prev.companionReaction === (reaction as typeof prev.companionReaction)
|
||||
? prev
|
||||
: { ...prev, companionReaction: reaction as typeof prev.companionReaction },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3686,13 +3692,15 @@ export function REPL({
|
|||
...prev,
|
||||
initialMessage: null,
|
||||
toolPermissionContext: updatedToolPermissionContext,
|
||||
...(shouldStorePlanForVerification ? {
|
||||
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<HookProgress> => {
|
||||
if (m.type !== 'progress') return false;
|
||||
const data = m.data as Record<string, unknown>;
|
||||
return (
|
||||
data.type === 'hook_progress' &&
|
||||
(data.hookEvent === 'Stop' || data.hookEvent === 'SubagentStop')
|
||||
);
|
||||
},
|
||||
);
|
||||
const progressMsgs = messages.filter((m): m is ProgressMessage<HookProgress> => {
|
||||
if (m.type !== 'progress') return false;
|
||||
const data = m.data as Record<string, unknown>;
|
||||
return data.type === 'hook_progress' && (data.hookEvent === 'Stop' || data.hookEvent === 'SubagentStop');
|
||||
});
|
||||
if (progressMsgs.length === 0) return null;
|
||||
|
||||
// Get the most recent stop hook execution
|
||||
|
|
|
|||
|
|
@ -1,139 +1,148 @@
|
|||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { expandEnvVarsInString } from "../envExpansion";
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { expandEnvVarsInString } from '../envExpansion'
|
||||
|
||||
describe("expandEnvVarsInString", () => {
|
||||
const ENV_OPEN = '$' + '{'
|
||||
const ENV_CLOSE = '}'
|
||||
const envExpr = (value: string): string => `${ENV_OPEN}${value}${ENV_CLOSE}`
|
||||
|
||||
describe('expandEnvVarsInString', () => {
|
||||
// Save and restore env vars touched by tests
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
const savedEnv: Record<string, string | undefined> = {}
|
||||
const trackedKeys = [
|
||||
"TEST_HOME",
|
||||
"MISSING",
|
||||
"TEST_A",
|
||||
"TEST_B",
|
||||
"TEST_EMPTY",
|
||||
"TEST_X",
|
||||
"VAR",
|
||||
"TEST_FOUND",
|
||||
];
|
||||
'TEST_HOME',
|
||||
'MISSING',
|
||||
'TEST_A',
|
||||
'TEST_B',
|
||||
'TEST_EMPTY',
|
||||
'TEST_X',
|
||||
'VAR',
|
||||
'TEST_FOUND',
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of trackedKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
savedEnv[key] = process.env[key]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of trackedKeys) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
process.env[key] = savedEnv[key]
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("expands a single env var that exists", () => {
|
||||
process.env.TEST_HOME = "/home/user";
|
||||
const result = expandEnvVarsInString("${TEST_HOME}");
|
||||
expect(result.expanded).toBe("/home/user");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('expands a single env var that exists', () => {
|
||||
process.env.TEST_HOME = '/home/user'
|
||||
const result = expandEnvVarsInString(envExpr('TEST_HOME'))
|
||||
expect(result.expanded).toBe('/home/user')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("returns original placeholder and tracks missing var when not found", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${MISSING}");
|
||||
expect(result.expanded).toBe("${MISSING}");
|
||||
expect(result.missingVars).toEqual(["MISSING"]);
|
||||
});
|
||||
test('returns original placeholder and tracks missing var when not found', () => {
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(envExpr('MISSING'))
|
||||
expect(result.expanded).toBe(envExpr('MISSING'))
|
||||
expect(result.missingVars).toEqual(['MISSING'])
|
||||
})
|
||||
|
||||
test("uses default value when var is missing and default is provided", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${MISSING:-fallback}");
|
||||
expect(result.expanded).toBe("fallback");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('uses default value when var is missing and default is provided', () => {
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(envExpr('MISSING:-fallback'))
|
||||
expect(result.expanded).toBe('fallback')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("expands multiple vars", () => {
|
||||
process.env.TEST_A = "hello";
|
||||
process.env.TEST_B = "world";
|
||||
const result = expandEnvVarsInString("${TEST_A}/${TEST_B}");
|
||||
expect(result.expanded).toBe("hello/world");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('expands multiple vars', () => {
|
||||
process.env.TEST_A = 'hello'
|
||||
process.env.TEST_B = 'world'
|
||||
const result = expandEnvVarsInString(
|
||||
`${envExpr('TEST_A')}/${envExpr('TEST_B')}`,
|
||||
)
|
||||
expect(result.expanded).toBe('hello/world')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles mix of found and missing vars", () => {
|
||||
process.env.TEST_FOUND = "yes";
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}");
|
||||
expect(result.expanded).toBe("yes-${MISSING}");
|
||||
expect(result.missingVars).toEqual(["MISSING"]);
|
||||
});
|
||||
test('handles mix of found and missing vars', () => {
|
||||
process.env.TEST_FOUND = 'yes'
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(
|
||||
`${envExpr('TEST_FOUND')}-${envExpr('MISSING')}`,
|
||||
)
|
||||
expect(result.expanded).toBe(`yes-${envExpr('MISSING')}`)
|
||||
expect(result.missingVars).toEqual(['MISSING'])
|
||||
})
|
||||
|
||||
test("returns plain string unchanged with empty missingVars", () => {
|
||||
const result = expandEnvVarsInString("plain string");
|
||||
expect(result.expanded).toBe("plain string");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('returns plain string unchanged with empty missingVars', () => {
|
||||
const result = expandEnvVarsInString('plain string')
|
||||
expect(result.expanded).toBe('plain string')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("expands empty env var value", () => {
|
||||
process.env.TEST_EMPTY = "";
|
||||
const result = expandEnvVarsInString("${TEST_EMPTY}");
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('expands empty env var value', () => {
|
||||
process.env.TEST_EMPTY = ''
|
||||
const result = expandEnvVarsInString(envExpr('TEST_EMPTY'))
|
||||
expect(result.expanded).toBe('')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("prefers env var value over default when var exists", () => {
|
||||
process.env.TEST_X = "real";
|
||||
const result = expandEnvVarsInString("${TEST_X:-default}");
|
||||
expect(result.expanded).toBe("real");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('prefers env var value over default when var exists', () => {
|
||||
process.env.TEST_X = 'real'
|
||||
const result = expandEnvVarsInString(envExpr('TEST_X:-default'))
|
||||
expect(result.expanded).toBe('real')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles default value containing colons", () => {
|
||||
test('handles default value containing colons', () => {
|
||||
// split(':-', 2) means only the first :- is the delimiter
|
||||
delete process.env.TEST_X;
|
||||
const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}");
|
||||
delete process.env.TEST_X
|
||||
const result = expandEnvVarsInString(envExpr('TEST_X:-value:-with:-colons'))
|
||||
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
|
||||
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
|
||||
// ["TEST_X", "value"] because limit=2 stops at 2 pieces
|
||||
expect(result.expanded).toBe("value");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
expect(result.expanded).toBe('value')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles nested-looking syntax as literal (not supported)", () => {
|
||||
test('handles nested-looking syntax as literal (not supported)', () => {
|
||||
// ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first })
|
||||
// so varName would be "${VAR" which won't be found in env
|
||||
delete process.env.VAR;
|
||||
const result = expandEnvVarsInString("${${VAR}}");
|
||||
delete process.env.VAR
|
||||
const nestedExpr = `${ENV_OPEN}${envExpr('VAR')}${ENV_CLOSE}`
|
||||
const result = expandEnvVarsInString(nestedExpr)
|
||||
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
|
||||
// That env var won't exist, so it stays as "${${VAR}" + remaining "}"
|
||||
expect(result.missingVars).toEqual(["${VAR"]);
|
||||
expect(result.expanded).toBe("${${VAR}}");
|
||||
});
|
||||
expect(result.missingVars).toEqual([`${ENV_OPEN}VAR`])
|
||||
expect(result.expanded).toBe(nestedExpr)
|
||||
})
|
||||
|
||||
test("handles empty string input", () => {
|
||||
const result = expandEnvVarsInString("");
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('handles empty string input', () => {
|
||||
const result = expandEnvVarsInString('')
|
||||
expect(result.expanded).toBe('')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles var surrounded by text", () => {
|
||||
process.env.TEST_A = "middle";
|
||||
const result = expandEnvVarsInString("before-${TEST_A}-after");
|
||||
expect(result.expanded).toBe("before-middle-after");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('handles var surrounded by text', () => {
|
||||
process.env.TEST_A = 'middle'
|
||||
const result = expandEnvVarsInString(`before-${envExpr('TEST_A')}-after`)
|
||||
expect(result.expanded).toBe('before-middle-after')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles default value that is empty string", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${MISSING:-}");
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('handles default value that is empty string', () => {
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(envExpr('MISSING:-'))
|
||||
expect(result.expanded).toBe('')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("does not expand $VAR without braces", () => {
|
||||
process.env.TEST_A = "value";
|
||||
const result = expandEnvVarsInString("$TEST_A");
|
||||
expect(result.expanded).toBe("$TEST_A");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
});
|
||||
test('does not expand $VAR without braces', () => {
|
||||
process.env.TEST_A = 'value'
|
||||
const result = expandEnvVarsInString('$TEST_A')
|
||||
expect(result.expanded).toBe('$TEST_A')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -172,10 +172,10 @@ function fillBackground(px: Uint8Array, bg: AnsiColor): void {
|
|||
// not the classic VGA dither pattern. Alpha-blend toward background for the
|
||||
// same look.
|
||||
const SHADE_ALPHA: Record<number, number> = {
|
||||
0x2591: 0.25, // ░
|
||||
0x2592: 0.5, // ▒
|
||||
0x2593: 0.75, // ▓
|
||||
0x2588: 1.0, // █
|
||||
9617: 0.25, // ░
|
||||
9618: 0.5, // ▒
|
||||
9619: 0.75, // ▓
|
||||
9608: 1.0, // █
|
||||
}
|
||||
|
||||
function blitShade(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import * as React from 'react'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { PassThrough } from 'stream'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
import { wrappedRender as render, useApp } from '@anthropic/ink'
|
||||
import * as React from 'react';
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { PassThrough } from 'stream';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { wrappedRender as render, useApp } from '@anthropic/ink';
|
||||
|
||||
// This is a workaround for the fact that Ink doesn't support multiple <Static>
|
||||
// components in the same render tree. Instead of using a <Static> we just render
|
||||
|
|
@ -14,26 +14,22 @@ import { wrappedRender as render, useApp } from '@anthropic/ink'
|
|||
* before exiting. This is more robust than process.nextTick() for React 19's
|
||||
* async render cycle.
|
||||
*/
|
||||
function RenderOnceAndExit({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
const { exit } = useApp()
|
||||
function RenderOnceAndExit({ children }: { children: React.ReactNode }): React.ReactNode {
|
||||
const { exit } = useApp();
|
||||
|
||||
// useLayoutEffect runs synchronously after React commits DOM mutations.
|
||||
// setTimeout(0) defers exit to allow Ink to flush output to the stream.
|
||||
useLayoutEffect(() => {
|
||||
const timer = setTimeout(exit, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [exit])
|
||||
const timer = setTimeout(exit, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [exit]);
|
||||
|
||||
return <>{children}</>
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// DEC synchronized update markers used by terminals
|
||||
const SYNC_START = '\x1B[?2026h'
|
||||
const SYNC_END = '\x1B[?2026l'
|
||||
const SYNC_START = '\x1B[?2026h';
|
||||
const SYNC_END = '\x1B[?2026l';
|
||||
|
||||
/**
|
||||
* Extracts content from the first complete frame in Ink's output.
|
||||
|
|
@ -41,64 +37,53 @@ const SYNC_END = '\x1B[?2026l'
|
|||
* update sequences ([?2026h ... [?2026l). We only want the first frame's content.
|
||||
*/
|
||||
function extractFirstFrame(output: string): string {
|
||||
const startIndex = output.indexOf(SYNC_START)
|
||||
if (startIndex === -1) return output
|
||||
const startIndex = output.indexOf(SYNC_START);
|
||||
if (startIndex === -1) return output;
|
||||
|
||||
const contentStart = startIndex + SYNC_START.length
|
||||
const endIndex = output.indexOf(SYNC_END, contentStart)
|
||||
if (endIndex === -1) return output
|
||||
const contentStart = startIndex + SYNC_START.length;
|
||||
const endIndex = output.indexOf(SYNC_END, contentStart);
|
||||
if (endIndex === -1) return output;
|
||||
|
||||
return output.slice(contentStart, endIndex)
|
||||
return output.slice(contentStart, endIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a React node to a string with ANSI escape codes (for terminal output).
|
||||
*/
|
||||
export function renderToAnsiString(
|
||||
node: React.ReactNode,
|
||||
columns?: number,
|
||||
): Promise<string> {
|
||||
return new Promise(async resolve => {
|
||||
let output = ''
|
||||
export async function renderToAnsiString(node: React.ReactNode, columns?: number): Promise<string> {
|
||||
let output = '';
|
||||
|
||||
// Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a
|
||||
// chosen width instead of PassThrough's undefined → 80 fallback —
|
||||
// useful for rendering at terminal width for file dumps that should
|
||||
// match what the user sees on screen.
|
||||
const stream = new PassThrough()
|
||||
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(
|
||||
<RenderOnceAndExit>{node}</RenderOnceAndExit>,
|
||||
{
|
||||
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(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
|
||||
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<string> {
|
||||
const output = await renderToAnsiString(node, columns)
|
||||
return stripAnsi(output)
|
||||
export async function renderToString(node: React.ReactNode, columns?: number): Promise<string> {
|
||||
const output = await renderToAnsiString(node, columns);
|
||||
return stripAnsi(output);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user