Files
claude-code/src/components/PromptInput/PromptInputFooter.tsx
claude-code-best fcbc882232 chore: 清理 src 下 113 项未使用导入和死代码
删除未使用的文件(BuiltinStatusLine.tsx、4 个重复的 .ts stub)、
移除约 55 个文件中未使用的 React 导入、
清理约 50 处未使用的导入/变量/参数。
净减少 ~296 行代码,precheck 4077 测试全部通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:05:15 +08:00

410 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { feature } from 'bun:bundle';
import * as React from 'react';
import { memo, type ReactNode, 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 (
<Box paddingX={2} paddingY={0}>
<PromptInputFooterSuggestions
suggestions={suggestions}
selectedSuggestion={selectedSuggestion}
maxColumnWidth={maxColumnWidth}
/>
</Box>
);
}
if (helpOpen) {
return <PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />;
}
return (
<>
<Box
flexDirection={isNarrow ? 'column' : 'row'}
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
paddingX={2}
gap={isNarrow ? 0 : 1}
>
<Box flexDirection="column" flexShrink={isNarrow ? 0 : 1}>
{mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && (
<StatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} vimMode={vimMode} />
)}
<PipeStatusInline />
<PromptInputFooterLeftSide
exitMessage={exitMessage}
vimMode={vimMode}
mode={mode}
toolPermissionContext={toolPermissionContext}
suppressHint={suppressHint}
isLoading={isLoading}
tasksSelected={pillSelected}
teamsSelected={teamsSelected}
teammateFooterIndex={teammateFooterIndex}
tmuxSelected={tmuxSelected}
isPasting={isPasting}
isSearching={isSearching}
historyQuery={historyQuery}
setHistoryQuery={setHistoryQuery}
historyFailedMatch={historyFailedMatch}
onOpenTasksDialog={onOpenTasksDialog}
/>
</Box>
<Box flexShrink={1} gap={1}>
{isFullscreen ? null : (
<Notifications
apiKeyStatus={apiKeyStatus}
autoUpdaterResult={autoUpdaterResult}
debug={debug}
isAutoUpdating={isAutoUpdating}
verbose={verbose}
messages={messages}
onAutoUpdaterResult={onAutoUpdaterResult}
onChangeIsUpdating={onChangeIsUpdating}
ideSelection={ideSelection}
mcpClients={mcpClients}
isInputWrapped={isInputWrapped}
isNarrow={isNarrow}
/>
)}
{process.env.USER_TYPE === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
</Box>
</Box>
{process.env.USER_TYPE === 'ant' && <CoordinatorTaskPanel />}
</>
);
}
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 (
<Text color={bridgeSelected ? 'background' : status.color} inverse={bridgeSelected} wrap="truncate">
{status.label}
{bridgeSelected && <Text dimColor> · Enter to view</Text>}
</Text>
);
}
/**
* 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 (
<Box height={1} gap={1}>
<Text dimColor>pipe:</Text>
<Text bold>{pipeIpc.serverName}</Text>
<Text dimColor>({displayRole})</Text>
{pipeIpc.localIp && <Text dimColor>{pipeIpc.localIp}</Text>}
{allPipes.length > 0 && (
<Text color={selectedRouteActive ? 'success' : undefined} dimColor={selectedPipes.length === 0}>
{selectedPipes.length}/{allPipes.length} selected
</Text>
)}
{pipeIpc && isPipeControlled(pipeIpc) && pipeIpc.attachedBy && (
<Text color="warning">
{'→ '}
{pipeIpc.attachedBy}
</Text>
)}
{allPipes.length > 0 && (
<Text color={selectedRouteActive ? 'success' : undefined} dimColor={!selectedRouteActive}>
{selectedPipes.length > 0
? `${routeMode === 'local' ? 'local main' : 'selected pipes only'} · ←/→ switch · Shift+↓ edit`
: 'local main · Shift+↓ select'}
</Text>
)}
</Box>
);
}
// Expanded mode: header + pipe list with checkboxes
return (
<Box flexDirection="column">
<Box height={1} gap={1}>
<Text dimColor>pipe:</Text>
<Text bold>{pipeIpc.serverName}</Text>
<Text dimColor>({displayRole})</Text>
{pipeIpc.localIp && <Text dimColor>{pipeIpc.localIp}</Text>}
<Text color="warning"> move Space select / or m route Enter/Esc close Shift+ toggle</Text>
</Box>
<Box height={1} paddingLeft={2}>
<Text dimColor>
{selectedPipes.length > 0
? `当前普通 prompt 走 ${routeMode === 'local' ? '本地 main' : '已选 sub'};切换不会清空选择`
: '当前未选择 pipe普通 prompt 会在本地 main 对话执行'}
</Text>
</Box>
{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 (
<Box key={name} height={1} paddingLeft={2}>
<Text
inverse={isCursor}
color={isSelected ? 'success' : isConnected ? undefined : 'error'}
dimColor={!isConnected && !isCursor}
>
{isSelected ? '☑' : '☐'} {name}
{isConnected ? '' : ' [offline]'}
{label ? ` (${label})` : ''}
</Text>
</Box>
);
})}
{allPipes.length === 0 && (
<Box height={1} paddingLeft={2}>
<Text dimColor>No other pipes found. Start another instance.</Text>
</Box>
)}
</Box>
);
}