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' type Props = { apiKeyStatus: VerificationStatus; debug: boolean; exitMessage: { show: boolean; key?: string; }; vimMode: VimMode | undefined; mode: PromptInputMode; autoUpdaterResult: AutoUpdaterResult | null; isAutoUpdating: boolean; verbose: boolean; onAutoUpdaterResult: (result: AutoUpdaterResult) => void; onChangeIsUpdating: (isUpdating: boolean) => void; suggestions: SuggestionItem[]; selectedSuggestion: number; maxColumnWidth?: number; toolPermissionContext: ToolPermissionContext; helpOpen: boolean; suppressHint: boolean; isLoading: boolean; tasksSelected: boolean; teamsSelected: boolean; bridgeSelected: boolean; tmuxSelected: boolean; teammateFooterIndex?: number; ideSelection: IDESelection | undefined; mcpClients?: MCPServerConnection[]; isPasting?: boolean; isInputWrapped?: boolean; messages: Message[]; isSearching: boolean; historyQuery: string; setHistoryQuery: (query: string) => void; historyFailedMatch: boolean; onOpenTasksDialog?: (taskId?: string) => void; }; function PromptInputFooter({ apiKeyStatus, debug, exitMessage, vimMode, mode, autoUpdaterResult, isAutoUpdating, verbose, onAutoUpdaterResult, onChangeIsUpdating, suggestions, selectedSuggestion, maxColumnWidth, toolPermissionContext, helpOpen, suppressHint: suppressHintFromProps, isLoading, tasksSelected, teamsSelected, bridgeSelected, tmuxSelected, teammateFooterIndex, ideSelection, mcpClients, isPasting = false, isInputWrapped = false, messages, isSearching, historyQuery, setHistoryQuery, historyFailedMatch, onOpenTasksDialog, }: Props): ReactNode { const settings = useSettings(); const { columns, rows } = useTerminalSize(); const messagesRef = useRef(messages); messagesRef.current = messages; const lastAssistantMessageId = useMemo(() => getLastAssistantMessageId(messages), [messages]); const isNarrow = columns < 80; // In fullscreen the bottom slot is flexShrink:0, so every row here is a row // stolen from the ScrollBox. Drop the optional StatusLine first. Non-fullscreen // has terminal scrollback to absorb overflow, so we never hide StatusLine there. const isFullscreen = isFullscreenEnvEnabled(); const isShort = isFullscreen && rows < 24; // Pill highlights when tasks is the active footer item AND no specific // agent row is selected. When coordinatorTaskIndex >= 0 the pointer has // moved into CoordinatorTaskPanel, so the pill should un-highlight. // coordinatorTaskCount === 0 covers the bash-only case (no agent rows // exist, pill is the only selectable item). const coordinatorTaskCount = useCoordinatorTaskCount(); const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex); const pillSelected = tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0); // Hide `? for shortcuts` if the user has a custom status line, or during ctrl-r const suppressHint = suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching; // Fullscreen: portal data to FullscreenLayout — see promptOverlayContext.tsx const overlayData = useMemo( () => (isFullscreen && suggestions.length ? { suggestions, selectedSuggestion, maxColumnWidth } : null), [isFullscreen, suggestions, selectedSuggestion, maxColumnWidth], ); useSetPromptOverlay(overlayData); if (suggestions.length && !isFullscreen) { return ( ); } if (helpOpen) { return ; } return ( <> {mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && ( )} {isFullscreen ? null : ( )} {process.env.USER_TYPE === 'ant' && isUndercover() && undercover} {process.env.USER_TYPE === 'ant' && } ); } export default memo(PromptInputFooter); type BridgeStatusProps = { bridgeSelected: boolean; }; function BridgeStatusIndicator({ bridgeSelected }: BridgeStatusProps): React.ReactNode { if (!feature('BRIDGE_MODE')) return null; const enabled = useAppState(s => s.replBridgeEnabled); const connected = useAppState(s => s.replBridgeConnected); const sessionActive = useAppState(s => s.replBridgeSessionActive); const reconnecting = useAppState(s => s.replBridgeReconnecting); const explicit = useAppState(s => s.replBridgeExplicit); // Failed state is surfaced via notification (useReplBridge), not a footer pill. if (!isBridgeEnabled() || !enabled) return null; const status = getBridgeStatus({ error: undefined, connected, sessionActive, reconnecting, }); // For implicit (config-driven) remote, only show the reconnecting state if (!explicit && status.label !== 'Remote Control reconnecting') { return null; } return ( {status.label} {bridgeSelected && · Enter to view} ); } /** * Inline pipe status panel with interactive checkbox selection. * * Shows after /pipes sets statusVisible. Displays: * - Header: own pipe info (collapsed mode) * - Ctrl+P: toggle expanded mode with sub list + checkboxes * - Expanded: ↑↓ to move cursor, Space to toggle, Enter/Esc to collapse * * Only uses AppState + Ink — no heavy external imports. */ function PipeStatusInline(): React.ReactNode { if (!feature('UDS_INBOX')) return null; // All hooks must be called before any conditional return to maintain // consistent hook count across renders (React rules of hooks). const pipeIpc = useAppState(s => (s as any).pipeIpc); const setAppState = useSetAppState(); const [cursorIndex, setCursorIndex] = useState(0); const isVisible = !!pipeIpc?.statusVisible && !!pipeIpc?.serverName; const selectorOpen: boolean = !!pipeIpc?.selectorOpen; const slaves = pipeIpc?.slaves ?? {}; const slaveNames = Object.keys(slaves); const discovered: Array<{ pipeName: string; role: string; ip: string; hostname: string }> = pipeIpc?.discoveredPipes ?? []; const allPipes = [...new Set([...slaveNames, ...discovered.map(d => d.pipeName)])].filter( n => n !== pipeIpc?.serverName, ); const selectedPipes: string[] = pipeIpc?.selectedPipes ?? []; const displayRole = pipeIpc ? getPipeDisplayRole(pipeIpc) : 'main'; const routeMode: 'selected' | 'local' = pipeIpc?.routeMode ?? 'selected'; const selectedRouteActive = routeMode !== 'local' && selectedPipes.length > 0; const setRouteMode = (mode: 'selected' | 'local') => { setAppState((prev: any) => { const pIpc = prev.pipeIpc ?? {}; return { ...prev, pipeIpc: { ...pIpc, routeMode: mode } }; }); }; // Register as modal overlay when selector is open. // This sets isModalOverlayActive=true in PromptInput → TextInput focus=false // → TextInput's useInput is deactivated → ↑↓ no longer trigger history navigation. // Same mechanism used by BackgroundTasksDialog, FuzzyPicker, etc. useRegisterOverlay('pipe-selector', isVisible && selectorOpen); // Keyboard handler — must be called every render (hooks rules). // ↑↓ navigate list, Space toggles selection, ←/→ or m switches route mode, Enter/Esc close selector. // No conflict with history nav: useRegisterOverlay above disables TextInput when open. useInput((_input, key) => { if (!isVisible) return; // When collapsed: only ←/→ arrow keys toggle route mode (no overlay, // so printable keys like 'm' would leak into the TextInput). // When expanded: ←/→ and 'm' all work (overlay blocks TextInput). if (selectedPipes.length > 0) { const arrowToggle = key.leftArrow || key.rightArrow; const mToggle = selectorOpen && _input.toLowerCase() === 'm'; if (arrowToggle || mToggle) { setRouteMode(routeMode === 'local' ? 'selected' : 'local'); return; } } if (!selectorOpen) return; if (key.downArrow) { setCursorIndex(i => Math.min(i + 1, allPipes.length - 1)); } else if (key.upArrow) { setCursorIndex(i => Math.max(i - 1, 0)); } else if (_input === ' ') { const pipeName = allPipes[cursorIndex]; if (pipeName) { setAppState((prev: any) => { const pIpc = prev.pipeIpc ?? {}; const sel: string[] = pIpc.selectedPipes ?? []; const newSel = sel.includes(pipeName) ? sel.filter((n: string) => n !== pipeName) : [...sel, pipeName]; return { ...prev, pipeIpc: { ...pIpc, selectedPipes: newSel } }; }); } } else if (key.return || key.escape) { setAppState((prev: any) => { const pIpc = prev.pipeIpc ?? {}; return { ...prev, pipeIpc: { ...pIpc, selectorOpen: false } }; }); } }); // Early return AFTER all hooks if (!isVisible) return null; if (!selectorOpen) { return ( pipe: {pipeIpc.serverName} ({displayRole}) {pipeIpc.localIp && {pipeIpc.localIp}} {allPipes.length > 0 && ( {selectedPipes.length}/{allPipes.length} selected )} {pipeIpc && isPipeControlled(pipeIpc) && pipeIpc.attachedBy && ( {'→ '} {pipeIpc.attachedBy} )} {allPipes.length > 0 && ( {selectedPipes.length > 0 ? `${routeMode === 'local' ? 'local main' : 'selected pipes only'} · ←/→ switch · Shift+↓ edit` : 'local main · Shift+↓ select'} )} ); } // Expanded mode: header + pipe list with checkboxes return ( pipe: {pipeIpc.serverName} ({displayRole}) {pipeIpc.localIp && {pipeIpc.localIp}} ↑↓ move Space select ←/→ or m route Enter/Esc close Shift+↓ toggle {selectedPipes.length > 0 ? `当前普通 prompt 走 ${routeMode === 'local' ? '本地 main' : '已选 sub'};切换不会清空选择` : '当前未选择 pipe;普通 prompt 会在本地 main 对话执行'} {allPipes.map((name, idx) => { const isSelected = selectedPipes.includes(name); const isCursor = idx === cursorIndex; const isConnected = !!slaves[name]; const disc = discovered.find(d => d.pipeName === name); const label = disc ? `${disc.role} ${disc.hostname}/${disc.ip}` : ''; return ( {isSelected ? '☑' : '☐'} {name} {isConnected ? '' : ' [offline]'} {label ? ` (${label})` : ''} ); })} {allPipes.length === 0 && ( No other pipes found. Start another instance. )} ); }