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:
claude-code-best
2026-04-11 23:22:55 +08:00
committed by GitHub
parent 2fea429dc6
commit 09fc515edb
124 changed files with 10958 additions and 577 deletions

View File

@@ -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>
)
);
}