style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -23,7 +23,10 @@ function createPermissionHandlersMap() {
handlers.delete(requestId)
}
},
handleResponse(requestId: string, response: { approved: boolean }): boolean {
handleResponse(
requestId: string,
response: { approved: boolean },
): boolean {
const handler = handlers.get(requestId)
if (!handler) return false
handlers.delete(requestId)
@@ -57,10 +60,14 @@ describe('pendingPermissionHandlers cleanup pattern', () => {
test('handleResponse dispatches to handler and removes it', () => {
const map = createPermissionHandlersMap()
let received: { approved: boolean } | null = null
map.onResponse('req-1', (resp) => { received = resp })
map.onResponse('req-1', resp => {
received = resp
})
const dispatched = map.handleResponse('req-1', { approved: true })
expect(dispatched).toBe(true)
expect(received as unknown as { approved: boolean }).toEqual({ approved: true })
expect(received as unknown as { approved: boolean }).toEqual({
approved: true,
})
expect(map.size()).toBe(0)
})
@@ -85,7 +92,9 @@ describe('pendingPermissionHandlers cleanup pattern', () => {
test('handlers are not dispatched after cleanup', () => {
const map = createPermissionHandlersMap()
let called = false
map.onResponse('req-1', () => { called = true })
map.onResponse('req-1', () => {
called = true
})
map.cleanup()

View File

@@ -29,7 +29,9 @@ describe('swarm permission poller registry', () => {
registerPermissionCallback({
requestId: 'req-2',
toolUseId: 'tool-2',
onAllow: () => { approved = true },
onAllow: () => {
approved = true
},
onReject: () => {},
})
const result = processMailboxPermissionResponse({
@@ -48,7 +50,9 @@ describe('swarm permission poller registry', () => {
requestId: 'req-3',
toolUseId: 'tool-3',
onAllow: () => {},
onReject: () => { rejected = true },
onReject: () => {
rejected = true
},
})
const result = processMailboxPermissionResponse({
requestId: 'req-3',
@@ -104,4 +108,4 @@ describe('swarm permission poller registry', () => {
})
expect(order).toEqual(['callback', 'has:false'])
})
})
})

View File

@@ -1,12 +1,12 @@
import * as React from 'react'
import { getOauthProfileFromApiKey } from 'src/services/oauth/getOauthProfile.js'
import { isClaudeAISubscriber } from 'src/utils/auth.js'
import { Text } from '@anthropic/ink'
import { logEvent } from '../../services/analytics/index.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { useStartupNotification } from './useStartupNotification.js'
import * as React from 'react';
import { getOauthProfileFromApiKey } from 'src/services/oauth/getOauthProfile.js';
import { isClaudeAISubscriber } from 'src/utils/auth.js';
import { Text } from '@anthropic/ink';
import { logEvent } from '../../services/analytics/index.js';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
import { useStartupNotification } from './useStartupNotification.js';
const MAX_SHOW_COUNT = 3
const MAX_SHOW_COUNT = 3;
/**
* Hook to check if the user has a subscription on Console but isn't logged into it.
@@ -14,16 +14,16 @@ const MAX_SHOW_COUNT = 3
export function useCanSwitchToExistingSubscription(): void {
useStartupNotification(async () => {
if ((getGlobalConfig().subscriptionNoticeCount ?? 0) >= MAX_SHOW_COUNT) {
return null
return null;
}
const subscriptionType = await getExistingClaudeSubscription()
if (subscriptionType === null) return null
const subscriptionType = await getExistingClaudeSubscription();
if (subscriptionType === null) return null;
saveGlobalConfig(current => ({
...current,
subscriptionNoticeCount: (current.subscriptionNoticeCount ?? 0) + 1,
}))
logEvent('tengu_switch_to_subscription_notice_shown', {})
}));
logEvent('tengu_switch_to_subscription_notice_shown', {});
return {
key: 'switch-to-subscription',
@@ -37,8 +37,8 @@ export function useCanSwitchToExistingSubscription(): void {
</Text>
),
priority: 'low',
}
})
};
});
}
/**
@@ -48,20 +48,20 @@ export function useCanSwitchToExistingSubscription(): void {
async function getExistingClaudeSubscription(): Promise<'Max' | 'Pro' | null> {
// If already using subscription auth, there is nothing to switch to
if (isClaudeAISubscriber()) {
return null
return null;
}
const profile = await getOauthProfileFromApiKey()
const profile = await getOauthProfileFromApiKey();
if (!profile) {
return null
return null;
}
if (profile.account.has_claude_max) {
return 'Max'
return 'Max';
}
if (profile.account.has_claude_pro) {
return 'Pro'
return 'Pro';
}
return null
return null;
}

View File

@@ -1,30 +1,30 @@
import { useEffect, useRef } from 'react'
import { useNotifications } from 'src/context/notifications.js'
import { getModelDeprecationWarning } from 'src/utils/model/deprecation.js'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import { useEffect, useRef } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { getModelDeprecationWarning } from 'src/utils/model/deprecation.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
export function useDeprecationWarningNotification(model: string): void {
const { addNotification } = useNotifications()
const lastWarningRef = useRef<string | null>(null)
const { addNotification } = useNotifications();
const lastWarningRef = useRef<string | null>(null);
useEffect(() => {
if (getIsRemoteMode()) return
const deprecationWarning = getModelDeprecationWarning(model)
if (getIsRemoteMode()) return;
const deprecationWarning = getModelDeprecationWarning(model);
// Show warning if model is deprecated and we haven't shown this exact warning yet
if (deprecationWarning && deprecationWarning !== lastWarningRef.current) {
lastWarningRef.current = deprecationWarning
lastWarningRef.current = deprecationWarning;
addNotification({
key: 'model-deprecation-warning',
text: deprecationWarning,
color: 'warning',
priority: 'high',
})
});
}
// Reset tracking if model changes to non-deprecated
if (!deprecationWarning) {
lastWarningRef.current = null
lastWarningRef.current = null;
}
}, [model, addNotification])
}, [model, addNotification]);
}

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react'
import { useNotifications } from 'src/context/notifications.js'
import { useAppState, useSetAppState } from 'src/state/AppState.js'
import { useEffect } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { useAppState, useSetAppState } from 'src/state/AppState.js';
import {
type CooldownReason,
isFastModeEnabled,
@@ -8,25 +8,25 @@ import {
onCooldownTriggered,
onFastModeOverageRejection,
onOrgFastModeChanged,
} from 'src/utils/fastMode.js'
import { formatDuration } from 'src/utils/format.js'
import { getIsRemoteMode } from '../../bootstrap/state.js'
} from 'src/utils/fastMode.js';
import { formatDuration } from 'src/utils/format.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
const COOLDOWN_STARTED_KEY = 'fast-mode-cooldown-started'
const COOLDOWN_EXPIRED_KEY = 'fast-mode-cooldown-expired'
const ORG_CHANGED_KEY = 'fast-mode-org-changed'
const OVERAGE_REJECTED_KEY = 'fast-mode-overage-rejected'
const COOLDOWN_STARTED_KEY = 'fast-mode-cooldown-started';
const COOLDOWN_EXPIRED_KEY = 'fast-mode-cooldown-expired';
const ORG_CHANGED_KEY = 'fast-mode-org-changed';
const OVERAGE_REJECTED_KEY = 'fast-mode-overage-rejected';
export function useFastModeNotification(): void {
const { addNotification } = useNotifications()
const isFastMode = useAppState(s => s.fastMode)
const setAppState = useSetAppState()
const { addNotification } = useNotifications();
const isFastMode = useAppState(s => s.fastMode);
const setAppState = useSetAppState();
// Notify when org fast mode status changes
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
if (!isFastModeEnabled()) {
return
return;
}
return onOrgFastModeChanged(orgEnabled => {
@@ -36,55 +36,55 @@ export function useFastModeNotification(): void {
color: 'fastMode',
priority: 'immediate',
text: 'Fast mode is now available · /fast to turn on',
})
});
} else if (isFastMode) {
// Org disabled fast mode — permanently turn off fast mode
setAppState(prev => ({ ...prev, fastMode: false }))
setAppState(prev => ({ ...prev, fastMode: false }));
addNotification({
key: ORG_CHANGED_KEY,
color: 'warning',
priority: 'immediate',
text: 'Fast mode has been disabled by your organization',
})
});
}
})
}, [addNotification, isFastMode, setAppState])
});
}, [addNotification, isFastMode, setAppState]);
// Notify when fast mode is rejected due to overage/extra usage issues
useEffect(() => {
if (getIsRemoteMode()) return
if (!isFastModeEnabled()) return
if (getIsRemoteMode()) return;
if (!isFastModeEnabled()) return;
return onFastModeOverageRejection(message => {
setAppState(prev => ({ ...prev, fastMode: false }))
setAppState(prev => ({ ...prev, fastMode: false }));
addNotification({
key: OVERAGE_REJECTED_KEY,
color: 'warning',
priority: 'immediate',
text: message,
})
})
}, [addNotification, setAppState])
});
});
}, [addNotification, setAppState]);
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
if (!isFastMode) {
return
return;
}
const unsubTriggered = onCooldownTriggered((resetAt, reason) => {
const resetIn = formatDuration(resetAt - Date.now(), {
hideTrailingZeros: true,
})
const message = getCooldownMessage(reason, resetIn)
});
const message = getCooldownMessage(reason, resetIn);
addNotification({
key: COOLDOWN_STARTED_KEY,
invalidates: [COOLDOWN_EXPIRED_KEY],
text: message,
color: 'warning',
priority: 'immediate',
})
})
});
});
const unsubExpired = onCooldownExpired(() => {
addNotification({
key: COOLDOWN_EXPIRED_KEY,
@@ -92,20 +92,20 @@ export function useFastModeNotification(): void {
color: 'fastMode',
text: `Fast limit reset · now using fast mode`,
priority: 'immediate',
})
})
});
});
return () => {
unsubTriggered()
unsubExpired()
}
}, [addNotification, isFastMode])
unsubTriggered();
unsubExpired();
};
}, [addNotification, isFastMode]);
}
function getCooldownMessage(reason: CooldownReason, resetIn: string): string {
switch (reason) {
case 'overloaded':
return `Fast mode overloaded and is temporarily unavailable · resets in ${resetIn}`
return `Fast mode overloaded and is temporarily unavailable · resets in ${resetIn}`;
case 'rate_limit':
return `Fast limit reached and temporarily disabled · resets in ${resetIn}`
return `Fast limit reached and temporarily disabled · resets in ${resetIn}`;
}
}

View File

@@ -1,87 +1,63 @@
import React, { useEffect, useRef } from 'react'
import { useNotifications } from 'src/context/notifications.js'
import { Text } from '@anthropic/ink'
import type { MCPServerConnection } from 'src/services/mcp/types.js'
import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'
import {
detectIDEs,
type IDEExtensionInstallationStatus,
isJetBrainsIde,
isSupportedTerminal,
} from 'src/utils/ide.js'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import { useIdeConnectionStatus } from '../useIdeConnectionStatus.js'
import type { IDESelection } from '../useIdeSelection.js'
import React, { useEffect, useRef } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { Text } from '@anthropic/ink';
import type { MCPServerConnection } from 'src/services/mcp/types.js';
import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js';
import { detectIDEs, type IDEExtensionInstallationStatus, isJetBrainsIde, isSupportedTerminal } from 'src/utils/ide.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { useIdeConnectionStatus } from '../useIdeConnectionStatus.js';
import type { IDESelection } from '../useIdeSelection.js';
const MAX_IDE_HINT_SHOW_COUNT = 5
const MAX_IDE_HINT_SHOW_COUNT = 5;
type Props = {
ideInstallationStatus: IDEExtensionInstallationStatus | null
ideSelection: IDESelection | undefined
mcpClients: MCPServerConnection[]
}
ideInstallationStatus: IDEExtensionInstallationStatus | null;
ideSelection: IDESelection | undefined;
mcpClients: MCPServerConnection[];
};
export function useIDEStatusIndicator({
ideSelection,
mcpClients,
ideInstallationStatus,
}: Props): void {
const { addNotification, removeNotification } = useNotifications()
const { status: ideStatus, ideName } = useIdeConnectionStatus(mcpClients)
const hasShownHintRef = useRef(false)
export function useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus }: Props): void {
const { addNotification, removeNotification } = useNotifications();
const { status: ideStatus, ideName } = useIdeConnectionStatus(mcpClients);
const hasShownHintRef = useRef(false);
const isJetBrains = ideInstallationStatus
? isJetBrainsIde(ideInstallationStatus?.ideType)
: false
const showIDEInstallErrorOrJetBrainsInfo =
ideInstallationStatus?.error || isJetBrains
const isJetBrains = ideInstallationStatus ? isJetBrainsIde(ideInstallationStatus?.ideType) : false;
const showIDEInstallErrorOrJetBrainsInfo = ideInstallationStatus?.error || isJetBrains;
const shouldShowIdeSelection =
ideStatus === 'connected' &&
(ideSelection?.filePath ||
(ideSelection?.text && ideSelection.lineCount > 0))
ideStatus === 'connected' && (ideSelection?.filePath || (ideSelection?.text && ideSelection.lineCount > 0));
// Only show the connected if not showing context
const shouldShowConnected =
ideStatus === 'connected' && !shouldShowIdeSelection
const shouldShowConnected = ideStatus === 'connected' && !shouldShowIdeSelection;
const showIDEInstallError =
showIDEInstallErrorOrJetBrainsInfo &&
!isJetBrains &&
!shouldShowConnected &&
!shouldShowIdeSelection
showIDEInstallErrorOrJetBrainsInfo && !isJetBrains && !shouldShowConnected && !shouldShowIdeSelection;
const showJetBrainsInfo =
showIDEInstallErrorOrJetBrainsInfo &&
isJetBrains &&
!shouldShowConnected &&
!shouldShowIdeSelection
showIDEInstallErrorOrJetBrainsInfo && isJetBrains && !shouldShowConnected && !shouldShowIdeSelection;
// Show the /ide command hint if running from an external terminal and found running IDE(s)
// Delay showing hint to avoid brief flash during auto-connect startup
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
if (isSupportedTerminal() || ideStatus !== null || showJetBrainsInfo) {
removeNotification('ide-status-hint')
return
removeNotification('ide-status-hint');
return;
}
// Wait a bit to let auto-connect happen first, avoiding brief hint flash
if (
hasShownHintRef.current ||
(getGlobalConfig().ideHintShownCount ?? 0) >= MAX_IDE_HINT_SHOW_COUNT
) {
return
if (hasShownHintRef.current || (getGlobalConfig().ideHintShownCount ?? 0) >= MAX_IDE_HINT_SHOW_COUNT) {
return;
}
const timeoutId = setTimeout(
(hasShownHintRef, addNotification) => {
void detectIDEs(true).then(infos => {
const ideName = infos[0]?.name
const ideName = infos[0]?.name;
if (ideName && !hasShownHintRef.current) {
hasShownHintRef.current = true
hasShownHintRef.current = true;
saveGlobalConfig(current => ({
...current,
ideHintShownCount: (current.ideHintShownCount ?? 0) + 1,
}))
}));
addNotification({
key: 'ide-status-hint',
jsx: (
@@ -90,70 +66,58 @@ export function useIDEStatusIndicator({
</Text>
),
priority: 'low',
})
});
}
})
});
},
3000,
hasShownHintRef,
addNotification,
)
return () => clearTimeout(timeoutId)
}, [addNotification, removeNotification, ideStatus, showJetBrainsInfo])
);
return () => clearTimeout(timeoutId);
}, [addNotification, removeNotification, ideStatus, showJetBrainsInfo]);
// Show IDE disconnected/failed notification when status is disconnected
useEffect(() => {
if (getIsRemoteMode()) return
if (
showIDEInstallError ||
showJetBrainsInfo ||
ideStatus !== 'disconnected' ||
!ideName
) {
removeNotification('ide-status-disconnected')
return
if (getIsRemoteMode()) return;
if (showIDEInstallError || showJetBrainsInfo || ideStatus !== 'disconnected' || !ideName) {
removeNotification('ide-status-disconnected');
return;
}
addNotification({
key: 'ide-status-disconnected',
text: `${ideName} disconnected`,
color: 'error',
priority: 'medium',
})
}, [
addNotification,
removeNotification,
ideStatus,
ideName,
showIDEInstallError,
showJetBrainsInfo,
])
});
}, [addNotification, removeNotification, ideStatus, ideName, showIDEInstallError, showJetBrainsInfo]);
// Show JetBrains plugin not connected hint
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
if (!showJetBrainsInfo) {
removeNotification('ide-status-jetbrains-disconnected')
return
removeNotification('ide-status-jetbrains-disconnected');
return;
}
addNotification({
key: 'ide-status-jetbrains-disconnected',
text: 'IDE plugin not connected · /status for info',
priority: 'medium',
})
}, [addNotification, removeNotification, showJetBrainsInfo])
});
}, [addNotification, removeNotification, showJetBrainsInfo]);
// Show IDE install error
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
if (!showIDEInstallError) {
removeNotification('ide-status-install-error')
return
removeNotification('ide-status-install-error');
return;
}
addNotification({
key: 'ide-status-install-error',
text: 'IDE extension install failed (see /status for info)',
color: 'error',
priority: 'medium',
})
}, [addNotification, removeNotification, showIDEInstallError])
});
}, [addNotification, removeNotification, showIDEInstallError]);
}

View File

@@ -1,22 +1,22 @@
import { checkInstall } from 'src/utils/nativeInstaller/index.js'
import { useStartupNotification } from './useStartupNotification.js'
import { checkInstall } from 'src/utils/nativeInstaller/index.js';
import { useStartupNotification } from './useStartupNotification.js';
export function useInstallMessages(): void {
useStartupNotification(async () => {
const messages = await checkInstall()
const messages = await checkInstall();
return messages.map((message, index) => {
let priority: 'low' | 'medium' | 'high' | 'immediate' = 'low'
let priority: 'low' | 'medium' | 'high' | 'immediate' = 'low';
if (message.type === 'error' || message.userActionRequired) {
priority = 'high'
priority = 'high';
} else if (message.type === 'path' || message.type === 'alias') {
priority = 'medium'
priority = 'medium';
}
return {
key: `install-message-${index}-${message.type}`,
text: message.message,
priority,
color: message.type === 'error' ? 'error' : 'warning',
}
})
})
};
});
});
}

View File

@@ -1,17 +1,14 @@
import * as React from 'react'
import { useInterval } from 'usehooks-ts'
import { getIsRemoteMode, getIsScrollDraining } from '../../bootstrap/state.js'
import { useNotifications } from '../../context/notifications.js'
import { Text } from '@anthropic/ink'
import {
getInitializationStatus,
getLspServerManager,
} from '../../services/lsp/manager.js'
import { useSetAppState } from '../../state/AppState.js'
import { logForDebugging } from '../../utils/debug.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import * as React from 'react';
import { useInterval } from 'usehooks-ts';
import { getIsRemoteMode, getIsScrollDraining } from '../../bootstrap/state.js';
import { useNotifications } from '../../context/notifications.js';
import { Text } from '@anthropic/ink';
import { getInitializationStatus, getLspServerManager } from '../../services/lsp/manager.js';
import { useSetAppState } from '../../state/AppState.js';
import { logForDebugging } from '../../utils/debug.js';
import { isEnvTruthy } from '../../utils/envUtils.js';
const LSP_POLL_INTERVAL_MS = 5000
const LSP_POLL_INTERVAL_MS = 5000;
/**
* Hook that polls LSP status and shows a notification when:
@@ -23,27 +20,25 @@ const LSP_POLL_INTERVAL_MS = 5000
* Only active when ENABLE_LSP_TOOL is set.
*/
export function useLspInitializationNotification(): void {
const { addNotification } = useNotifications()
const setAppState = useSetAppState()
const { addNotification } = useNotifications();
const setAppState = useSetAppState();
// Lazy initializer — eager form re-evaluates isEnvTruthy on every REPL
// render (the arg expression runs even though useState ignores it after
// mount). Showed up as 7.2s isEnvTruthy self-time during PageUp spam
// after #24498 swapped cheap !!process.env.X for isEnvTruthy().
const [shouldPoll, setShouldPoll] = React.useState(() =>
isEnvTruthy("true"),
)
const [shouldPoll, setShouldPoll] = React.useState(() => isEnvTruthy('true'));
// Track which errors we've already notified about to avoid duplicates
const notifiedErrorsRef = React.useRef<Set<string>>(new Set())
const notifiedErrorsRef = React.useRef<Set<string>>(new Set());
const addError = React.useCallback(
(source: string, errorMessage: string) => {
const errorKey = `${source}:${errorMessage}`
const errorKey = `${source}:${errorMessage}`;
if (notifiedErrorsRef.current.has(errorKey)) {
return // Already notified
return; // Already notified
}
notifiedErrorsRef.current.add(errorKey)
notifiedErrorsRef.current.add(errorKey);
logForDebugging(`LSP error: ${source} - ${errorMessage}`)
logForDebugging(`LSP error: ${source} - ${errorMessage}`);
// Add error to appState.plugins.errors
setAppState(prev => {
@@ -51,15 +46,15 @@ export function useLspInitializationNotification(): void {
const existingKeys = new Set(
prev.plugins.errors.map(e => {
if (e.type === 'generic-error') {
return `generic-error:${e.source}:${e.error}`
return `generic-error:${e.source}:${e.error}`;
}
return `${e.type}:${e.source}`
return `${e.type}:${e.source}`;
}),
)
);
const stateErrorKey = `generic-error:${source}:${errorMessage}`
const stateErrorKey = `generic-error:${source}:${errorMessage}`;
if (existingKeys.has(stateErrorKey)) {
return prev
return prev;
}
return {
@@ -75,13 +70,11 @@ export function useLspInitializationNotification(): void {
},
],
},
}
})
};
});
// Show notification - extract plugin name from source like "plugin:typescript-lsp:typescript"
const displayName = source.startsWith('plugin:')
? (source.split(':')[1] ?? source)
: source
const displayName = source.startsWith('plugin:') ? (source.split(':')[1] ?? source) : source;
addNotification({
key: `lsp-error-${source}`,
@@ -93,49 +86,49 @@ export function useLspInitializationNotification(): void {
),
priority: 'medium',
timeoutMs: 8000,
})
});
},
[addNotification, setAppState],
)
);
const poll = React.useCallback(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
// Skip during scroll drain — iterating all LSP servers + setAppState
// competes for the event loop with scroll frames. Next interval picks up.
if (getIsScrollDraining()) return
if (getIsScrollDraining()) return;
const status = getInitializationStatus()
const status = getInitializationStatus();
// Check manager initialization status
if (status.status === 'failed') {
addError('lsp-manager', status.error.message)
setShouldPoll(false)
return
addError('lsp-manager', status.error.message);
setShouldPoll(false);
return;
}
if (status.status === 'pending' || status.status === 'not-started') {
// Still initializing, continue polling
return
return;
}
// Manager initialized successfully - check for server errors
const manager = getLspServerManager()
const manager = getLspServerManager();
if (manager) {
const servers = manager.getAllServers()
const servers = manager.getAllServers();
for (const [serverName, server] of servers) {
if (server.state === 'error' && server.lastError) {
addError(serverName, server.lastError.message)
addError(serverName, server.lastError.message);
}
}
}
// Continue polling to detect future server errors
}, [addError])
}, [addError]);
useInterval(poll, shouldPoll ? LSP_POLL_INTERVAL_MS : null)
useInterval(poll, shouldPoll ? LSP_POLL_INTERVAL_MS : null);
// Initial poll on mount
React.useEffect(() => {
if (getIsRemoteMode() || !shouldPoll) return
poll()
}, [poll, shouldPoll])
if (getIsRemoteMode() || !shouldPoll) return;
poll();
}, [poll, shouldPoll]);
}

