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,4 +1,4 @@
|
||||
import figures from 'figures'
|
||||
import figures from 'figures';
|
||||
import React, {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -29,53 +29,53 @@ import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggest
|
||||
import type { StickyPrompt } from './VirtualMessageList.js'
|
||||
|
||||
/** Rows of transcript context kept visible above the modal pane's ▔ divider. */
|
||||
const MODAL_TRANSCRIPT_PEEK = 2
|
||||
const MODAL_TRANSCRIPT_PEEK = 2;
|
||||
|
||||
/** Context for scroll-derived chrome (sticky header, pill). StickyTracker
|
||||
* in VirtualMessageList writes via this instead of threading a callback
|
||||
* up through Messages → REPL → FullscreenLayout. The setter is stable so
|
||||
* consuming this context never causes re-renders. */
|
||||
export const ScrollChromeContext = createContext<{
|
||||
setStickyPrompt: (p: StickyPrompt | null) => void
|
||||
}>({ setStickyPrompt: () => {} })
|
||||
setStickyPrompt: (p: StickyPrompt | null) => void;
|
||||
}>({ setStickyPrompt: () => {} });
|
||||
|
||||
type Props = {
|
||||
/** Content that scrolls (messages, tool output) */
|
||||
scrollable: ReactNode
|
||||
scrollable: ReactNode;
|
||||
/** Content pinned to the bottom (spinner, prompt, permissions) */
|
||||
bottom: ReactNode
|
||||
bottom: ReactNode;
|
||||
/** Content rendered inside the ScrollBox after messages — user can scroll
|
||||
* up to see context while it's showing (used by PermissionRequest). */
|
||||
overlay?: ReactNode
|
||||
overlay?: ReactNode;
|
||||
/** Absolute-positioned content anchored at the bottom-right of the
|
||||
* ScrollBox area, floating over scrollback. Rendered inside the flexGrow
|
||||
* region (not the bottom slot) so the overflowY:hidden cap doesn't clip
|
||||
* it. Fullscreen only — used for the companion speech bubble. */
|
||||
bottomFloat?: ReactNode
|
||||
bottomFloat?: ReactNode;
|
||||
/** Slash-command dialog content. Rendered in an absolute-positioned
|
||||
* bottom-anchored pane (▔ divider, paddingX=2) that paints over the
|
||||
* ScrollBox AND bottom slot. Provides ModalContext so Pane/Dialog inside
|
||||
* skip their own frame. Fullscreen only; inline after overlay otherwise. */
|
||||
modal?: ReactNode
|
||||
modal?: ReactNode;
|
||||
/** Ref passed via ModalContext so Tabs (or any scroll-owning descendant)
|
||||
* can attach it to their own ScrollBox for tall content. */
|
||||
modalScrollRef?: React.RefObject<ScrollBoxHandle | null>
|
||||
modalScrollRef?: React.RefObject<ScrollBoxHandle | null>;
|
||||
/** Ref to the scroll box for keyboard scrolling. RefObject (not Ref) so
|
||||
* pillVisible's useSyncExternalStore can subscribe to scroll changes. */
|
||||
scrollRef?: RefObject<ScrollBoxHandle | null>
|
||||
scrollRef?: RefObject<ScrollBoxHandle | null>;
|
||||
/** Y-position (scrollHeight at snapshot) of the unseen-divider. Pill
|
||||
* shows while viewport bottom hasn't reached this. Ref so REPL doesn't
|
||||
* re-render on the one-shot snapshot write. */
|
||||
dividerYRef?: RefObject<number | null>
|
||||
dividerYRef?: RefObject<number | null>;
|
||||
/** Force-hide the pill (e.g. viewing a sub-agent task). */
|
||||
hidePill?: boolean
|
||||
hidePill?: boolean;
|
||||
/** Force-hide the sticky prompt header (e.g. viewing a teammate task). */
|
||||
hideSticky?: boolean
|
||||
hideSticky?: boolean;
|
||||
/** Count for the pill text. 0 → "Jump to bottom", >0 → "N new messages". */
|
||||
newMessageCount?: number
|
||||
newMessageCount?: number;
|
||||
/** Called when the user clicks the "N new" pill. */
|
||||
onPillClick?: () => void
|
||||
}
|
||||
onPillClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks the in-transcript "N new messages" divider position while the
|
||||
@@ -98,33 +98,33 @@ export function useUnseenDivider(messageCount: number): {
|
||||
/** Index into messages[] where the divider line renders. Cleared on
|
||||
* sticky-resume (scroll back to bottom) so the "N new" line doesn't
|
||||
* linger once everything is visible. */
|
||||
dividerIndex: number | null
|
||||
dividerIndex: number | null;
|
||||
/** scrollHeight snapshot at first scroll-away — the divider's y-position.
|
||||
* FullscreenLayout subscribes to ScrollBox and compares viewport bottom
|
||||
* against this for pillVisible. Ref so writes don't re-render REPL. */
|
||||
dividerYRef: RefObject<number | null>
|
||||
onScrollAway: (handle: ScrollBoxHandle) => void
|
||||
onRepin: () => void
|
||||
dividerYRef: RefObject<number | null>;
|
||||
onScrollAway: (handle: ScrollBoxHandle) => void;
|
||||
onRepin: () => void;
|
||||
/** Scroll the handle so the divider line is at the top of the viewport. */
|
||||
jumpToNew: (handle: ScrollBoxHandle | null) => void
|
||||
jumpToNew: (handle: ScrollBoxHandle | null) => void;
|
||||
/** Shift dividerIndex and dividerYRef when messages are prepended
|
||||
* (infinite scroll-back). indexDelta = number of messages prepended;
|
||||
* heightDelta = content height growth in rows. */
|
||||
shiftDivider: (indexDelta: number, heightDelta: number) => void
|
||||
shiftDivider: (indexDelta: number, heightDelta: number) => void;
|
||||
} {
|
||||
const [dividerIndex, setDividerIndex] = useState<number | null>(null)
|
||||
const [dividerIndex, setDividerIndex] = useState<number | null>(null);
|
||||
// Ref holds the current count for onScrollAway to snapshot. Written in
|
||||
// the render body (not useEffect) so wheel events arriving between a
|
||||
// message-append render and its effect flush don't capture a stale
|
||||
// count (off-by-one in the baseline). React Compiler bails out here —
|
||||
// acceptable for a hook instantiated once in REPL.
|
||||
const countRef = useRef(messageCount)
|
||||
countRef.current = messageCount
|
||||
const countRef = useRef(messageCount);
|
||||
countRef.current = messageCount;
|
||||
// scrollHeight snapshot — the divider's y in content coords. Ref-only:
|
||||
// read synchronously in onScrollAway (setState is batched, can't
|
||||
// read-then-write in the same callback) AND by FullscreenLayout's
|
||||
// pillVisible subscription. null = pinned to bottom.
|
||||
const dividerYRef = useRef<number | null>(null)
|
||||
const dividerYRef = useRef<number | null>(null);
|
||||
|
||||
const onRepin = useCallback(() => {
|
||||
// Don't clear dividerYRef here — a trackpad momentum wheel event
|
||||
@@ -132,8 +132,8 @@ export function useUnseenDivider(messageCount: number): {
|
||||
// overriding the setDividerIndex(null) below. The useEffect below
|
||||
// clears the ref after React commits the null dividerIndex, so the
|
||||
// ref stays non-null until the state settles.
|
||||
setDividerIndex(null)
|
||||
}, [])
|
||||
setDividerIndex(null);
|
||||
}, []);
|
||||
|
||||
const onScrollAway = useCallback((handle: ScrollBoxHandle) => {
|
||||
// Nothing below the viewport → nothing to jump to. Covers both:
|
||||
@@ -145,24 +145,21 @@ export function useUnseenDivider(messageCount: number): {
|
||||
// at max (Sarah Deaton, #claude-code-feedback 2026-03-15)
|
||||
// pendingDelta: scrollBy accumulates without updating scrollTop. Without
|
||||
// it, wheeling up from max would see scrollTop==max and suppress the pill.
|
||||
const max = Math.max(
|
||||
0,
|
||||
handle.getScrollHeight() - handle.getViewportHeight(),
|
||||
)
|
||||
if (handle.getScrollTop() + handle.getPendingDelta() >= max) return
|
||||
const max = Math.max(0, handle.getScrollHeight() - handle.getViewportHeight());
|
||||
if (handle.getScrollTop() + handle.getPendingDelta() >= max) return;
|
||||
// Snapshot only on the FIRST scroll-away. onScrollAway fires on EVERY
|
||||
// scroll action (not just the initial break from sticky) — this guard
|
||||
// preserves the original baseline so the count doesn't reset on the
|
||||
// second PageUp. Subsequent calls are ref-only no-ops (no REPL re-render).
|
||||
if (dividerYRef.current === null) {
|
||||
dividerYRef.current = handle.getScrollHeight()
|
||||
dividerYRef.current = handle.getScrollHeight();
|
||||
// New scroll-away session → move the divider here (replaces old one)
|
||||
setDividerIndex(countRef.current)
|
||||
setDividerIndex(countRef.current);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const jumpToNew = useCallback((handle: ScrollBoxHandle | null) => {
|
||||
if (!handle) return
|
||||
if (!handle) return;
|
||||
// scrollToBottom (not scrollTo(dividerY)): sets stickyScroll=true so
|
||||
// useVirtualScroll mounts the tail and render-node-to-output pins
|
||||
// scrollTop=maxScroll. scrollTo sets stickyScroll=false → the clamp
|
||||
@@ -170,8 +167,8 @@ export function useUnseenDivider(messageCount: number): {
|
||||
// back, stopping short. The divider stays rendered (dividerIndex
|
||||
// unchanged) so users see where new messages started; the clear on
|
||||
// next submit/explicit scroll-to-bottom handles cleanup.
|
||||
handle.scrollToBottom()
|
||||
}, [])
|
||||
handle.scrollToBottom();
|
||||
}, []);
|
||||
|
||||
// Sync dividerYRef with dividerIndex. When onRepin fires (submit,
|
||||
// scroll-to-bottom), it sets dividerIndex=null but leaves the ref
|
||||
@@ -184,22 +181,19 @@ export function useUnseenDivider(messageCount: number): {
|
||||
// below the divider index, the divider would point at nothing.
|
||||
useEffect(() => {
|
||||
if (dividerIndex === null) {
|
||||
dividerYRef.current = null
|
||||
dividerYRef.current = null;
|
||||
} else if (messageCount < dividerIndex) {
|
||||
dividerYRef.current = null
|
||||
setDividerIndex(null)
|
||||
dividerYRef.current = null;
|
||||
setDividerIndex(null);
|
||||
}
|
||||
}, [messageCount, dividerIndex])
|
||||
}, [messageCount, dividerIndex]);
|
||||
|
||||
const shiftDivider = useCallback(
|
||||
(indexDelta: number, heightDelta: number) => {
|
||||
setDividerIndex(idx => (idx === null ? null : idx + indexDelta))
|
||||
if (dividerYRef.current !== null) {
|
||||
dividerYRef.current += heightDelta
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
const shiftDivider = useCallback((indexDelta: number, heightDelta: number) => {
|
||||
setDividerIndex(idx => (idx === null ? null : idx + indexDelta));
|
||||
if (dividerYRef.current !== null) {
|
||||
dividerYRef.current += heightDelta;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
dividerIndex,
|
||||
@@ -208,7 +202,7 @@ export function useUnseenDivider(messageCount: number): {
|
||||
onRepin,
|
||||
jumpToNew,
|
||||
shiftDivider,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,25 +213,22 @@ export function useUnseenDivider(messageCount: number): {
|
||||
* carry text — tool-use-only entries are skipped (like progress messages)
|
||||
* so "⏺ Searched for 13 patterns, read 6 files" doesn't tick the pill.
|
||||
*/
|
||||
export function countUnseenAssistantTurns(
|
||||
messages: readonly Message[],
|
||||
dividerIndex: number,
|
||||
): number {
|
||||
let count = 0
|
||||
let prevWasAssistant = false
|
||||
export function countUnseenAssistantTurns(messages: readonly Message[], dividerIndex: number): number {
|
||||
let count = 0;
|
||||
let prevWasAssistant = false;
|
||||
for (let i = dividerIndex; i < messages.length; i++) {
|
||||
const m = messages[i]!
|
||||
if (m.type === 'progress') continue
|
||||
const m = messages[i]!;
|
||||
if (m.type === 'progress') continue;
|
||||
// Tool-use-only assistant entries aren't "new messages" to the user —
|
||||
// skip them the same way we skip progress. prevWasAssistant is NOT
|
||||
// updated, so a text block immediately following still counts as the
|
||||
// same turn (tool_use + text from one API response = 1).
|
||||
if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue
|
||||
const isAssistant = m.type === 'assistant'
|
||||
if (isAssistant && !prevWasAssistant) count++
|
||||
prevWasAssistant = isAssistant
|
||||
if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue;
|
||||
const isAssistant = m.type === 'assistant';
|
||||
if (isAssistant && !prevWasAssistant) count++;
|
||||
prevWasAssistant = isAssistant;
|
||||
}
|
||||
return count
|
||||
return count;
|
||||
}
|
||||
|
||||
function assistantHasVisibleText(m: Message): boolean {
|
||||
@@ -246,10 +237,10 @@ function assistantHasVisibleText(m: Message): boolean {
|
||||
for (const b of m.message!.content) {
|
||||
if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
export type UnseenDivider = { firstUnseenUuid: Message['uuid']; count: number }
|
||||
export type UnseenDivider = { firstUnseenUuid: Message['uuid']; count: number };
|
||||
|
||||
/**
|
||||
* Builds the unseenDivider object REPL passes to Messages + the pill.
|
||||
@@ -265,23 +256,22 @@ export function computeUnseenDivider(
|
||||
messages: readonly Message[],
|
||||
dividerIndex: number | null,
|
||||
): UnseenDivider | undefined {
|
||||
if (dividerIndex === null) return undefined
|
||||
if (dividerIndex === null) return undefined;
|
||||
// Skip progress and null-rendering attachments when picking the divider
|
||||
// anchor — Messages.tsx filters these out of renderableMessages before the
|
||||
// dividerBeforeIndex search, so their UUID wouldn't be found (CC-724).
|
||||
// Hook attachments use randomUUID() so nothing shares their 24-char prefix.
|
||||
let anchorIdx = dividerIndex
|
||||
let anchorIdx = dividerIndex;
|
||||
while (
|
||||
anchorIdx < messages.length &&
|
||||
(messages[anchorIdx]?.type === 'progress' ||
|
||||
isNullRenderingAttachment(messages[anchorIdx]!))
|
||||
(messages[anchorIdx]?.type === 'progress' || isNullRenderingAttachment(messages[anchorIdx]!))
|
||||
) {
|
||||
anchorIdx++
|
||||
anchorIdx++;
|
||||
}
|
||||
const uuid = messages[anchorIdx]?.uuid
|
||||
if (!uuid) return undefined
|
||||
const count = countUnseenAssistantTurns(messages, dividerIndex)
|
||||
return { firstUnseenUuid: uuid, count: Math.max(1, count) }
|
||||
const uuid = messages[anchorIdx]?.uuid;
|
||||
if (!uuid) return undefined;
|
||||
const count = countUnseenAssistantTurns(messages, dividerIndex);
|
||||
return { firstUnseenUuid: uuid, count: Math.max(1, count) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,56 +300,53 @@ export function FullscreenLayout({
|
||||
newMessageCount = 0,
|
||||
onPillClick,
|
||||
}: Props): React.ReactNode {
|
||||
const { rows: terminalRows, columns } = useTerminalSize()
|
||||
const { rows: terminalRows, columns } = useTerminalSize();
|
||||
// Scroll-derived chrome state lives HERE, not in REPL. StickyTracker
|
||||
// writes via ScrollChromeContext; pillVisible subscribes directly to
|
||||
// ScrollBox. Both change rarely (pill flips once per threshold crossing,
|
||||
// sticky changes ~5-20×/transcript) — re-rendering FullscreenLayout on
|
||||
// those is fine; re-rendering the 6966-line REPL + its 22+ useAppState
|
||||
// selectors per-scroll-frame was not.
|
||||
const [stickyPrompt, setStickyPrompt] = useState<StickyPrompt | null>(null)
|
||||
const chromeCtx = useMemo(() => ({ setStickyPrompt }), [])
|
||||
const [stickyPrompt, setStickyPrompt] = useState<StickyPrompt | null>(null);
|
||||
const chromeCtx = useMemo(() => ({ setStickyPrompt }), []);
|
||||
// Boolean-quantized scroll subscription. Snapshot is "is viewport bottom
|
||||
// above the divider y?" — Object.is on a boolean → FullscreenLayout only
|
||||
// re-renders when the pill should actually flip, not per-frame.
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) =>
|
||||
scrollRef?.current?.subscribe(listener) ?? (() => {}),
|
||||
(listener: () => void) => scrollRef?.current?.subscribe(listener) ?? (() => {}),
|
||||
[scrollRef],
|
||||
)
|
||||
);
|
||||
const pillVisible = useSyncExternalStore(subscribe, () => {
|
||||
const s = scrollRef?.current
|
||||
const dividerY = dividerYRef?.current
|
||||
if (!s || dividerY == null) return false
|
||||
return (
|
||||
s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY
|
||||
)
|
||||
})
|
||||
const s = scrollRef?.current;
|
||||
const dividerY = dividerYRef?.current;
|
||||
if (!s || dividerY == null) return false;
|
||||
return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY;
|
||||
});
|
||||
// Wire up hyperlink click handling — in fullscreen mode, mouse tracking
|
||||
// intercepts clicks before the terminal can open OSC 8 links natively.
|
||||
useLayoutEffect(() => {
|
||||
if (!isFullscreenEnvEnabled()) return
|
||||
const ink = instances.get(process.stdout)
|
||||
if (!ink) return
|
||||
if (!isFullscreenEnvEnabled()) return;
|
||||
const ink = instances.get(process.stdout);
|
||||
if (!ink) return;
|
||||
ink.onHyperlinkClick = url => {
|
||||
// Most OSC 8 links emitted by Claude Code are file:// URLs from
|
||||
// FilePathLink (FileEdit/FileWrite/FileRead tool output). openBrowser
|
||||
// rejects non-http(s) protocols — route file: to openPath instead.
|
||||
if (url.startsWith('file:')) {
|
||||
try {
|
||||
void openPath(fileURLToPath(url))
|
||||
void openPath(fileURLToPath(url));
|
||||
} catch {
|
||||
// Malformed file: URLs (e.g. file://host/path from plain-text
|
||||
// detection) cause fileURLToPath to throw — ignore silently.
|
||||
}
|
||||
} else {
|
||||
void openBrowser(url)
|
||||
void openBrowser(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
ink.onHyperlinkClick = undefined
|
||||
}
|
||||
}, [])
|
||||
ink.onHyperlinkClick = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isFullscreenEnvEnabled()) {
|
||||
// Overlay renders BELOW messages inside the same ScrollBox — user can
|
||||
@@ -379,50 +366,41 @@ export function FullscreenLayout({
|
||||
// row 0. On next scroll the onChange fires with a fresh {text} and
|
||||
// header comes back (viewportTop 0→1, a single 1-row shift —
|
||||
// acceptable since user explicitly scrolled).
|
||||
const sticky = hideSticky ? null : stickyPrompt
|
||||
const headerPrompt =
|
||||
sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null
|
||||
const padCollapsed = sticky != null && overlay == null
|
||||
const sticky = hideSticky ? null : stickyPrompt;
|
||||
const headerPrompt = sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null;
|
||||
const padCollapsed = sticky != null && overlay == null;
|
||||
return (
|
||||
<PromptOverlayProvider>
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
{headerPrompt && (
|
||||
<StickyPromptHeader
|
||||
text={headerPrompt.text}
|
||||
onClick={headerPrompt.scrollTo}
|
||||
/>
|
||||
)}
|
||||
<ScrollBox
|
||||
ref={scrollRef}
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
paddingTop={padCollapsed ? 0 : 1}
|
||||
stickyScroll
|
||||
>
|
||||
<ScrollChromeContext value={chromeCtx}>
|
||||
{scrollable}
|
||||
</ScrollChromeContext>
|
||||
{overlay}
|
||||
</ScrollBox>
|
||||
{!hidePill && pillVisible && overlay == null && (
|
||||
<NewMessagesPill count={newMessageCount} onClick={onPillClick} />
|
||||
)}
|
||||
{bottomFloat != null && (
|
||||
<Box position="absolute" bottom={0} right={0} opaque>
|
||||
{bottomFloat}
|
||||
<Box flexDirection="row" flexGrow={1} overflow="hidden" width="100%">
|
||||
<Box flexDirection="column" flexGrow={1} width={columns} overflow="hidden">
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
{headerPrompt && <StickyPromptHeader text={headerPrompt.text} onClick={headerPrompt.scrollTo} />}
|
||||
<ScrollBox
|
||||
ref={scrollRef}
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
paddingTop={padCollapsed ? 0 : 1}
|
||||
stickyScroll
|
||||
>
|
||||
<ScrollChromeContext value={chromeCtx}>{scrollable}</ScrollChromeContext>
|
||||
{overlay}
|
||||
</ScrollBox>
|
||||
{!hidePill && pillVisible && overlay == null && (
|
||||
<NewMessagesPill count={newMessageCount} onClick={onPillClick} />
|
||||
)}
|
||||
{bottomFloat != null && (
|
||||
<Box position="absolute" bottom={0} right={0} opaque>
|
||||
{bottomFloat}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column" flexShrink={0} width="100%" maxHeight="50%">
|
||||
<SuggestionsOverlay />
|
||||
<DialogOverlay />
|
||||
<Box flexDirection="column" width="100%" flexGrow={1} overflowY="hidden">
|
||||
{bottom}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column" flexShrink={0} width="100%" maxHeight="50%">
|
||||
<SuggestionsOverlay />
|
||||
<DialogOverlay />
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
overflowY="hidden"
|
||||
>
|
||||
{bottom}
|
||||
</Box>
|
||||
</Box>
|
||||
{modal != null && (
|
||||
@@ -465,19 +443,14 @@ export function FullscreenLayout({
|
||||
<Box flexShrink={0}>
|
||||
<Text color="permission">{'▔'.repeat(columns)}</Text>
|
||||
</Box>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={2}
|
||||
flexShrink={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box flexDirection="column" paddingX={2} flexShrink={0} overflow="hidden">
|
||||
{modal}
|
||||
</Box>
|
||||
</Box>
|
||||
</ModalContext>
|
||||
)}
|
||||
</PromptOverlayProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -487,7 +460,7 @@ export function FullscreenLayout({
|
||||
{overlay}
|
||||
{modal}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Slack-style pill. Absolute overlay at bottom={0} of the scrollwrap — floats
|
||||
@@ -497,42 +470,18 @@ export function FullscreenLayout({
|
||||
// (absoluteRectsPrev third-pass in render-node-to-output.ts, #23939). Shows
|
||||
// "Jump to bottom" when count is 0 (scrolled away but no new messages yet —
|
||||
// the dead zone where users previously thought chat stalled).
|
||||
function NewMessagesPill({
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
count: number
|
||||
onClick?: () => void
|
||||
}): React.ReactNode {
|
||||
const [hover, setHover] = useState(false)
|
||||
function NewMessagesPill({ count, onClick }: { count: number; onClick?: () => void }): React.ReactNode {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Box
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
<Text
|
||||
backgroundColor={
|
||||
hover ? 'userMessageBackgroundHover' : 'userMessageBackground'
|
||||
}
|
||||
dimColor
|
||||
>
|
||||
<Box position="absolute" bottom={0} left={0} right={0} justifyContent="center">
|
||||
<Box onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
||||
<Text backgroundColor={hover ? 'userMessageBackgroundHover' : 'userMessageBackground'} dimColor>
|
||||
{' '}
|
||||
{count > 0
|
||||
? `${count} new ${plural(count, 'message')}`
|
||||
: 'Jump to bottom'}{' '}
|
||||
{figures.arrowDown}{' '}
|
||||
{count > 0 ? `${count} new ${plural(count, 'message')}` : 'Jump to bottom'} {figures.arrowDown}{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Context breadcrumb: when scrolled up into history, pin the current
|
||||
@@ -547,23 +496,15 @@ function NewMessagesPill({
|
||||
// even with scrollTop unchanged (the DECSTBM region top shifts with the
|
||||
// ScrollBox, and the diff engine sees "everything moved"). Fixed height
|
||||
// keeps the ScrollBox anchored; only the header TEXT changes, not its box.
|
||||
function StickyPromptHeader({
|
||||
text,
|
||||
onClick,
|
||||
}: {
|
||||
text: string
|
||||
onClick: () => void
|
||||
}): React.ReactNode {
|
||||
const [hover, setHover] = useState(false)
|
||||
function StickyPromptHeader({ text, onClick }: { text: string; onClick: () => void }): React.ReactNode {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
height={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
hover ? 'userMessageBackgroundHover' : 'userMessageBackground'
|
||||
}
|
||||
backgroundColor={hover ? 'userMessageBackgroundHover' : 'userMessageBackground'}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
@@ -572,7 +513,7 @@ function StickyPromptHeader({
|
||||
{figures.pointer} {text}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Slash-command suggestion overlay — see promptOverlayContext.tsx for why
|
||||
@@ -584,19 +525,10 @@ function StickyPromptHeader({
|
||||
// flex-end here: they would create empty padding rows that shift visible
|
||||
// items down into the prompt area when the list has fewer items than max.
|
||||
function SuggestionsOverlay(): React.ReactNode {
|
||||
const data = usePromptOverlay()
|
||||
if (!data || data.suggestions.length === 0) return null
|
||||
const data = usePromptOverlay();
|
||||
if (!data || data.suggestions.length === 0) return null;
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="100%"
|
||||
left={0}
|
||||
right={0}
|
||||
paddingX={2}
|
||||
paddingTop={1}
|
||||
flexDirection="column"
|
||||
opaque
|
||||
>
|
||||
<Box position="absolute" bottom="100%" left={0} right={0} paddingX={2} paddingTop={1} flexDirection="column" opaque>
|
||||
<PromptInputFooterSuggestions
|
||||
suggestions={data.suggestions}
|
||||
selectedSuggestion={data.selectedSuggestion}
|
||||
@@ -604,18 +536,18 @@ function SuggestionsOverlay(): React.ReactNode {
|
||||
overlay
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Dialog portaled from PromptInput (AutoModeOptInDialog) — same clip-escape
|
||||
// pattern as SuggestionsOverlay. Renders later in tree order so it paints
|
||||
// over suggestions if both are ever up (they shouldn't be).
|
||||
function DialogOverlay(): React.ReactNode {
|
||||
const node = usePromptOverlayDialog()
|
||||
if (!node) return null
|
||||
const node = usePromptOverlayDialog();
|
||||
if (!node) return null;
|
||||
return (
|
||||
<Box position="absolute" bottom="100%" left={0} right={0} opaque>
|
||||
{node}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const MonitorPermissionRequest: (props: Record<string, unknown>) => null = () => null;
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { Box, Text, useTheme } from '@anthropic/ink'
|
||||
import { getTheme } from '../../../utils/theme.js'
|
||||
import { env } from '../../../utils/env.js'
|
||||
import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'
|
||||
import { truncateToLines } from '../../../utils/stringUtils.js'
|
||||
import { logUnaryEvent } from '../../../utils/unaryLogging.js'
|
||||
import { PermissionDialog } from '../PermissionDialog.js'
|
||||
import {
|
||||
PermissionPrompt,
|
||||
type PermissionPromptOption,
|
||||
} from '../PermissionPrompt.js'
|
||||
import type { PermissionRequestProps } from '../PermissionRequest.js'
|
||||
import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'
|
||||
|
||||
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no'
|
||||
|
||||
/**
|
||||
* Permission request UI for the MonitorTool. Asks the user to confirm
|
||||
* starting a long-running background monitor process.
|
||||
* Follows the FallbackPermissionRequest pattern.
|
||||
*/
|
||||
export function MonitorPermissionRequest({
|
||||
toolUseConfirm,
|
||||
onDone,
|
||||
onReject,
|
||||
workerBadge,
|
||||
}: PermissionRequestProps): React.ReactNode {
|
||||
const [themeName] = useTheme()
|
||||
const theme = getTheme(themeName)
|
||||
|
||||
const input = toolUseConfirm.input as {
|
||||
command: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const showAlwaysAllowOptions = useMemo(
|
||||
() => shouldShowAlwaysAllowOptions(),
|
||||
[],
|
||||
)
|
||||
|
||||
const options: PermissionPromptOption<OptionValue>[] = useMemo(() => {
|
||||
const opts: PermissionPromptOption<OptionValue>[] = [
|
||||
{
|
||||
label: 'Yes',
|
||||
value: 'yes',
|
||||
feedbackConfig: { type: 'accept' as const },
|
||||
},
|
||||
]
|
||||
if (showAlwaysAllowOptions) {
|
||||
opts.push({
|
||||
label: (
|
||||
<Text>
|
||||
Yes, and don{'\u2019'}t ask again for{' '}
|
||||
<Text bold>{toolUseConfirm.tool.name}</Text> commands
|
||||
</Text>
|
||||
),
|
||||
value: 'yes-dont-ask-again',
|
||||
})
|
||||
}
|
||||
opts.push({
|
||||
label: 'No',
|
||||
value: 'no',
|
||||
feedbackConfig: { type: 'reject' as const },
|
||||
})
|
||||
return opts
|
||||
}, [showAlwaysAllowOptions, toolUseConfirm.tool.name])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: OptionValue, feedback?: string) => {
|
||||
switch (value) {
|
||||
case 'yes':
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'accept',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
})
|
||||
toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback)
|
||||
onDone()
|
||||
break
|
||||
case 'yes-dont-ask-again':
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'accept',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
})
|
||||
toolUseConfirm.onAllow(toolUseConfirm.input, [
|
||||
{
|
||||
type: 'addRules',
|
||||
rules: [{ toolName: toolUseConfirm.tool.name }],
|
||||
behavior: 'allow',
|
||||
destination: 'localSettings',
|
||||
},
|
||||
])
|
||||
onDone()
|
||||
break
|
||||
case 'no':
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'reject',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
})
|
||||
toolUseConfirm.onReject(feedback)
|
||||
onReject()
|
||||
onDone()
|
||||
break
|
||||
}
|
||||
},
|
||||
[toolUseConfirm, onDone, onReject],
|
||||
)
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'reject',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
})
|
||||
toolUseConfirm.onReject()
|
||||
onReject()
|
||||
onDone()
|
||||
}, [toolUseConfirm, onDone, onReject])
|
||||
|
||||
return (
|
||||
<PermissionDialog
|
||||
title="Monitor"
|
||||
workerBadge={workerBadge}
|
||||
>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.permission as any}>
|
||||
{input.description}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{truncateToLines(input.command, 5)}
|
||||
</Text>
|
||||
</Box>
|
||||
<PermissionRuleExplanation
|
||||
permissionResult={toolUseConfirm.permissionResult}
|
||||
toolType="command"
|
||||
/>
|
||||
<PermissionPrompt<OptionValue>
|
||||
options={options}
|
||||
onSelect={handleSelect}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const ReviewArtifactPermissionRequest: (props: Record<string, unknown>) => null = () => null;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { Select } from '../../CustomSelect/select.js'
|
||||
import { usePermissionRequestLogging } from '../hooks.js'
|
||||
import { PermissionDialog } from '../PermissionDialog.js'
|
||||
import type { PermissionRequestProps } from '../PermissionRequest.js'
|
||||
import { logUnaryPermissionEvent } from '../utils.js'
|
||||
|
||||
export function ReviewArtifactPermissionRequest({
|
||||
toolUseConfirm,
|
||||
onDone,
|
||||
onReject,
|
||||
workerBadge,
|
||||
}: PermissionRequestProps): React.ReactNode {
|
||||
const { title, annotations, summary } = toolUseConfirm.input as {
|
||||
title?: string
|
||||
annotations?: Array<{ line?: number; message: string; severity?: string }>
|
||||
summary?: string
|
||||
}
|
||||
|
||||
const unaryEvent = {
|
||||
completion_type: 'tool_use_single' as const,
|
||||
language_name: 'none',
|
||||
}
|
||||
usePermissionRequestLogging(toolUseConfirm, unaryEvent)
|
||||
|
||||
const annotationCount = annotations?.length ?? 0
|
||||
|
||||
function handleResponse(value: 'yes' | 'no'): void {
|
||||
if (value === 'yes') {
|
||||
logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept')
|
||||
toolUseConfirm.onAllow(toolUseConfirm.input, [])
|
||||
onDone()
|
||||
} else {
|
||||
logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'reject')
|
||||
toolUseConfirm.onReject()
|
||||
onReject()
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PermissionDialog
|
||||
color="permission"
|
||||
title="Review artifact?"
|
||||
workerBadge={workerBadge}
|
||||
>
|
||||
<Box flexDirection="column" marginTop={1} paddingX={1}>
|
||||
<Text>
|
||||
Claude wants to review{title ? `: ${title}` : ' an artifact'}.
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor>
|
||||
{annotationCount} annotation{annotationCount !== 1 ? 's' : ''} will
|
||||
be presented.
|
||||
</Text>
|
||||
{summary ? <Text dimColor>Summary: {summary}</Text> : null}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Yes, show review', value: 'yes' as const },
|
||||
{ label: 'No, skip', value: 'no' as const },
|
||||
]}
|
||||
onChange={handleResponse}
|
||||
onCancel={() => handleResponse('no')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
)
|
||||
}
|
||||
@@ -528,7 +528,7 @@ export function BackgroundTasksDialog({
|
||||
return (
|
||||
<WorkflowDetailDialog
|
||||
workflow={task}
|
||||
onDone={onDone}
|
||||
onDone={onDone as (message?: string, options?: { display?: string }) => void}
|
||||
onKill={
|
||||
task.status === 'running' && killWorkflowTask
|
||||
? () => killWorkflowTask(task.id, setAppState)
|
||||
@@ -536,12 +536,12 @@ export function BackgroundTasksDialog({
|
||||
}
|
||||
onSkipAgent={
|
||||
task.status === 'running' && skipWorkflowAgent
|
||||
? (agentId: AgentId) => skipWorkflowAgent(task.id, agentId, setAppState)
|
||||
? (agentId: string) => skipWorkflowAgent(task.id, agentId as AgentId, setAppState)
|
||||
: undefined
|
||||
}
|
||||
onRetryAgent={
|
||||
task.status === 'running' && retryWorkflowAgent
|
||||
? (agentId: AgentId) => retryWorkflowAgent(task.id, agentId, setAppState)
|
||||
? (agentId: string) => retryWorkflowAgent(task.id, agentId as AgentId, setAppState)
|
||||
: undefined
|
||||
}
|
||||
onBack={goBackToList}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const MonitorMcpDetailDialog: (props: Record<string, unknown>) => null = () => null;
|
||||
100
src/components/tasks/MonitorMcpDetailDialog.tsx
Normal file
100
src/components/tasks/MonitorMcpDetailDialog.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react'
|
||||
import type { DeepImmutable } from 'src/types/utils.js'
|
||||
import { useElapsedTime } from '../../hooks/useElapsedTime.js'
|
||||
import { Box, Text, type KeyboardEvent } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import type { MonitorMcpTaskState } from '../../tasks/MonitorMcpTask/MonitorMcpTask.js'
|
||||
import { Byline } from '../design-system/Byline.js'
|
||||
import { Dialog } from '../design-system/Dialog.js'
|
||||
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'
|
||||
|
||||
type Props = {
|
||||
task: DeepImmutable<MonitorMcpTaskState>
|
||||
onBack?: () => void
|
||||
onKill?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail dialog for MCP monitor tasks shown in the Shift+Down background
|
||||
* tasks overlay. Displays the server name, resource URI, and current status.
|
||||
* Follows the DreamDetailDialog/ShellDetailDialog pattern.
|
||||
*/
|
||||
export function MonitorMcpDetailDialog({
|
||||
task,
|
||||
onBack,
|
||||
onKill,
|
||||
}: Props): React.ReactNode {
|
||||
const elapsedTime = useElapsedTime(
|
||||
task.startTime,
|
||||
task.status === 'running',
|
||||
1000,
|
||||
0,
|
||||
)
|
||||
|
||||
useKeybindings(
|
||||
{},
|
||||
{ context: 'MonitorMcpDetail' },
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'left' && onBack) {
|
||||
e.preventDefault()
|
||||
onBack()
|
||||
} else if (e.key === 'x' && task.status === 'running' && onKill) {
|
||||
e.preventDefault()
|
||||
onKill()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" tabIndex={0} borderStyle="round" onKeyDown={handleKeyDown}>
|
||||
<Dialog
|
||||
title="MCP Monitor"
|
||||
subtitle={
|
||||
<Text dimColor>
|
||||
{elapsedTime} · {task.serverName}:{task.resourceUri}
|
||||
</Text>
|
||||
}
|
||||
onCancel={onBack ?? (() => {})}
|
||||
inputGuide={() => (
|
||||
<Byline>
|
||||
{onBack && (
|
||||
<KeyboardShortcutHint shortcut="←" action="go back" />
|
||||
)}
|
||||
<KeyboardShortcutHint shortcut="Esc" action="close" />
|
||||
{task.status === 'running' && onKill && (
|
||||
<KeyboardShortcutHint shortcut="x" action="stop" />
|
||||
)}
|
||||
</Byline>
|
||||
)}
|
||||
>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
<Text bold>Status:</Text>{' '}
|
||||
{task.status === 'running' ? (
|
||||
<Text color="ansi:green">running</Text>
|
||||
) : task.status === 'completed' ? (
|
||||
<Text color="ansi:green">{task.status}</Text>
|
||||
) : (
|
||||
<Text color="ansi:red">{task.status}</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Description:</Text> {task.description}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Server:</Text> {task.serverName}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Resource:</Text> {task.resourceUri}
|
||||
</Text>
|
||||
{task.command && (
|
||||
<Text>
|
||||
<Text bold>Command:</Text> {task.command}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const WorkflowDetailDialog: (props: Record<string, unknown>) => null = () => null;
|
||||
115
src/components/tasks/WorkflowDetailDialog.tsx
Normal file
115
src/components/tasks/WorkflowDetailDialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import type { DeepImmutable } from 'src/types/utils.js'
|
||||
import { useElapsedTime } from '../../hooks/useElapsedTime.js'
|
||||
import { Box, Text, type KeyboardEvent } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import type { LocalWorkflowTaskState } from '../../tasks/LocalWorkflowTask/LocalWorkflowTask.js'
|
||||
import { Byline } from '../design-system/Byline.js'
|
||||
import { Dialog } from '../design-system/Dialog.js'
|
||||
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'
|
||||
|
||||
type Props = {
|
||||
workflow: DeepImmutable<LocalWorkflowTaskState>
|
||||
onDone: (message?: string, options?: { display?: string }) => void
|
||||
onKill?: () => void
|
||||
onSkipAgent?: (agentId: string) => void
|
||||
onRetryAgent?: (agentId: string) => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail dialog for local workflow tasks shown in the Shift+Down background
|
||||
* tasks overlay. Displays the workflow name, file, status, and output.
|
||||
* Follows the DreamDetailDialog/ShellDetailDialog pattern.
|
||||
*/
|
||||
export function WorkflowDetailDialog({
|
||||
workflow,
|
||||
onDone,
|
||||
onKill,
|
||||
onSkipAgent: _onSkipAgent,
|
||||
onRetryAgent: _onRetryAgent,
|
||||
onBack,
|
||||
}: Props): React.ReactNode {
|
||||
const elapsedTime = useElapsedTime(
|
||||
workflow.startTime,
|
||||
workflow.status === 'running',
|
||||
1000,
|
||||
0,
|
||||
)
|
||||
|
||||
useKeybindings(
|
||||
{},
|
||||
{ context: 'WorkflowDetail' },
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent): void => {
|
||||
if (e.key === 'left' && onBack) {
|
||||
e.preventDefault()
|
||||
onBack()
|
||||
} else if (e.key === 'x' && workflow.status === 'running' && onKill) {
|
||||
e.preventDefault()
|
||||
onKill()
|
||||
}
|
||||
},
|
||||
[onBack, onKill, workflow.status],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" tabIndex={0} borderStyle="round" onKeyDown={handleKeyDown}>
|
||||
<Dialog
|
||||
title="Workflow"
|
||||
subtitle={
|
||||
<Text dimColor>
|
||||
{elapsedTime} · {workflow.workflowName}
|
||||
</Text>
|
||||
}
|
||||
onCancel={onBack ?? (() => {})}
|
||||
inputGuide={() => (
|
||||
<Byline>
|
||||
{onBack && (
|
||||
<KeyboardShortcutHint shortcut={'\u2190'} action="go back" />
|
||||
)}
|
||||
<KeyboardShortcutHint shortcut="Esc" action="close" />
|
||||
{workflow.status === 'running' && onKill && (
|
||||
<KeyboardShortcutHint shortcut="x" action="stop" />
|
||||
)}
|
||||
</Byline>
|
||||
)}
|
||||
>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
<Text bold>Status:</Text>{' '}
|
||||
{workflow.status === 'running' ? (
|
||||
<Text color="ansi:green">running</Text>
|
||||
) : workflow.status === 'completed' ? (
|
||||
<Text color="ansi:green">{workflow.status}</Text>
|
||||
) : (
|
||||
<Text color="ansi:red">{workflow.status}</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Description:</Text> {workflow.description}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Workflow:</Text> {workflow.workflowName}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>File:</Text> {workflow.workflowFile}
|
||||
</Text>
|
||||
{workflow.summary && (
|
||||
<Text>
|
||||
<Text bold>Summary:</Text> {workflow.summary}
|
||||
</Text>
|
||||
)}
|
||||
{workflow.output && (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Output:</Text>
|
||||
<Text dimColor>{workflow.output}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user