import { c as _c } from "react/compiler-runtime"; import type { RefObject } from 'react'; import * as React from 'react'; import { useCallback, useContext, useEffect, useImperativeHandle, useRef, useState, useSyncExternalStore } from 'react'; import { useVirtualScroll } from '../hooks/useVirtualScroll.js'; import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; import type { DOMElement } from '../ink/dom.js'; import type { MatchPosition } from '../ink/render-to-screen.js'; import { Box } from '../ink.js'; import type { RenderableMessage } from '../types/message.js'; import { TextHoverColorContext } from './design-system/ThemedText.js'; import { ScrollChromeContext } from './FullscreenLayout.js'; // Rows of breathing room above the target when we scrollTo. const HEADROOM = 3; import { logForDebugging } from '../utils/debug.js'; import { sleep } from '../utils/sleep.js'; import { renderableSearchText } from '../utils/transcriptSearch.js'; import { isNavigableMessage, type MessageActionsNav, type MessageActionsState, type NavigableMessage, type NavigableType, stripSystemReminders, toolCallOf } from './messageActions.js'; // Fallback extractor: lower + cache here for callers without the // Messages.tsx tool-lookup path (tests, static contexts). Messages.tsx // provides its own lowering cache that also handles tool extractSearchText. const fallbackLowerCache = new WeakMap(); function defaultExtractSearchText(msg: RenderableMessage): string { const cached = fallbackLowerCache.get(msg); if (cached !== undefined) return cached; const lowered = renderableSearchText(msg); fallbackLowerCache.set(msg, lowered); return lowered; } export type StickyPrompt = { text: string; scrollTo: () => void; } // Click sets this — header HIDES but padding stays collapsed (0) so // the content ❯ lands at screen row 0 instead of row 1. Cleared on // the next sticky-prompt compute (user scrolls again). | 'clicked'; /** Huge pasted prompts (cat file | claude) can be MBs. Header wraps into * 2 rows via overflow:hidden — this just bounds the React prop size. */ const STICKY_TEXT_CAP = 500; /** Imperative handle for transcript navigation. Methods compute matches * HERE (renderableMessages indices are only valid inside this component — * Messages.tsx filters and reorders, REPL can't compute externally). */ export type JumpHandle = { jumpToIndex: (i: number) => void; setSearchQuery: (q: string) => void; nextMatch: () => void; prevMatch: () => void; /** Capture current scrollTop as the incsearch anchor. Typing jumps * around as preview; 0-matches snaps back here. Enter/n/N never * restore (they don't call setSearchQuery with empty). Next / call * overwrites. */ setAnchor: () => void; /** Warm the search-text cache by extracting every message's text. * Returns elapsed ms, or 0 if already warm (subsequent / in same * transcript session). Yields before work so the caller can paint * "indexing…" first. Caller shows "indexed in Xms" on resolve. */ warmSearchIndex: () => Promise; /** Manual scroll (j/k/PgUp/wheel) exited the search context. Clear * positions (yellow goes away, inverse highlights stay). Next n/N * re-establishes via step()→jump(). Wired from ScrollKeybindingHandler's * onScroll — only fires for keyboard/wheel, not programmatic scrollTo. */ disarmSearch: () => void; }; type Props = { messages: RenderableMessage[]; scrollRef: RefObject; /** Invalidates heightCache on change — cached heights from a different * width are wrong (text rewrap → black screen on scroll-up after widen). */ columns: number; itemKey: (msg: RenderableMessage) => string; renderItem: (msg: RenderableMessage, index: number) => React.ReactNode; /** Fires when a message Box is clicked (toggle per-message verbose). */ onItemClick?: (msg: RenderableMessage) => void; /** Per-item filter — suppress hover/click for messages where the verbose * toggle does nothing (text, file edits, etc). Defaults to all-clickable. */ isItemClickable?: (msg: RenderableMessage) => boolean; /** Expanded items get a persistent grey bg (not just on hover). */ isItemExpanded?: (msg: RenderableMessage) => boolean; /** PRE-LOWERED search text. Messages.tsx caches the lowered result * once at warm time so setSearchQuery's per-keystroke loop does * only indexOf (zero toLowerCase alloc). Falls back to a lowering * wrapper on renderableSearchText for callers without the cache. */ extractSearchText?: (msg: RenderableMessage) => string; /** Enable the sticky-prompt tracker. StickyTracker writes via * ScrollChromeContext (not a callback prop) so state lives in * FullscreenLayout instead of REPL. */ trackStickyPrompt?: boolean; selectedIndex?: number; /** Nav handle lives here because height measurement lives here. */ cursorNavRef?: React.Ref; setCursor?: (c: MessageActionsState | null) => void; jumpRef?: RefObject; /** Fires when search matches change (query edit, n/N). current is * 1-based for "3/47" display; 0 means no matches. */ onSearchMatchesChange?: (count: number, current: number) => void; /** Paint existing DOM subtree to fresh Screen, scan. Element from the * main tree (all providers). Message-relative positions (row 0 = el * top). Works for any height — closes the tall-message gap. */ scanElement?: (el: DOMElement) => MatchPosition[]; /** Position-based CURRENT highlight. Positions known upfront (from * scanElement), navigation = index arithmetic + scrollTo. rowOffset * = message's current screen-top; positions stay stable. */ setPositions?: (state: { positions: MatchPosition[]; rowOffset: number; currentIdx: number; } | null) => void; }; /** * Returns the text of a real user prompt, or null for anything else. * "Real" = what the human typed: not tool results, not XML-wrapped payloads * (, , , etc.), not meta. * * Two shapes land here: NormalizedUserMessage (normal prompts) and * AttachmentMessage with type==='queued_command' (prompts sent mid-turn * while a tool was executing — they get drained as attachments on the * next turn, see query.ts:1410). Both render as ❯-prefixed UserTextMessage * in the UI so both should stick. * * Leading blocks are stripped before checking — they get * prepended to the stored text for Claude's context (memory updates, auto * mode reminders) but aren't what the user typed. Without stripping, any * prompt that happened to get a reminder is rejected by the startsWith('<') * check. Shows up on `cc -c` resumes where memory-update reminders are dense. */ const promptTextCache = new WeakMap(); function stickyPromptText(msg: RenderableMessage): string | null { // Cache keyed on message object — messages are append-only and don't // mutate, so a WeakMap hit is always valid. The walk (StickyTracker, // per-scroll-tick) calls this 5-50+ times with the SAME messages every // tick; the system-reminder strip allocates a fresh string on each // parse. WeakMap self-GCs on compaction/clear (messages[] replaced). const cached = promptTextCache.get(msg); if (cached !== undefined) return cached; const result = computeStickyPromptText(msg); promptTextCache.set(msg, result); return result; } function computeStickyPromptText(msg: RenderableMessage): string | null { let raw: string | null = null; if (msg.type === 'user') { if (msg.isMeta || msg.isVisibleInTranscriptOnly) return null; const block = Array.isArray(msg.message.content) ? msg.message.content[0] : undefined; if (!block || typeof block === 'string' || block?.type !== 'text') return null; raw = block.text; } else if (msg.type === 'attachment' && msg.attachment.type === 'queued_command' && msg.attachment.commandMode !== 'task-notification' && !msg.attachment.isMeta) { const p = msg.attachment.prompt; raw = typeof p === 'string' ? p : (p as any[]).flatMap(b => b.type === 'text' ? [b.text] : []).join('\n'); } if (raw === null) return null; const t = stripSystemReminders(raw); if (t.startsWith('<') || t === '') return null; return t; } /** * Virtualized message list for fullscreen mode. Split from Messages.tsx so * useVirtualScroll is called unconditionally (rules-of-hooks) — Messages.tsx * conditionally renders either this or a plain .map(). * * The wrapping is the measurement anchor — MessageRow doesn't take * a ref. Single-child column Box passes Yoga height through unchanged. */ type VirtualItemProps = { itemKey: string; msg: RenderableMessage; idx: number; measureRef: (key: string) => (el: DOMElement | null) => void; expanded: boolean | undefined; hovered: boolean; clickable: boolean; onClickK: (msg: RenderableMessage, cellIsBlank: boolean) => void; onEnterK: (k: string) => void; onLeaveK: (k: string) => void; renderItem: (msg: RenderableMessage, idx: number) => React.ReactNode; }; // Item wrapper with stable click handlers. The per-item closures were the // `operationNewArrowFunction` leafs → `FunctionExecutable::finalizeUnconditionally` // GC cleanup (16% of GC time during fast scroll). 3 closures × 60 mounted × // 10 commits/sec = 1800 closures/sec. With stable onClickK/onEnterK/onLeaveK // threaded via itemKey, the closures here are per-item-per-render but CHEAP // (just wrap the stable callback with k bound) and don't close over msg/idx // which lets JIT inline them. The bigger win is inside: MessageRow.memo // bails for unchanged msgs, skipping marked.lexer + formatToken. // // NOT React.memo'd — renderItem captures changing state (cursor, selectedIdx, // verbose). Memoing with a comparator that ignores renderItem would use a // STALE closure on bail (wrong selection highlight, stale verbose). Including // renderItem in the comparator defeats memo since it's fresh each render. function VirtualItem(t0) { const $ = _c(30); const { itemKey: k, msg, idx, measureRef, expanded, hovered, clickable, onClickK, onEnterK, onLeaveK, renderItem } = t0; let t1; if ($[0] !== k || $[1] !== measureRef) { t1 = measureRef(k); $[0] = k; $[1] = measureRef; $[2] = t1; } else { t1 = $[2]; } const t2 = expanded ? "userMessageBackgroundHover" : undefined; const t3 = expanded ? 1 : undefined; let t4; if ($[3] !== clickable || $[4] !== msg || $[5] !== onClickK) { t4 = clickable ? e => onClickK(msg, e.cellIsBlank) : undefined; $[3] = clickable; $[4] = msg; $[5] = onClickK; $[6] = t4; } else { t4 = $[6]; } let t5; if ($[7] !== clickable || $[8] !== k || $[9] !== onEnterK) { t5 = clickable ? () => onEnterK(k) : undefined; $[7] = clickable; $[8] = k; $[9] = onEnterK; $[10] = t5; } else { t5 = $[10]; } let t6; if ($[11] !== clickable || $[12] !== k || $[13] !== onLeaveK) { t6 = clickable ? () => onLeaveK(k) : undefined; $[11] = clickable; $[12] = k; $[13] = onLeaveK; $[14] = t6; } else { t6 = $[14]; } const t7 = hovered && !expanded ? "text" : undefined; let t8; if ($[15] !== idx || $[16] !== msg || $[17] !== renderItem) { t8 = renderItem(msg, idx); $[15] = idx; $[16] = msg; $[17] = renderItem; $[18] = t8; } else { t8 = $[18]; } let t9; if ($[19] !== t7 || $[20] !== t8) { t9 = {t8}; $[19] = t7; $[20] = t8; $[21] = t9; } else { t9 = $[21]; } let t10; if ($[22] !== t1 || $[23] !== t2 || $[24] !== t3 || $[25] !== t4 || $[26] !== t5 || $[27] !== t6 || $[28] !== t9) { t10 = {t9}; $[22] = t1; $[23] = t2; $[24] = t3; $[25] = t4; $[26] = t5; $[27] = t6; $[28] = t9; $[29] = t10; } else { t10 = $[29]; } return t10; } export function VirtualMessageList({ messages, scrollRef, columns, itemKey, renderItem, onItemClick, isItemClickable, isItemExpanded, extractSearchText = defaultExtractSearchText, trackStickyPrompt, selectedIndex, cursorNavRef, setCursor, jumpRef, onSearchMatchesChange, scanElement, setPositions }: Props): React.ReactNode { // Incremental key array. Streaming appends one message at a time; rebuilding // the full string array on every commit allocates O(n) per message (~1MB // churn at 27k messages). Append-only delta push when the prefix matches; // fall back to full rebuild on compaction, /clear, or itemKey change. const keysRef = useRef([]); const prevMessagesRef = useRef(messages); const prevItemKeyRef = useRef(itemKey); if (prevItemKeyRef.current !== itemKey || messages.length < keysRef.current.length || messages[0] !== prevMessagesRef.current[0]) { keysRef.current = messages.map(m => itemKey(m)); } else { for (let i = keysRef.current.length; i < messages.length; i++) { keysRef.current.push(itemKey(messages[i]!)); } } prevMessagesRef.current = messages; prevItemKeyRef.current = itemKey; const keys = keysRef.current; const { range, topSpacer, bottomSpacer, measureRef, spacerRef, offsets, getItemTop, getItemElement, getItemHeight, scrollToIndex } = useVirtualScroll(scrollRef, keys, columns); const [start, end] = range; // Unmeasured (undefined height) falls through — assume visible. const isVisible = useCallback((i: number) => { const h = getItemHeight(i); if (h === 0) return false; return isNavigableMessage(messages[i]!); }, [getItemHeight, messages]); useImperativeHandle(cursorNavRef, (): MessageActionsNav => { const select = (m: NavigableMessage) => setCursor?.({ uuid: m.uuid, msgType: m.type as NavigableType, expanded: false, toolName: toolCallOf(m)?.name }); const selIdx = selectedIndex ?? -1; const scan = (from: number, dir: 1 | -1, pred: (i: number) => boolean = isVisible) => { for (let i = from; i >= 0 && i < messages.length; i += dir) { if (pred(i)) { select(messages[i]!); return true; } } return false; }; const isUser = (i: number) => isVisible(i) && messages[i]!.type === 'user'; return { // Entry via shift+↑ = same semantic as in-cursor shift+↑ (prevUser). enterCursor: () => scan(messages.length - 1, -1, isUser), navigatePrev: () => scan(selIdx - 1, -1), navigateNext: () => { if (scan(selIdx + 1, 1)) return; // Past last visible → exit + repin. Last message's TOP is at viewport // top (selection-scroll effect); its BOTTOM may be below the fold. scrollRef.current?.scrollToBottom(); setCursor?.(null); }, // type:'user' only — queued_command attachments look like prompts but have no raw UserMessage to rewind to. navigatePrevUser: () => scan(selIdx - 1, -1, isUser), navigateNextUser: () => scan(selIdx + 1, 1, isUser), navigateTop: () => scan(0, 1), navigateBottom: () => scan(messages.length - 1, -1), getSelected: () => selIdx >= 0 ? messages[selIdx] ?? null : null }; }, [messages, selectedIndex, setCursor, isVisible]); // Two-phase jump + search engine. Read-through-ref so the handle stays // stable across renders — offsets/messages identity changes every render, // can't go in useImperativeHandle deps without recreating the handle. const jumpState = useRef({ offsets, start, getItemElement, getItemTop, messages, scrollToIndex }); jumpState.current = { offsets, start, getItemElement, getItemTop, messages, scrollToIndex }; // Keep cursor-selected message visible. offsets rebuilds every render // — as a bare dep this re-pinned on every mousewheel tick. Read through // jumpState instead; past-overscan jumps land via scrollToIndex, next // nav is precise. useEffect(() => { if (selectedIndex === undefined) return; const s = jumpState.current; const el = s.getItemElement(selectedIndex); if (el) { scrollRef.current?.scrollToElement(el, 1); } else { s.scrollToIndex(selectedIndex); } }, [selectedIndex, scrollRef]); // Pending seek request. jump() sets this + bumps seekGen. The seek // effect fires post-paint (passive effect — after resetAfterCommit), // checks if target is mounted. Yes → scan+highlight. No → re-estimate // with a fresher anchor (start moved toward idx) and scrollTo again. const scanRequestRef = useRef<{ idx: number; wantLast: boolean; tries: number; } | null>(null); // Message-relative positions from scanElement. Row 0 = message top. // Stable across scroll — highlight computes rowOffset fresh. msgIdx // for computing rowOffset = getItemTop(msgIdx) - scrollTop. const elementPositions = useRef<{ msgIdx: number; positions: MatchPosition[]; }>({ msgIdx: -1, positions: [] }); // Wraparound guard. Auto-advance stops if ptr wraps back to here. const startPtrRef = useRef(-1); // Phantom-burst cap. Resets on scan success. const phantomBurstRef = useRef(0); // One-deep queue: n/N arriving mid-seek gets stored (not dropped) and // fires after the seek completes. Holding n stays smooth without // queueing 30 jumps. Latest press overwrites — we want the direction // the user is going NOW, not where they were 10 keypresses ago. const pendingStepRef = useRef<1 | -1 | 0>(0); // step + highlight via ref so the seek effect reads latest without // closure-capture or deps churn. const stepRef = useRef<(d: 1 | -1) => void>(() => {}); const highlightRef = useRef<(ord: number) => void>(() => {}); const searchState = useRef({ matches: [] as number[], // deduplicated msg indices ptr: 0, screenOrd: 0, // Cumulative engine-occurrence count before each matches[k]. Lets us // compute a global current index: prefixSum[ptr] + screenOrd + 1. // Engine-counted (indexOf on extractSearchText), not render-counted — // close enough for the badge; exact counts would need scanElement on // every matched message (~1-3ms × N). total = prefixSum[matches.length]. prefixSum: [] as number[] }); // scrollTop at the moment / was pressed. Incsearch preview-jumps snap // back here when matches drop to 0. -1 = no anchor (before first /). const searchAnchor = useRef(-1); const indexWarmed = useRef(false); // Scroll target for message i: land at MESSAGE TOP. est = top - HEADROOM // so lo = top - est = HEADROOM ≥ 0 (or lo = top if est clamped to 0). // Post-clamp read-back in jump() handles the scrollHeight boundary. // No frac (render transform didn't respect it), no monotone clamp // (was a safety net for frac garbage — without frac, est IS the next // message's top, spam-n/N converges because message tops are ordered). function targetFor(i: number): number { const top = jumpState.current.getItemTop(i); return Math.max(0, top - HEADROOM); } // Highlight positions[ord]. Positions are MESSAGE-RELATIVE (row 0 = // element top, from scanElement). Compute rowOffset = getItemTop - // scrollTop fresh. If ord's position is off-viewport, scroll to bring // it in, recompute rowOffset. setPositions triggers overlay write. function highlight(ord: number): void { const s = scrollRef.current; const { msgIdx, positions } = elementPositions.current; if (!s || positions.length === 0 || msgIdx < 0) { setPositions?.(null); return; } const idx = Math.max(0, Math.min(ord, positions.length - 1)); const p = positions[idx]!; const top = jumpState.current.getItemTop(msgIdx); // lo = item's position within scroll content (wrapper-relative). // viewportTop = where the scroll content starts on SCREEN (after // ScrollBox padding/border + any chrome above). Highlight writes to // screen-absolute, so rowOffset = viewportTop + lo. Observed: off-by- // 1+ without viewportTop (FullscreenLayout has paddingTop=1 on the // ScrollBox, plus any header above). const vpTop = s.getViewportTop(); let lo = top - s.getScrollTop(); const vp = s.getViewportHeight(); let screenRow = vpTop + lo + p.row; // Off viewport → scroll to bring it in (HEADROOM from top). // scrollTo commits sync; read-back after gives fresh lo. if (screenRow < vpTop || screenRow >= vpTop + vp) { s.scrollTo(Math.max(0, top + p.row - HEADROOM)); lo = top - s.getScrollTop(); screenRow = vpTop + lo + p.row; } setPositions?.({ positions, rowOffset: vpTop + lo, currentIdx: idx }); // Badge: global current = sum of occurrences before this msg + ord+1. // prefixSum[ptr] is engine-counted (indexOf on extractSearchText); // may drift from render-count for ghost messages but close enough — // badge is a rough location hint, not a proof. const st = searchState.current; const total = st.prefixSum.at(-1) ?? 0; const current = (st.prefixSum[st.ptr] ?? 0) + idx + 1; onSearchMatchesChange?.(total, current); logForDebugging(`highlight(i=${msgIdx}, ord=${idx}/${positions.length}): ` + `pos={row:${p.row},col:${p.col}} lo=${lo} screenRow=${screenRow} ` + `badge=${current}/${total}`); } highlightRef.current = highlight; // Seek effect. jump() sets scanRequestRef + scrollToIndex + bump. // bump → re-render → useVirtualScroll mounts the target (scrollToIndex // guarantees this — scrollTop and topSpacer agree via the same // offsets value) → resetAfterCommit paints → this passive effect // fires POST-PAINT with the element mounted. Precise scrollTo + scan. // // Dep is ONLY seekGen — effect doesn't re-run on random renders // (onSearchMatchesChange churn during incsearch). const [seekGen, setSeekGen] = useState(0); const bumpSeek = useCallback(() => setSeekGen(g => g + 1), []); useEffect(() => { const req = scanRequestRef.current; if (!req) return; const { idx, wantLast, tries } = req; const s = scrollRef.current; if (!s) return; const { getItemElement, getItemTop, scrollToIndex } = jumpState.current; const el = getItemElement(idx); const h = el?.yogaNode?.getComputedHeight() ?? 0; if (!el || h === 0) { // Not mounted after scrollToIndex. Shouldn't happen — scrollToIndex // guarantees mount by construction (scrollTop and topSpacer agree // via the same offsets value). Sanity: retry once, then skip. if (tries > 1) { scanRequestRef.current = null; logForDebugging(`seek(i=${idx}): no mount after scrollToIndex, skip`); stepRef.current(wantLast ? -1 : 1); return; } scanRequestRef.current = { idx, wantLast, tries: tries + 1 }; scrollToIndex(idx); bumpSeek(); return; } scanRequestRef.current = null; // Precise scrollTo — scrollToIndex got us in the neighborhood // (item is mounted, maybe a few-dozen rows off due to overscan // estimate drift). Now land it at top-HEADROOM. s.scrollTo(Math.max(0, getItemTop(idx) - HEADROOM)); const positions = scanElement?.(el) ?? []; elementPositions.current = { msgIdx: idx, positions }; logForDebugging(`seek(i=${idx} t=${tries}): ${positions.length} positions`); if (positions.length === 0) { // Phantom — engine matched, render didn't. Auto-advance. if (++phantomBurstRef.current > 20) { phantomBurstRef.current = 0; return; } stepRef.current(wantLast ? -1 : 1); return; } phantomBurstRef.current = 0; const ord = wantLast ? positions.length - 1 : 0; searchState.current.screenOrd = ord; startPtrRef.current = -1; highlightRef.current(ord); const pending = pendingStepRef.current; if (pending) { pendingStepRef.current = 0; stepRef.current(pending); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [seekGen]); // Scroll to message i's top, arm scanPending. scan-effect reads fresh // screen next tick. wantLast: N-into-message — screenOrd = length-1. function jump(i: number, wantLast: boolean): void { const s = scrollRef.current; if (!s) return; const js = jumpState.current; const { getItemElement, scrollToIndex } = js; // offsets is a Float64Array whose .length is the allocated buffer (only // grows) — messages.length is the logical item count. if (i < 0 || i >= js.messages.length) return; // Clear stale highlight before scroll. Between now and the seek // effect's highlight, inverse-only from scan-highlight shows. setPositions?.(null); elementPositions.current = { msgIdx: -1, positions: [] }; scanRequestRef.current = { idx: i, wantLast, tries: 0 }; const el = getItemElement(i); const h = el?.yogaNode?.getComputedHeight() ?? 0; // Mounted → precise scrollTo. Unmounted → scrollToIndex mounts it // (scrollTop and topSpacer agree via the same offsets value — exact // by construction, no estimation). Seek effect does the precise // scrollTo after paint either way. if (el && h > 0) { s.scrollTo(targetFor(i)); } else { scrollToIndex(i); } bumpSeek(); } // Advance screenOrd within elementPositions. Exhausted → ptr advances, // jump to next matches[ptr], re-scan. Phantom (scan found 0 after // jump) triggers auto-advance from scan-effect. Wraparound guard stops // if every message is a phantom. function step(delta: 1 | -1): void { const st = searchState.current; const { matches, prefixSum } = st; const total = prefixSum.at(-1) ?? 0; if (matches.length === 0) return; // Seek in-flight — queue this press (one-deep, latest overwrites). // The seek effect fires it after highlight. if (scanRequestRef.current) { pendingStepRef.current = delta; return; } if (startPtrRef.current < 0) startPtrRef.current = st.ptr; const { positions } = elementPositions.current; const newOrd = st.screenOrd + delta; if (newOrd >= 0 && newOrd < positions.length) { st.screenOrd = newOrd; highlight(newOrd); // updates badge internally startPtrRef.current = -1; return; } // Exhausted visible. Advance ptr → jump → re-scan. const ptr = (st.ptr + delta + matches.length) % matches.length; if (ptr === startPtrRef.current) { setPositions?.(null); startPtrRef.current = -1; logForDebugging(`step: wraparound at ptr=${ptr}, all ${matches.length} msgs phantoms`); return; } st.ptr = ptr; st.screenOrd = 0; // resolved after scan (wantLast → length-1) jump(matches[ptr]!, delta < 0); // screenOrd will resolve after scan. Best-effort: prefixSum[ptr] + 0 // for n (first pos), prefixSum[ptr+1] for N (last pos = count-1). // The scan-effect's highlight will be the real value; this is a // pre-scan placeholder so the badge updates immediately. const placeholder = delta < 0 ? prefixSum[ptr + 1] ?? total : prefixSum[ptr]! + 1; onSearchMatchesChange?.(total, placeholder); } stepRef.current = step; useImperativeHandle(jumpRef, () => ({ // Non-search jump (sticky header click, etc). No scan, no positions. jumpToIndex: (i: number) => { const s = scrollRef.current; if (s) s.scrollTo(targetFor(i)); }, setSearchQuery: (q: string) => { // New search invalidates everything. scanRequestRef.current = null; elementPositions.current = { msgIdx: -1, positions: [] }; startPtrRef.current = -1; setPositions?.(null); const lq = q.toLowerCase(); // One entry per MESSAGE (deduplicated). Boolean "does this msg // contain the query". ~10ms for 9k messages with cached lowered. const matches: number[] = []; // Per-message occurrence count → prefixSum for global current // index. Engine-counted (cheap indexOf loop); may differ from // render-count (scanElement) for ghost/phantom messages but close // enough for the badge. The badge is a rough location hint. const prefixSum: number[] = [0]; if (lq) { const msgs = jumpState.current.messages; for (let i = 0; i < msgs.length; i++) { const text = extractSearchText(msgs[i]!); let pos = text.indexOf(lq); let cnt = 0; while (pos >= 0) { cnt++; pos = text.indexOf(lq, pos + lq.length); } if (cnt > 0) { matches.push(i); prefixSum.push(prefixSum.at(-1)! + cnt); } } } const total = prefixSum.at(-1)!; // Nearest MESSAGE to the anchor. <= so ties go to later. let ptr = 0; const s = scrollRef.current; const { offsets, start, getItemTop } = jumpState.current; const firstTop = getItemTop(start); const origin = firstTop >= 0 ? firstTop - offsets[start]! : 0; if (matches.length > 0 && s) { const curTop = searchAnchor.current >= 0 ? searchAnchor.current : s.getScrollTop(); let best = Infinity; for (let k = 0; k < matches.length; k++) { const d = Math.abs(origin + offsets[matches[k]!]! - curTop); if (d <= best) { best = d; ptr = k; } } logForDebugging(`setSearchQuery('${q}'): ${matches.length} msgs · ptr=${ptr} ` + `msgIdx=${matches[ptr]} curTop=${curTop} origin=${origin}`); } searchState.current = { matches, ptr, screenOrd: 0, prefixSum }; if (matches.length > 0) { // wantLast=true: preview the LAST occurrence in the nearest // message. At sticky-bottom (common / entry), nearest is the // last msg; its last occurrence is closest to where the user // was — minimal view movement. n advances forward from there. jump(matches[ptr]!, true); } else if (searchAnchor.current >= 0 && s) { // /foob → 0 matches → snap back to anchor. less/vim incsearch. s.scrollTo(searchAnchor.current); } // Global occurrence count + 1-based current. wantLast=true so the // scan will land on the last occurrence in matches[ptr]. Placeholder // = prefixSum[ptr+1] (count through this msg). highlight() updates // to the exact value after scan completes. onSearchMatchesChange?.(total, matches.length > 0 ? prefixSum[ptr + 1] ?? total : 0); }, nextMatch: () => step(1), prevMatch: () => step(-1), setAnchor: () => { const s = scrollRef.current; if (s) searchAnchor.current = s.getScrollTop(); }, disarmSearch: () => { // Manual scroll invalidates screen-absolute positions. setPositions?.(null); scanRequestRef.current = null; elementPositions.current = { msgIdx: -1, positions: [] }; startPtrRef.current = -1; }, warmSearchIndex: async () => { if (indexWarmed.current) return 0; const msgs = jumpState.current.messages; const CHUNK = 500; let workMs = 0; const wallStart = performance.now(); for (let i = 0; i < msgs.length; i += CHUNK) { await sleep(0); const t0 = performance.now(); const end = Math.min(i + CHUNK, msgs.length); for (let j = i; j < end; j++) { extractSearchText(msgs[j]!); } workMs += performance.now() - t0; } const wallMs = Math.round(performance.now() - wallStart); logForDebugging(`warmSearchIndex: ${msgs.length} msgs · work=${Math.round(workMs)}ms wall=${wallMs}ms chunks=${Math.ceil(msgs.length / CHUNK)}`); indexWarmed.current = true; return Math.round(workMs); } }), // Closures over refs + callbacks. scrollRef stable; others are // useCallback([]) or prop-drilled from REPL (stable). // eslint-disable-next-line react-hooks/exhaustive-deps [scrollRef]); // StickyTracker goes AFTER the list content. It returns null (no DOM node) // so order shouldn't matter for layout — but putting it first means every // fine-grained commit from its own scroll subscription reconciles THROUGH // the sibling items (React walks children in order). After the items, it's // a leaf reconcile. Defensive: also avoids any Yoga child-index quirks if // the Ink reconciler ever materializes a placeholder for null returns. const [hoveredKey, setHoveredKey] = useState(null); // Stable click/hover handlers — called with k, dispatch from a ref so // closure identity doesn't change per render. The per-item handler // closures (`e => ...`, `() => setHoveredKey(k)`) were the // `operationNewArrowFunction` leafs in the scroll CPU profile; their // cleanup was 16% of GC time (`FunctionExecutable::finalizeUnconditionally`). // Allocating 3 closures × 60 mounted items × 10 commits/sec during fast // scroll = 1800 short-lived closures/sec. With stable refs the item // wrapper props don't change → VirtualItem.memo bails for the ~35 // unchanged items, only ~25 fresh items pay createElement cost. const handlersRef = useRef({ onItemClick, setHoveredKey }); handlersRef.current = { onItemClick, setHoveredKey }; const onClickK = useCallback((msg: RenderableMessage, cellIsBlank: boolean) => { const h = handlersRef.current; if (!cellIsBlank && h.onItemClick) h.onItemClick(msg); }, []); const onEnterK = useCallback((k: string) => { handlersRef.current.setHoveredKey(k); }, []); const onLeaveK = useCallback((k: string) => { handlersRef.current.setHoveredKey(prev => prev === k ? null : prev); }, []); return <> {messages.slice(start, end).map((msg, i) => { const idx = start + i; const k = keys[idx]!; const clickable = !!onItemClick && (isItemClickable?.(msg) ?? true); const hovered = clickable && hoveredKey === k; const expanded = isItemExpanded?.(msg); return ; })} {bottomSpacer > 0 && } {trackStickyPrompt && } ; } const NOOP_UNSUB = () => {}; /** * Effect-only child that tracks the last user-prompt scrolled above the * viewport top and fires onChange when it changes. * * Rendered as a separate component (not a hook in VirtualMessageList) so it * can subscribe to scroll at FINER granularity than SCROLL_QUANTUM=40. The * list needs the coarse quantum to avoid per-wheel-tick Yoga relayouts; this * tracker is just a walk + comparison and can afford to run every tick. When * it re-renders alone, the list's reconciled output is unchanged (same props * from the parent's last commit) — no Yoga work. Without this split, the * header lags by ~one conversation turn (40 rows ≈ one prompt + response). * * firstVisible derivation: item Boxes are direct Yoga children of the * ScrollBox content wrapper (fragments collapse in the Ink DOM), so * yoga.getComputedTop is content-wrapper-relative — same coordinate space as * scrollTop. Compare against scrollTop + pendingDelta (the scroll TARGET — * scrollBy only sets pendingDelta, committed scrollTop lags). Walk backward * from the mount-range end; break when an item's top is above target. */ function StickyTracker({ messages, start, end, offsets, getItemTop, getItemElement, scrollRef }: { messages: RenderableMessage[]; start: number; end: number; offsets: ArrayLike; getItemTop: (index: number) => number; getItemElement: (index: number) => DOMElement | null; scrollRef: RefObject; }): null { const { setStickyPrompt } = useContext(ScrollChromeContext); // Fine-grained subscription — snapshot is unquantized scrollTop+delta so // every scroll action (wheel tick, PgUp, drag) triggers a re-render of // THIS component only. Sticky bit folded into the sign so sticky→broken // also triggers (scrollToBottom sets sticky without moving scrollTop). const subscribe = useCallback((listener: () => void) => scrollRef.current?.subscribe(listener) ?? NOOP_UNSUB, [scrollRef]); useSyncExternalStore(subscribe, () => { const s = scrollRef.current; if (!s) return NaN; const t = s.getScrollTop() + s.getPendingDelta(); return s.isSticky() ? -1 - t : t; }); // Read live scroll state on every render. const isSticky = scrollRef.current?.isSticky() ?? true; const target = Math.max(0, (scrollRef.current?.getScrollTop() ?? 0) + (scrollRef.current?.getPendingDelta() ?? 0)); // Walk the mounted range to find the first item at-or-below the viewport // top. `range` is from the parent's coarse-quantum render (may be slightly // stale) but overscan guarantees it spans well past the viewport in both // directions. Items without a Yoga layout yet (newly mounted this frame) // are treated as at-or-below — they're somewhere in view, and assuming // otherwise would show a sticky for a prompt that's actually on screen. let firstVisible = start; let firstVisibleTop = -1; for (let i = end - 1; i >= start; i--) { const top = getItemTop(i); if (top >= 0) { if (top < target) break; firstVisibleTop = top; } firstVisible = i; } let idx = -1; let text: string | null = null; if (firstVisible > 0 && !isSticky) { for (let i = firstVisible - 1; i >= 0; i--) { const t = stickyPromptText(messages[i]!); if (t === null) continue; // The prompt's wrapping Box top is above target (that's why it's in // the [0, firstVisible) range), but its ❯ is at top+1 (marginTop=1). // If the ❯ is at-or-below target, it's VISIBLE at viewport top — // showing the same text in the header would duplicate it. Happens // in the 1-row gap between Box top scrolling past and ❯ scrolling // past. Skip to the next-older prompt (its ❯ is definitely above). const top = getItemTop(i); if (top >= 0 && top + 1 >= target) continue; idx = i; text = t; break; } } const baseOffset = firstVisibleTop >= 0 ? firstVisibleTop - offsets[firstVisible]! : 0; const estimate = idx >= 0 ? Math.max(0, baseOffset + offsets[idx]!) : -1; // For click-jumps to items not yet mounted (user scrolled far past, // prompt is in the topSpacer). Click handler scrolls to the estimate // to mount it; this anchors by element once it appears. scrollToElement // defers the Yoga-position read to render time (render-node-to-output // reads el.yogaNode.getComputedTop() in the SAME calculateLayout pass // that produces scrollHeight) — no throttle race. Cap retries: a /clear // race could unmount the item mid-sequence. const pending = useRef({ idx: -1, tries: 0 }); // Suppression state machine. The click handler arms; the onChange effect // consumes (armed→force) then fires-and-clears on the render AFTER that // (force→none). The force step poisons the dedup: after click, idx often // recomputes to the SAME prompt (its top is still above target), so // without force the last.idx===idx guard would hold 'clicked' until the // user crossed a prompt boundary. Previously encoded in last.idx as // -1/-2/-3 which overlapped with real indices — too clever. type Suppress = 'none' | 'armed' | 'force'; const suppress = useRef('none'); // Dedup on idx only — estimate derives from firstVisibleTop which shifts // every scroll tick, so including it in the key made the guard dead // (setStickyPrompt fired a fresh {text,scrollTo} per-frame). The scrollTo // closure still captures the current estimate; it just doesn't need to // re-fire when only estimate moved. const lastIdx = useRef(-1); // setStickyPrompt effect FIRST — must see pending.idx before the // correction effect below clears it. On the estimate-fallback path, the // render that mounts the item is ALSO the render where correction clears // pending; if this ran second, the pending gate would be dead and // setStickyPrompt(prevPrompt) would fire mid-jump, re-mounting the // header over 'clicked'. useEffect(() => { // Hold while two-phase correction is in flight. if (pending.current.idx >= 0) return; if (suppress.current === 'armed') { suppress.current = 'force'; return; } const force = suppress.current === 'force'; suppress.current = 'none'; if (!force && lastIdx.current === idx) return; lastIdx.current = idx; if (text === null) { setStickyPrompt(null); return; } // First paragraph only (split on blank line) — a prompt like // "still seeing bugs:\n\n1. foo\n2. bar" previews as just the // lead-in. trimStart so a leading blank line (queued_command mid- // turn messages sometimes have one) doesn't find paraEnd at 0. const trimmed = text.trimStart(); const paraEnd = trimmed.search(/\n\s*\n/); const collapsed = (paraEnd >= 0 ? trimmed.slice(0, paraEnd) : trimmed).slice(0, STICKY_TEXT_CAP).replace(/\s+/g, ' ').trim(); if (collapsed === '') { setStickyPrompt(null); return; } const capturedIdx = idx; const capturedEstimate = estimate; setStickyPrompt({ text: collapsed, scrollTo: () => { // Hide header, keep padding collapsed — FullscreenLayout's // 'clicked' sentinel → scrollBox_y=0 + pad=0 → viewportTop=0. setStickyPrompt('clicked'); suppress.current = 'armed'; // scrollToElement anchors by DOMElement ref, not a number: // render-node-to-output reads el.yogaNode.getComputedTop() at // paint time (same Yoga pass as scrollHeight). No staleness from // the throttled render — the ref is stable, the position read is // deferred. offset=1 = UserPromptMessage marginTop. const el = getItemElement(capturedIdx); if (el) { scrollRef.current?.scrollToElement(el, 1); } else { // Not mounted (scrolled far past — in topSpacer). Jump to // estimate to mount it; correction effect re-anchors once it // appears. Estimate is DEFAULT_ESTIMATE-based — lands short. scrollRef.current?.scrollTo(capturedEstimate); pending.current = { idx: capturedIdx, tries: 0 }; } } }); // No deps — must run every render. Suppression state lives in a ref // (not idx/estimate), so a deps-gated effect would never see it tick. // Body's own guards short-circuit when nothing changed. // eslint-disable-next-line react-hooks/exhaustive-deps }); // Correction: for click-jumps to unmounted items. Click handler scrolled // to the estimate; this re-anchors by element once the item appears. // scrollToElement defers the Yoga read to paint time — deterministic. // SECOND so it clears pending AFTER the onChange gate above has seen it. useEffect(() => { if (pending.current.idx < 0) return; const el = getItemElement(pending.current.idx); if (el) { scrollRef.current?.scrollToElement(el, 1); pending.current = { idx: -1, tries: 0 }; } else if (++pending.current.tries > 5) { pending.current = { idx: -1, tries: 0 }; } }); return null; }