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:
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
80
packages/cli/src/tui/hooks/use-viewport.ts
Normal file
80
packages/cli/src/tui/hooks/use-viewport.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user