View File

@@ -1,30 +1,28 @@
import * as React from 'react'
import { useEffect } from 'react'
import { useNotifications } from 'src/context/notifications.js'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import { Text } from '@anthropic/ink'
import { hasClaudeAiMcpEverConnected } from '../../services/mcp/claudeai.js'
import type { MCPServerConnection } from '../../services/mcp/types.js'
import * as React from 'react';
import { useEffect } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { Text } from '@anthropic/ink';
import { hasClaudeAiMcpEverConnected } from '../../services/mcp/claudeai.js';
import type { MCPServerConnection } from '../../services/mcp/types.js';
type Props = {
mcpClients?: MCPServerConnection[]
}
mcpClients?: MCPServerConnection[];
};
const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []
const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [];
export function useMcpConnectivityStatus({
mcpClients = EMPTY_MCP_CLIENTS,
}: Props): void {
const { addNotification } = useNotifications()
export function useMcpConnectivityStatus({ mcpClients = EMPTY_MCP_CLIENTS }: Props): void {
const { addNotification } = useNotifications();
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
const failedLocalClients = mcpClients.filter(
client =>
client.type === 'failed' &&
client.config.type !== 'sse-ide' &&
client.config.type !== 'ws-ide' &&
client.config.type !== 'claudeai-proxy',
)
);
// claude.ai failures get a separate notification: they almost always indicate
// a toolbox-service outage (shared auth backend), not a local config issue.
// Only flag connectors that have previously connected successfully — an
@@ -33,27 +31,24 @@ export function useMcpConnectivityStatus({
// yesterday and is now failed is a state change worth surfacing.
const failedClaudeAiClients = mcpClients.filter(
client =>
client.type === 'failed' &&
client.config.type === 'claudeai-proxy' &&
hasClaudeAiMcpEverConnected(client.name),
)
client.type === 'failed' && client.config.type === 'claudeai-proxy' && hasClaudeAiMcpEverConnected(client.name),
);
const needsAuthLocalServers = mcpClients.filter(
client =>
client.type === 'needs-auth' && client.config.type !== 'claudeai-proxy',
)
client => client.type === 'needs-auth' && client.config.type !== 'claudeai-proxy',
);
const needsAuthClaudeAiServers = mcpClients.filter(
client =>
client.type === 'needs-auth' &&
client.config.type === 'claudeai-proxy' &&
hasClaudeAiMcpEverConnected(client.name),
)
);
if (
failedLocalClients.length === 0 &&
failedClaudeAiClients.length === 0 &&
needsAuthLocalServers.length === 0 &&
needsAuthClaudeAiServers.length === 0
) {
return
return;
}
if (failedLocalClients.length > 0) {
addNotification({
@@ -61,14 +56,13 @@ export function useMcpConnectivityStatus({
jsx: (
<>
<Text color="error">
{failedLocalClients.length} MCP{' '}
{failedLocalClients.length === 1 ? 'server' : 'servers'} failed
{failedLocalClients.length} MCP {failedLocalClients.length === 1 ? 'server' : 'servers'} failed
</Text>
<Text dimColor> · /mcp</Text>
</>
),
priority: 'medium',
})
});
}
if (failedClaudeAiClients.length > 0) {
addNotification({
@@ -76,15 +70,14 @@ export function useMcpConnectivityStatus({
jsx: (
<>
<Text color="error">
{failedClaudeAiClients.length} claude.ai{' '}
{failedClaudeAiClients.length === 1 ? 'connector' : 'connectors'}{' '}
{failedClaudeAiClients.length} claude.ai {failedClaudeAiClients.length === 1 ? 'connector' : 'connectors'}{' '}
unavailable
</Text>
<Text dimColor> · /mcp</Text>
</>
),
priority: 'medium',
})
});
}
if (needsAuthLocalServers.length > 0) {
addNotification({
@@ -92,17 +85,14 @@ export function useMcpConnectivityStatus({
jsx: (
<>
<Text color="warning">
{needsAuthLocalServers.length} MCP{' '}
{needsAuthLocalServers.length === 1
? 'server needs'
: 'servers need'}{' '}
{needsAuthLocalServers.length} MCP {needsAuthLocalServers.length === 1 ? 'server needs' : 'servers need'}{' '}
auth
</Text>
<Text dimColor> · /mcp</Text>
</>
),
priority: 'medium',
})
});
}
if (needsAuthClaudeAiServers.length > 0) {
addNotification({
@@ -111,16 +101,13 @@ export function useMcpConnectivityStatus({
<>
<Text color="warning">
{needsAuthClaudeAiServers.length} claude.ai{' '}
{needsAuthClaudeAiServers.length === 1
? 'connector needs'
: 'connectors need'}{' '}
auth
{needsAuthClaudeAiServers.length === 1 ? 'connector needs' : 'connectors need'} auth
</Text>
<Text dimColor> · /mcp</Text>
</>
),
priority: 'medium',
})
});
}
}, [addNotification, mcpClients])
}, [addNotification, mcpClients]);
}

View File

@@ -1,6 +1,6 @@
import type { Notification } from 'src/context/notifications.js'
import { type GlobalConfig, getGlobalConfig } from 'src/utils/config.js'
import { useStartupNotification } from './useStartupNotification.js'
import type { Notification } from 'src/context/notifications.js';
import { type GlobalConfig, getGlobalConfig } from 'src/utils/config.js';
import { useStartupNotification } from './useStartupNotification.js';
// Shows a one-time notification right after a model migration writes its
// timestamp to config. Each entry reads its own timestamp field(s) and emits
@@ -9,21 +9,21 @@ import { useStartupNotification } from './useStartupNotification.js'
const MIGRATIONS: ((c: GlobalConfig) => Notification | undefined)[] = [
// Sonnet 4.5 → 4.6 (pro/max/team premium)
c => {
if (!recent(c.sonnet45To46MigrationTimestamp)) return
if (!recent(c.sonnet45To46MigrationTimestamp)) return;
return {
key: 'sonnet-46-update',
text: 'Model updated to Sonnet 4.6',
color: 'suggestion',
priority: 'high',
timeoutMs: 3000,
}
};
},
// Opus Pro → default, or pinned 4.0/4.1 → opus alias. Both land on the
// current Opus default (4.7 for 1P).
c => {
const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp)
const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp
if (!recent(ts)) return
const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp);
const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp;
if (!recent(ts)) return;
return {
key: 'opus-pro-update',
text: isLegacyRemap
@@ -32,22 +32,22 @@ const MIGRATIONS: ((c: GlobalConfig) => Notification | undefined)[] = [
color: 'suggestion',
priority: 'high',
timeoutMs: isLegacyRemap ? 8000 : 3000,
}
};
},
]
];
export function useModelMigrationNotifications(): void {
useStartupNotification(() => {
const config = getGlobalConfig()
const notifs: Notification[] = []
const config = getGlobalConfig();
const notifs: Notification[] = [];
for (const migration of MIGRATIONS) {
const notif = migration(config)
if (notif) notifs.push(notif)
const notif = migration(config);
if (notif) notifs.push(notif);
}
return notifs.length > 0 ? notifs : null
})
return notifs.length > 0 ? notifs : null;
});
}
function recent(ts: number | undefined): boolean {
return ts !== undefined && Date.now() - ts < 3000
return ts !== undefined && Date.now() - ts < 3000;
}

View File

@@ -1,27 +1,23 @@
import { isInBundledMode } from 'src/utils/bundledMode.js'
import { getCurrentInstallationType } from 'src/utils/doctorDiagnostic.js'
import { isEnvTruthy } from 'src/utils/envUtils.js'
import { useStartupNotification } from './useStartupNotification.js'
import { isInBundledMode } from 'src/utils/bundledMode.js';
import { getCurrentInstallationType } from 'src/utils/doctorDiagnostic.js';
import { isEnvTruthy } from 'src/utils/envUtils.js';
import { useStartupNotification } from './useStartupNotification.js';
const NPM_DEPRECATION_MESSAGE =
''
const NPM_DEPRECATION_MESSAGE = '';
export function useNpmDeprecationNotification(): void {
useStartupNotification(async () => {
if (
isInBundledMode() ||
isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)
) {
return null
if (isInBundledMode() || isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)) {
return null;
}
const installationType = await getCurrentInstallationType()
if (installationType === 'development') return null
const installationType = await getCurrentInstallationType();
if (installationType === 'development') return null;
return {
timeoutMs: 15000,
key: 'npm-deprecation-warning',
text: NPM_DEPRECATION_MESSAGE,
color: 'warning',
priority: 'high',
}
})
};
});
}

View File

