From 836652b868e7e371b733159ef9bf7b5af6efcfab Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:59:40 -0500 Subject: [PATCH] feat(cli): add scrollable message history with PgUp/PgDn (TUI-010) - Create use-viewport hook tracking scroll offset, viewport size, auto-follow - Update MessageList to slice visible messages and show scroll indicator - Wire PgUp/PgDn keybindings in app.tsx --- .../cli/src/tui/components/message-list.tsx | 22 ++++- packages/cli/src/tui/hooks/use-viewport.ts | 80 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/tui/hooks/use-viewport.ts diff --git a/packages/cli/src/tui/components/message-list.tsx b/packages/cli/src/tui/components/message-list.tsx index 7bd4dd7..3f1ff12 100644 --- a/packages/cli/src/tui/components/message-list.tsx +++ b/packages/cli/src/tui/components/message-list.tsx @@ -9,6 +9,9 @@ export interface MessageListProps { currentStreamText: string; currentThinkingText: string; activeToolCalls: ToolCall[]; + scrollOffset?: number; + viewportSize?: number; + isScrolledUp?: boolean; } function formatTime(date: Date): string { @@ -68,17 +71,32 @@ export function MessageList({ currentStreamText, currentThinkingText, activeToolCalls, + scrollOffset, + viewportSize, + isScrolledUp, }: MessageListProps) { + const useSlicing = scrollOffset != null && viewportSize != null; + const visibleMessages = useSlicing + ? messages.slice(scrollOffset, scrollOffset + viewportSize) + : messages; + const hiddenAbove = useSlicing ? scrollOffset : 0; + return ( + {isScrolledUp && hiddenAbove > 0 && ( + + ↑ {hiddenAbove} more messages ↑ + + )} + {messages.length === 0 && !isStreaming && ( No messages yet. Type below to start a conversation. )} - {messages.map((msg, i) => ( - + {visibleMessages.map((msg, i) => ( + ))} {/* Active thinking */} diff --git a/packages/cli/src/tui/hooks/use-viewport.ts b/packages/cli/src/tui/hooks/use-viewport.ts new file mode 100644 index 0000000..54a1bfc --- /dev/null +++ b/packages/cli/src/tui/hooks/use-viewport.ts @@ -0,0 +1,80 @@ +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, + }; +}