diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 263988686c3..c5bda6bea92 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -16,7 +16,6 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
-import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
import { t } from '../../i18n/index.js';
@@ -104,8 +103,6 @@ export const Composer = () => {
/>
)}
- {!uiState.isConfigInitialized && }
-
{uiState.isFeedbackDialogOpen && }
diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index 585f47ecec2..c405dbe3eef 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -78,6 +78,7 @@ const createMockUIState = (overrides: Partial = {}): UIState =>
contextFileNames: [],
showToolDescriptions: false,
ideContextState: undefined,
+ isConfigInitialized: true,
...overrides,
}) as UIState;
@@ -149,6 +150,43 @@ describe('', () => {
});
});
+ describe('config init message', () => {
+ it('shows init status in place of the hint while config is initializing', () => {
+ const { lastFrame } = renderWithWidth(
+ 120,
+ createMockUIState({ isConfigInitialized: false }),
+ );
+ const frame = lastFrame()!;
+ expect(frame).toContain('Initializing...');
+ expect(frame).not.toContain('? for shortcuts');
+ });
+
+ it('falls back to the hint once config is initialized', () => {
+ const { lastFrame } = renderWithWidth(
+ 120,
+ createMockUIState({ isConfigInitialized: true }),
+ );
+ const frame = lastFrame()!;
+ expect(frame).not.toContain('Initializing...');
+ expect(frame).toContain('? for shortcuts');
+ });
+
+ // Init progress is more useful than zero layout shift: we show it even
+ // when a custom status line is active, accepting that the row shrinks
+ // by one line once init completes. Still strictly better than the
+ // original bug (a 2-row residual above the input in the default case).
+ it('shows init status even when a custom status line is active', () => {
+ useStatusLineMock.mockReturnValue({ lines: ['model-name ctx:34%'] });
+ const { lastFrame } = renderWithWidth(
+ 120,
+ createMockUIState({ isConfigInitialized: false }),
+ );
+ const frame = lastFrame()!;
+ expect(frame).toContain('model-name ctx:34%');
+ expect(frame).toContain('Initializing...');
+ });
+ });
+
describe('footer rendering (golden snapshots)', () => {
it('renders complete footer on wide terminal', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index abe7e456949..1debdcb9ca6 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -15,10 +15,12 @@ import { ShellModeIndicator } from './ShellModeIndicator.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useStatusLine } from '../hooks/useStatusLine.js';
+import { useConfigInitMessage } from '../hooks/useConfigInitMessage.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
+import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { t } from '../../i18n/index.js';
/**
@@ -52,6 +54,7 @@ export const Footer: React.FC = () => {
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const { lines: statusLineLines } = useStatusLine();
+ const configInitMessage = useConfigInitMessage(uiState.isConfigInitialized);
const dreamRunning = useDreamRunning(config.getProjectRoot());
const { promptTokenCount, showAutoAcceptIndicator } = {
@@ -82,7 +85,15 @@ export const Footer: React.FC = () => {
// occupies the footer, so the hint is redundant). Matches upstream behavior.
const suppressHint = statusLineLines.length > 0;
- // Left bottom row: high-priority messages > approval mode > hint.
+ // MCP init progress lives in this row (not a standalone component above the
+ // input) so the live area's height is constant in the default case, avoiding
+ // the residual-blank-line artifact left behind when a separate block unmounts.
+ // When a custom status line is active, the row shrinks by 1 on transition to
+ // ready — a one-time, small regression preferred over hiding init progress.
+ //
+ // `configInitMessage` is placed ahead of `showAutoAcceptIndicator` so users
+ // launched with YOLO / auto-accept-edits still see the ~1s startup progress;
+ // the approval-mode indicator takes over as soon as init finishes.
const leftBottomContent = uiState.ctrlCPressedOnce ? (
{t('Press Ctrl+C again to exit.')}
) : uiState.ctrlDPressedOnce ? (
@@ -93,6 +104,10 @@ export const Footer: React.FC = () => {
-- INSERT --
) : uiState.shellModeActive ? (
+ ) : configInitMessage ? (
+
+ {configInitMessage}
+
) : showAutoAcceptIndicator !== undefined &&
showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
diff --git a/packages/cli/src/ui/hooks/useConfigInitMessage.test.ts b/packages/cli/src/ui/hooks/useConfigInitMessage.test.ts
new file mode 100644
index 00000000000..5ab5393d54b
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useConfigInitMessage.test.ts
@@ -0,0 +1,111 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { MCPServerStatus, type McpClient } from '@qwen-code/qwen-code-core';
+import { appEvents } from '../../utils/events.js';
+import { useConfigInitMessage } from './useConfigInitMessage.js';
+
+function makeClient(status: MCPServerStatus): McpClient {
+ return { getStatus: () => status } as unknown as McpClient;
+}
+
+describe('useConfigInitMessage', () => {
+ afterEach(() => {
+ appEvents.removeAllListeners('mcp-client-update');
+ });
+
+ it('returns null once config is initialized', () => {
+ const { result } = renderHook(() => useConfigInitMessage(true));
+ expect(result.current).toBeNull();
+ });
+
+ it('defaults to "Initializing..." while config is still initializing', () => {
+ const { result } = renderHook(() => useConfigInitMessage(false));
+ expect(result.current).toBe('Initializing...');
+ });
+
+ it('reports connection progress as MCP clients connect', () => {
+ const { result } = renderHook(() => useConfigInitMessage(false));
+
+ const clients = new Map([
+ ['a', makeClient(MCPServerStatus.CONNECTED)],
+ ['b', makeClient(MCPServerStatus.DISCONNECTED)],
+ ['c', makeClient(MCPServerStatus.DISCONNECTED)],
+ ]);
+
+ act(() => {
+ appEvents.emit('mcp-client-update', clients);
+ });
+ expect(result.current).toBe('Connecting to MCP servers... (1/3)');
+
+ clients.set('b', makeClient(MCPServerStatus.CONNECTED));
+ act(() => {
+ appEvents.emit('mcp-client-update', clients);
+ });
+ expect(result.current).toBe('Connecting to MCP servers... (2/3)');
+ });
+
+ it('falls back to "Initializing..." when the clients map is empty', () => {
+ const { result } = renderHook(() => useConfigInitMessage(false));
+
+ act(() => {
+ appEvents.emit(
+ 'mcp-client-update',
+ new Map([
+ ['a', makeClient(MCPServerStatus.CONNECTED)],
+ ]),
+ );
+ });
+ expect(result.current).toBe('Connecting to MCP servers... (1/1)');
+
+ act(() => {
+ appEvents.emit('mcp-client-update', new Map());
+ });
+ expect(result.current).toBe('Initializing...');
+ });
+
+ it('flips to null as soon as config finishes initializing', () => {
+ const { result, rerender } = renderHook(
+ ({ initialized }: { initialized: boolean }) =>
+ useConfigInitMessage(initialized),
+ { initialProps: { initialized: false } },
+ );
+
+ act(() => {
+ appEvents.emit(
+ 'mcp-client-update',
+ new Map([
+ ['a', makeClient(MCPServerStatus.CONNECTED)],
+ ]),
+ );
+ });
+ expect(result.current).toBe('Connecting to MCP servers... (1/1)');
+
+ rerender({ initialized: true });
+ expect(result.current).toBeNull();
+ });
+
+ it('unsubscribes from mcp-client-update on unmount', () => {
+ const { unmount } = renderHook(() => useConfigInitMessage(false));
+ expect(appEvents.listenerCount('mcp-client-update')).toBe(1);
+ unmount();
+ expect(appEvents.listenerCount('mcp-client-update')).toBe(0);
+ });
+
+ it('unsubscribes when config transitions to initialized', () => {
+ const { rerender } = renderHook(
+ ({ initialized }: { initialized: boolean }) =>
+ useConfigInitMessage(initialized),
+ { initialProps: { initialized: false } },
+ );
+ expect(appEvents.listenerCount('mcp-client-update')).toBe(1);
+
+ rerender({ initialized: true });
+ expect(appEvents.listenerCount('mcp-client-update')).toBe(0);
+ });
+});
diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.tsx b/packages/cli/src/ui/hooks/useConfigInitMessage.ts
similarity index 56%
rename from packages/cli/src/ui/components/ConfigInitDisplay.tsx
rename to packages/cli/src/ui/hooks/useConfigInitMessage.ts
index 264eeeafa47..ffc12d24485 100644
--- a/packages/cli/src/ui/components/ConfigInitDisplay.tsx
+++ b/packages/cli/src/ui/hooks/useConfigInitMessage.ts
@@ -5,19 +5,23 @@
*/
import { useEffect, useState } from 'react';
-import { appEvents } from './../../utils/events.js';
-import { Box, Text } from 'ink';
-import { useConfig } from '../contexts/ConfigContext.js';
+import { appEvents } from '../../utils/events.js';
import { type McpClient, MCPServerStatus } from '@qwen-code/qwen-code-core';
-import { GeminiSpinner } from './GeminiRespondingSpinner.js';
-import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
-export const ConfigInitDisplay = () => {
- const config = useConfig();
- const [message, setMessage] = useState(t('Initializing...'));
+// Tracks MCP connection progress. Returns the current status string while
+// config is initializing, or `null` once complete so callers can fall
+// through to their default content.
+export function useConfigInitMessage(
+ isConfigInitialized: boolean,
+): string | null {
+ const [message, setMessage] = useState(() => t('Initializing...'));
useEffect(() => {
+ if (isConfigInitialized) {
+ return;
+ }
+
const onChange = (clients?: Map) => {
if (!clients || clients.size === 0) {
setMessage(t('Initializing...'));
@@ -41,13 +45,10 @@ export const ConfigInitDisplay = () => {
return () => {
appEvents.off('mcp-client-update', onChange);
};
- }, [config]);
+ }, [isConfigInitialized]);
- return (
-
-
- {message}
-
-
- );
-};
+ // Gating on isConfigInitialized (rather than clearing state from the effect)
+ // ensures the first render that flips to initialized returns null without
+ // a transient frame still showing the old message.
+ return isConfigInitialized ? null : message;
+}