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}