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}