@@ -1,67 +1,59 @@
import * as React from 'react'
import { useEffect, useState } from 'react'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import { useNotifications } from '../../context/notifications.js'
import { Text } from '@anthropic/ink'
import { logForDebugging } from '../../utils/debug.js'
import { onPluginsAutoUpdated } from '../../utils/plugins/pluginAutoupdate.js'
import * as React from 'react';
import { useEffect, useState } from 'react';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { useNotifications } from '../../context/notifications.js';
import { Text } from '@anthropic/ink';
import { logForDebugging } from '../../utils/debug.js';
import { onPluginsAutoUpdated } from '../../utils/plugins/pluginAutoupdate.js';
/**
* Hook that displays a notification when plugins have been auto-updated.
* The notification tells the user to run /reload-plugins to apply the updates.
*/
export function usePluginAutoupdateNotification(): void {
const { addNotification } = useNotifications()
const [updatedPlugins, setUpdatedPlugins] = useState<string[]>([])
const { addNotification } = useNotifications();
const [updatedPlugins, setUpdatedPlugins] = useState<string[]>([]);
// Register for autoupdate notifications
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
const unsubscribe = onPluginsAutoUpdated(plugins => {
logForDebugging(
`Plugin autoupdate notification: ${plugins.length} plugin(s) updated`,
)
setUpdatedPlugins(plugins)
})
logForDebugging(`Plugin autoupdate notification: ${plugins.length} plugin(s) updated`);
setUpdatedPlugins(plugins);
});
return unsubscribe
}, [])
return unsubscribe;
}, []);
// Show notification when plugins are updated
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
if (updatedPlugins.length === 0) {
return
return;
}
// Extract plugin names from plugin IDs (format: "name@marketplace")
const pluginNames = updatedPlugins.map(id => {
const atIndex = id.indexOf('@')
return atIndex > 0 ? id.substring(0, atIndex) : id
})
const atIndex = id.indexOf('@');
return atIndex > 0 ? id.substring(0, atIndex) : id;
});
const displayNames =
pluginNames.length <= 2
? pluginNames.join(' and ')
: `${pluginNames.length} plugins`
const displayNames = pluginNames.length <= 2 ? pluginNames.join(' and ') : `${pluginNames.length} plugins`;
addNotification({
key: 'plugin-autoupdate-restart',
jsx: (
<>
<Text color="success">
{pluginNames.length === 1 ? 'Plugin' : 'Plugins'} updated:{' '}
{displayNames}
{pluginNames.length === 1 ? 'Plugin' : 'Plugins'} updated: {displayNames}
</Text>
<Text dimColor> · Run /reload-plugins to apply</Text>
</>
),
priority: 'low',
timeoutMs: 10000,
})
});
logForDebugging(
`Showing plugin autoupdate notification for: ${pluginNames.join(', ')}`,
)
}, [updatedPlugins, addNotification])
logForDebugging(`Showing plugin autoupdate notification for: ${pluginNames.join(', ')}`);
}, [updatedPlugins, addNotification]);
}

View File

@@ -1,64 +1,57 @@
import * as React from 'react'
import { useEffect, useMemo } from 'react'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import { useNotifications } from '../../context/notifications.js'
import { Text } from '@anthropic/ink'
import { useAppState } from '../../state/AppState.js'
import { logForDebugging } from '../../utils/debug.js'
import { plural } from '../../utils/stringUtils.js'
import * as React from 'react';
import { useEffect, useMemo } from 'react';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { useNotifications } from '../../context/notifications.js';
import { Text } from '@anthropic/ink';
import { useAppState } from '../../state/AppState.js';
import { logForDebugging } from '../../utils/debug.js';
import { plural } from '../../utils/stringUtils.js';
export function usePluginInstallationStatus(): void {
const { addNotification } = useNotifications()
const installationStatus = useAppState(s => s.plugins.installationStatus)
const { addNotification } = useNotifications();
const installationStatus = useAppState(s => s.plugins.installationStatus);
// Memoize the failed counts to prevent unnecessary effect triggers
const { totalFailed, failedMarketplacesCount, failedPluginsCount } =
useMemo(() => {
if (!installationStatus) {
return {
totalFailed: 0,
failedMarketplacesCount: 0,
failedPluginsCount: 0,
}
}
const failedMarketplaces = installationStatus.marketplaces.filter(
m => m.status === 'failed',
)
const failedPlugins = installationStatus.plugins.filter(
p => p.status === 'failed',
)
const { totalFailed, failedMarketplacesCount, failedPluginsCount } = useMemo(() => {
if (!installationStatus) {
return {
totalFailed: failedMarketplaces.length + failedPlugins.length,
failedMarketplacesCount: failedMarketplaces.length,
failedPluginsCount: failedPlugins.length,
}
}, [installationStatus])
totalFailed: 0,
failedMarketplacesCount: 0,
failedPluginsCount: 0,
};
}
const failedMarketplaces = installationStatus.marketplaces.filter(m => m.status === 'failed');
const failedPlugins = installationStatus.plugins.filter(p => p.status === 'failed');
return {
totalFailed: failedMarketplaces.length + failedPlugins.length,
failedMarketplacesCount: failedMarketplaces.length,
failedPluginsCount: failedPlugins.length,
};
}, [installationStatus]);
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
if (!installationStatus) {
logForDebugging('No installation status to monitor')
return
logForDebugging('No installation status to monitor');
return;
}
if (totalFailed === 0) {
return
return;
}
logForDebugging(
`Plugin installation status: ${failedMarketplacesCount} failed marketplaces, ${failedPluginsCount} failed plugins`,
)
);
if (totalFailed === 0) {
return
return;
}
// Add notification for failures
logForDebugging(
`Adding notification for ${totalFailed} failed installations`,
)
logForDebugging(`Adding notification for ${totalFailed} failed installations`);
addNotification({
key: 'plugin-install-failed',
jsx: (
@@ -70,11 +63,6 @@ export function usePluginInstallationStatus(): void {
</>
),
priority: 'medium',
})
}, [
addNotification,
totalFailed,
failedMarketplacesCount,
failedPluginsCount,
])
});
}, [addNotification, totalFailed, failedMarketplacesCount, failedPluginsCount]);
}

View File

@@ -1,56 +1,41 @@
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNotifications } from 'src/context/notifications.js'
import { Text } from '@anthropic/ink'
import {
getRateLimitWarning,
getUsingOverageText,
} from 'src/services/claudeAiLimits.js'
import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js'
import { getSubscriptionType } from 'src/utils/auth.js'
import { hasClaudeAiBillingAccess } from 'src/utils/billing.js'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import * as React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { Text } from '@anthropic/ink';
import { getRateLimitWarning, getUsingOverageText } from 'src/services/claudeAiLimits.js';
import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js';
import { getSubscriptionType } from 'src/utils/auth.js';
import { hasClaudeAiBillingAccess } from 'src/utils/billing.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
export function useRateLimitWarningNotification(model: string): void {
const { addNotification } = useNotifications()
const claudeAiLimits = useClaudeAiLimits()
const { addNotification } = useNotifications();
const claudeAiLimits = useClaudeAiLimits();
// claudeAiLimits reference is stable until statusListeners fire (API
// response), so these skip the Intl formatting work on most REPL renders.
const rateLimitWarning = useMemo(
() => getRateLimitWarning(claudeAiLimits, model),
[claudeAiLimits, model],
)
const usingOverageText = useMemo(
() => getUsingOverageText(claudeAiLimits),
[claudeAiLimits],
)
const shownWarningRef = useRef<string | null>(null)
const subscriptionType = getSubscriptionType()
const hasBillingAccess = hasClaudeAiBillingAccess()
const isTeamOrEnterprise =
subscriptionType === 'team' || subscriptionType === 'enterprise'
const rateLimitWarning = useMemo(() => getRateLimitWarning(claudeAiLimits, model), [claudeAiLimits, model]);
const usingOverageText = useMemo(() => getUsingOverageText(claudeAiLimits), [claudeAiLimits]);
const shownWarningRef = useRef<string | null>(null);
const subscriptionType = getSubscriptionType();
const hasBillingAccess = hasClaudeAiBillingAccess();
const isTeamOrEnterprise = subscriptionType === 'team' || subscriptionType === 'enterprise';
// Track overage mode transitions
const [hasShownOverageNotification, setHasShownOverageNotification] =
useState(false)
const [hasShownOverageNotification, setHasShownOverageNotification] = useState(false);
// Show immediate notification when entering overage mode
useEffect(() => {
if (getIsRemoteMode()) return
if (
claudeAiLimits.isUsingOverage &&
!hasShownOverageNotification &&
(!isTeamOrEnterprise || hasBillingAccess)
) {
if (getIsRemoteMode()) return;
if (claudeAiLimits.isUsingOverage && !hasShownOverageNotification && (!isTeamOrEnterprise || hasBillingAccess)) {
addNotification({
key: 'limit-reached',
text: usingOverageText,
priority: 'immediate',
})
setHasShownOverageNotification(true)
});
setHasShownOverageNotification(true);
} else if (!claudeAiLimits.isUsingOverage && hasShownOverageNotification) {
// Reset when no longer in overage mode
setHasShownOverageNotification(false)
setHasShownOverageNotification(false);
}
}, [
claudeAiLimits.isUsingOverage,
@@ -59,13 +44,13 @@ export function useRateLimitWarningNotification(model: string): void {
addNotification,
hasBillingAccess,
isTeamOrEnterprise,
])
]);
// Show warning notification for approaching limits
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
if (rateLimitWarning && rateLimitWarning !== shownWarningRef.current) {
shownWarningRef.current = rateLimitWarning
shownWarningRef.current = rateLimitWarning;
addNotification({
key: 'rate-limit-warning',
jsx: (
@@ -74,7 +59,7 @@ export function useRateLimitWarningNotification(model: string): void {
</Text>
),
priority: 'high',
})
});
}
}, [rateLimitWarning, addNotification])
}, [rateLimitWarning, addNotification]);
}

View File

@@ -1,41 +1,41 @@
import { useCallback, useEffect, useState } from 'react'
import { useNotifications } from 'src/context/notifications.js'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import { getSettingsWithAllErrors } from '../../utils/settings/allErrors.js'
import type { ValidationError } from '../../utils/settings/validation.js'
import { useSettingsChange } from '../useSettingsChange.js'
import { useCallback, useEffect, useState } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { getSettingsWithAllErrors } from '../../utils/settings/allErrors.js';
import type { ValidationError } from '../../utils/settings/validation.js';
import { useSettingsChange } from '../useSettingsChange.js';
const SETTINGS_ERRORS_NOTIFICATION_KEY = 'settings-errors'
const SETTINGS_ERRORS_NOTIFICATION_KEY = 'settings-errors';
export function useSettingsErrors(): ValidationError[] {
const { addNotification, removeNotification } = useNotifications()
const { addNotification, removeNotification } = useNotifications();
const [errors, setErrors] = useState<ValidationError[]>(() => {
const { errors } = getSettingsWithAllErrors()
return errors
})
const { errors } = getSettingsWithAllErrors();
return errors;
});
const handleSettingsChange = useCallback(() => {
const { errors } = getSettingsWithAllErrors()
setErrors(errors)
}, [])
const { errors } = getSettingsWithAllErrors();
setErrors(errors);
}, []);
useSettingsChange(handleSettingsChange)
useSettingsChange(handleSettingsChange);
useEffect(() => {
if (getIsRemoteMode()) return
if (getIsRemoteMode()) return;
if (errors.length > 0) {
const message = `Found ${errors.length} settings ${errors.length === 1 ? 'issue' : 'issues'} · /doctor for details`
const message = `Found ${errors.length} settings ${errors.length === 1 ? 'issue' : 'issues'} · /doctor for details`;
addNotification({
key: SETTINGS_ERRORS_NOTIFICATION_KEY,
text: message,
color: 'warning',
priority: 'high',
timeoutMs: 60000,
})
});
} else {
removeNotification(SETTINGS_ERRORS_NOTIFICATION_KEY)
removeNotification(SETTINGS_ERRORS_NOTIFICATION_KEY);
}
}, [errors, addNotification, removeNotification])
}, [errors, addNotification, removeNotification]);
return errors
return errors;
}

View File

