mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user