// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered 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 /* 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 { 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' // 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 /* eslint-enable @typescript-eslint/no-require-imports */ const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {} const NULL = () => null const MAX_VOICE_HINT_SHOWS = 3 const RSS_UPDATE_INTERVAL_MS = 5_000 type RssState = { text: string; level: 'normal' | 'warning' | 'error' } function useRssDisplay(): RssState | null { const [state, setState] = useState(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 })) } 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 } function ProactiveCountdown(): React.ReactNode { const nextTickAt = useSyncExternalStore( proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL, ) const [remainingSeconds, setRemainingSeconds] = useState(null) useEffect(() => { if (nextTickAt === null) { setRemainingSeconds(null) return } function update(): void { const remaining = Math.max( 0, Math.ceil((nextTickAt! - Date.now()) / 1000), ) setRemainingSeconds(remaining) } update() const interval = setInterval(update, 1000) return () => clearInterval(interval) }, [nextTickAt]) if (remainingSeconds === null) return null return ( waiting{' '} {formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })} ) } export function PromptInputFooterLeftSide({ exitMessage, vimMode, mode, toolPermissionContext, suppressHint, isLoading, tasksSelected, teamsSelected, tmuxSelected, teammateFooterIndex, isPasting, isSearching, historyQuery, setHistoryQuery, historyFailedMatch, onOpenTasksDialog, }: Props): React.ReactNode { if (exitMessage.show) { return ( Press {exitMessage.key} again to exit ) } if (isPasting) { return ( Pasting text… ) } const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching return ( {isSearching && ( )} {showVim ? ( -- INSERT -- ) : null} ) } type ModeIndicatorProps = { mode: PromptInputMode toolPermissionContext: ToolPermissionContext showHint: boolean isLoading: boolean tasksSelected: boolean teamsSelected: boolean tmuxSelected: boolean teammateFooterIndex?: number onOpenTasksDialog?: (taskId?: string) => void } function ModeIndicator({ mode, toolPermissionContext, showHint, isLoading, tasksSelected, teamsSelected, tmuxSelected, 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) // 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 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 runningTaskCount = useMemo( () => count( Object.values(tasks), 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') : '' // 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 useEffect(() => { if (feature('VOICE_MODE')) { 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 } }) } }, [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 // In-process mode uses Shift+Down/Up navigation, not footer teams menu const hasTeams = isAgentSwarmsEnabled() && !isInProcessEnabled() && teamContext !== undefined && count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0 if (mode === 'bash') { return ! for bash mode } 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) // PR indicator is short (~10 chars) — unlike the old diff indicator the // >=100 threshold was tuned for. Now that auto mode is effectively the // baseline, primaryItemCount is ≥1 for most sessions; keep the threshold // low enough to show PR status on standard 80-col terminals. const shouldShowPrStatus = isPrStatusEnabled() && prStatus.number !== null && prStatus.reviewState !== null && prStatus.url !== null && primaryItemCount < 2 && (primaryItemCount === 0 || columns >= 80) // Hide the shift+tab hint when there are 2 primary items 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) // In remote mode (`claude assistant`, --teleport) the agent runs elsewhere; // the local permission mode shown here doesn't reflect the agent's state. // Rendered before the tasks pill so a long pill label (e.g. ultraplan URL) // doesn't push the mode indicator off-screen. const modePart = currentMode && hasActiveMode && !getIsRemoteMode() ? ( {permissionModeSymbol(currentMode)}{' '} {permissionModeTitle(currentMode).toLowerCase()} on {shouldShowModeHint && ( {' '} )} ) : null // Build parts array - exclude BackgroundTaskStatus when we have teammate pills // (teammate pills get their own row) const parts = [ // Remote session indicator ...(remoteSessionUrl ? [ {figures.circleDouble} remote , ] : []), // BackgroundTaskStatus is NOT in parts — it renders as a Box sibling so // its click-target Box isn't nested inside the // wrapper (reconciler throws on Box-in-Text). // Tmux pill (ant-only) — appears right after tasks in nav order ...(process.env.USER_TYPE === 'ant' && hasTmuxSession ? [] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [ , ] : []), ...(shouldShowPrStatus ? [ , ] : []), // RSS memory indicator — always visible ...(rssState ? [ {rssState.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', ) // Get hint parts separately for potential second-line rendering const hintParts = showHint ? getSpinnerHintParts( isLoading, escShortcut, todosShortcut, killAgentsShortcut, hasTaskItems, expandedView, hasAnyInProcessTeammates, hasRunningAgentTasks, isKillAgentsConfirmShowing, ) : [] if (isViewingCompletedTeammate) { parts.push( , ) } else if ((feature('PROACTIVE') || feature('KAIROS')) && hasNextTick) { parts.push() } else if (!hasTeammatePills && showHint) { 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), ] return ( {otherParts.length > 0 && ( {otherParts} )} ) } // Add "↓ to manage tasks" hint when panel has visible rows 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 — 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) ? ( ) : null if (parts.length === 0 && !tasksPart && !modePart && showHint) { parts.push( ? for shortcuts , ) } // 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()) // 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() } else if (isFullscreenEnvEnabled() && selectionHintHasContent) { // xterm.js (VS Code/Cursor/Windsurf) force-selection modifier is // platform-specific and gated on macOS (SelectionService.shouldForceSelection): // macOS: altKey && macOptionClickForcesSelection (VS Code default: false) // non-macOS: shiftKey // On macOS, if we RECEIVED an alt+click (lastPressHadAlt), the VS Code // setting is off — xterm.js would have consumed the event otherwise. // Tell the user the exact setting to flip instead of repeating the // 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) parts.push( {!copyOnSelect && ( )} {isXtermJs() && (altClickFailed ? ( set macOptionClickForcesSelection in VS Code settings ) : ( ))} , ) } else if ( feature('VOICE_MODE') && parts.length > 0 && showHint && voiceEnabled && voiceState === 'idle' && hintParts.length === 0 && voiceHintUnderCap ) { parts.push( hold {voiceKeyShortcut} to speak , ) } if ((tasksPart || hasCoordinatorTasks) && showHint && !hasTeams) { parts.push( {tasksSelected ? ( ) : ( )} , ) } // In fullscreen the bottom section is flexShrink:0 — every row here // is a row stolen from the ScrollBox. This component must have a STABLE // height so the footer never grows/shrinks and shifts scroll content. // Returning null when parts is empty (e.g. StatusLine on → suppressHint // → showHint=false → no "? for shortcuts") would let a later-added // part (e.g. the selection copy/native-select hints) grow the column // 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() ? : null } // flexShrink=0 keeps mode + pill at natural width; the remaining parts // truncate at the tail as one string inside the Text wrapper. return ( {modePart && ( {modePart} {(tasksPart || parts.length > 0) && · } )} {tasksPart && ( {tasksPart} {parts.length > 0 && · } )} {parts.length > 0 && ( {parts} )} ) } function getSpinnerHintParts( isLoading: boolean, escShortcut: string, todosShortcut: string, killAgentsShortcut: string, hasTaskItems: boolean, expandedView: 'none' | 'tasks' | 'teammates', hasTeammates: boolean, hasRunningAgentTasks: boolean, isKillAgentsConfirmShowing: boolean, ): React.ReactElement[] { let toggleAction: string if (hasTeammates) { // Cycling: none → tasks → teammates → none switch (expandedView) { case 'none': toggleAction = 'show tasks' break case 'tasks': toggleAction = 'show teammates' break case 'teammates': toggleAction = 'hide' break } } else { 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 return [ ...(isLoading ? [ , ] : []), ...(!isLoading && hasRunningAgentTasks && !isKillAgentsConfirmShowing ? [ , ] : []), ...(showToggleHint ? [ , ] : []), ] } function isPrStatusEnabled(): boolean { return getGlobalConfig().prStatusFooterEnabled ?? true }