feat(ui): optimize startup flow to be non-blocking

This commit is contained in:
Sehoon Shon
2026-03-11 23:48:34 -04:00
parent 35bf746e62
commit 7aa5e2064f
11 changed files with 280 additions and 253 deletions
+186 -11
View File
@@ -34,12 +34,19 @@ import {
} from '@google/gemini-cli-core';
// Mock coreEvents
const mockCoreEvents = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
drainBacklogs: vi.fn(),
emit: vi.fn(),
}));
const mockCoreEvents = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mock: any = {
on: vi.fn(),
off: vi.fn(),
drainBacklogs: vi.fn(),
emit: vi.fn(),
};
mock.emitFeedback = vi.fn((severity, message, error) => {
mock.emit('feedback', { type: severity, message, error });
});
return mock;
});
// Mock IdeClient
const mockIdeClient = vi.hoisted(() => ({
@@ -98,7 +105,7 @@ import ansiEscapes from 'ansi-escapes';
import { mergeSettings, type LoadedSettings } from '../config/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { StreamingState } from './types.js';
import { StreamingState, AuthState } from './types.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import {
UIActionsContext,
@@ -3394,7 +3401,10 @@ describe('AppContainer State Management', () => {
}).unmount;
});
await waitFor(() => expect(capturedUIActions).toBeTruthy());
await waitFor(() => {
expect(capturedUIActions).toBeTruthy();
expect(capturedUIState.isConfigInitialized).toBe(true);
});
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
@@ -3436,7 +3446,10 @@ describe('AppContainer State Management', () => {
}).unmount;
});
await waitFor(() => expect(capturedUIActions).toBeTruthy());
await waitFor(() => {
expect(capturedUIActions).toBeTruthy();
expect(capturedUIState.isConfigInitialized).toBe(true);
});
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
@@ -3747,7 +3760,10 @@ describe('AppContainer State Management', () => {
let unmount: () => void;
await act(async () => (unmount = renderAppContainer().unmount));
await waitFor(() => expect(capturedUIActions).toBeTruthy());
await waitFor(() => {
expect(capturedUIActions).toBeTruthy();
expect(capturedUIState.isConfigInitialized).toBe(true);
});
await act(async () =>
capturedUIActions.handleFinalSubmit('read @file.txt'),
@@ -3776,7 +3792,10 @@ describe('AppContainer State Management', () => {
let unmount: () => void;
await act(async () => (unmount = renderAppContainer().unmount));
await waitFor(() => expect(capturedUIActions).toBeTruthy());
await waitFor(() => {
expect(capturedUIActions).toBeTruthy();
expect(capturedUIState.isConfigInitialized).toBe(true);
});
await act(async () =>
capturedUIActions.handleFinalSubmit('read @file.txt'),
@@ -3893,4 +3912,160 @@ describe('AppContainer State Management', () => {
unmount!();
});
});
describe('Authentication Optimization', () => {
it('does NOT show AuthInProgress and unblocks UI during initial authentication', async () => {
const mockedUseAuthCommand = useAuthCommand as Mock;
mockedUseAuthCommand.mockReturnValue({
authState: AuthState.Unauthenticated, // isAuthenticating will be true
setAuthState: vi.fn(),
authError: null,
onAuthError: vi.fn(),
});
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer().unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// dialogsVisible should be false even if isAuthenticating is true
expect(capturedUIState.isAuthenticating).toBe(true);
expect(capturedUIState.dialogsVisible).toBe(false);
unmount!();
});
it('allows typing and queues prompts if submitted while initializing', async () => {
mockedUseAuthCommand.mockReturnValue({
authState: AuthState.Authenticated,
setAuthState: vi.fn(),
authError: null,
onAuthError: vi.fn(),
});
let unmount: () => void;
await act(async () => {
vi.spyOn(mockConfig, 'isInitialized').mockReturnValue(false);
const initPromise = new Promise(() => {}); // Never resolves
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(mockConfig, 'initialize').mockReturnValue(initPromise as any);
unmount = renderAppContainer().unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(capturedUIState.isInputActive).toBe(true);
expect(capturedUIState.isConfigInitialized).toBe(false);
// Reset mockCoreEvents.emit feedback call count
mockCoreEvents.emit.mockClear();
// Submit the prompt while still initializing
await act(async () => {
await capturedUIActions.handleFinalSubmit('hello');
});
// Feedback should be emitted with "Initializing..." message
expect(mockCoreEvents.emit).toHaveBeenCalledWith(
'feedback',
expect.objectContaining({
type: 'info',
message:
'Initializing... Slash commands are still available and prompts will be queued.',
}),
);
unmount!();
});
it('queues prompts and shows feedback if submitted while authenticating', async () => {
const mockedUseAuthCommand = useAuthCommand as Mock;
mockedUseAuthCommand.mockReturnValue({
authState: AuthState.Unauthenticated,
setAuthState: vi.fn(),
authError: null,
onAuthError: vi.fn(),
});
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer().unmount;
});
await waitFor(() => {
expect(capturedUIActions).toBeTruthy();
expect(capturedUIState.isConfigInitialized).toBe(true);
});
// Reset mockCoreEvents.emit feedback call count
mockCoreEvents.emit.mockClear();
// Submit a prompt while authenticating
await act(async () => {
await capturedUIActions.handleFinalSubmit('hello');
});
// Feedback should be emitted
expect(mockCoreEvents.emit).toHaveBeenCalledWith(
'feedback',
expect.objectContaining({
type: 'info',
message:
'Authentication is still in progress... Slash commands are still available and prompts will be queued.',
}),
);
unmount!();
});
it('queues prompts and shows feedback if submitted while initializing', async () => {
mockedUseAuthCommand.mockReturnValue({
authState: AuthState.Authenticated,
setAuthState: vi.fn(),
authError: null,
onAuthError: vi.fn(),
});
// Override the default mock to simulate non-initialized config
let unmount: () => void;
await act(async () => {
// We need to render with isConfigInitialized = false
// AppContainer state starts with isConfigInitialized = false
// and only sets it to true after config.initialize() completes.
// In tests, we can control how long config.initialize() takes.
vi.spyOn(mockConfig, 'isInitialized').mockReturnValue(false);
const initPromise = new Promise(() => {}); // Never resolves
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(mockConfig, 'initialize').mockReturnValue(initPromise as any);
unmount = renderAppContainer().unmount;
});
await waitFor(() => expect(capturedUIActions).toBeTruthy());
// isConfigInitialized should be false because we mocked initialize() to never resolve
expect(capturedUIState.isConfigInitialized).toBe(false);
// Reset mockCoreEvents.emit feedback call count
mockCoreEvents.emit.mockClear();
// Submit a prompt while initializing
await act(async () => {
await capturedUIActions.handleFinalSubmit('hello');
});
// Feedback should be emitted with "Initializing..." message
expect(mockCoreEvents.emit).toHaveBeenCalledWith(
'feedback',
expect.objectContaining({
type: 'info',
message:
'Initializing... Slash commands are still available and prompts will be queued.',
}),
);
unmount!();
});
});
});
+20 -16
View File
@@ -1295,7 +1295,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
return;
}
if (isSlash || (isIdle && isMcpReady)) {
if (
isSlash ||
(isIdle && isMcpReady && !isAuthenticating && isConfigInitialized)
) {
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
@@ -1318,11 +1321,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
if (isIdle && !isMcpReady && messageQueue.length === 0) {
coreEvents.emitFeedback(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
);
if (
isIdle &&
(!isMcpReady || isAuthenticating || !isConfigInitialized) &&
messageQueue.length === 0
) {
const message = isAuthenticating
? 'Authentication is still in progress... Slash commands are still available and prompts will be queued.'
: !isConfigInitialized
? 'Initializing... Slash commands are still available and prompts will be queued.'
: 'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.';
coreEvents.emitFeedback('info', message);
}
addMessage(submittedValue);
}
@@ -1345,6 +1354,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
reset,
handleHintSubmit,
triggerExpandHint,
isAuthenticating,
isConfigInitialized,
],
);
@@ -1373,15 +1384,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
* - Tool confirmations (WaitingForConfirmation state)
* - Any future streaming states not explicitly allowed
*/
const isInputActive =
isConfigInitialized &&
!initError &&
!isProcessing &&
!isResuming &&
!!slashCommands &&
(streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding) &&
!proQuotaRequest;
const isInputActive = !initError && !isProcessing && !proQuotaRequest;
const [controlsHeight, setControlsHeight] = useState(0);
@@ -2004,7 +2007,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isModelDialogOpen ||
isAgentConfigDialogOpen ||
isPermissionsDialogOpen ||
isAuthenticating ||
isAuthDialogOpen ||
isEditorDialogOpen ||
showPrivacyNotice ||
@@ -2170,6 +2172,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
themeError,
isAuthenticating,
isConfigInitialized,
isMcpReady,
authError,
accountSuspensionInfo,
isAuthDialogOpen,
@@ -2300,6 +2303,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
themeError,
isAuthenticating,
isConfigInitialized,
isMcpReady,
authError,
accountSuspensionInfo,
isAuthDialogOpen,
@@ -1,133 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render } from '../../test-utils/render.js';
import { act } from 'react';
import { AuthInProgress } from './AuthInProgress.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { debugLogger } from '@google/gemini-cli-core';
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('../components/CliSpinner.js', () => ({
CliSpinner: () => '[Spinner]',
}));
describe('AuthInProgress', () => {
const onTimeout = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(debugLogger.error).mockImplementation((...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('was not wrapped in act')
) {
return;
}
});
});
afterEach(() => {
vi.useRealTimers();
});
it('renders initial state with spinner', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('[Spinner] Waiting for authentication...');
expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel');
unmount();
});
it('calls onTimeout when ESC is pressed', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'escape' } as unknown as Key);
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
expect(onTimeout).toHaveBeenCalled();
unmount();
});
it('calls onTimeout when Ctrl+C is pressed', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
});
await waitUntilReady();
expect(onTimeout).toHaveBeenCalled();
unmount();
});
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
vi.advanceTimersByTime(180000);
});
await waitUntilReady();
expect(onTimeout).toHaveBeenCalled();
expect(lastFrame()).toContain('Authentication timed out');
unmount();
});
it('clears timer on unmount', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
unmount();
});
await act(async () => {
vi.advanceTimersByTime(180000);
});
expect(onTimeout).not.toHaveBeenCalled();
});
});
@@ -1,63 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { CliSpinner } from '../components/CliSpinner.js';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface AuthInProgressProps {
onTimeout: () => void;
}
export function AuthInProgress({
onTimeout,
}: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false);
useKeypress(
(key) => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onTimeout();
}
},
{ isActive: true },
);
useEffect(() => {
const timer = setTimeout(() => {
setTimedOut(true);
onTimeout();
}, 180000);
return () => clearTimeout(timer);
}, [onTimeout]);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
{timedOut ? (
<Text color={theme.status.error}>
Authentication timed out. Please try again.
</Text>
) : (
<Box>
<Text>
<CliSpinner type="dots" /> Waiting for authentication... (Press Esc
or Ctrl+C to cancel)
</Text>
</Box>
)}
</Box>
);
}
+14 -10
View File
@@ -25,6 +25,7 @@ import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { CliSpinner } from './CliSpinner.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
@@ -35,7 +36,6 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { isContextUsageHigh } from '../utils/contextUsage.js';
@@ -196,16 +196,20 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexGrow={0}
flexShrink={0}
>
{(!uiState.slashCommands ||
!uiState.isConfigInitialized ||
uiState.isResuming) && (
<ConfigInitDisplay
message={uiState.isResuming ? 'Resuming session...' : undefined}
/>
)}
{showUiDetails && (
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
<Box flexDirection="row" justifyContent="space-between" width="100%">
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
{(!uiState.isConfigInitialized ||
uiState.isAuthenticating ||
uiState.isResuming) && (
<Box marginRight={1}>
<Text color="cyan" dimColor>
<CliSpinner type="dots" />{' '}
{uiState.isResuming ? 'Resuming session...' : 'Initializing...'}
</Text>
</Box>
)}
</Box>
)}
{showUiDetails && <TodoTray />}
@@ -31,9 +31,6 @@ vi.mock('./ThemeDialog.js', () => ({
vi.mock('./SettingsDialog.js', () => ({
SettingsDialog: () => <Text>SettingsDialog</Text>,
}));
vi.mock('../auth/AuthInProgress.js', () => ({
AuthInProgress: () => <Text>AuthInProgress</Text>,
}));
vi.mock('../auth/AuthDialog.js', () => ({
AuthDialog: () => <Text>AuthDialog</Text>,
}));
@@ -169,7 +166,6 @@ describe('DialogManager', () => {
[{ isThemeDialogOpen: true }, 'ThemeDialog'],
[{ isSettingsDialogOpen: true }, 'SettingsDialog'],
[{ isModelDialogOpen: true }, 'ModelDialog'],
[{ isAuthenticating: true }, 'AuthInProgress'],
[{ isAwaitingApiKeyInput: true }, 'ApiAuthDialog'],
[{ isAuthDialogOpen: true }, 'AuthDialog'],
[{ isEditorDialogOpen: true }, 'EditorSettingsDialog'],
@@ -11,9 +11,8 @@ import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { BannedAccountDialog } from '../auth/BannedAccountDialog.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
@@ -280,15 +279,6 @@ export const DialogManager = ({
</Box>
);
}
if (uiState.isAuthenticating) {
return (
<AuthInProgress
onTimeout={() => {
uiActions.onAuthError('Authentication cancelled.');
}}
/>
);
}
if (uiState.isAwaitingApiKeyInput) {
return (
<Box flexDirection="column">
@@ -62,7 +62,6 @@ import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import {
appEvents,
@@ -216,7 +215,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const { stdout } = useStdout();
const { merged: settings } = useSettings();
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const {
setEmbeddedShellFocused,
setShortcutsHelpVisible,
@@ -329,7 +327,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isShellSuggestionsVisible,
} = completion;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const showCursor = focus && !isEmbeddedShellFocused;
// Notify parent component about escape prompt state changes
useEffect(() => {
@@ -1277,7 +1275,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
);
useKeypress(handleInput, {
isActive: !isEmbeddedShellFocused,
isActive: focus && !isEmbeddedShellFocused,
priority: true,
});
@@ -1470,7 +1468,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
) : null;
const borderColor =
isShellFocused && !isEmbeddedShellFocused
focus && !isEmbeddedShellFocused
? (statusColor ?? theme.ui.focus)
: theme.border.default;
@@ -110,6 +110,7 @@ export interface UIState {
themeError: string | null;
isAuthenticating: boolean;
isConfigInitialized: boolean;
isMcpReady: boolean;
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
isAuthDialogOpen: boolean;
@@ -29,6 +29,7 @@ describe('useMessageQueue', () => {
streamingState: StreamingState;
submitQuery: (query: string) => void;
isMcpReady: boolean;
isAuthenticating?: boolean;
}) => {
let hookResult: ReturnType<typeof useMessageQueue>;
function TestComponent(props: typeof initialProps) {
@@ -264,6 +265,56 @@ describe('useMessageQueue', () => {
});
});
it('should not submit queued messages if isAuthenticating is true', () => {
const { result, rerender } = renderMessageQueueHook({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
isMcpReady: true,
isAuthenticating: true,
});
act(() => {
result.current.addMessage('Message 1');
});
rerender({
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
isMcpReady: true,
isAuthenticating: true,
});
expect(mockSubmitQuery).not.toHaveBeenCalled();
expect(result.current.messageQueue).toEqual(['Message 1']);
});
it('should submit queued messages when isAuthenticating becomes false', () => {
const { result, rerender } = renderMessageQueueHook({
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
isMcpReady: true,
isAuthenticating: true,
});
act(() => {
result.current.addMessage('Message 1');
});
rerender({
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
isMcpReady: true,
isAuthenticating: false,
});
expect(mockSubmitQuery).toHaveBeenCalledWith('Message 1');
expect(result.current.messageQueue).toEqual([]);
});
describe('popAllMessages', () => {
it('should pop all messages and return them joined with double newlines', () => {
const { result } = renderMessageQueueHook({
@@ -12,6 +12,7 @@ export interface UseMessageQueueOptions {
streamingState: StreamingState;
submitQuery: (query: string) => void;
isMcpReady: boolean;
isAuthenticating?: boolean;
}
export interface UseMessageQueueReturn {
@@ -32,6 +33,7 @@ export function useMessageQueue({
streamingState,
submitQuery,
isMcpReady,
isAuthenticating = false,
}: UseMessageQueueOptions): UseMessageQueueReturn {
const [messageQueue, setMessageQueue] = useState<string[]>([]);
@@ -70,6 +72,7 @@ export function useMessageQueue({
isConfigInitialized &&
streamingState === StreamingState.Idle &&
isMcpReady &&
!isAuthenticating &&
messageQueue.length > 0
) {
// Combine all messages with double newlines for clarity
@@ -82,6 +85,7 @@ export function useMessageQueue({
isConfigInitialized,
streamingState,
isMcpReady,
isAuthenticating,
messageQueue,
submitQuery,
]);