style: 完成所有文件的lint

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

View File

@@ -1,23 +1,17 @@
import * as React from 'react'
import { Box, Text, stringWidth } from '@anthropic/ink'
import TextInput from '../TextInput.js'
import * as React from 'react';
import { Box, Text, stringWidth } from '@anthropic/ink';
import TextInput from '../TextInput.js';
type Props = {
value: string
onChange: (value: string) => void
historyFailedMatch: boolean
}
value: string;
onChange: (value: string) => void;
historyFailedMatch: boolean;
};
function HistorySearchInput({
value,
onChange,
historyFailedMatch,
}: Props): React.ReactNode {
function HistorySearchInput({ value, onChange, historyFailedMatch }: Props): React.ReactNode {
return (
<Box gap={1}>
<Text dimColor>
{historyFailedMatch ? 'no matching prompt:' : 'search prompts:'}
</Text>
<Text dimColor>{historyFailedMatch ? 'no matching prompt:' : 'search prompts:'}</Text>
<TextInput
value={value}
onChange={onChange}
@@ -31,7 +25,7 @@ function HistorySearchInput({
dimColor={true}
/>
</Box>
)
);
}
export default HistorySearchInput
export default HistorySearchInput;

View File

@@ -1,6 +1,6 @@
import * as React from 'react'
import { FLAG_ICON } from '../../constants/figures.js'
import { Box, Text } from '@anthropic/ink'
import * as React from 'react';
import { FLAG_ICON } from '../../constants/figures.js';
import { Box, Text } from '@anthropic/ink';
/**
* ANT-ONLY: Banner shown in the transcript that prompts users to report
@@ -8,7 +8,7 @@ import { Box, Text } from '@anthropic/ink'
*/
export function IssueFlagBanner(): React.ReactNode {
if (process.env.USER_TYPE !== 'ant') {
return null
return null;
}
return (
@@ -24,5 +24,5 @@ export function IssueFlagBanner(): React.ReactNode {
<Text dimColor> /issue to report it</Text>
</Text>
</Box>
)
);
}

View File

@@ -1,67 +1,59 @@
import { feature } from 'bun:bundle'
import * as React from 'react'
import { type ReactNode, useEffect, useMemo, useState } from 'react'
import {
type Notification,
useNotifications,
} from 'src/context/notifications.js'
import { logEvent } from 'src/services/analytics/index.js'
import { useAppState } from 'src/state/AppState.js'
import { useVoiceState } from '../../context/voice.js'
import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'
import { useIdeConnectionStatus } from '../../hooks/useIdeConnectionStatus.js'
import type { IDESelection } from '../../hooks/useIdeSelection.js'
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'
import { Box, Text } from '@anthropic/ink'
import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js'
import { calculateTokenWarningState } from '../../services/compact/autoCompact.js'
import type { MCPServerConnection } from '../../services/mcp/types.js'
import type { Message } from '../../types/message.js'
import {
getApiKeyHelperElapsedMs,
getConfiguredApiKeyHelper,
getSubscriptionType,
} from '../../utils/auth.js'
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'
import { getExternalEditor } from '../../utils/editor.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { formatDuration } from '../../utils/format.js'
import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js'
import { toIDEDisplayName } from '../../utils/ide.js'
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js'
import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js'
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
import { IdeStatusIndicator } from '../IdeStatusIndicator.js'
import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js'
import { SentryErrorBoundary } from '../SentryErrorBoundary.js'
import { TokenWarning } from '../TokenWarning.js'
import { SandboxPromptFooterHint } from './SandboxPromptFooterHint.js'
import { feature } from 'bun:bundle';
import * as React from 'react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { type Notification, useNotifications } from 'src/context/notifications.js';
import { logEvent } from 'src/services/analytics/index.js';
import { useAppState } from 'src/state/AppState.js';
import { useVoiceState } from '../../context/voice.js';
import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js';
import { useIdeConnectionStatus } from '../../hooks/useIdeConnectionStatus.js';
import type { IDESelection } from '../../hooks/useIdeSelection.js';
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js';
import { Box, Text } from '@anthropic/ink';
import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js';
import { calculateTokenWarningState } from '../../services/compact/autoCompact.js';
import type { MCPServerConnection } from '../../services/mcp/types.js';
import type { Message } from '../../types/message.js';
import { getApiKeyHelperElapsedMs, getConfiguredApiKeyHelper, getSubscriptionType } from '../../utils/auth.js';
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js';
import { getExternalEditor } from '../../utils/editor.js';
import { isEnvTruthy } from '../../utils/envUtils.js';
import { formatDuration } from '../../utils/format.js';
import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js';
import { toIDEDisplayName } from '../../utils/ide.js';
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js';
import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { IdeStatusIndicator } from '../IdeStatusIndicator.js';
import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js';
import { SentryErrorBoundary } from '../SentryErrorBoundary.js';
import { TokenWarning } from '../TokenWarning.js';
import { SandboxPromptFooterHint } from './SandboxPromptFooterHint.js';
/* eslint-disable @typescript-eslint/no-require-imports */
const VoiceIndicator: typeof import('./VoiceIndicator.js').VoiceIndicator =
feature('VOICE_MODE')
? require('./VoiceIndicator.js').VoiceIndicator
: () => null
const VoiceIndicator: typeof import('./VoiceIndicator.js').VoiceIndicator = feature('VOICE_MODE')
? require('./VoiceIndicator.js').VoiceIndicator
: () => null;
/* eslint-enable @typescript-eslint/no-require-imports */
export const FOOTER_TEMPORARY_STATUS_TIMEOUT = 5000
export const FOOTER_TEMPORARY_STATUS_TIMEOUT = 5000;
type Props = {
apiKeyStatus: VerificationStatus
autoUpdaterResult: AutoUpdaterResult | null
isAutoUpdating: boolean
debug: boolean
verbose: boolean
messages: Message[]
onAutoUpdaterResult: (result: AutoUpdaterResult) => void
onChangeIsUpdating: (isUpdating: boolean) => void
ideSelection: IDESelection | undefined
mcpClients?: MCPServerConnection[]
isInputWrapped?: boolean
isNarrow?: boolean
}
apiKeyStatus: VerificationStatus;
autoUpdaterResult: AutoUpdaterResult | null;
isAutoUpdating: boolean;
debug: boolean;
verbose: boolean;
messages: Message[];
onAutoUpdaterResult: (result: AutoUpdaterResult) => void;
onChangeIsUpdating: (isUpdating: boolean) => void;
ideSelection: IDESelection | undefined;
mcpClients?: MCPServerConnection[];
isInputWrapped?: boolean;
isNarrow?: boolean;
};
export function Notifications({
apiKeyStatus,
@@ -78,22 +70,19 @@ export function Notifications({
isNarrow = false,
}: Props): ReactNode {
const tokenUsage = useMemo(() => {
const messagesForTokenCount = getMessagesAfterCompactBoundary(messages)
return tokenCountFromLastAPIResponse(messagesForTokenCount)
}, [messages])
const messagesForTokenCount = getMessagesAfterCompactBoundary(messages);
return tokenCountFromLastAPIResponse(messagesForTokenCount);
}, [messages]);
// AppState-sourced model — same source as API requests. getMainLoopModel()
// re-reads settings.json on every call, so another session's /model write
// would leak into this session's display (anthropics/claude-code#37596).
const mainLoopModel = useMainLoopModel()
const isShowingCompactMessage = calculateTokenWarningState(
tokenUsage,
mainLoopModel,
).isAboveWarningThreshold
const { status: ideStatus } = useIdeConnectionStatus(mcpClients)
const notifications = useAppState(s => s.notifications)
const { addNotification, removeNotification } = useNotifications()
const claudeAiLimits = useClaudeAiLimits()
const mainLoopModel = useMainLoopModel();
const isShowingCompactMessage = calculateTokenWarningState(tokenUsage, mainLoopModel).isAboveWarningThreshold;
const { status: ideStatus } = useIdeConnectionStatus(mcpClients);
const notifications = useAppState(s => s.notifications);
const { addNotification, removeNotification } = useNotifications();
const claudeAiLimits = useClaudeAiLimits();
// Register env hook notifier for CwdChanged/FileChanged feedback
useEffect(() => {
@@ -104,42 +93,36 @@ export function Notifications({
color: isError ? 'error' : undefined,
priority: isError ? 'medium' : 'low',
timeoutMs: isError ? 8000 : 5000,
})
})
return () => setEnvHookNotifier(null)
}, [addNotification])
});
});
return () => setEnvHookNotifier(null);
}, [addNotification]);
// Check if we should show the IDE selection indicator
const shouldShowIdeSelection =
ideStatus === 'connected' &&
(ideSelection?.filePath ||
(ideSelection?.text && ideSelection.lineCount > 0))
ideStatus === 'connected' && (ideSelection?.filePath || (ideSelection?.text && ideSelection.lineCount > 0));
// Hide update installed message when showing IDE selection
const shouldShowAutoUpdater =
!shouldShowIdeSelection ||
isAutoUpdating ||
autoUpdaterResult?.status !== 'success'
const shouldShowAutoUpdater = !shouldShowIdeSelection || isAutoUpdating || autoUpdaterResult?.status !== 'success';
// Check if we're in overage mode for UI indicators
const isInOverageMode = claudeAiLimits.isUsingOverage
const subscriptionType = getSubscriptionType()
const isTeamOrEnterprise =
subscriptionType === 'team' || subscriptionType === 'enterprise'
const isInOverageMode = claudeAiLimits.isUsingOverage;
const subscriptionType = getSubscriptionType();
const isTeamOrEnterprise = subscriptionType === 'team' || subscriptionType === 'enterprise';
// Check if the external editor hint should be shown
const editor = getExternalEditor()
const editor = getExternalEditor();
const shouldShowExternalEditorHint =
isInputWrapped &&
!isShowingCompactMessage &&
apiKeyStatus !== 'invalid' &&
apiKeyStatus !== 'missing' &&
editor !== undefined
editor !== undefined;
// Show external editor hint as notification when input is wrapped
useEffect(() => {
if (shouldShowExternalEditorHint && editor) {
logEvent('tengu_external_editor_hint_shown', {})
logEvent('tengu_external_editor_hint_shown', {});
addNotification({
key: 'external-editor-hint',
jsx: (
@@ -154,25 +137,15 @@ export function Notifications({
),
priority: 'immediate',
timeoutMs: 5000,
})
});
} else {
removeNotification('external-editor-hint')
removeNotification('external-editor-hint');
}
}, [
shouldShowExternalEditorHint,
editor,
addNotification,
removeNotification,
])
}, [shouldShowExternalEditorHint, editor, addNotification, removeNotification]);
return (
<SentryErrorBoundary>
<Box
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
flexShrink={0}
overflowX="hidden"
>
<Box flexDirection="column" alignItems={isNarrow ? 'flex-start' : 'flex-end'} flexShrink={0} overflowX="hidden">
<NotificationContent
ideSelection={ideSelection}
mcpClients={mcpClients}
@@ -193,7 +166,7 @@ export function Notifications({
/>
</Box>
</SentryErrorBoundary>
)
);
}
function NotificationContent({
@@ -214,68 +187,54 @@ function NotificationContent({
onAutoUpdaterResult,
onChangeIsUpdating,
}: {
ideSelection: IDESelection | undefined
mcpClients?: MCPServerConnection[]
ideSelection: IDESelection | undefined;
mcpClients?: MCPServerConnection[];
notifications: {
current: Notification | null
queue: Notification[]
}
isInOverageMode: boolean
isTeamOrEnterprise: boolean
apiKeyStatus: VerificationStatus
debug: boolean
verbose: boolean
tokenUsage: number
mainLoopModel: string
shouldShowAutoUpdater: boolean
autoUpdaterResult: AutoUpdaterResult | null
isAutoUpdating: boolean
isShowingCompactMessage: boolean
onAutoUpdaterResult: (result: AutoUpdaterResult) => void
onChangeIsUpdating: (isUpdating: boolean) => void
current: Notification | null;
queue: Notification[];
};
isInOverageMode: boolean;
isTeamOrEnterprise: boolean;
apiKeyStatus: VerificationStatus;
debug: boolean;
verbose: boolean;
tokenUsage: number;
mainLoopModel: string;
shouldShowAutoUpdater: boolean;
autoUpdaterResult: AutoUpdaterResult | null;
isAutoUpdating: boolean;
isShowingCompactMessage: boolean;
onAutoUpdaterResult: (result: AutoUpdaterResult) => void;
onChangeIsUpdating: (isUpdating: boolean) => void;
}): ReactNode {
// Poll apiKeyHelper inflight state to show slow-helper notice.
// Gated on configuration — most users never set apiKeyHelper, so the
// effect is a no-op for them (no interval allocated).
const [apiKeyHelperSlow, setApiKeyHelperSlow] = useState<string | null>(null)
const [apiKeyHelperSlow, setApiKeyHelperSlow] = useState<string | null>(null);
useEffect(() => {
if (!getConfiguredApiKeyHelper()) return
if (!getConfiguredApiKeyHelper()) return;
const interval = setInterval(
(setSlow: React.Dispatch<React.SetStateAction<string | null>>) => {
const ms = getApiKeyHelperElapsedMs()
const next = ms >= 10_000 ? formatDuration(ms) : null
setSlow(prev => (next === prev ? prev : next))
const ms = getApiKeyHelperElapsedMs();
const next = ms >= 10_000 ? formatDuration(ms) : null;
setSlow(prev => (next === prev ? prev : next));
},
1000,
setApiKeyHelperSlow,
)
return () => clearInterval(interval)
}, [])
);
return () => clearInterval(interval);
}, []);
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
const voiceState = feature('VOICE_MODE')
?
useVoiceState(s => s.voiceState)
: ('idle' as const)
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
const voiceError = feature('VOICE_MODE')
?
useVoiceState(s => s.voiceError)
: null
const isBriefOnly =
feature('KAIROS') || feature('KAIROS_BRIEF')
?
useAppState(s => s.isBriefOnly)
: false
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
const voiceError = feature('VOICE_MODE') ? useVoiceState(s => s.voiceError) : null;
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
// When voice is actively recording or processing, replace all
// notifications with just the voice indicator.
if (
feature('VOICE_MODE') &&
voiceEnabled &&
(voiceState === 'recording' || voiceState === 'processing')
) {
return <VoiceIndicator voiceState={voiceState} />
if (feature('VOICE_MODE') && voiceEnabled && (voiceState === 'recording' || voiceState === 'processing')) {
return <VoiceIndicator voiceState={voiceState} />;
}
return (
@@ -287,11 +246,7 @@ function NotificationContent({
{notifications.current.jsx}
</Text>
) : (
<Text
color={notifications.current.color}
dimColor={!notifications.current.color}
wrap="truncate"
>
<Text color={notifications.current.color} dimColor={!notifications.current.color} wrap="truncate">
{notifications.current.text}
</Text>
))}
@@ -335,9 +290,7 @@ function NotificationContent({
</Text>
</Box>
)}
{!isBriefOnly && (
<TokenWarning tokenUsage={tokenUsage} model={mainLoopModel} />
)}
{!isBriefOnly && <TokenWarning tokenUsage={tokenUsage} model={mainLoopModel} />}
{feature('VOICE_MODE')
? voiceEnabled &&
voiceError && (
@@ -351,5 +304,5 @@ function NotificationContent({
<MemoryUsageIndicator />
<SandboxPromptFooterHint />
</>
)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,32 @@
import { feature } from 'bun:bundle'
import * as React from 'react'
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'
import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js'
import { useSetPromptOverlay } from '../../context/promptOverlayContext.js'
import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'
import type { IDESelection } from '../../hooks/useIdeSelection.js'
import { useSettings } from '../../hooks/useSettings.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text, useInput } from '@anthropic/ink'
import type { MCPServerConnection } from '../../services/mcp/types.js'
import { useRegisterOverlay } from '../../context/overlayContext.js'
import { useAppState, useSetAppState } from '../../state/AppState.js'
import type { ToolPermissionContext } from '../../Tool.js'
import type { Message } from '../../types/message.js'
import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js'
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
import { getPipeDisplayRole, isPipeControlled } from '../../utils/pipeTransport.js'
import { isUndercover } from '../../utils/undercover.js'
import {
CoordinatorTaskPanel,
useCoordinatorTaskCount,
} from '../CoordinatorAgentStatus.js'
import {
getLastAssistantMessageId,
StatusLine,
statusLineShouldDisplay,
} from '../StatusLine.js'
import { Notifications } from './Notifications.js'
import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'
import { feature } from 'bun:bundle';
import * as React from 'react';
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js';
import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js';
import { useSetPromptOverlay } from '../../context/promptOverlayContext.js';
import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js';
import type { IDESelection } from '../../hooks/useIdeSelection.js';
import { useSettings } from '../../hooks/useSettings.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, Text, useInput } from '@anthropic/ink';
import type { MCPServerConnection } from '../../services/mcp/types.js';
import { useRegisterOverlay } from '../../context/overlayContext.js';
import { useAppState, useSetAppState } from '../../state/AppState.js';
import type { ToolPermissionContext } from '../../Tool.js';
import type { Message } from '../../types/message.js';
import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js';
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js';
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
import { getPipeDisplayRole, isPipeControlled } from '../../utils/pipeTransport.js';
import { isUndercover } from '../../utils/undercover.js';
import { CoordinatorTaskPanel, useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js';
import { getLastAssistantMessageId, StatusLine, statusLineShouldDisplay } from '../StatusLine.js';
import { Notifications } from './Notifications.js';
import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js';
// Inline pipe status is shown only after /pipes sets pipeIpc.statusVisible.
import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js'
import { PromptInputHelpMenu } from './PromptInputHelpMenu.js'
import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js';
import { PromptInputHelpMenu } from './PromptInputHelpMenu.js';
type Props = {
apiKeyStatus: VerificationStatus;

View File

@@ -1,147 +1,130 @@
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import { feature } from 'bun:bundle'
import { feature } from 'bun:bundle';
// Dead code elimination: conditional import for COORDINATOR_MODE
/* eslint-disable @typescript-eslint/no-require-imports */
const coordinatorModule = feature('COORDINATOR_MODE')
? (require('../../coordinator/coordinatorMode.js') as typeof import('../../coordinator/coordinatorMode.js'))
: undefined
: undefined;
/* eslint-enable @typescript-eslint/no-require-imports */
import { Box, Text, Link } from '@anthropic/ink'
import * as React from 'react'
import figures from 'figures'
import {
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js'
import type { ToolPermissionContext } from '../../Tool.js'
import { isVimModeEnabled } from './utils.js'
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'
import { Box, Text, Link } from '@anthropic/ink';
import * as React from 'react';
import figures from 'figures';
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js';
import type { ToolPermissionContext } from '../../Tool.js';
import { isVimModeEnabled } from './utils.js';
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';
import {
isDefaultMode,
permissionModeSymbol,
permissionModeTitle,
getModeColor,
} from '../../utils/permissions/PermissionMode.js'
import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js'
import { isBackgroundTask } from '../../tasks/types.js'
import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js'
import { count } from '../../utils/array.js'
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
import { TeamStatus } from '../teams/TeamStatus.js'
import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'
import { useAppState, useAppStateStore } from 'src/state/AppState.js'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import HistorySearchInput from './HistorySearchInput.js'
import { usePrStatus } from '../../hooks/usePrStatus.js'
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { useTasksV2 } from '../../hooks/useTasksV2.js'
import { formatDuration, formatFileSize } from '../../utils/format.js'
import { VoiceWarmupHint } from './VoiceIndicator.js'
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'
import { useVoiceState } from '../../context/voice.js'
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
import { isXtermJs, useHasSelection, useSelection } from '@anthropic/ink'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { getPlatform } from '../../utils/platform.js'
import { PrBadge } from '../PrBadge.js'
} from '../../utils/permissions/PermissionMode.js';
import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js';
import { isBackgroundTask } from '../../tasks/types.js';
import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js';
import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js';
import { count } from '../../utils/array.js';
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js';
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
import { TeamStatus } from '../teams/TeamStatus.js';
import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js';
import { useAppState, useAppStateStore } from 'src/state/AppState.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import HistorySearchInput from './HistorySearchInput.js';
import { usePrStatus } from '../../hooks/usePrStatus.js';
import { Byline, KeyboardShortcutHint } from '@anthropic/ink';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { useTasksV2 } from '../../hooks/useTasksV2.js';
import { formatDuration, formatFileSize } from '../../utils/format.js';
import { VoiceWarmupHint } from './VoiceIndicator.js';
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js';
import { useVoiceState } from '../../context/voice.js';
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
import { isXtermJs, useHasSelection, useSelection } from '@anthropic/ink';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
import { getPlatform } from '../../utils/platform.js';
import { PrBadge } from '../PrBadge.js';
// Dead code elimination: conditional import for proactive mode
/* eslint-disable @typescript-eslint/no-require-imports */
const proactiveModule =
feature('PROACTIVE') || feature('KAIROS')
? require('../../proactive/index.js')
: null
const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../../proactive/index.js') : null;
/* eslint-enable @typescript-eslint/no-require-imports */
const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}
const NULL = () => null
const MAX_VOICE_HINT_SHOWS = 3
const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {};
const NULL = () => null;
const MAX_VOICE_HINT_SHOWS = 3;
const RSS_UPDATE_INTERVAL_MS = 5_000
const RSS_UPDATE_INTERVAL_MS = 5_000;
type RssState = { text: string; level: 'normal' | 'warning' | 'error' }
type RssState = { text: string; level: 'normal' | 'warning' | 'error' };
function useRssDisplay(): RssState | null {
const [state, setState] = useState<RssState | null>(null)
const [state, setState] = useState<RssState | null>(null);
useEffect(() => {
function update(): void {
const mb = process.memoryUsage().rss / (1024 * 1024)
const level = mb >= 1024 ? 'error' : mb >= 512 ? 'warning' : 'normal'
const text = formatFileSize(mb * 1024 * 1024)
setState(prev => (prev?.text === text ? prev : { text, level }))
const mb = process.memoryUsage().rss / (1024 * 1024);
const level = mb >= 1024 ? 'error' : mb >= 512 ? 'warning' : 'normal';
const text = formatFileSize(mb * 1024 * 1024);
setState(prev => (prev?.text === text ? prev : { text, level }));
}
update()
const timer = setInterval(update, RSS_UPDATE_INTERVAL_MS)
return () => clearInterval(timer)
}, [])
return state
update();
const timer = setInterval(update, RSS_UPDATE_INTERVAL_MS);
return () => clearInterval(timer);
}, []);
return state;
}
type Props = {
exitMessage: {
show: boolean
key?: string
}
vimMode: VimMode | undefined
mode: PromptInputMode
toolPermissionContext: ToolPermissionContext
suppressHint: boolean
isLoading: boolean
showMemoryTypeSelector?: boolean
tasksSelected: boolean
teamsSelected: boolean
tmuxSelected: boolean
teammateFooterIndex?: number
isPasting?: boolean
isSearching: boolean
historyQuery: string
setHistoryQuery: (query: string) => void
historyFailedMatch: boolean
onOpenTasksDialog?: (taskId?: string) => void
}
show: boolean;
key?: string;
};
vimMode: VimMode | undefined;
mode: PromptInputMode;
toolPermissionContext: ToolPermissionContext;
suppressHint: boolean;
isLoading: boolean;
showMemoryTypeSelector?: boolean;
tasksSelected: boolean;
teamsSelected: boolean;
tmuxSelected: boolean;
teammateFooterIndex?: number;
isPasting?: boolean;
isSearching: boolean;
historyQuery: string;
setHistoryQuery: (query: string) => void;
historyFailedMatch: boolean;
onOpenTasksDialog?: (taskId?: string) => void;
};
function ProactiveCountdown(): React.ReactNode {
const nextTickAt = useSyncExternalStore(
proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE,
proactiveModule?.getNextTickAt ?? NULL,
NULL,
)
);
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null)
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
useEffect(() => {
if (nextTickAt === null) {
setRemainingSeconds(null)
return
setRemainingSeconds(null);
return;
}
function update(): void {
const remaining = Math.max(
0,
Math.ceil((nextTickAt! - Date.now()) / 1000),
)
setRemainingSeconds(remaining)
const remaining = Math.max(0, Math.ceil((nextTickAt! - Date.now()) / 1000));
setRemainingSeconds(remaining);
}
update()
const interval = setInterval(update, 1000)
return () => clearInterval(interval)
}, [nextTickAt])
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [nextTickAt]);
if (remainingSeconds === null) return null
if (remainingSeconds === null) return null;
return (
<Text dimColor>
waiting{' '}
{formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })}
</Text>
)
return <Text dimColor>waiting {formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })}</Text>;
}
export function PromptInputFooterLeftSide({
@@ -167,26 +150,22 @@ export function PromptInputFooterLeftSide({
<Text dimColor key="exit-message">
Press {exitMessage.key} again to exit
</Text>
)
);
}
if (isPasting) {
return (
<Text dimColor key="pasting-message">
Pasting text
</Text>
)
);
}
const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching
const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching;
return (
<Box justifyContent="flex-start" gap={1}>
{isSearching && (
<HistorySearchInput
value={historyQuery}
onChange={setHistoryQuery}
historyFailedMatch={historyFailedMatch}
/>
<HistorySearchInput value={historyQuery} onChange={setHistoryQuery} historyFailedMatch={historyFailedMatch} />
)}
{showVim ? (
<Text dimColor key="vim-insert">
@@ -205,20 +184,20 @@ export function PromptInputFooterLeftSide({
onOpenTasksDialog={onOpenTasksDialog}
/>
</Box>
)
);
}
type ModeIndicatorProps = {
mode: PromptInputMode
toolPermissionContext: ToolPermissionContext
showHint: boolean
isLoading: boolean
tasksSelected: boolean
teamsSelected: boolean
tmuxSelected: boolean
teammateFooterIndex?: number
onOpenTasksDialog?: (taskId?: string) => void
}
mode: PromptInputMode;
toolPermissionContext: ToolPermissionContext;
showHint: boolean;
isLoading: boolean;
tasksSelected: boolean;
teamsSelected: boolean;
tmuxSelected: boolean;
teammateFooterIndex?: number;
onOpenTasksDialog?: (taskId?: string) => void;
};
function ModeIndicator({
mode,
@@ -231,109 +210,70 @@ function ModeIndicator({
teammateFooterIndex,
onOpenTasksDialog,
}: ModeIndicatorProps): React.ReactNode {
const { columns } = useTerminalSize()
const modeCycleShortcut = useShortcutDisplay(
'chat:cycleMode',
'Chat',
'shift+tab',
)
const tasks = useAppState(s => s.tasks)
const teamContext = useAppState(s => s.teamContext)
const { columns } = useTerminalSize();
const modeCycleShortcut = useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab');
const tasks = useAppState(s => s.tasks);
const teamContext = useAppState(s => s.teamContext);
// Set once in initialState (main.tsx --remote mode) and never mutated — lazy
// init captures the immutable value without a subscription.
const store = useAppStateStore()
const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl)
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
const expandedView = useAppState(s => s.expandedView)
const showSpinnerTree = expandedView === 'teammates'
const prStatus = usePrStatus(isLoading, isPrStatusEnabled())
const hasTmuxSession = useAppState(
s =>
process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined,
)
const store = useAppStateStore();
const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl);
const viewSelectionMode = useAppState(s => s.viewSelectionMode);
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
const expandedView = useAppState(s => s.expandedView);
const showSpinnerTree = expandedView === 'teammates';
const prStatus = usePrStatus(isLoading, isPrStatusEnabled());
const hasTmuxSession = useAppState(s => process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined);
const nextTickAt = useSyncExternalStore(
proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE,
proactiveModule?.getNextTickAt ?? NULL,
NULL,
)
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
const voiceState = feature('VOICE_MODE')
?
useVoiceState(s => s.voiceState)
: ('idle' as const)
const voiceWarmingUp = feature('VOICE_MODE')
?
useVoiceState(s => s.voiceWarmingUp)
: false
const hasSelection = useHasSelection()
const selGetState = useSelection().getState
const hasNextTick = nextTickAt !== null
const isCoordinator = feature('COORDINATOR_MODE')
? coordinatorModule?.isCoordinatorMode() === true
: false
);
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceWarmingUp = feature('VOICE_MODE') ? useVoiceState(s => s.voiceWarmingUp) : false;
const hasSelection = useHasSelection();
const selGetState = useSelection().getState;
const hasNextTick = nextTickAt !== null;
const isCoordinator = feature('COORDINATOR_MODE') ? coordinatorModule?.isCoordinatorMode() === true : false;
const runningTaskCount = useMemo(
() =>
count(
Object.values(tasks),
t =>
isBackgroundTask(t) &&
!(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)),
t => isBackgroundTask(t) && !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)),
),
[tasks],
)
const tasksV2 = useTasksV2()
const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0
const escShortcut = useShortcutDisplay(
'chat:cancel',
'Chat',
'esc',
).toLowerCase()
const todosShortcut = useShortcutDisplay(
'app:toggleTodos',
'Global',
'ctrl+t',
)
const killAgentsShortcut = useShortcutDisplay(
'chat:killAgents',
'Chat',
'ctrl+x ctrl+k',
)
const voiceKeyShortcut = feature('VOICE_MODE')
?
useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
: ''
);
const tasksV2 = useTasksV2();
const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0;
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t');
const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k');
const voiceKeyShortcut = feature('VOICE_MODE') ? useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : '';
// Captured at mount so the hint doesn't flicker mid-session if another
// CC instance increments the counter. Incremented once via useEffect the
// first time voice is enabled in this session — approximates "hint was
// shown" without tracking the exact render-time condition (which depends
// on parts/hintParts computed after the early-return hooks boundary).
const [voiceHintUnderCap] = feature('VOICE_MODE')
?
useState(
() =>
(getGlobalConfig().voiceFooterHintSeenCount ?? 0) <
MAX_VOICE_HINT_SHOWS,
)
: [false]
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null
? useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS)
: [false];
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null;
useEffect(() => {
if (feature('VOICE_MODE')) {
if (!voiceEnabled || !voiceHintUnderCap) return
if (voiceHintIncrementedRef?.current) return
if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true
const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1
if (!voiceEnabled || !voiceHintUnderCap) return;
if (voiceHintIncrementedRef?.current) return;
if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true;
const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1;
saveGlobalConfig(prev => {
if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev
return { ...prev, voiceFooterHintSeenCount: newCount }
})
if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev;
return { ...prev, voiceFooterHintSeenCount: newCount };
});
}
}, [voiceEnabled, voiceHintUnderCap])
const isKillAgentsConfirmShowing = useAppState(
s => s.notifications.current?.key === 'kill-agents-confirm',
)
const rssState = useRssDisplay()
}, [voiceEnabled, voiceHintUnderCap]);
const isKillAgentsConfirmShowing = useAppState(s => s.notifications.current?.key === 'kill-agents-confirm');
const rssState = useRssDisplay();
// Derive team info from teamContext (no filesystem I/O needed)
// Match the same logic as TeamStatus to avoid trailing separator
@@ -342,27 +282,21 @@ function ModeIndicator({
isAgentSwarmsEnabled() &&
!isInProcessEnabled() &&
teamContext !== undefined &&
count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0
count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0;
if (mode === 'bash') {
return <Text color="bashBorder">! for bash mode</Text>
return <Text color="bashBorder">! for bash mode</Text>;
}
const currentMode = toolPermissionContext?.mode
const hasActiveMode = !isDefaultMode(currentMode)
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined
const isViewingTeammate =
viewSelectionMode === 'viewing-agent' &&
viewedTask?.type === 'in_process_teammate'
const isViewingCompletedTeammate =
isViewingTeammate && viewedTask != null && viewedTask.status !== 'running'
const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate
const currentMode = toolPermissionContext?.mode;
const hasActiveMode = !isDefaultMode(currentMode);
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined;
const isViewingTeammate = viewSelectionMode === 'viewing-agent' && viewedTask?.type === 'in_process_teammate';
const isViewingCompletedTeammate = isViewingTeammate && viewedTask != null && viewedTask.status !== 'running';
const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate;
// Count primary items (permission mode or coordinator mode, background tasks, and teams)
const primaryItemCount =
(isCoordinator || hasActiveMode ? 1 : 0) +
(hasBackgroundTasks ? 1 : 0) +
(hasTeams ? 1 : 0)
const primaryItemCount = (isCoordinator || hasActiveMode ? 1 : 0) + (hasBackgroundTasks ? 1 : 0) + (hasTeams ? 1 : 0);
// PR indicator is short (~10 chars) — unlike the old diff indicator the
// >=100 threshold was tuned for. Now that auto mode is effectively the
@@ -374,19 +308,16 @@ function ModeIndicator({
prStatus.reviewState !== null &&
prStatus.url !== null &&
primaryItemCount < 2 &&
(primaryItemCount === 0 || columns >= 80)
(primaryItemCount === 0 || columns >= 80);
// Hide the shift+tab hint when there are 2 primary items
const shouldShowModeHint = primaryItemCount < 2
const shouldShowModeHint = primaryItemCount < 2;
// Check if we have in-process teammates (showing pills)
// In spinner-tree mode, pills are disabled - teammates appear in the spinner tree instead
const hasInProcessTeammates =
!showSpinnerTree &&
hasBackgroundTasks &&
Object.values(tasks).some(t => t.type === 'in_process_teammate')
const hasTeammatePills =
hasInProcessTeammates || (!showSpinnerTree && isViewingTeammate)
!showSpinnerTree && hasBackgroundTasks && Object.values(tasks).some(t => t.type === 'in_process_teammate');
const hasTeammatePills = hasInProcessTeammates || (!showSpinnerTree && isViewingTeammate);
// In remote mode (`claude assistant`, --teleport) the agent runs elsewhere;
// the local permission mode shown here doesn't reflect the agent's state.
@@ -395,20 +326,15 @@ function ModeIndicator({
const modePart =
currentMode && hasActiveMode && !getIsRemoteMode() ? (
<Text color={getModeColor(currentMode)} key="mode">
{permissionModeSymbol(currentMode)}{' '}
{permissionModeTitle(currentMode).toLowerCase()} on
{permissionModeSymbol(currentMode)} {permissionModeTitle(currentMode).toLowerCase()} on
{shouldShowModeHint && (
<Text dimColor>
{' '}
<KeyboardShortcutHint
shortcut={modeCycleShortcut}
action="cycle"
parens
/>
<KeyboardShortcutHint shortcut={modeCycleShortcut} action="cycle" parens />
</Text>
)}
</Text>
) : null
) : null;
// Build parts array - exclude BackgroundTaskStatus when we have teammate pills
// (teammate pills get their own row)
@@ -425,27 +351,12 @@ function ModeIndicator({
// its click-target Box isn't nested inside the <Text wrap="truncate">
// wrapper (reconciler throws on Box-in-Text).
// Tmux pill (ant-only) — appears right after tasks in nav order
...(process.env.USER_TYPE === 'ant' && hasTmuxSession
? [<TungstenPill key="tmux" selected={tmuxSelected} />]
: []),
...(process.env.USER_TYPE === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []),
...(isAgentSwarmsEnabled() && hasTeams
? [
<TeamStatus
key="teams"
teamsSelected={teamsSelected}
showHint={showHint && !hasBackgroundTasks}
/>,
]
? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />]
: []),
...(shouldShowPrStatus
? [
<PrBadge
key="pr-status"
number={prStatus.number!}
url={prStatus.url!}
reviewState={prStatus.reviewState!}
/>,
]
? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />]
: []),
// RSS memory indicator — always visible
...(rssState
@@ -459,15 +370,13 @@ function ModeIndicator({
</Text>,
]
: []),
]
];
// Check if any in-process teammates exist (for hint text cycling)
const hasAnyInProcessTeammates = Object.values(tasks).some(
t => t.type === 'in_process_teammate' && t.status === 'running',
)
const hasRunningAgentTasks = Object.values(tasks).some(
t => t.type === 'local_agent' && t.status === 'running',
)
);
const hasRunningAgentTasks = Object.values(tasks).some(t => t.type === 'local_agent' && t.status === 'running');
// Get hint parts separately for potential second-line rendering
const hintParts = showHint
@@ -482,32 +391,25 @@ function ModeIndicator({
hasRunningAgentTasks,
isKillAgentsConfirmShowing,
)
: []
: [];
if (isViewingCompletedTeammate) {
parts.push(
<Text dimColor key="esc-return">
<KeyboardShortcutHint
shortcut={escShortcut}
action="return to team lead"
/>
<KeyboardShortcutHint shortcut={escShortcut} action="return to team lead" />
</Text>,
)
);
} else if ((feature('PROACTIVE') || feature('KAIROS')) && hasNextTick) {
parts.push(<ProactiveCountdown key="proactive" />)
parts.push(<ProactiveCountdown key="proactive" />);
} else if (!hasTeammatePills && showHint) {
parts.push(...hintParts)
parts.push(...hintParts);
}
// When we have teammate pills, always render them on their own line above other parts
if (hasTeammatePills) {
// Don't append spinner hints when viewing a completed teammate —
// the "esc to return to team lead" hint already replaces "esc to interrupt"
const otherParts = [
...(modePart ? [modePart] : []),
...parts,
...(isViewingCompletedTeammate ? [] : hintParts),
]
const otherParts = [...(modePart ? [modePart] : []), ...parts, ...(isViewingCompletedTeammate ? [] : hintParts)];
return (
<Box flexDirection="column">
<Box>
@@ -525,21 +427,18 @@ function ModeIndicator({
</Box>
)}
</Box>
)
);
}
// Add "↓ to manage tasks" hint when panel has visible rows
const hasCoordinatorTasks =
process.env.USER_TYPE === 'ant' && getVisibleAgentTasks(tasks).length > 0
const hasCoordinatorTasks = process.env.USER_TYPE === 'ant' && getVisibleAgentTasks(tasks).length > 0;
// Tasks pill renders as a Box sibling (not a parts entry) so its
// click-target Box isn't nested inside <Text wrap="truncate"> — the
// reconciler throws on Box-in-Text. Computed here so the empty-checks
// below still treat "pill present" as non-empty.
const tasksPart =
hasBackgroundTasks &&
!hasTeammatePills &&
!shouldHideTasksFooter(tasks, showSpinnerTree) ? (
hasBackgroundTasks && !hasTeammatePills && !shouldHideTasksFooter(tasks, showSpinnerTree) ? (
<BackgroundTaskStatus
tasksSelected={tasksSelected}
isViewingTeammate={isViewingTeammate}
@@ -547,27 +446,27 @@ function ModeIndicator({
isLeaderIdle={!isLoading}
onOpenDialog={onOpenTasksDialog}
/>
) : null
) : null;
if (parts.length === 0 && !tasksPart && !modePart && showHint) {
parts.push(
<Text dimColor key="shortcuts-hint">
? for shortcuts
</Text>,
)
);
}
// Only replace the idle voice hint when there's something to say — otherwise
// fall through instead of showing an empty Byline. "esc to clear" was removed
// (looked like "esc to interrupt" when idle; esc-clears-selection is standard
// UX) leaving only ctrl+c (copyOnSelect off) and the xterm.js native-select hint.
const copyOnSelect = getGlobalConfig().copyOnSelect ?? true
const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs())
const copyOnSelect = getGlobalConfig().copyOnSelect ?? true;
const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs());
// Warmup hint takes priority — when the user is actively holding
// the activation key, show feedback regardless of other hints.
if (feature('VOICE_MODE') && voiceEnabled && voiceWarmingUp) {
parts.push(<VoiceWarmupHint key="voice-warmup" />)
parts.push(<VoiceWarmupHint key="voice-warmup" />);
} else if (isFullscreenEnvEnabled() && selectionHintHasContent) {
// xterm.js (VS Code/Cursor/Windsurf) force-selection modifier is
// platform-specific and gated on macOS (SelectionService.shouldForceSelection):
@@ -579,26 +478,21 @@ function ModeIndicator({
// option+click hint they just tried.
// Non-reactive getState() read is safe: lastPressHadAlt is immutable
// while hasSelection is true (set pre-drag, cleared with selection).
const isMac = getPlatform() === 'macos'
const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false)
const isMac = getPlatform() === 'macos';
const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false);
parts.push(
<Text dimColor key="selection-copy">
<Byline>
{!copyOnSelect && (
<KeyboardShortcutHint shortcut="ctrl+c" action="copy" />
)}
{!copyOnSelect && <KeyboardShortcutHint shortcut="ctrl+c" action="copy" />}
{isXtermJs() &&
(altClickFailed ? (
<Text>set macOptionClickForcesSelection in VS Code settings</Text>
) : (
<KeyboardShortcutHint
shortcut={isMac ? 'option+click' : 'shift+click'}
action="native select"
/>
<KeyboardShortcutHint shortcut={isMac ? 'option+click' : 'shift+click'} action="native select" />
))}
</Byline>
</Text>,
)
);
} else if (
feature('VOICE_MODE') &&
parts.length > 0 &&
@@ -612,7 +506,7 @@ function ModeIndicator({
<Text dimColor key="voice-hint">
hold {voiceKeyShortcut} to speak
</Text>,
)
);
}
if ((tasksPart || hasCoordinatorTasks) && showHint && !hasTeams) {
@@ -624,7 +518,7 @@ function ModeIndicator({
<KeyboardShortcutHint shortcut="↓" action="manage" />
)}
</Text>,
)
);
}
// In fullscreen the bottom section is flexShrink:0 — every row here
@@ -636,7 +530,7 @@ function ModeIndicator({
// from 0→1 row. Always render 1 row in fullscreen; return a space when
// empty so Yoga reserves the row without painting anything visible.
if (parts.length === 0 && !tasksPart && !modePart) {
return isFullscreenEnvEnabled() ? <Text> </Text> : null
return isFullscreenEnvEnabled() ? <Text> </Text> : null;
}
// flexShrink=0 keeps mode + pill at natural width; the remaining parts
@@ -661,7 +555,7 @@ function ModeIndicator({
</Text>
)}
</Box>
)
);
}
function getSpinnerHintParts(
@@ -675,27 +569,27 @@ function getSpinnerHintParts(
hasRunningAgentTasks: boolean,
isKillAgentsConfirmShowing: boolean,
): React.ReactElement[] {
let toggleAction: string
let toggleAction: string;
if (hasTeammates) {
// Cycling: none → tasks → teammates → none
switch (expandedView) {
case 'none':
toggleAction = 'show tasks'
break
toggleAction = 'show tasks';
break;
case 'tasks':
toggleAction = 'show teammates'
break
toggleAction = 'show teammates';
break;
case 'teammates':
toggleAction = 'hide'
break
toggleAction = 'hide';
break;
}
} else {
toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks'
toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks';
}
// Show the toggle hint only when there are task items to display or
// teammates to cycle to
const showToggleHint = hasTaskItems || hasTeammates
const showToggleHint = hasTaskItems || hasTeammates;
return [
...(isLoading
@@ -708,26 +602,20 @@ function getSpinnerHintParts(
...(!isLoading && hasRunningAgentTasks && !isKillAgentsConfirmShowing
? [
<Text dimColor key="kill-agents">
<KeyboardShortcutHint
shortcut={killAgentsShortcut}
action="stop agents"
/>
<KeyboardShortcutHint shortcut={killAgentsShortcut} action="stop agents" />
</Text>,
]
: []),
...(showToggleHint
? [
<Text dimColor key="toggle-tasks">
<KeyboardShortcutHint
shortcut={todosShortcut}
action={toggleAction}
/>
<KeyboardShortcutHint shortcut={todosShortcut} action={toggleAction} />
</Text>,
]
: []),
]
];
}
function isPrStatusEnabled(): boolean {
return getGlobalConfig().prStatusFooterEnabled ?? true
return getGlobalConfig().prStatusFooterEnabled ?? true;
}

View File

@@ -1,18 +1,18 @@
import * as React from 'react'
import { memo, type ReactNode } from 'react'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text, stringWidth } from '@anthropic/ink'
import { truncatePathMiddle, truncateToWidth } from '../../utils/format.js'
import type { Theme } from '../../utils/theme.js'
import * as React from 'react';
import { memo, type ReactNode } from 'react';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, Text, stringWidth } from '@anthropic/ink';
import { truncatePathMiddle, truncateToWidth } from '../../utils/format.js';
import type { Theme } from '../../utils/theme.js';
export type SuggestionItem = {
id: string
displayText: string
tag?: string
description?: string
metadata?: unknown
color?: keyof Theme
}
id: string;
displayText: string;
tag?: string;
description?: string;
metadata?: unknown;
color?: keyof Theme;
};
export type SuggestionType =
| 'command'
@@ -22,30 +22,26 @@ export type SuggestionType =
| 'shell'
| 'custom-title'
| 'slack-channel'
| 'none'
| 'none';
export const OVERLAY_MAX_ITEMS = 5
export const OVERLAY_MAX_ITEMS = 5;
/**
* Get the icon for a suggestion based on its type
* Icons: + for files, ◇ for MCP resources, * for agents
*/
function getIcon(itemId: string): string {
if (itemId.startsWith('file-')) return '+'
if (itemId.startsWith('mcp-resource-')) return '◇'
if (itemId.startsWith('agent-')) return '*'
return '+'
if (itemId.startsWith('file-')) return '+';
if (itemId.startsWith('mcp-resource-')) return '◇';
if (itemId.startsWith('agent-')) return '*';
return '+';
}
/**
* Check if an item is a unified suggestion type (file, mcp-resource, or agent)
*/
function isUnifiedSuggestion(itemId: string): boolean {
return (
itemId.startsWith('file-') ||
itemId.startsWith('mcp-resource-') ||
itemId.startsWith('agent-')
)
return itemId.startsWith('file-') || itemId.startsWith('mcp-resource-') || itemId.startsWith('agent-');
}
const SuggestionItemRow = memo(function SuggestionItemRow({
@@ -53,109 +49,88 @@ const SuggestionItemRow = memo(function SuggestionItemRow({
maxColumnWidth,
isSelected,
}: {
item: SuggestionItem
maxColumnWidth?: number
isSelected: boolean
item: SuggestionItem;
maxColumnWidth?: number;
isSelected: boolean;
}): ReactNode {
const columns = useTerminalSize().columns
const isUnified = isUnifiedSuggestion(item.id)
const columns = useTerminalSize().columns;
const isUnified = isUnifiedSuggestion(item.id);
// For unified suggestions (file, mcp-resource, agent), use single-line layout with icon
if (isUnified) {
const icon = getIcon(item.id)
const textColor: keyof Theme | undefined = isSelected
? 'suggestion'
: undefined
const dimColor = !isSelected
const icon = getIcon(item.id);
const textColor: keyof Theme | undefined = isSelected ? 'suggestion' : undefined;
const dimColor = !isSelected;
const isFile = item.id.startsWith('file-')
const isMcpResource = item.id.startsWith('mcp-resource-')
const isFile = item.id.startsWith('file-');
const isMcpResource = item.id.startsWith('mcp-resource-');
// Calculate layout widths
// Layout: "X " (2) + displayText + " " (3) + description + padding (4)
const iconWidth = 2 // icon + space (fixed)
const paddingWidth = 4
const separatorWidth = item.description ? 3 : 0 // ' ' separator
const iconWidth = 2; // icon + space (fixed)
const paddingWidth = 4;
const separatorWidth = item.description ? 3 : 0; // ' ' separator
// For files, truncate middle of path to show both directory context and filename
// For MCP resources, limit displayText to 30 chars (truncate from end)
// For agents, no truncation
let displayText: string
let displayText: string;
if (isFile) {
// Reserve space for description if present, otherwise use all available space
const descReserve = item.description
? Math.min(20, stringWidth(item.description))
: 0
const maxPathLength =
columns - iconWidth - paddingWidth - separatorWidth - descReserve
displayText = truncatePathMiddle(item.displayText, maxPathLength)
const descReserve = item.description ? Math.min(20, stringWidth(item.description)) : 0;
const maxPathLength = columns - iconWidth - paddingWidth - separatorWidth - descReserve;
displayText = truncatePathMiddle(item.displayText, maxPathLength);
} else if (isMcpResource) {
const maxDisplayTextLength = 30
displayText = truncateToWidth(item.displayText, maxDisplayTextLength)
const maxDisplayTextLength = 30;
displayText = truncateToWidth(item.displayText, maxDisplayTextLength);
} else {
displayText = item.displayText
displayText = item.displayText;
}
const availableWidth =
columns -
iconWidth -
stringWidth(displayText) -
separatorWidth -
paddingWidth
const availableWidth = columns - iconWidth - stringWidth(displayText) - separatorWidth - paddingWidth;
// Build the full line as a single string to prevent wrapping
let lineContent: string
let lineContent: string;
if (item.description) {
const maxDescLength = Math.max(0, availableWidth)
const truncatedDesc = truncateToWidth(
item.description.replace(/\s+/g, ' '),
maxDescLength,
)
lineContent = `${icon} ${displayText} ${truncatedDesc}`
const maxDescLength = Math.max(0, availableWidth);
const truncatedDesc = truncateToWidth(item.description.replace(/\s+/g, ' '), maxDescLength);
lineContent = `${icon} ${displayText} ${truncatedDesc}`;
} else {
lineContent = `${icon} ${displayText}`
lineContent = `${icon} ${displayText}`;
}
return (
<Text color={textColor} dimColor={dimColor} wrap="truncate">
{lineContent}
</Text>
)
);
}
// For non-unified suggestions (commands, shell, etc.), use improved layout from main
// Cap the command name column at 40% of terminal width to ensure description has space
const maxNameWidth = Math.floor(columns * 0.4)
const displayTextWidth = Math.min(
maxColumnWidth ?? stringWidth(item.displayText) + 5,
maxNameWidth,
)
const maxNameWidth = Math.floor(columns * 0.4);
const displayTextWidth = Math.min(maxColumnWidth ?? stringWidth(item.displayText) + 5, maxNameWidth);
const textColor = item.color || (isSelected ? 'suggestion' : undefined)
const shouldDim = !isSelected
const textColor = item.color || (isSelected ? 'suggestion' : undefined);
const shouldDim = !isSelected;
// Truncate and pad the display text to fixed width
let displayText = item.displayText
let displayText = item.displayText;
if (stringWidth(displayText) > displayTextWidth - 2) {
displayText = truncateToWidth(displayText, displayTextWidth - 2)
displayText = truncateToWidth(displayText, displayTextWidth - 2);
}
const paddedDisplayText =
displayText +
' '.repeat(Math.max(0, displayTextWidth - stringWidth(displayText)))
const paddedDisplayText = displayText + ' '.repeat(Math.max(0, displayTextWidth - stringWidth(displayText)));
const tagText = item.tag ? `[${item.tag}] ` : ''
const tagWidth = stringWidth(tagText)
const descriptionWidth = Math.max(
0,
columns - displayTextWidth - tagWidth - 4,
)
const tagText = item.tag ? `[${item.tag}] ` : '';
const tagWidth = stringWidth(tagText);
const descriptionWidth = Math.max(0, columns - displayTextWidth - tagWidth - 4);
// Skill descriptions can contain newlines (e.g. /claude-api's "TRIGGER
// when:" block). A multi-line row grows the overlay past minHeight; when
// the filter narrows past that skill, the overlay shrinks and leaves
// ghost rows. Flatten to one line before truncating.
const truncatedDescription = item.description
? truncateToWidth(item.description.replace(/\s+/g, ' '), descriptionWidth)
: ''
: '';
return (
<Text wrap="truncate">
@@ -167,27 +142,24 @@ const SuggestionItemRow = memo(function SuggestionItemRow({
{tagText}
</Text>
) : null}
<Text
color={isSelected ? 'suggestion' : undefined}
dimColor={!isSelected}
>
<Text color={isSelected ? 'suggestion' : undefined} dimColor={!isSelected}>
{truncatedDescription}
</Text>
</Text>
)
})
);
});
type Props = {
suggestions: SuggestionItem[]
selectedSuggestion: number
maxColumnWidth?: number
suggestions: SuggestionItem[];
selectedSuggestion: number;
maxColumnWidth?: number;
/**
* When true, the suggestions are rendered inside a position=absolute
* overlay. We omit minHeight and flex-end so the y-clamp in the
* renderer doesn't push fewer items down into the prompt area.
*/
overlay?: boolean
}
overlay?: boolean;
};
export function PromptInputFooterSuggestions({
suggestions,
@@ -195,34 +167,27 @@ export function PromptInputFooterSuggestions({
maxColumnWidth: maxColumnWidthProp,
overlay,
}: Props): ReactNode {
const { rows } = useTerminalSize()
const { rows } = useTerminalSize();
// Maximum number of suggestions to show at once (leaving space for prompt).
// Overlay mode (fullscreen) uses a fixed 5 — the floating box sits over
// the ScrollBox, so terminal height isn't the constraint.
const maxVisibleItems = overlay
? OVERLAY_MAX_ITEMS
: Math.min(6, Math.max(1, rows - 3))
const maxVisibleItems = overlay ? OVERLAY_MAX_ITEMS : Math.min(6, Math.max(1, rows - 3));
// No suggestions to display
if (suggestions.length === 0) {
return null
return null;
}
// Use prop if provided (stable width from all commands), otherwise calculate from visible
const maxColumnWidth =
maxColumnWidthProp ??
Math.max(...suggestions.map(item => stringWidth(item.displayText))) + 5
const maxColumnWidth = maxColumnWidthProp ?? Math.max(...suggestions.map(item => stringWidth(item.displayText))) + 5;
// Calculate visible items range based on selected index
const startIndex = Math.max(
0,
Math.min(
selectedSuggestion - Math.floor(maxVisibleItems / 2),
suggestions.length - maxVisibleItems,
),
)
const endIndex = Math.min(startIndex + maxVisibleItems, suggestions.length)
const visibleItems = suggestions.slice(startIndex, endIndex)
Math.min(selectedSuggestion - Math.floor(maxVisibleItems / 2), suggestions.length - maxVisibleItems),
);
const endIndex = Math.min(startIndex + maxVisibleItems, suggestions.length);
const visibleItems = suggestions.slice(startIndex, endIndex);
// In non-overlay (inline) mode, justifyContent keeps suggestions
// anchored to the bottom (near the prompt). In overlay mode we omit
@@ -232,10 +197,7 @@ export function PromptInputFooterSuggestions({
// padding rows that shift the visible items down into the prompt area
// when the list has fewer items than maxVisibleItems.
return (
<Box
flexDirection="column"
justifyContent={overlay ? undefined : 'flex-end'}
>
<Box flexDirection="column" justifyContent={overlay ? undefined : 'flex-end'}>
{visibleItems.map(item => (
<SuggestionItemRow
key={item.id}
@@ -245,7 +207,7 @@ export function PromptInputFooterSuggestions({
/>
))}
</Box>
)
);
}
export default memo(PromptInputFooterSuggestions)
export default memo(PromptInputFooterSuggestions);

View File

@@ -1,59 +1,39 @@
import { feature } from 'bun:bundle'
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import { getPlatform } from 'src/utils/platform.js'
import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js'
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { isFastModeAvailable, isFastModeEnabled } from '../../utils/fastMode.js'
import { getNewlineInstructions } from './utils.js'
import { feature } from 'bun:bundle';
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import { getPlatform } from 'src/utils/platform.js';
import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js';
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js';
import { isFastModeAvailable, isFastModeEnabled } from '../../utils/fastMode.js';
import { getNewlineInstructions } from './utils.js';
/** Format a shortcut for display in the help menu (e.g., "ctrl+o" → "ctrl + o") */
function formatShortcut(shortcut: string): string {
return shortcut.replace(/\+/g, ' + ')
return shortcut.replace(/\+/g, ' + ');
}
type Props = {
dimColor?: boolean
fixedWidth?: boolean
gap?: number
paddingX?: number
}
dimColor?: boolean;
fixedWidth?: boolean;
gap?: number;
paddingX?: number;
};
export function PromptInputHelpMenu(props: Props): React.ReactNode {
const { dimColor, fixedWidth, gap, paddingX } = props
const { dimColor, fixedWidth, gap, paddingX } = props;
// Get configured shortcuts from keybinding system
const transcriptShortcut = formatShortcut(
useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'),
)
const todosShortcut = formatShortcut(
useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t'),
)
const undoShortcut = formatShortcut(
useShortcutDisplay('chat:undo', 'Chat', 'ctrl+_'),
)
const stashShortcut = formatShortcut(
useShortcutDisplay('chat:stash', 'Chat', 'ctrl+s'),
)
const cycleModeShortcut = formatShortcut(
useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'),
)
const modelPickerShortcut = formatShortcut(
useShortcutDisplay('chat:modelPicker', 'Chat', 'alt+p'),
)
const fastModeShortcut = formatShortcut(
useShortcutDisplay('chat:fastMode', 'Chat', 'alt+o'),
)
const externalEditorShortcut = formatShortcut(
useShortcutDisplay('chat:externalEditor', 'Chat', 'ctrl+g'),
)
const terminalShortcut = formatShortcut(
useShortcutDisplay('app:toggleTerminal', 'Global', 'meta+j'),
)
const imagePasteShortcut = formatShortcut(
useShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v'),
)
const transcriptShortcut = formatShortcut(useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'));
const todosShortcut = formatShortcut(useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t'));
const undoShortcut = formatShortcut(useShortcutDisplay('chat:undo', 'Chat', 'ctrl+_'));
const stashShortcut = formatShortcut(useShortcutDisplay('chat:stash', 'Chat', 'ctrl+s'));
const cycleModeShortcut = formatShortcut(useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'));
const modelPickerShortcut = formatShortcut(useShortcutDisplay('chat:modelPicker', 'Chat', 'alt+p'));
const fastModeShortcut = formatShortcut(useShortcutDisplay('chat:fastMode', 'Chat', 'alt+o'));
const externalEditorShortcut = formatShortcut(useShortcutDisplay('chat:externalEditor', 'Chat', 'ctrl+g'));
const terminalShortcut = formatShortcut(useShortcutDisplay('app:toggleTerminal', 'Global', 'meta+j'));
const imagePasteShortcut = formatShortcut(useShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v'));
// Compute terminal shortcut element outside JSX to satisfy feature() constraint
const terminalShortcutElement = feature('TERMINAL_PANEL') ? (
@@ -62,7 +42,7 @@ export function PromptInputHelpMenu(props: Props): React.ReactNode {
<Text dimColor={dimColor}>{terminalShortcut} for terminal</Text>
</Box>
) : null
) : null
) : null;
return (
<Box paddingX={paddingX} flexDirection="row" gap={gap}>
@@ -89,16 +69,11 @@ export function PromptInputHelpMenu(props: Props): React.ReactNode {
</Box>
<Box>
<Text dimColor={dimColor}>
{cycleModeShortcut}{' '}
{process.env.USER_TYPE === 'ant'
? 'to cycle modes'
: 'to auto-accept edits'}
{cycleModeShortcut} {process.env.USER_TYPE === 'ant' ? 'to cycle modes' : 'to auto-accept edits'}
</Text>
</Box>
<Box>
<Text dimColor={dimColor}>
{transcriptShortcut} for verbose output
</Text>
<Text dimColor={dimColor}>{transcriptShortcut} for verbose output</Text>
</Box>
<Box>
<Text dimColor={dimColor}>{todosShortcut} to toggle tasks</Text>
@@ -125,18 +100,14 @@ export function PromptInputHelpMenu(props: Props): React.ReactNode {
</Box>
{isFastModeEnabled() && isFastModeAvailable() && (
<Box>
<Text dimColor={dimColor}>
{fastModeShortcut} to toggle fast mode
</Text>
<Text dimColor={dimColor}>{fastModeShortcut} to toggle fast mode</Text>
</Box>
)}
<Box>
<Text dimColor={dimColor}>{stashShortcut} to stash prompt</Text>
</Box>
<Box>
<Text dimColor={dimColor}>
{externalEditorShortcut} to edit in $EDITOR
</Text>
<Text dimColor={dimColor}>{externalEditorShortcut} to edit in $EDITOR</Text>
</Box>
{isKeybindingCustomizationEnabled() && (
<Box>
@@ -145,5 +116,5 @@ export function PromptInputHelpMenu(props: Props): React.ReactNode {
)}
</Box>
</Box>
)
);
}

View File

@@ -1,22 +1,22 @@
import figures from 'figures'
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import figures from 'figures';
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import {
AGENT_COLOR_TO_THEME_COLOR,
AGENT_COLORS,
type AgentColorName,
} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'
import type { PromptInputMode } from 'src/types/textInputTypes.js'
import { getTeammateColor } from 'src/utils/teammate.js'
import type { Theme } from 'src/utils/theme.js'
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js';
import type { PromptInputMode } from 'src/types/textInputTypes.js';
import { getTeammateColor } from 'src/utils/teammate.js';
import type { Theme } from 'src/utils/theme.js';
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
type Props = {
mode: PromptInputMode
isLoading: boolean
viewingAgentName?: string
viewingAgentColor?: AgentColorName
}
mode: PromptInputMode;
isLoading: boolean;
viewingAgentName?: string;
viewingAgentColor?: AgentColorName;
};
/**
* Gets the theme color key for the teammate's assigned color.
@@ -24,42 +24,39 @@ type Props = {
*/
function getTeammateThemeColor(): keyof Theme | undefined {
if (!isAgentSwarmsEnabled()) {
return undefined
return undefined;
}
const colorName = getTeammateColor()
const colorName = getTeammateColor();
if (!colorName) {
return undefined
return undefined;
}
if (AGENT_COLORS.includes(colorName as AgentColorName)) {
return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName]
return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName];
}
return undefined
return undefined;
}
type PromptCharProps = {
isLoading: boolean
isLoading: boolean;
// Dead code elimination: parameter named themeColor to avoid "teammate" string in external builds
themeColor?: keyof Theme
}
themeColor?: keyof Theme;
};
/**
* Renders the prompt character ().
* Teammate color overrides the default color when set.
*/
function PromptChar({
isLoading,
themeColor,
}: PromptCharProps): React.ReactNode {
function PromptChar({ isLoading, themeColor }: PromptCharProps): React.ReactNode {
// Assign to original name for clarity within the function
const teammateColor = themeColor
const isAnt = process.env.USER_TYPE === 'ant'
const color = teammateColor ?? (isAnt ? 'subtle' : undefined)
const teammateColor = themeColor;
const isAnt = process.env.USER_TYPE === 'ant';
const color = teammateColor ?? (isAnt ? 'subtle' : undefined);
return (
<Text color={color} dimColor={isLoading}>
{figures.pointer}&nbsp;
</Text>
)
);
}
export function PromptInputModeIndicator({
@@ -68,37 +65,24 @@ export function PromptInputModeIndicator({
viewingAgentName,
viewingAgentColor,
}: Props): React.ReactNode {
const teammateColor = getTeammateThemeColor()
const teammateColor = getTeammateThemeColor();
// Convert viewed teammate's color to theme color
// Falls back to PromptChar's default (subtle for ants, undefined for external)
const viewedTeammateThemeColor = viewingAgentColor
? AGENT_COLOR_TO_THEME_COLOR[viewingAgentColor]
: undefined
const viewedTeammateThemeColor = viewingAgentColor ? AGENT_COLOR_TO_THEME_COLOR[viewingAgentColor] : undefined;
return (
<Box
alignItems="flex-start"
alignSelf="flex-start"
flexWrap="nowrap"
justifyContent="flex-start"
>
<Box alignItems="flex-start" alignSelf="flex-start" flexWrap="nowrap" justifyContent="flex-start">
{viewingAgentName ? (
// Use teammate's color on the standard prompt character, matching established style
<PromptChar
isLoading={isLoading}
themeColor={viewedTeammateThemeColor}
/>
<PromptChar isLoading={isLoading} themeColor={viewedTeammateThemeColor} />
) : mode === 'bash' ? (
<Text color="bashBorder" dimColor={isLoading}>
!&nbsp;
</Text>
) : (
<PromptChar
isLoading={isLoading}
themeColor={isAgentSwarmsEnabled() ? teammateColor : undefined}
/>
<PromptChar isLoading={isLoading} themeColor={isAgentSwarmsEnabled() ? teammateColor : undefined} />
)}
</Box>
)
);
}

View File

@@ -1,26 +1,18 @@
import { feature } from 'bun:bundle'
import * as React from 'react'
import { useMemo } from 'react'
import { Box } from '@anthropic/ink'
import { useAppState } from 'src/state/AppState.js'
import {
STATUS_TAG,
SUMMARY_TAG,
TASK_NOTIFICATION_TAG,
} from '../../constants/xml.js'
import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js'
import { useCommandQueue } from '../../hooks/useCommandQueue.js'
import type { QueuedCommand } from '../../types/textInputTypes.js'
import { isQueuedCommandVisible } from '../../utils/messageQueueManager.js'
import {
createUserMessage,
EMPTY_LOOKUPS,
normalizeMessages,
} from '../../utils/messages.js'
import { jsonParse } from '../../utils/slowOperations.js'
import { Message } from '../Message.js'
import { feature } from 'bun:bundle';
import * as React from 'react';
import { useMemo } from 'react';
import { Box } from '@anthropic/ink';
import { useAppState } from 'src/state/AppState.js';
import { STATUS_TAG, SUMMARY_TAG, TASK_NOTIFICATION_TAG } from '../../constants/xml.js';
import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js';
import { useCommandQueue } from '../../hooks/useCommandQueue.js';
import type { QueuedCommand } from '../../types/textInputTypes.js';
import { isQueuedCommandVisible } from '../../utils/messageQueueManager.js';
import { createUserMessage, EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js';
import { jsonParse } from '../../utils/slowOperations.js';
import { Message } from '../Message.js';
const EMPTY_SET = new Set<string>()
const EMPTY_SET = new Set<string>();
/**
* Check if a command value is an idle notification that should be hidden.
@@ -28,15 +20,15 @@ const EMPTY_SET = new Set<string>()
*/
function isIdleNotification(value: string): boolean {
try {
const parsed = jsonParse(value)
return parsed?.type === 'idle_notification'
const parsed = jsonParse(value);
return parsed?.type === 'idle_notification';
} catch {
return false
return false;
}
}
// Maximum number of task notification lines to show
const MAX_VISIBLE_NOTIFICATIONS = 3
const MAX_VISIBLE_NOTIFICATIONS = 3;
/**
* Create a synthetic overflow notification message for capped task notifications.
@@ -45,7 +37,7 @@ function createOverflowNotificationMessage(count: number): string {
return `<${TASK_NOTIFICATION_TAG}>
<${SUMMARY_TAG}>+${count} more tasks completed</${SUMMARY_TAG}>
<${STATUS_TAG}>completed</${STATUS_TAG}>
</${TASK_NOTIFICATION_TAG}>`
</${TASK_NOTIFICATION_TAG}>`;
}
/**
@@ -53,94 +45,76 @@ function createOverflowNotificationMessage(count: number): string {
* Other command types are always shown in full.
* Idle notifications are filtered out entirely.
*/
function processQueuedCommands(
queuedCommands: QueuedCommand[],
): QueuedCommand[] {
function processQueuedCommands(queuedCommands: QueuedCommand[]): QueuedCommand[] {
// Filter out idle notifications - they are processed silently
const filteredCommands = queuedCommands.filter(
cmd => typeof cmd.value !== 'string' || !isIdleNotification(cmd.value),
)
);
// Separate task notifications from other commands
const taskNotifications = filteredCommands.filter(
cmd => cmd.mode === 'task-notification',
)
const otherCommands = filteredCommands.filter(
cmd => cmd.mode !== 'task-notification',
)
const taskNotifications = filteredCommands.filter(cmd => cmd.mode === 'task-notification');
const otherCommands = filteredCommands.filter(cmd => cmd.mode !== 'task-notification');
// If notifications fit within limit, return all commands as-is
if (taskNotifications.length <= MAX_VISIBLE_NOTIFICATIONS) {
return [...otherCommands, ...taskNotifications]
return [...otherCommands, ...taskNotifications];
}
// Show first (MAX_VISIBLE_NOTIFICATIONS - 1) notifications, then a summary
const visibleNotifications = taskNotifications.slice(
0,
MAX_VISIBLE_NOTIFICATIONS - 1,
)
const overflowCount =
taskNotifications.length - (MAX_VISIBLE_NOTIFICATIONS - 1)
const visibleNotifications = taskNotifications.slice(0, MAX_VISIBLE_NOTIFICATIONS - 1);
const overflowCount = taskNotifications.length - (MAX_VISIBLE_NOTIFICATIONS - 1);
// Create synthetic overflow message
const overflowCommand: QueuedCommand = {
value: createOverflowNotificationMessage(overflowCount),
mode: 'task-notification',
}
};
return [...otherCommands, ...visibleNotifications, overflowCommand]
return [...otherCommands, ...visibleNotifications, overflowCommand];
}
function PromptInputQueuedCommandsImpl(): React.ReactNode {
const queuedCommands = useCommandQueue()
const viewingAgent = useAppState(s => !!s.viewingAgentTaskId)
const queuedCommands = useCommandQueue();
const viewingAgent = useAppState(s => !!s.viewingAgentTaskId);
// Brief layout: dim queue items + skip the paddingX (brief messages
// already indent themselves). Gate mirrors the brief-spinner/message
// check elsewhere — no teammate-view override needed since this
// component early-returns when viewing a teammate.
const useBriefLayout =
feature('KAIROS') || feature('KAIROS_BRIEF')
?
useAppState(s => s.isBriefOnly)
: false
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
// createUserMessage mints a fresh UUID per call; without memoization, streaming
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.
const messages = useMemo(() => {
if (queuedCommands.length === 0) return null
if (queuedCommands.length === 0) return null;
// task-notification is shown via useInboxNotification; most isMeta commands
// (scheduled tasks, proactive ticks) are system-generated and hidden.
// Channel messages are the exception — isMeta but shown so the keyboard
// user sees what arrived.
const visibleCommands = queuedCommands.filter(isQueuedCommandVisible)
if (visibleCommands.length === 0) return null
const processedCommands = processQueuedCommands(visibleCommands)
const visibleCommands = queuedCommands.filter(isQueuedCommandVisible);
if (visibleCommands.length === 0) return null;
const processedCommands = processQueuedCommands(visibleCommands);
return normalizeMessages(
processedCommands.map(cmd => {
let content = cmd.value
let content = cmd.value;
if (cmd.mode === 'bash' && typeof content === 'string') {
content = `<bash-input>${content}</bash-input>`
content = `<bash-input>${content}</bash-input>`;
}
// [Image #N] placeholders are inline in the text value (inserted at
// paste time), so the queue preview shows them without stub blocks.
return createUserMessage({ content })
return createUserMessage({ content });
}),
)
}, [queuedCommands])
);
}, [queuedCommands]);
// Don't show leader's queued commands when viewing any agent's transcript
if (viewingAgent || messages === null) {
return null
return null;
}
return (
<Box marginTop={1} flexDirection="column">
{messages.map((message, i) => (
<QueuedMessageProvider
key={i}
isFirst={i === 0}
useBriefLayout={useBriefLayout}
>
<QueuedMessageProvider key={i} isFirst={i === 0} useBriefLayout={useBriefLayout}>
<Message
message={message}
lookups={EMPTY_LOOKUPS}
@@ -158,9 +132,7 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
</QueuedMessageProvider>
))}
</Box>
)
);
}
export const PromptInputQueuedCommands = React.memo(
PromptInputQueuedCommandsImpl,
)
export const PromptInputQueuedCommands = React.memo(PromptInputQueuedCommandsImpl);

View File

@@ -1,21 +1,19 @@
import figures from 'figures'
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import figures from 'figures';
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
type Props = {
hasStash: boolean
}
hasStash: boolean;
};
export function PromptInputStashNotice({ hasStash }: Props): React.ReactNode {
if (!hasStash) {
return null
return null;
}
return (
<Box paddingLeft={2}>
<Text dimColor>
{figures.pointerSmall} Stashed (auto-restores after submit)
</Text>
<Text dimColor>{figures.pointerSmall} Stashed (auto-restores after submit)</Text>
</Box>
)
);
}