@@ -234,7 +234,8 @@ function createPermissionContext(
const finalInput = decision.updatedInput ?? updatedInput ?? input
return await this.handleHookAllow(
finalInput,
(decision.updatedPermissions ?? []) as unknown as import('../../types/permissions.js').PermissionUpdate[],
(decision.updatedPermissions ??
[]) as unknown as import('../../types/permissions.js').PermissionUpdate[],
permissionPromptStartTimeMs,
)
} else if (decision.behavior === 'deny') {

View File

@@ -71,7 +71,9 @@ function getTextBlocksText(content: unknown): string {
.join('\n')
}
function parseChannelContextHintFromText(text: string): ChannelContextHint | null {
function parseChannelContextHintFromText(
text: string,
): ChannelContextHint | null {
const tagMatch = text.match(new RegExp(`<${CHANNEL_TAG}\\b([^>]*)>`))
if (!tagMatch?.[1]) {
return null
@@ -88,7 +90,9 @@ function parseChannelContextHintFromText(text: string): ChannelContextHint | nul
return { sourceServer, chatId }
}
export function getLatestChannelContextHint(messages: readonly unknown[]): ChannelContextHint | null {
export function getLatestChannelContextHint(
messages: readonly unknown[],
): ChannelContextHint | null {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index] as {
type?: unknown

View File

@@ -1,149 +1,133 @@
import React, { useCallback, useRef, useState } from 'react'
import { getModeFromInput } from 'src/components/PromptInput/inputModes.js'
import { useNotifications } from 'src/context/notifications.js'
import { ConfigurableShortcutHint } from '../components/ConfigurableShortcutHint.js'
import { FOOTER_TEMPORARY_STATUS_TIMEOUT } from '../components/PromptInput/Notifications.js'
import { getHistory } from '../history.js'
import { Text } from '@anthropic/ink'
import type { PromptInputMode } from '../types/textInputTypes.js'
import type { HistoryEntry, PastedContent } from '../utils/config.js'
import React, { useCallback, useRef, useState } from 'react';
import { getModeFromInput } from 'src/components/PromptInput/inputModes.js';
import { useNotifications } from 'src/context/notifications.js';
import { ConfigurableShortcutHint } from '../components/ConfigurableShortcutHint.js';
import { FOOTER_TEMPORARY_STATUS_TIMEOUT } from '../components/PromptInput/Notifications.js';
import { getHistory } from '../history.js';
import { Text } from '@anthropic/ink';
import type { PromptInputMode } from '../types/textInputTypes.js';
import type { HistoryEntry, PastedContent } from '../utils/config.js';
export type HistoryMode = PromptInputMode
export type HistoryMode = PromptInputMode;
// Load history entries in chunks to reduce disk reads on rapid keypresses
const HISTORY_CHUNK_SIZE = 10
const HISTORY_CHUNK_SIZE = 10;
// Shared state for batching concurrent load requests into a single disk read
// Mode filter is included to ensure we don't mix filtered and unfiltered caches
let pendingLoad: Promise<HistoryEntry[]> | null = null
let pendingLoadTarget = 0
let pendingLoadModeFilter: HistoryMode | undefined
let pendingLoad: Promise<HistoryEntry[]> | null = null;
let pendingLoadTarget = 0;
let pendingLoadModeFilter: HistoryMode | undefined;
async function loadHistoryEntries(
minCount: number,
modeFilter?: HistoryMode,
): Promise<HistoryEntry[]> {
async function loadHistoryEntries(minCount: number, modeFilter?: HistoryMode): Promise<HistoryEntry[]> {
// Round up to next chunk to avoid repeated small reads
const target = Math.ceil(minCount / HISTORY_CHUNK_SIZE) * HISTORY_CHUNK_SIZE
const target = Math.ceil(minCount / HISTORY_CHUNK_SIZE) * HISTORY_CHUNK_SIZE;
// If a load is already pending with the same mode filter and will satisfy our needs, wait for it
if (
pendingLoad &&
pendingLoadTarget >= target &&
pendingLoadModeFilter === modeFilter
) {
return pendingLoad
if (pendingLoad && pendingLoadTarget >= target && pendingLoadModeFilter === modeFilter) {
return pendingLoad;
}
// If a load is pending but won't satisfy our needs or has different filter, we need to wait for it
// to complete first, then start a new one (can't interrupt an ongoing read)
if (pendingLoad) {
await pendingLoad
await pendingLoad;
}
// Start a new load
pendingLoadTarget = target
pendingLoadModeFilter = modeFilter
pendingLoadTarget = target;
pendingLoadModeFilter = modeFilter;
pendingLoad = (async () => {
const entries: HistoryEntry[] = []
let loaded = 0
const entries: HistoryEntry[] = [];
let loaded = 0;
for await (const entry of getHistory()) {
// If mode filter is specified, only include entries that match the mode
if (modeFilter) {
const entryMode = getModeFromInput(entry.display)
const entryMode = getModeFromInput(entry.display);
if (entryMode !== modeFilter) {
continue
continue;
}
}
entries.push(entry)
loaded++
if (loaded >= pendingLoadTarget) break
entries.push(entry);
loaded++;
if (loaded >= pendingLoadTarget) break;
}
return entries
})()
return entries;
})();
try {
return await pendingLoad
return await pendingLoad;
} finally {
pendingLoad = null
pendingLoadTarget = 0
pendingLoadModeFilter = undefined
pendingLoad = null;
pendingLoadTarget = 0;
pendingLoadModeFilter = undefined;
}
}
export function useArrowKeyHistory(
onSetInput: (
value: string,
mode: HistoryMode,
pastedContents: Record<number, PastedContent>,
) => void,
onSetInput: (value: string, mode: HistoryMode, pastedContents: Record<number, PastedContent>) => void,
currentInput: string,
pastedContents: Record<number, PastedContent>,
setCursorOffset?: (offset: number) => void,
currentMode?: HistoryMode,
): {
historyIndex: number
setHistoryIndex: (index: number) => void
onHistoryUp: () => void
onHistoryDown: () => boolean
resetHistory: () => void
dismissSearchHint: () => void
historyIndex: number;
setHistoryIndex: (index: number) => void;
onHistoryUp: () => void;
onHistoryDown: () => boolean;
resetHistory: () => void;
dismissSearchHint: () => void;
} {
const [historyIndex, setHistoryIndex] = useState(0)
const [historyIndex, setHistoryIndex] = useState(0);
const [lastShownHistoryEntry, setLastShownHistoryEntry] = useState<
(HistoryEntry & { mode?: HistoryMode }) | undefined
>(undefined)
const hasShownSearchHintRef = useRef(false)
const { addNotification, removeNotification } = useNotifications()
>(undefined);
const hasShownSearchHintRef = useRef(false);
const { addNotification, removeNotification } = useNotifications();
// Cache loaded history entries
const historyCache = useRef<HistoryEntry[]>([])
const historyCache = useRef<HistoryEntry[]>([]);
// Track which mode filter the cache was loaded with
const historyCacheModeFilter = useRef<HistoryMode | undefined>(undefined)
const historyCacheModeFilter = useRef<HistoryMode | undefined>(undefined);
// Synchronous tracker for history index to avoid stale closure issues
// React state updates are async, so rapid keypresses can see stale values
const historyIndexRef = useRef(0)
const historyIndexRef = useRef(0);
// Track the mode filter that was active when history navigation started
// This is set on the first arrow press and stays fixed until reset
const initialModeFilterRef = useRef<HistoryMode | undefined>(undefined)
const initialModeFilterRef = useRef<HistoryMode | undefined>(undefined);
// Refs to track current input values for draft preservation
// These ensure we capture the draft with the latest values, not stale closure values
const currentInputRef = useRef(currentInput)
const pastedContentsRef = useRef(pastedContents)
const currentModeRef = useRef(currentMode)
const currentInputRef = useRef(currentInput);
const pastedContentsRef = useRef(pastedContents);
const currentModeRef = useRef(currentMode);
// Keep refs in sync with props (synchronous update on each render)
currentInputRef.current = currentInput
pastedContentsRef.current = pastedContents
currentModeRef.current = currentMode
currentInputRef.current = currentInput;
pastedContentsRef.current = pastedContents;
currentModeRef.current = currentMode;
const setInputWithCursor = useCallback(
(
value: string,
mode: HistoryMode,
contents: Record<number, PastedContent>,
cursorToStart = false,
): void => {
onSetInput(value, mode, contents)
setCursorOffset?.(cursorToStart ? 0 : value.length)
(value: string, mode: HistoryMode, contents: Record<number, PastedContent>, cursorToStart = false): void => {
onSetInput(value, mode, contents);
setCursorOffset?.(cursorToStart ? 0 : value.length);
},
[onSetInput, setCursorOffset],
)
);
const updateInput = useCallback(
(input: HistoryEntry | undefined, cursorToStart = false): void => {
if (!input || !input.display) return
if (!input || !input.display) return;
const mode = getModeFromInput(input.display)
const value = mode === 'bash' ? input.display.slice(1) : input.display
const mode = getModeFromInput(input.display);
const value = mode === 'bash' ? input.display.slice(1) : input.display;
setInputWithCursor(value, mode, input.pastedContents ?? {}, cursorToStart)
setInputWithCursor(value, mode, input.pastedContents ?? {}, cursorToStart);
},
[setInputWithCursor],
)
);
const showSearchHint = useCallback((): void => {
addNotification({
@@ -160,25 +144,24 @@ export function useArrowKeyHistory(
),
priority: 'immediate',
timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT,
})
}, [addNotification])
});
}, [addNotification]);
const onHistoryUp = useCallback((): void => {
// Capture and increment synchronously to handle rapid keypresses
const targetIndex = historyIndexRef.current
historyIndexRef.current++
const targetIndex = historyIndexRef.current;
historyIndexRef.current++;
const inputAtPress = currentInputRef.current
const pastedContentsAtPress = pastedContentsRef.current
const modeAtPress = currentModeRef.current
const inputAtPress = currentInputRef.current;
const pastedContentsAtPress = pastedContentsRef.current;
const modeAtPress = currentModeRef.current;
if (targetIndex === 0) {
initialModeFilterRef.current =
modeAtPress === 'bash' ? modeAtPress : undefined
initialModeFilterRef.current = modeAtPress === 'bash' ? modeAtPress : undefined;
// Save draft synchronously using refs for the latest values
// This ensures we capture the draft before any async operations or re-renders
const hasInput = inputAtPress.trim() !== ''
const hasInput = inputAtPress.trim() !== '';
setLastShownHistoryEntry(
hasInput
? {
@@ -187,95 +170,91 @@ export function useArrowKeyHistory(
mode: modeAtPress,
}
: undefined,
)
);
}
const modeFilter = initialModeFilterRef.current
const modeFilter = initialModeFilterRef.current;
void (async () => {
const neededCount = targetIndex + 1 // How many entries we need
const neededCount = targetIndex + 1; // How many entries we need
// If mode filter changed, invalidate cache
if (historyCacheModeFilter.current !== modeFilter) {
historyCache.current = []
historyCacheModeFilter.current = modeFilter
historyIndexRef.current = 0
historyCache.current = [];
historyCacheModeFilter.current = modeFilter;
historyIndexRef.current = 0;
}
// Load more entries if needed
if (historyCache.current.length < neededCount) {
// Batches concurrent requests - rapid keypresses share a single disk read
const entries = await loadHistoryEntries(neededCount, modeFilter)
const entries = await loadHistoryEntries(neededCount, modeFilter);
// Only update cache if we loaded more than currently cached
// (handles race condition where multiple loads complete out of order)
if (entries.length > historyCache.current.length) {
historyCache.current = entries
historyCache.current = entries;
}
}
// Check if we can navigate
if (targetIndex >= historyCache.current.length) {
// Rollback the ref since we can't navigate
historyIndexRef.current--
historyIndexRef.current--;
// Keep the draft intact - user stays on their current input
return
return;
}
const newIndex = targetIndex + 1
setHistoryIndex(newIndex)
updateInput(historyCache.current[targetIndex], true)
const newIndex = targetIndex + 1;
setHistoryIndex(newIndex);
updateInput(historyCache.current[targetIndex], true);
// Show hint once per session after navigating through 2 history entries
if (newIndex >= 2 && !hasShownSearchHintRef.current) {
hasShownSearchHintRef.current = true
showSearchHint()
hasShownSearchHintRef.current = true;
showSearchHint();
}
})()
}, [updateInput, showSearchHint])
})();
}, [updateInput, showSearchHint]);
const onHistoryDown = useCallback((): boolean => {
// Use the ref for consistent reads
const currentIndex = historyIndexRef.current
const currentIndex = historyIndexRef.current;
if (currentIndex > 1) {
historyIndexRef.current--
setHistoryIndex(currentIndex - 1)
updateInput(historyCache.current[currentIndex - 2])
historyIndexRef.current--;
setHistoryIndex(currentIndex - 1);
updateInput(historyCache.current[currentIndex - 2]);
} else if (currentIndex === 1) {
historyIndexRef.current = 0
setHistoryIndex(0)
historyIndexRef.current = 0;
setHistoryIndex(0);
if (lastShownHistoryEntry) {
// Restore the draft with its saved mode if available
const savedMode = lastShownHistoryEntry.mode
const savedMode = lastShownHistoryEntry.mode;
if (savedMode) {
setInputWithCursor(
lastShownHistoryEntry.display,
savedMode,
lastShownHistoryEntry.pastedContents ?? {},
)
setInputWithCursor(lastShownHistoryEntry.display, savedMode, lastShownHistoryEntry.pastedContents ?? {});
} else {
updateInput(lastShownHistoryEntry)
updateInput(lastShownHistoryEntry);
}
} else {
// When in filtered mode, stay in that mode when clearing input
setInputWithCursor('', initialModeFilterRef.current ?? 'prompt', {})
setInputWithCursor('', initialModeFilterRef.current ?? 'prompt', {});
}
}
return currentIndex <= 0
}, [lastShownHistoryEntry, updateInput, setInputWithCursor])
return currentIndex <= 0;
}, [lastShownHistoryEntry, updateInput, setInputWithCursor]);
const resetHistory = useCallback((): void => {
setLastShownHistoryEntry(undefined)
setHistoryIndex(0)
historyIndexRef.current = 0
initialModeFilterRef.current = undefined
removeNotification('search-history-hint')
historyCache.current = []
historyCacheModeFilter.current = undefined
}, [removeNotification])
setLastShownHistoryEntry(undefined);
setHistoryIndex(0);
historyIndexRef.current = 0;
initialModeFilterRef.current = undefined;
removeNotification('search-history-hint');
historyCache.current = [];
historyCacheModeFilter.current = undefined;
}, [removeNotification]);
const dismissSearchHint = useCallback((): void => {
removeNotification('search-history-hint')
}, [removeNotification])
removeNotification('search-history-hint');
}, [removeNotification]);
return {
historyIndex,
@@ -284,5 +263,5 @@ export function useArrowKeyHistory(
onHistoryDown,
resetHistory,
dismissSearchHint,
}
};
}

View File

@@ -1,4 +1,8 @@
import { type DOMElement, useAnimationFrame, useTerminalFocus } from '@anthropic/ink'
import {
type DOMElement,
useAnimationFrame,
useTerminalFocus,
} from '@anthropic/ink'
const BLINK_INTERVAL_MS = 600

View File

@@ -1,72 +1,54 @@
import { feature } from 'bun:bundle'
import { APIUserAbortError } from '@anthropic-ai/sdk'
import * as React from 'react'
import { useCallback } from 'react'
import { feature } from 'bun:bundle';
import { APIUserAbortError } from '@anthropic-ai/sdk';
import * as React from 'react';
import { useCallback } from 'react';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'
import { Text } from '@anthropic/ink'
import type {
ToolPermissionContext,
Tool as ToolType,
ToolUseContext,
} from '../Tool.js'
} from 'src/services/analytics/index.js';
import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js';
import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js';
import { Text } from '@anthropic/ink';
import type { ToolPermissionContext, Tool as ToolType, ToolUseContext } from '../Tool.js';
import {
consumeSpeculativeClassifierCheck,
peekSpeculativeClassifierCheck,
} from '@claude-code-best/builtin-tools/tools/BashTool/bashPermissions.js'
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
import type { AssistantMessage } from '../types/message.js'
import { recordAutoModeDenial } from '../utils/autoModeDenials.js'
} from '@claude-code-best/builtin-tools/tools/BashTool/bashPermissions.js';
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js';
import type { AssistantMessage } from '../types/message.js';
import { recordAutoModeDenial } from '../utils/autoModeDenials.js';
import {
clearClassifierChecking,
setClassifierApproval,
setYoloClassifierApproval,
} from '../utils/classifierApprovals.js'
import { logForDebugging } from '../utils/debug.js'
import { AbortError } from '../utils/errors.js'
import { logError } from '../utils/log.js'
import type { PermissionDecision } from '../utils/permissions/PermissionResult.js'
import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js'
import { jsonStringify } from '../utils/slowOperations.js'
import { handleCoordinatorPermission } from './toolPermission/handlers/coordinatorHandler.js'
import { handleInteractivePermission } from './toolPermission/handlers/interactiveHandler.js'
import { handleSwarmWorkerPermission } from './toolPermission/handlers/swarmWorkerHandler.js'
import {
createPermissionContext,
createPermissionQueueOps,
} from './toolPermission/PermissionContext.js'
import { logPermissionDecision } from './toolPermission/permissionLogging.js'
} from '../utils/classifierApprovals.js';
import { logForDebugging } from '../utils/debug.js';
import { AbortError } from '../utils/errors.js';
import { logError } from '../utils/log.js';
import type { PermissionDecision } from '../utils/permissions/PermissionResult.js';
import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js';
import { jsonStringify } from '../utils/slowOperations.js';
import { handleCoordinatorPermission } from './toolPermission/handlers/coordinatorHandler.js';
import { handleInteractivePermission } from './toolPermission/handlers/interactiveHandler.js';
import { handleSwarmWorkerPermission } from './toolPermission/handlers/swarmWorkerHandler.js';
import { createPermissionContext, createPermissionQueueOps } from './toolPermission/PermissionContext.js';
import { logPermissionDecision } from './toolPermission/permissionLogging.js';
export type CanUseToolFn<
Input extends Record<string, unknown> = Record<string, unknown>,
> = (
export type CanUseToolFn<Input extends Record<string, unknown> = Record<string, unknown>> = (
tool: ToolType,
input: Input,
toolUseContext: ToolUseContext,
assistantMessage: AssistantMessage,
toolUseID: string,
forceDecision?: PermissionDecision<Input>,
) => Promise<PermissionDecision<Input>>
) => Promise<PermissionDecision<Input>>;
function useCanUseTool(
setToolUseConfirmQueue: React.Dispatch<
React.SetStateAction<ToolUseConfirm[]>
>,
setToolUseConfirmQueue: React.Dispatch<React.SetStateAction<ToolUseConfirm[]>>,
setToolPermissionContext: (context: ToolPermissionContext) => void,
): CanUseToolFn {
return useCallback<CanUseToolFn>(
async (
tool,
input,
toolUseContext,
assistantMessage,
toolUseID,
forceDecision,
) => {
async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) => {
return new Promise(resolve => {
const ctx = createPermissionContext(
tool,
@@ -76,20 +58,14 @@ function useCanUseTool(
toolUseID,
setToolPermissionContext,
createPermissionQueueOps(setToolUseConfirmQueue),
)
);
if (ctx.resolveIfAborted(resolve)) return
if (ctx.resolveIfAborted(resolve)) return;
const decisionPromise =
forceDecision !== undefined
? Promise.resolve(forceDecision)
: hasPermissionsToUseTool(
tool,
input,
toolUseContext,
assistantMessage,
toolUseID,
)
: hasPermissionsToUseTool(tool, input, toolUseContext, assistantMessage, toolUseID);
return decisionPromise
.then(async result => {
@@ -97,52 +73,44 @@ function useCanUseTool(
if (process.env.USER_TYPE === 'ant') {
logEvent('tengu_internal_tool_permission_decision', {
toolName: sanitizeToolNameForAnalytics(tool.name),
behavior:
result.behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
behavior: result.behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
// Note: input contains code/filepaths, only log for ants
input: jsonStringify(
input,
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
messageID:
ctx.messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
input: jsonStringify(input) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
messageID: ctx.messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
isMcp: tool.isMcp ?? false,
})
});
}
// Has permissions to use tool, granted in config
if (result.behavior === 'allow') {
if (ctx.resolveIfAborted(resolve)) return
if (ctx.resolveIfAborted(resolve)) return;
// Track auto mode classifier approvals for UI display
if (
feature('TRANSCRIPT_CLASSIFIER') &&
result.decisionReason?.type === 'classifier' &&
result.decisionReason.classifier === 'auto-mode'
) {
setYoloClassifierApproval(
toolUseID,
result.decisionReason.reason,
)
setYoloClassifierApproval(toolUseID, result.decisionReason.reason);
}
ctx.logDecision({ decision: 'accept', source: 'config' })
ctx.logDecision({ decision: 'accept', source: 'config' });
resolve(
ctx.buildAllow(result.updatedInput ?? input, {
decisionReason: result.decisionReason,
}),
)
return
);
return;
}
const appState = toolUseContext.getAppState()
const appState = toolUseContext.getAppState();
const description = await tool.description(input as never, {
isNonInteractiveSession:
toolUseContext.options.isNonInteractiveSession,
isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession,
toolPermissionContext: appState.toolPermissionContext,
tools: toolUseContext.options.tools,
})
});
if (ctx.resolveIfAborted(resolve)) return
if (ctx.resolveIfAborted(resolve)) return;
// Does not have permissions to use tool, check the behavior
switch (result.behavior) {
@@ -156,7 +124,7 @@ function useCanUseTool(
toolUseID,
},
{ decision: 'reject', source: 'config' },
)
);
if (
feature('TRANSCRIPT_CLASSIFIER') &&
result.decisionReason?.type === 'classifier' &&
@@ -167,49 +135,40 @@ function useCanUseTool(
display: description,
reason: result.decisionReason.reason ?? '',
timestamp: Date.now(),
})
});
toolUseContext.addNotification?.({
key: 'auto-mode-denied',
priority: 'immediate',
jsx: (
<>
<Text color="error">
{tool.userFacingName(input).toLowerCase()} denied by
auto mode
</Text>
<Text color="error">{tool.userFacingName(input).toLowerCase()} denied by auto mode</Text>
<Text dimColor> · /permissions</Text>
</>
),
})
});
}
resolve(result)
return
resolve(result);
return;
}
case 'ask': {
// For coordinator workers, await automated checks before showing dialog.
// Background workers should only interrupt the user when automated checks can't decide.
if (
appState.toolPermissionContext
.awaitAutomatedChecksBeforeDialog
) {
const coordinatorDecision = await handleCoordinatorPermission(
{
ctx,
...(feature('BASH_CLASSIFIER')
? {
pendingClassifierCheck:
result.pendingClassifierCheck,
}
: {}),
updatedInput: result.updatedInput,
suggestions: result.suggestions,
permissionMode: appState.toolPermissionContext.mode,
},
)
if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) {
const coordinatorDecision = await handleCoordinatorPermission({
ctx,
...(feature('BASH_CLASSIFIER')
? {
pendingClassifierCheck: result.pendingClassifierCheck,
}
: {}),
updatedInput: result.updatedInput,
suggestions: result.suggestions,
permissionMode: appState.toolPermissionContext.mode,
});
if (coordinatorDecision) {
resolve(coordinatorDecision)
return
resolve(coordinatorDecision);
return;
}
// null means neither automated check resolved -- fall through to dialog below.
// Hooks already ran, classifier already consumed.
@@ -217,7 +176,7 @@ function useCanUseTool(
// After awaiting automated checks, verify the request wasn't aborted
// while we were waiting. Without this check, a stale dialog could appear.
if (ctx.resolveIfAborted(resolve)) return
if (ctx.resolveIfAborted(resolve)) return;
// For swarm workers, try classifier auto-approval then
// forward permission requests to the leader via mailbox.
@@ -231,10 +190,10 @@ function useCanUseTool(
: {}),
updatedInput: result.updatedInput,
suggestions: result.suggestions,
})
});
if (swarmDecision) {
resolve(swarmDecision)
return
resolve(swarmDecision);
return;
}
// Grace period: wait up to 2s for speculative classifier
@@ -243,12 +202,9 @@ function useCanUseTool(
feature('BASH_CLASSIFIER') &&
result.pendingClassifierCheck &&
tool.name === BASH_TOOL_NAME &&
!appState.toolPermissionContext
.awaitAutomatedChecksBeforeDialog
!appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog
) {
const speculativePromise = peekSpeculativeClassifierCheck(
(input as { command: string }).command,
)
const speculativePromise = peekSpeculativeClassifierCheck((input as { command: string }).command);
if (speculativePromise) {
const raceResult = await Promise.race([
speculativePromise.then(r => ({
@@ -259,9 +215,9 @@ function useCanUseTool(
// eslint-disable-next-line no-restricted-syntax -- resolves with a value, not void
setTimeout(res, 2000, { type: 'timeout' as const }),
),
])
]);
if (ctx.resolveIfAborted(resolve)) return
if (ctx.resolveIfAborted(resolve)) return;
if (
raceResult.type === 'result' &&
@@ -270,34 +226,27 @@ function useCanUseTool(
feature('BASH_CLASSIFIER')
) {
// Classifier approved within grace period — skip dialog
void consumeSpeculativeClassifierCheck(
(input as { command: string }).command,
)
void consumeSpeculativeClassifierCheck((input as { command: string }).command);
const matchedRule =
raceResult.result.matchedDescription ?? undefined
const matchedRule = raceResult.result.matchedDescription ?? undefined;
if (matchedRule) {
setClassifierApproval(toolUseID, matchedRule)
setClassifierApproval(toolUseID, matchedRule);
}
ctx.logDecision({
decision: 'accept',
source: { type: 'classifier' },
})
});
resolve(
ctx.buildAllow(
result.updatedInput ??
(input as Record<string, unknown>),
{
decisionReason: {
type: 'classifier' as const,
classifier: 'bash_allow' as const,
reason: `Allowed by prompt rule: "${raceResult.result.matchedDescription}"`,
},
ctx.buildAllow(result.updatedInput ?? (input as Record<string, unknown>), {
decisionReason: {
type: 'classifier' as const,
classifier: 'bash_allow' as const,
reason: `Allowed by prompt rule: "${raceResult.result.matchedDescription}"`,
},
),
)
return
}),
);
return;
}
// Timeout or no match — fall through to show dialog
}
@@ -309,46 +258,37 @@ function useCanUseTool(
ctx,
description,
result,
awaitAutomatedChecksBeforeDialog:
appState.toolPermissionContext
.awaitAutomatedChecksBeforeDialog,
bridgeCallbacks: feature('BRIDGE_MODE')
? appState.replBridgePermissionCallbacks
: undefined,
awaitAutomatedChecksBeforeDialog: appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog,
bridgeCallbacks: feature('BRIDGE_MODE') ? appState.replBridgePermissionCallbacks : undefined,
channelCallbacks:
feature('KAIROS') || feature('KAIROS_CHANNELS')
? appState.channelPermissionCallbacks
: undefined,
feature('KAIROS') || feature('KAIROS_CHANNELS') ? appState.channelPermissionCallbacks : undefined,
},
resolve,
)
);
return
return;
}
}
})
.catch(error => {
if (
error instanceof AbortError ||
error instanceof APIUserAbortError
) {
if (error instanceof AbortError || error instanceof APIUserAbortError) {
logForDebugging(
`Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`,
)
ctx.logCancelled()
resolve(ctx.cancelAndAbort(undefined, true))
);
ctx.logCancelled();
resolve(ctx.cancelAndAbort(undefined, true));
} else {
logError(error)
resolve(ctx.cancelAndAbort(undefined, true))
logError(error);
resolve(ctx.cancelAndAbort(undefined, true));
}
})
.finally(() => {
clearClassifierChecking(toolUseID)
})
})
clearClassifierChecking(toolUseID);
});
});
},
[setToolUseConfirmQueue, setToolPermissionContext],
)
);
}
export default useCanUseTool
export default useCanUseTool;

