fix(cli): anchor VP viewport to the top until a conversation turn exists

On a fresh VP-mode session the virtualized list holds the banner plus startup
notices (tips / MOTD / info), so it is longer than one item. Keying the initial
scroll anchor off list length alone selected scroll-to-end, which pinned the
banner to the bottom of the full-height viewport and left the top half of the
screen blank.

Anchor to the top until there is an actual conversation turn (a user/user_shell
history item or a pending response), then resume scroll-to-end so the latest
output stays in view. Startup notices no longer count as content that forces
bottom alignment.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
秦奇 2026-07-21 16:37:20 +08:00
parent 3f6bb4f273
commit fbe86a9e15
2 changed files with 49 additions and 3 deletions

View file

@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { MainContent } from './MainContent.js';
import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import {
UIActionsContext,
@ -769,6 +770,40 @@ describe('<MainContent />', () => {
expect(lastFrame()).toMatch(/VP_ITEM:1[\s\S]*VP_ITEM:2/);
});
it('anchors to the top on a fresh session so the banner is not pushed to the bottom', () => {
scrollableListPropsSpy.mockClear();
// Fresh session: banner + a startup notice, but no conversation turn.
renderMainContent(
createUIState({
useTerminalBuffer: true,
history: [{ id: 1, type: 'info', text: 'Tips: try /help' }],
}),
);
expect(
scrollableListPropsSpy.mock.calls.at(-1)?.[0].initialScrollIndex,
).toBe(0);
});
it('scrolls to the end once a conversation turn exists', () => {
scrollableListPropsSpy.mockClear();
renderMainContent(
createUIState({
useTerminalBuffer: true,
history: [
{ id: 1, type: 'info', text: 'Tips: try /help' },
{ id: 2, type: 'user', text: 'hello' },
],
}),
);
expect(
scrollableListPropsSpy.mock.calls.at(-1)?.[0].initialScrollIndex,
).toBe(SCROLL_TO_ITEM_END);
});
it('keeps ShowMoreLines reachable in VP mode (regression of OverflowProvider misplacement)', () => {
const { lastFrame } = renderMainContent(
createUIState({

View file

@ -413,6 +413,19 @@ export const MainContent = () => {
uiState.availableTerminalHeight ?? 0,
);
// Anchor the initial view to the top until there is an actual conversation
// turn. On a fresh session the list holds only the banner plus startup
// notices (tips / MOTD / info), which is length > 1 — so keying the anchor
// off length alone made it scroll-to-end and pin the banner to the bottom
// of the viewport, leaving the top half of the VP screen blank. Once the
// user submits (or a response streams in) we resume scroll-to-end so the
// latest output stays in view.
const hasConversationTurn =
pendingHistoryItems.length > 0 ||
visibleHistory.some(
(item) => item.type === 'user' || item.type === 'user_shell',
);
return (
<OverflowProvider>
<ScrollableList
@ -422,9 +435,7 @@ export const MainContent = () => {
renderItem={renderVirtualItem}
estimatedItemHeight={virtualEstimatedItemHeight}
keyExtractor={virtualKeyExtractor}
initialScrollIndex={
allVirtualItems.length <= 1 ? 0 : SCROLL_TO_ITEM_END
}
initialScrollIndex={hasConversationTurn ? SCROLL_TO_ITEM_END : 0}
isStaticItem={virtualIsStaticItem}
containerHeight={scrollContainerHeight}
showScrollbar={showScrollbar}