import { useState, useCallback, useEffect } from 'react'; import { useStdout } from 'ink'; export interface UseViewportOptions { totalItems: number; reservedLines?: number; } export interface UseViewportReturn { scrollOffset: number; viewportSize: number; isScrolledUp: boolean; scrollToBottom: () => void; scrollBy: (delta: number) => void; scrollTo: (offset: number) => void; canScrollUp: boolean; canScrollDown: boolean; } export function useViewport({ totalItems, reservedLines = 10, }: UseViewportOptions): UseViewportReturn { const { stdout } = useStdout(); const rows = stdout?.rows ?? 24; const viewportSize = Math.max(1, rows - reservedLines); const [scrollOffset, setScrollOffset] = useState(0); const [autoFollow, setAutoFollow] = useState(true); // Compute the maximum valid scroll offset const maxOffset = Math.max(0, totalItems - viewportSize); // Auto-follow: when new items arrive and auto-follow is on, snap to bottom useEffect(() => { if (autoFollow) { setScrollOffset(maxOffset); } }, [autoFollow, maxOffset]); const scrollTo = useCallback( (offset: number) => { const clamped = Math.max(0, Math.min(offset, maxOffset)); setScrollOffset(clamped); setAutoFollow(clamped >= maxOffset); }, [maxOffset], ); const scrollBy = useCallback( (delta: number) => { setScrollOffset((prev) => { const next = Math.max(0, Math.min(prev + delta, maxOffset)); setAutoFollow(next >= maxOffset); return next; }); }, [maxOffset], ); const scrollToBottom = useCallback(() => { setScrollOffset(maxOffset); setAutoFollow(true); }, [maxOffset]); const isScrolledUp = scrollOffset < maxOffset; const canScrollUp = scrollOffset > 0; const canScrollDown = scrollOffset < maxOffset; return { scrollOffset, viewportSize, isScrolledUp, scrollToBottom, scrollBy, scrollTo, canScrollUp, canScrollDown, }; }