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:
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