diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 0fbd68c..5f16270 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -6,6 +6,7 @@ import { MessageList } from './components/message-list.js'; import { InputBar } from './components/input-bar.js'; import { useSocket } from './hooks/use-socket.js'; import { useGitInfo } from './hooks/use-git-info.js'; +import { useViewport } from './hooks/use-viewport.js'; export interface TuiAppProps { gatewayUrl: string; @@ -23,10 +24,19 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp initialConversationId: conversationId, }); + const viewport = useViewport({ totalItems: socket.messages.length }); + useInput((ch, key) => { if (key.ctrl && ch === 'c') { exit(); } + // Page Up / Page Down: scroll message history + if (key.pageUp) { + viewport.scrollBy(-viewport.viewportSize); + } + if (key.pageDown) { + viewport.scrollBy(viewport.viewportSize); + } // Ctrl+T: cycle thinking level if (key.ctrl && ch === 't') { const levels = socket.availableThinkingLevels; @@ -61,6 +71,9 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp currentStreamText={socket.currentStreamText} currentThinkingText={socket.currentThinkingText} activeToolCalls={socket.activeToolCalls} + scrollOffset={viewport.scrollOffset} + viewportSize={viewport.viewportSize} + isScrolledUp={viewport.isScrolledUp} /> + {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, + }; +}