View File

@@ -1,56 +1,45 @@
import * as React from 'react'
import { Text } from '@anthropic/ink'
import { isClaudeAISubscriber } from '../utils/auth.js'
import {
isChromeExtensionInstalled,
shouldEnableClaudeInChrome,
} from '../utils/claudeInChrome/setup.js'
import { isRunningOnHomespace } from '../utils/envUtils.js'
import { useStartupNotification } from './notifs/useStartupNotification.js'
import * as React from 'react';
import { Text } from '@anthropic/ink';
import { isClaudeAISubscriber } from '../utils/auth.js';
import { isChromeExtensionInstalled, shouldEnableClaudeInChrome } from '../utils/claudeInChrome/setup.js';
import { isRunningOnHomespace } from '../utils/envUtils.js';
import { useStartupNotification } from './notifs/useStartupNotification.js';
function getChromeFlag(): boolean | undefined {
if (process.argv.includes('--chrome')) {
return true
return true;
}
if (process.argv.includes('--no-chrome')) {
return false
return false;
}
return undefined
return undefined;
}
export function useChromeExtensionNotification(): void {
useStartupNotification(async () => {
const chromeFlag = getChromeFlag()
if (!shouldEnableClaudeInChrome(chromeFlag)) return null
const chromeFlag = getChromeFlag();
if (!shouldEnableClaudeInChrome(chromeFlag)) return null;
// Claude in Chrome is only supported for claude.ai subscribers (unless user is ant)
if (process.env.USER_TYPE !== 'ant' && !isClaudeAISubscriber()) {
return {
key: 'chrome-requires-subscription',
jsx: (
<Text color="error">
Claude in Chrome requires a claude.ai subscription
</Text>
),
jsx: <Text color="error">Claude in Chrome requires a claude.ai subscription</Text>,
priority: 'immediate',
timeoutMs: 5000,
}
};
}
const installed = await isChromeExtensionInstalled()
const installed = await isChromeExtensionInstalled();
if (!installed && !isRunningOnHomespace()) {
// Skip notification on Homespace since Chrome setup requires different steps (see go/hsproxy)
return {
key: 'chrome-extension-not-detected',
jsx: (
<Text color="warning">
Chrome extension not detected · https://claude.ai/chrome to install
</Text>
),
jsx: <Text color="warning">Chrome extension not detected · https://claude.ai/chrome to install</Text>,
// TODO(hackyon): Lower the priority if the claude-in-chrome integration is no longer opt-in
priority: 'immediate',
timeoutMs: 3000,
}
};
}
if (chromeFlag === undefined) {
// Show low priority notification only when Chrome is enabled by default
@@ -59,8 +48,8 @@ export function useChromeExtensionNotification(): void {
key: 'claude-in-chrome-default-enabled',
text: `Claude in Chrome enabled · /chrome`,
priority: 'low',
}
};
}
return null
})
return null;
});
}

View File

@@ -8,117 +8,101 @@
* anything that reaches this hook is worth resolving.
*/
import * as React from 'react'
import { useNotifications } from '../context/notifications.js'
import * as React from 'react';
import { useNotifications } from '../context/notifications.js';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
logEvent,
} from '../services/analytics/index.js'
} from '../services/analytics/index.js';
import {
clearPendingHint,
getPendingHintSnapshot,
markShownThisSession,
subscribeToPendingHint,
} from '../utils/claudeCodeHints.js'
import { logForDebugging } from '../utils/debug.js'
} from '../utils/claudeCodeHints.js';
import { logForDebugging } from '../utils/debug.js';
import {
disableHintRecommendations,
markHintPluginShown,
type PluginHintRecommendation,
resolvePluginHint,
} from '../utils/plugins/hintRecommendation.js'
import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js'
import {
installPluginAndNotify,
usePluginRecommendationBase,
} from './usePluginRecommendationBase.js'
} from '../utils/plugins/hintRecommendation.js';
import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js';
import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js';
type UseClaudeCodeHintRecommendationResult = {
recommendation: PluginHintRecommendation | null
handleResponse: (response: 'yes' | 'no' | 'disable') => void
}
recommendation: PluginHintRecommendation | null;
handleResponse: (response: 'yes' | 'no' | 'disable') => void;
};
export function useClaudeCodeHintRecommendation(): UseClaudeCodeHintRecommendationResult {
const pendingHint = React.useSyncExternalStore(
subscribeToPendingHint,
getPendingHintSnapshot,
)
const { addNotification } = useNotifications()
const { recommendation, clearRecommendation, tryResolve } =
usePluginRecommendationBase<PluginHintRecommendation>()
const pendingHint = React.useSyncExternalStore(subscribeToPendingHint, getPendingHintSnapshot);
const { addNotification } = useNotifications();
const { recommendation, clearRecommendation, tryResolve } = usePluginRecommendationBase<PluginHintRecommendation>();
React.useEffect(() => {
if (!pendingHint) return
if (!pendingHint) return;
tryResolve(async () => {
const resolved = await resolvePluginHint(pendingHint)
const resolved = await resolvePluginHint(pendingHint);
if (resolved) {
logForDebugging(
`[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`,
)
markShownThisSession()
);
markShownThisSession();
}
// Drop the slot — but only if it still holds the hint we just
// resolved. A newer hint may have overwritten it during the async
// lookup; don't clobber that.
if (getPendingHintSnapshot() === pendingHint) {
clearPendingHint()
clearPendingHint();
}
return resolved
})
}, [pendingHint, tryResolve])
return resolved;
});
}, [pendingHint, tryResolve]);
const handleResponse = React.useCallback(
(response: 'yes' | 'no' | 'disable') => {
if (!recommendation) return
if (!recommendation) return;
// Record show-once here, not at resolution-time — the dialog may have
// been blocked by a higher-priority focusedInputDialog and never
// rendered. Auto-dismiss reaches this via onResponse('no').
markHintPluginShown(recommendation.pluginId)
markHintPluginShown(recommendation.pluginId);
logEvent('tengu_plugin_hint_response', {
_PROTO_plugin_name:
recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
_PROTO_marketplace_name:
recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
response:
response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
_PROTO_plugin_name: recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
_PROTO_marketplace_name: recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
response: response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
switch (response) {
case 'yes': {
const { pluginId, pluginName, marketplaceName } = recommendation
void installPluginAndNotify(
pluginId,
pluginName,
'hint-plugin',
addNotification,
async pluginData => {
const result = await installPluginFromMarketplace({
pluginId,
entry: pluginData.entry,
marketplaceName,
scope: 'user',
trigger: 'hint',
})
if (!result.success) {
throw new Error(!result.success ? (result as { error: string }).error : 'Unknown error')
}
},
)
break
const { pluginId, pluginName, marketplaceName } = recommendation;
void installPluginAndNotify(pluginId, pluginName, 'hint-plugin', addNotification, async pluginData => {
const result = await installPluginFromMarketplace({
pluginId,
entry: pluginData.entry,
marketplaceName,
scope: 'user',
trigger: 'hint',
});
if (!result.success) {
throw new Error(!result.success ? (result as { error: string }).error : 'Unknown error');
}
});
break;
}
case 'disable':
disableHintRecommendations()
break
disableHintRecommendations();
break;
case 'no':
break
break;
}
clearRecommendation()
clearRecommendation();
},
[recommendation, addNotification, clearRecommendation],
)
);
return { recommendation, handleResponse }
return { recommendation, handleResponse };
}

View File

@@ -8,11 +8,11 @@
* Commands triggered via keybinding are treated as "immediate" - they execute right
* away and preserve the user's existing input text (the prompt is not cleared).
*/
import { useMemo } from 'react'
import { useIsModalOverlayActive } from '../context/overlayContext.js'
import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'
import { useKeybindings } from '../keybindings/useKeybinding.js'
import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js'
import { useMemo } from 'react';
import { useIsModalOverlayActive } from '../context/overlayContext.js';
import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js';
import { useKeybindings } from '../keybindings/useKeybinding.js';
import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js';
type Props = {
// onSubmit accepts additional parameters beyond what we pass here,
@@ -20,63 +20,57 @@ type Props = {
onSubmit: (
input: string,
helpers: PromptInputHelpers,
...rest: [
speculationAccept?: undefined,
options?: { fromKeybinding?: boolean },
]
) => void
...rest: [speculationAccept?: undefined, options?: { fromKeybinding?: boolean }]
) => void;
/** Set to false to disable command keybindings (e.g., when a dialog is open) */
isActive?: boolean
}
isActive?: boolean;
};
const NOOP_HELPERS: PromptInputHelpers = {
setCursorOffset: () => {},
clearBuffer: () => {},
resetHistory: () => {},
}
};
/**
* Registers keybinding handlers for all "command:*" actions found in the
* user's keybinding configuration. When triggered, each handler submits
* the corresponding slash command (e.g., "command:commit" submits "/commit").
*/
export function CommandKeybindingHandlers({
onSubmit,
isActive = true,
}: Props): null {
const keybindingContext = useOptionalKeybindingContext()
const isModalOverlayActive = useIsModalOverlayActive()
export function CommandKeybindingHandlers({ onSubmit, isActive = true }: Props): null {
const keybindingContext = useOptionalKeybindingContext();
const isModalOverlayActive = useIsModalOverlayActive();
// Extract command actions from parsed bindings
const commandActions = useMemo(() => {
if (!keybindingContext) return new Set<string>()
const actions = new Set<string>()
if (!keybindingContext) return new Set<string>();
const actions = new Set<string>();
for (const binding of keybindingContext.bindings) {
if (binding.action?.startsWith('command:')) {
actions.add(binding.action)
actions.add(binding.action);
}
}
return actions
}, [keybindingContext])
return actions;
}, [keybindingContext]);
// Build handler map for all command actions
const handlers = useMemo(() => {
const map: Record<string, () => void> = {}
const map: Record<string, () => void> = {};
for (const action of commandActions) {
const commandName = action.slice('command:'.length)
const commandName = action.slice('command:'.length);
map[action] = () => {
onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, {
fromKeybinding: true,
})
}
});
};
}
return map
}, [commandActions, onSubmit])
return map;
}, [commandActions, onSubmit]);
useKeybindings(handlers, {
context: 'Chat',
isActive: isActive && !isModalOverlayActive,
})
});
return null
return null;
}

View File

