mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 19:14:33 -07:00
Merge branch 'main' into adibakm/disable-esc-esc-rewind
This commit is contained in:
@@ -6,18 +6,20 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Config,
|
||||
ValidationRequiredError,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
AuthType: {
|
||||
OAUTH: 'oauth',
|
||||
},
|
||||
getErrorMessage: (e: unknown) => (e as Error).message,
|
||||
}));
|
||||
|
||||
const AuthType = {
|
||||
OAUTH: 'oauth',
|
||||
} as const;
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getErrorMessage: (e: unknown) => (e as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
describe('auth', () => {
|
||||
let mockConfig: Config;
|
||||
@@ -37,10 +39,12 @@ describe('auth', () => {
|
||||
it('should return null on successful auth', async () => {
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.OAUTH as unknown as Parameters<typeof performInitialAuth>[1],
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH);
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error message on failed auth', async () => {
|
||||
@@ -48,9 +52,25 @@ describe('auth', () => {
|
||||
vi.mocked(mockConfig.refreshAuth).mockRejectedValue(error);
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.OAUTH as unknown as Parameters<typeof performInitialAuth>[1],
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toBe('Failed to login. Message: Auth failed');
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH);
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null if refreshAuth throws ValidationRequiredError', async () => {
|
||||
vi.mocked(mockConfig.refreshAuth).mockRejectedValue(
|
||||
new ValidationRequiredError('Validation required'),
|
||||
);
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type AuthType,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
ValidationRequiredError,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,11 @@ export async function performInitialAuth(
|
||||
// The console.log is intentionally left out here.
|
||||
// We can add a dedicated startup message later if needed.
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationRequiredError) {
|
||||
// Don't treat validation required as a fatal auth error during startup.
|
||||
// This allows the React UI to load and show the ValidationDialog.
|
||||
return null;
|
||||
}
|
||||
return `Failed to login. Message: ${getErrorMessage(e)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
getVersion,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type FetchAdminControlsResponse,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
@@ -406,8 +408,19 @@ export async function main() {
|
||||
await partialConfig.refreshAuth(authType);
|
||||
}
|
||||
} catch (err) {
|
||||
debugLogger.error('Error authenticating:', err);
|
||||
initialAuthFailed = true;
|
||||
if (err instanceof ValidationCancelledError) {
|
||||
// User cancelled verification, exit immediately.
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
// If validation is required, we don't treat it as a fatal failure.
|
||||
// We allow the app to start, and the React-based ValidationDialog
|
||||
// will handle it.
|
||||
if (!(err instanceof ValidationRequiredError)) {
|
||||
debugLogger.error('Error authenticating:', err);
|
||||
initialAuthFailed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
generateSummary,
|
||||
ChangeAuthRequestedError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -527,7 +528,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
onAuthError,
|
||||
apiKeyDefaultValue,
|
||||
reloadApiKey,
|
||||
} = useAuthCommand(settings, config);
|
||||
} = useAuthCommand(settings, config, initializationResult.authError);
|
||||
const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>(
|
||||
{},
|
||||
);
|
||||
@@ -549,6 +550,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
historyManager,
|
||||
userTier,
|
||||
setModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: () => setAuthState(AuthState.Updating),
|
||||
});
|
||||
|
||||
// Derive auth state variables for backward compatibility with UIStateContext
|
||||
@@ -558,7 +560,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Session browser and resume functionality
|
||||
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
|
||||
|
||||
const { loadHistoryForResume } = useSessionResume({
|
||||
const { loadHistoryForResume, isResuming } = useSessionResume({
|
||||
config,
|
||||
historyManager,
|
||||
refreshStatic,
|
||||
@@ -598,6 +600,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
await config.refreshAuth(authType);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
} catch (e) {
|
||||
if (e instanceof ChangeAuthRequestedError) {
|
||||
return;
|
||||
}
|
||||
onAuthError(
|
||||
`Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
@@ -1013,6 +1018,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isConfigInitialized &&
|
||||
!initError &&
|
||||
!isProcessing &&
|
||||
!isResuming &&
|
||||
!!slashCommands &&
|
||||
(streamingState === StreamingState.Idle ||
|
||||
streamingState === StreamingState.Responding) &&
|
||||
@@ -1665,6 +1671,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
inputWidth,
|
||||
suggestionsWidth,
|
||||
isInputActive,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||
isTrustedFolder,
|
||||
@@ -1761,6 +1768,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
inputWidth,
|
||||
suggestionsWidth,
|
||||
isInputActive,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen,
|
||||
isTrustedFolder,
|
||||
|
||||
@@ -34,12 +34,16 @@ export function validateAuthMethodWithSettings(
|
||||
return validateAuthMethod(authType);
|
||||
}
|
||||
|
||||
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
export const useAuthCommand = (
|
||||
settings: LoadedSettings,
|
||||
config: Config,
|
||||
initialAuthError: string | null = null,
|
||||
) => {
|
||||
const [authState, setAuthState] = useState<AuthState>(
|
||||
AuthState.Unauthenticated,
|
||||
initialAuthError ? AuthState.Updating : AuthState.Unauthenticated,
|
||||
);
|
||||
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(initialAuthError);
|
||||
const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
@@ -71,8 +71,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!uiState.slashCommands || !uiState.isConfigInitialized) && (
|
||||
<ConfigInitDisplay />
|
||||
{(!uiState.slashCommands ||
|
||||
!uiState.isConfigInitialized ||
|
||||
uiState.isResuming) && (
|
||||
<ConfigInitDisplay
|
||||
message={uiState.isResuming ? 'Resuming session...' : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
|
||||
|
||||
@@ -15,13 +15,17 @@ import {
|
||||
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const ConfigInitDisplay = () => {
|
||||
const [message, setMessage] = useState('Initializing...');
|
||||
export const ConfigInitDisplay = ({
|
||||
message: initialMessage = 'Initializing...',
|
||||
}: {
|
||||
message?: string;
|
||||
}) => {
|
||||
const [message, setMessage] = useState(initialMessage);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = (clients?: Map<string, McpClient>) => {
|
||||
if (!clients || clients.size === 0) {
|
||||
setMessage(`Initializing...`);
|
||||
setMessage(initialMessage);
|
||||
return;
|
||||
}
|
||||
let connected = 0;
|
||||
@@ -39,12 +43,18 @@ export const ConfigInitDisplay = () => {
|
||||
const displayedServers = connecting.slice(0, maxDisplay).join(', ');
|
||||
const remaining = connecting.length - maxDisplay;
|
||||
const suffix = remaining > 0 ? `, +${remaining} more` : '';
|
||||
const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size}) - Waiting for: ${displayedServers}${suffix}`;
|
||||
setMessage(
|
||||
`Connecting to MCP servers... (${connected}/${clients.size}) - Waiting for: ${displayedServers}${suffix}`,
|
||||
initialMessage && initialMessage !== 'Initializing...'
|
||||
? `${initialMessage} (${mcpMessage})`
|
||||
: mcpMessage,
|
||||
);
|
||||
} else {
|
||||
const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size})`;
|
||||
setMessage(
|
||||
`Connecting to MCP servers... (${connected}/${clients.size})`,
|
||||
initialMessage && initialMessage !== 'Initializing...'
|
||||
? `${initialMessage} (${mcpMessage})`
|
||||
: mcpMessage,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -53,7 +63,7 @@ export const ConfigInitDisplay = () => {
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.McpClientUpdate, onChange);
|
||||
};
|
||||
}, []);
|
||||
}, [initialMessage]);
|
||||
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'vitest';
|
||||
import { ValidationDialog } from './ValidationDialog.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
|
||||
// Mock the child components and utilities
|
||||
vi.mock('./shared/RadioButtonSelect.js', () => ({
|
||||
@@ -41,8 +42,15 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Capture keypress handler to test it
|
||||
let mockKeypressHandler: (key: Key) => void;
|
||||
let mockKeypressOptions: { isActive: boolean };
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
useKeypress: vi.fn((handler, options) => {
|
||||
mockKeypressHandler = handler;
|
||||
mockKeypressOptions = options;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ValidationDialog', () => {
|
||||
@@ -99,6 +107,29 @@ describe('ValidationDialog', () => {
|
||||
expect(lastFrame()).toContain('https://example.com/help');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onChoice with cancel when ESCAPE is pressed', () => {
|
||||
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
|
||||
|
||||
// Verify the keypress hook is active
|
||||
expect(mockKeypressOptions.isActive).toBe(true);
|
||||
|
||||
// Simulate ESCAPE key press
|
||||
act(() => {
|
||||
mockKeypressHandler({
|
||||
name: 'escape',
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x1b',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('cancel');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChoice handling', () => {
|
||||
|
||||
@@ -48,17 +48,17 @@ export function ValidationDialog({
|
||||
},
|
||||
];
|
||||
|
||||
// Handle keypresses during 'waiting' state (ESC to cancel, Enter to confirm completion)
|
||||
// Handle keypresses globally for cancellation, and specific logic for waiting state
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) {
|
||||
onChoice('cancel');
|
||||
} else if (keyMatchers[Command.RETURN](key)) {
|
||||
} else if (state === 'waiting' && keyMatchers[Command.RETURN](key)) {
|
||||
// User confirmed verification is complete - transition to 'complete' state
|
||||
setState('complete');
|
||||
}
|
||||
},
|
||||
{ isActive: state === 'waiting' },
|
||||
{ isActive: state !== 'complete' },
|
||||
);
|
||||
|
||||
// When state becomes 'complete', show success message briefly then proceed
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface UIState {
|
||||
inputWidth: number;
|
||||
suggestionsWidth: number;
|
||||
isInputActive: boolean;
|
||||
isResuming: boolean;
|
||||
shouldShowIdePrompt: boolean;
|
||||
isFolderTrustDialogOpen: boolean;
|
||||
isTrustedFolder: boolean | undefined;
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('useQuotaAndFallback', () => {
|
||||
let mockConfig: Config;
|
||||
let mockHistoryManager: UseHistoryManagerReturn;
|
||||
let mockSetModelSwitchedFromQuotaError: Mock;
|
||||
let mockOnShowAuthSelection: Mock;
|
||||
let setFallbackHandlerSpy: SpyInstance;
|
||||
let mockGoogleApiError: GoogleApiError;
|
||||
|
||||
@@ -66,6 +67,7 @@ describe('useQuotaAndFallback', () => {
|
||||
loadHistory: vi.fn(),
|
||||
};
|
||||
mockSetModelSwitchedFromQuotaError = vi.fn();
|
||||
mockOnShowAuthSelection = vi.fn();
|
||||
|
||||
setFallbackHandlerSpy = vi.spyOn(mockConfig, 'setFallbackModelHandler');
|
||||
vi.spyOn(mockConfig, 'setQuotaErrorOccurred');
|
||||
@@ -85,6 +87,7 @@ describe('useQuotaAndFallback', () => {
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -101,6 +104,7 @@ describe('useQuotaAndFallback', () => {
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler;
|
||||
@@ -127,6 +131,7 @@ describe('useQuotaAndFallback', () => {
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -178,6 +183,7 @@ describe('useQuotaAndFallback', () => {
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -243,6 +249,7 @@ describe('useQuotaAndFallback', () => {
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError:
|
||||
mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -297,6 +304,7 @@ describe('useQuotaAndFallback', () => {
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -345,6 +353,7 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -362,6 +371,7 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -392,6 +402,7 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -435,6 +446,7 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -470,6 +482,7 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -513,6 +526,7 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -527,6 +541,7 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -568,6 +583,7 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -602,13 +618,14 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
expect(result.current.validationRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should add info message when change_auth is chosen', async () => {
|
||||
it('should call onShowAuthSelection when change_auth is chosen', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -628,19 +645,17 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('change_auth');
|
||||
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toBe('Use /auth to change authentication method.');
|
||||
expect(mockOnShowAuthSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not add info message when cancel is chosen', async () => {
|
||||
it('should call onShowAuthSelection when cancel is chosen', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -660,7 +675,7 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('cancel');
|
||||
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
expect(mockOnShowAuthSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should do nothing if handleValidationChoice is called without pending request', () => {
|
||||
@@ -670,6 +685,7 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ interface UseQuotaAndFallbackArgs {
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
userTier: UserTierId | undefined;
|
||||
setModelSwitchedFromQuotaError: (value: boolean) => void;
|
||||
onShowAuthSelection: () => void;
|
||||
}
|
||||
|
||||
export function useQuotaAndFallback({
|
||||
@@ -38,6 +39,7 @@ export function useQuotaAndFallback({
|
||||
historyManager,
|
||||
userTier,
|
||||
setModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection,
|
||||
}: UseQuotaAndFallbackArgs) {
|
||||
const [proQuotaRequest, setProQuotaRequest] =
|
||||
useState<ProQuotaDialogRequest | null>(null);
|
||||
@@ -197,17 +199,11 @@ export function useQuotaAndFallback({
|
||||
validationRequest.resolve(choice);
|
||||
setValidationRequest(null);
|
||||
|
||||
if (choice === 'change_auth') {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Use /auth to change authentication method.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
if (choice === 'change_auth' || choice === 'cancel') {
|
||||
onShowAuthSelection();
|
||||
}
|
||||
},
|
||||
[validationRequest, historyManager],
|
||||
[validationRequest, onShowAuthSelection],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -24,7 +24,7 @@ export const useSessionBrowser = (
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
resumedSessionData: ResumedSessionData,
|
||||
) => void,
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
const [isSessionBrowserOpen, setIsSessionBrowserOpen] = useState(false);
|
||||
|
||||
@@ -73,7 +73,7 @@ export const useSessionBrowser = (
|
||||
const historyData = convertSessionToHistoryFormats(
|
||||
conversation.messages,
|
||||
);
|
||||
onLoadHistory(
|
||||
await onLoadHistory(
|
||||
historyData.uiHistory,
|
||||
historyData.clientHistory,
|
||||
resumedSessionData,
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('useSessionResume', () => {
|
||||
expect(result.current.loadHistoryForResume).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should clear history and add items when loading history', () => {
|
||||
it('should clear history and add items when loading history', async () => {
|
||||
const { result } = renderHook(() => useSessionResume(getDefaultProps()));
|
||||
|
||||
const uiHistory: HistoryItemWithoutId[] = [
|
||||
@@ -86,8 +86,8 @@ describe('useSessionResume', () => {
|
||||
filePath: '/path/to/session.json',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.loadHistoryForResume(
|
||||
await act(async () => {
|
||||
await result.current.loadHistoryForResume(
|
||||
uiHistory,
|
||||
clientHistory,
|
||||
resumedData,
|
||||
@@ -116,7 +116,7 @@ describe('useSessionResume', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not load history if Gemini client is not initialized', () => {
|
||||
it('should not load history if Gemini client is not initialized', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
@@ -141,8 +141,8 @@ describe('useSessionResume', () => {
|
||||
filePath: '/path/to/session.json',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.loadHistoryForResume(
|
||||
await act(async () => {
|
||||
await result.current.loadHistoryForResume(
|
||||
uiHistory,
|
||||
clientHistory,
|
||||
resumedData,
|
||||
@@ -154,7 +154,7 @@ describe('useSessionResume', () => {
|
||||
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty history arrays', () => {
|
||||
it('should handle empty history arrays', async () => {
|
||||
const { result } = renderHook(() => useSessionResume(getDefaultProps()));
|
||||
|
||||
const resumedData: ResumedSessionData = {
|
||||
@@ -168,8 +168,8 @@ describe('useSessionResume', () => {
|
||||
filePath: '/path/to/session.json',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.loadHistoryForResume([], [], resumedData);
|
||||
await act(async () => {
|
||||
await result.current.loadHistoryForResume([], [], resumedData);
|
||||
});
|
||||
|
||||
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
|
||||
@@ -311,15 +311,17 @@ describe('useSessionResume', () => {
|
||||
] as MessageRecord[],
|
||||
};
|
||||
|
||||
renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
|
||||
@@ -358,20 +360,24 @@ describe('useSessionResume', () => {
|
||||
] as MessageRecord[],
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ refreshStatic }: { refreshStatic: () => void }) =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
refreshStatic,
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
}),
|
||||
{
|
||||
initialProps: { refreshStatic: mockRefreshStatic },
|
||||
},
|
||||
);
|
||||
let rerenderFunc: (props: { refreshStatic: () => void }) => void;
|
||||
await act(async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ refreshStatic }: { refreshStatic: () => void }) =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
refreshStatic,
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
}),
|
||||
{
|
||||
initialProps: { refreshStatic: mockRefreshStatic as () => void },
|
||||
},
|
||||
);
|
||||
rerenderFunc = rerender;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
|
||||
@@ -383,7 +389,9 @@ describe('useSessionResume', () => {
|
||||
|
||||
// Rerender with different refreshStatic
|
||||
const newRefreshStatic = vi.fn();
|
||||
rerender({ refreshStatic: newRefreshStatic });
|
||||
await act(async () => {
|
||||
rerenderFunc({ refreshStatic: newRefreshStatic });
|
||||
});
|
||||
|
||||
// Should not resume again
|
||||
expect(mockHistoryManager.clearItems).toHaveBeenCalledTimes(
|
||||
@@ -413,15 +421,17 @@ describe('useSessionResume', () => {
|
||||
] as MessageRecord[],
|
||||
};
|
||||
|
||||
renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalled();
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { Config, ResumedSessionData } from '@google/gemini-cli-core';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
coreEvents,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -35,6 +39,8 @@ export function useSessionResume({
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
}: UseSessionResumeParams) {
|
||||
const [isResuming, setIsResuming] = useState(false);
|
||||
|
||||
// Use refs to avoid dependency chain that causes infinite loop
|
||||
const historyManagerRef = useRef(historyManager);
|
||||
const refreshStaticRef = useRef(refreshStatic);
|
||||
@@ -45,7 +51,7 @@ export function useSessionResume({
|
||||
});
|
||||
|
||||
const loadHistoryForResume = useCallback(
|
||||
(
|
||||
async (
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
resumedData: ResumedSessionData,
|
||||
@@ -55,17 +61,27 @@ export function useSessionResume({
|
||||
return;
|
||||
}
|
||||
|
||||
// Now that we have the client, load the history into the UI and the client.
|
||||
setQuittingMessages(null);
|
||||
historyManagerRef.current.clearItems();
|
||||
uiHistory.forEach((item, index) => {
|
||||
historyManagerRef.current.addItem(item, index, true);
|
||||
});
|
||||
refreshStaticRef.current(); // Force Static component to re-render with the updated history.
|
||||
setIsResuming(true);
|
||||
try {
|
||||
// Now that we have the client, load the history into the UI and the client.
|
||||
setQuittingMessages(null);
|
||||
historyManagerRef.current.clearItems();
|
||||
uiHistory.forEach((item, index) => {
|
||||
historyManagerRef.current.addItem(item, index, true);
|
||||
});
|
||||
refreshStaticRef.current(); // Force Static component to re-render with the updated history.
|
||||
|
||||
// Give the history to the Gemini client.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
|
||||
// Give the history to the Gemini client.
|
||||
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'Failed to resume session. Please try again.',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsResuming(false);
|
||||
}
|
||||
},
|
||||
[config, isGeminiClientInitialized, setQuittingMessages],
|
||||
);
|
||||
@@ -84,7 +100,7 @@ export function useSessionResume({
|
||||
const historyData = convertSessionToHistoryFormats(
|
||||
resumedSessionData.conversation.messages,
|
||||
);
|
||||
loadHistoryForResume(
|
||||
void loadHistoryForResume(
|
||||
historyData.uiHistory,
|
||||
historyData.clientHistory,
|
||||
resumedSessionData,
|
||||
@@ -97,5 +113,5 @@ export function useSessionResume({
|
||||
loadHistoryForResume,
|
||||
]);
|
||||
|
||||
return { loadHistoryForResume };
|
||||
return { loadHistoryForResume, isResuming };
|
||||
}
|
||||
|
||||
@@ -69,7 +69,10 @@ export function getInstallationInfo(
|
||||
updateMessage: 'Running via npx, update not applicable.',
|
||||
};
|
||||
}
|
||||
if (realPath.includes('/.pnpm/_pnpx')) {
|
||||
if (
|
||||
realPath.includes('/.pnpm/_pnpx') ||
|
||||
realPath.includes('/.cache/pnpm/dlx')
|
||||
) {
|
||||
return {
|
||||
packageManager: PackageManager.PNPX,
|
||||
isGlobal: false,
|
||||
@@ -103,7 +106,10 @@ export function getInstallationInfo(
|
||||
}
|
||||
|
||||
// Check for pnpm
|
||||
if (realPath.includes('/.pnpm/global')) {
|
||||
if (
|
||||
realPath.includes('/.pnpm/global') ||
|
||||
realPath.includes('/.local/share/pnpm')
|
||||
) {
|
||||
const updateCommand = 'pnpm add -g @google/gemini-cli@latest';
|
||||
return {
|
||||
packageManager: PackageManager.PNPM,
|
||||
|
||||
Reference in New Issue
Block a user