less-style / bar. 1-row, same border-top styling as TranscriptModeFooter * so swapping them in the bottom slot doesn't shift ScrollBox height. * useSearchInput handles readline editing; we report query changes and * render the counter. Incremental — re-search + highlight per keystroke.
({
jumpRef,
count,
current,
onClose,
onCancel,
setHighlight,
initialQuery,
}: {
jumpRef: RefObject<JumpHandle | null>;
count: number;
current: number;
/** Enter — commit. Query persists for n/N. */
onClose: (lastQuery: string) => void;
/** Esc/ctrl+c/ctrl+g — undo to pre-/ state. */
onCancel: () => void;
setHighlight: (query: string) => void;
// Seed with the previous query (less: / shows last pattern). Mount-fire
// of the effect re-scans with the same query — idempotent (same matches,
// nearest-ptr, same highlights). User can edit or clear.
initialQuery: string;
})
| 611 | * useSearchInput handles readline editing; we report query changes and |
| 612 | * render the counter. Incremental — re-search + highlight per keystroke. */ |
| 613 | function TranscriptSearchBar({ |
| 614 | jumpRef, |
| 615 | count, |
| 616 | current, |
| 617 | onClose, |
| 618 | onCancel, |
| 619 | setHighlight, |
| 620 | initialQuery, |
| 621 | }: { |
| 622 | jumpRef: RefObject<JumpHandle | null>; |
| 623 | count: number; |
| 624 | current: number; |
| 625 | /** Enter — commit. Query persists for n/N. */ |
| 626 | onClose: (lastQuery: string) => void; |
| 627 | /** Esc/ctrl+c/ctrl+g — undo to pre-/ state. */ |
| 628 | onCancel: () => void; |
| 629 | setHighlight: (query: string) => void; |
| 630 | // Seed with the previous query (less: / shows last pattern). Mount-fire |
| 631 | // of the effect re-scans with the same query — idempotent (same matches, |
| 632 | // nearest-ptr, same highlights). User can edit or clear. |
| 633 | initialQuery: string; |
| 634 | }): React.ReactNode { |
| 635 | const { query, cursorOffset } = useSearchInput({ |
| 636 | isActive: true, |
| 637 | initialQuery, |
| 638 | onExit: () => onClose(query), |
| 639 | onCancel, |
| 640 | }); |
| 641 | // Index warm-up runs before the query effect so it measures the real |
| 642 | // cost — otherwise setSearchQuery fills the cache first and warm |
| 643 | // reports ~0ms while the user felt the actual lag. |
| 644 | // First / in a transcript session pays the extractSearchText cost. |
| 645 | // Subsequent / return 0 immediately (indexWarmed ref in VML). |
| 646 | // Transcript is frozen at ctrl+o so the cache stays valid. |
| 647 | // Initial 'building' so warmDone is false on mount — the [query] effect |
| 648 | // waits for the warm effect's first resolve instead of racing it. With |
| 649 | // null initial, warmDone would be true on mount → [query] fires → |
| 650 | // setSearchQuery fills cache → warm reports ~0ms while the user felt |
| 651 | // the real lag. |
| 652 | const [indexStatus, setIndexStatus] = React.useState<'building' | { ms: number } | null>('building'); |
| 653 | React.useEffect(() => { |
| 654 | let alive = true; |
| 655 | let hideTimeout: ReturnType<typeof setTimeout> | undefined; |
| 656 | const warm = jumpRef.current?.warmSearchIndex; |
| 657 | if (!warm) { |
| 658 | setIndexStatus(null); // VML not mounted yet — rare, skip indicator |
| 659 | return; |
| 660 | } |
| 661 | setIndexStatus('building'); |
| 662 | warm().then(ms => { |
| 663 | if (!alive) return; |
| 664 | // <20ms = imperceptible. No point showing "indexed in 3ms". |
| 665 | if (ms < 20) { |
| 666 | setIndexStatus(null); |
| 667 | } else { |
| 668 | setIndexStatus({ ms }); |
| 669 | hideTimeout = setTimeout(() => alive && setIndexStatus(null), 2000); |
| 670 | } |
nothing calls this directly
no test coverage detected