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
This commit is contained in:
2026-03-15 14:59:40 -05:00
parent f0b031fb05
commit 4d4ad38405
3 changed files with 113 additions and 2 deletions

View File

@@ -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}
/>
<InputBar

View File

@@ -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 (
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{isScrolledUp && hiddenAbove > 0 && (
<Box justifyContent="center">
<Text dimColor> {hiddenAbove} more messages </Text>
</Box>
)}
{messages.length === 0 && !isStreaming && (
<Box justifyContent="center" marginY={1}>
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
</Box>
)}
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} />
{visibleMessages.map((msg, i) => (
<MessageBubble key={hiddenAbove + i} msg={msg} />
))}
{/* Active thinking */}

View File

@@ -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,
};
}