mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</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}>
|
||||
!
|
||||
</Text>
|
||||
) : (
|
||||
<PromptChar
|
||||
isLoading={isLoading}
|
||||
themeColor={isAgentSwarmsEnabled() ? teammateColor : undefined}
|
||||
/>
|
||||
<PromptChar isLoading={isLoading} themeColor={isAgentSwarmsEnabled() ? teammateColor : undefined} />
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user