View File

@@ -1,61 +1,56 @@
import * as React from 'react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { Box, Text } from '@anthropic/ink'
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
import * as React from 'react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Text } from '@anthropic/ink';
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js';
export function SandboxPromptFooterHint(): ReactNode {
const [recentViolationCount, setRecentViolationCount] = useState(0)
const timerRef = useRef<NodeJS.Timeout | null>(null)
const detailsShortcut = useShortcutDisplay(
'app:toggleTranscript',
'Global',
'ctrl+o',
)
const [recentViolationCount, setRecentViolationCount] = useState(0);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const detailsShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o');
useEffect(() => {
if (!SandboxManager.isSandboxingEnabled()) {
return
return;
}
const store = SandboxManager.getSandboxViolationStore()
let lastCount = store.getTotalCount()
const store = SandboxManager.getSandboxViolationStore();
let lastCount = store.getTotalCount();
const unsubscribe = store.subscribe(() => {
const currentCount = store.getTotalCount()
const newViolations = currentCount - lastCount
const currentCount = store.getTotalCount();
const newViolations = currentCount - lastCount;
if (newViolations > 0) {
setRecentViolationCount(newViolations)
lastCount = currentCount
setRecentViolationCount(newViolations);
lastCount = currentCount;
if (timerRef.current) {
clearTimeout(timerRef.current)
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(setRecentViolationCount, 5000, 0)
timerRef.current = setTimeout(setRecentViolationCount, 5000, 0);
}
})
});
return () => {
unsubscribe()
unsubscribe();
if (timerRef.current) {
clearTimeout(timerRef.current)
clearTimeout(timerRef.current);
}
}
}, [])
};
}, []);
if (!SandboxManager.isSandboxingEnabled() || recentViolationCount === 0) {
return null
return null;
}
return (
<Box paddingX={0} paddingY={0}>
<Text color="inactive" wrap="truncate">
Sandbox blocked {recentViolationCount}{' '}
{recentViolationCount === 1 ? 'operation' : 'operations'} ·{' '}
Sandbox blocked {recentViolationCount} {recentViolationCount === 1 ? 'operation' : 'operations'} ·{' '}
{detailsShortcut} for details · /sandbox to disable
</Text>
</Box>
)
);
}

