mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +00:00
feat: 远程群控 (#243)
* feat: restore pipe IPC, LAN pipes, monitor tool, and PR-package features Core IPC system (UDS_INBOX): - PipeServer/PipeClient with UDS + TCP dual transport, NDJSON protocol - PipeRegistry: machineId-based role assignment, file locking - Master/slave attach, prompt relay, permission forwarding - Heartbeat lifecycle with parallel isPipeAlive probes - Commands: /pipes, /attach, /detach, /send, /claim-main, /pipe-status LAN Pipes (LAN_PIPES): - UDP multicast beacon (224.0.71.67:7101) for zero-config LAN discovery - PipeServer TCP listener, PipeClient TCP connect mode - Heartbeat auto-attaches LAN peers via TCP - Cross-machine attach allowed regardless of role - /pipes shows [LAN] peers with role + hostname/IP - SendMessageTool supports tcp: scheme with user consent Architecture — extracted hooks from REPL.tsx (~830 lines → ~20 lines): - usePipeIpc: lifecycle (bootstrap, handlers, heartbeat, cleanup) - usePipeRelay: slave→master message relay via module singleton - usePipePermissionForward: permission request/cancel forwarding - usePipeRouter: selected pipe input routing with role+IP labels - Shared ndjsonFramer.ts replaces 3 duplicate NDJSON parsers Key fixes applied during development: - Multicast binds to correct LAN interface (not WSL/Docker) - Beacon ref stored as module singleton (not Zustand state mutation) - Heartbeat preserves LAN peers in discoveredPipes and selectedPipes - Disconnect handler calls removeSlaveClient (fixes listener leak) - cleanupStaleEntries probes without lock, writes briefly under lock - getMachineId uses async execFile (not blocking execSync) - globalThis.__pipeSendToMaster replaced with setPipeRelay singleton - M key only toggles route mode when selector panel is expanded - User prompt displayed in message list on pipe broadcast - Broadcast notifications show [role] + hostname/IP for LAN peers Other restored features: - Monitor tool: /monitor command, MonitorTool, MonitorMcpTask lifecycle - Daemon supervisor and remoteControlServer command - Tools: SnipTool, SleepTool, ListPeersTool, SendUserFileTool, WebBrowserTool, WorkflowTool, and 10+ stub→implementation rewrites - Feature flags: UDS_INBOX, LAN_PIPES, MONITOR_TOOL, FORK_SUBAGENT, KAIROS, COORDINATOR_MODE, WORKFLOW_SCRIPTS, HISTORY_SNIP Tests: 2190 pass / 0 fail (15 new: lanBeacon 7, peerAddress 8) * fix: resolve merge conflicts and fix all tsc/test errors after main merge - Export ToolResultBlockParam from Tool.ts (14 tool files fixed) - Migrate ink imports from ../../ink.js to @anthropic/ink (7 files) - Fix toolUseID → toolUseId typo in monitor.ts and MonitorTool.tsx - Add fallback values for string|undefined type errors (8 locations) - Fix AppState type in assistant.ts, add NewInstallWizard stubs - Fix ParsedRepository.repo → .name in subscribe-pr.ts - Fix AgentId/string type mismatch in BackgroundTasksDialog.tsx - Fix PipeRelayFn return type in pipePermissionRelay.ts - Use PipeMessage type in usePipeRelay.ts - Fix lanBeacon.test.ts mock type assertions - Create missing MouseActionEvent class for ink package - Use ansi: color format instead of bare "green"/"red" - Resolve theme.permission access via getTheme() Result: 0 tsc errors, 2496 tests pass, 0 fail Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 恢复 /poor 的说明 --------- Co-authored-by: unraid <local@unraid.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import { memo, type ReactNode, useMemo, useRef } 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'
|
||||
@@ -8,14 +8,16 @@ 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 } from '@anthropic/ink'
|
||||
import { Box, Text, useInput } from '@anthropic/ink'
|
||||
import type { MCPServerConnection } from '../../services/mcp/types.js'
|
||||
import { useAppState } from '../../state/AppState.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,
|
||||
@@ -28,49 +30,48 @@ import {
|
||||
} from '../StatusLine.js'
|
||||
import { Notifications } from './Notifications.js'
|
||||
import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'
|
||||
import {
|
||||
PromptInputFooterSuggestions,
|
||||
type SuggestionItem,
|
||||
} from './PromptInputFooterSuggestions.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
|
||||
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
|
||||
}
|
||||
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,
|
||||
@@ -106,43 +107,35 @@ function PromptInputFooter({
|
||||
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
|
||||
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
|
||||
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)
|
||||
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
|
||||
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.length ? { suggestions, selectedSuggestion, maxColumnWidth } : null),
|
||||
[isFullscreen, suggestions, selectedSuggestion, maxColumnWidth],
|
||||
)
|
||||
useSetPromptOverlay(overlayData)
|
||||
);
|
||||
useSetPromptOverlay(overlayData);
|
||||
|
||||
if (suggestions.length && !isFullscreen) {
|
||||
return (
|
||||
@@ -153,13 +146,11 @@ function PromptInputFooter({
|
||||
maxColumnWidth={maxColumnWidth}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (helpOpen) {
|
||||
return (
|
||||
<PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />
|
||||
)
|
||||
return <PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -171,17 +162,10 @@ function PromptInputFooter({
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
{mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && (
|
||||
<StatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} vimMode={vimMode} />
|
||||
)}
|
||||
<PipeStatusInline />
|
||||
<PromptInputFooterLeftSide
|
||||
exitMessage={exitMessage}
|
||||
vimMode={vimMode}
|
||||
@@ -218,62 +202,215 @@ function PromptInputFooter({
|
||||
isNarrow={isNarrow}
|
||||
/>
|
||||
)}
|
||||
{process.env.USER_TYPE === 'ant' && isUndercover() && (
|
||||
<Text dimColor>undercover</Text>
|
||||
)}
|
||||
{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)
|
||||
export default memo(PromptInputFooter);
|
||||
|
||||
type BridgeStatusProps = {
|
||||
bridgeSelected: boolean
|
||||
}
|
||||
bridgeSelected: boolean;
|
||||
};
|
||||
|
||||
function BridgeStatusIndicator({
|
||||
bridgeSelected,
|
||||
}: BridgeStatusProps): React.ReactNode {
|
||||
if (!feature('BRIDGE_MODE')) return null
|
||||
function BridgeStatusIndicator({ bridgeSelected }: BridgeStatusProps): React.ReactNode {
|
||||
if (!feature('BRIDGE_MODE')) return null;
|
||||
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const enabled = useAppState(s => s.replBridgeEnabled)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const connected = useAppState(s => s.replBridgeConnected)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const sessionActive = useAppState(s => s.replBridgeSessionActive)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const reconnecting = useAppState(s => s.replBridgeReconnecting)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const explicit = useAppState(s => s.replBridgeExplicit)
|
||||
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
|
||||
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 null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
color={bridgeSelected ? 'background' : status.color}
|
||||
inverse={bridgeSelected}
|
||||
wrap="truncate"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user