更新大量 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:
claude-code-best
2026-04-04 23:24:27 +08:00
committed by GitHub
parent 02694918b5
commit 5b1a52b8e0
559 changed files with 103807 additions and 101817 deletions

View File

@@ -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
}

View File

@@ -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])
}

View File

@@ -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}`
}
}

View File

@@ -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])
}

View File

@@ -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',
}
})
})
}

View File

@@ -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])
}

View File

@@ -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])
}

View File

@@ -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
}

View File

@@ -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',
}
})
}

View File

@@ -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])
}

View File

@@ -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,
])
}

View File

@@ -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])
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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
})
}

View File

@@ -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 }
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
])
}

View File

@@ -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 }
}

View File

@@ -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
})
}

View File

@@ -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,
})
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}