Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -104,8 +103,6 @@ export const Composer = () => {
/>
)}

{!uiState.isConfigInitialized && <ConfigInitDisplay />}

<QueuedMessageDisplay messageQueue={uiState.messageQueue} />

{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/ui/components/Footer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
contextFileNames: [],
showToolDescriptions: false,
ideContextState: undefined,
isConfigInitialized: true,
...overrides,
}) as UIState;

Expand Down Expand Up @@ -149,6 +150,43 @@ describe('<Footer />', () => {
});
});

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());
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 } = {
Expand Down Expand Up @@ -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 ? (
<Text color={theme.status.warning}>{t('Press Ctrl+C again to exit.')}</Text>
) : uiState.ctrlDPressedOnce ? (
Expand All @@ -93,6 +104,10 @@ export const Footer: React.FC = () => {
<Text color={theme.text.secondary}>-- INSERT --</Text>
) : uiState.shellModeActive ? (
<ShellModeIndicator />
) : configInitMessage ? (

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the init status into Footer regresses screen-reader mode. Composer only renders <Footer /> when !isScreenReaderEnabled, while the old ConfigInitDisplay was rendered regardless, so users running with a screen reader now lose all MCP init progress feedback during startup. We should keep an accessible path for this message outside the footer gate, or special-case screen-reader mode here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the old ConfigInitDisplay was rendered in Composer outside the !isScreenReaderEnabled && <Footer /> gate, and moving it into the footer silently dropped the init feedback for screen-reader users.

Fixed in 29838ad: Composer now renders configInitMessage as a plain <Text> node when isScreenReaderEnabled is true, reusing the same useConfigInitMessage hook. This keeps the Footer-based rendering (and its constant live-area height) for everyone else — screen-reader users never experienced the residual-blank-line issue that motivated the move in the first place, so an independent text node is harmless for them.

<Text color={theme.text.secondary}>
<GeminiSpinner /> {configInitMessage}
</Text>
) : showAutoAcceptIndicator !== undefined &&
showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
Expand Down
111 changes: 111 additions & 0 deletions packages/cli/src/ui/hooks/useConfigInitMessage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, McpClient>([
['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<string, McpClient>([
['a', makeClient(MCPServerStatus.CONNECTED)],
]),
);
});
expect(result.current).toBe('Connecting to MCP servers... (1/1)');

act(() => {
appEvents.emit('mcp-client-update', new Map<string, McpClient>());
});
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<string, McpClient>([
['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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(() => t('Initializing...'));

useEffect(() => {
if (isConfigInitialized) {
return;
}

const onChange = (clients?: Map<string, McpClient>) => {
if (!clients || clients.size === 0) {
setMessage(t('Initializing...'));
Expand All @@ -41,13 +45,10 @@ export const ConfigInitDisplay = () => {
return () => {
appEvents.off('mcp-client-update', onChange);
};
}, [config]);
}, [isConfigInitialized]);

return (
<Box marginTop={1}>
<Text>
<GeminiSpinner /> <Text color={theme.text.primary}>{message}</Text>
</Text>
</Box>
);
};
// 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;
}
Loading