View File

@@ -1,21 +1,18 @@
import * as React from 'react'
import { Ansi, Box, Text, useAnimationFrame } from '@anthropic/ink'
import {
segmentTextByHighlights,
type TextHighlight,
} from '../../utils/textHighlighting.js'
import { ShimmerChar } from '../Spinner/ShimmerChar.js'
import * as React from 'react';
import { Ansi, Box, Text, useAnimationFrame } from '@anthropic/ink';
import { segmentTextByHighlights, type TextHighlight } from '../../utils/textHighlighting.js';
import { ShimmerChar } from '../Spinner/ShimmerChar.js';
type Props = {
text: string
highlights: TextHighlight[]
}
text: string;
highlights: TextHighlight[];
};
type LinePart = {
text: string
highlight: TextHighlight | undefined
start: number
}
text: string;
highlight: TextHighlight | undefined;
start: number;
};
export function HighlightedInput({ text, highlights }: Props): React.ReactNode {
// The shimmer animation (below) re-renders this component at 20fps while the
@@ -24,59 +21,57 @@ export function HighlightedInput({ text, highlights }: Props): React.ReactNode {
// that derives from them: segmentTextByHighlights alone is ~85µs/call
// (tokenize + sort + O(n²) overlap), which adds up fast at 20fps.
const { lines, hasShimmer, sweepStart, cycleLength } = React.useMemo(() => {
const segments = segmentTextByHighlights(text, highlights)
const segments = segmentTextByHighlights(text, highlights);
// Split segments by newlines into per-line groups. Ink's row-direction Box
// indents continuation lines of a multi-line child to that child's X offset.
// By splitting at newlines, each line renders as its own row, avoiding the
// incorrect indentation when highlighted text is followed by wrapped content.
const lines: LinePart[][] = [[]]
let pos = 0
const lines: LinePart[][] = [[]];
let pos = 0;
for (const segment of segments) {
const parts = segment.text.split('\n')
const parts = segment.text.split('\n');
for (let i = 0; i < parts.length; i++) {
if (i > 0) {
lines.push([])
pos += 1
lines.push([]);
pos += 1;
}
const part = parts[i]!
const part = parts[i]!;
if (part.length > 0) {
lines[lines.length - 1]!.push({
text: part,
highlight: segment.highlight,
start: pos,
})
});
}
pos += part.length
pos += part.length;
}
}
// Scope the sweep to shimmer-highlighted ranges so cycle time doesn't grow
// with input length. Padding creates an offscreen pause between sweeps.
const hasShimmer = highlights.some(h => h.shimmerColor)
let sweepStart = 0
let cycleLength = 1
const hasShimmer = highlights.some(h => h.shimmerColor);
let sweepStart = 0;
let cycleLength = 1;
if (hasShimmer) {
const padding = 10
let lo = Infinity
let hi = -Infinity
const padding = 10;
let lo = Infinity;
let hi = -Infinity;
for (const h of highlights) {
if (h.shimmerColor) {
lo = Math.min(lo, h.start)
hi = Math.max(hi, h.end)
lo = Math.min(lo, h.start);
hi = Math.max(hi, h.end);
}
}
sweepStart = lo - padding
cycleLength = hi - lo + padding * 2
sweepStart = lo - padding;
cycleLength = hi - lo + padding * 2;
}
return { lines, hasShimmer, sweepStart, cycleLength }
}, [text, highlights])
return { lines, hasShimmer, sweepStart, cycleLength };
}, [text, highlights]);
const [ref, time] = useAnimationFrame(hasShimmer ? 50 : null)
const glimmerIndex = hasShimmer
? sweepStart + (Math.floor(time / 50) % cycleLength)
: -100
const [ref, time] = useAnimationFrame(hasShimmer ? 50 : null);
const glimmerIndex = hasShimmer ? sweepStart + (Math.floor(time / 50) % cycleLength) : -100;
return (
<Box ref={ref} flexDirection="column">
@@ -100,7 +95,7 @@ export function HighlightedInput({ text, highlights }: Props): React.ReactNode {
/>
))}
</Text>
)
);
}
return (
<Text
@@ -111,11 +106,11 @@ export function HighlightedInput({ text, highlights }: Props): React.ReactNode {
>
<Ansi>{part.text}</Ansi>
</Text>
)
);
})
)}
</Box>
))}
</Box>
)
);
}

