mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)
* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-2): 格式化 commands (79 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-5): 格式化 components其余 + hooks + tools (232 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md - README.md: 大幅重写,更详细版本历史和配置示例 - Run.ps1: 新增 Windows 启动脚本 - TODO.md: 新增包完成清单 - V6.md: 删除(架构重构规划已不适用) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复以前的问题 * fix: 修复 login 面板的问题 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,59 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import { getOauthProfileFromApiKey } from 'src/services/oauth/getOauthProfile.js';
|
||||
import { isClaudeAISubscriber } from 'src/utils/auth.js';
|
||||
import { Text } from '../../ink.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { useStartupNotification } from './useStartupNotification.js';
|
||||
const MAX_SHOW_COUNT = 3;
|
||||
import * as React from 'react'
|
||||
import { getOauthProfileFromApiKey } from 'src/services/oauth/getOauthProfile.js'
|
||||
import { isClaudeAISubscriber } from 'src/utils/auth.js'
|
||||
import { Text } from '../../ink.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { useStartupNotification } from './useStartupNotification.js'
|
||||
|
||||
const MAX_SHOW_COUNT = 3
|
||||
|
||||
/**
|
||||
* Hook to check if the user has a subscription on Console but isn't logged into it.
|
||||
*/
|
||||
export function useCanSwitchToExistingSubscription() {
|
||||
useStartupNotification(_temp2);
|
||||
export function useCanSwitchToExistingSubscription(): void {
|
||||
useStartupNotification(async () => {
|
||||
if ((getGlobalConfig().subscriptionNoticeCount ?? 0) >= MAX_SHOW_COUNT) {
|
||||
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', {})
|
||||
|
||||
return {
|
||||
key: 'switch-to-subscription',
|
||||
jsx: (
|
||||
<Text color="suggestion">
|
||||
Use your existing Claude {subscriptionType} plan with Claude Code
|
||||
<Text color="text" dimColor>
|
||||
{' '}
|
||||
· /login to activate
|
||||
</Text>
|
||||
</Text>
|
||||
),
|
||||
priority: 'low',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has a subscription but is not currently logged into it.
|
||||
* This helps inform users they should run /login to access their subscription.
|
||||
*/
|
||||
async function _temp2() {
|
||||
if ((getGlobalConfig().subscriptionNoticeCount ?? 0) >= MAX_SHOW_COUNT) {
|
||||
return null;
|
||||
}
|
||||
const subscriptionType = await getExistingClaudeSubscription();
|
||||
if (subscriptionType === null) {
|
||||
return null;
|
||||
}
|
||||
saveGlobalConfig(_temp);
|
||||
logEvent("tengu_switch_to_subscription_notice_shown", {});
|
||||
return {
|
||||
key: "switch-to-subscription",
|
||||
jsx: <Text color="suggestion">Use your existing Claude {subscriptionType} plan with Claude Code<Text color="text" dimColor={true}>{" "}· /login to activate</Text></Text>,
|
||||
priority: "low"
|
||||
};
|
||||
}
|
||||
function _temp(current) {
|
||||
return {
|
||||
...current,
|
||||
subscriptionNoticeCount: (current.subscriptionNoticeCount ?? 0) + 1
|
||||
};
|
||||
}
|
||||
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,43 +1,30 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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) {
|
||||
const $ = _c(4);
|
||||
const {
|
||||
addNotification
|
||||
} = useNotifications();
|
||||
const lastWarningRef = useRef(null);
|
||||
let t0;
|
||||
let t1;
|
||||
if ($[0] !== addNotification || $[1] !== model) {
|
||||
t0 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
const deprecationWarning = getModelDeprecationWarning(model);
|
||||
if (deprecationWarning && deprecationWarning !== lastWarningRef.current) {
|
||||
lastWarningRef.current = deprecationWarning;
|
||||
addNotification({
|
||||
key: "model-deprecation-warning",
|
||||
text: deprecationWarning,
|
||||
color: "warning",
|
||||
priority: "high"
|
||||
});
|
||||
}
|
||||
if (!deprecationWarning) {
|
||||
lastWarningRef.current = null;
|
||||
}
|
||||
};
|
||||
t1 = [model, addNotification];
|
||||
$[0] = addNotification;
|
||||
$[1] = model;
|
||||
$[2] = t0;
|
||||
$[3] = t1;
|
||||
} else {
|
||||
t0 = $[2];
|
||||
t1 = $[3];
|
||||
}
|
||||
useEffect(t0, t1);
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
addNotification({
|
||||
key: 'model-deprecation-warning',
|
||||
text: deprecationWarning,
|
||||
color: 'warning',
|
||||
priority: 'high',
|
||||
})
|
||||
}
|
||||
|
||||
// Reset tracking if model changes to non-deprecated
|
||||
if (!deprecationWarning) {
|
||||
lastWarningRef.current = null
|
||||
}
|
||||
}, [model, addNotification])
|
||||
}
|
||||
|
||||
@@ -1,161 +1,111 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import { useEffect } from 'react';
|
||||
import { useNotifications } from 'src/context/notifications.js';
|
||||
import { useAppState, useSetAppState } from 'src/state/AppState.js';
|
||||
import { type CooldownReason, isFastModeEnabled, onCooldownExpired, onCooldownTriggered, onFastModeOverageRejection, onOrgFastModeChanged } 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';
|
||||
export function useFastModeNotification() {
|
||||
const $ = _c(13);
|
||||
const {
|
||||
addNotification
|
||||
} = useNotifications();
|
||||
const isFastMode = useAppState(_temp);
|
||||
const setAppState = useSetAppState();
|
||||
let t0;
|
||||
let t1;
|
||||
if ($[0] !== addNotification || $[1] !== isFastMode || $[2] !== setAppState) {
|
||||
t0 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (!isFastModeEnabled()) {
|
||||
return;
|
||||
}
|
||||
return onOrgFastModeChanged(orgEnabled => {
|
||||
if (orgEnabled) {
|
||||
addNotification({
|
||||
key: ORG_CHANGED_KEY,
|
||||
color: "fastMode",
|
||||
priority: "immediate",
|
||||
text: "Fast mode is now available \xB7 /fast to turn on"
|
||||
});
|
||||
} else {
|
||||
if (isFastMode) {
|
||||
setAppState(_temp2);
|
||||
addNotification({
|
||||
key: ORG_CHANGED_KEY,
|
||||
color: "warning",
|
||||
priority: "immediate",
|
||||
text: "Fast mode has been disabled by your organization"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
t1 = [addNotification, isFastMode, setAppState];
|
||||
$[0] = addNotification;
|
||||
$[1] = isFastMode;
|
||||
$[2] = setAppState;
|
||||
$[3] = t0;
|
||||
$[4] = t1;
|
||||
} else {
|
||||
t0 = $[3];
|
||||
t1 = $[4];
|
||||
}
|
||||
useEffect(t0, t1);
|
||||
let t2;
|
||||
let t3;
|
||||
if ($[5] !== addNotification || $[6] !== setAppState) {
|
||||
t2 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (!isFastModeEnabled()) {
|
||||
return;
|
||||
}
|
||||
return onFastModeOverageRejection(message => {
|
||||
setAppState(_temp3);
|
||||
import { useEffect } from 'react'
|
||||
import { useNotifications } from 'src/context/notifications.js'
|
||||
import { useAppState, useSetAppState } from 'src/state/AppState.js'
|
||||
import {
|
||||
type CooldownReason,
|
||||
isFastModeEnabled,
|
||||
onCooldownExpired,
|
||||
onCooldownTriggered,
|
||||
onFastModeOverageRejection,
|
||||
onOrgFastModeChanged,
|
||||
} 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'
|
||||
|
||||
export function useFastModeNotification(): void {
|
||||
const { addNotification } = useNotifications()
|
||||
const isFastMode = useAppState(s => s.fastMode)
|
||||
const setAppState = useSetAppState()
|
||||
|
||||
// Notify when org fast mode status changes
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (!isFastModeEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
return onOrgFastModeChanged(orgEnabled => {
|
||||
if (orgEnabled) {
|
||||
addNotification({
|
||||
key: OVERAGE_REJECTED_KEY,
|
||||
color: "warning",
|
||||
priority: "immediate",
|
||||
text: message
|
||||
});
|
||||
});
|
||||
};
|
||||
t3 = [addNotification, setAppState];
|
||||
$[5] = addNotification;
|
||||
$[6] = setAppState;
|
||||
$[7] = t2;
|
||||
$[8] = t3;
|
||||
} else {
|
||||
t2 = $[7];
|
||||
t3 = $[8];
|
||||
}
|
||||
useEffect(t2, t3);
|
||||
let t4;
|
||||
let t5;
|
||||
if ($[9] !== addNotification || $[10] !== isFastMode) {
|
||||
t4 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (!isFastMode) {
|
||||
return;
|
||||
}
|
||||
const unsubTriggered = onCooldownTriggered((resetAt, reason) => {
|
||||
const resetIn = formatDuration(resetAt - Date.now(), {
|
||||
hideTrailingZeros: true
|
||||
});
|
||||
const message_0 = getCooldownMessage(reason, resetIn);
|
||||
key: ORG_CHANGED_KEY,
|
||||
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 }))
|
||||
addNotification({
|
||||
key: COOLDOWN_STARTED_KEY,
|
||||
invalidates: [COOLDOWN_EXPIRED_KEY],
|
||||
text: message_0,
|
||||
color: "warning",
|
||||
priority: "immediate"
|
||||
});
|
||||
});
|
||||
const unsubExpired = onCooldownExpired(() => {
|
||||
addNotification({
|
||||
key: COOLDOWN_EXPIRED_KEY,
|
||||
invalidates: [COOLDOWN_STARTED_KEY],
|
||||
color: "fastMode",
|
||||
text: "Fast limit reset \xB7 now using fast mode",
|
||||
priority: "immediate"
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
unsubTriggered();
|
||||
unsubExpired();
|
||||
};
|
||||
};
|
||||
t5 = [addNotification, isFastMode];
|
||||
$[9] = addNotification;
|
||||
$[10] = isFastMode;
|
||||
$[11] = t4;
|
||||
$[12] = t5;
|
||||
} else {
|
||||
t4 = $[11];
|
||||
t5 = $[12];
|
||||
}
|
||||
useEffect(t4, t5);
|
||||
}
|
||||
function _temp3(prev_0) {
|
||||
return {
|
||||
...prev_0,
|
||||
fastMode: false
|
||||
};
|
||||
}
|
||||
function _temp2(prev) {
|
||||
return {
|
||||
...prev,
|
||||
fastMode: false
|
||||
};
|
||||
}
|
||||
function _temp(s) {
|
||||
return s.fastMode;
|
||||
key: ORG_CHANGED_KEY,
|
||||
color: 'warning',
|
||||
priority: 'immediate',
|
||||
text: 'Fast mode has been disabled by your organization',
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [addNotification, isFastMode, setAppState])
|
||||
|
||||
// Notify when fast mode is rejected due to overage/extra usage issues
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (!isFastModeEnabled()) return
|
||||
|
||||
return onFastModeOverageRejection(message => {
|
||||
setAppState(prev => ({ ...prev, fastMode: false }))
|
||||
addNotification({
|
||||
key: OVERAGE_REJECTED_KEY,
|
||||
color: 'warning',
|
||||
priority: 'immediate',
|
||||
text: message,
|
||||
})
|
||||
})
|
||||
}, [addNotification, setAppState])
|
||||
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (!isFastMode) {
|
||||
return
|
||||
}
|
||||
|
||||
const unsubTriggered = onCooldownTriggered((resetAt, reason) => {
|
||||
const resetIn = formatDuration(resetAt - Date.now(), {
|
||||
hideTrailingZeros: true,
|
||||
})
|
||||
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,
|
||||
invalidates: [COOLDOWN_STARTED_KEY],
|
||||
color: 'fastMode',
|
||||
text: `Fast limit reset · now using fast mode`,
|
||||
priority: 'immediate',
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
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,185 +1,159 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useNotifications } from 'src/context/notifications.js';
|
||||
import { Text } from 'src/ink.js';
|
||||
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;
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useNotifications } from 'src/context/notifications.js'
|
||||
import { Text } from 'src/ink.js'
|
||||
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
|
||||
|
||||
type Props = {
|
||||
ideInstallationStatus: IDEExtensionInstallationStatus | null;
|
||||
ideSelection: IDESelection | undefined;
|
||||
mcpClients: MCPServerConnection[];
|
||||
};
|
||||
export function useIDEStatusIndicator(t0) {
|
||||
const $ = _c(26);
|
||||
const {
|
||||
ideSelection,
|
||||
mcpClients,
|
||||
ideInstallationStatus
|
||||
} = t0;
|
||||
const {
|
||||
addNotification,
|
||||
removeNotification
|
||||
} = useNotifications();
|
||||
const {
|
||||
status: ideStatus,
|
||||
ideName
|
||||
} = useIdeConnectionStatus(mcpClients);
|
||||
const hasShownHintRef = useRef(false);
|
||||
let t1;
|
||||
if ($[0] !== ideInstallationStatus) {
|
||||
t1 = ideInstallationStatus ? isJetBrainsIde(ideInstallationStatus?.ideType) : false;
|
||||
$[0] = ideInstallationStatus;
|
||||
$[1] = t1;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
}
|
||||
const isJetBrains = t1;
|
||||
const showIDEInstallErrorOrJetBrainsInfo = ideInstallationStatus?.error || isJetBrains;
|
||||
const shouldShowIdeSelection = ideStatus === "connected" && (ideSelection?.filePath || ideSelection?.text && ideSelection.lineCount > 0);
|
||||
const shouldShowConnected = ideStatus === "connected" && !shouldShowIdeSelection;
|
||||
const showIDEInstallError = showIDEInstallErrorOrJetBrainsInfo && !isJetBrains && !shouldShowConnected && !shouldShowIdeSelection;
|
||||
const showJetBrainsInfo = showIDEInstallErrorOrJetBrainsInfo && isJetBrains && !shouldShowConnected && !shouldShowIdeSelection;
|
||||
let t2;
|
||||
let t3;
|
||||
if ($[2] !== addNotification || $[3] !== ideStatus || $[4] !== removeNotification || $[5] !== showJetBrainsInfo) {
|
||||
t2 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (isSupportedTerminal() || ideStatus !== null || showJetBrainsInfo) {
|
||||
removeNotification("ide-status-hint");
|
||||
return;
|
||||
}
|
||||
if (hasShownHintRef.current || (getGlobalConfig().ideHintShownCount ?? 0) >= MAX_IDE_HINT_SHOW_COUNT) {
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(_temp2, 3000, hasShownHintRef, addNotification);
|
||||
return () => clearTimeout(timeoutId);
|
||||
};
|
||||
t3 = [addNotification, removeNotification, ideStatus, showJetBrainsInfo];
|
||||
$[2] = addNotification;
|
||||
$[3] = ideStatus;
|
||||
$[4] = removeNotification;
|
||||
$[5] = showJetBrainsInfo;
|
||||
$[6] = t2;
|
||||
$[7] = t3;
|
||||
} else {
|
||||
t2 = $[6];
|
||||
t3 = $[7];
|
||||
}
|
||||
useEffect(t2, t3);
|
||||
let t4;
|
||||
let t5;
|
||||
if ($[8] !== addNotification || $[9] !== ideName || $[10] !== ideStatus || $[11] !== removeNotification || $[12] !== showIDEInstallError || $[13] !== showJetBrainsInfo) {
|
||||
t4 = () => {
|
||||
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"
|
||||
});
|
||||
};
|
||||
t5 = [addNotification, removeNotification, ideStatus, ideName, showIDEInstallError, showJetBrainsInfo];
|
||||
$[8] = addNotification;
|
||||
$[9] = ideName;
|
||||
$[10] = ideStatus;
|
||||
$[11] = removeNotification;
|
||||
$[12] = showIDEInstallError;
|
||||
$[13] = showJetBrainsInfo;
|
||||
$[14] = t4;
|
||||
$[15] = t5;
|
||||
} else {
|
||||
t4 = $[14];
|
||||
t5 = $[15];
|
||||
}
|
||||
useEffect(t4, t5);
|
||||
let t6;
|
||||
let t7;
|
||||
if ($[16] !== addNotification || $[17] !== removeNotification || $[18] !== showJetBrainsInfo) {
|
||||
t6 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (!showJetBrainsInfo) {
|
||||
removeNotification("ide-status-jetbrains-disconnected");
|
||||
return;
|
||||
}
|
||||
addNotification({
|
||||
key: "ide-status-jetbrains-disconnected",
|
||||
text: "IDE plugin not connected \xB7 /status for info",
|
||||
priority: "medium"
|
||||
});
|
||||
};
|
||||
t7 = [addNotification, removeNotification, showJetBrainsInfo];
|
||||
$[16] = addNotification;
|
||||
$[17] = removeNotification;
|
||||
$[18] = showJetBrainsInfo;
|
||||
$[19] = t6;
|
||||
$[20] = t7;
|
||||
} else {
|
||||
t6 = $[19];
|
||||
t7 = $[20];
|
||||
}
|
||||
useEffect(t6, t7);
|
||||
let t8;
|
||||
let t9;
|
||||
if ($[21] !== addNotification || $[22] !== removeNotification || $[23] !== showIDEInstallError) {
|
||||
t8 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (!showIDEInstallError) {
|
||||
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"
|
||||
});
|
||||
};
|
||||
t9 = [addNotification, removeNotification, showIDEInstallError];
|
||||
$[21] = addNotification;
|
||||
$[22] = removeNotification;
|
||||
$[23] = showIDEInstallError;
|
||||
$[24] = t8;
|
||||
$[25] = t9;
|
||||
} else {
|
||||
t8 = $[24];
|
||||
t9 = $[25];
|
||||
}
|
||||
useEffect(t8, t9);
|
||||
ideInstallationStatus: IDEExtensionInstallationStatus | null
|
||||
ideSelection: IDESelection | undefined
|
||||
mcpClients: MCPServerConnection[]
|
||||
}
|
||||
function _temp2(hasShownHintRef_0, addNotification_0) {
|
||||
detectIDEs(true).then(infos => {
|
||||
const ideName_0 = infos[0]?.name;
|
||||
if (ideName_0 && !hasShownHintRef_0.current) {
|
||||
hasShownHintRef_0.current = true;
|
||||
saveGlobalConfig(_temp);
|
||||
addNotification_0({
|
||||
key: "ide-status-hint",
|
||||
jsx: <Text dimColor={true}>/ide for <Text color="ide">{ideName_0}</Text></Text>,
|
||||
priority: "low"
|
||||
});
|
||||
|
||||
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 shouldShowIdeSelection =
|
||||
ideStatus === 'connected' &&
|
||||
(ideSelection?.filePath ||
|
||||
(ideSelection?.text && ideSelection.lineCount > 0))
|
||||
|
||||
// Only show the connected if not showing context
|
||||
const shouldShowConnected =
|
||||
ideStatus === 'connected' && !shouldShowIdeSelection
|
||||
|
||||
const showIDEInstallError =
|
||||
showIDEInstallErrorOrJetBrainsInfo &&
|
||||
!isJetBrains &&
|
||||
!shouldShowConnected &&
|
||||
!shouldShowIdeSelection
|
||||
|
||||
const showJetBrainsInfo =
|
||||
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 (isSupportedTerminal() || ideStatus !== null || showJetBrainsInfo) {
|
||||
removeNotification('ide-status-hint')
|
||||
return
|
||||
}
|
||||
});
|
||||
}
|
||||
function _temp(current) {
|
||||
return {
|
||||
...current,
|
||||
ideHintShownCount: (current.ideHintShownCount ?? 0) + 1
|
||||
};
|
||||
// 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
|
||||
}
|
||||
const timeoutId = setTimeout(
|
||||
(hasShownHintRef, addNotification) => {
|
||||
void detectIDEs(true).then(infos => {
|
||||
const ideName = infos[0]?.name
|
||||
if (ideName && !hasShownHintRef.current) {
|
||||
hasShownHintRef.current = true
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
ideHintShownCount: (current.ideHintShownCount ?? 0) + 1,
|
||||
}))
|
||||
addNotification({
|
||||
key: 'ide-status-hint',
|
||||
jsx: (
|
||||
<Text dimColor>
|
||||
/ide for <Text color="ide">{ideName}</Text>
|
||||
</Text>
|
||||
),
|
||||
priority: 'low',
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
3000,
|
||||
hasShownHintRef,
|
||||
addNotification,
|
||||
)
|
||||
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
|
||||
}
|
||||
addNotification({
|
||||
key: 'ide-status-disconnected',
|
||||
text: `${ideName} disconnected`,
|
||||
color: 'error',
|
||||
priority: 'medium',
|
||||
})
|
||||
}, [
|
||||
addNotification,
|
||||
removeNotification,
|
||||
ideStatus,
|
||||
ideName,
|
||||
showIDEInstallError,
|
||||
showJetBrainsInfo,
|
||||
])
|
||||
|
||||
// Show JetBrains plugin not connected hint
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (!showJetBrainsInfo) {
|
||||
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])
|
||||
|
||||
// Show IDE install error
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (!showIDEInstallError) {
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import { checkInstall } from 'src/utils/nativeInstaller/index.js';
|
||||
import { useStartupNotification } from './useStartupNotification.js';
|
||||
export function useInstallMessages() {
|
||||
useStartupNotification(_temp2);
|
||||
}
|
||||
async function _temp2() {
|
||||
const messages = await checkInstall();
|
||||
return messages.map(_temp);
|
||||
}
|
||||
function _temp(message, index) {
|
||||
let priority = "low";
|
||||
if (message.type === "error" || message.userActionRequired) {
|
||||
priority = "high";
|
||||
} else {
|
||||
if (message.type === "path" || message.type === "alias") {
|
||||
priority = "medium";
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: `install-message-${index}-${message.type}`,
|
||||
text: message.message,
|
||||
priority,
|
||||
color: message.type === "error" ? "error" : "warning"
|
||||
};
|
||||
import { checkInstall } from 'src/utils/nativeInstaller/index.js'
|
||||
import { useStartupNotification } from './useStartupNotification.js'
|
||||
|
||||
export function useInstallMessages(): void {
|
||||
useStartupNotification(async () => {
|
||||
const messages = await checkInstall()
|
||||
return messages.map((message, index) => {
|
||||
let priority: 'low' | 'medium' | 'high' | 'immediate' = 'low'
|
||||
if (message.type === 'error' || message.userActionRequired) {
|
||||
priority = 'high'
|
||||
} else if (message.type === 'path' || message.type === 'alias') {
|
||||
priority = 'medium'
|
||||
}
|
||||
return {
|
||||
key: `install-message-${index}-${message.type}`,
|
||||
text: message.message,
|
||||
priority,
|
||||
color: message.type === 'error' ? 'error' : 'warning',
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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 '../../ink.js';
|
||||
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;
|
||||
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 '../../ink.js'
|
||||
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
|
||||
|
||||
/**
|
||||
* Hook that polls LSP status and shows a notification when:
|
||||
@@ -19,124 +22,120 @@ const LSP_POLL_INTERVAL_MS = 5000;
|
||||
*
|
||||
* Only active when ENABLE_LSP_TOOL is set.
|
||||
*/
|
||||
export function useLspInitializationNotification() {
|
||||
const $ = _c(10);
|
||||
const {
|
||||
addNotification
|
||||
} = useNotifications();
|
||||
const setAppState = useSetAppState();
|
||||
const [shouldPoll, setShouldPoll] = React.useState(_temp);
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = new Set();
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
const notifiedErrorsRef = React.useRef(t0);
|
||||
let t1;
|
||||
if ($[1] !== addNotification || $[2] !== setAppState) {
|
||||
t1 = (source, errorMessage) => {
|
||||
const errorKey = `${source}:${errorMessage}`;
|
||||
export function useLspInitializationNotification(): void {
|
||||
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"),
|
||||
)
|
||||
// Track which errors we've already notified about to avoid duplicates
|
||||
const notifiedErrorsRef = React.useRef<Set<string>>(new Set())
|
||||
|
||||
const addError = React.useCallback(
|
||||
(source: string, errorMessage: string) => {
|
||||
const errorKey = `${source}:${errorMessage}`
|
||||
if (notifiedErrorsRef.current.has(errorKey)) {
|
||||
return;
|
||||
return // Already notified
|
||||
}
|
||||
notifiedErrorsRef.current.add(errorKey);
|
||||
logForDebugging(`LSP error: ${source} - ${errorMessage}`);
|
||||
notifiedErrorsRef.current.add(errorKey)
|
||||
|
||||
logForDebugging(`LSP error: ${source} - ${errorMessage}`)
|
||||
|
||||
// Add error to appState.plugins.errors
|
||||
setAppState(prev => {
|
||||
const existingKeys = new Set(prev.plugins.errors.map(_temp2));
|
||||
const stateErrorKey = `generic-error:${source}:${errorMessage}`;
|
||||
// Check if this error already exists to avoid duplicates
|
||||
const existingKeys = new Set(
|
||||
prev.plugins.errors.map(e => {
|
||||
if (e.type === 'generic-error') {
|
||||
return `generic-error:${e.source}:${e.error}`
|
||||
}
|
||||
return `${e.type}:${e.source}`
|
||||
}),
|
||||
)
|
||||
|
||||
const stateErrorKey = `generic-error:${source}:${errorMessage}`
|
||||
if (existingKeys.has(stateErrorKey)) {
|
||||
return prev;
|
||||
return prev
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
plugins: {
|
||||
...prev.plugins,
|
||||
errors: [...prev.plugins.errors, {
|
||||
type: "generic-error" as const,
|
||||
source,
|
||||
error: errorMessage
|
||||
}]
|
||||
}
|
||||
};
|
||||
});
|
||||
const displayName = source.startsWith("plugin:") ? source.split(":")[1] ?? source : source;
|
||||
errors: [
|
||||
...prev.plugins.errors,
|
||||
{
|
||||
type: 'generic-error' as const,
|
||||
source,
|
||||
error: errorMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Show notification - extract plugin name from source like "plugin:typescript-lsp:typescript"
|
||||
const displayName = source.startsWith('plugin:')
|
||||
? (source.split(':')[1] ?? source)
|
||||
: source
|
||||
|
||||
addNotification({
|
||||
key: `lsp-error-${source}`,
|
||||
jsx: <><Text color="error">LSP for {displayName} failed</Text><Text dimColor={true}> · /plugin for details</Text></>,
|
||||
priority: "medium",
|
||||
timeoutMs: 8000
|
||||
});
|
||||
};
|
||||
$[1] = addNotification;
|
||||
$[2] = setAppState;
|
||||
$[3] = t1;
|
||||
} else {
|
||||
t1 = $[3];
|
||||
}
|
||||
const addError = t1;
|
||||
let t2;
|
||||
if ($[4] !== addError) {
|
||||
t2 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (getIsScrollDraining()) {
|
||||
return;
|
||||
}
|
||||
const status = getInitializationStatus();
|
||||
if (status.status === "failed") {
|
||||
addError("lsp-manager", status.error.message);
|
||||
setShouldPoll(false);
|
||||
return;
|
||||
}
|
||||
if (status.status === "pending" || status.status === "not-started") {
|
||||
return;
|
||||
}
|
||||
const manager = getLspServerManager();
|
||||
if (manager) {
|
||||
const servers = manager.getAllServers();
|
||||
for (const [serverName, server] of servers) {
|
||||
if (server.state === "error" && server.lastError) {
|
||||
addError(serverName, server.lastError.message);
|
||||
}
|
||||
jsx: (
|
||||
<>
|
||||
<Text color="error">LSP for {displayName} failed</Text>
|
||||
<Text dimColor> · /plugin for details</Text>
|
||||
</>
|
||||
),
|
||||
priority: 'medium',
|
||||
timeoutMs: 8000,
|
||||
})
|
||||
},
|
||||
[addNotification, setAppState],
|
||||
)
|
||||
|
||||
const poll = React.useCallback(() => {
|
||||
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
|
||||
|
||||
const status = getInitializationStatus()
|
||||
|
||||
// Check manager initialization status
|
||||
if (status.status === 'failed') {
|
||||
addError('lsp-manager', status.error.message)
|
||||
setShouldPoll(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (status.status === 'pending' || status.status === 'not-started') {
|
||||
// Still initializing, continue polling
|
||||
return
|
||||
}
|
||||
|
||||
// Manager initialized successfully - check for server errors
|
||||
const manager = getLspServerManager()
|
||||
if (manager) {
|
||||
const servers = manager.getAllServers()
|
||||
for (const [serverName, server] of servers) {
|
||||
if (server.state === 'error' && server.lastError) {
|
||||
addError(serverName, server.lastError.message)
|
||||
}
|
||||
}
|
||||
};
|
||||
$[4] = addError;
|
||||
$[5] = t2;
|
||||
} else {
|
||||
t2 = $[5];
|
||||
}
|
||||
const poll = t2;
|
||||
useInterval(poll, shouldPoll ? LSP_POLL_INTERVAL_MS : null);
|
||||
let t3;
|
||||
let t4;
|
||||
if ($[6] !== poll || $[7] !== shouldPoll) {
|
||||
t3 = () => {
|
||||
if (getIsRemoteMode() || !shouldPoll) {
|
||||
return;
|
||||
}
|
||||
poll();
|
||||
};
|
||||
t4 = [poll, shouldPoll];
|
||||
$[6] = poll;
|
||||
$[7] = shouldPoll;
|
||||
$[8] = t3;
|
||||
$[9] = t4;
|
||||
} else {
|
||||
t3 = $[8];
|
||||
t4 = $[9];
|
||||
}
|
||||
React.useEffect(t3, t4);
|
||||
}
|
||||
function _temp2(e) {
|
||||
if (e.type === "generic-error") {
|
||||
return `generic-error:${e.source}:${e.error}`;
|
||||
}
|
||||
return `${e.type}:${e.source}`;
|
||||
}
|
||||
function _temp() {
|
||||
return isEnvTruthy("true");
|
||||
}
|
||||
// Continue polling to detect future server errors
|
||||
}, [addError])
|
||||
|
||||
useInterval(poll, shouldPoll ? LSP_POLL_INTERVAL_MS : null)
|
||||
|
||||
// Initial poll on mount
|
||||
React.useEffect(() => {
|
||||
if (getIsRemoteMode() || !shouldPoll) return
|
||||
poll()
|
||||
}, [poll, shouldPoll])
|
||||
}
|
||||
|
||||
@@ -1,87 +1,126 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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 '../../ink.js';
|
||||
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 '../../ink.js'
|
||||
import { hasClaudeAiMcpEverConnected } from '../../services/mcp/claudeai.js'
|
||||
import type { MCPServerConnection } from '../../services/mcp/types.js'
|
||||
|
||||
type Props = {
|
||||
mcpClients?: MCPServerConnection[];
|
||||
};
|
||||
const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [];
|
||||
export function useMcpConnectivityStatus(t0) {
|
||||
const $ = _c(4);
|
||||
const {
|
||||
mcpClients: t1
|
||||
} = t0;
|
||||
const mcpClients = t1 === undefined ? EMPTY_MCP_CLIENTS : t1;
|
||||
const {
|
||||
addNotification
|
||||
} = useNotifications();
|
||||
let t2;
|
||||
let t3;
|
||||
if ($[0] !== addNotification || $[1] !== mcpClients) {
|
||||
t2 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
const failedLocalClients = mcpClients.filter(_temp);
|
||||
const failedClaudeAiClients = mcpClients.filter(_temp2);
|
||||
const needsAuthLocalServers = mcpClients.filter(_temp3);
|
||||
const needsAuthClaudeAiServers = mcpClients.filter(_temp4);
|
||||
if (failedLocalClients.length === 0 && failedClaudeAiClients.length === 0 && needsAuthLocalServers.length === 0 && needsAuthClaudeAiServers.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (failedLocalClients.length > 0) {
|
||||
addNotification({
|
||||
key: "mcp-failed",
|
||||
jsx: <><Text color="error">{failedLocalClients.length} MCP{" "}{failedLocalClients.length === 1 ? "server" : "servers"} failed</Text><Text dimColor={true}> · /mcp</Text></>,
|
||||
priority: "medium"
|
||||
});
|
||||
}
|
||||
if (failedClaudeAiClients.length > 0) {
|
||||
addNotification({
|
||||
key: "mcp-claudeai-failed",
|
||||
jsx: <><Text color="error">{failedClaudeAiClients.length} claude.ai{" "}{failedClaudeAiClients.length === 1 ? "connector" : "connectors"}{" "}unavailable</Text><Text dimColor={true}> · /mcp</Text></>,
|
||||
priority: "medium"
|
||||
});
|
||||
}
|
||||
if (needsAuthLocalServers.length > 0) {
|
||||
addNotification({
|
||||
key: "mcp-needs-auth",
|
||||
jsx: <><Text color="warning">{needsAuthLocalServers.length} MCP{" "}{needsAuthLocalServers.length === 1 ? "server needs" : "servers need"}{" "}auth</Text><Text dimColor={true}> · /mcp</Text></>,
|
||||
priority: "medium"
|
||||
});
|
||||
}
|
||||
if (needsAuthClaudeAiServers.length > 0) {
|
||||
addNotification({
|
||||
key: "mcp-claudeai-needs-auth",
|
||||
jsx: <><Text color="warning">{needsAuthClaudeAiServers.length} claude.ai{" "}{needsAuthClaudeAiServers.length === 1 ? "connector needs" : "connectors need"}{" "}auth</Text><Text dimColor={true}> · /mcp</Text></>,
|
||||
priority: "medium"
|
||||
});
|
||||
}
|
||||
};
|
||||
t3 = [addNotification, mcpClients];
|
||||
$[0] = addNotification;
|
||||
$[1] = mcpClients;
|
||||
$[2] = t2;
|
||||
$[3] = t3;
|
||||
} else {
|
||||
t2 = $[2];
|
||||
t3 = $[3];
|
||||
}
|
||||
useEffect(t2, t3);
|
||||
mcpClients?: MCPServerConnection[]
|
||||
}
|
||||
function _temp4(client_2) {
|
||||
return client_2.type === "needs-auth" && client_2.config.type === "claudeai-proxy" && hasClaudeAiMcpEverConnected(client_2.name);
|
||||
}
|
||||
function _temp3(client_1) {
|
||||
return client_1.type === "needs-auth" && client_1.config.type !== "claudeai-proxy";
|
||||
}
|
||||
function _temp2(client_0) {
|
||||
return client_0.type === "failed" && client_0.config.type === "claudeai-proxy" && hasClaudeAiMcpEverConnected(client_0.name);
|
||||
}
|
||||
function _temp(client) {
|
||||
return client.type === "failed" && client.config.type !== "sse-ide" && client.config.type !== "ws-ide" && client.config.type !== "claudeai-proxy";
|
||||
|
||||
const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []
|
||||
|
||||
export function useMcpConnectivityStatus({
|
||||
mcpClients = EMPTY_MCP_CLIENTS,
|
||||
}: Props): void {
|
||||
const { addNotification } = useNotifications()
|
||||
useEffect(() => {
|
||||
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
|
||||
// org-configured connector that's been needs-auth since it appeared is one
|
||||
// the user has ignored and shouldn't nag about; one that was working
|
||||
// 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),
|
||||
)
|
||||
const needsAuthLocalServers = mcpClients.filter(
|
||||
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
|
||||
}
|
||||
if (failedLocalClients.length > 0) {
|
||||
addNotification({
|
||||
key: 'mcp-failed',
|
||||
jsx: (
|
||||
<>
|
||||
<Text color="error">
|
||||
{failedLocalClients.length} MCP{' '}
|
||||
{failedLocalClients.length === 1 ? 'server' : 'servers'} failed
|
||||
</Text>
|
||||
<Text dimColor> · /mcp</Text>
|
||||
</>
|
||||
),
|
||||
priority: 'medium',
|
||||
})
|
||||
}
|
||||
if (failedClaudeAiClients.length > 0) {
|
||||
addNotification({
|
||||
key: 'mcp-claudeai-failed',
|
||||
jsx: (
|
||||
<>
|
||||
<Text color="error">
|
||||
{failedClaudeAiClients.length} claude.ai{' '}
|
||||
{failedClaudeAiClients.length === 1 ? 'connector' : 'connectors'}{' '}
|
||||
unavailable
|
||||
</Text>
|
||||
<Text dimColor> · /mcp</Text>
|
||||
</>
|
||||
),
|
||||
priority: 'medium',
|
||||
})
|
||||
}
|
||||
if (needsAuthLocalServers.length > 0) {
|
||||
addNotification({
|
||||
key: 'mcp-needs-auth',
|
||||
jsx: (
|
||||
<>
|
||||
<Text color="warning">
|
||||
{needsAuthLocalServers.length} MCP{' '}
|
||||
{needsAuthLocalServers.length === 1
|
||||
? 'server needs'
|
||||
: 'servers need'}{' '}
|
||||
auth
|
||||
</Text>
|
||||
<Text dimColor> · /mcp</Text>
|
||||
</>
|
||||
),
|
||||
priority: 'medium',
|
||||
})
|
||||
}
|
||||
if (needsAuthClaudeAiServers.length > 0) {
|
||||
addNotification({
|
||||
key: 'mcp-claudeai-needs-auth',
|
||||
jsx: (
|
||||
<>
|
||||
<Text color="warning">
|
||||
{needsAuthClaudeAiServers.length} claude.ai{' '}
|
||||
{needsAuthClaudeAiServers.length === 1
|
||||
? 'connector needs'
|
||||
: 'connectors need'}{' '}
|
||||
auth
|
||||
</Text>
|
||||
<Text dimColor> · /mcp</Text>
|
||||
</>
|
||||
),
|
||||
priority: 'medium',
|
||||
})
|
||||
}
|
||||
}, [addNotification, mcpClients])
|
||||
}
|
||||
|
||||
@@ -1,51 +1,53 @@
|
||||
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
|
||||
// a notification if the write happened within the last 3s (i.e. this launch).
|
||||
// Future model migrations: add an entry to MIGRATIONS below.
|
||||
const MIGRATIONS: ((c: GlobalConfig) => Notification | undefined)[] = [
|
||||
// Sonnet 4.5 → 4.6 (pro/max/team premium)
|
||||
c => {
|
||||
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.6 for 1P).
|
||||
c => {
|
||||
const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp);
|
||||
const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp;
|
||||
if (!recent(ts)) return;
|
||||
return {
|
||||
key: 'opus-pro-update',
|
||||
text: isLegacyRemap ? 'Model updated to Opus 4.6 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out' : 'Model updated to Opus 4.6',
|
||||
color: 'suggestion',
|
||||
priority: 'high',
|
||||
timeoutMs: isLegacyRemap ? 8000 : 3000
|
||||
};
|
||||
}];
|
||||
export function useModelMigrationNotifications() {
|
||||
useStartupNotification(_temp);
|
||||
}
|
||||
function _temp() {
|
||||
const config = getGlobalConfig();
|
||||
const notifs = [];
|
||||
for (const migration of MIGRATIONS) {
|
||||
const notif = migration(config);
|
||||
if (notif) {
|
||||
notifs.push(notif);
|
||||
// Sonnet 4.5 → 4.6 (pro/max/team premium)
|
||||
c => {
|
||||
if (!recent(c.sonnet45To46MigrationTimestamp)) return
|
||||
return {
|
||||
key: 'sonnet-46-update',
|
||||
text: 'Model updated to Sonnet 4.6',
|
||||
color: 'suggestion',
|
||||
priority: 'high',
|
||||
timeoutMs: 3000,
|
||||
}
|
||||
}
|
||||
return notifs.length > 0 ? notifs : null;
|
||||
},
|
||||
// Opus Pro → default, or pinned 4.0/4.1 → opus alias. Both land on the
|
||||
// current Opus default (4.6 for 1P).
|
||||
c => {
|
||||
const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp)
|
||||
const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp
|
||||
if (!recent(ts)) return
|
||||
return {
|
||||
key: 'opus-pro-update',
|
||||
text: isLegacyRemap
|
||||
? 'Model updated to Opus 4.6 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out'
|
||||
: 'Model updated to Opus 4.6',
|
||||
color: 'suggestion',
|
||||
priority: 'high',
|
||||
timeoutMs: isLegacyRemap ? 8000 : 3000,
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
export function useModelMigrationNotifications(): void {
|
||||
useStartupNotification(() => {
|
||||
const config = getGlobalConfig()
|
||||
const notifs: Notification[] = []
|
||||
for (const migration of MIGRATIONS) {
|
||||
const notif = migration(config)
|
||||
if (notif) notifs.push(notif)
|
||||
}
|
||||
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,25 +1,27 @@
|
||||
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 = 'Claude Code has switched from npm to native installer. Run `claude install` or see https://docs.anthropic.com/en/docs/claude-code/getting-started for more options.';
|
||||
export function useNpmDeprecationNotification() {
|
||||
useStartupNotification(_temp);
|
||||
}
|
||||
async function _temp() {
|
||||
if (isInBundledMode() || isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)) {
|
||||
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"
|
||||
};
|
||||
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 =
|
||||
'Claude Code has switched from npm to native installer. Run `claude install` or see https://docs.anthropic.com/en/docs/claude-code/getting-started for more options.'
|
||||
|
||||
export function useNpmDeprecationNotification(): void {
|
||||
useStartupNotification(async () => {
|
||||
if (
|
||||
isInBundledMode() ||
|
||||
isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)
|
||||
) {
|
||||
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,82 +1,67 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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 '../../ink.js';
|
||||
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 '../../ink.js'
|
||||
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() {
|
||||
const $ = _c(7);
|
||||
const {
|
||||
addNotification
|
||||
} = useNotifications();
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = [];
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
const [updatedPlugins, setUpdatedPlugins] = useState(t0);
|
||||
let t1;
|
||||
let t2;
|
||||
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t1 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = onPluginsAutoUpdated(plugins => {
|
||||
logForDebugging(`Plugin autoupdate notification: ${plugins.length} plugin(s) updated`);
|
||||
setUpdatedPlugins(plugins);
|
||||
});
|
||||
return unsubscribe;
|
||||
};
|
||||
t2 = [];
|
||||
$[1] = t1;
|
||||
$[2] = t2;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
t2 = $[2];
|
||||
}
|
||||
useEffect(t1, t2);
|
||||
let t3;
|
||||
let t4;
|
||||
if ($[3] !== addNotification || $[4] !== updatedPlugins) {
|
||||
t3 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (updatedPlugins.length === 0) {
|
||||
return;
|
||||
}
|
||||
const pluginNames = updatedPlugins.map(_temp);
|
||||
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}</Text><Text dimColor={true}> · Run /reload-plugins to apply</Text></>,
|
||||
priority: "low",
|
||||
timeoutMs: 10000
|
||||
});
|
||||
logForDebugging(`Showing plugin autoupdate notification for: ${pluginNames.join(", ")}`);
|
||||
};
|
||||
t4 = [updatedPlugins, addNotification];
|
||||
$[3] = addNotification;
|
||||
$[4] = updatedPlugins;
|
||||
$[5] = t3;
|
||||
$[6] = t4;
|
||||
} else {
|
||||
t3 = $[5];
|
||||
t4 = $[6];
|
||||
}
|
||||
useEffect(t3, t4);
|
||||
}
|
||||
function _temp(id) {
|
||||
const atIndex = id.indexOf("@");
|
||||
return atIndex > 0 ? id.substring(0, atIndex) : id;
|
||||
export function usePluginAutoupdateNotification(): void {
|
||||
const { addNotification } = useNotifications()
|
||||
const [updatedPlugins, setUpdatedPlugins] = useState<string[]>([])
|
||||
|
||||
// Register for autoupdate notifications
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
const unsubscribe = onPluginsAutoUpdated(plugins => {
|
||||
logForDebugging(
|
||||
`Plugin autoupdate notification: ${plugins.length} plugin(s) updated`,
|
||||
)
|
||||
setUpdatedPlugins(plugins)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
// Show notification when plugins are updated
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (updatedPlugins.length === 0) {
|
||||
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 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}
|
||||
</Text>
|
||||
<Text dimColor> · Run /reload-plugins to apply</Text>
|
||||
</>
|
||||
),
|
||||
priority: 'low',
|
||||
timeoutMs: 10000,
|
||||
})
|
||||
|
||||
logForDebugging(
|
||||
`Showing plugin autoupdate notification for: ${pluginNames.join(', ')}`,
|
||||
)
|
||||
}, [updatedPlugins, addNotification])
|
||||
}
|
||||
|
||||
@@ -1,127 +1,80 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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 '../../ink.js';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import { logForDebugging } from '../../utils/debug.js';
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
export function usePluginInstallationStatus() {
|
||||
const $ = _c(20);
|
||||
const {
|
||||
addNotification
|
||||
} = useNotifications();
|
||||
const installationStatus = useAppState(_temp);
|
||||
let t0;
|
||||
bb0: {
|
||||
if (!installationStatus) {
|
||||
let t1;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t1 = {
|
||||
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 '../../ink.js'
|
||||
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)
|
||||
|
||||
// Memoize the failed counts to prevent unnecessary effect triggers
|
||||
const { totalFailed, failedMarketplacesCount, failedPluginsCount } =
|
||||
useMemo(() => {
|
||||
if (!installationStatus) {
|
||||
return {
|
||||
totalFailed: 0,
|
||||
failedMarketplacesCount: 0,
|
||||
failedPluginsCount: 0
|
||||
};
|
||||
$[0] = t1;
|
||||
} else {
|
||||
t1 = $[0];
|
||||
failedPluginsCount: 0,
|
||||
}
|
||||
}
|
||||
t0 = t1;
|
||||
break bb0;
|
||||
}
|
||||
let t1;
|
||||
if ($[1] !== installationStatus.marketplaces) {
|
||||
t1 = installationStatus.marketplaces.filter(_temp2);
|
||||
$[1] = installationStatus.marketplaces;
|
||||
$[2] = t1;
|
||||
} else {
|
||||
t1 = $[2];
|
||||
}
|
||||
const failedMarketplaces = t1;
|
||||
let t2;
|
||||
if ($[3] !== installationStatus.plugins) {
|
||||
t2 = installationStatus.plugins.filter(_temp3);
|
||||
$[3] = installationStatus.plugins;
|
||||
$[4] = t2;
|
||||
} else {
|
||||
t2 = $[4];
|
||||
}
|
||||
const failedPlugins = t2;
|
||||
const t3 = failedMarketplaces.length + failedPlugins.length;
|
||||
let t4;
|
||||
if ($[5] !== failedMarketplaces.length || $[6] !== failedPlugins.length || $[7] !== t3) {
|
||||
t4 = {
|
||||
totalFailed: t3,
|
||||
|
||||
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
|
||||
};
|
||||
$[5] = failedMarketplaces.length;
|
||||
$[6] = failedPlugins.length;
|
||||
$[7] = t3;
|
||||
$[8] = t4;
|
||||
} else {
|
||||
t4 = $[8];
|
||||
failedPluginsCount: failedPlugins.length,
|
||||
}
|
||||
}, [installationStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (!installationStatus) {
|
||||
logForDebugging('No installation status to monitor')
|
||||
return
|
||||
}
|
||||
t0 = t4;
|
||||
}
|
||||
const {
|
||||
|
||||
if (totalFailed === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`Plugin installation status: ${failedMarketplacesCount} failed marketplaces, ${failedPluginsCount} failed plugins`,
|
||||
)
|
||||
|
||||
if (totalFailed === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add notification for failures
|
||||
logForDebugging(
|
||||
`Adding notification for ${totalFailed} failed installations`,
|
||||
)
|
||||
addNotification({
|
||||
key: 'plugin-install-failed',
|
||||
jsx: (
|
||||
<>
|
||||
<Text color="error">
|
||||
{totalFailed} {plural(totalFailed, 'plugin')} failed to install
|
||||
</Text>
|
||||
<Text dimColor> · /plugin for details</Text>
|
||||
</>
|
||||
),
|
||||
priority: 'medium',
|
||||
})
|
||||
}, [
|
||||
addNotification,
|
||||
totalFailed,
|
||||
failedMarketplacesCount,
|
||||
failedPluginsCount
|
||||
} = t0;
|
||||
let t1;
|
||||
if ($[9] !== addNotification || $[10] !== failedMarketplacesCount || $[11] !== failedPluginsCount || $[12] !== installationStatus || $[13] !== totalFailed) {
|
||||
t1 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (!installationStatus) {
|
||||
logForDebugging("No installation status to monitor");
|
||||
return;
|
||||
}
|
||||
if (totalFailed === 0) {
|
||||
return;
|
||||
}
|
||||
logForDebugging(`Plugin installation status: ${failedMarketplacesCount} failed marketplaces, ${failedPluginsCount} failed plugins`);
|
||||
if (totalFailed === 0) {
|
||||
return;
|
||||
}
|
||||
logForDebugging(`Adding notification for ${totalFailed} failed installations`);
|
||||
addNotification({
|
||||
key: "plugin-install-failed",
|
||||
jsx: <><Text color="error">{totalFailed} {plural(totalFailed, "plugin")} failed to install</Text><Text dimColor={true}> · /plugin for details</Text></>,
|
||||
priority: "medium"
|
||||
});
|
||||
};
|
||||
$[9] = addNotification;
|
||||
$[10] = failedMarketplacesCount;
|
||||
$[11] = failedPluginsCount;
|
||||
$[12] = installationStatus;
|
||||
$[13] = totalFailed;
|
||||
$[14] = t1;
|
||||
} else {
|
||||
t1 = $[14];
|
||||
}
|
||||
let t2;
|
||||
if ($[15] !== addNotification || $[16] !== failedMarketplacesCount || $[17] !== failedPluginsCount || $[18] !== totalFailed) {
|
||||
t2 = [addNotification, totalFailed, failedMarketplacesCount, failedPluginsCount];
|
||||
$[15] = addNotification;
|
||||
$[16] = failedMarketplacesCount;
|
||||
$[17] = failedPluginsCount;
|
||||
$[18] = totalFailed;
|
||||
$[19] = t2;
|
||||
} else {
|
||||
t2 = $[19];
|
||||
}
|
||||
useEffect(t1, t2);
|
||||
}
|
||||
function _temp3(p) {
|
||||
return p.status === "failed";
|
||||
}
|
||||
function _temp2(m) {
|
||||
return m.status === "failed";
|
||||
}
|
||||
function _temp(s) {
|
||||
return s.plugins.installationStatus;
|
||||
failedPluginsCount,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,113 +1,80 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNotifications } from 'src/context/notifications.js';
|
||||
import { Text } from 'src/ink.js';
|
||||
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) {
|
||||
const $ = _c(17);
|
||||
const {
|
||||
addNotification
|
||||
} = useNotifications();
|
||||
const claudeAiLimits = useClaudeAiLimits();
|
||||
let t0;
|
||||
if ($[0] !== claudeAiLimits || $[1] !== model) {
|
||||
t0 = getRateLimitWarning(claudeAiLimits, model);
|
||||
$[0] = claudeAiLimits;
|
||||
$[1] = model;
|
||||
$[2] = t0;
|
||||
} else {
|
||||
t0 = $[2];
|
||||
}
|
||||
const rateLimitWarning = t0;
|
||||
let t1;
|
||||
if ($[3] !== claudeAiLimits) {
|
||||
t1 = getUsingOverageText(claudeAiLimits);
|
||||
$[3] = claudeAiLimits;
|
||||
$[4] = t1;
|
||||
} else {
|
||||
t1 = $[4];
|
||||
}
|
||||
const usingOverageText = t1;
|
||||
const shownWarningRef = useRef(null);
|
||||
let t2;
|
||||
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t2 = getSubscriptionType();
|
||||
$[5] = t2;
|
||||
} else {
|
||||
t2 = $[5];
|
||||
}
|
||||
const subscriptionType = t2;
|
||||
let t3;
|
||||
if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t3 = hasClaudeAiBillingAccess();
|
||||
$[6] = t3;
|
||||
} else {
|
||||
t3 = $[6];
|
||||
}
|
||||
const hasBillingAccess = t3;
|
||||
const isTeamOrEnterprise = subscriptionType === "team" || subscriptionType === "enterprise";
|
||||
const [hasShownOverageNotification, setHasShownOverageNotification] = useState(false);
|
||||
let t4;
|
||||
let t5;
|
||||
if ($[7] !== addNotification || $[8] !== claudeAiLimits.isUsingOverage || $[9] !== hasShownOverageNotification || $[10] !== usingOverageText) {
|
||||
t4 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (claudeAiLimits.isUsingOverage && !hasShownOverageNotification && (!isTeamOrEnterprise || hasBillingAccess)) {
|
||||
addNotification({
|
||||
key: "limit-reached",
|
||||
text: usingOverageText,
|
||||
priority: "immediate"
|
||||
});
|
||||
setHasShownOverageNotification(true);
|
||||
} else {
|
||||
if (!claudeAiLimits.isUsingOverage && hasShownOverageNotification) {
|
||||
setHasShownOverageNotification(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
t5 = [claudeAiLimits.isUsingOverage, usingOverageText, hasShownOverageNotification, addNotification, hasBillingAccess, isTeamOrEnterprise];
|
||||
$[7] = addNotification;
|
||||
$[8] = claudeAiLimits.isUsingOverage;
|
||||
$[9] = hasShownOverageNotification;
|
||||
$[10] = usingOverageText;
|
||||
$[11] = t4;
|
||||
$[12] = t5;
|
||||
} else {
|
||||
t4 = $[11];
|
||||
t5 = $[12];
|
||||
}
|
||||
useEffect(t4, t5);
|
||||
let t6;
|
||||
let t7;
|
||||
if ($[13] !== addNotification || $[14] !== rateLimitWarning) {
|
||||
t6 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (rateLimitWarning && rateLimitWarning !== shownWarningRef.current) {
|
||||
shownWarningRef.current = rateLimitWarning;
|
||||
addNotification({
|
||||
key: "rate-limit-warning",
|
||||
jsx: <Text><Text color="warning">{rateLimitWarning}</Text></Text>,
|
||||
priority: "high"
|
||||
});
|
||||
}
|
||||
};
|
||||
t7 = [rateLimitWarning, addNotification];
|
||||
$[13] = addNotification;
|
||||
$[14] = rateLimitWarning;
|
||||
$[15] = t6;
|
||||
$[16] = t7;
|
||||
} else {
|
||||
t6 = $[15];
|
||||
t7 = $[16];
|
||||
}
|
||||
useEffect(t6, t7);
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNotifications } from 'src/context/notifications.js'
|
||||
import { Text } from 'src/ink.js'
|
||||
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()
|
||||
// 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'
|
||||
|
||||
// Track overage mode transitions
|
||||
const [hasShownOverageNotification, setHasShownOverageNotification] =
|
||||
useState(false)
|
||||
|
||||
// Show immediate notification when entering overage mode
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (
|
||||
claudeAiLimits.isUsingOverage &&
|
||||
!hasShownOverageNotification &&
|
||||
(!isTeamOrEnterprise || hasBillingAccess)
|
||||
) {
|
||||
addNotification({
|
||||
key: 'limit-reached',
|
||||
text: usingOverageText,
|
||||
priority: 'immediate',
|
||||
})
|
||||
setHasShownOverageNotification(true)
|
||||
} else if (!claudeAiLimits.isUsingOverage && hasShownOverageNotification) {
|
||||
// Reset when no longer in overage mode
|
||||
setHasShownOverageNotification(false)
|
||||
}
|
||||
}, [
|
||||
claudeAiLimits.isUsingOverage,
|
||||
usingOverageText,
|
||||
hasShownOverageNotification,
|
||||
addNotification,
|
||||
hasBillingAccess,
|
||||
isTeamOrEnterprise,
|
||||
])
|
||||
|
||||
// Show warning notification for approaching limits
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (rateLimitWarning && rateLimitWarning !== shownWarningRef.current) {
|
||||
shownWarningRef.current = rateLimitWarning
|
||||
addNotification({
|
||||
key: 'rate-limit-warning',
|
||||
jsx: (
|
||||
<Text>
|
||||
<Text color="warning">{rateLimitWarning}</Text>
|
||||
</Text>
|
||||
),
|
||||
priority: 'high',
|
||||
})
|
||||
}
|
||||
}, [rateLimitWarning, addNotification])
|
||||
}
|
||||
|
||||
@@ -1,68 +1,41 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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';
|
||||
export function useSettingsErrors() {
|
||||
const $ = _c(6);
|
||||
const {
|
||||
addNotification,
|
||||
removeNotification
|
||||
} = useNotifications();
|
||||
const [errors_0, setErrors] = useState(_temp);
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = () => {
|
||||
const {
|
||||
errors: errors_1
|
||||
} = getSettingsWithAllErrors();
|
||||
setErrors(errors_1);
|
||||
};
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
const handleSettingsChange = t0;
|
||||
useSettingsChange(handleSettingsChange);
|
||||
let t1;
|
||||
let t2;
|
||||
if ($[1] !== addNotification || $[2] !== errors_0 || $[3] !== removeNotification) {
|
||||
t1 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (errors_0.length > 0) {
|
||||
const message = `Found ${errors_0.length} settings ${errors_0.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);
|
||||
}
|
||||
};
|
||||
t2 = [errors_0, addNotification, removeNotification];
|
||||
$[1] = addNotification;
|
||||
$[2] = errors_0;
|
||||
$[3] = removeNotification;
|
||||
$[4] = t1;
|
||||
$[5] = t2;
|
||||
} else {
|
||||
t1 = $[4];
|
||||
t2 = $[5];
|
||||
}
|
||||
useEffect(t1, t2);
|
||||
return errors_0;
|
||||
}
|
||||
function _temp() {
|
||||
const {
|
||||
errors
|
||||
} = getSettingsWithAllErrors();
|
||||
return errors;
|
||||
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'
|
||||
|
||||
export function useSettingsErrors(): ValidationError[] {
|
||||
const { addNotification, removeNotification } = useNotifications()
|
||||
const [errors, setErrors] = useState<ValidationError[]>(() => {
|
||||
const { errors } = getSettingsWithAllErrors()
|
||||
return errors
|
||||
})
|
||||
|
||||
const handleSettingsChange = useCallback(() => {
|
||||
const { errors } = getSettingsWithAllErrors()
|
||||
setErrors(errors)
|
||||
}, [])
|
||||
|
||||
useSettingsChange(handleSettingsChange)
|
||||
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (errors.length > 0) {
|
||||
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)
|
||||
}
|
||||
}, [errors, addNotification, removeNotification])
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
@@ -1,228 +1,288 @@
|
||||
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 '../ink.js';
|
||||
import type { PromptInputMode } from '../types/textInputTypes.js';
|
||||
import type { HistoryEntry, PastedContent } from '../utils/config.js';
|
||||
export type HistoryMode = PromptInputMode;
|
||||
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 '../ink.js'
|
||||
import type { PromptInputMode } from '../types/textInputTypes.js'
|
||||
import type { HistoryEntry, PastedContent } from '../utils/config.js'
|
||||
|
||||
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 = undefined;
|
||||
async function loadHistoryEntries(minCount: number, modeFilter?: HistoryMode): Promise<HistoryEntry[]> {
|
||||
let pendingLoad: Promise<HistoryEntry[]> | null = null
|
||||
let pendingLoadTarget = 0
|
||||
let pendingLoadModeFilter: HistoryMode | undefined = undefined
|
||||
|
||||
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, 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;
|
||||
|
||||
export function useArrowKeyHistory(
|
||||
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
|
||||
} {
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
const [lastShownHistoryEntry, setLastShownHistoryEntry] = useState<(HistoryEntry & {
|
||||
mode?: HistoryMode;
|
||||
}) | undefined>(undefined);
|
||||
const hasShownSearchHintRef = useRef(false);
|
||||
const {
|
||||
addNotification,
|
||||
removeNotification
|
||||
} = useNotifications();
|
||||
const [historyIndex, setHistoryIndex] = useState(0)
|
||||
const [lastShownHistoryEntry, setLastShownHistoryEntry] = useState<
|
||||
(HistoryEntry & { mode?: HistoryMode }) | undefined
|
||||
>(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;
|
||||
const setInputWithCursor = useCallback((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_0 = false): void => {
|
||||
if (!input || !input.display) return;
|
||||
const mode_0 = getModeFromInput(input.display);
|
||||
const value_0 = mode_0 === 'bash' ? input.display.slice(1) : input.display;
|
||||
setInputWithCursor(value_0, mode_0, input.pastedContents ?? {}, cursorToStart_0);
|
||||
}, [setInputWithCursor]);
|
||||
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)
|
||||
},
|
||||
[onSetInput, setCursorOffset],
|
||||
)
|
||||
|
||||
const updateInput = useCallback(
|
||||
(input: HistoryEntry | undefined, cursorToStart = false): void => {
|
||||
if (!input || !input.display) return
|
||||
|
||||
const mode = getModeFromInput(input.display)
|
||||
const value = mode === 'bash' ? input.display.slice(1) : input.display
|
||||
|
||||
setInputWithCursor(value, mode, input.pastedContents ?? {}, cursorToStart)
|
||||
},
|
||||
[setInputWithCursor],
|
||||
)
|
||||
|
||||
const showSearchHint = useCallback((): void => {
|
||||
addNotification({
|
||||
key: 'search-history-hint',
|
||||
jsx: <Text dimColor>
|
||||
<ConfigurableShortcutHint action="history:search" context="Global" fallback="ctrl+r" description="search history" />
|
||||
</Text>,
|
||||
jsx: (
|
||||
<Text dimColor>
|
||||
<ConfigurableShortcutHint
|
||||
action="history:search"
|
||||
context="Global"
|
||||
fallback="ctrl+r"
|
||||
description="search history"
|
||||
/>
|
||||
</Text>
|
||||
),
|
||||
priority: 'immediate',
|
||||
timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT
|
||||
});
|
||||
}, [addNotification]);
|
||||
timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT,
|
||||
})
|
||||
}, [addNotification])
|
||||
|
||||
const onHistoryUp = useCallback((): void => {
|
||||
// Capture and increment synchronously to handle rapid keypresses
|
||||
const targetIndex = historyIndexRef.current;
|
||||
historyIndexRef.current++;
|
||||
const inputAtPress = currentInputRef.current;
|
||||
const pastedContentsAtPress = pastedContentsRef.current;
|
||||
const modeAtPress = currentModeRef.current;
|
||||
const targetIndex = historyIndexRef.current
|
||||
historyIndexRef.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() !== '';
|
||||
setLastShownHistoryEntry(hasInput ? {
|
||||
display: inputAtPress,
|
||||
pastedContents: pastedContentsAtPress,
|
||||
mode: modeAtPress
|
||||
} : undefined);
|
||||
const hasInput = inputAtPress.trim() !== ''
|
||||
setLastShownHistoryEntry(
|
||||
hasInput
|
||||
? {
|
||||
display: inputAtPress,
|
||||
pastedContents: pastedContentsAtPress,
|
||||
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,
|
||||
setHistoryIndex,
|
||||
onHistoryUp,
|
||||
onHistoryDown,
|
||||
resetHistory,
|
||||
dismissSearchHint
|
||||
};
|
||||
dismissSearchHint,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,203 +1,354 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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 '../ink.js';
|
||||
import type { ToolPermissionContext, Tool as ToolType, ToolUseContext } from '../Tool.js';
|
||||
import { consumeSpeculativeClassifierCheck, peekSpeculativeClassifierCheck } from '../tools/BashTool/bashPermissions.js';
|
||||
import { BASH_TOOL_NAME } from '../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';
|
||||
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>>;
|
||||
function useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext) {
|
||||
const $ = _c(3);
|
||||
let t0;
|
||||
if ($[0] !== setToolPermissionContext || $[1] !== setToolUseConfirmQueue) {
|
||||
t0 = async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) => new Promise(resolve => {
|
||||
const ctx = createPermissionContext(tool, input, toolUseContext, assistantMessage, toolUseID, setToolPermissionContext, createPermissionQueueOps(setToolUseConfirmQueue));
|
||||
if (ctx.resolveIfAborted(resolve)) {
|
||||
return;
|
||||
}
|
||||
const decisionPromise = forceDecision !== undefined ? Promise.resolve(forceDecision) : hasPermissionsToUseTool(tool, input, toolUseContext, assistantMessage, toolUseID);
|
||||
return decisionPromise.then(async result => {
|
||||
if (result.behavior === "allow") {
|
||||
if (ctx.resolveIfAborted(resolve)) {
|
||||
return;
|
||||
}
|
||||
if (feature("TRANSCRIPT_CLASSIFIER") && result.decisionReason?.type === "classifier" && result.decisionReason.classifier === "auto-mode") {
|
||||
setYoloClassifierApproval(toolUseID, result.decisionReason.reason);
|
||||
}
|
||||
ctx.logDecision({
|
||||
decision: "accept",
|
||||
source: "config"
|
||||
});
|
||||
resolve(ctx.buildAllow(result.updatedInput ?? input, {
|
||||
decisionReason: result.decisionReason
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const appState = toolUseContext.getAppState();
|
||||
const description = await tool.description(input as never, {
|
||||
isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession,
|
||||
toolPermissionContext: appState.toolPermissionContext,
|
||||
tools: toolUseContext.options.tools
|
||||
});
|
||||
if (ctx.resolveIfAborted(resolve)) {
|
||||
return;
|
||||
}
|
||||
switch (result.behavior) {
|
||||
case "deny":
|
||||
{
|
||||
logPermissionDecision({
|
||||
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 '../ink.js'
|
||||
import type {
|
||||
ToolPermissionContext,
|
||||
Tool as ToolType,
|
||||
ToolUseContext,
|
||||
} from '../Tool.js'
|
||||
import {
|
||||
consumeSpeculativeClassifierCheck,
|
||||
peekSpeculativeClassifierCheck,
|
||||
} from '../tools/BashTool/bashPermissions.js'
|
||||
import { BASH_TOOL_NAME } from '../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'
|
||||
|
||||
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>>
|
||||
|
||||
function useCanUseTool(
|
||||
setToolUseConfirmQueue: React.Dispatch<
|
||||
React.SetStateAction<ToolUseConfirm[]>
|
||||
>,
|
||||
setToolPermissionContext: (context: ToolPermissionContext) => void,
|
||||
): CanUseToolFn {
|
||||
return useCallback<CanUseToolFn>(
|
||||
async (
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
assistantMessage,
|
||||
toolUseID,
|
||||
forceDecision,
|
||||
) => {
|
||||
return new Promise(resolve => {
|
||||
const ctx = createPermissionContext(
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
assistantMessage,
|
||||
toolUseID,
|
||||
setToolPermissionContext,
|
||||
createPermissionQueueOps(setToolUseConfirmQueue),
|
||||
)
|
||||
|
||||
if (ctx.resolveIfAborted(resolve)) return
|
||||
|
||||
const decisionPromise =
|
||||
forceDecision !== undefined
|
||||
? Promise.resolve(forceDecision)
|
||||
: hasPermissionsToUseTool(
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
messageId: ctx.messageId,
|
||||
toolUseID
|
||||
}, {
|
||||
decision: "reject",
|
||||
source: "config"
|
||||
});
|
||||
if (feature("TRANSCRIPT_CLASSIFIER") && result.decisionReason?.type === "classifier" && result.decisionReason.classifier === "auto-mode") {
|
||||
recordAutoModeDenial({
|
||||
toolName: tool.name,
|
||||
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 dimColor={true}> · /permissions</Text></>
|
||||
});
|
||||
}
|
||||
resolve(result);
|
||||
return;
|
||||
assistantMessage,
|
||||
toolUseID,
|
||||
)
|
||||
|
||||
return decisionPromise
|
||||
.then(async result => {
|
||||
// [ANT-ONLY] Log all tool permission decisions with tool name and args
|
||||
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,
|
||||
// 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,
|
||||
isMcp: tool.isMcp ?? false,
|
||||
})
|
||||
}
|
||||
case "ask":
|
||||
{
|
||||
if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) {
|
||||
const coordinatorDecision = await handleCoordinatorPermission({
|
||||
|
||||
// Has permissions to use tool, granted in config
|
||||
if (result.behavior === 'allow') {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
ctx.logDecision({ decision: 'accept', source: 'config' })
|
||||
|
||||
resolve(
|
||||
ctx.buildAllow(result.updatedInput ?? input, {
|
||||
decisionReason: result.decisionReason,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const appState = toolUseContext.getAppState()
|
||||
const description = await tool.description(input as never, {
|
||||
isNonInteractiveSession:
|
||||
toolUseContext.options.isNonInteractiveSession,
|
||||
toolPermissionContext: appState.toolPermissionContext,
|
||||
tools: toolUseContext.options.tools,
|
||||
})
|
||||
|
||||
if (ctx.resolveIfAborted(resolve)) return
|
||||
|
||||
// Does not have permissions to use tool, check the behavior
|
||||
switch (result.behavior) {
|
||||
case 'deny': {
|
||||
logPermissionDecision(
|
||||
{
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
messageId: ctx.messageId,
|
||||
toolUseID,
|
||||
},
|
||||
{ decision: 'reject', source: 'config' },
|
||||
)
|
||||
if (
|
||||
feature('TRANSCRIPT_CLASSIFIER') &&
|
||||
result.decisionReason?.type === 'classifier' &&
|
||||
result.decisionReason.classifier === 'auto-mode'
|
||||
) {
|
||||
recordAutoModeDenial({
|
||||
toolName: tool.name,
|
||||
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 dimColor> · /permissions</Text>
|
||||
</>
|
||||
),
|
||||
})
|
||||
}
|
||||
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 (coordinatorDecision) {
|
||||
resolve(coordinatorDecision)
|
||||
return
|
||||
}
|
||||
// null means neither automated check resolved -- fall through to dialog below.
|
||||
// Hooks already ran, classifier already consumed.
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// For swarm workers, try classifier auto-approval then
|
||||
// forward permission requests to the leader via mailbox.
|
||||
const swarmDecision = await handleSwarmWorkerPermission({
|
||||
ctx,
|
||||
...(feature("BASH_CLASSIFIER") ? {
|
||||
pendingClassifierCheck: result.pendingClassifierCheck
|
||||
} : {}),
|
||||
description,
|
||||
...(feature('BASH_CLASSIFIER')
|
||||
? {
|
||||
pendingClassifierCheck: result.pendingClassifierCheck,
|
||||
}
|
||||
: {}),
|
||||
updatedInput: result.updatedInput,
|
||||
suggestions: result.suggestions,
|
||||
permissionMode: appState.toolPermissionContext.mode
|
||||
});
|
||||
if (coordinatorDecision) {
|
||||
resolve(coordinatorDecision);
|
||||
return;
|
||||
})
|
||||
if (swarmDecision) {
|
||||
resolve(swarmDecision)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (ctx.resolveIfAborted(resolve)) {
|
||||
return;
|
||||
}
|
||||
const swarmDecision = await handleSwarmWorkerPermission({
|
||||
ctx,
|
||||
description,
|
||||
...(feature("BASH_CLASSIFIER") ? {
|
||||
pendingClassifierCheck: result.pendingClassifierCheck
|
||||
} : {}),
|
||||
updatedInput: result.updatedInput,
|
||||
suggestions: result.suggestions
|
||||
});
|
||||
if (swarmDecision) {
|
||||
resolve(swarmDecision);
|
||||
return;
|
||||
}
|
||||
if (feature("BASH_CLASSIFIER") && result.pendingClassifierCheck && tool.name === BASH_TOOL_NAME && !appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) {
|
||||
const speculativePromise = peekSpeculativeClassifierCheck((input as {
|
||||
command: string;
|
||||
}).command);
|
||||
if (speculativePromise) {
|
||||
const raceResult = await Promise.race([speculativePromise.then(_temp), new Promise(_temp2)]);
|
||||
if (ctx.resolveIfAborted(resolve)) {
|
||||
return;
|
||||
}
|
||||
if ((raceResult as any).type === "result" && (raceResult as any).result.matches && (raceResult as any).result.confidence === "high" && feature("BASH_CLASSIFIER")) {
|
||||
consumeSpeculativeClassifierCheck((input as {
|
||||
command: string;
|
||||
}).command);
|
||||
const matchedRule = (raceResult as any).result.matchedDescription ?? undefined;
|
||||
if (matchedRule) {
|
||||
setClassifierApproval(toolUseID, matchedRule);
|
||||
|
||||
// Grace period: wait up to 2s for speculative classifier
|
||||
// to resolve before showing the dialog (main agent only)
|
||||
if (
|
||||
feature('BASH_CLASSIFIER') &&
|
||||
result.pendingClassifierCheck &&
|
||||
tool.name === BASH_TOOL_NAME &&
|
||||
!appState.toolPermissionContext
|
||||
.awaitAutomatedChecksBeforeDialog
|
||||
) {
|
||||
const speculativePromise = peekSpeculativeClassifierCheck(
|
||||
(input as { command: string }).command,
|
||||
)
|
||||
if (speculativePromise) {
|
||||
const raceResult = await Promise.race([
|
||||
speculativePromise.then(r => ({
|
||||
type: 'result' as const,
|
||||
result: r,
|
||||
})),
|
||||
new Promise<{ type: 'timeout' }>(res =>
|
||||
// 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 (
|
||||
raceResult.type === 'result' &&
|
||||
raceResult.result.matches &&
|
||||
raceResult.result.confidence === 'high' &&
|
||||
feature('BASH_CLASSIFIER')
|
||||
) {
|
||||
// Classifier approved within grace period — skip dialog
|
||||
void consumeSpeculativeClassifierCheck(
|
||||
(input as { command: string }).command,
|
||||
)
|
||||
|
||||
const matchedRule =
|
||||
raceResult.result.matchedDescription ?? undefined
|
||||
if (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}"`,
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
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 as any).result.matchedDescription}"`
|
||||
}
|
||||
}));
|
||||
return;
|
||||
// Timeout or no match — fall through to show dialog
|
||||
}
|
||||
}
|
||||
|
||||
// Show dialog and start hooks/classifier in background
|
||||
handleInteractivePermission(
|
||||
{
|
||||
ctx,
|
||||
description,
|
||||
result,
|
||||
awaitAutomatedChecksBeforeDialog:
|
||||
appState.toolPermissionContext
|
||||
.awaitAutomatedChecksBeforeDialog,
|
||||
bridgeCallbacks: feature('BRIDGE_MODE')
|
||||
? appState.replBridgePermissionCallbacks
|
||||
: undefined,
|
||||
channelCallbacks:
|
||||
feature('KAIROS') || feature('KAIROS_CHANNELS')
|
||||
? appState.channelPermissionCallbacks
|
||||
: undefined,
|
||||
},
|
||||
resolve,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
handleInteractivePermission({
|
||||
ctx,
|
||||
description,
|
||||
result,
|
||||
awaitAutomatedChecksBeforeDialog: appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog,
|
||||
bridgeCallbacks: feature("BRIDGE_MODE") ? appState.replBridgePermissionCallbacks : undefined,
|
||||
channelCallbacks: feature("KAIROS") || feature("KAIROS_CHANNELS") ? appState.channelPermissionCallbacks : undefined
|
||||
}, resolve);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}).catch(error => {
|
||||
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));
|
||||
} else {
|
||||
logError(error);
|
||||
resolve(ctx.cancelAndAbort(undefined, true));
|
||||
}
|
||||
}).finally(() => {
|
||||
clearClassifierChecking(toolUseID);
|
||||
});
|
||||
});
|
||||
$[0] = setToolPermissionContext;
|
||||
$[1] = setToolUseConfirmQueue;
|
||||
$[2] = t0;
|
||||
} else {
|
||||
t0 = $[2];
|
||||
}
|
||||
return t0;
|
||||
})
|
||||
.catch(error => {
|
||||
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))
|
||||
} else {
|
||||
logError(error)
|
||||
resolve(ctx.cancelAndAbort(undefined, true))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
clearClassifierChecking(toolUseID)
|
||||
})
|
||||
})
|
||||
},
|
||||
[setToolUseConfirmQueue, setToolPermissionContext],
|
||||
)
|
||||
}
|
||||
function _temp2(res) {
|
||||
return setTimeout(res, 2000, {
|
||||
type: "timeout" as const
|
||||
});
|
||||
}
|
||||
function _temp(r) {
|
||||
return {
|
||||
type: "result" as const,
|
||||
result: r
|
||||
};
|
||||
}
|
||||
export default useCanUseTool;
|
||||
|
||||
export default useCanUseTool
|
||||
|
||||
@@ -1,42 +1,66 @@
|
||||
import * as React from 'react';
|
||||
import { Text } from '../ink.js';
|
||||
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 '../ink.js'
|
||||
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() {
|
||||
useStartupNotification(_temp);
|
||||
}
|
||||
async function _temp() {
|
||||
const chromeFlag = getChromeFlag();
|
||||
if (!shouldEnableClaudeInChrome(chromeFlag)) {
|
||||
return null;
|
||||
}
|
||||
// Subscription check bypassed
|
||||
const installed = await isChromeExtensionInstalled();
|
||||
if (!installed && !isRunningOnHomespace()) {
|
||||
return {
|
||||
key: "chrome-extension-not-detected",
|
||||
jsx: <Text color="warning">Chrome extension not detected · https://claude.ai/chrome to install</Text>,
|
||||
priority: "immediate",
|
||||
timeoutMs: 3000
|
||||
};
|
||||
}
|
||||
if (chromeFlag === undefined) {
|
||||
return {
|
||||
key: "claude-in-chrome-default-enabled",
|
||||
text: "Claude in Chrome enabled \xB7 /chrome",
|
||||
priority: "low"
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
export function useChromeExtensionNotification(): void {
|
||||
useStartupNotification(async () => {
|
||||
const chromeFlag = getChromeFlag()
|
||||
if (!shouldEnableClaudeInChrome(chromeFlag)) return null
|
||||
|
||||
// Claude in Chrome is only supported for claude.ai subscribers (unless user is ant)
|
||||
if ("external" !== 'ant' && !isClaudeAISubscriber()) {
|
||||
return {
|
||||
key: 'chrome-requires-subscription',
|
||||
jsx: (
|
||||
<Text color="error">
|
||||
Claude in Chrome requires a claude.ai subscription
|
||||
</Text>
|
||||
),
|
||||
priority: 'immediate',
|
||||
timeoutMs: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
),
|
||||
// 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
|
||||
// (not explicitly enabled with --chrome or disabled with --no-chrome)
|
||||
return {
|
||||
key: 'claude-in-chrome-default-enabled',
|
||||
text: `Claude in Chrome enabled · /chrome`,
|
||||
priority: 'low',
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
/**
|
||||
* Surfaces plugin-install prompts driven by `<claude-code-hint />` tags
|
||||
* that CLIs/SDKs emit to stderr. See docs/claude-code-hints.md.
|
||||
@@ -9,120 +8,117 @@ import { c as _c } from "react/compiler-runtime";
|
||||
* anything that reaches this hook is worth resolving.
|
||||
*/
|
||||
|
||||
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';
|
||||
import { clearPendingHint, getPendingHintSnapshot, markShownThisSession, subscribeToPendingHint } 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';
|
||||
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'
|
||||
import {
|
||||
clearPendingHint,
|
||||
getPendingHintSnapshot,
|
||||
markShownThisSession,
|
||||
subscribeToPendingHint,
|
||||
} 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'
|
||||
|
||||
type UseClaudeCodeHintRecommendationResult = {
|
||||
recommendation: PluginHintRecommendation | null;
|
||||
handleResponse: (response: 'yes' | 'no' | 'disable') => void;
|
||||
};
|
||||
export function useClaudeCodeHintRecommendation() {
|
||||
const $ = _c(11);
|
||||
const pendingHint = React.useSyncExternalStore(subscribeToPendingHint, getPendingHintSnapshot);
|
||||
const {
|
||||
addNotification
|
||||
} = useNotifications();
|
||||
const {
|
||||
recommendation,
|
||||
clearRecommendation,
|
||||
tryResolve
|
||||
} = usePluginRecommendationBase();
|
||||
let t0;
|
||||
let t1;
|
||||
if ($[0] !== pendingHint || $[1] !== tryResolve) {
|
||||
t0 = () => {
|
||||
if (!pendingHint) {
|
||||
return;
|
||||
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>()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingHint) return
|
||||
tryResolve(async () => {
|
||||
const resolved = await resolvePluginHint(pendingHint)
|
||||
if (resolved) {
|
||||
logForDebugging(
|
||||
`[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`,
|
||||
)
|
||||
markShownThisSession()
|
||||
}
|
||||
tryResolve(async () => {
|
||||
const resolved = await resolvePluginHint(pendingHint);
|
||||
if (resolved) {
|
||||
logForDebugging(`[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`);
|
||||
markShownThisSession();
|
||||
}
|
||||
if (getPendingHintSnapshot() === pendingHint) {
|
||||
clearPendingHint();
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
};
|
||||
t1 = [pendingHint, tryResolve];
|
||||
$[0] = pendingHint;
|
||||
$[1] = tryResolve;
|
||||
$[2] = t0;
|
||||
$[3] = t1;
|
||||
} else {
|
||||
t0 = $[2];
|
||||
t1 = $[3];
|
||||
}
|
||||
React.useEffect(t0, t1);
|
||||
let t2;
|
||||
if ($[4] !== addNotification || $[5] !== clearRecommendation || $[6] !== recommendation) {
|
||||
t2 = response => {
|
||||
if (!recommendation) {
|
||||
return;
|
||||
// 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()
|
||||
}
|
||||
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
|
||||
});
|
||||
bb15: switch (response) {
|
||||
case "yes":
|
||||
{
|
||||
const {
|
||||
pluginId,
|
||||
pluginName,
|
||||
marketplaceName
|
||||
} = recommendation;
|
||||
installPluginAndNotify(pluginId, pluginName, "hint-plugin", addNotification, async pluginData => {
|
||||
return resolved
|
||||
})
|
||||
}, [pendingHint, tryResolve])
|
||||
|
||||
const handleResponse = React.useCallback(
|
||||
(response: 'yes' | 'no' | 'disable') => {
|
||||
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)
|
||||
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,
|
||||
})
|
||||
|
||||
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"
|
||||
});
|
||||
scope: 'user',
|
||||
trigger: 'hint',
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error((result as any).error);
|
||||
throw new Error(result.error)
|
||||
}
|
||||
});
|
||||
break bb15;
|
||||
}
|
||||
case "disable":
|
||||
{
|
||||
disableHintRecommendations();
|
||||
break bb15;
|
||||
}
|
||||
case "no":
|
||||
},
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'disable':
|
||||
disableHintRecommendations()
|
||||
break
|
||||
case 'no':
|
||||
break
|
||||
}
|
||||
clearRecommendation();
|
||||
};
|
||||
$[4] = addNotification;
|
||||
$[5] = clearRecommendation;
|
||||
$[6] = recommendation;
|
||||
$[7] = t2;
|
||||
} else {
|
||||
t2 = $[7];
|
||||
}
|
||||
const handleResponse = t2;
|
||||
let t3;
|
||||
if ($[8] !== handleResponse || $[9] !== recommendation) {
|
||||
t3 = {
|
||||
recommendation,
|
||||
handleResponse
|
||||
};
|
||||
$[8] = handleResponse;
|
||||
$[9] = recommendation;
|
||||
$[10] = t3;
|
||||
} else {
|
||||
t3 = $[10];
|
||||
}
|
||||
return t3;
|
||||
|
||||
clearRecommendation()
|
||||
},
|
||||
[recommendation, addNotification, clearRecommendation],
|
||||
)
|
||||
|
||||
return { recommendation, handleResponse }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
/**
|
||||
* Component that registers keybinding handlers for command bindings.
|
||||
*
|
||||
@@ -9,99 +8,75 @@ import { c as _c } from "react/compiler-runtime";
|
||||
* 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,
|
||||
// so we use a rest parameter to allow any additional args
|
||||
onSubmit: (input: string, helpers: PromptInputHelpers, ...rest: [speculationAccept?: undefined, options?: {
|
||||
fromKeybinding?: boolean;
|
||||
}]) => void;
|
||||
onSubmit: (
|
||||
input: string,
|
||||
helpers: PromptInputHelpers,
|
||||
...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: () => {}
|
||||
};
|
||||
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(t0) {
|
||||
const $ = _c(8);
|
||||
const {
|
||||
onSubmit,
|
||||
isActive: t1
|
||||
} = t0;
|
||||
const isActive = t1 === undefined ? true : t1;
|
||||
const keybindingContext = useOptionalKeybindingContext();
|
||||
const isModalOverlayActive = useIsModalOverlayActive();
|
||||
let t2;
|
||||
bb0: {
|
||||
if (!keybindingContext) {
|
||||
let t3;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t3 = new Set();
|
||||
$[0] = t3;
|
||||
} else {
|
||||
t3 = $[0];
|
||||
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>()
|
||||
for (const binding of keybindingContext.bindings) {
|
||||
if (binding.action?.startsWith('command:')) {
|
||||
actions.add(binding.action)
|
||||
}
|
||||
t2 = t3;
|
||||
break bb0;
|
||||
}
|
||||
let actions;
|
||||
if ($[1] !== keybindingContext.bindings) {
|
||||
actions = new Set();
|
||||
for (const binding of keybindingContext.bindings) {
|
||||
if (binding.action?.startsWith("command:")) {
|
||||
actions.add(binding.action);
|
||||
}
|
||||
}
|
||||
$[1] = keybindingContext.bindings;
|
||||
$[2] = actions;
|
||||
} else {
|
||||
actions = $[2];
|
||||
}
|
||||
t2 = actions;
|
||||
}
|
||||
const commandActions = t2;
|
||||
let map;
|
||||
if ($[3] !== commandActions || $[4] !== onSubmit) {
|
||||
map = {};
|
||||
return actions
|
||||
}, [keybindingContext])
|
||||
|
||||
// Build handler map for all command actions
|
||||
const handlers = useMemo(() => {
|
||||
const map: Record<string, () => void> = {}
|
||||
for (const action of commandActions) {
|
||||
const commandName = action.slice(8);
|
||||
const commandName = action.slice('command:'.length)
|
||||
map[action] = () => {
|
||||
onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, {
|
||||
fromKeybinding: true
|
||||
});
|
||||
};
|
||||
fromKeybinding: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
$[3] = commandActions;
|
||||
$[4] = onSubmit;
|
||||
$[5] = map;
|
||||
} else {
|
||||
map = $[5];
|
||||
}
|
||||
const handlers = map;
|
||||
const t3 = isActive && !isModalOverlayActive;
|
||||
let t4;
|
||||
if ($[6] !== t3) {
|
||||
t4 = {
|
||||
context: "Chat",
|
||||
isActive: t3
|
||||
};
|
||||
$[6] = t3;
|
||||
$[7] = t4;
|
||||
} else {
|
||||
t4 = $[7];
|
||||
}
|
||||
useKeybindings(handlers, t4);
|
||||
return null;
|
||||
return map
|
||||
}, [commandActions, onSubmit])
|
||||
|
||||
useKeybindings(handlers, {
|
||||
context: 'Chat',
|
||||
isActive: isActive && !isModalOverlayActive,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -4,27 +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 '../ink/instances.js';
|
||||
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';
|
||||
import { feature } from 'bun:bundle'
|
||||
import { useCallback } from 'react'
|
||||
import instances from '../ink/instances.js'
|
||||
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'
|
||||
|
||||
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:
|
||||
@@ -42,56 +46,55 @@ export function GlobalKeybindingHandlers({
|
||||
onEnterTranscript,
|
||||
onExitTranscript,
|
||||
virtualScrollActive,
|
||||
searchBarOpen = false
|
||||
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'
|
||||
});
|
||||
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;
|
||||
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
|
||||
|
||||
if (hasTeammates) {
|
||||
// Both exist: none → tasks → teammates → none
|
||||
switch (prev.expandedView) {
|
||||
case 'none':
|
||||
return {
|
||||
...prev,
|
||||
expandedView: 'tasks' as const
|
||||
};
|
||||
return { ...prev, expandedView: 'tasks' as const }
|
||||
case 'tasks':
|
||||
return {
|
||||
...prev,
|
||||
expandedView: 'teammates' as const
|
||||
};
|
||||
return { ...prev, expandedView: 'teammates' as const }
|
||||
case 'teammates':
|
||||
return {
|
||||
...prev,
|
||||
expandedView: 'none' as const
|
||||
};
|
||||
return { ...prev, expandedView: 'none' as const }
|
||||
}
|
||||
}
|
||||
// Only tasks: none ↔ tasks
|
||||
return {
|
||||
...prev,
|
||||
expandedView: prev.expandedView === 'tasks' ? 'none' as const : 'tasks' as const
|
||||
};
|
||||
});
|
||||
}, [expandedView, setAppState]);
|
||||
expandedView:
|
||||
prev.expandedView === 'tasks'
|
||||
? ('none' as const)
|
||||
: ('tasks' as const),
|
||||
}
|
||||
})
|
||||
}, [expandedView, setAppState])
|
||||
|
||||
// Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript.
|
||||
// Brief view has its own dedicated toggle on ctrl+shift+b.
|
||||
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ?
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useAppState(s_0 => s_0.isBriefOnly) : false;
|
||||
const isBriefOnly =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
const handleToggleTranscript = useCallback(() => {
|
||||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
||||
// Escape hatch: GB kill-switch while defaultView=chat was persisted
|
||||
@@ -100,58 +103,71 @@ export function GlobalKeybindingHandlers({
|
||||
// Only needed in the prompt screen — transcript mode already ignores
|
||||
// isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode).
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const {
|
||||
isBriefEnabled
|
||||
} = require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js');
|
||||
const { isBriefEnabled } =
|
||||
require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') {
|
||||
setAppState(prev_0 => {
|
||||
if (!prev_0.isBriefOnly) return prev_0;
|
||||
return {
|
||||
...prev_0,
|
||||
isBriefOnly: false
|
||||
};
|
||||
});
|
||||
return;
|
||||
setAppState(prev => {
|
||||
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_1 => s_1 === 'transcript' ? 'prompt' : 'transcript');
|
||||
setShowAllInTranscript(false);
|
||||
message_count: messageCount,
|
||||
})
|
||||
setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript'))
|
||||
setShowAllInTranscript(false)
|
||||
if (isEnteringTranscript && onEnterTranscript) {
|
||||
onEnterTranscript();
|
||||
onEnterTranscript()
|
||||
}
|
||||
if (!isEnteringTranscript && onExitTranscript) {
|
||||
onExitTranscript();
|
||||
onExitTranscript()
|
||||
}
|
||||
}, [screen, setScreen, isBriefOnly, showAllInTranscript, setShowAllInTranscript, messageCount, setAppState, onEnterTranscript, onExitTranscript]);
|
||||
}, [
|
||||
screen,
|
||||
setScreen,
|
||||
isBriefOnly,
|
||||
showAllInTranscript,
|
||||
setShowAllInTranscript,
|
||||
messageCount,
|
||||
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_1 => !prev_1);
|
||||
}, [showAllInTranscript, setShowAllInTranscript, messageCount]);
|
||||
message_count: 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);
|
||||
message_count: messageCount,
|
||||
})
|
||||
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
|
||||
@@ -160,81 +176,80 @@ export function GlobalKeybindingHandlers({
|
||||
const handleToggleBrief = useCallback(() => {
|
||||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const {
|
||||
isBriefEnabled: isBriefEnabled_0
|
||||
} = require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js');
|
||||
const { isBriefEnabled } =
|
||||
require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
if (!isBriefEnabled_0() && !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
|
||||
});
|
||||
setAppState(prev_2 => {
|
||||
if (prev_2.isBriefOnly === next) return prev_2;
|
||||
return {
|
||||
...prev_2,
|
||||
isBriefOnly: next
|
||||
};
|
||||
});
|
||||
source:
|
||||
'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
setAppState(prev => {
|
||||
if (prev.isBriefOnly === next) return prev
|
||||
return { ...prev, isBriefOnly: next }
|
||||
})
|
||||
}
|
||||
}, [isBriefOnly, setAppState]);
|
||||
}, [isBriefOnly, setAppState])
|
||||
|
||||
// Register keybinding handlers
|
||||
useKeybinding('app:toggleTodos', handleToggleTodos, {
|
||||
context: 'Global'
|
||||
});
|
||||
context: 'Global',
|
||||
})
|
||||
useKeybinding('app:toggleTranscript', handleToggleTranscript, {
|
||||
context: 'Global'
|
||||
});
|
||||
context: 'Global',
|
||||
})
|
||||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useKeybinding('app:toggleBrief', handleToggleBrief, {
|
||||
context: 'Global'
|
||||
});
|
||||
context: 'Global',
|
||||
})
|
||||
}
|
||||
|
||||
// Register teammate keybinding
|
||||
useKeybinding('app:toggleTeammatePreview', () => {
|
||||
setAppState(prev_3 => ({
|
||||
...prev_3,
|
||||
showTeammateMessagePreview: !prev_3.showTeammateMessagePreview
|
||||
}));
|
||||
}, {
|
||||
context: 'Global'
|
||||
});
|
||||
useKeybinding(
|
||||
'app:toggleTeammatePreview',
|
||||
() => {
|
||||
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'
|
||||
});
|
||||
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
|
||||
});
|
||||
isActive: isInTranscript && !virtualScrollActive,
|
||||
})
|
||||
useKeybinding('transcript:exit', handleExitTranscript, {
|
||||
context: 'Transcript',
|
||||
// Bar-open is a mode (owns keystrokes). Navigating (highlights
|
||||
@@ -242,7 +257,8 @@ export function GlobalKeybindingHandlers({
|
||||
// directly, same as less q. useSearchInput doesn't stopPropagation,
|
||||
// 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;
|
||||
isActive: isInTranscript && !searchBarOpen,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,69 +1,88 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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';
|
||||
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'
|
||||
|
||||
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>>;
|
||||
};
|
||||
export function useIDEIntegration(t0) {
|
||||
const $ = _c(7);
|
||||
const {
|
||||
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,
|
||||
ideToInstallExtension,
|
||||
setDynamicMcpConfig,
|
||||
setShowIdeOnboarding,
|
||||
setIDEInstallationState,
|
||||
}: UseIDEIntegrationProps): void {
|
||||
useEffect(() => {
|
||||
function addIde(ide: DetectedIDEInfo | null) {
|
||||
if (!ide) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if auto-connect is enabled
|
||||
const globalConfig = getGlobalConfig()
|
||||
const autoConnectEnabled =
|
||||
(globalConfig.autoConnectIde ||
|
||||
autoConnectIdeFlag ||
|
||||
isSupportedTerminal() ||
|
||||
// tmux/screen overwrite TERM_PROGRAM, breaking terminal detection, but the
|
||||
// IDE extension's port env var is inherited. If set, auto-connect anyway.
|
||||
process.env.CLAUDE_CODE_SSE_PORT ||
|
||||
ideToInstallExtension ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) &&
|
||||
!isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)
|
||||
|
||||
if (!autoConnectEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
setDynamicMcpConfig(prev => {
|
||||
// Only add the IDE if we don't already have one
|
||||
if (prev?.ide) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
ide: {
|
||||
type: ide.url.startsWith('ws:') ? 'ws-ide' : 'sse-ide',
|
||||
url: ide.url,
|
||||
ideName: ide.name,
|
||||
authToken: ide.authToken,
|
||||
ideRunningInWindows: ide.ideRunningInWindows,
|
||||
scope: 'dynamic' as const,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Use the new utility function
|
||||
void initializeIdeIntegration(
|
||||
addIde,
|
||||
ideToInstallExtension,
|
||||
() => setShowIdeOnboarding(true),
|
||||
status => setIDEInstallationState(status),
|
||||
)
|
||||
}, [
|
||||
autoConnectIdeFlag,
|
||||
ideToInstallExtension,
|
||||
setDynamicMcpConfig,
|
||||
setShowIdeOnboarding,
|
||||
setIDEInstallationState
|
||||
} = t0;
|
||||
let t1;
|
||||
let t2;
|
||||
if ($[0] !== autoConnectIdeFlag || $[1] !== ideToInstallExtension || $[2] !== setDynamicMcpConfig || $[3] !== setIDEInstallationState || $[4] !== setShowIdeOnboarding) {
|
||||
t1 = () => {
|
||||
const addIde = function addIde(ide) {
|
||||
if (!ide) {
|
||||
return;
|
||||
}
|
||||
const globalConfig = getGlobalConfig();
|
||||
const autoConnectEnabled = (globalConfig.autoConnectIde || autoConnectIdeFlag || isSupportedTerminal() || process.env.CLAUDE_CODE_SSE_PORT || ideToInstallExtension || isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) && !isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE);
|
||||
if (!autoConnectEnabled) {
|
||||
return;
|
||||
}
|
||||
setDynamicMcpConfig(prev => {
|
||||
if (prev?.ide) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
ide: {
|
||||
type: ide.url.startsWith("ws:") ? "ws-ide" : "sse-ide",
|
||||
url: ide.url,
|
||||
ideName: ide.name,
|
||||
authToken: ide.authToken,
|
||||
ideRunningInWindows: ide.ideRunningInWindows,
|
||||
scope: "dynamic" as const
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
initializeIdeIntegration(addIde, ideToInstallExtension, () => setShowIdeOnboarding(true), status => setIDEInstallationState(status));
|
||||
};
|
||||
t2 = [autoConnectIdeFlag, ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, setIDEInstallationState];
|
||||
$[0] = autoConnectIdeFlag;
|
||||
$[1] = ideToInstallExtension;
|
||||
$[2] = setDynamicMcpConfig;
|
||||
$[3] = setIDEInstallationState;
|
||||
$[4] = setShowIdeOnboarding;
|
||||
$[5] = t1;
|
||||
$[6] = t2;
|
||||
} else {
|
||||
t1 = $[5];
|
||||
t2 = $[6];
|
||||
}
|
||||
useEffect(t1, t2);
|
||||
setIDEInstallationState,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
/**
|
||||
* Hook for LSP plugin recommendations
|
||||
*
|
||||
@@ -11,183 +10,170 @@ import { c as _c } from "react/compiler-runtime";
|
||||
* 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;
|
||||
};
|
||||
export function useLspPluginRecommendation() {
|
||||
const $ = _c(12);
|
||||
const trackedFiles = useAppState(_temp);
|
||||
const {
|
||||
addNotification
|
||||
} = useNotifications();
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = new Set();
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
const checkedFilesRef = React.useRef(t0);
|
||||
const {
|
||||
recommendation,
|
||||
clearRecommendation,
|
||||
tryResolve
|
||||
} = usePluginRecommendationBase();
|
||||
let t1;
|
||||
let t2;
|
||||
if ($[1] !== trackedFiles || $[2] !== tryResolve) {
|
||||
t1 = () => {
|
||||
tryResolve(async () => {
|
||||
if (hasShownLspRecommendationThisSession()) {
|
||||
return null;
|
||||
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 { recommendation, clearRecommendation, tryResolve } =
|
||||
usePluginRecommendationBase<NonNullable<LspRecommendationState>>()
|
||||
|
||||
React.useEffect(() => {
|
||||
tryResolve(async () => {
|
||||
if (hasShownLspRecommendationThisSession()) return null
|
||||
|
||||
const newFiles: string[] = []
|
||||
for (const file of trackedFiles) {
|
||||
if (!checkedFilesRef.current.has(file)) {
|
||||
checkedFilesRef.current.add(file)
|
||||
newFiles.push(file)
|
||||
}
|
||||
const newFiles = [];
|
||||
for (const file of trackedFiles) {
|
||||
if (!checkedFilesRef.current.has(file)) {
|
||||
checkedFilesRef.current.add(file);
|
||||
newFiles.push(file);
|
||||
}
|
||||
}
|
||||
for (const filePath of newFiles) {
|
||||
;
|
||||
try {
|
||||
const matches = await getMatchingLspPlugins(filePath);
|
||||
const match = matches[0];
|
||||
if (match) {
|
||||
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 (t3) {
|
||||
const error = t3;
|
||||
logError(error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
t2 = [trackedFiles, tryResolve];
|
||||
$[1] = trackedFiles;
|
||||
$[2] = tryResolve;
|
||||
$[3] = t1;
|
||||
$[4] = t2;
|
||||
} else {
|
||||
t1 = $[3];
|
||||
t2 = $[4];
|
||||
}
|
||||
React.useEffect(t1, t2);
|
||||
let t3;
|
||||
if ($[5] !== addNotification || $[6] !== clearRecommendation || $[7] !== recommendation) {
|
||||
t3 = response => {
|
||||
if (!recommendation) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
pluginId,
|
||||
pluginName,
|
||||
shownAt
|
||||
} = recommendation;
|
||||
logForDebugging(`[useLspPluginRecommendation] User response: ${response} for ${pluginName}`);
|
||||
bb60: switch (response) {
|
||||
case "yes":
|
||||
{
|
||||
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, localSourcePath);
|
||||
const settings = getSettingsForSource("userSettings");
|
||||
updateSettingsForSource("userSettings", {
|
||||
|
||||
for (const filePath of newFiles) {
|
||||
try {
|
||||
const matches = await getMatchingLspPlugins(filePath)
|
||||
const match = matches[0] // official plugins prioritized
|
||||
if (match) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
}, [trackedFiles, tryResolve])
|
||||
|
||||
const handleResponse = React.useCallback(
|
||||
(response: 'yes' | 'no' | 'never' | 'disable') => {
|
||||
if (!recommendation) return
|
||||
|
||||
const { pluginId, pluginName, shownAt } = recommendation
|
||||
|
||||
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 bb60;
|
||||
}
|
||||
case "no":
|
||||
{
|
||||
const elapsed = Date.now() - shownAt;
|
||||
if (elapsed >= TIMEOUT_THRESHOLD_MS) {
|
||||
logForDebugging(`[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`);
|
||||
incrementIgnoredCount();
|
||||
}
|
||||
break bb60;
|
||||
}
|
||||
case "never":
|
||||
{
|
||||
addToNeverSuggest(pluginId);
|
||||
break bb60;
|
||||
}
|
||||
case "disable":
|
||||
{
|
||||
saveGlobalConfig(_temp2);
|
||||
[pluginId]: true,
|
||||
},
|
||||
})
|
||||
logForDebugging(
|
||||
`[useLspPluginRecommendation] Plugin installed: ${pluginId}`,
|
||||
)
|
||||
},
|
||||
)
|
||||
break
|
||||
|
||||
case 'no': {
|
||||
const elapsed = Date.now() - shownAt
|
||||
if (elapsed >= TIMEOUT_THRESHOLD_MS) {
|
||||
logForDebugging(
|
||||
`[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`,
|
||||
)
|
||||
incrementIgnoredCount()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'never':
|
||||
addToNeverSuggest(pluginId)
|
||||
break
|
||||
|
||||
case 'disable':
|
||||
saveGlobalConfig(current => {
|
||||
if (current.lspRecommendationDisabled) return current
|
||||
return { ...current, lspRecommendationDisabled: true }
|
||||
})
|
||||
break
|
||||
}
|
||||
clearRecommendation();
|
||||
};
|
||||
$[5] = addNotification;
|
||||
$[6] = clearRecommendation;
|
||||
$[7] = recommendation;
|
||||
$[8] = t3;
|
||||
} else {
|
||||
t3 = $[8];
|
||||
}
|
||||
const handleResponse = t3;
|
||||
let t4;
|
||||
if ($[9] !== handleResponse || $[10] !== recommendation) {
|
||||
t4 = {
|
||||
recommendation,
|
||||
handleResponse
|
||||
};
|
||||
$[9] = handleResponse;
|
||||
$[10] = recommendation;
|
||||
$[11] = t4;
|
||||
} else {
|
||||
t4 = $[11];
|
||||
}
|
||||
return t4;
|
||||
}
|
||||
function _temp2(current) {
|
||||
if (current.lspRecommendationDisabled) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
lspRecommendationDisabled: true
|
||||
};
|
||||
}
|
||||
function _temp(s) {
|
||||
return s.fileHistory.trackedFiles;
|
||||
|
||||
clearRecommendation()
|
||||
},
|
||||
[recommendation, addNotification, clearRecommendation],
|
||||
)
|
||||
|
||||
return { recommendation, handleResponse }
|
||||
}
|
||||
|
||||
@@ -1,47 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import type { Notification } from '../context/notifications.js';
|
||||
import { Text } from '../ink.js';
|
||||
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 '../ink.js'
|
||||
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
|
||||
* notifications for success/failure in the bottom right of the REPL.
|
||||
*/
|
||||
export function useOfficialMarketplaceNotification() {
|
||||
useStartupNotification(_temp);
|
||||
}
|
||||
async function _temp() {
|
||||
const result = await checkAndInstallOfficialMarketplace();
|
||||
const notifs = [];
|
||||
if (result.configSaveFailed) {
|
||||
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>,
|
||||
priority: "immediate",
|
||||
timeoutMs: 10000
|
||||
});
|
||||
}
|
||||
if (result.installed) {
|
||||
logForDebugging("Showing marketplace installation success notification");
|
||||
notifs.push({
|
||||
key: "marketplace-installed",
|
||||
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");
|
||||
export function useOfficialMarketplaceNotification(): void {
|
||||
useStartupNotification(async () => {
|
||||
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')
|
||||
notifs.push({
|
||||
key: "marketplace-install-failed",
|
||||
jsx: <Text color="warning">Failed to install Anthropic marketplace · Will retry on next startup</Text>,
|
||||
priority: "immediate",
|
||||
timeoutMs: 8000
|
||||
});
|
||||
key: 'marketplace-config-save-failed',
|
||||
jsx: (
|
||||
<Text color="error">
|
||||
Failed to save marketplace retry info · Check ~/.claude.json
|
||||
permissions
|
||||
</Text>
|
||||
),
|
||||
priority: 'immediate',
|
||||
timeoutMs: 10000,
|
||||
})
|
||||
}
|
||||
}
|
||||
return notifs;
|
||||
|
||||
if (result.installed) {
|
||||
logForDebugging('Showing marketplace installation success notification')
|
||||
notifs.push({
|
||||
key: 'marketplace-installed',
|
||||
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')
|
||||
notifs.push({
|
||||
key: 'marketplace-install-failed',
|
||||
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)
|
||||
// - policy_blocked (enterprise policy, don't nag)
|
||||
// - already_attempted (handled by retry logic now)
|
||||
// - 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
/**
|
||||
* Shared state machine + install helper for plugin-recommendation hooks
|
||||
* (LSP, claude-code-hint). Centralizes the gate chain, async-guard,
|
||||
* 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 '../ink.js';
|
||||
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>>>;
|
||||
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 '../ink.js'
|
||||
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>>>
|
||||
|
||||
/**
|
||||
* Call tryResolve inside a useEffect; it applies standard gates (remote
|
||||
@@ -21,84 +21,72 @@ type PluginData = NonNullable<Awaited<ReturnType<typeof getPluginById>>>;
|
||||
* becomes the recommendation. Include tryResolve in effect deps — its
|
||||
* identity tracks recommendation, so clearing re-triggers resolution.
|
||||
*/
|
||||
export function usePluginRecommendationBase() {
|
||||
const $ = _c(6);
|
||||
const [recommendation, setRecommendation] = React.useState(null);
|
||||
const isCheckingRef = React.useRef(false);
|
||||
let t0;
|
||||
if ($[0] !== recommendation) {
|
||||
t0 = resolve => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (recommendation) {
|
||||
return;
|
||||
}
|
||||
if (isCheckingRef.current) {
|
||||
return;
|
||||
}
|
||||
isCheckingRef.current = true;
|
||||
resolve().then(rec => {
|
||||
if (rec) {
|
||||
setRecommendation(rec);
|
||||
}
|
||||
}).catch(logError).finally(() => {
|
||||
isCheckingRef.current = false;
|
||||
});
|
||||
};
|
||||
$[0] = recommendation;
|
||||
$[1] = t0;
|
||||
} else {
|
||||
t0 = $[1];
|
||||
}
|
||||
const tryResolve = t0;
|
||||
let t1;
|
||||
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t1 = () => setRecommendation(null);
|
||||
$[2] = t1;
|
||||
} else {
|
||||
t1 = $[2];
|
||||
}
|
||||
const clearRecommendation = t1;
|
||||
let t2;
|
||||
if ($[3] !== recommendation || $[4] !== tryResolve) {
|
||||
t2 = {
|
||||
recommendation,
|
||||
clearRecommendation,
|
||||
tryResolve
|
||||
};
|
||||
$[3] = recommendation;
|
||||
$[4] = tryResolve;
|
||||
$[5] = t2;
|
||||
} else {
|
||||
t2 = $[5];
|
||||
}
|
||||
return t2;
|
||||
export function usePluginRecommendationBase<T>(): {
|
||||
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 tryResolve = React.useCallback(
|
||||
(resolve: () => Promise<T | null>) => {
|
||||
if (getIsRemoteMode()) return
|
||||
if (recommendation) return
|
||||
if (isCheckingRef.current) return
|
||||
|
||||
isCheckingRef.current = true
|
||||
void resolve()
|
||||
.then(rec => {
|
||||
if (rec) setRecommendation(rec)
|
||||
})
|
||||
.catch(logError)
|
||||
.finally(() => {
|
||||
isCheckingRef.current = false
|
||||
})
|
||||
},
|
||||
[recommendation],
|
||||
)
|
||||
|
||||
const clearRecommendation = React.useCallback(
|
||||
() => setRecommendation(null),
|
||||
[],
|
||||
)
|
||||
|
||||
return { recommendation, clearRecommendation, tryResolve }
|
||||
}
|
||||
|
||||
/** Look up plugin, run install(), emit standard success/failure notification. */
|
||||
export async function installPluginAndNotify(pluginId: string, pluginName: string, keyPrefix: string, addNotification: AddNotification, install: (pluginData: PluginData) => Promise<void>): Promise<void> {
|
||||
export async function installPluginAndNotify(
|
||||
pluginId: string,
|
||||
pluginName: string,
|
||||
keyPrefix: string,
|
||||
addNotification: AddNotification,
|
||||
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: <Text color="success">
|
||||
jsx: (
|
||||
<Text color="success">
|
||||
{figures.tick} {pluginName} installed · restart to apply
|
||||
</Text>,
|
||||
</Text>
|
||||
),
|
||||
priority: 'immediate',
|
||||
timeoutMs: 5000
|
||||
});
|
||||
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
|
||||
});
|
||||
timeoutMs: 5000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,129 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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(() => z.object({
|
||||
method: z.literal('notifications/message'),
|
||||
params: z.object({
|
||||
prompt: z.string(),
|
||||
image: z.object({
|
||||
type: z.literal('base64'),
|
||||
media_type: z.enum(['image/jpeg', 'image/png', 'image/gif', 'image/webp']),
|
||||
data: z.string()
|
||||
}).optional(),
|
||||
tabId: z.number().optional()
|
||||
})
|
||||
}));
|
||||
const ClaudeInChromePromptNotificationSchema = lazySchema(() =>
|
||||
z.object({
|
||||
method: z.literal('notifications/message'),
|
||||
params: z.object({
|
||||
prompt: z.string(),
|
||||
image: z
|
||||
.object({
|
||||
type: z.literal('base64'),
|
||||
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,
|
||||
* enqueues them as user prompts, and syncs permission mode changes to the extension.
|
||||
*/
|
||||
export function usePromptsFromClaudeInChrome(mcpClients, toolPermissionMode) {
|
||||
const $ = _c(6);
|
||||
useRef(undefined);
|
||||
let t0;
|
||||
if ($[0] !== mcpClients) {
|
||||
t0 = [mcpClients];
|
||||
$[0] = mcpClients;
|
||||
$[1] = t0;
|
||||
} else {
|
||||
t0 = $[1];
|
||||
}
|
||||
useEffect(_temp, t0);
|
||||
let t1;
|
||||
let t2;
|
||||
if ($[2] !== mcpClients || $[3] !== toolPermissionMode) {
|
||||
t1 = () => {
|
||||
const chromeClient = findChromeClient(mcpClients);
|
||||
if (!chromeClient) {
|
||||
return;
|
||||
}
|
||||
const chromeMode = toolPermissionMode === "bypassPermissions" ? "skip_all_permission_checks" : "ask";
|
||||
callIdeRpc("set_permission_mode", {
|
||||
mode: chromeMode
|
||||
}, chromeClient);
|
||||
};
|
||||
t2 = [mcpClients, toolPermissionMode];
|
||||
$[2] = mcpClients;
|
||||
$[3] = toolPermissionMode;
|
||||
$[4] = t1;
|
||||
$[5] = t2;
|
||||
} else {
|
||||
t1 = $[4];
|
||||
t2 = $[5];
|
||||
}
|
||||
useEffect(t1, t2);
|
||||
export function usePromptsFromClaudeInChrome(
|
||||
mcpClients: MCPServerConnection[],
|
||||
toolPermissionMode: PermissionMode,
|
||||
): void {
|
||||
const mcpClientRef = useRef<ConnectedMCPServer | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if ("external" !== 'ant') {
|
||||
return
|
||||
}
|
||||
|
||||
const mcpClient = findChromeClient(mcpClients)
|
||||
if (mcpClientRef.current !== mcpClient) {
|
||||
mcpClientRef.current = mcpClient
|
||||
}
|
||||
|
||||
if (mcpClient) {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}, [mcpClients])
|
||||
|
||||
// Sync permission mode with Chrome extension whenever it changes
|
||||
useEffect(() => {
|
||||
const chromeClient = findChromeClient(mcpClients)
|
||||
if (!chromeClient) return
|
||||
|
||||
const chromeMode =
|
||||
toolPermissionMode === 'bypassPermissions'
|
||||
? 'skip_all_permission_checks'
|
||||
: 'ask'
|
||||
|
||||
void callIdeRpc('set_permission_mode', { mode: chromeMode }, chromeClient)
|
||||
}, [mcpClients, toolPermissionMode])
|
||||
}
|
||||
function _temp() {}
|
||||
function findChromeClient(clients: MCPServerConnection[]): ConnectedMCPServer | undefined {
|
||||
return clients.find((client): client is ConnectedMCPServer => client.type === 'connected' && client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME);
|
||||
|
||||
function findChromeClient(
|
||||
clients: MCPServerConnection[],
|
||||
): ConnectedMCPServer | undefined {
|
||||
return clients.find(
|
||||
(client): client is ConnectedMCPServer =>
|
||||
client.type === 'connected' &&
|
||||
client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME,
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,84 +1,78 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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';
|
||||
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'
|
||||
|
||||
export type TeleportResumeError = {
|
||||
message: string;
|
||||
formattedMessage?: string;
|
||||
isOperationError: boolean;
|
||||
};
|
||||
export type TeleportSource = 'cliArg' | 'localCommand';
|
||||
export function useTeleportResume(source) {
|
||||
const $ = _c(8);
|
||||
const [isResuming, setIsResuming] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedSession, setSelectedSession] = useState(null);
|
||||
let t0;
|
||||
if ($[0] !== source) {
|
||||
t0 = async session => {
|
||||
setIsResuming(true);
|
||||
setError(null);
|
||||
setSelectedSession(session);
|
||||
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
|
||||
});
|
||||
;
|
||||
try {
|
||||
const result = await teleportResumeCodeSession(session.id);
|
||||
setTeleportedSessionInfo({
|
||||
sessionId: session.id
|
||||
});
|
||||
setIsResuming(false);
|
||||
return result;
|
||||
} catch (t1) {
|
||||
const err = t1;
|
||||
const teleportError = {
|
||||
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;
|
||||
}
|
||||
};
|
||||
$[0] = source;
|
||||
$[1] = t0;
|
||||
} else {
|
||||
t0 = $[1];
|
||||
}
|
||||
const resumeSession = t0;
|
||||
let t1;
|
||||
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t1 = () => {
|
||||
setError(null);
|
||||
};
|
||||
$[2] = t1;
|
||||
} else {
|
||||
t1 = $[2];
|
||||
}
|
||||
const clearError = t1;
|
||||
let t2;
|
||||
if ($[3] !== error || $[4] !== isResuming || $[5] !== resumeSession || $[6] !== selectedSession) {
|
||||
t2 = {
|
||||
resumeSession,
|
||||
isResuming,
|
||||
error,
|
||||
selectedSession,
|
||||
clearError
|
||||
};
|
||||
$[3] = error;
|
||||
$[4] = isResuming;
|
||||
$[5] = resumeSession;
|
||||
$[6] = selectedSession;
|
||||
$[7] = t2;
|
||||
} else {
|
||||
t2 = $[7];
|
||||
}
|
||||
return t2;
|
||||
message: string
|
||||
formattedMessage?: string
|
||||
isOperationError: boolean
|
||||
}
|
||||
|
||||
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 resumeSession = useCallback(
|
||||
async (session: CodeSession): Promise<TeleportRemoteResponse | null> => {
|
||||
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,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await teleportResumeCodeSession(session.id)
|
||||
// Track teleported session for reliability logging
|
||||
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,
|
||||
isOperationError: err instanceof TeleportOperationError,
|
||||
}
|
||||
setError(teleportError)
|
||||
setIsResuming(false)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[source],
|
||||
)
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
resumeSession,
|
||||
isResuming,
|
||||
error,
|
||||
selectedSession,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,75 +1,85 @@
|
||||
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 } from '../ink/events/keyboard-event.js';
|
||||
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 } from '../ink/events/keyboard-event.js'
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until REPL wires handleKeyDown to <Box onKeyDown>
|
||||
import { useInput } from '../ink.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';
|
||||
import { useInput } from '../ink.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') ? require('./useVoice.js') : {
|
||||
useVoice: ({
|
||||
enabled: _e
|
||||
}: {
|
||||
onTranscript: (t: string) => void;
|
||||
enabled: boolean;
|
||||
}) => ({
|
||||
state: 'idle' as const,
|
||||
handleKeyEvent: (_fallbackMs?: number) => {}
|
||||
})
|
||||
};
|
||||
const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature(
|
||||
'VOICE_MODE',
|
||||
)
|
||||
? require('./useVoice.js')
|
||||
: {
|
||||
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.
|
||||
@@ -82,60 +92,61 @@ const DEFAULT_VOICE_KEYSTROKE: ParsedKeystroke = {
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
super: 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>;
|
||||
};
|
||||
type InterimRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
setInputValueRaw: React.Dispatch<React.SetStateAction<string>>
|
||||
inputValueRef: React.RefObject<string>
|
||||
insertTextRef: React.RefObject<InsertTextHandle | null>
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
@@ -149,53 +160,59 @@ export function useVoiceIntegration({
|
||||
// defensive cleanup only removes leaks). Returns the number of
|
||||
// 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);
|
||||
// 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 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
|
||||
// structure (prefix + leading + interim + trailing + suffix), so
|
||||
// the gap is seamless once transcript text arrives.
|
||||
// Always overwrite on anchor — if a prior activation failed to start
|
||||
// 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 = '';
|
||||
if (anchor) {
|
||||
voicePrefixRef.current = stripped;
|
||||
voiceSuffixRef.current = afterCursor;
|
||||
if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) {
|
||||
gap = ' ';
|
||||
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)
|
||||
// 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 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);
|
||||
} else {
|
||||
setInputValueRaw(newValue);
|
||||
}
|
||||
return remaining;
|
||||
}, [setInputValueRaw, inputValueRef, insertTextRef]);
|
||||
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
|
||||
// structure (prefix + leading + interim + trailing + suffix), so
|
||||
// the gap is seamless once transcript text arrives.
|
||||
// Always overwrite on anchor — if a prior activation failed to start
|
||||
// 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 = ''
|
||||
if (anchor) {
|
||||
voicePrefixRef.current = stripped
|
||||
voiceSuffixRef.current = afterCursor
|
||||
if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) {
|
||||
gap = ' '
|
||||
}
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
setInputValueRaw(newValue)
|
||||
}
|
||||
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
|
||||
@@ -204,110 +221,124 @@ export function useVoiceIntegration({
|
||||
// reach the stale anchor. Without this, the gap space and stale refs
|
||||
// persist in the input.
|
||||
const resetAnchor = useCallback(() => {
|
||||
const prefix = voicePrefixRef.current;
|
||||
if (prefix === null) return;
|
||||
const suffix = voiceSuffixRef.current;
|
||||
voicePrefixRef.current = null;
|
||||
voiceSuffixRef.current = '';
|
||||
const restored = prefix + suffix;
|
||||
const prefix = voicePrefixRef.current
|
||||
if (prefix === null) return
|
||||
const suffix = voiceSuffixRef.current
|
||||
voicePrefixRef.current = null
|
||||
voiceSuffixRef.current = ''
|
||||
const restored = prefix + suffix
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(restored, prefix.length);
|
||||
insertTextRef.current.setInputWithCursor(restored, prefix.length)
|
||||
} else {
|
||||
setInputValueRaw(restored);
|
||||
setInputValueRaw(restored)
|
||||
}
|
||||
}, [setInputValueRaw, insertTextRef]);
|
||||
}, [setInputValueRaw, insertTextRef])
|
||||
|
||||
// Voice state selectors. useVoiceEnabled = user intent (settings) +
|
||||
// auth + GB kill-switch, with the auth half memoized on authVersion so
|
||||
// render loops never hit a cold keychain spawn.
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
||||
const voiceState = feature('VOICE_MODE') ?
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState) : 'idle' as const;
|
||||
const voiceInterimTranscript: string = feature('VOICE_MODE') ?
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s_0 => s_0.voiceInterimTranscript) as string : '';
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
const voiceInterimTranscript = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceInterimTranscript)
|
||||
: ''
|
||||
|
||||
// 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_0 = insertTextRef.current?.cursorOffset ?? input.length;
|
||||
voicePrefixRef.current = input.slice(0, offset_0);
|
||||
voiceSuffixRef.current = input.slice(offset_0);
|
||||
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_0 = voicePrefixRef.current;
|
||||
const suffix_0 = 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_0.length > 0 && !/\s$/.test(prefix_0) && 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_0.length > 0 && !/^\s/.test(suffix_0);
|
||||
const leadingSpace = needsSpace ? ' ' : '';
|
||||
const trailingSpace = needsTrailingSpace ? ' ' : '';
|
||||
const newValue_0 = prefix_0 + leadingSpace + voiceInterimTranscript + trailingSpace + suffix_0;
|
||||
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_0.length + leadingSpace.length + voiceInterimTranscript.length;
|
||||
const cursorPos =
|
||||
prefix.length + leadingSpace.length + voiceInterimTranscript.length
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newValue_0, cursorPos);
|
||||
insertTextRef.current.setInputWithCursor(newValue, cursorPos)
|
||||
} else {
|
||||
setInputValueRaw(newValue_0);
|
||||
setInputValueRaw(newValue)
|
||||
}
|
||||
lastSetInputRef.current = newValue_0;
|
||||
}, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]);
|
||||
const handleVoiceTranscript = useCallback((text: string) => {
|
||||
if (!feature('VOICE_MODE')) return;
|
||||
const prefix_1 = voicePrefixRef.current;
|
||||
// No voice anchor — voice was reset (or never started). Nothing to do.
|
||||
if (prefix_1 === null) return;
|
||||
const suffix_1 = 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_0 = prefix_1.length > 0 && !/\s$/.test(prefix_1) && text.length > 0;
|
||||
const needsTrailingSpace_0 = suffix_1.length > 0 && !/^\s/.test(suffix_1) && text.length > 0;
|
||||
const leadingSpace_0 = needsSpace_0 ? ' ' : '';
|
||||
const trailingSpace_0 = needsTrailingSpace_0 ? ' ' : '';
|
||||
const newInput = prefix_1 + leadingSpace_0 + text + trailingSpace_0 + suffix_1;
|
||||
// Position cursor after the transcribed text (before suffix)
|
||||
const cursorPos_0 = prefix_1.length + leadingSpace_0.length + text.length;
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newInput, cursorPos_0);
|
||||
} else {
|
||||
setInputValueRaw(newInput);
|
||||
}
|
||||
lastSetInputRef.current = newInput;
|
||||
// Update the prefix to include this chunk so focus mode can continue
|
||||
// appending subsequent transcripts after it.
|
||||
voicePrefixRef.current = prefix_1 + leadingSpace_0 + text;
|
||||
}, [setInputValueRaw, inputValueRef, insertTextRef]);
|
||||
lastSetInputRef.current = newValue
|
||||
}, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef])
|
||||
|
||||
const handleVoiceTranscript = useCallback(
|
||||
(text: string) => {
|
||||
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
|
||||
// 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
|
||||
// Position cursor after the transcribed text (before suffix)
|
||||
const cursorPos = prefix.length + leadingSpace.length + text.length
|
||||
if (insertTextRef.current) {
|
||||
insertTextRef.current.setInputWithCursor(newInput, cursorPos)
|
||||
} else {
|
||||
setInputValueRaw(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
|
||||
},
|
||||
[setInputValueRaw, inputValueRef, insertTextRef],
|
||||
)
|
||||
|
||||
const voice = voiceNs.useVoice({
|
||||
onTranscript: handleVoiceTranscript,
|
||||
onError: (message: string) => {
|
||||
@@ -316,34 +347,35 @@ export function useVoiceIntegration({
|
||||
text: message,
|
||||
color: 'error',
|
||||
priority: 'immediate',
|
||||
timeoutMs: 10_000
|
||||
});
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
},
|
||||
enabled: voiceEnabled,
|
||||
focusMode: false
|
||||
});
|
||||
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_2 = voicePrefixRef.current;
|
||||
const needsSpace_1 = prefix_2.length > 0 && !/\s$/.test(prefix_2) && voiceInterimTranscript.length > 0;
|
||||
const start = prefix_2.length + (needsSpace_1 ? 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
|
||||
};
|
||||
interimRange,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,24 +406,23 @@ export function useVoiceKeybindingHandler({
|
||||
voiceHandleKeyEvent,
|
||||
stripTrailing,
|
||||
resetAnchor,
|
||||
isActive
|
||||
isActive,
|
||||
}: {
|
||||
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();
|
||||
voiceHandleKeyEvent: (fallbackMs?: number) => void
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number
|
||||
resetAnchor: () => void
|
||||
isActive: boolean
|
||||
}): { handleKeyDown: (e: KeyboardEvent) => void } {
|
||||
const getVoiceState = useGetVoiceState()
|
||||
const setVoiceState = useSetVoiceState()
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
const isModalOverlayActive = useIsModalOverlayActive()
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
||||
const voiceState = feature('VOICE_MODE') ?
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState) : 'idle';
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState)
|
||||
: 'idle'
|
||||
|
||||
// Find the configured key for voice:pushToTalk from keybinding context.
|
||||
// Forward iteration with last-wins (matching the resolver): if a later
|
||||
@@ -403,22 +434,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"),
|
||||
@@ -426,8 +457,18 @@ export function useVoiceKeybindingHandler({
|
||||
// Modifier combos (meta+k, ctrl+x) also auto-repeat (the letter part
|
||||
// repeats) but don't insert text, so they're swallowed from the first
|
||||
// press with no stripping needed. matchesKeyboardEvent handles those.
|
||||
const bareChar = voiceKeystroke !== null && voiceKeystroke.key.length === 1 && !voiceKeystroke.ctrl && !voiceKeystroke.alt && !voiceKeystroke.shift && !voiceKeystroke.meta && !voiceKeystroke.super ? voiceKeystroke.key : null;
|
||||
const rapidCountRef = useRef(0);
|
||||
const bareChar =
|
||||
voiceKeystroke !== null &&
|
||||
voiceKeystroke.key.length === 1 &&
|
||||
!voiceKeystroke.ctrl &&
|
||||
!voiceKeystroke.alt &&
|
||||
!voiceKeystroke.shift &&
|
||||
!voiceKeystroke.meta &&
|
||||
!voiceKeystroke.super
|
||||
? voiceKeystroke.key
|
||||
: null
|
||||
|
||||
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
|
||||
@@ -436,15 +477,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
|
||||
@@ -452,21 +493,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).
|
||||
@@ -476,32 +515,37 @@ 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
|
||||
@@ -511,22 +555,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
|
||||
});
|
||||
floor: recordingFloorRef.current,
|
||||
})
|
||||
}
|
||||
voiceHandleKeyEvent();
|
||||
return;
|
||||
voiceHandleKeyEvent()
|
||||
return
|
||||
}
|
||||
|
||||
// Non-hold recording (focus-mode) or processing is active.
|
||||
@@ -536,11 +580,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
|
||||
@@ -550,42 +595,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;
|
||||
setVoiceState(prev_0 => {
|
||||
if (!prev_0.voiceWarmingUp) return prev_0;
|
||||
return {
|
||||
...prev_0,
|
||||
voiceWarmingUp: false
|
||||
};
|
||||
});
|
||||
rapidCountRef.current = 0
|
||||
isHoldActiveRef.current = true
|
||||
setVoiceState(prev => {
|
||||
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
|
||||
@@ -594,10 +634,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) ──
|
||||
@@ -610,67 +650,74 @@ 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
|
||||
});
|
||||
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_1 => {
|
||||
if (prev_1.voiceWarmingUp) return prev_1;
|
||||
return {
|
||||
...prev_1,
|
||||
voiceWarmingUp: true
|
||||
};
|
||||
});
|
||||
setVoiceState(prev => {
|
||||
if (prev.voiceWarmingUp) return prev
|
||||
return { ...prev, voiceWarmingUp: true }
|
||||
})
|
||||
}
|
||||
|
||||
if (resetTimerRef.current) {
|
||||
clearTimeout(resetTimerRef.current);
|
||||
clearTimeout(resetTimerRef.current)
|
||||
}
|
||||
resetTimerRef.current = setTimeout((resetTimerRef_0, rapidCountRef_0, charsInInputRef_0, setVoiceState_0) => {
|
||||
resetTimerRef_0.current = null;
|
||||
rapidCountRef_0.current = 0;
|
||||
charsInInputRef_0.current = 0;
|
||||
setVoiceState_0(prev_2 => {
|
||||
if (!prev_2.voiceWarmingUp) return prev_2;
|
||||
return {
|
||||
...prev_2,
|
||||
voiceWarmingUp: false
|
||||
};
|
||||
});
|
||||
}, RAPID_KEY_GAP_MS, resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState);
|
||||
};
|
||||
resetTimerRef.current = setTimeout(
|
||||
(resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState) => {
|
||||
resetTimerRef.current = null
|
||||
rapidCountRef.current = 0
|
||||
charsInInputRef.current = 0
|
||||
setVoiceState(prev => {
|
||||
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 →
|
||||
// KeyboardEvent until the consumer is migrated (separate PR).
|
||||
// TODO(onKeyDown-migration): remove once REPL passes handleKeyDown.
|
||||
useInput((_input, _key, event) => {
|
||||
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();
|
||||
}
|
||||
}, {
|
||||
isActive
|
||||
});
|
||||
return {
|
||||
handleKeyDown
|
||||
};
|
||||
useInput(
|
||||
(_input, _key, event) => {
|
||||
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()
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
)
|
||||
|
||||
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) {
|
||||
useVoiceKeybindingHandler(props);
|
||||
return null;
|
||||
export function VoiceKeybindingHandler(props: {
|
||||
voiceHandleKeyEvent: (fallbackMs?: number) => void
|
||||
stripTrailing: (maxStrip: number, opts?: StripOpts) => number
|
||||
resetAnchor: () => void
|
||||
isActive: boolean
|
||||
}): null {
|
||||
useVoiceKeybindingHandler(props)
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user