feat: launch Gemini 3 Flash in Gemini CLI ️ (#15196)

Co-authored-by: gemini-cli-robot <gemini-cli-robot@google.com>
Co-authored-by: joshualitt <joshualitt@google.com>
Co-authored-by: Sehoon Shon <sshon@google.com>
Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com>
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: Jenna Inouye <jinouye@google.com>
This commit is contained in:
Tommaso Sciortino
2025-12-17 09:43:21 -08:00
committed by GitHub
parent 18698d6929
commit bf90b59935
65 changed files with 1898 additions and 2060 deletions

View File

@@ -7,7 +7,11 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
import {
shortenPath,
tildeifyPath,
getDisplayString,
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import { ThemedGradient } from './ThemedGradient.js';
@@ -145,7 +149,8 @@ export const Footer: React.FC = () => {
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.accent}>
{model}
{getDisplayString(model, config.getPreviewFeatures())}
<Text color={theme.text.secondary}> /model</Text>
{!hideContextPercentage && (
<>
{' '}

View File

@@ -4,239 +4,240 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { cleanup } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
GEMINI_MODEL_ALIAS_FLASH_LITE,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_PRO,
DEFAULT_GEMINI_MODEL_AUTO,
} from '@google/gemini-cli-core';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ModelDialog } from './ModelDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
} from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
const mockedUseKeypress = vi.mocked(useKeypress);
vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({
DescriptiveRadioButtonSelect: vi.fn(() => null),
}));
const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect);
const renderComponent = (
props: Partial<React.ComponentProps<typeof ModelDialog>> = {},
contextValue: Partial<Config> | undefined = undefined,
) => {
const defaultProps = {
onClose: vi.fn(),
};
const combinedProps = { ...defaultProps, ...props };
const mockConfig = contextValue
? ({
// --- 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),
getSessionId: vi.fn(() => 'mock-session-id'),
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
getUseSmartEdit: vi.fn(() => false),
getProxy: vi.fn(() => undefined),
isInteractive: vi.fn(() => false),
getExperiments: () => {},
// --- Spread test-specific overrides ---
...contextValue,
} as Config)
: undefined;
const renderResult = render(
<ConfigContext.Provider value={mockConfig}>
<ModelDialog {...combinedProps} />
</ConfigContext.Provider>,
);
// Mock dependencies
const mockGetDisplayString = vi.fn();
const mockLogModelSlashCommand = vi.fn();
const mockModelSlashCommandEvent = vi.fn();
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
...renderResult,
props: combinedProps,
mockConfig,
...actual,
getDisplayString: (val: string) => mockGetDisplayString(val),
logModelSlashCommand: (config: Config, event: ModelSlashCommandEvent) =>
mockLogModelSlashCommand(config, event),
ModelSlashCommandEvent: class {
constructor(model: string) {
mockModelSlashCommandEvent(model);
}
},
};
};
});
describe('<ModelDialog />', () => {
const mockSetModel = vi.fn();
const mockGetModel = vi.fn();
const mockGetPreviewFeatures = vi.fn();
const mockOnClose = vi.fn();
const mockGetHasAccessToPreviewModel = vi.fn();
interface MockConfig extends Partial<Config> {
setModel: (model: string) => void;
getModel: () => string;
getPreviewFeatures: () => boolean;
getHasAccessToPreviewModel: () => boolean;
}
const mockConfig: MockConfig = {
setModel: mockSetModel,
getModel: mockGetModel,
getPreviewFeatures: mockGetPreviewFeatures,
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
};
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGetPreviewFeatures.mockReturnValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(false);
// Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => {
if (val === 'auto-gemini-2.5') return 'Auto (Gemini 2.5)';
if (val === 'auto-gemini-3') return 'Auto (Preview)';
return val;
});
});
afterEach(() => {
cleanup();
});
const renderComponent = (contextValue = mockConfig as Config) =>
render(
<KeypressProvider>
<ConfigContext.Provider value={contextValue}>
<ModelDialog onClose={mockOnClose} />
</ConfigContext.Provider>
</KeypressProvider>,
);
it('renders the title and help text', () => {
const { lastFrame, unmount } = renderComponent();
const waitForUpdate = () =>
new Promise((resolve) => setTimeout(resolve, 150));
it('renders the initial "main" view correctly', () => {
const { lastFrame } = renderComponent();
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.',
);
unmount();
expect(lastFrame()).toContain('Auto');
expect(lastFrame()).toContain('Manual');
});
it('passes all model options to DescriptiveRadioButtonSelect', () => {
const { unmount } = renderComponent();
expect(mockedSelect).toHaveBeenCalledTimes(1);
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(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('renders "main" view with preview options when preview features are enabled', () => {
mockGetPreviewFeatures.mockReturnValue(true);
mockGetHasAccessToPreviewModel.mockReturnValue(true); // Must have access
const { lastFrame } = renderComponent();
expect(lastFrame()).toContain('Auto (Preview)');
});
it('initializes with the model from ConfigContext', () => {
const mockGetModel = vi.fn(() => GEMINI_MODEL_ALIAS_FLASH);
const { unmount } = renderComponent({}, { getModel: mockGetModel });
it('switches to "manual" view when "Manual" is selected', async () => {
const { lastFrame, stdin } = renderComponent();
expect(mockGetModel).toHaveBeenCalled();
expect(mockedSelect).toHaveBeenCalledWith(
expect.objectContaining({
initialIndex: 2,
}),
undefined,
);
unmount();
// Select "Manual" (index 1)
// Press down arrow to move to "Manual"
stdin.write('\u001B[B'); // Arrow Down
await waitForUpdate();
// Press enter to select
stdin.write('\r');
await waitForUpdate();
// Should now show manual options
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_MODEL);
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('initializes with "auto" model if context is not provided', () => {
const { unmount } = renderComponent({}, undefined);
it('renders "manual" view with preview options when preview features are enabled', async () => {
mockGetPreviewFeatures.mockReturnValue(true);
mockGetHasAccessToPreviewModel.mockReturnValue(true); // Must have access
mockGetModel.mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
const { lastFrame, stdin } = renderComponent();
expect(mockedSelect).toHaveBeenCalledWith(
expect.objectContaining({
initialIndex: 0,
}),
undefined,
);
unmount();
// Select "Manual" (index 2 because Preview Auto is first, then Auto (Gemini 2.5))
// Press down enough times to ensure we reach the bottom (Manual)
stdin.write('\u001B[B'); // Arrow Down
await waitForUpdate();
stdin.write('\u001B[B'); // Arrow Down
await waitForUpdate();
// Press enter to select Manual
stdin.write('\r');
await waitForUpdate();
expect(lastFrame()).toContain(PREVIEW_GEMINI_MODEL);
});
it('initializes with "auto" model if getModel returns undefined', () => {
const mockGetModel = vi.fn(() => undefined);
// @ts-expect-error This test validates component robustness when getModel
// returns an unexpected undefined value.
const { unmount } = renderComponent({}, { getModel: mockGetModel });
it('sets model and closes when a model is selected in "main" view', async () => {
const { stdin } = renderComponent();
expect(mockGetModel).toHaveBeenCalled();
// Select "Auto" (index 0)
stdin.write('\r');
await waitForUpdate();
// When getModel returns undefined, preferredModel falls back to DEFAULT_GEMINI_MODEL_AUTO
// which has index 0, so initialIndex should be 0
expect(mockedSelect).toHaveBeenCalledWith(
expect.objectContaining({
initialIndex: 0,
}),
undefined,
);
expect(mockedSelect).toHaveBeenCalledTimes(1);
unmount();
expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL_AUTO);
expect(mockOnClose).toHaveBeenCalled();
});
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
const { props, mockConfig, unmount } = renderComponent({}, {}); // Pass empty object for contextValue
it('sets model and closes when a model is selected in "manual" view', async () => {
const { stdin } = renderComponent();
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
expect(childOnSelect).toBeDefined();
// Navigate to Manual (index 1) and select
stdin.write('\u001B[B');
await waitForUpdate();
stdin.write('\r');
await waitForUpdate();
childOnSelect(GEMINI_MODEL_ALIAS_PRO);
// Now in manual view. Default selection is first item (DEFAULT_GEMINI_MODEL)
stdin.write('\r');
await waitForUpdate();
// Assert against the default mock provided by renderComponent
expect(mockConfig?.setModel).toHaveBeenCalledWith(GEMINI_MODEL_ALIAS_PRO);
expect(props.onClose).toHaveBeenCalledTimes(1);
unmount();
expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
expect(mockOnClose).toHaveBeenCalled();
});
it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => {
const { unmount } = renderComponent();
it('closes dialog on escape in "main" view', async () => {
const { stdin } = renderComponent();
const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight;
expect(childOnHighlight).toBeUndefined();
unmount();
stdin.write('\u001B'); // Escape
await waitForUpdate();
expect(mockOnClose).toHaveBeenCalled();
});
it('calls onClose prop when "escape" key is pressed', () => {
const { props, unmount } = renderComponent();
it('goes back to "main" view on escape in "manual" view', async () => {
const { lastFrame, stdin } = renderComponent();
expect(mockedUseKeypress).toHaveBeenCalled();
// Go to manual view
stdin.write('\u001B[B');
await waitForUpdate();
stdin.write('\r');
await waitForUpdate();
const keyPressHandler = mockedUseKeypress.mock.calls[0][0];
const options = mockedUseKeypress.mock.calls[0][1];
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
expect(options).toEqual({ isActive: true });
// Press Escape
stdin.write('\u001B');
await waitForUpdate();
keyPressHandler({
name: 'escape',
ctrl: false,
meta: false,
shift: false,
paste: false,
insertable: false,
sequence: '',
expect(mockOnClose).not.toHaveBeenCalled();
// Should be back to main view (Manual option visible)
expect(lastFrame()).toContain('Manual');
});
describe('Preview Logic', () => {
it('should NOT show preview options if user has no access', () => {
mockGetHasAccessToPreviewModel.mockReturnValue(false);
mockGetPreviewFeatures.mockReturnValue(true); // Even if enabled
const { lastFrame } = renderComponent();
expect(lastFrame()).not.toContain('Auto (Preview)');
});
expect(props.onClose).toHaveBeenCalledTimes(1);
keyPressHandler({
name: 'a',
ctrl: false,
meta: false,
shift: false,
paste: false,
insertable: true,
sequence: '',
it('should NOT show preview options if user has access but preview features are disabled', () => {
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetPreviewFeatures.mockReturnValue(false);
const { lastFrame } = renderComponent();
expect(lastFrame()).not.toContain('Auto (Preview)');
});
expect(props.onClose).toHaveBeenCalledTimes(1);
unmount();
});
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={oldMockConfig}>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>,
);
it('should show preview options if user has access AND preview features are enabled', () => {
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetPreviewFeatures.mockReturnValue(true);
const { lastFrame } = renderComponent();
expect(lastFrame()).toContain('Auto (Preview)');
});
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
it('should show "Gemini 3 is now available" header if user has access but preview features disabled', () => {
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetPreviewFeatures.mockReturnValue(false);
const { lastFrame } = renderComponent();
expect(lastFrame()).toContain('Gemini 3 is now available.');
expect(lastFrame()).toContain('Enable "Preview features" in /settings');
});
mockGetModel.mockReturnValue(GEMINI_MODEL_ALIAS_FLASH_LITE);
const newMockConfig = {
getModel: mockGetModel,
getPreviewFeatures: vi.fn(() => false),
} as unknown as Config;
it('should show "Gemini 3 is coming soon" header if user has no access', () => {
mockGetHasAccessToPreviewModel.mockReturnValue(false);
mockGetPreviewFeatures.mockReturnValue(false);
const { lastFrame } = renderComponent();
expect(lastFrame()).toContain('Gemini 3 is coming soon.');
});
rerender(
<ConfigContext.Provider value={newMockConfig}>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>,
);
// Should be called at least twice: initial render + re-render after context change
expect(mockedSelect).toHaveBeenCalledTimes(2);
expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(3);
unmount();
it('should NOT show header/subheader if preview options are shown', () => {
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetPreviewFeatures.mockReturnValue(true);
const { lastFrame } = renderComponent();
expect(lastFrame()).not.toContain('Gemini 3 is now available.');
expect(lastFrame()).not.toContain('Gemini 3 is coming soon.');
});
});
});

View File

@@ -5,19 +5,19 @@
*/
import type React from 'react';
import { useCallback, useContext, useMemo } from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
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,
getDisplayString,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
@@ -31,61 +31,131 @@ interface ModelDialogProps {
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
const [view, setView] = useState<'main' | 'manual'>('main');
// Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
const shouldShowPreviewModels =
config?.getPreviewFeatures() && config.getHasAccessToPreviewModel();
const manualModelSelected = useMemo(() => {
const manualModels = [
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
];
if (manualModels.includes(preferredModel)) {
return preferredModel;
}
return '';
}, [preferredModel]);
useKeypress(
(key) => {
if (key.name === 'escape') {
onClose();
if (view === 'manual') {
setView('main');
} else {
onClose();
}
}
},
{ isActive: true },
);
const options = useMemo(
() => [
const mainOptions = useMemo(() => {
const list = [
{
value: DEFAULT_GEMINI_MODEL_AUTO,
title: 'Auto',
description: 'Let the system choose the best model for your task.',
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
description:
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
key: DEFAULT_GEMINI_MODEL_AUTO,
},
{
value: GEMINI_MODEL_ALIAS_PRO,
title: config?.getPreviewFeatures()
? `Pro (${PREVIEW_GEMINI_MODEL}, ${DEFAULT_GEMINI_MODEL})`
: `Pro (${DEFAULT_GEMINI_MODEL})`,
value: 'Manual',
title: manualModelSelected
? `Manual (${manualModelSelected})`
: 'Manual',
description: 'Manually select a model',
key: 'Manual',
},
];
if (shouldShowPreviewModels) {
list.unshift({
value: PREVIEW_GEMINI_MODEL_AUTO,
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
description:
'For complex tasks that require deep reasoning and creativity',
key: GEMINI_MODEL_ALIAS_PRO,
'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
key: PREVIEW_GEMINI_MODEL_AUTO,
});
}
return list;
}, [shouldShowPreviewModels, manualModelSelected]);
const manualOptions = useMemo(() => {
const list = [
{
value: DEFAULT_GEMINI_MODEL,
title: DEFAULT_GEMINI_MODEL,
key: DEFAULT_GEMINI_MODEL,
},
{
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: DEFAULT_GEMINI_FLASH_MODEL,
title: DEFAULT_GEMINI_FLASH_MODEL,
key: DEFAULT_GEMINI_FLASH_MODEL,
},
{
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,
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: DEFAULT_GEMINI_FLASH_LITE_MODEL,
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
},
],
[config],
);
];
if (shouldShowPreviewModels) {
list.unshift(
{
value: PREVIEW_GEMINI_MODEL,
title: PREVIEW_GEMINI_MODEL,
key: PREVIEW_GEMINI_MODEL,
},
{
value: PREVIEW_GEMINI_FLASH_MODEL,
title: PREVIEW_GEMINI_FLASH_MODEL,
key: PREVIEW_GEMINI_FLASH_MODEL,
},
);
}
return list;
}, [shouldShowPreviewModels]);
const options = view === 'main' ? mainOptions : manualOptions;
// Calculate the initial index based on the preferred model.
const initialIndex = useMemo(
() => options.findIndex((option) => option.value === preferredModel),
[preferredModel, options],
);
const initialIndex = useMemo(() => {
const idx = options.findIndex((option) => option.value === preferredModel);
if (idx !== -1) {
return idx;
}
if (view === 'main') {
const manualIdx = options.findIndex((o) => o.value === 'Manual');
return manualIdx !== -1 ? manualIdx : 0;
}
return 0;
}, [preferredModel, options, view]);
// Handle selection internally (Autonomous Dialog).
const handleSelect = useCallback(
(model: string) => {
if (model === 'Manual') {
setView('manual');
return;
}
if (config) {
config.setModel(model);
const event = new ModelSlashCommandEvent(model);
@@ -96,13 +166,23 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
[config, onClose],
);
const header = config?.getPreviewFeatures()
? 'Gemini 3 is now enabled.'
: 'Gemini 3 is now available.';
let header;
let subheader;
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`;
// Do not show any header or subheader since it's already showing preview model
// options
if (shouldShowPreviewModels) {
header = undefined;
subheader = undefined;
// When a user has the access but has not enabled the preview features.
} else if (config?.getHasAccessToPreviewModel()) {
header = 'Gemini 3 is now available.';
subheader =
'Enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features';
} else {
header = 'Gemini 3 is coming soon.';
subheader = undefined;
}
return (
<Box
@@ -114,11 +194,15 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
>
<Text bold>Select Model</Text>
<Box marginTop={1} marginBottom={1} flexDirection="column">
<ThemedGradient>
<Text>{header}</Text>
</ThemedGradient>
<Text>{subheader}</Text>
<Box flexDirection="column">
{header && (
<Box marginTop={1}>
<ThemedGradient>
<Text>{header}</Text>
</ThemedGradient>
</Box>
)}
{subheader && <Text>{subheader}</Text>}
</Box>
<Box marginTop={1}>

View File

@@ -33,7 +33,7 @@ describe('ProQuotaDialog', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel={DEFAULT_GEMINI_FLASH_MODEL}
fallbackModel="gemini-2.5-pro"
fallbackModel={DEFAULT_GEMINI_FLASH_MODEL}
message="flash error"
isTerminalQuotaError={true} // should not matter
onChoice={mockOnChoice}
@@ -97,6 +97,38 @@ describe('ProQuotaDialog', () => {
unmount();
});
it('should render "Keep trying" and "Stop" options when failed model and fallback model are the same', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel={PREVIEW_GEMINI_MODEL}
fallbackModel={PREVIEW_GEMINI_MODEL}
message="flash error"
isTerminalQuotaError={true}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
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 render switch, upgrade, and stop options for free tier', () => {
const { unmount } = render(
<ProQuotaDialog
@@ -137,7 +169,7 @@ describe('ProQuotaDialog', () => {
});
describe('when it is a capacity error', () => {
it('should render keep trying, switch, and stop options', () => {
it('should render keep trying and stop options', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
@@ -158,11 +190,6 @@ describe('ProQuotaDialog', () => {
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' },
],
}),
@@ -263,44 +290,4 @@ describe('ProQuotaDialog', () => {
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();
});
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,12 +9,7 @@ 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,
DEFAULT_GEMINI_MODEL,
UserTierId,
} from '@google/gemini-cli-core';
import { UserTierId } from '@google/gemini-cli-core';
interface ProQuotaDialogProps {
failedModel: string;
@@ -41,11 +36,8 @@ export function ProQuotaDialog({
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
) {
// Do not provide a fallback option if failed model and fallbackmodel are same.
if (failedModel === fallbackModel) {
items = [
{
label: 'Keep trying',
@@ -99,11 +91,6 @@ export function ProQuotaDialog({
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,
@@ -118,19 +105,31 @@ export function ProQuotaDialog({
onChoice(choice);
};
// Helper to highlight simple slash commands in the message
const renderMessage = (msg: string) => {
const parts = msg.split(/(\s+)/);
return (
<Text>
{parts.map((part, index) => {
if (part.startsWith('/')) {
return (
<Text key={index} bold color={theme.text.accent}>
{part}
</Text>
);
}
return <Text key={index}>{part}</Text>;
})}
</Text>
);
};
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text>{message}</Text>
</Box>
<Box marginBottom={1}>{renderMessage(message)}</Box>
<Box marginTop={1} marginBottom={1}>
<RadioButtonSelect items={items} onSelect={handleSelect} />
</Box>
<Text color={theme.text.primary}>
{fallbackModel === DEFAULT_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

@@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro (100%)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox Manual (gemini-pro) /model (100%)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs) gemini-pro (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs) Manual (gemini-pro) /model (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;

View File

@@ -12,7 +12,7 @@ import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
title: string;
description: string;
description?: string;
}
export interface DescriptiveRadioButtonSelectProps<T> {
@@ -62,7 +62,9 @@ export function DescriptiveRadioButtonSelect<T>({
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" key={item.key}>
<Text color={titleColor}>{item.title}</Text>
<Text color={theme.text.secondary}>{item.description}</Text>
{item.description && (
<Text color={theme.text.secondary}>{item.description}</Text>
)}
</Box>
)}
/>