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 { InputBar } from './components/input-bar.js';
|
||||||
import { useSocket } from './hooks/use-socket.js';
|
import { useSocket } from './hooks/use-socket.js';
|
||||||
import { useGitInfo } from './hooks/use-git-info.js';
|
import { useGitInfo } from './hooks/use-git-info.js';
|
||||||
|
import { useViewport } from './hooks/use-viewport.js';
|
||||||
|
|
||||||
export interface TuiAppProps {
|
export interface TuiAppProps {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
@@ -23,10 +24,19 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
|
|||||||
initialConversationId: conversationId,
|
initialConversationId: conversationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const viewport = useViewport({ totalItems: socket.messages.length });
|
||||||
|
|
||||||
useInput((ch, key) => {
|
useInput((ch, key) => {
|
||||||
if (key.ctrl && ch === 'c') {
|
if (key.ctrl && ch === 'c') {
|
||||||
exit();
|
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
|
// Ctrl+T: cycle thinking level
|
||||||
if (key.ctrl && ch === 't') {
|
if (key.ctrl && ch === 't') {
|
||||||
const levels = socket.availableThinkingLevels;
|
const levels = socket.availableThinkingLevels;
|
||||||
@@ -61,6 +71,9 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
|
|||||||
currentStreamText={socket.currentStreamText}
|
currentStreamText={socket.currentStreamText}
|
||||||
currentThinkingText={socket.currentThinkingText}
|
currentThinkingText={socket.currentThinkingText}
|
||||||
activeToolCalls={socket.activeToolCalls}
|
activeToolCalls={socket.activeToolCalls}
|
||||||
|
scrollOffset={viewport.scrollOffset}
|
||||||
|
viewportSize={viewport.viewportSize}
|
||||||
|
isScrolledUp={viewport.isScrolledUp}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputBar
|
<InputBar
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export interface MessageListProps {
|
|||||||
currentStreamText: string;
|
currentStreamText: string;
|
||||||
currentThinkingText: string;
|
currentThinkingText: string;
|
||||||
activeToolCalls: ToolCall[];
|
activeToolCalls: ToolCall[];
|
||||||
|
scrollOffset?: number;
|
||||||
|
viewportSize?: number;
|
||||||
|
isScrolledUp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
function formatTime(date: Date): string {
|
||||||
@@ -68,17 +71,32 @@ export function MessageList({
|
|||||||
currentStreamText,
|
currentStreamText,
|
||||||
currentThinkingText,
|
currentThinkingText,
|
||||||
activeToolCalls,
|
activeToolCalls,
|
||||||
|
scrollOffset,
|
||||||
|
viewportSize,
|
||||||
|
isScrolledUp,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
|
const useSlicing = scrollOffset != null && viewportSize != null;
|
||||||
|
const visibleMessages = useSlicing
|
||||||
|
? messages.slice(scrollOffset, scrollOffset + viewportSize)
|
||||||
|
: messages;
|
||||||
|
const hiddenAbove = useSlicing ? scrollOffset : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||||
|
{isScrolledUp && hiddenAbove > 0 && (
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Text dimColor>↑ {hiddenAbove} more messages ↑</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{messages.length === 0 && !isStreaming && (
|
{messages.length === 0 && !isStreaming && (
|
||||||
<Box justifyContent="center" marginY={1}>
|
<Box justifyContent="center" marginY={1}>
|
||||||
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
|
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg, i) => (
|
{visibleMessages.map((msg, i) => (
|
||||||
<MessageBubble key={i} msg={msg} />
|
<MessageBubble key={hiddenAbove + i} msg={msg} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Active thinking */}
|
{/* 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