import * as React from 'react'; import { Ansi, Box, Text, useAnimationFrame } from '@anthropic/ink'; import { segmentTextByHighlights, type TextHighlight } from '../../utils/textHighlighting.js'; import { ShimmerChar } from '../Spinner/ShimmerChar.js'; type Props = { text: string; highlights: TextHighlight[]; }; type LinePart = { text: string; highlight: TextHighlight | undefined; start: number; }; export function HighlightedInput({ text, highlights }: Props): React.ReactNode { // The shimmer animation (below) re-renders this component at 20fps while the // ultrathink keyword is present. text/highlights are referentially stable // across animation ticks (parent doesn't re-render), so memoize everything // that derives from them: segmentTextByHighlights alone is ~85µs/call // (tokenize + sort + O(n²) overlap), which adds up fast at 20fps. const { lines, hasShimmer, sweepStart, cycleLength } = React.useMemo(() => { const segments = segmentTextByHighlights(text, highlights); // Split segments by newlines into per-line groups. Ink's row-direction Box // indents continuation lines of a multi-line child to that child's X offset. // By splitting at newlines, each line renders as its own row, avoiding the // incorrect indentation when highlighted text is followed by wrapped content. const lines: LinePart[][] = [[]]; let pos = 0; for (const segment of segments) { const parts = segment.text.split('\n'); for (let i = 0; i < parts.length; i++) { if (i > 0) { lines.push([]); pos += 1; } const part = parts[i]!; if (part.length > 0) { lines[lines.length - 1]!.push({ text: part, highlight: segment.highlight, start: pos, }); } pos += part.length; } } // Scope the sweep to shimmer-highlighted ranges so cycle time doesn't grow // with input length. Padding creates an offscreen pause between sweeps. const hasShimmer = highlights.some(h => h.shimmerColor); let sweepStart = 0; let cycleLength = 1; if (hasShimmer) { const padding = 10; let lo = Infinity; let hi = -Infinity; for (const h of highlights) { if (h.shimmerColor) { lo = Math.min(lo, h.start); hi = Math.max(hi, h.end); } } sweepStart = lo - padding; cycleLength = hi - lo + padding * 2; } return { lines, hasShimmer, sweepStart, cycleLength }; }, [text, highlights]); const [ref, time] = useAnimationFrame(hasShimmer ? 50 : null); const glimmerIndex = hasShimmer ? sweepStart + (Math.floor(time / 50) % cycleLength) : -100; return ( {lines.map((lineParts, lineIndex) => ( {lineParts.length === 0 ? ( ) : ( lineParts.map((part, partIndex) => { if (part.highlight?.shimmerColor && part.highlight.color) { return ( {part.text.split('').map((char, charIndex) => ( ))} ); } return ( {part.text} ); }) )} ))} ); }