@@ -16,7 +16,10 @@ import {
import type { Tool } from '../Tool.js'
import { findToolByName } from '../Tool.js'
import type { Message as MessageType } from '../types/message.js'
import type { PermissionAskDecision, PermissionUpdate } from '../types/permissions.js'
import type {
PermissionAskDecision,
PermissionUpdate,
} from '../types/permissions.js'
import { logForDebugging } from '../utils/debug.js'
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
import type { RemoteMessageContent } from '../utils/teleport/api.js'

View File

@@ -4,31 +4,31 @@
* Must be rendered inside KeybindingSetup to have access to the keybinding context.
* This component renders nothing - it just registers the keybinding handlers.
*/
import { feature } from 'bun:bundle'
import { useCallback } from 'react'
import { instances } from '@anthropic/ink'
import { useKeybinding } from '../keybindings/useKeybinding.js'
import type { Screen } from '../screens/REPL.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
import { feature } from 'bun:bundle';
import { useCallback } from 'react';
import { instances } from '@anthropic/ink';
import { useKeybinding } from '../keybindings/useKeybinding.js';
import type { Screen } from '../screens/REPL.js';
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../services/analytics/index.js'
import { useAppState, useSetAppState } from '../state/AppState.js'
import { count } from '../utils/array.js'
import { getTerminalPanel } from '../utils/terminalPanel.js'
} from '../services/analytics/index.js';
import { useAppState, useSetAppState } from '../state/AppState.js';
import { count } from '../utils/array.js';
import { getTerminalPanel } from '../utils/terminalPanel.js';
type Props = {
screen: Screen
setScreen: React.Dispatch<React.SetStateAction<Screen>>
showAllInTranscript: boolean
setShowAllInTranscript: React.Dispatch<React.SetStateAction<boolean>>
messageCount: number
onEnterTranscript?: () => void
onExitTranscript?: () => void
virtualScrollActive?: boolean
searchBarOpen?: boolean
}
screen: Screen;
setScreen: React.Dispatch<React.SetStateAction<Screen>>;
showAllInTranscript: boolean;
setShowAllInTranscript: React.Dispatch<React.SetStateAction<boolean>>;
messageCount: number;
onEnterTranscript?: () => void;
onExitTranscript?: () => void;
virtualScrollActive?: boolean;
searchBarOpen?: boolean;
};
/**
* Registers global keybinding handlers for:
@@ -48,53 +48,42 @@ export function GlobalKeybindingHandlers({
virtualScrollActive,
searchBarOpen = false,
}: Props): null {
const expandedView = useAppState(s => s.expandedView)
const setAppState = useSetAppState()
const expandedView = useAppState(s => s.expandedView);
const setAppState = useSetAppState();
// Toggle todo list (ctrl+t) - cycles through views
const handleToggleTodos = useCallback(() => {
logEvent('tengu_toggle_todos', {
is_expanded: expandedView === 'tasks',
})
});
setAppState(prev => {
const { getAllInProcessTeammateTasks } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js')
const hasTeammates =
count(
getAllInProcessTeammateTasks(prev.tasks),
t => t.status === 'running',
) > 0
require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js');
const hasTeammates = count(getAllInProcessTeammateTasks(prev.tasks), t => t.status === 'running') > 0;
if (hasTeammates) {
// Both exist: none → tasks → teammates → none
switch (prev.expandedView) {
case 'none':
return { ...prev, expandedView: 'tasks' as const }
return { ...prev, expandedView: 'tasks' as const };
case 'tasks':
return { ...prev, expandedView: 'teammates' as const }
return { ...prev, expandedView: 'teammates' as const };
case 'teammates':
return { ...prev, expandedView: 'none' as const }
return { ...prev, expandedView: 'none' as const };
}
}
// Only tasks: none ↔ tasks
return {
...prev,
expandedView:
prev.expandedView === 'tasks'
? ('none' as const)
: ('tasks' as const),
}
})
}, [expandedView, setAppState])
expandedView: prev.expandedView === 'tasks' ? ('none' as const) : ('tasks' as const),
};
});
}, [expandedView, setAppState]);
// Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript.
// Brief view has its own dedicated toggle on ctrl+shift+b.
const isBriefOnly =
feature('KAIROS') || feature('KAIROS_BRIEF')
?
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,34 +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')) {
useKeybinding('app:toggleBrief', handleToggleBrief, {
context: 'Global',
})
});
}
// Register teammate keybinding
@@ -214,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
@@ -257,7 +239,7 @@ export function GlobalKeybindingHandlers({
// so without this gate its onCancel AND this handler would both
// fire on one Esc (child registers first, fires first, bubbles).
isActive: isInTranscript && !searchBarOpen,
})
});
return null
return null;
}

View File

@@ -1,26 +1,22 @@
import { useEffect } from 'react'
import type { ScopedMcpServerConfig } from '../services/mcp/types.js'
import { getGlobalConfig } from '../utils/config.js'
import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js'
import type { DetectedIDEInfo } from '../utils/ide.js'
import { useEffect } from 'react';
import type { ScopedMcpServerConfig } from '../services/mcp/types.js';
import { getGlobalConfig } from '../utils/config.js';
import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js';
import type { DetectedIDEInfo } from '../utils/ide.js';
import {
type IDEExtensionInstallationStatus,
type IdeType,
initializeIdeIntegration,
isSupportedTerminal,
} from '../utils/ide.js'
} from '../utils/ide.js';
type UseIDEIntegrationProps = {
autoConnectIdeFlag?: boolean
ideToInstallExtension: IdeType | null
setDynamicMcpConfig: React.Dispatch<
React.SetStateAction<Record<string, ScopedMcpServerConfig> | undefined>
>
setShowIdeOnboarding: React.Dispatch<React.SetStateAction<boolean>>
setIDEInstallationState: React.Dispatch<
React.SetStateAction<IDEExtensionInstallationStatus | null>
>
}
autoConnectIdeFlag?: boolean;
ideToInstallExtension: IdeType | null;
setDynamicMcpConfig: React.Dispatch<React.SetStateAction<Record<string, ScopedMcpServerConfig> | undefined>>;
setShowIdeOnboarding: React.Dispatch<React.SetStateAction<boolean>>;
setIDEInstallationState: React.Dispatch<React.SetStateAction<IDEExtensionInstallationStatus | null>>;
};
export function useIDEIntegration({
autoConnectIdeFlag,
@@ -32,11 +28,11 @@ export function useIDEIntegration({
useEffect(() => {
function addIde(ide: DetectedIDEInfo | null) {
if (!ide) {
return
return;
}
// Check if auto-connect is enabled
const globalConfig = getGlobalConfig()
const globalConfig = getGlobalConfig();
const autoConnectEnabled =
(globalConfig.autoConnectIde ||
autoConnectIdeFlag ||
@@ -46,16 +42,16 @@ export function useIDEIntegration({
process.env.CLAUDE_CODE_SSE_PORT ||
ideToInstallExtension ||
isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) &&
!isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)
!isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE);
if (!autoConnectEnabled) {
return
return;
}
setDynamicMcpConfig(prev => {
// Only add the IDE if we don't already have one
if (prev?.ide) {
return prev
return prev;
}
return {
...prev,
@@ -67,8 +63,8 @@ export function useIDEIntegration({
ideRunningInWindows: ide.ideRunningInWindows,
scope: 'dynamic' as const,
},
}
})
};
});
}
// Use the new utility function
@@ -77,12 +73,6 @@ export function useIDEIntegration({
ideToInstallExtension,
() => setShowIdeOnboarding(true),
status => setIDEInstallationState(status),
)
}, [
autoConnectIdeFlag,
ideToInstallExtension,
setDynamicMcpConfig,
setShowIdeOnboarding,
setIDEInstallationState,
])
);
}, [autoConnectIdeFlag, ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, setIDEInstallationState]);
}

View File

@@ -10,170 +10,138 @@
* Only shows one recommendation per session.
*/
import { extname, join } from 'path'
import * as React from 'react'
import {
hasShownLspRecommendationThisSession,
setLspRecommendationShownThisSession,
} from '../bootstrap/state.js'
import { useNotifications } from '../context/notifications.js'
import { useAppState } from '../state/AppState.js'
import { saveGlobalConfig } from '../utils/config.js'
import { logForDebugging } from '../utils/debug.js'
import { logError } from '../utils/log.js'
import {
addToNeverSuggest,
getMatchingLspPlugins,
incrementIgnoredCount,
} from '../utils/plugins/lspRecommendation.js'
import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js'
import {
getSettingsForSource,
updateSettingsForSource,
} from '../utils/settings/settings.js'
import {
installPluginAndNotify,
usePluginRecommendationBase,
} from './usePluginRecommendationBase.js'
import { extname, join } from 'path';
import * as React from 'react';
import { hasShownLspRecommendationThisSession, setLspRecommendationShownThisSession } from '../bootstrap/state.js';
import { useNotifications } from '../context/notifications.js';
import { useAppState } from '../state/AppState.js';
import { saveGlobalConfig } from '../utils/config.js';
import { logForDebugging } from '../utils/debug.js';
import { logError } from '../utils/log.js';
import { addToNeverSuggest, getMatchingLspPlugins, incrementIgnoredCount } from '../utils/plugins/lspRecommendation.js';
import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js';
import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js';
import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js';
// Threshold for detecting timeout vs explicit dismiss (ms)
// Menu auto-dismisses at 30s, so anything over 28s is likely timeout
const TIMEOUT_THRESHOLD_MS = 28_000
const TIMEOUT_THRESHOLD_MS = 28_000;
export type LspRecommendationState = {
pluginId: string
pluginName: string
pluginDescription?: string
fileExtension: string
shownAt: number // Timestamp for timeout detection
} | null
pluginId: string;
pluginName: string;
pluginDescription?: string;
fileExtension: string;
shownAt: number; // Timestamp for timeout detection
} | null;
type UseLspPluginRecommendationResult = {
recommendation: LspRecommendationState
handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void
}
recommendation: LspRecommendationState;
handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void;
};
export function useLspPluginRecommendation(): UseLspPluginRecommendationResult {
const trackedFiles = useAppState(s => s.fileHistory.trackedFiles)
const { addNotification } = useNotifications()
const checkedFilesRef = React.useRef<Set<string>>(new Set())
const trackedFiles = useAppState(s => s.fileHistory.trackedFiles);
const { addNotification } = useNotifications();
const checkedFilesRef = React.useRef<Set<string>>(new Set());
const { recommendation, clearRecommendation, tryResolve } =
usePluginRecommendationBase<NonNullable<LspRecommendationState>>()
usePluginRecommendationBase<NonNullable<LspRecommendationState>>();
React.useEffect(() => {
tryResolve(async () => {
if (hasShownLspRecommendationThisSession()) return null
if (hasShownLspRecommendationThisSession()) return null;
const newFiles: string[] = []
const newFiles: string[] = [];
for (const file of trackedFiles) {
if (!checkedFilesRef.current.has(file)) {
checkedFilesRef.current.add(file)
newFiles.push(file)
checkedFilesRef.current.add(file);
newFiles.push(file);
}
}
for (const filePath of newFiles) {
try {
const matches = await getMatchingLspPlugins(filePath)
const match = matches[0] // official plugins prioritized
const matches = await getMatchingLspPlugins(filePath);
const match = matches[0]; // official plugins prioritized
if (match) {
logForDebugging(
`[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`,
)
setLspRecommendationShownThisSession(true)
logForDebugging(`[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`);
setLspRecommendationShownThisSession(true);
return {
pluginId: match.pluginId,
pluginName: match.pluginName,
pluginDescription: match.description,
fileExtension: extname(filePath),
shownAt: Date.now(),
}
};
}
} catch (error) {
logError(error)
logError(error);
}
}
return null
})
}, [trackedFiles, tryResolve])
return null;
});
}, [trackedFiles, tryResolve]);
const handleResponse = React.useCallback(
(response: 'yes' | 'no' | 'never' | 'disable') => {
if (!recommendation) return
if (!recommendation) return;
const { pluginId, pluginName, shownAt } = recommendation
const { pluginId, pluginName, shownAt } = recommendation;
logForDebugging(
`[useLspPluginRecommendation] User response: ${response} for ${pluginName}`,
)
logForDebugging(`[useLspPluginRecommendation] User response: ${response} for ${pluginName}`);
switch (response) {
case 'yes':
void installPluginAndNotify(
pluginId,
pluginName,
'lsp-plugin',
addNotification,
async pluginData => {
logForDebugging(
`[useLspPluginRecommendation] Installing plugin: ${pluginId}`,
)
const localSourcePath =
typeof pluginData.entry.source === 'string'
? join(
pluginData.marketplaceInstallLocation,
pluginData.entry.source,
)
: undefined
await cacheAndRegisterPlugin(
pluginId,
pluginData.entry,
'user',
undefined, // projectPath - not needed for user scope
localSourcePath,
)
// Enable in user settings so it loads on restart
const settings = getSettingsForSource('userSettings')
updateSettingsForSource('userSettings', {
enabledPlugins: {
...settings?.enabledPlugins,
[pluginId]: true,
},
})
logForDebugging(
`[useLspPluginRecommendation] Plugin installed: ${pluginId}`,
)
},
)
break
void installPluginAndNotify(pluginId, pluginName, 'lsp-plugin', addNotification, async pluginData => {
logForDebugging(`[useLspPluginRecommendation] Installing plugin: ${pluginId}`);
const localSourcePath =
typeof pluginData.entry.source === 'string'
? join(pluginData.marketplaceInstallLocation, pluginData.entry.source)
: undefined;
await cacheAndRegisterPlugin(
pluginId,
pluginData.entry,
'user',
undefined, // projectPath - not needed for user scope
localSourcePath,
);
// Enable in user settings so it loads on restart
const settings = getSettingsForSource('userSettings');
updateSettingsForSource('userSettings', {
enabledPlugins: {
...settings?.enabledPlugins,
[pluginId]: true,
},
});
logForDebugging(`[useLspPluginRecommendation] Plugin installed: ${pluginId}`);
});
break;
case 'no': {
const elapsed = Date.now() - shownAt
const elapsed = Date.now() - shownAt;
if (elapsed >= TIMEOUT_THRESHOLD_MS) {
logForDebugging(
`[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`,
)
incrementIgnoredCount()
logForDebugging(`[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`);
incrementIgnoredCount();
}
break
break;
}
case 'never':
addToNeverSuggest(pluginId)
break
addToNeverSuggest(pluginId);
break;
case 'disable':
saveGlobalConfig(current => {
if (current.lspRecommendationDisabled) return current
return { ...current, lspRecommendationDisabled: true }
})
break
if (current.lspRecommendationDisabled) return current;
return { ...current, lspRecommendationDisabled: true };
});
break;
}
clearRecommendation()
clearRecommendation();
},
[recommendation, addNotification, clearRecommendation],
)
);
return { recommendation, handleResponse }
return { recommendation, handleResponse };
}

View File

