mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-10 11:12:35 -07:00
feat(ui): optimize startup flow to be non-blocking
This commit is contained in:
@@ -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!();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user