637 lines
83 KiB
TypeScript
637 lines
83 KiB
TypeScript
|
|
import { c as _c } from "react/compiler-runtime";
|
||
|
|
import figures from 'figures';
|
||
|
|
import React, { createContext, type ReactNode, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||
|
|
import { fileURLToPath } from 'url';
|
||
|
|
import { ModalContext } from '../context/modalContext.js';
|
||
|
|
import { PromptOverlayProvider, usePromptOverlay, usePromptOverlayDialog } from '../context/promptOverlayContext.js';
|
||
|
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||
|
|
import ScrollBox, { type ScrollBoxHandle } from '../ink/components/ScrollBox.js';
|
||
|
|
import instances from '../ink/instances.js';
|
||
|
|
import { Box, Text } from '../ink.js';
|
||
|
|
import type { Message } from '../types/message.js';
|
||
|
|
import { openBrowser, openPath } from '../utils/browser.js';
|
||
|
|
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
|
||
|
|
import { plural } from '../utils/stringUtils.js';
|
||
|
|
import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js';
|
||
|
|
import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js';
|
||
|
|
import type { StickyPrompt } from './VirtualMessageList.js';
|
||
|
|
|
||
|
|
/** Rows of transcript context kept visible above the modal pane's ▔ divider. */
|
||
|
|
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: () => {}
|
||
|
|
});
|
||
|
|
type Props = {
|
||
|
|
/** Content that scrolls (messages, tool output) */
|
||
|
|
scrollable: ReactNode;
|
||
|
|
/** Content pinned to the bottom (spinner, prompt, permissions) */
|
||
|
|
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;
|
||
|
|
/** 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;
|
||
|
|
/** 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;
|
||
|
|
/** 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>;
|
||
|
|
/** Ref to the scroll box for keyboard scrolling. RefObject (not Ref) so
|
||
|
|
* pillVisible's useSyncExternalStore can subscribe to scroll changes. */
|
||
|
|
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>;
|
||
|
|
/** Force-hide the pill (e.g. viewing a sub-agent task). */
|
||
|
|
hidePill?: boolean;
|
||
|
|
/** Force-hide the sticky prompt header (e.g. viewing a teammate task). */
|
||
|
|
hideSticky?: boolean;
|
||
|
|
/** Count for the pill text. 0 → "Jump to bottom", >0 → "N new messages". */
|
||
|
|
newMessageCount?: number;
|
||
|
|
/** Called when the user clicks the "N new" pill. */
|
||
|
|
onPillClick?: () => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Tracks the in-transcript "N new messages" divider position while the
|
||
|
|
* user is scrolled up. Snapshots message count AND scrollHeight the first
|
||
|
|
* time sticky breaks. scrollHeight ≈ the y-position of the divider in the
|
||
|
|
* scroll content (it renders right after the last message that existed at
|
||
|
|
* snapshot time).
|
||
|
|
*
|
||
|
|
* `pillVisible` lives in FullscreenLayout (not here) — it subscribes
|
||
|
|
* directly to ScrollBox via useSyncExternalStore with a boolean snapshot
|
||
|
|
* against `dividerYRef`, so per-frame scroll never re-renders REPL.
|
||
|
|
* `dividerIndex` stays here because REPL needs it for computeUnseenDivider
|
||
|
|
* → Messages' divider line; it changes only ~twice/scroll-session
|
||
|
|
* (first scroll-away + repin), acceptable REPL re-render cost.
|
||
|
|
*
|
||
|
|
* `onScrollAway` must be called by every scroll-away action with the
|
||
|
|
* handle; `onRepin` by submit/scroll-to-bottom.
|
||
|
|
*/
|
||
|
|
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;
|
||
|
|
/** 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;
|
||
|
|
/** Scroll the handle so the divider line is at the top of the viewport. */
|
||
|
|
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;
|
||
|
|
} {
|
||
|
|
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;
|
||
|
|
// 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 onRepin = useCallback(() => {
|
||
|
|
// Don't clear dividerYRef here — a trackpad momentum wheel event
|
||
|
|
// racing in the same stdin batch would see null and re-snapshot,
|
||
|
|
// 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);
|
||
|
|
}, []);
|
||
|
|
const onScrollAway = useCallback((handle: ScrollBoxHandle) => {
|
||
|
|
// Nothing below the viewport → nothing to jump to. Covers both:
|
||
|
|
// • empty/short session: scrollUp calls scrollTo(0) which breaks sticky
|
||
|
|
// even at scrollTop=0 (wheel-up on fresh session showed the pill)
|
||
|
|
// • click-to-select at bottom: useDragToScroll.check() calls
|
||
|
|
// scrollTo(current) to break sticky so streaming content doesn't shift
|
||
|
|
// under the selection, then onScroll(false, …) — but scrollTop is still
|
||
|
|
// 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;
|
||
|
|
// 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();
|
||
|
|
// New scroll-away session → move the divider here (replaces old one)
|
||
|
|
setDividerIndex(countRef.current);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
const jumpToNew = useCallback((handle_0: ScrollBoxHandle | null) => {
|
||
|
|
if (!handle_0) 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
|
||
|
|
// (still at top-range bounds before React re-renders) pins scrollTop
|
||
|
|
// 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_0.scrollToBottom();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Sync dividerYRef with dividerIndex. When onRepin fires (submit,
|
||
|
|
// scroll-to-bottom), it sets dividerIndex=null but leaves the ref
|
||
|
|
// non-null — a wheel event racing in the same stdin batch would
|
||
|
|
// otherwise see null and re-snapshot. Deferring the ref clear to
|
||
|
|
// useEffect guarantees the ref stays non-null until React has committed
|
||
|
|
// the null dividerIndex, blocking the if-null guard in onScrollAway.
|
||
|
|
//
|
||
|
|
// Also handles /clear, rewind, teammate-view swap — if the count drops
|
||
|
|
// below the divider index, the divider would point at nothing.
|
||
|
|
useEffect(() => {
|
||
|
|
if (dividerIndex === null) {
|
||
|
|
dividerYRef.current = null;
|
||
|
|
} else if (messageCount < dividerIndex) {
|
||
|
|
dividerYRef.current = null;
|
||
|
|
setDividerIndex(null);
|
||
|
|
}
|
||
|
|
}, [messageCount, dividerIndex]);
|
||
|
|
const shiftDivider = useCallback((indexDelta: number, heightDelta: number) => {
|
||
|
|
setDividerIndex(idx => idx === null ? null : idx + indexDelta);
|
||
|
|
if (dividerYRef.current !== null) {
|
||
|
|
dividerYRef.current += heightDelta;
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
return {
|
||
|
|
dividerIndex,
|
||
|
|
dividerYRef,
|
||
|
|
onScrollAway,
|
||
|
|
onRepin,
|
||
|
|
jumpToNew,
|
||
|
|
shiftDivider
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Counts assistant turns in messages[dividerIndex..end). A "turn" is what
|
||
|
|
* users think of as "a new message from Claude" — not raw assistant entries
|
||
|
|
* (one turn yields multiple entries: tool_use blocks + text blocks). We count
|
||
|
|
* non-assistant→assistant transitions, but only for entries that actually
|
||
|
|
* 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;
|
||
|
|
for (let i = dividerIndex; i < messages.length; i++) {
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
return count;
|
||
|
|
}
|
||
|
|
function assistantHasVisibleText(m: Message): boolean {
|
||
|
|
if (m.type !== 'assistant') return false;
|
||
|
|
for (const b of m.message.content) {
|
||
|
|
if (b.type === 'text' && b.text.trim() !== '') return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
export type UnseenDivider = {
|
||
|
|
firstUnseenUuid: Message['uuid'];
|
||
|
|
count: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Builds the unseenDivider object REPL passes to Messages + the pill.
|
||
|
|
* Returns undefined only when no content has arrived past the divider
|
||
|
|
* yet (messages[dividerIndex] doesn't exist). Once ANY message arrives
|
||
|
|
* — including tool_use-only assistant entries and tool_result user entries
|
||
|
|
* that countUnseenAssistantTurns skips — count floors at 1 so the pill
|
||
|
|
* flips from "Jump to bottom" to "1 new message". Without the floor,
|
||
|
|
* the pill stays "Jump to bottom" through an entire tool-call sequence
|
||
|
|
* until Claude's text response lands.
|
||
|
|
*/
|
||
|
|
export function computeUnseenDivider(messages: readonly Message[], dividerIndex: number | null): UnseenDivider | 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;
|
||
|
|
while (anchorIdx < messages.length && (messages[anchorIdx]?.type === 'progress' || isNullRenderingAttachment(messages[anchorIdx]!))) {
|
||
|
|
anchorIdx++;
|
||
|
|
}
|
||
|
|
const uuid = messages[anchorIdx]?.uuid;
|
||
|
|
if (!uuid) return undefined;
|
||
|
|
const count = countUnseenAssistantTurns(messages, dividerIndex);
|
||
|
|
return {
|
||
|
|
firstUnseenUuid: uuid,
|
||
|
|
count: Math.max(1, count)
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Layout wrapper for the REPL. In fullscreen mode, puts scrollable
|
||
|
|
* content in a sticky-scroll box and pins bottom content via flexbox.
|
||
|
|
* Outside fullscreen mode, renders content sequentially so the existing
|
||
|
|
* main-screen scrollback rendering works unchanged.
|
||
|
|
*
|
||
|
|
* Fullscreen mode defaults on for ants (CLAUDE_CODE_NO_FLICKER=0 to opt out)
|
||
|
|
* and off for external users (CLAUDE_CODE_NO_FLICKER=1 to opt in).
|
||
|
|
* The <AlternateScreen> wrapper
|
||
|
|
* (alt buffer + mouse tracking + height constraint) lives at REPL's root
|
||
|
|
* so nothing can accidentally render outside it.
|
||
|
|
*/
|
||
|
|
export function FullscreenLayout(t0) {
|
||
|
|
const $ = _c(47);
|
||
|
|
const {
|
||
|
|
scrollable,
|
||
|
|
bottom,
|
||
|
|
overlay,
|
||
|
|
bottomFloat,
|
||
|
|
modal,
|
||
|
|
modalScrollRef,
|
||
|
|
scrollRef,
|
||
|
|
dividerYRef,
|
||
|
|
hidePill: t1,
|
||
|
|
hideSticky: t2,
|
||
|
|
newMessageCount: t3,
|
||
|
|
onPillClick
|
||
|
|
} = t0;
|
||
|
|
const hidePill = t1 === undefined ? false : t1;
|
||
|
|
const hideSticky = t2 === undefined ? false : t2;
|
||
|
|
const newMessageCount = t3 === undefined ? 0 : t3;
|
||
|
|
const {
|
||
|
|
rows: terminalRows,
|
||
|
|
columns
|
||
|
|
} = useTerminalSize();
|
||
|
|
const [stickyPrompt, setStickyPrompt] = useState(null);
|
||
|
|
let t4;
|
||
|
|
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||
|
|
t4 = {
|
||
|
|
setStickyPrompt
|
||
|
|
};
|
||
|
|
$[0] = t4;
|
||
|
|
} else {
|
||
|
|
t4 = $[0];
|
||
|
|
}
|
||
|
|
const chromeCtx = t4;
|
||
|
|
let t5;
|
||
|
|
if ($[1] !== scrollRef) {
|
||
|
|
t5 = listener => scrollRef?.current?.subscribe(listener) ?? _temp;
|
||
|
|
$[1] = scrollRef;
|
||
|
|
$[2] = t5;
|
||
|
|
} else {
|
||
|
|
t5 = $[2];
|
||
|
|
}
|
||
|
|
const subscribe = t5;
|
||
|
|
let t6;
|
||
|
|
if ($[3] !== dividerYRef || $[4] !== scrollRef) {
|
||
|
|
t6 = () => {
|
||
|
|
const s = scrollRef?.current;
|
||
|
|
const dividerY = dividerYRef?.current;
|
||
|
|
if (!s || dividerY == null) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY;
|
||
|
|
};
|
||
|
|
$[3] = dividerYRef;
|
||
|
|
$[4] = scrollRef;
|
||
|
|
$[5] = t6;
|
||
|
|
} else {
|
||
|
|
t6 = $[5];
|
||
|
|
}
|
||
|
|
const pillVisible = useSyncExternalStore(subscribe, t6);
|
||
|
|
let t7;
|
||
|
|
if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
|
||
|
|
t7 = [];
|
||
|
|
$[6] = t7;
|
||
|
|
} else {
|
||
|
|
t7 = $[6];
|
||
|
|
}
|
||
|
|
useLayoutEffect(_temp3, t7);
|
||
|
|
if (isFullscreenEnvEnabled()) {
|
||
|
|
const sticky = hideSticky ? null : stickyPrompt;
|
||
|
|
const headerPrompt = sticky != null && sticky !== "clicked" && overlay == null ? sticky : null;
|
||
|
|
const padCollapsed = sticky != null && overlay == null;
|
||
|
|
let t8;
|
||
|
|
if ($[7] !== headerPrompt) {
|
||
|
|
t8 = headerPrompt && <StickyPromptHeader text={headerPrompt.text} onClick={headerPrompt.scrollTo} />;
|
||
|
|
$[7] = headerPrompt;
|
||
|
|
$[8] = t8;
|
||
|
|
} else {
|
||
|
|
t8 = $[8];
|
||
|
|
}
|
||
|
|
const t9 = padCollapsed ? 0 : 1;
|
||
|
|
let t10;
|
||
|
|
if ($[9] !== scrollable) {
|
||
|
|
t10 = <ScrollChromeContext value={chromeCtx}>{scrollable}</ScrollChromeContext>;
|
||
|
|
$[9] = scrollable;
|
||
|
|
$[10] = t10;
|
||
|
|
} else {
|
||
|
|
t10 = $[10];
|
||
|
|
}
|
||
|
|
let t11;
|
||
|
|
if ($[11] !== overlay || $[12] !== scrollRef || $[13] !== t10 || $[14] !== t9) {
|
||
|
|
t11 = <ScrollBox ref={scrollRef} flexGrow={1} flexDirection="column" paddingTop={t9} stickyScroll={true}>{t10}{overlay}</ScrollBox>;
|
||
|
|
$[11] = overlay;
|
||
|
|
$[12] = scrollRef;
|
||
|
|
$[13] = t10;
|
||
|
|
$[14] = t9;
|
||
|
|
$[15] = t11;
|
||
|
|
} else {
|
||
|
|
t11 = $[15];
|
||
|
|
}
|
||
|
|
let t12;
|
||
|
|
if ($[16] !== hidePill || $[17] !== newMessageCount || $[18] !== onPillClick || $[19] !== overlay || $[20] !== pillVisible) {
|
||
|
|
t12 = !hidePill && pillVisible && overlay == null && <NewMessagesPill count={newMessageCount} onClick={onPillClick} />;
|
||
|
|
$[16] = hidePill;
|
||
|
|
$[17] = newMessageCount;
|
||
|
|
$[18] = onPillClick;
|
||
|
|
$[19] = overlay;
|
||
|
|
$[20] = pillVisible;
|
||
|
|
$[21] = t12;
|
||
|
|
} else {
|
||
|
|
t12 = $[21];
|
||
|
|
}
|
||
|
|
let t13;
|
||
|
|
if ($[22] !== bottomFloat) {
|
||
|
|
t13 = bottomFloat != null && <Box position="absolute" bottom={0} right={0} opaque={true}>{bottomFloat}</Box>;
|
||
|
|
$[22] = bottomFloat;
|
||
|
|
$[23] = t13;
|
||
|
|
} else {
|
||
|
|
t13 = $[23];
|
||
|
|
}
|
||
|
|
let t14;
|
||
|
|
if ($[24] !== t11 || $[25] !== t12 || $[26] !== t13 || $[27] !== t8) {
|
||
|
|
t14 = <Box flexGrow={1} flexDirection="column" overflow="hidden">{t8}{t11}{t12}{t13}</Box>;
|
||
|
|
$[24] = t11;
|
||
|
|
$[25] = t12;
|
||
|
|
$[26] = t13;
|
||
|
|
$[27] = t8;
|
||
|
|
$[28] = t14;
|
||
|
|
} else {
|
||
|
|
t14 = $[28];
|
||
|
|
}
|
||
|
|
let t15;
|
||
|
|
let t16;
|
||
|
|
if ($[29] === Symbol.for("react.memo_cache_sentinel")) {
|
||
|
|
t15 = <SuggestionsOverlay />;
|
||
|
|
t16 = <DialogOverlay />;
|
||
|
|
$[29] = t15;
|
||
|
|
$[30] = t16;
|
||
|
|
} else {
|
||
|
|
t15 = $[29];
|
||
|
|
t16 = $[30];
|
||
|
|
}
|
||
|
|
let t17;
|
||
|
|
if ($[31] !== bottom) {
|
||
|
|
t17 = <Box flexDirection="column" flexShrink={0} width="100%" maxHeight="50%">{t15}{t16}<Box flexDirection="column" width="100%" flexGrow={1} overflowY="hidden">{bottom}</Box></Box>;
|
||
|
|
$[31] = bottom;
|
||
|
|
$[32] = t17;
|
||
|
|
} else {
|
||
|
|
t17 = $[32];
|
||
|
|
}
|
||
|
|
let t18;
|
||
|
|
if ($[33] !== columns || $[34] !== modal || $[35] !== modalScrollRef || $[36] !== terminalRows) {
|
||
|
|
t18 = modal != null && <ModalContext value={{
|
||
|
|
rows: terminalRows - MODAL_TRANSCRIPT_PEEK - 1,
|
||
|
|
columns: columns - 4,
|
||
|
|
scrollRef: modalScrollRef ?? null
|
||
|
|
}}><Box position="absolute" bottom={0} left={0} right={0} maxHeight={terminalRows - MODAL_TRANSCRIPT_PEEK} flexDirection="column" overflow="hidden" opaque={true}><Box flexShrink={0}><Text color="permission">{"\u2594".repeat(columns)}</Text></Box><Box flexDirection="column" paddingX={2} flexShrink={0} overflow="hidden">{modal}</Box></Box></ModalContext>;
|
||
|
|
$[33] = columns;
|
||
|
|
$[34] = modal;
|
||
|
|
$[35] = modalScrollRef;
|
||
|
|
$[36] = terminalRows;
|
||
|
|
$[37] = t18;
|
||
|
|
} else {
|
||
|
|
t18 = $[37];
|
||
|
|
}
|
||
|
|
let t19;
|
||
|
|
if ($[38] !== t14 || $[39] !== t17 || $[40] !== t18) {
|
||
|
|
t19 = <PromptOverlayProvider>{t14}{t17}{t18}</PromptOverlayProvider>;
|
||
|
|
$[38] = t14;
|
||
|
|
$[39] = t17;
|
||
|
|
$[40] = t18;
|
||
|
|
$[41] = t19;
|
||
|
|
} else {
|
||
|
|
t19 = $[41];
|
||
|
|
}
|
||
|
|
return t19;
|
||
|
|
}
|
||
|
|
let t8;
|
||
|
|
if ($[42] !== bottom || $[43] !== modal || $[44] !== overlay || $[45] !== scrollable) {
|
||
|
|
t8 = <>{scrollable}{bottom}{overlay}{modal}</>;
|
||
|
|
$[42] = bottom;
|
||
|
|
$[43] = modal;
|
||
|
|
$[44] = overlay;
|
||
|
|
$[45] = scrollable;
|
||
|
|
$[46] = t8;
|
||
|
|
} else {
|
||
|
|
t8 = $[46];
|
||
|
|
}
|
||
|
|
return t8;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Slack-style pill. Absolute overlay at bottom={0} of the scrollwrap — floats
|
||
|
|
// over the ScrollBox's last content row, only obscuring the centered pill
|
||
|
|
// text (the rest of the row shows ScrollBox content). Scroll-smear from
|
||
|
|
// DECSTBM shifting the pill's pixels is repaired at the Ink layer
|
||
|
|
// (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 _temp3() {
|
||
|
|
if (!isFullscreenEnvEnabled()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const ink = instances.get(process.stdout);
|
||
|
|
if (!ink) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
ink.onHyperlinkClick = _temp2;
|
||
|
|
return () => {
|
||
|
|
ink.onHyperlinkClick = undefined;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
function _temp2(url) {
|
||
|
|
if (url.startsWith("file:")) {
|
||
|
|
try {
|
||
|
|
openPath(fileURLToPath(url));
|
||
|
|
} catch {}
|
||
|
|
} else {
|
||
|
|
openBrowser(url);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function _temp() {}
|
||
|
|
function NewMessagesPill(t0) {
|
||
|
|
const $ = _c(10);
|
||
|
|
const {
|
||
|
|
count,
|
||
|
|
onClick
|
||
|
|
} = t0;
|
||
|
|
const [hover, setHover] = useState(false);
|
||
|
|
let t1;
|
||
|
|
let t2;
|
||
|
|
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||
|
|
t1 = () => setHover(true);
|
||
|
|
t2 = () => setHover(false);
|
||
|
|
$[0] = t1;
|
||
|
|
$[1] = t2;
|
||
|
|
} else {
|
||
|
|
t1 = $[0];
|
||
|
|
t2 = $[1];
|
||
|
|
}
|
||
|
|
const t3 = hover ? "userMessageBackgroundHover" : "userMessageBackground";
|
||
|
|
let t4;
|
||
|
|
if ($[2] !== count) {
|
||
|
|
t4 = count > 0 ? `${count} new ${plural(count, "message")}` : "Jump to bottom";
|
||
|
|
$[2] = count;
|
||
|
|
$[3] = t4;
|
||
|
|
} else {
|
||
|
|
t4 = $[3];
|
||
|
|
}
|
||
|
|
let t5;
|
||
|
|
if ($[4] !== t3 || $[5] !== t4) {
|
||
|
|
t5 = <Text backgroundColor={t3} dimColor={true}>{" "}{t4}{" "}{figures.arrowDown}{" "}</Text>;
|
||
|
|
$[4] = t3;
|
||
|
|
$[5] = t4;
|
||
|
|
$[6] = t5;
|
||
|
|
} else {
|
||
|
|
t5 = $[6];
|
||
|
|
}
|
||
|
|
let t6;
|
||
|
|
if ($[7] !== onClick || $[8] !== t5) {
|
||
|
|
t6 = <Box position="absolute" bottom={0} left={0} right={0} justifyContent="center"><Box onClick={onClick} onMouseEnter={t1} onMouseLeave={t2}>{t5}</Box></Box>;
|
||
|
|
$[7] = onClick;
|
||
|
|
$[8] = t5;
|
||
|
|
$[9] = t6;
|
||
|
|
} else {
|
||
|
|
t6 = $[9];
|
||
|
|
}
|
||
|
|
return t6;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Context breadcrumb: when scrolled up into history, pin the current
|
||
|
|
// conversation turn's prompt above the viewport so you know what Claude was
|
||
|
|
// responding to. Normal-flow sibling BEFORE the ScrollBox (mirrors the pill
|
||
|
|
// below it) — shrinks the ScrollBox by exactly 1 row via flex, stays outside
|
||
|
|
// the DECSTBM scroll region. Click jumps back to the prompt.
|
||
|
|
//
|
||
|
|
// Height is FIXED at 1 row (truncate-end for long prompts). A variable-height
|
||
|
|
// header (1 when short, 2 when wrapped) shifts the ScrollBox by 1 row every
|
||
|
|
// time the sticky prompt switches during scroll — content jumps on screen
|
||
|
|
// 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(t0) {
|
||
|
|
const $ = _c(8);
|
||
|
|
const {
|
||
|
|
text,
|
||
|
|
onClick
|
||
|
|
} = t0;
|
||
|
|
const [hover, setHover] = useState(false);
|
||
|
|
const t1 = hover ? "userMessageBackgroundHover" : "userMessageBackground";
|
||
|
|
let t2;
|
||
|
|
let t3;
|
||
|
|
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||
|
|
t2 = () => setHover(true);
|
||
|
|
t3 = () => setHover(false);
|
||
|
|
$[0] = t2;
|
||
|
|
$[1] = t3;
|
||
|
|
} else {
|
||
|
|
t2 = $[0];
|
||
|
|
t3 = $[1];
|
||
|
|
}
|
||
|
|
let t4;
|
||
|
|
if ($[2] !== text) {
|
||
|
|
t4 = <Text color="subtle" wrap="truncate-end">{figures.pointer} {text}</Text>;
|
||
|
|
$[2] = text;
|
||
|
|
$[3] = t4;
|
||
|
|
} else {
|
||
|
|
t4 = $[3];
|
||
|
|
}
|
||
|
|
let t5;
|
||
|
|
if ($[4] !== onClick || $[5] !== t1 || $[6] !== t4) {
|
||
|
|
t5 = <Box flexShrink={0} width="100%" height={1} paddingRight={1} backgroundColor={t1} onClick={onClick} onMouseEnter={t2} onMouseLeave={t3}>{t4}</Box>;
|
||
|
|
$[4] = onClick;
|
||
|
|
$[5] = t1;
|
||
|
|
$[6] = t4;
|
||
|
|
$[7] = t5;
|
||
|
|
} else {
|
||
|
|
t5 = $[7];
|
||
|
|
}
|
||
|
|
return t5;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Slash-command suggestion overlay — see promptOverlayContext.tsx for why
|
||
|
|
// it's portaled. Scroll-smear from floating over the DECSTBM region is
|
||
|
|
// repaired at the Ink layer (absoluteRectsPrev in render-node-to-output.ts).
|
||
|
|
// The renderer clamps negative y to 0 for absolute elements (see
|
||
|
|
// render-node-to-output.ts), so the top rows (best matches) stay visible
|
||
|
|
// even when the overlay extends above the viewport. We omit minHeight and
|
||
|
|
// 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() {
|
||
|
|
const $ = _c(4);
|
||
|
|
const data = usePromptOverlay();
|
||
|
|
if (!data || data.suggestions.length === 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
let t0;
|
||
|
|
if ($[0] !== data.maxColumnWidth || $[1] !== data.selectedSuggestion || $[2] !== data.suggestions) {
|
||
|
|
t0 = <Box position="absolute" bottom="100%" left={0} right={0} paddingX={2} paddingTop={1} flexDirection="column" opaque={true}><PromptInputFooterSuggestions suggestions={data.suggestions} selectedSuggestion={data.selectedSuggestion} maxColumnWidth={data.maxColumnWidth} overlay={true} /></Box>;
|
||
|
|
$[0] = data.maxColumnWidth;
|
||
|
|
$[1] = data.selectedSuggestion;
|
||
|
|
$[2] = data.suggestions;
|
||
|
|
$[3] = t0;
|
||
|
|
} else {
|
||
|
|
t0 = $[3];
|
||
|
|
}
|
||
|
|
return t0;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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() {
|
||
|
|
const $ = _c(2);
|
||
|
|
const node = usePromptOverlayDialog();
|
||
|
|
if (!node) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
let t0;
|
||
|
|
if ($[0] !== node) {
|
||
|
|
t0 = <Box position="absolute" bottom="100%" left={0} right={0} opaque={true}>{node}</Box>;
|
||
|
|
$[0] = node;
|
||
|
|
$[1] = t0;
|
||
|
|
} else {
|
||
|
|
t0 = $[1];
|
||
|
|
}
|
||
|
|
return t0;
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmaWd1cmVzIiwiUmVhY3QiLCJjcmVhdGVDb250ZXh0IiwiUmVhY3ROb2RlIiwiUmVmT2JqZWN0IiwidXNlQ2FsbGJhY2siLCJ1c2VFZmZlY3QiLCJ1c2VMYXlvdXRFZmZlY3QiLCJ1c2VNZW1vIiwidXNlUmVmIiwidXNlU3RhdGUiLCJ1c2VTeW5jRXh0ZXJuYWxTdG9yZSIsImZpbGVVUkxUb1BhdGgiLCJNb2RhbENvbnRleHQiLCJQcm9tcHRPdmVybGF5UHJvdmlkZXIiLCJ1c2VQcm9tcHRPdmVybGF5IiwidXNlUHJvbXB0T3ZlcmxheURpYWxvZyIsInVzZVRlcm1pbmFsU2l6ZSIsIlNjcm9sbEJveCIsIlNjcm9sbEJveEhhbmRsZSIsImluc3RhbmNlcyIsIkJveCIsIlRleHQiLCJNZXNzYWdlIiwib3BlbkJyb3dzZXIiLCJvcGVuUGF0aCIsImlzRnVsbHNjcmVlbkVudkVuYWJsZWQiLCJwbHVyYWwiLCJpc051bGxSZW5kZXJpbmdBdHRhY2htZW50IiwiUHJvbXB0SW5wdXRGb290ZXJTdWdnZXN0aW9ucyIsIlN0aWNreVByb21wdCIsIk1PREFMX1RSQU5TQ1JJUFRfUEVFSyIsIlNjcm9sbENocm9tZUNvbnRleHQiLCJzZXRTdGlja3lQcm9tcHQiLCJwIiwiUHJvcHMiLCJzY3JvbGxhYmxlIiwiYm90dG9tIiwib3ZlcmxheSIsImJvdHRvbUZsb2F0IiwibW9kYWwiLCJtb2RhbFNjcm9sbFJlZiIsInNjcm9sbFJlZiIsImRpdmlkZXJZUmVmIiwiaGlkZVBpbGwiLCJoaWRlU3RpY2t5IiwibmV3TWVzc2FnZUNvdW50Iiwib25QaWxsQ2xpY2siLCJ1c2VVbnNlZW5EaXZpZGVyIiwibWVzc2FnZUNvdW50IiwiZGl2aWRlckluZGV4Iiwib25TY3JvbGxBd2F5IiwiaGFuZGxlIiwib25SZXBpbiIsImp1bXBUb05ldyIsInNoaWZ0RGl2aWRlciIsImluZGV4RGVsdGEiLCJoZWlnaHREZWx0YSIsInNldERpdmlkZXJJbmRleCIsImNvdW50UmVmIiwiY3VycmVudCIsIm1heCIsIk1hdGgiLCJnZXRTY3JvbGxIZWlnaHQiLCJnZXRWaWV3cG9ydEhlaWdodCIsImdldFNjcm9sbFRvcCIsImdldFBlbmRpbmdEZWx0YSIsInNjcm9sbFRvQm90dG9tIiwiaWR4IiwiY291bnRVbnNlZW5Bc3Npc3RhbnRUdXJucyIsIm1lc3NhZ2VzIiwiY291bnQiLCJwcmV2V2FzQXNzaXN0YW50IiwiaSIsImxlbmd0aCIsIm0iLCJ0eXBlIiwiYXNzaXN0YW50SGFzVmlzaWJsZVRleHQiLCJpc0Fzc2lzdGFudCIsImIiLCJtZXNzYWdlIiwiY29udGVudCIsInRleHQiLCJ0cmltIiwiVW5zZWVuRGl2aWRlciIsImZpcnN0VW5zZWVuVXVpZCIsImNvbXB1dGVVbnNlZW5EaXZpZGVyIiwidW5kZWZpbmVkIiwiYW5jaG9ySWR4IiwidXVpZCIsIkZ1bGxzY3JlZW5MYXlvdXQiLCJ0MCIsIiQiLCJfYyIsInQxIiwidDIiLCJ0MyIsInJvd3MiLCJ0ZXJtaW5hbFJvd3MiLCJjb2x1bW5zIiwic3RpY2t5UHJvbXB0IiwidDQiLCJTeW1ib2wiLCJmb3IiLCJjaHJvbWVDdHgiLCJ0NSIsImxpc3RlbmVyIiwic3Vic2NyaWJlIiwiX3RlbXAiLCJ0NiIsInMiLCJkaXZpZGVyWSIsInBpbGxWaXNpYmxlIiwidDciLCJfdGVtcDMiLCJzdGlja3kiLCJoZWFkZXJQcm9tcHQiLCJwYWRDb2xsYXBzZWQiLCJ0OCIsInNjcm9sbFRvIiwidDkiLCJ0MTAiLCJ0MTEiLCJ0MTIiLCJ0MTMiLCJ0MTQiLCJ0MTUiLCJ0MTYiLCJ0MTciLCJ0MTgiLCJyZXBlYXQiLCJ0MTkiLCJpbmsiLCJnZXQiLCJwcm9jZXNzIiwic3Rkb3V0Iiwib25IeXBlcmxpbmtDbGljayIsIl90ZW1wMiIsInVybCIsInN0YXJ0c1dpdGgiLCJOZXdNZXNzYWdlc1BpbGwiLCJvbkNsaWNrIiwiaG92ZXIiLCJzZXRIb3ZlciIsImFycm93RG93biIsIlN0aWNreVByb21wdEhlYWRlciIsInBvaW50ZXIiLCJTdWdnZXN0aW9uc092ZXJsYXkiLCJkYXRhIiwic3VnZ2VzdGlvbnMiLCJtYXhDb2x1bW5XaWR0aCIsInNlbGVjdGVkU3VnZ2VzdGlvbiIsIkRpYWxvZ092ZXJsYXkiLCJub2RlIl0sInNvdXJjZXMiOlsiRnVsbHNjcmVlbkxheW91dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IGZpZ3VyZXMgZnJvbSAnZmlndXJlcydcbmltcG9ydCBSZWFjdCwge1xuICBjcmVhdGVDb250ZXh0LFxuICB0eXBlIFJlYWN0Tm9kZSxcbiAgdHlwZSBSZWZPYmplY3QsXG4gIHVzZUNhbGxiYWNrLFxuICB1c2VFZmZlY3QsXG4gIHVzZUxheW91dEVmZmVjdCxcbiAgdXNlTWVtbyxcbiAgdXNlUmVmLFxuICB1c2VTdGF0ZSxcbiAgdXNlU3luY0V4dGVybmFsU3RvcmUsXG59IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgZmlsZVVSTFRvUGF0aCB9IGZyb20gJ3VybCdcbmltcG9ydCB7IE1vZGFsQ29udGV4dCB9IGZyb20gJy4uL2NvbnRleHQvbW9kYWxDb250ZXh0LmpzJ1xuaW1wb3J0IHtcbiAgUHJvbXB0T3ZlcmxheVByb3ZpZGVyLFxuICB1c2VQcm9tcHRPdmVybGF5LFxuICB1c2VQcm9tcHRPdmVybGF5RGlhbG9nLFxufSBmcm9tICcuLi9jb250ZXh0L3Byb21wdE92ZXJsYXlDb250ZXh0LmpzJ1xuaW1wb3J0IHsgdXNlVGVybWluYWxTaXplIH0gZnJvbSAnLi4vaG9va3MvdXNlVGVybWluYWxTaXplLmpzJ1xuaW1wb3J0IFNjcm9sbEJveCwgeyB0eXBlIFNjcm9sbEJveEhhbmRsZSB9IGZyb20gJy4uL2luay9jb21wb25lbnRzL1Njcm9sbEJveC5qcydcbmltcG9ydCBpbnN0YW5jZXMgZnJvbSAnLi4vaW5rL2luc3RhbmNlcy5qcydcbmltcG9ydCB7IEJveCwgVGV4dCB9IGZyb20gJy4uL2luay5qcydcbmltcG9ydCB0eXBlIHsgTWVzc2FnZSB9IGZyb20gJy4uL3R5cGVzL21lc3NhZ2UuanMnXG5pbXBvcnQgeyBvcGVuQnJvd3Nlciwgb3BlblBhdGggfSBmcm9tICcuLi91dGlscy9icm93c2VyLmpzJ1xuaW1wb3J0IHsgaXNGdWxsc2NyZWVuRW52RW5hYmxlZCB9IGZyb20gJy4uL3V0aWxzL2Z1bGxzY3JlZW4uanMnXG5pbXBvcnQgeyBwbHVyYWwgfSBmcm9tICcuLi91dGlscy9zdHJpbmdVdGlscy5qcydcbmltcG9ydCB7IGlzTnVsbFJlbmRlcmluZ0F0dGFjaG1lbnQgfSBmcm9tICcuL21lc3NhZ2VzL251bGxSZW5kZXJpbmdBdHRhY2htZW50cy5qcydcbmltcG9ydCBQcm9tcHRJbnB1dEZvb3RlclN1Z2dlc3Rpb25zIGZyb20gJy4vUHJvbXB0SW5wdXQvUHJvbXB0SW5wdXRGb290ZXJTdWdnZXN0aW9ucy5qcydcbmltcG9ydCB0eXB
|