View File

@@ -1,32 +1,32 @@
import { feature } from 'bun:bundle'
import * as React from 'react'
import { useSettings } from '../../hooks/useSettings.js'
import { Box, Text, useAnimationFrame } from '@anthropic/ink'
import { interpolateColor, toRGBColor } from '../Spinner/utils.js'
import { feature } from 'bun:bundle';
import * as React from 'react';
import { useSettings } from '../../hooks/useSettings.js';
import { Box, Text, useAnimationFrame } from '@anthropic/ink';
import { interpolateColor, toRGBColor } from '../Spinner/utils.js';
type Props = {
voiceState: 'idle' | 'recording' | 'processing'
}
voiceState: 'idle' | 'recording' | 'processing';
};
// Processing shimmer colors: dim gray to lighter gray (matches ThinkingShimmerText)
const PROCESSING_DIM = { r: 153, g: 153, b: 153 }
const PROCESSING_BRIGHT = { r: 185, g: 185, b: 185 }
const PROCESSING_DIM = { r: 153, g: 153, b: 153 };
const PROCESSING_BRIGHT = { r: 185, g: 185, b: 185 };
const PULSE_PERIOD_S = 2 // 2 second period for all pulsing animations
const PULSE_PERIOD_S = 2; // 2 second period for all pulsing animations
export function VoiceIndicator(props: Props): React.ReactNode {
if (!feature('VOICE_MODE')) return null
return <VoiceIndicatorImpl {...props} />
if (!feature('VOICE_MODE')) return null;
return <VoiceIndicatorImpl {...props} />;
}
function VoiceIndicatorImpl({ voiceState }: Props): React.ReactNode {
switch (voiceState) {
case 'recording':
return <Text dimColor>listening</Text>
return <Text dimColor>listening</Text>;
case 'processing':
return <ProcessingShimmer />
return <ProcessingShimmer />;
case 'idle':
return null
return null;
}
}
@@ -35,29 +35,26 @@ function VoiceIndicatorImpl({ voiceState }: Props): React.ReactNode {
// timer here runs concurrently with auto-repeat spaces arriving every
// 30-80ms, compounding re-renders during an already-busy window.
export function VoiceWarmupHint(): React.ReactNode {
if (!feature('VOICE_MODE')) return null
return <Text dimColor>keep holding</Text>
if (!feature('VOICE_MODE')) return null;
return <Text dimColor>keep holding</Text>;
}
function ProcessingShimmer(): React.ReactNode {
const settings = useSettings()
const reducedMotion = settings.prefersReducedMotion ?? false
const [ref, time] = useAnimationFrame(reducedMotion ? null : 50)
const settings = useSettings();
const reducedMotion = settings.prefersReducedMotion ?? false;
const [ref, time] = useAnimationFrame(reducedMotion ? null : 50);
if (reducedMotion) {
return <Text color="warning">Voice: processing</Text>
return <Text color="warning">Voice: processing</Text>;
}
const elapsedSec = time / 1000
const opacity =
(Math.sin((elapsedSec * Math.PI * 2) / PULSE_PERIOD_S) + 1) / 2
const color = toRGBColor(
interpolateColor(PROCESSING_DIM, PROCESSING_BRIGHT, opacity),
)
const elapsedSec = time / 1000;
const opacity = (Math.sin((elapsedSec * Math.PI * 2) / PULSE_PERIOD_S) + 1) / 2;
const color = toRGBColor(interpolateColor(PROCESSING_DIM, PROCESSING_BRIGHT, opacity));
return (
<Box ref={ref}>
<Text color={color}>Voice: processing</Text>
</Box>
)
);
}