From fbe86a9e159b75ea1f5b689cc327599c9dc91090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 21 Jul 2026 16:37:20 +0800 Subject: [PATCH] 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 --- .../src/ui/components/MainContent.test.tsx | 35 +++++++++++++++++++ .../cli/src/ui/components/MainContent.tsx | 17 +++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index e91f16ace3..0ed864dcd2 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -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('', () => { 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({ diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index b15e1130d4..b1441da69e 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -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 ( { 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}