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; /** Ref to the scroll box for keyboard scrolling. RefObject (not Ref) so * pillVisible's useSyncExternalStore can subscribe to scroll changes. */ scrollRef?: RefObject; /** 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; /** 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; 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(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(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 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 && ; $[7] = headerPrompt; $[8] = t8; } else { t8 = $[8]; } const t9 = padCollapsed ? 0 : 1; let t10; if ($[9] !== scrollable) { t10 = {scrollable}; $[9] = scrollable; $[10] = t10; } else { t10 = $[10]; } let t11; if ($[11] !== overlay || $[12] !== scrollRef || $[13] !== t10 || $[14] !== t9) { t11 = {t10}{overlay}; $[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 && ; $[16] = hidePill; $[17] = newMessageCount; $[18] = onPillClick; $[19] = overlay; $[20] = pillVisible; $[21] = t12; } else { t12 = $[21]; } let t13; if ($[22] !== bottomFloat) { t13 = bottomFloat != null && {bottomFloat}; $[22] = bottomFloat; $[23] = t13; } else { t13 = $[23]; } let t14; if ($[24] !== t11 || $[25] !== t12 || $[26] !== t13 || $[27] !== t8) { t14 = {t8}{t11}{t12}{t13}; $[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 = ; t16 = ; $[29] = t15; $[30] = t16; } else { t15 = $[29]; t16 = $[30]; } let t17; if ($[31] !== bottom) { t17 = {t15}{t16}{bottom}; $[31] = bottom; $[32] = t17; } else { t17 = $[32]; } let t18; if ($[33] !== columns || $[34] !== modal || $[35] !== modalScrollRef || $[36] !== terminalRows) { t18 = modal != null && {"\u2594".repeat(columns)}{modal}; $[33] = columns; $[34] = modal; $[35] = modalScrollRef; $[36] = terminalRows; $[37] = t18; } else { t18 = $[37]; } let t19; if ($[38] !== t14 || $[39] !== t17 || $[40] !== t18) { t19 = {t14}{t17}{t18}; $[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 = {" "}{t4}{" "}{figures.arrowDown}{" "}; $[4] = t3; $[5] = t4; $[6] = t5; } else { t5 = $[6]; } let t6; if ($[7] !== onClick || $[8] !== t5) { t6 = {t5}; $[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 = {figures.pointer} {text}; $[2] = text; $[3] = t4; } else { t4 = $[3]; } let t5; if ($[4] !== onClick || $[5] !== t1 || $[6] !== t4) { t5 = {t4}; $[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 = ; $[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 = {node}; $[0] = node; $[1] = t0; } else { t0 = $[1]; } return t0; }