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 fce4d5dd6f
commit 8d7e4e70ff
79 changed files with 3148 additions and 605 deletions
@@ -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,
},
@@ -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();
});
});
+41 -2
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} />
+33
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>
);
};
@@ -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}>
+61 -34
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">
@@ -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();
});
});
});
+105 -21
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>
);
@@ -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 │