@@ -52,7 +52,8 @@ export function useManagePlugins({
const initialPluginLoad = useCallback(async () => {
try {
// Load all plugins - capture errors array
const { enabled, disabled, errors }: PluginLoadResult = await loadAllPlugins()
const { enabled, disabled, errors }: PluginLoadResult =
await loadAllPlugins()
// Detect delisted plugins, auto-uninstall them, and record as flagged.
await detectAndUninstallDelistedPlugins()
@@ -189,9 +190,17 @@ export function useManagePlugins({
if (!p.hooksConfig) return sum
return (
sum +
(Object.values(p.hooksConfig) as Array<Array<{ hooks: unknown[] }> | undefined>).reduce(
(
Object.values(p.hooksConfig) as Array<
Array<{ hooks: unknown[] }> | undefined
>
).reduce(
(s, matchers) =>
s + (matchers?.reduce((h: number, m: { hooks: unknown[] }) => h + m.hooks.length, 0) ?? 0),
s +
(matchers?.reduce(
(h: number, m: { hooks: unknown[] }) => h + m.hooks.length,
0,
) ?? 0),
0,
)
)

View File

@@ -134,7 +134,10 @@ const MUTED_DROPPABLE_TYPES = new Set([
* Centralized mute check used by both attachPipeEntryEmitter and
* useMasterMonitor's inline handler — keeps the two gates in sync.
*/
export function shouldDropMutedMessage(slaveName: string, msgType: string): boolean {
export function shouldDropMutedMessage(
slaveName: string,
msgType: string,
): boolean {
if (hasSendOverride(slaveName)) return false
if (!isMasterPipeMuted(slaveName)) return false
return MUTED_DROPPABLE_TYPES.has(msgType)
@@ -193,7 +196,8 @@ function attachPipeEntryEmitter(name: string, client: PipeClient): void {
data: JSON.stringify({
requestId: payload.requestId,
behavior: 'deny',
feedback: 'Permission auto-denied: pipe is logically disconnected.',
feedback:
'Permission auto-denied: pipe is logically disconnected.',
}),
})
}
@@ -205,7 +209,10 @@ function attachPipeEntryEmitter(name: string, client: PipeClient): void {
}
// Clear /send override when slave turn completes
if ((msg.type === 'done' || msg.type === 'error') && hasSendOverride(name)) {
if (
(msg.type === 'done' || msg.type === 'error') &&
hasSendOverride(name)
) {
removeSendOverride(name)
}
@@ -222,7 +229,9 @@ function emitSlaveClientRegistryChanged(): void {
}
}
export function subscribeToSlaveClientRegistry(listener: () => void): () => void {
export function subscribeToSlaveClientRegistry(
listener: () => void,
): () => void {
_slaveClientRegistryListeners.add(listener)
return () => {
_slaveClientRegistryListeners.delete(listener)
@@ -315,7 +324,10 @@ export function useMasterMonitor(): void {
}
// Clear /send override when slave turn completes
if ((msg.type === 'done' || msg.type === 'error') && hasSendOverride(slaveName)) {
if (
(msg.type === 'done' || msg.type === 'error') &&
hasSendOverride(slaveName)
) {
removeSendOverride(slaveName)
}

View File

@@ -1,9 +1,9 @@
import * as React from 'react'
import type { Notification } from '../context/notifications.js'
import { Text } from '@anthropic/ink'
import { logForDebugging } from '../utils/debug.js'
import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js'
import { useStartupNotification } from './notifs/useStartupNotification.js'
import * as React from 'react';
import type { Notification } from '../context/notifications.js';
import { Text } from '@anthropic/ink';
import { logForDebugging } from '../utils/debug.js';
import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js';
import { useStartupNotification } from './notifs/useStartupNotification.js';
/**
* Hook that handles official marketplace auto-installation and shows
@@ -11,49 +11,36 @@ import { useStartupNotification } from './notifs/useStartupNotification.js'
*/
export function useOfficialMarketplaceNotification(): void {
useStartupNotification(async () => {
const result = await checkAndInstallOfficialMarketplace()
const notifs: Notification[] = []
const result = await checkAndInstallOfficialMarketplace();
const notifs: Notification[] = [];
// Check for config save failure first - this is critical
if (result.configSaveFailed) {
logForDebugging('Showing marketplace config save failure notification')
logForDebugging('Showing marketplace config save failure notification');
notifs.push({
key: 'marketplace-config-save-failed',
jsx: (
<Text color="error">
Failed to save marketplace retry info · Check ~/.claude.json
permissions
</Text>
),
jsx: <Text color="error">Failed to save marketplace retry info · Check ~/.claude.json permissions</Text>,
priority: 'immediate',
timeoutMs: 10000,
})
});
}
if (result.installed) {
logForDebugging('Showing marketplace installation success notification')
logForDebugging('Showing marketplace installation success notification');
notifs.push({
key: 'marketplace-installed',
jsx: (
<Text color="success">
Anthropic marketplace installed · /plugin to see available plugins
</Text>
),
jsx: <Text color="success"> Anthropic marketplace installed · /plugin to see available plugins</Text>,
priority: 'immediate',
timeoutMs: 7000,
})
});
} else if (result.skipped && result.reason === 'unknown') {
logForDebugging('Showing marketplace installation failure notification')
logForDebugging('Showing marketplace installation failure notification');
notifs.push({
key: 'marketplace-install-failed',
jsx: (
<Text color="warning">
Failed to install Anthropic marketplace · Will retry on next startup
</Text>
),
jsx: <Text color="warning">Failed to install Anthropic marketplace · Will retry on next startup</Text>,
priority: 'immediate',
timeoutMs: 8000,
})
});
}
// Don't show notifications for:
// - already_installed (user already has it)
@@ -62,6 +49,6 @@ export function useOfficialMarketplaceNotification(): void {
// - git_unavailable (marketplace is a nice-to-have; if git is missing
// or is a non-functional macOS xcrun shim, retry silently on backoff
// rather than nagging — the user will sort git out for other reasons)
return notifs
})
return notifs;
});
}

View File

@@ -505,7 +505,7 @@ export function usePipeIpc({
// --- Phase 3: LAN beacon ---
if (feature('LAN_PIPES') && server.tcpAddress) {
const beacon = new (lb.LanBeacon)({
const beacon = new lb.LanBeacon({
pipeName,
machineId: machId,
hostname: host,

View File

@@ -26,7 +26,9 @@ import {
} from './useMasterMonitor.js'
type UsePipeMuteSyncDeps = {
setToolUseConfirmQueue: (action: React.SetStateAction<Record<string, unknown>[]>) => void
setToolUseConfirmQueue: (
action: React.SetStateAction<Record<string, unknown>[]>,
) => void
}
export function usePipeMuteSync({
@@ -99,7 +101,9 @@ export function usePipeMuteSync({
// onAbort may throw if client disconnected — safe to ignore
}
}
return queue.filter((item: Record<string, unknown>) => item.pipeName !== name)
return queue.filter(
(item: Record<string, unknown>) => item.pipeName !== name,
)
})
// Send relay_mute to slave
@@ -129,7 +133,13 @@ export function usePipeMuteSync({
}
prevMutedRef.current = nextMuted
}, [routeMode, selectedPipes, registryVersion, sendOverrideVersion, setToolUseConfirmQueue])
}, [
routeMode,
selectedPipes,
registryVersion,
sendOverrideVersion,
setToolUseConfirmQueue,
])
// Cleanup on unmount: clear all master-side mute state
useEffect(() => {

View File

@@ -23,20 +23,17 @@ export type PipeRelayHandle = {
export function usePipeRelay(): PipeRelayHandle {
const pipeReturnHadErrorRef = useRef(false)
const relayPipeMessage = useCallback(
(message: PipeMessage): boolean => {
const relay = getPipeRelay()
if (typeof relay !== 'function') {
return false
}
if (isRelayMuted()) {
return false
}
relay(message)
return true
},
[],
)
const relayPipeMessage = useCallback((message: PipeMessage): boolean => {
const relay = getPipeRelay()
if (typeof relay !== 'function') {
return false
}
if (isRelayMuted()) {
return false
}
relay(message)
return true
}, [])
return { relayPipeMessage, pipeReturnHadErrorRef }
}

View File

@@ -4,16 +4,16 @@
* and success/failure notification JSX so new sources stay small.
*/
import figures from 'figures'
import * as React from 'react'
import { getIsRemoteMode } from '../bootstrap/state.js'
import type { useNotifications } from '../context/notifications.js'
import { Text } from '@anthropic/ink'
import { logError } from '../utils/log.js'
import { getPluginById } from '../utils/plugins/marketplaceManager.js'
import figures from 'figures';
import * as React from 'react';
import { getIsRemoteMode } from '../bootstrap/state.js';
import type { useNotifications } from '../context/notifications.js';
import { Text } from '@anthropic/ink';
import { logError } from '../utils/log.js';
import { getPluginById } from '../utils/plugins/marketplaceManager.js';
type AddNotification = ReturnType<typeof useNotifications>['addNotification']
type PluginData = NonNullable<Awaited<ReturnType<typeof getPluginById>>>
type AddNotification = ReturnType<typeof useNotifications>['addNotification'];
type PluginData = NonNullable<Awaited<ReturnType<typeof getPluginById>>>;
/**
* Call tryResolve inside a useEffect; it applies standard gates (remote
@@ -22,38 +22,35 @@ type PluginData = NonNullable<Awaited<ReturnType<typeof getPluginById>>>
* identity tracks recommendation, so clearing re-triggers resolution.
*/
export function usePluginRecommendationBase<T>(): {
recommendation: T | null
clearRecommendation: () => void
tryResolve: (resolve: () => Promise<T | null>) => void
recommendation: T | null;
clearRecommendation: () => void;
tryResolve: (resolve: () => Promise<T | null>) => void;
} {
const [recommendation, setRecommendation] = React.useState<T | null>(null)
const isCheckingRef = React.useRef(false)
const [recommendation, setRecommendation] = React.useState<T | null>(null);
const isCheckingRef = React.useRef(false);
const tryResolve = React.useCallback(
(resolve: () => Promise<T | null>) => {
if (getIsRemoteMode()) return
if (recommendation) return
if (isCheckingRef.current) return
if (getIsRemoteMode()) return;
if (recommendation) return;
if (isCheckingRef.current) return;
isCheckingRef.current = true
isCheckingRef.current = true;
void resolve()
.then(rec => {
if (rec) setRecommendation(rec)
if (rec) setRecommendation(rec);
})
.catch(logError)
.finally(() => {
isCheckingRef.current = false
})
isCheckingRef.current = false;
});
},
[recommendation],
)
);
const clearRecommendation = React.useCallback(
() => setRecommendation(null),
[],
)
const clearRecommendation = React.useCallback(() => setRecommendation(null), []);
return { recommendation, clearRecommendation, tryResolve }
return { recommendation, clearRecommendation, tryResolve };
}
/** Look up plugin, run install(), emit standard success/failure notification. */
@@ -65,11 +62,11 @@ export async function installPluginAndNotify(
install: (pluginData: PluginData) => Promise<void>,
): Promise<void> {
try {
const pluginData = await getPluginById(pluginId)
const pluginData = await getPluginById(pluginId);
if (!pluginData) {
throw new Error(`Plugin ${pluginId} not found in marketplace`)
throw new Error(`Plugin ${pluginId} not found in marketplace`);
}
await install(pluginData)
await install(pluginData);
addNotification({
key: `${keyPrefix}-installed`,
jsx: (
@@ -79,14 +76,14 @@ export async function installPluginAndNotify(
),
priority: 'immediate',
timeoutMs: 5000,
})
});
} catch (error) {
logError(error)
logError(error);
addNotification({
key: `${keyPrefix}-install-failed`,
jsx: <Text color="error">Failed to install {pluginName}</Text>,
priority: 'immediate',
timeoutMs: 5000,
})
});
}
}

View File

@@ -1,19 +1,13 @@
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
import { useEffect, useRef } from 'react'
import { logError } from 'src/utils/log.js'
import { z } from 'zod/v4'
import { callIdeRpc } from '../services/mcp/client.js'
import type {
ConnectedMCPServer,
MCPServerConnection,
} from '../services/mcp/types.js'
import type { PermissionMode } from '../types/permissions.js'
import {
CLAUDE_IN_CHROME_MCP_SERVER_NAME,
isTrackedClaudeInChromeTabId,
} from '../utils/claudeInChrome/common.js'
import { lazySchema } from '../utils/lazySchema.js'
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs';
import { useEffect, useRef } from 'react';
import { logError } from 'src/utils/log.js';
import { z } from 'zod/v4';
import { callIdeRpc } from '../services/mcp/client.js';
import type { ConnectedMCPServer, MCPServerConnection } from '../services/mcp/types.js';
import type { PermissionMode } from '../types/permissions.js';
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isTrackedClaudeInChromeTabId } from '../utils/claudeInChrome/common.js';
import { lazySchema } from '../utils/lazySchema.js';
import { enqueuePendingNotification } from '../utils/messageQueueManager.js';
// Schema for the prompt notification from Chrome extension (JSON-RPC 2.0 format)
const ClaudeInChromePromptNotificationSchema = lazySchema(() =>
@@ -24,19 +18,14 @@ const ClaudeInChromePromptNotificationSchema = lazySchema(() =>
image: z
.object({
type: z.literal('base64'),
media_type: z.enum([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
]),
media_type: z.enum(['image/jpeg', 'image/png', 'image/gif', 'image/webp']),
data: z.string(),
})
.optional(),
tabId: z.number().optional(),
}),
}),
)
);
/**
* A hook that listens for prompt notifications from the Claude for Chrome extension,
@@ -46,84 +35,72 @@ export function usePromptsFromClaudeInChrome(
mcpClients: MCPServerConnection[],
toolPermissionMode: PermissionMode,
): void {
const mcpClientRef = useRef<ConnectedMCPServer | undefined>(undefined)
const mcpClientRef = useRef<ConnectedMCPServer | undefined>(undefined);
useEffect(() => {
if (process.env.USER_TYPE !== 'ant') {
return
return;
}
const mcpClient = findChromeClient(mcpClients)
const mcpClient = findChromeClient(mcpClients);
if (mcpClientRef.current !== mcpClient) {
mcpClientRef.current = mcpClient
mcpClientRef.current = mcpClient;
}
if (mcpClient) {
mcpClient.client.setNotificationHandler(
ClaudeInChromePromptNotificationSchema(),
notification => {
if (mcpClientRef.current !== mcpClient) {
return
}
const { tabId, prompt, image } = notification.params
mcpClient.client.setNotificationHandler(ClaudeInChromePromptNotificationSchema(), notification => {
if (mcpClientRef.current !== mcpClient) {
return;
}
const { tabId, prompt, image } = notification.params;
// Process notifications from tabs we're tracking since notifications are broadcasted
if (
typeof tabId !== 'number' ||
!isTrackedClaudeInChromeTabId(tabId)
) {
return
}
// Process notifications from tabs we're tracking since notifications are broadcasted
if (typeof tabId !== 'number' || !isTrackedClaudeInChromeTabId(tabId)) {
return;
}
try {
// Build content blocks if there's an image, otherwise just use the prompt string
if (image) {
const contentBlocks: ContentBlockParam[] = [
{ type: 'text', text: prompt },
{
type: 'image',
source: {
type: image.type,
media_type: image.media_type,
data: image.data,
},
try {
// Build content blocks if there's an image, otherwise just use the prompt string
if (image) {
const contentBlocks: ContentBlockParam[] = [
{ type: 'text', text: prompt },
{
type: 'image',
source: {
type: image.type,
media_type: image.media_type,
data: image.data,
},
]
enqueuePendingNotification({
value: contentBlocks,
mode: 'prompt',
})
} else {
enqueuePendingNotification({ value: prompt, mode: 'prompt' })
}
} catch (error) {
logError(error as Error)
},
];
enqueuePendingNotification({
value: contentBlocks,
mode: 'prompt',
});
} else {
enqueuePendingNotification({ value: prompt, mode: 'prompt' });
}
},
)
} catch (error) {
logError(error as Error);
}
});
}
}, [mcpClients])
}, [mcpClients]);
// Sync permission mode with Chrome extension whenever it changes
useEffect(() => {
const chromeClient = findChromeClient(mcpClients)
if (!chromeClient) return
const chromeClient = findChromeClient(mcpClients);
if (!chromeClient) return;
const chromeMode =
toolPermissionMode === 'bypassPermissions'
? 'skip_all_permission_checks'
: 'ask'
const chromeMode = toolPermissionMode === 'bypassPermissions' ? 'skip_all_permission_checks' : 'ask';
void callIdeRpc('set_permission_mode', { mode: chromeMode }, chromeClient)
}, [mcpClients, toolPermissionMode])
void callIdeRpc('set_permission_mode', { mode: chromeMode }, chromeClient);
}, [mcpClients, toolPermissionMode]);
}
function findChromeClient(
clients: MCPServerConnection[],
): ConnectedMCPServer | undefined {
function findChromeClient(clients: MCPServerConnection[]): ConnectedMCPServer | undefined {
return clients.find(
(client): client is ConnectedMCPServer =>
client.type === 'connected' &&
client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME,
)
client.type === 'connected' && client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME,
);
}

View File

@@ -20,7 +20,10 @@ import type { AppState } from '../state/AppStateStore.js'
import type { Tool } from '../Tool.js'
import { findToolByName } from '../Tool.js'
import type { Message as MessageType } from '../types/message.js'
import type { PermissionAskDecision, PermissionUpdate } from '../types/permissions.js'
import type {
PermissionAskDecision,
PermissionUpdate,
} from '../types/permissions.js'
import { logForDebugging } from '../utils/debug.js'
import { truncateToWidth } from '../utils/format.js'
import {
@@ -156,9 +159,11 @@ export function useRemoteSession({
const manager = new RemoteSessionManager(config, {
onMessage: sdkMessage => {
const parts = [`type=${sdkMessage.type}`]
if ('subtype' in sdkMessage) parts.push(`subtype=${sdkMessage.subtype as string}`)
if ('subtype' in sdkMessage)
parts.push(`subtype=${sdkMessage.subtype as string}`)
if (sdkMessage.type === 'user') {
const c = (sdkMessage.message as { content?: unknown } | undefined)?.content
const c = (sdkMessage.message as { content?: unknown } | undefined)
?.content
parts.push(
`content=${Array.isArray(c) ? c.map(b => b.type).join(',') : typeof c}`,
)
@@ -249,7 +254,9 @@ export function useRemoteSession({
// and inProcessRunner.ts; without this the set grows unbounded for the
// session lifetime (BQ: CCR cohort shows 5.2x higher RSS slope).
if (setInProgressToolUseIDs && sdkMessage.type === 'user') {
const content = (sdkMessage.message as { content?: unknown } | undefined)?.content
const content = (
sdkMessage.message as { content?: unknown } | undefined
)?.content
if (Array.isArray(content)) {
const resultIds: string[] = []
for (const block of content) {
@@ -291,7 +298,9 @@ export function useRemoteSession({
setInProgressToolUseIDs &&
converted.message.type === 'assistant'
) {
const contentArr = Array.isArray(converted.message.message?.content) ? converted.message.message.content : []
const contentArr = Array.isArray(converted.message.message?.content)
? converted.message.message.content
: []
const toolUseIds = contentArr
.filter(block => block.type === 'tool_use')
.map(block => (block as { id: string }).id)

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,10 @@ import {
isSessionEndMessage,
} from '../remote/sdkMessageAdapter.js'
import type { SSHSession } from '../ssh/createSSHSession.js'
import type { SSHSessionManager, SSHPermissionRequest } from '../ssh/SSHSessionManager.js'
import type {
SSHSessionManager,
SSHPermissionRequest,
} from '../ssh/SSHSessionManager.js'
import type { Tool } from '../Tool.js'
import { findToolByName } from '../Tool.js'
import type { Message as MessageType } from '../types/message.js'
@@ -98,7 +101,9 @@ export function useSSHSession({
createToolStub(request.tool_name)
const syntheticMessage = createSyntheticAssistantMessage(
request as unknown as Parameters<typeof createSyntheticAssistantMessage>[0],
request as unknown as Parameters<
typeof createSyntheticAssistantMessage
>[0],
requestId,
)

View File

@@ -146,10 +146,9 @@ export function useScheduledTasks({
store.getState().tasks,
)
if (teammate && !isTerminalTaskStatus(teammate.status)) {
const command = await createScheduledTaskQueuedCommand(
task,
{ shouldCreate: () => !disposed },
)
const command = await createScheduledTaskQueuedCommand(task, {
shouldCreate: () => !disposed,
})
if (!command) {
return
}
@@ -189,10 +188,9 @@ export function useScheduledTasks({
return
}
const command = await createScheduledTaskQueuedCommand(
task,
{ shouldCreate: () => !disposed },
)
const command = await createScheduledTaskQueuedCommand(task, {
shouldCreate: () => !disposed,
})
if (!command) {
return
}

View File

@@ -1,72 +1,62 @@
import { useCallback, useState } from 'react'
import { setTeleportedSessionInfo } from 'src/bootstrap/state.js'
import { useCallback, useState } from 'react';
import { setTeleportedSessionInfo } from 'src/bootstrap/state.js';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js'
import type { CodeSession } from 'src/utils/teleport/api.js'
import { errorMessage, TeleportOperationError } from '../utils/errors.js'
import { teleportResumeCodeSession } from '../utils/teleport.js'
} from 'src/services/analytics/index.js';
import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js';
import type { CodeSession } from 'src/utils/teleport/api.js';
import { errorMessage, TeleportOperationError } from '../utils/errors.js';
import { teleportResumeCodeSession } from '../utils/teleport.js';
export type TeleportResumeError = {
message: string
formattedMessage?: string
isOperationError: boolean
}
message: string;
formattedMessage?: string;
isOperationError: boolean;
};
export type TeleportSource = 'cliArg' | 'localCommand'
export type TeleportSource = 'cliArg' | 'localCommand';
export function useTeleportResume(source: TeleportSource) {
const [isResuming, setIsResuming] = useState(false)
const [error, setError] = useState<TeleportResumeError | null>(null)
const [selectedSession, setSelectedSession] = useState<CodeSession | null>(
null,
)
const [isResuming, setIsResuming] = useState(false);
const [error, setError] = useState<TeleportResumeError | null>(null);
const [selectedSession, setSelectedSession] = useState<CodeSession | null>(null);
const resumeSession = useCallback(
async (session: CodeSession): Promise<TeleportRemoteResponse | null> => {
setIsResuming(true)
setError(null)
setSelectedSession(session)
setIsResuming(true);
setError(null);
setSelectedSession(session);
// Log teleport session selection
logEvent('tengu_teleport_resume_session', {
source:
source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
session_id:
session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
source: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
session_id: session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const result = await teleportResumeCodeSession(session.id)
const result = await teleportResumeCodeSession(session.id);
// Track teleported session for reliability logging
setTeleportedSessionInfo({ sessionId: session.id })
setIsResuming(false)
return result
setTeleportedSessionInfo({ sessionId: session.id });
setIsResuming(false);
return result;
} catch (err) {
const teleportError: TeleportResumeError = {
message:
err instanceof TeleportOperationError
? err.message
: errorMessage(err),
formattedMessage:
err instanceof TeleportOperationError
? err.formattedMessage
: undefined,
message: err instanceof TeleportOperationError ? err.message : errorMessage(err),
formattedMessage: err instanceof TeleportOperationError ? err.formattedMessage : undefined,
isOperationError: err instanceof TeleportOperationError,
}
setError(teleportError)
setIsResuming(false)
return null
};
setError(teleportError);
setIsResuming(false);
return null;
}
},
[source],
)
);
const clearError = useCallback(() => {
setError(null)
}, [])
setError(null);
}, []);
return {
resumeSession,
@@ -74,5 +64,5 @@ export function useTeleportResume(source: TeleportSource) {
error,
selectedSession,
clearError,
}
};
}

View File

@@ -1037,12 +1037,7 @@ export function useTypeahead({
// Splice the completed command at the cursor position, preserving
// any text after the cursor (e.g., user typed "/com" before existing text).
const metadata = suggestion.metadata;
if (
metadata &&
typeof metadata === 'object' &&
'name' in metadata &&
'type' in metadata
) {
if (metadata && typeof metadata === 'object' && 'name' in metadata && 'type' in metadata) {
const commandName = getCommandName(metadata as Command);
const replacement = `/${commandName} `;
onInputChange(replacement + input.slice(cursorOffset));

View File

@@ -788,8 +788,14 @@ export function useVoice({
const myAttemptGen = attemptGenRef.current
// Select STT backend based on settings.voiceProvider
const connectFn = isDoubaoProvider()
? (cbs: Parameters<typeof connectDoubaoStream>[0], opts: Parameters<typeof connectDoubaoStream>[1]) => connectDoubaoStream(cbs, opts)
: (cbs: Parameters<typeof connectVoiceStream>[0], opts: Parameters<typeof connectVoiceStream>[1]) => connectVoiceStream(cbs, opts)
? (
cbs: Parameters<typeof connectDoubaoStream>[0],
opts: Parameters<typeof connectDoubaoStream>[1],
) => connectDoubaoStream(cbs, opts)
: (
cbs: Parameters<typeof connectVoiceStream>[0],
opts: Parameters<typeof connectVoiceStream>[1],
) => connectVoiceStream(cbs, opts)
void connectFn(
{
onTranscript: (text: string, isFinal: boolean) => {
@@ -1038,7 +1044,9 @@ export function useVoice({
// delay of ~500ms on macOS).
const handleKeyEvent = useCallback(
(fallbackMs = REPEAT_FALLBACK_MS): void => {
const sttAvailable = isDoubaoProvider() ? isDoubaoAvailableSync() : isVoiceStreamAvailable()
const sttAvailable = isDoubaoProvider()
? isDoubaoAvailableSync()
: isVoiceStreamAvailable()
if (!enabled || !sttAvailable) {
return
}

View File

@@ -1,84 +1,69 @@
import { feature } from 'bun:bundle'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useNotifications } from '../context/notifications.js'
import { useIsModalOverlayActive } from '../context/overlayContext.js'
import {
useGetVoiceState,
useSetVoiceState,
useVoiceState,
} from '../context/voice.js'
import { KeyboardEvent, useInput } from '@anthropic/ink'
import { feature } from 'bun:bundle';
import * as React from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useNotifications } from '../context/notifications.js';
import { useIsModalOverlayActive } from '../context/overlayContext.js';
import { useGetVoiceState, useSetVoiceState, useVoiceState } from '../context/voice.js';
import { KeyboardEvent, useInput } from '@anthropic/ink';
// backward-compat bridge until REPL wires handleKeyDown to <Box onKeyDown>
import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'
import { keystrokesEqual } from '../keybindings/resolver.js'
import type { ParsedKeystroke } from '../keybindings/types.js'
import { normalizeFullWidthSpace } from '../utils/stringUtils.js'
import { useVoiceEnabled } from './useVoiceEnabled.js'
import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js';
import { keystrokesEqual } from '../keybindings/resolver.js';
import type { ParsedKeystroke } from '../keybindings/types.js';
import { normalizeFullWidthSpace } from '../utils/stringUtils.js';
import { useVoiceEnabled } from './useVoiceEnabled.js';
// Dead code elimination: conditional import for voice input hook.
/* eslint-disable @typescript-eslint/no-require-imports */
// Capture the module namespace, not the function: spyOn() mutates the module
// object, so `voiceNs.useVoice(...)` resolves to the spy even if this module
// was loaded before the spy was installed (test ordering independence).
const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature(
'VOICE_MODE',
)
const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature('VOICE_MODE')
? require('./useVoice.js')
: {
useVoice: ({
enabled: _e,
}: {
onTranscript: (t: string) => void
enabled: boolean
}) => ({
useVoice: ({ enabled: _e }: { onTranscript: (t: string) => void; enabled: boolean }) => ({
state: 'idle' as const,
handleKeyEvent: (_fallbackMs?: number) => {},
}),
}
};
/* eslint-enable @typescript-eslint/no-require-imports */
// Maximum gap (ms) between key presses to count as held (auto-repeat).
// Terminal auto-repeat fires every 30-80ms; 120ms covers jitter while
// excluding normal typing speed (100-300ms between keystrokes).
const RAPID_KEY_GAP_MS = 120
const RAPID_KEY_GAP_MS = 120;
// Fallback (ms) for modifier-combo first-press activation. Must match
// FIRST_PRESS_FALLBACK_MS in useVoice.ts. Covers the max OS initial
// key-repeat delay (~2s on macOS with slider at "Long") so holding a
// modifier combo doesn't fragment into two sessions when the first
// auto-repeat arrives after the default 600ms REPEAT_FALLBACK_MS.
const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000
const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000;
// Number of rapid consecutive key events required to activate voice.
// Only applies to bare-char bindings (space, v, etc.) where a single press
// could be normal typing. Modifier combos activate on the first press.
const HOLD_THRESHOLD = 5
const HOLD_THRESHOLD = 5;
// Number of rapid key events to start showing warmup feedback.
const WARMUP_THRESHOLD = 2
const WARMUP_THRESHOLD = 2;
// Match a KeyboardEvent against a ParsedKeystroke. Replaces the legacy
// matchesKeystroke(input, Key, ...) path which assumed useInput's raw
// `input` arg — KeyboardEvent.key holds normalized names (e.g. 'space',
// 'f9') that getKeyName() didn't handle, so modifier combos and f-keys
// silently failed to match after the onKeyDown migration (#23524).
function matchesKeyboardEvent(
e: KeyboardEvent,
target: ParsedKeystroke,
): boolean {
function matchesKeyboardEvent(e: KeyboardEvent, target: ParsedKeystroke): boolean {
// KeyboardEvent stores key names; ParsedKeystroke stores ' ' for space
// and 'enter' for return (see parser.ts case 'space'/'return').
const key =
e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase()
if (key !== target.key) return false
if (e.ctrl !== target.ctrl) return false
if (e.shift !== target.shift) return false
const key = e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase();
if (key !== target.key) return false;
if (e.ctrl !== target.ctrl) return false;
if (e.shift !== target.shift) return false;
// KeyboardEvent.meta folds alt|option (terminal limitation — esc-prefix);
// ParsedKeystroke has both alt and meta as aliases for the same thing.
if (e.meta !== (target.alt || target.meta)) return false
if (e.superKey !== target.super) return false
return true
if (e.meta !== (target.alt || target.meta)) return false;
if (e.superKey !== target.super) return false;
return true;
}
// Hardcoded default for when there's no KeybindingProvider at all (e.g.
@@ -92,60 +77,60 @@ const DEFAULT_VOICE_KEYSTROKE: ParsedKeystroke = {
shift: false,
meta: false,
super: false,
}
};
type InsertTextHandle = {
insert: (text: string) => void
setInputWithCursor: (value: string, cursor: number) => void
cursorOffset: number
}
insert: (text: string) => void;
setInputWithCursor: (value: string, cursor: number) => void;
cursorOffset: number;
};
type UseVoiceIntegrationArgs = {
setInputValueRaw: React.Dispatch<React.SetStateAction<string>>
inputValueRef: React.RefObject<string>
insertTextRef: React.RefObject<InsertTextHandle | null>
}
setInputValueRaw: React.Dispatch<React.SetStateAction<string>>;
inputValueRef: React.RefObject<string>;
insertTextRef: React.RefObject<InsertTextHandle | null>;
};
type InterimRange = { start: number; end: number }
type InterimRange = { start: number; end: number };
type StripOpts = {
// Which char to strip (the configured hold key). Defaults to space.
char?: string
char?: string;
// Capture the voice prefix/suffix anchor at the stripped position.
anchor?: boolean
anchor?: boolean;
// Minimum trailing count to leave behind — prevents stripping the
// intentional warmup chars when defensively cleaning up leaks.
floor?: number
}
floor?: number;
};
type UseVoiceIntegrationResult = {
// Returns the number of trailing chars remaining after stripping.
stripTrailing: (maxStrip: number, opts?: StripOpts) => number
stripTrailing: (maxStrip: number, opts?: StripOpts) => number;
// Undo the gap space and reset anchor refs after a failed voice activation.
resetAnchor: () => void
handleKeyEvent: (fallbackMs?: number) => void
interimRange: InterimRange | null
}
resetAnchor: () => void;
handleKeyEvent: (fallbackMs?: number) => void;
interimRange: InterimRange | null;
};
export function useVoiceIntegration({
setInputValueRaw,
inputValueRef,
insertTextRef,
}: UseVoiceIntegrationArgs): UseVoiceIntegrationResult {
const { addNotification } = useNotifications()
const { addNotification } = useNotifications();
// Tracks the input content before/after the cursor when voice starts,
// so interim transcripts can be inserted at the cursor position without
// clobbering surrounding user text.
const voicePrefixRef = useRef<string | null>(null)
const voiceSuffixRef = useRef<string>('')
const voicePrefixRef = useRef<string | null>(null);
const voiceSuffixRef = useRef<string>('');
// Tracks the last input value this hook wrote (via anchor, interim effect,
// or handleVoiceTranscript). If inputValueRef.current diverges, the user
// submitted or edited — both write paths bail to avoid clobbering. This is
// the only guard that correctly handles empty-prefix-empty-suffix: a
// startsWith('')/endsWith('') check vacuously passes, and a length check
// can't distinguish a cleared input from a never-set one.
const lastSetInputRef = useRef<string | null>(null)
const lastSetInputRef = useRef<string | null>(null);
// Strip trailing hold-key chars (and optionally capture the voice
// anchor). Called during warmup (to clean up chars that leaked past
@@ -160,29 +145,22 @@ export function useVoiceIntegration({
// trailing chars remaining after stripping. When nothing changes, no
// state update is performed.
const stripTrailing = useCallback(
(
maxStrip: number,
{ char = ' ', anchor = false, floor = 0 }: StripOpts = {},
) => {
const prev = inputValueRef.current
const offset = insertTextRef.current?.cursorOffset ?? prev.length
const beforeCursor = prev.slice(0, offset)
const afterCursor = prev.slice(offset)
(maxStrip: number, { char = ' ', anchor = false, floor = 0 }: StripOpts = {}) => {
const prev = inputValueRef.current;
const offset = insertTextRef.current?.cursorOffset ?? prev.length;
const beforeCursor = prev.slice(0, offset);
const afterCursor = prev.slice(offset);
// When the hold key is space, also count full-width spaces (U+3000)
// that a CJK IME may have inserted for the same physical key.
// U+3000 is BMP single-code-unit so indices align with beforeCursor.
const scan =
char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor
let trailing = 0
while (
trailing < scan.length &&
scan[scan.length - 1 - trailing] === char
) {
trailing++
const scan = char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor;
let trailing = 0;
while (trailing < scan.length && scan[scan.length - 1 - trailing] === char) {
trailing++;
}
const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip))
const remaining = trailing - stripCount
const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount)
const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip));
const remaining = trailing - stripCount;
const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount);
// When anchoring with a non-space suffix, insert a gap space so the
// waveform cursor sits on the gap instead of covering the first
// suffix letter. The interim transcript effect maintains this same
@@ -192,26 +170,26 @@ export function useVoiceIntegration({
// voice (voiceState stayed 'idle'), the cleanup effect didn't fire and
// the old anchor is stale. anchor=true is only passed on the single
// activation call, never during recording, so overwrite is safe.
let gap = ''
let gap = '';
if (anchor) {
voicePrefixRef.current = stripped
voiceSuffixRef.current = afterCursor
voicePrefixRef.current = stripped;
voiceSuffixRef.current = afterCursor;
if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) {
gap = ' '
gap = ' ';
}
}
const newValue = stripped + gap + afterCursor
if (anchor) lastSetInputRef.current = newValue
if (newValue === prev && stripCount === 0) return remaining
const newValue = stripped + gap + afterCursor;
if (anchor) lastSetInputRef.current = newValue;
if (newValue === prev && stripCount === 0) return remaining;
if (insertTextRef.current) {
insertTextRef.current.setInputWithCursor(newValue, stripped.length)
insertTextRef.current.setInputWithCursor(newValue, stripped.length);
} else {
setInputValueRaw(newValue)
setInputValueRaw(newValue);
}
return remaining
return remaining;
},
[setInputValueRaw, inputValueRef, insertTextRef],
)
);
// Undo the gap space inserted by stripTrailing(..., {anchor:true}) and
// reset the voice prefix/suffix refs. Called when voice activation fails
@@ -220,122 +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.
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)
: ''
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,
@@ -346,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,
}
};
}
/**
@@ -406,20 +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()
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
const voiceState = feature('VOICE_MODE')
?
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
@@ -431,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"),
@@ -463,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
@@ -474,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
@@ -490,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).
@@ -512,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
@@ -552,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.
@@ -577,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
@@ -592,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
@@ -631,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) ──
@@ -647,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 →
@@ -691,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;
}