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:
Shreya Keshive
2025-11-18 12:01:16 -05:00
committed by GitHub
parent 78075c8a37
commit 86828bb561
79 changed files with 3148 additions and 605 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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',

View File

@@ -72,6 +72,10 @@ describe('App', () => {
},
history: [],
pendingHistoryItems: [],
bannerData: {
defaultText: 'Mock Banner Text',
warningText: '',
},
};
const mockConfig = makeFakeConfig();

View File

@@ -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();
});
});
});
});

View File

@@ -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,
],
);

View File

@@ -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

View File

@@ -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,
},

View 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();
});
});

View File

@@ -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} />

View 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>
);
};

View File

@@ -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 || []}

View File

@@ -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}
/>
);
}

View File

@@ -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 {

View File

@@ -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([
{

View File

@@ -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,
],
);

View File

@@ -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}>

View File

@@ -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">

View File

@@ -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();
});
});
});

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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."
`;

View File

@@ -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 │

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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[];

View File

@@ -201,6 +201,7 @@ describe('useSlashCommandProcessor', () => {
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
vi.fn(), // setBannerVisible
vi.fn(), // setCustomDialog
),
);

View File

@@ -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,
],
);

View File

@@ -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),
);

View File

@@ -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(),
);

View File

@@ -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.`,
);
});
});
});

View File

@@ -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)}.`;
}

View 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();