mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-18 18:11:02 -07:00
feat: launch Gemini 3 in Gemini CLI 🚀🚀🚀 (in main) (#13287)
Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Co-authored-by: Sehoon Shon <sshon@google.com> Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com> Co-authored-by: Sandy Tao <sandytao520@icloud.com> Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com> Co-authored-by: Aishanee Shah <aishaneeshah@gmail.com> Co-authored-by: gemini-cli-robot <gemini-cli-robot@google.com> Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com> Co-authored-by: Jacob Richman <jacob314@gmail.com> Co-authored-by: joshualitt <joshualitt@google.com> Co-authored-by: Jenna Inouye <jinouye@google.com>
This commit is contained in:
@@ -581,6 +581,7 @@ export async function loadCliConfig(
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
debugMode,
|
||||
question,
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
|
||||
@@ -322,6 +322,30 @@ describe('SettingsSchema', () => {
|
||||
).toBe('Enable debug logging of keystrokes to the console.');
|
||||
});
|
||||
|
||||
it('should have previewFeatures setting in schema', () => {
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures,
|
||||
).toBeDefined();
|
||||
expect(getSettingsSchema().general.properties.previewFeatures.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.category,
|
||||
).toBe('General');
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.default,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.requiresRestart,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.description,
|
||||
).toBe('Enable preview features (e.g., preview models).');
|
||||
});
|
||||
|
||||
it('should have useModelRouter setting in schema', () => {
|
||||
expect(
|
||||
getSettingsSchema().experimental.properties.useModelRouter,
|
||||
|
||||
@@ -160,6 +160,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'General application settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
previewFeatures: {
|
||||
type: 'boolean',
|
||||
label: 'Preview Features (e.g., models)',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable preview features (e.g., preview models).',
|
||||
showInDialog: true,
|
||||
},
|
||||
preferredEditor: {
|
||||
type: 'string',
|
||||
label: 'Preferred Editor',
|
||||
@@ -251,6 +260,7 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as SessionRetentionSettings | undefined,
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
|
||||
@@ -72,6 +72,10 @@ describe('App', () => {
|
||||
},
|
||||
history: [],
|
||||
pendingHistoryItems: [],
|
||||
bannerData: {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CoreEvent,
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock coreEvents
|
||||
@@ -1796,4 +1797,21 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
describe('Banner Text', () => {
|
||||
it('should render placeholder banner text for USE_GEMINI auth type', async () => {
|
||||
const config = makeFakeConfig();
|
||||
vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.USE_GEMINI,
|
||||
apiKey: 'fake-key',
|
||||
});
|
||||
const { unmount } = renderAppContainer();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(capturedUIState.bannerData.defaultText).toBeDefined();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,6 +181,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
null,
|
||||
);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
const [bannerVisible, setBannerVisible] = useState(true);
|
||||
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
// We are in the interactive CLI, update how we request consent and settings.
|
||||
extensionManager.setRequestConsent((description) =>
|
||||
@@ -596,6 +600,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
slashCommandActions,
|
||||
extensionsUpdateStateInternal,
|
||||
isConfigInitialized,
|
||||
setBannerVisible,
|
||||
setCustomDialog,
|
||||
);
|
||||
|
||||
@@ -1305,6 +1310,38 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchBannerTexts = async () => {
|
||||
const [defaultBanner, warningBanner] = await Promise.all([
|
||||
config.getBannerTextNoCapacityIssues(),
|
||||
config.getBannerTextCapacityIssues(),
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setDefaultBannerText(defaultBanner);
|
||||
setWarningBannerText(warningBanner);
|
||||
setBannerVisible(true);
|
||||
refreshStatic();
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI ||
|
||||
authType === AuthType.USE_VERTEX_AI
|
||||
) {
|
||||
setDefaultBannerText(
|
||||
'Gemini 3 is now available.\nTo use Gemini 3, enable "Preview features" in /settings\nLearn more at https://goo.gle/enable-preview-features',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchBannerTexts();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [config, refreshStatic]);
|
||||
|
||||
const uiState: UIState = useMemo(
|
||||
() => ({
|
||||
history: historyManager.history,
|
||||
@@ -1394,6 +1431,11 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
customDialog,
|
||||
copyModeEnabled,
|
||||
warningMessage,
|
||||
bannerData: {
|
||||
defaultText: defaultBannerText,
|
||||
warningText: warningBannerText,
|
||||
},
|
||||
bannerVisible,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -1482,6 +1524,9 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
authState,
|
||||
copyModeEnabled,
|
||||
warningMessage,
|
||||
defaultBannerText,
|
||||
warningBannerText,
|
||||
bannerVisible,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1519,6 +1564,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -1548,6 +1594,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -121,11 +121,12 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
|
||||
async function setIdeModeAndSyncConnection(
|
||||
config: Config,
|
||||
value: boolean,
|
||||
options: { logToConsole?: boolean } = {},
|
||||
): Promise<void> {
|
||||
config.setIdeMode(value);
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
if (value) {
|
||||
await ideClient.connect();
|
||||
await ideClient.connect(options);
|
||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION));
|
||||
} else {
|
||||
await ideClient.disconnect();
|
||||
@@ -144,7 +145,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks.`,
|
||||
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`,
|
||||
}) as const,
|
||||
};
|
||||
}
|
||||
@@ -212,7 +213,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
);
|
||||
// Poll for up to 5 seconds for the extension to activate.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await setIdeModeAndSyncConnection(context.services.config!, true);
|
||||
await setIdeModeAndSyncConnection(context.services.config!, true, {
|
||||
logToConsole: false,
|
||||
});
|
||||
if (
|
||||
ideClient.getConnectionStatus().status ===
|
||||
IDEConnectionStatus.Connected
|
||||
|
||||
@@ -12,6 +12,10 @@ import { Text } from 'ink';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../contexts/AppContext.js', () => ({
|
||||
useAppContext: () => ({
|
||||
version: '0.10.0',
|
||||
@@ -85,6 +89,11 @@ const mockConfig = {
|
||||
getTargetDir: () => '/tmp',
|
||||
getDebugMode: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getExperiments: () => ({
|
||||
flags: {},
|
||||
experimentIds: [],
|
||||
}),
|
||||
getPreviewFeatures: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
describe('AlternateBufferQuittingDisplay', () => {
|
||||
@@ -101,6 +110,10 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
renderMarkdown: false,
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
},
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
|
||||
189
packages/cli/src/ui/components/AppHeader.test.tsx
Normal file
189
packages/cli/src/ui/components/AppHeader.test.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
|
||||
const persistentStateMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/persistentState.js', () => ({
|
||||
persistentState: persistentStateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: () => null,
|
||||
}));
|
||||
|
||||
describe('<AppHeader />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
persistentStateMock.get.mockReturnValue(0);
|
||||
});
|
||||
|
||||
it('should render the banner with default text', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render the banner with warning text', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: 'There are capacity issues',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('There are capacity issues');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render the banner when no flags are set', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render the banner when previewFeatures is disabled', () => {
|
||||
const mockConfig = makeFakeConfig({ previewFeatures: false });
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render the banner when previewFeatures is enabled', () => {
|
||||
const mockConfig = makeFakeConfig({ previewFeatures: true });
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render the default banner if shown count is 5 or more', () => {
|
||||
persistentStateMock.get.mockReturnValue(5);
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should increment the shown count when default banner is displayed', () => {
|
||||
persistentStateMock.get.mockReturnValue(0);
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { unmount } = renderWithProviders(<AppHeader version="1.0.0" />, {
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
});
|
||||
|
||||
expect(persistentStateMock.set).toHaveBeenCalledWith(
|
||||
'defaultBannerShownCount',
|
||||
1,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render banner text with unescaped newlines', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: 'First line\\nSecond line',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('First line\\nSecond line');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,11 @@ import { Tips } from './Tips.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
@@ -18,12 +23,46 @@ interface AppHeaderProps {
|
||||
export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { nightly } = useUIState();
|
||||
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
|
||||
|
||||
const [defaultBannerShownCount] = useState(
|
||||
() => persistentState.get('defaultBannerShownCount') || 0,
|
||||
);
|
||||
|
||||
const { defaultText, warningText } = bannerData;
|
||||
|
||||
const showDefaultBanner =
|
||||
warningText === '' &&
|
||||
!config.getPreviewFeatures() &&
|
||||
defaultBannerShownCount < 5;
|
||||
const bannerText = showDefaultBanner ? defaultText : warningText;
|
||||
const unescapedBannerText = bannerText.replace(/\\n/g, '\n');
|
||||
|
||||
const defaultColor = Colors.AccentBlue;
|
||||
const fontColor = warningText === '' ? defaultColor : theme.status.warning;
|
||||
|
||||
const hasIncrementedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (showDefaultBanner && defaultText && !hasIncrementedRef.current) {
|
||||
hasIncrementedRef.current = true;
|
||||
const current = persistentState.get('defaultBannerShownCount') || 0;
|
||||
persistentState.set('defaultBannerShownCount', current + 1);
|
||||
}
|
||||
}, [showDefaultBanner, defaultText]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
|
||||
<Header version={version} nightly={nightly} />
|
||||
<>
|
||||
<Header version={version} nightly={nightly} />
|
||||
{bannerVisible && unescapedBannerText && (
|
||||
<Banner
|
||||
width={mainAreaWidth}
|
||||
bannerText={unescapedBannerText}
|
||||
color={fontColor}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
|
||||
<Tips config={config} />
|
||||
|
||||
33
packages/cli/src/ui/components/Banner.tsx
Normal file
33
packages/cli/src/ui/components/Banner.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
interface BannerProps {
|
||||
bannerText: string;
|
||||
color: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const Banner = ({ bannerText, color, width }: BannerProps) => {
|
||||
const gradient = theme.ui.gradient;
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={color}
|
||||
width={width}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Gradient colors={gradient}>
|
||||
<Text>{bannerText}</Text>
|
||||
</Gradient>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -157,6 +157,7 @@ export const Composer = () => {
|
||||
suggestionsWidth={uiState.suggestionsWidth}
|
||||
onSubmit={uiActions.handleFinalSubmit}
|
||||
userMessages={uiState.userMessages}
|
||||
setBannerVisible={uiActions.setBannerVisible}
|
||||
onClearScreen={uiActions.handleClearScreen}
|
||||
config={config}
|
||||
slashCommands={uiState.slashCommands || []}
|
||||
|
||||
@@ -53,8 +53,13 @@ export const DialogManager = ({
|
||||
if (uiState.proQuotaRequest) {
|
||||
return (
|
||||
<ProQuotaDialog
|
||||
failedModel={uiState.proQuotaRequest.failedModel}
|
||||
fallbackModel={uiState.proQuotaRequest.fallbackModel}
|
||||
message={uiState.proQuotaRequest.message}
|
||||
isTerminalQuotaError={uiState.proQuotaRequest.isTerminalQuotaError}
|
||||
isModelNotFoundError={!!uiState.proQuotaRequest.isModelNotFoundError}
|
||||
onChoice={uiActions.handleProQuotaChoice}
|
||||
userTier={uiState.userTier}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
editorSettingsManager,
|
||||
type EditorDisplay,
|
||||
} from '../editors/editorSettingsManager.js';
|
||||
@@ -19,8 +18,11 @@ import type {
|
||||
LoadedSettings,
|
||||
} from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { EditorType } from '@google/gemini-cli-core';
|
||||
import { isEditorAvailable } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type EditorType,
|
||||
isEditorAvailable,
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
interface EditorDialogProps {
|
||||
|
||||
@@ -235,6 +235,7 @@ describe('InputPrompt', () => {
|
||||
focus: true,
|
||||
setQueueErrorMessage: vi.fn(),
|
||||
streamingState: StreamingState.Idle,
|
||||
setBannerVisible: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -812,6 +813,19 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call setBannerVisible(false) when clear screen key is pressed', async () => {
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x0C'); // Ctrl+L
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.setBannerVisible).toHaveBeenCalledWith(false);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('cursor-based completion trigger', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface InputPromptProps {
|
||||
streamingState: StreamingState;
|
||||
popAllMessages?: (onPop: (messages: string | undefined) => void) => void;
|
||||
suggestionsPosition?: 'above' | 'below';
|
||||
setBannerVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
// The input content, input container, and input suggestions list may have different widths
|
||||
@@ -121,6 +122,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
streamingState,
|
||||
popAllMessages,
|
||||
suggestionsPosition = 'below',
|
||||
setBannerVisible,
|
||||
}) => {
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
@@ -525,6 +527,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||
setBannerVisible(false);
|
||||
onClearScreen();
|
||||
return;
|
||||
}
|
||||
@@ -819,6 +822,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
commandSearchCompletion,
|
||||
kittyProtocol.supported,
|
||||
tryLoadQueuedMessages,
|
||||
setBannerVisible,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import { render } from '../../test-utils/render.js';
|
||||
import { cleanup } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
@@ -43,6 +43,7 @@ const renderComponent = (
|
||||
// --- Functions used by ModelDialog ---
|
||||
getModel: vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO),
|
||||
setModel: vi.fn(),
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
|
||||
// --- Functions used by ClearcutLogger ---
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
@@ -86,7 +87,7 @@ describe('<ModelDialog />', () => {
|
||||
expect(lastFrame()).toContain('Select Model');
|
||||
expect(lastFrame()).toContain('(Press Esc to close)');
|
||||
expect(lastFrame()).toContain(
|
||||
'> To use a specific Gemini model on startup, use the --model flag.',
|
||||
'To use a specific Gemini model on startup, use the --model flag.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
@@ -98,15 +99,15 @@ describe('<ModelDialog />', () => {
|
||||
const props = mockedSelect.mock.calls[0][0];
|
||||
expect(props.items).toHaveLength(4);
|
||||
expect(props.items[0].value).toBe(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
expect(props.items[1].value).toBe(DEFAULT_GEMINI_MODEL);
|
||||
expect(props.items[2].value).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(props.items[3].value).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
expect(props.items[1].value).toBe(GEMINI_MODEL_ALIAS_PRO);
|
||||
expect(props.items[2].value).toBe(GEMINI_MODEL_ALIAS_FLASH);
|
||||
expect(props.items[3].value).toBe(GEMINI_MODEL_ALIAS_FLASH_LITE);
|
||||
expect(props.showNumbers).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('initializes with the model from ConfigContext', () => {
|
||||
const mockGetModel = vi.fn(() => DEFAULT_GEMINI_FLASH_MODEL);
|
||||
const mockGetModel = vi.fn(() => GEMINI_MODEL_ALIAS_FLASH);
|
||||
const { unmount } = renderComponent({}, { getModel: mockGetModel });
|
||||
|
||||
expect(mockGetModel).toHaveBeenCalled();
|
||||
@@ -157,10 +158,10 @@ describe('<ModelDialog />', () => {
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
expect(childOnSelect).toBeDefined();
|
||||
|
||||
childOnSelect(DEFAULT_GEMINI_MODEL);
|
||||
childOnSelect(GEMINI_MODEL_ALIAS_PRO);
|
||||
|
||||
// Assert against the default mock provided by renderComponent
|
||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
|
||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(GEMINI_MODEL_ALIAS_PRO);
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
@@ -209,18 +210,23 @@ describe('<ModelDialog />', () => {
|
||||
|
||||
it('updates initialIndex when config context changes', () => {
|
||||
const mockGetModel = vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO);
|
||||
const oldMockConfig = {
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const { rerender, unmount } = render(
|
||||
<ConfigContext.Provider
|
||||
value={{ getModel: mockGetModel } as unknown as Config}
|
||||
>
|
||||
<ConfigContext.Provider value={oldMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
|
||||
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
||||
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
const newMockConfig = { getModel: mockGetModel } as unknown as Config;
|
||||
mockGetModel.mockReturnValue(GEMINI_MODEL_ALIAS_FLASH_LITE);
|
||||
const newMockConfig = {
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
|
||||
rerender(
|
||||
<ConfigContext.Provider value={newMockConfig}>
|
||||
|
||||
@@ -8,10 +8,14 @@ import type React from 'react';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -19,38 +23,12 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import Gradient from 'ink-gradient';
|
||||
|
||||
interface ModelDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MODEL_OPTIONS = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: 'Auto (recommended)',
|
||||
description: 'Let the system choose the best model for your task',
|
||||
key: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: 'Pro',
|
||||
description: 'For complex tasks that require deep reasoning and creativity',
|
||||
key: DEFAULT_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: 'Flash',
|
||||
description: 'For tasks that need a balance of speed and reasoning',
|
||||
key: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: 'Flash-Lite',
|
||||
description: 'For simple tasks that need to be done quickly',
|
||||
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
},
|
||||
];
|
||||
|
||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const config = useContext(ConfigContext);
|
||||
|
||||
@@ -66,10 +44,43 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: 'Auto',
|
||||
description: 'Let the system choose the best model for your task.',
|
||||
key: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_PRO,
|
||||
title: config?.getPreviewFeatures()
|
||||
? `Pro (${PREVIEW_GEMINI_MODEL}, ${DEFAULT_GEMINI_MODEL})`
|
||||
: `Pro (${DEFAULT_GEMINI_MODEL})`,
|
||||
description:
|
||||
'For complex tasks that require deep reasoning and creativity',
|
||||
key: GEMINI_MODEL_ALIAS_PRO,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_FLASH,
|
||||
title: `Flash (${DEFAULT_GEMINI_FLASH_MODEL})`,
|
||||
description: 'For tasks that need a balance of speed and reasoning',
|
||||
key: GEMINI_MODEL_ALIAS_FLASH,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
title: `Flash-Lite (${DEFAULT_GEMINI_FLASH_LITE_MODEL})`,
|
||||
description: 'For simple tasks that need to be done quickly',
|
||||
key: GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
},
|
||||
],
|
||||
[config],
|
||||
);
|
||||
|
||||
// Calculate the initial index based on the preferred model.
|
||||
const initialIndex = useMemo(
|
||||
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
|
||||
[preferredModel],
|
||||
() => options.findIndex((option) => option.value === preferredModel),
|
||||
[preferredModel, options],
|
||||
);
|
||||
|
||||
// Handle selection internally (Autonomous Dialog).
|
||||
@@ -85,6 +96,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
[config, onClose],
|
||||
);
|
||||
|
||||
const header = config?.getPreviewFeatures()
|
||||
? 'Gemini 3 is now enabled.'
|
||||
: 'Gemini 3 is now available.';
|
||||
|
||||
const subheader = config?.getPreviewFeatures()
|
||||
? `To disable Gemini 3, disable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features\n\nWhen you select Auto or Pro, Gemini CLI will attempt to use ${PREVIEW_GEMINI_MODEL} first, before falling back to ${DEFAULT_GEMINI_MODEL}.`
|
||||
: `To use Gemini 3, enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -94,17 +113,25 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Select Model</Text>
|
||||
|
||||
<Box marginTop={1} marginBottom={1} flexDirection="column">
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>{header}</Text>
|
||||
</Gradient>
|
||||
<Text>{subheader}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={MODEL_OPTIONS}
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
initialIndex={initialIndex}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{'> To use a specific Gemini model on startup, use the --model flag.'}
|
||||
{'To use a specific Gemini model on startup, use the --model flag.'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
|
||||
@@ -10,86 +10,297 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
UserTierId,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock the child component to make it easier to test the parent
|
||||
vi.mock('./shared/RadioButtonSelect.js', () => ({
|
||||
RadioButtonSelect: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ProQuotaDialog', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with correct title and options', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ProQuotaDialog fallbackModel="gemini-2.5-flash" onChoice={() => {}} />,
|
||||
);
|
||||
describe('for flash model failures', () => {
|
||||
it('should render "Keep trying" and "Stop" options', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel={DEFAULT_GEMINI_FLASH_MODEL}
|
||||
fallbackModel="gemini-2.5-pro"
|
||||
message="flash error"
|
||||
isTerminalQuotaError={true} // should not matter
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(
|
||||
'Note: You can always use /model to select a different option.',
|
||||
);
|
||||
|
||||
// Check that RadioButtonSelect was called with the correct items
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Try again later',
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
{
|
||||
label: `Switch to gemini-2.5-flash for the rest of this session`,
|
||||
value: 'retry' as const,
|
||||
key: 'retry',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once',
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onChoice with "auth" when "Change auth" is selected', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
describe('for non-flash model failures', () => {
|
||||
describe('when it is a terminal quota error', () => {
|
||||
it('should render switch and stop options for paid tiers', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message="paid tier quota error"
|
||||
isTerminalQuotaError={true}
|
||||
isModelNotFoundError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.LEGACY}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Get the onSelect function passed to RadioButtonSelect
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Switch to gemini-2.5-flash',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
// Simulate the selection
|
||||
act(() => {
|
||||
onSelect('auth');
|
||||
it('should render switch, upgrade, and stop options for free tier', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message="free tier quota error"
|
||||
isTerminalQuotaError={true}
|
||||
isModelNotFoundError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Switch to gemini-2.5-flash',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Upgrade for higher limits',
|
||||
value: 'upgrade',
|
||||
key: 'upgrade',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('auth');
|
||||
unmount();
|
||||
describe('when it is a capacity error', () => {
|
||||
it('should render keep trying, switch, and stop options', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message="capacity error"
|
||||
isTerminalQuotaError={false}
|
||||
isModelNotFoundError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once',
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: 'Switch to gemini-2.5-flash',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{ label: 'Stop', value: 'retry_later', key: 'retry_later' },
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when it is a model not found error', () => {
|
||||
it('should render switch and stop options regardless of tier', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-3-pro-preview"
|
||||
fallbackModel="gemini-2.5-pro"
|
||||
message="You don't have access to gemini-3-pro-preview yet."
|
||||
isTerminalQuotaError={false}
|
||||
isModelNotFoundError={true}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Switch to gemini-2.5-pro',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render switch and stop options for paid tier as well', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-3-pro-preview"
|
||||
fallbackModel="gemini-2.5-pro"
|
||||
message="You don't have access to gemini-3-pro-preview yet."
|
||||
isTerminalQuotaError={false}
|
||||
isModelNotFoundError={true}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.LEGACY}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Switch to gemini-2.5-pro',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onChoice with "continue" when "Continue with flash" is selected', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
describe('onChoice handling', () => {
|
||||
it('should call onChoice with the selected value', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message=""
|
||||
isTerminalQuotaError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Get the onSelect function passed to RadioButtonSelect
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
act(() => {
|
||||
onSelect('retry_always');
|
||||
});
|
||||
|
||||
// Simulate the selection
|
||||
act(() => {
|
||||
onSelect('retry');
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('retry_always');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer note', () => {
|
||||
it('should show a special note for PREVIEW_GEMINI_MODEL', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel={PREVIEW_GEMINI_MODEL}
|
||||
fallbackModel="gemini-2.5-pro"
|
||||
message=""
|
||||
isTerminalQuotaError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(
|
||||
'Note: We will periodically retry Preview Model to see if congestion has cleared.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('retry');
|
||||
unmount();
|
||||
it('should show the default note for other models', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message=""
|
||||
isTerminalQuotaError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(
|
||||
'Note: You can always use /model to select a different option.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,43 +9,127 @@ import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
interface ProQuotaDialogProps {
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
onChoice: (choice: 'retry_later' | 'retry') => void;
|
||||
message: string;
|
||||
isTerminalQuotaError: boolean;
|
||||
isModelNotFoundError?: boolean;
|
||||
onChoice: (
|
||||
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
|
||||
) => void;
|
||||
userTier: UserTierId | undefined;
|
||||
}
|
||||
|
||||
export function ProQuotaDialog({
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
message,
|
||||
isTerminalQuotaError,
|
||||
isModelNotFoundError,
|
||||
onChoice,
|
||||
userTier,
|
||||
}: ProQuotaDialogProps): React.JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: 'Try again later',
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
{
|
||||
label: `Switch to ${fallbackModel} for the rest of this session`,
|
||||
value: 'retry' as const,
|
||||
key: 'retry',
|
||||
},
|
||||
];
|
||||
// Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
let items;
|
||||
// flash and flash lite don't have options to switch or upgrade.
|
||||
if (
|
||||
failedModel === DEFAULT_GEMINI_FLASH_MODEL ||
|
||||
failedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL
|
||||
) {
|
||||
items = [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once' as const,
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
];
|
||||
} else if (isModelNotFoundError || (isTerminalQuotaError && isPaidTier)) {
|
||||
// out of quota
|
||||
items = [
|
||||
{
|
||||
label: `Switch to ${fallbackModel}`,
|
||||
value: 'retry_always' as const,
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: `Stop`,
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
];
|
||||
} else if (isTerminalQuotaError && !isPaidTier) {
|
||||
// free user gets an option to upgrade
|
||||
items = [
|
||||
{
|
||||
label: `Switch to ${fallbackModel}`,
|
||||
value: 'retry_always' as const,
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Upgrade for higher limits',
|
||||
value: 'upgrade' as const,
|
||||
key: 'upgrade',
|
||||
},
|
||||
{
|
||||
label: `Stop`,
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// capacity error
|
||||
items = [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once' as const,
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: `Switch to ${fallbackModel}`,
|
||||
value: 'retry_always' as const,
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const handleSelect = (choice: 'retry_later' | 'retry') => {
|
||||
const handleSelect = (
|
||||
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
|
||||
) => {
|
||||
onChoice(choice);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text>{message}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={1}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<RadioButtonSelect items={items} onSelect={handleSelect} />
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>
|
||||
Note: You can always use /model to select a different option.
|
||||
{failedModel === PREVIEW_GEMINI_MODEL && !isModelNotFoundError
|
||||
? 'Note: We will periodically retry Preview Model to see if congestion has cleared.'
|
||||
: 'Note: You can always use /model to select a different option.'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -367,17 +367,17 @@ describe('SettingsDialog', () => {
|
||||
|
||||
const { stdin, unmount, lastFrame } = renderDialog(settings, onSelect);
|
||||
|
||||
// Wait for initial render and verify we're on Vim Mode (first setting)
|
||||
// Wait for initial render and verify we're on Preview Features (first setting)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
expect(lastFrame()).toContain('Preview Features (e.g., models)');
|
||||
});
|
||||
|
||||
// Navigate to Disable Auto Update setting and verify we're there
|
||||
// Navigate to Vim Mode setting and verify we're there
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Disable Auto Update');
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
});
|
||||
|
||||
// Toggle the setting
|
||||
@@ -397,10 +397,10 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['general.disableAutoUpdate']),
|
||||
new Set<string>(['general.vimMode']),
|
||||
expect.objectContaining({
|
||||
general: expect.objectContaining({
|
||||
disableAutoUpdate: true,
|
||||
vimMode: true,
|
||||
}),
|
||||
}),
|
||||
expect.any(LoadedSettings),
|
||||
@@ -571,7 +571,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Hide Window Title');
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
});
|
||||
|
||||
// Verify the dialog is rendered properly
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should not render the banner when previewFeatures is enabled 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner when previewFeatures is disabled 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ This is the default banner │
|
||||
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with default text 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ This is the default banner │
|
||||
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ There are capacity issues │
|
||||
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
@@ -6,7 +6,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
@@ -14,8 +16,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -41,7 +41,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode true* │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode true* │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
@@ -49,8 +51,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -76,7 +76,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false* │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false* │
|
||||
│ │
|
||||
│ Disable Auto Update false* │
|
||||
│ │
|
||||
@@ -84,8 +86,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -111,7 +111,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
@@ -119,8 +121,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -146,7 +146,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
@@ -154,8 +156,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -181,6 +181,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
@@ -189,8 +191,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -216,7 +216,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false* │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false* │
|
||||
│ │
|
||||
│ Disable Auto Update true* │
|
||||
│ │
|
||||
@@ -224,8 +226,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -251,7 +251,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
@@ -259,8 +261,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
@@ -286,7 +286,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode true* │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ │
|
||||
│ Vim Mode true* │
|
||||
│ │
|
||||
│ Disable Auto Update true* │
|
||||
│ │
|
||||
@@ -294,8 +296,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
|
||||
@@ -42,11 +42,14 @@ export interface UIActions {
|
||||
refreshStatic: () => void;
|
||||
handleFinalSubmit: (value: string) => void;
|
||||
handleClearScreen: () => void;
|
||||
handleProQuotaChoice: (choice: 'retry_later' | 'retry') => void;
|
||||
handleProQuotaChoice: (
|
||||
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
|
||||
) => void;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
popAllMessages: (onPop: (messages: string | undefined) => void) => void;
|
||||
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
||||
handleApiKeyCancel: () => void;
|
||||
setBannerVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -32,6 +32,9 @@ import type { UpdateObject } from '../utils/updateCheck.js';
|
||||
export interface ProQuotaDialogRequest {
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
message: string;
|
||||
isTerminalQuotaError: boolean;
|
||||
isModelNotFoundError?: boolean;
|
||||
resolve: (intent: FallbackIntent) => void;
|
||||
}
|
||||
|
||||
@@ -126,6 +129,11 @@ export interface UIState {
|
||||
showFullTodos: boolean;
|
||||
copyModeEnabled: boolean;
|
||||
warningMessage: string | null;
|
||||
bannerData: {
|
||||
defaultText: string;
|
||||
warningText: string;
|
||||
};
|
||||
bannerVisible: boolean;
|
||||
customDialog: React.ReactNode | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
allowEditorTypeInSandbox,
|
||||
checkHasEditorType,
|
||||
type EditorType,
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export interface EditorDisplay {
|
||||
@@ -16,17 +17,6 @@ export interface EditorDisplay {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
|
||||
cursor: 'Cursor',
|
||||
emacs: 'Emacs',
|
||||
neovim: 'Neovim',
|
||||
vim: 'Vim',
|
||||
vscode: 'VS Code',
|
||||
vscodium: 'VSCodium',
|
||||
windsurf: 'Windsurf',
|
||||
zed: 'Zed',
|
||||
};
|
||||
|
||||
class EditorSettingsManager {
|
||||
private readonly availableEditors: EditorDisplay[];
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
},
|
||||
new Map(), // extensionsUpdateState
|
||||
true, // isConfigInitialized
|
||||
vi.fn(), // setBannerVisible
|
||||
vi.fn(), // setCustomDialog
|
||||
),
|
||||
);
|
||||
|
||||
@@ -77,6 +77,7 @@ export const useSlashCommandProcessor = (
|
||||
actions: SlashCommandProcessorActions,
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
|
||||
isConfigInitialized: boolean,
|
||||
setBannerVisible: (visible: boolean) => void,
|
||||
setCustomDialog: (dialog: React.ReactNode | null) => void,
|
||||
) => {
|
||||
const session = useSessionStats();
|
||||
@@ -203,6 +204,7 @@ export const useSlashCommandProcessor = (
|
||||
console.clear();
|
||||
}
|
||||
refreshStatic();
|
||||
setBannerVisible(false);
|
||||
},
|
||||
loadHistory,
|
||||
setDebugMessage: actions.setDebugMessage,
|
||||
@@ -241,6 +243,7 @@ export const useSlashCommandProcessor = (
|
||||
sessionShellAllowlist,
|
||||
reloadCommands,
|
||||
extensionsUpdateState,
|
||||
setBannerVisible,
|
||||
setCustomDialog,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('useEditorSettings', () => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Editor preference set to "vscode" in User settings.',
|
||||
text: 'Editor preference set to "VS Code" in User settings.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -164,6 +164,11 @@ describe('useEditorSettings', () => {
|
||||
render(<TestComponent />);
|
||||
|
||||
const editorTypes: EditorType[] = ['cursor', 'windsurf', 'vim'];
|
||||
const displayNames: Record<string, string> = {
|
||||
cursor: 'Cursor',
|
||||
windsurf: 'Windsurf',
|
||||
vim: 'Vim',
|
||||
};
|
||||
const scope = SettingScope.User;
|
||||
|
||||
editorTypes.forEach((editorType) => {
|
||||
@@ -180,7 +185,7 @@ describe('useEditorSettings', () => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Editor preference set to "${editorType}" in User settings.`,
|
||||
text: `Editor preference set to "${displayNames[editorType]}" in User settings.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -210,7 +215,7 @@ describe('useEditorSettings', () => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Editor preference set to "vscode" in ${scope} settings.`,
|
||||
text: `Editor preference set to "VS Code" in ${scope} settings.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { EditorType } from '@google/gemini-cli-core';
|
||||
import {
|
||||
allowEditorTypeInSandbox,
|
||||
checkHasEditorType,
|
||||
getEditorDisplayName,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { SettingPaths } from '../../config/settingPaths.js';
|
||||
@@ -58,7 +59,7 @@ export const useEditorSettings = (
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Editor preference ${editorType ? `set to "${editorType}"` : 'cleared'} in ${scope} settings.`,
|
||||
text: `Editor preference ${editorType ? `set to "${getEditorDisplayName(editorType)}"` : 'cleared'} in ${scope} settings.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
makeFakeConfig,
|
||||
type GoogleApiError,
|
||||
RetryableQuotaError,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
ModelNotFoundError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useQuotaAndFallback } from './useQuotaAndFallback.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -87,18 +89,14 @@ describe('useQuotaAndFallback', () => {
|
||||
|
||||
describe('Fallback Handler Logic', () => {
|
||||
// Helper function to render the hook and extract the registered handler
|
||||
const getRegisteredHandler = (
|
||||
userTier: UserTierId = UserTierId.FREE,
|
||||
): FallbackModelHandler => {
|
||||
renderHook(
|
||||
(props) =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: props.userTier,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
{ initialProps: { userTier } },
|
||||
const getRegisteredHandler = (): FallbackModelHandler => {
|
||||
renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler;
|
||||
};
|
||||
@@ -116,65 +114,8 @@ describe('useQuotaAndFallback', () => {
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Flash Model Fallback', () => {
|
||||
it('should show a terminal quota message and stop, without offering a fallback', async () => {
|
||||
const handler = getRegisteredHandler();
|
||||
const result = await handler(
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash',
|
||||
new TerminalQuotaError('flash quota', mockGoogleApiError),
|
||||
);
|
||||
|
||||
expect(result).toBe('stop');
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
||||
.text;
|
||||
expect(message).toContain(
|
||||
'You have reached your daily gemini-2.5-flash',
|
||||
);
|
||||
expect(message).not.toContain('continue with the fallback model');
|
||||
});
|
||||
|
||||
it('should show a capacity message and stop', async () => {
|
||||
const handler = getRegisteredHandler();
|
||||
// let result: FallbackIntent | null = null;
|
||||
const result = await handler(
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash',
|
||||
new Error('capacity'),
|
||||
);
|
||||
|
||||
expect(result).toBe('stop');
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
||||
.text;
|
||||
expect(message).toContain(
|
||||
'Pardon Our Congestion! It looks like gemini-2.5-flash is very popular',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show a capacity message and stop, even when already in fallback mode', async () => {
|
||||
vi.spyOn(mockConfig, 'isInFallbackMode').mockReturnValue(true);
|
||||
const handler = getRegisteredHandler();
|
||||
const result = await handler(
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash',
|
||||
new Error('capacity'),
|
||||
);
|
||||
|
||||
expect(result).toBe('stop');
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
||||
.text;
|
||||
expect(message).toContain(
|
||||
'Pardon Our Congestion! It looks like gemini-2.5-flash is very popular',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interactive Fallback', () => {
|
||||
// Pro Quota Errors
|
||||
it('should set an interactive request and wait for user choice', async () => {
|
||||
it('should set an interactive request for a terminal quota error', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
@@ -187,31 +128,42 @@ describe('useQuotaAndFallback', () => {
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
// Call the handler but do not await it, to check the intermediate state
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
const error = new TerminalQuotaError(
|
||||
'pro quota',
|
||||
mockGoogleApiError,
|
||||
1000 * 60 * 5,
|
||||
); // 5 minutes
|
||||
await act(() => {
|
||||
promise = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new TerminalQuotaError('pro quota', mockGoogleApiError),
|
||||
);
|
||||
promise = handler('gemini-pro', 'gemini-flash', error);
|
||||
});
|
||||
|
||||
// The hook should now have a pending request for the UI to handle
|
||||
expect(result.current.proQuotaRequest).not.toBeNull();
|
||||
expect(result.current.proQuotaRequest?.failedModel).toBe('gemini-pro');
|
||||
const request = result.current.proQuotaRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.failedModel).toBe('gemini-pro');
|
||||
expect(request?.isTerminalQuotaError).toBe(true);
|
||||
|
||||
const message = request!.message;
|
||||
expect(message).toContain('Usage limit reached for gemini-pro.');
|
||||
expect(message).toContain('Access resets at'); // From getResetTimeMessage
|
||||
expect(message).toContain('/stats for usage details');
|
||||
expect(message).toContain('/auth to switch to API key.');
|
||||
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate the user choosing to continue with the fallback model
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry');
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
// The original promise from the handler should now resolve
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry');
|
||||
expect(intent).toBe('retry_always');
|
||||
|
||||
// The pending request should be cleared from the state
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle race conditions by stopping subsequent requests', async () => {
|
||||
@@ -253,120 +205,129 @@ describe('useQuotaAndFallback', () => {
|
||||
expect(result.current.proQuotaRequest).toBe(firstRequest);
|
||||
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry');
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
const intent1 = await promise1!;
|
||||
expect(intent1).toBe('retry');
|
||||
expect(intent1).toBe('retry_always');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
// Non-Quota error test cases
|
||||
// Non-TerminalQuotaError test cases
|
||||
const testCases = [
|
||||
{
|
||||
description: 'other error for FREE tier',
|
||||
tier: UserTierId.FREE,
|
||||
description: 'generic error',
|
||||
error: new Error('some error'),
|
||||
expectedMessageSnippets: [
|
||||
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
|
||||
'Please retry again later.',
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'other error for LEGACY tier',
|
||||
tier: UserTierId.LEGACY, // Paid tier
|
||||
error: new Error('some error'),
|
||||
expectedMessageSnippets: [
|
||||
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
|
||||
'Please retry again later.',
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'retryable quota error for FREE tier',
|
||||
tier: UserTierId.FREE,
|
||||
description: 'retryable quota error',
|
||||
error: new RetryableQuotaError(
|
||||
'retryable quota',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
),
|
||||
expectedMessageSnippets: [
|
||||
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
|
||||
'Please retry again later.',
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'retryable quota error for LEGACY tier',
|
||||
tier: UserTierId.LEGACY, // Paid tier
|
||||
error: new RetryableQuotaError(
|
||||
'retryable quota',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
),
|
||||
expectedMessageSnippets: [
|
||||
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
|
||||
'Please retry again later.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const {
|
||||
description,
|
||||
tier,
|
||||
error,
|
||||
expectedMessageSnippets,
|
||||
} of testCases) {
|
||||
for (const { description, error } of testCases) {
|
||||
it(`should handle ${description} correctly`, async () => {
|
||||
const { result } = renderHook(
|
||||
(props) =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: props.tier,
|
||||
setModelSwitchedFromQuotaError:
|
||||
mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
{ initialProps: { tier } },
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError:
|
||||
mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
// Call the handler but do not await it, to check the intermediate state
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
await act(() => {
|
||||
promise = handler('model-A', 'model-B', error);
|
||||
});
|
||||
|
||||
// The hook should now have a pending request for the UI to handle
|
||||
expect(result.current.proQuotaRequest).not.toBeNull();
|
||||
expect(result.current.proQuotaRequest?.failedModel).toBe('model-A');
|
||||
const request = result.current.proQuotaRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.failedModel).toBe('model-A');
|
||||
expect(request?.isTerminalQuotaError).toBe(false);
|
||||
|
||||
// Check that the correct initial message was added
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: MessageType.INFO }),
|
||||
expect.any(Number),
|
||||
// Check that the correct initial message was generated
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
const message = request!.message;
|
||||
expect(message).toContain(
|
||||
'model-A is currently experiencing high demand. We apologize and appreciate your patience.',
|
||||
);
|
||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
||||
.text;
|
||||
for (const snippet of expectedMessageSnippets) {
|
||||
expect(message).toContain(snippet);
|
||||
}
|
||||
|
||||
// Simulate the user choosing to continue with the fallback model
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry');
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
expect(mockSetModelSwitchedFromQuotaError).toHaveBeenCalledWith(true);
|
||||
// The original promise from the handler should now resolve
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry');
|
||||
expect(intent).toBe('retry_always');
|
||||
|
||||
// The pending request should be cleared from the state
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
expect(mockConfig.setQuotaErrorOccurred).toHaveBeenCalledWith(true);
|
||||
|
||||
// Check for the "Switched to fallback model" message
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain('Switched to fallback model.');
|
||||
});
|
||||
}
|
||||
|
||||
it('should handle ModelNotFoundError correctly', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
const error = new ModelNotFoundError('model not found', 404);
|
||||
|
||||
await act(() => {
|
||||
promise = handler('gemini-3-pro-preview', 'gemini-2.5-pro', error);
|
||||
});
|
||||
|
||||
// The hook should now have a pending request for the UI to handle
|
||||
const request = result.current.proQuotaRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.failedModel).toBe('gemini-3-pro-preview');
|
||||
expect(request?.isTerminalQuotaError).toBe(false);
|
||||
expect(request?.isModelNotFoundError).toBe(true);
|
||||
|
||||
const message = request!.message;
|
||||
expect(message).toBe(
|
||||
`It seems like you don't have access to Gemini 3.
|
||||
Learn more at https://goo.gle/enable-preview-features
|
||||
To disable Gemini 3, disable "Preview features" in /settings.`,
|
||||
);
|
||||
|
||||
// Simulate the user choosing to switch
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry_always');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -418,7 +379,7 @@ describe('useQuotaAndFallback', () => {
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should resolve intent to "retry" and add info message on continue', async () => {
|
||||
it('should resolve intent to "retry_always" and add info message on continue', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
@@ -430,7 +391,7 @@ describe('useQuotaAndFallback', () => {
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
// The first `addItem` call is for the initial quota error message
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
await act(() => {
|
||||
promise = handler(
|
||||
@@ -441,18 +402,53 @@ describe('useQuotaAndFallback', () => {
|
||||
});
|
||||
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry');
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry');
|
||||
expect(intent).toBe('retry_always');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
|
||||
// Check for the second "Switched to fallback model" message
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[1][0];
|
||||
// Check for the "Switched to fallback model" message
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain('Switched to fallback model.');
|
||||
});
|
||||
|
||||
it('should show a special message when falling back from the preview model', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
await act(() => {
|
||||
promise = handler(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
'gemini-flash',
|
||||
new Error('preview model failed'),
|
||||
);
|
||||
});
|
||||
|
||||
await act(() => {
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
await promise!;
|
||||
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain(
|
||||
`Switched to fallback model gemini-flash. We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
type FallbackModelHandler,
|
||||
type FallbackIntent,
|
||||
TerminalQuotaError,
|
||||
UserTierId,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
ModelNotFoundError,
|
||||
type UserTierId,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -51,56 +52,29 @@ export function useQuotaAndFallback({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
|
||||
const isFallbackModel = failedModel === DEFAULT_GEMINI_FLASH_MODEL;
|
||||
let message: string;
|
||||
|
||||
let isTerminalQuotaError = false;
|
||||
let isModelNotFoundError = false;
|
||||
if (error instanceof TerminalQuotaError) {
|
||||
isTerminalQuotaError = true;
|
||||
// Common part of the message for both tiers
|
||||
const messageLines = [
|
||||
`⚡ You have reached your daily ${failedModel} quota limit.`,
|
||||
`⚡ You can choose to authenticate with a paid API key${
|
||||
isFallbackModel ? '.' : ' or continue with the fallback model.'
|
||||
}`,
|
||||
`Usage limit reached for ${failedModel}.`,
|
||||
error.retryDelayMs ? getResetTimeMessage(error.retryDelayMs) : null,
|
||||
`/stats for usage details`,
|
||||
`/auth to switch to API key.`,
|
||||
].filter(Boolean);
|
||||
message = messageLines.join('\n');
|
||||
} else if (error instanceof ModelNotFoundError) {
|
||||
isModelNotFoundError = true;
|
||||
const messageLines = [
|
||||
`It seems like you don't have access to Gemini 3.`,
|
||||
`Learn more at https://goo.gle/enable-preview-features`,
|
||||
`To disable Gemini 3, disable "Preview features" in /settings.`,
|
||||
];
|
||||
|
||||
// Tier-specific part
|
||||
if (isPaidTier) {
|
||||
messageLines.push(
|
||||
`⚡ Increase your limits by using a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key`,
|
||||
`⚡ You can switch authentication methods by typing /auth`,
|
||||
);
|
||||
} else {
|
||||
messageLines.push(
|
||||
`⚡ Increase your limits by `,
|
||||
`⚡ - signing up for a plan with higher limits at https://goo.gle/set-up-gemini-code-assist`,
|
||||
`⚡ - or using a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key`,
|
||||
`⚡ You can switch authentication methods by typing /auth`,
|
||||
);
|
||||
}
|
||||
message = messageLines.join('\n');
|
||||
} else {
|
||||
// Capacity error
|
||||
message = [
|
||||
`🚦Pardon Our Congestion! It looks like ${failedModel} is very popular at the moment.`,
|
||||
`Please retry again later.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Add message to UI history
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
if (isFallbackModel) {
|
||||
return 'stop';
|
||||
message = `${failedModel} is currently experiencing high demand. We apologize and appreciate your patience.`;
|
||||
}
|
||||
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
@@ -117,6 +91,9 @@ export function useQuotaAndFallback({
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
resolve,
|
||||
message,
|
||||
isTerminalQuotaError,
|
||||
isModelNotFoundError,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -136,14 +113,25 @@ export function useQuotaAndFallback({
|
||||
setProQuotaRequest(null);
|
||||
isDialogPending.current = false; // Reset the flag here
|
||||
|
||||
if (choice === 'retry') {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Switched to fallback model. Tip: Press Ctrl+P (or Up Arrow) to recall your previous prompt and submit it again if you wish.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
if (choice === 'retry_always') {
|
||||
// If we were recovering from a Preview Model failure, show a specific message.
|
||||
if (proQuotaRequest.failedModel === PREVIEW_GEMINI_MODEL) {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Switched to fallback model ${proQuotaRequest.fallbackModel}. ${!proQuotaRequest.isModelNotFoundError ? `We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.` : ''}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Switched to fallback model.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[proQuotaRequest, historyManager],
|
||||
@@ -154,3 +142,15 @@ export function useQuotaAndFallback({
|
||||
handleProQuotaChoice,
|
||||
};
|
||||
}
|
||||
|
||||
function getResetTimeMessage(delayMs: number): string {
|
||||
const resetDate = new Date(Date.now() + delayMs);
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
return `Access resets at ${timeFormatter.format(resetDate)}.`;
|
||||
}
|
||||
|
||||
79
packages/cli/src/utils/persistentState.ts
Normal file
79
packages/cli/src/utils/persistentState.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Storage, debugLogger } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const STATE_FILENAME = 'state.json';
|
||||
|
||||
interface PersistentStateData {
|
||||
defaultBannerShownCount?: number;
|
||||
// Add other persistent state keys here as needed
|
||||
}
|
||||
|
||||
export class PersistentState {
|
||||
private cache: PersistentStateData | null = null;
|
||||
private filePath: string | null = null;
|
||||
|
||||
private getPath(): string {
|
||||
if (!this.filePath) {
|
||||
this.filePath = path.join(Storage.getGlobalGeminiDir(), STATE_FILENAME);
|
||||
}
|
||||
return this.filePath;
|
||||
}
|
||||
|
||||
private load(): PersistentStateData {
|
||||
if (this.cache) {
|
||||
return this.cache;
|
||||
}
|
||||
try {
|
||||
const filePath = this.getPath();
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
this.cache = JSON.parse(content);
|
||||
} else {
|
||||
this.cache = {};
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.warn('Failed to load persistent state:', error);
|
||||
// If error reading (e.g. corrupt JSON), start fresh
|
||||
this.cache = {};
|
||||
}
|
||||
return this.cache!;
|
||||
}
|
||||
|
||||
private save() {
|
||||
if (!this.cache) return;
|
||||
try {
|
||||
const filePath = this.getPath();
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(this.cache, null, 2));
|
||||
} catch (error) {
|
||||
debugLogger.warn('Failed to save persistent state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
get<K extends keyof PersistentStateData>(
|
||||
key: K,
|
||||
): PersistentStateData[K] | undefined {
|
||||
return this.load()[key];
|
||||
}
|
||||
|
||||
set<K extends keyof PersistentStateData>(
|
||||
key: K,
|
||||
value: PersistentStateData[K],
|
||||
): void {
|
||||
this.load(); // ensure loaded
|
||||
this.cache![key] = value;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
export const persistentState = new PersistentState();
|
||||
Reference in New Issue
Block a user