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
+184 -57
View File
@@ -17,7 +17,7 @@ import {
import { render } from '../test-utils/render.js';
import { waitFor } from '../test-utils/async.js';
import { cleanup } from 'ink-testing-library';
import { act, useContext } from 'react';
import { act, useContext, type ReactElement } from 'react';
import { AppContainer } from './AppContainer.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import {
@@ -71,6 +71,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
})),
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
FileDiscoveryService: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
})),
startupProfiler: {
flush: vi.fn(),
start: vi.fn(),
end: vi.fn(),
},
};
});
import ansiEscapes from 'ansi-escapes';
@@ -344,7 +352,7 @@ describe('AppContainer State Management', () => {
// Add other properties if AppContainer uses them
});
mockedUseLogger.mockReturnValue({
getPreviousUserMessages: vi.fn().mockReturnValue(new Promise(() => {})),
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
});
mockedUseInputHistoryStore.mockReturnValue({
inputHistory: [],
@@ -361,6 +369,8 @@ describe('AppContainer State Management', () => {
// Mock config's getTargetDir to return consistent workspace directory
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(mockConfig, 'getDebugMode').mockReturnValue(false);
mockExtensionManager = vi.mockObject({
getExtensions: vi.fn().mockReturnValue([]),
@@ -403,17 +413,25 @@ describe('AppContainer State Management', () => {
describe('Basic Rendering', () => {
it('renders without crashing with minimal props', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
it('renders with startup warnings', async () => {
const startupWarnings = ['Warning 1', 'Warning 2'];
const { unmount } = renderAppContainer({ startupWarnings });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ startupWarnings });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
});
@@ -424,11 +442,15 @@ describe('AppContainer State Management', () => {
themeError: 'Failed to load theme',
};
const { unmount } = renderAppContainer({
initResult: initResultWithError,
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
initResult: initResultWithError,
});
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
it('handles debug mode state', () => {
@@ -443,29 +465,45 @@ describe('AppContainer State Management', () => {
describe('Context Providers', () => {
it('provides AppContext with correct values', async () => {
const { unmount } = renderAppContainer({ version: '2.0.0' });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ version: '2.0.0' });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Should render and unmount cleanly
expect(() => unmount()).not.toThrow();
expect(() => unmount!()).not.toThrow();
});
it('provides UIStateContext with state management', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
it('provides UIActionsContext with action handlers', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
it('provides ConfigContext with config object', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
});
@@ -480,9 +518,13 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
const { unmount } = renderAppContainer({ settings: settingsAllHidden });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: settingsAllHidden });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
it('handles settings with memory usage enabled', async () => {
@@ -495,9 +537,13 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
const { unmount } = renderAppContainer({ settings: settingsWithMemory });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: settingsWithMemory });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
});
@@ -505,9 +551,13 @@ describe('AppContainer State Management', () => {
it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])(
'handles version format: %s',
async (version) => {
const { unmount } = renderAppContainer({ version });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ version });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
},
);
});
@@ -529,9 +579,13 @@ describe('AppContainer State Management', () => {
merged: {},
} as LoadedSettings;
const { unmount } = renderAppContainer({ settings: undefinedSettings });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: undefinedSettings });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
});
@@ -860,12 +914,16 @@ describe('AppContainer State Management', () => {
describe('Quota and Fallback Integration', () => {
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
// The default mock from beforeEach already sets proQuotaRequest to null
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
// Assert that the context value is as expected
expect(capturedUIState.proQuotaRequest).toBeNull();
});
unmount();
unmount!();
});
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => {
@@ -881,12 +939,16 @@ describe('AppContainer State Management', () => {
});
// Act: Render the container
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
// Assert: The mock request is correctly passed through the context
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
});
unmount();
unmount!();
});
it('passes the handleProQuotaChoice function to UIActionsContext', async () => {
@@ -898,7 +960,11 @@ describe('AppContainer State Management', () => {
});
// Act: Render the container
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
// Assert: The action in the context is the mock handler we provided
expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler);
@@ -909,7 +975,7 @@ describe('AppContainer State Management', () => {
capturedUIActions.handleProQuotaChoice('retry_later');
});
expect(mockHandler).toHaveBeenCalledWith('retry_later');
unmount();
unmount!();
});
});
@@ -1327,13 +1393,17 @@ describe('AppContainer State Management', () => {
activePtyId: 'some-id',
});
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
const lastCall =
resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1];
// Check the height argument specifically
expect(lastCall[2]).toBe(1);
unmount();
unmount!();
});
});
@@ -1672,11 +1742,15 @@ describe('AppContainer State Management', () => {
closeModelDialog: vi.fn(),
});
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(capturedUIState.isModelDialogOpen).toBe(true);
unmount();
unmount!();
});
it('should provide model dialog actions in the UIActionsContext', async () => {
@@ -1688,7 +1762,11 @@ describe('AppContainer State Management', () => {
closeModelDialog: mockCloseModelDialog,
});
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Verify that the actions are correctly passed through context
@@ -1696,13 +1774,17 @@ describe('AppContainer State Management', () => {
capturedUIActions.closeModelDialog();
});
expect(mockCloseModelDialog).toHaveBeenCalled();
unmount();
unmount!();
});
});
describe('CoreEvents Integration', () => {
it('subscribes to UserFeedback and drains backlog on mount', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(mockCoreEvents.on).toHaveBeenCalledWith(
@@ -1710,14 +1792,18 @@ describe('AppContainer State Management', () => {
expect.any(Function),
);
expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1);
unmount();
unmount!();
});
it('unsubscribes from UserFeedback on unmount', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
expect(mockCoreEvents.off).toHaveBeenCalledWith(
CoreEvent.UserFeedback,
@@ -1726,7 +1812,11 @@ describe('AppContainer State Management', () => {
});
it('adds history item when UserFeedback event is received', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Get the registered handler
@@ -1751,14 +1841,18 @@ describe('AppContainer State Management', () => {
}),
expect.any(Number),
);
unmount();
unmount!();
});
it('updates currentModel when ModelChanged event is received', async () => {
// Arrange: Mock initial model
vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model');
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState?.currentModel).toBe('initial-model');
});
@@ -1770,13 +1864,15 @@ describe('AppContainer State Management', () => {
expect(handler).toBeDefined();
// Act: Simulate ModelChanged event
// Update config mock to return new model since the handler reads from config
vi.spyOn(mockConfig, 'getModel').mockReturnValue('new-model');
act(() => {
handler({ model: 'new-model' });
});
// Assert: Verify model is updated
expect(capturedUIState.currentModel).toBe('new-model');
unmount();
unmount!();
});
});
@@ -1799,10 +1895,14 @@ describe('AppContainer State Management', () => {
});
// The main assertion is that the render does not throw.
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
unmount();
unmount!();
});
});
describe('Banner Text', () => {
@@ -1812,10 +1912,14 @@ describe('AppContainer State Management', () => {
authType: AuthType.USE_GEMINI,
apiKey: 'fake-key',
});
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState.bannerData.defaultText).toBeDefined();
unmount();
unmount!();
});
});
});
@@ -1838,7 +1942,11 @@ describe('AppContainer State Management', () => {
});
it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
const { onCancelSubmit } = extractUseGeminiStreamArgs(
@@ -1851,7 +1959,7 @@ describe('AppContainer State Management', () => {
expect(mockSetText).toHaveBeenCalledWith('');
unmount();
unmount!();
});
it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => {
@@ -1862,7 +1970,11 @@ describe('AppContainer State Management', () => {
initializeFromLogger: vi.fn(),
});
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() =>
expect(capturedUIState.userMessages).toContain('previous message'),
);
@@ -1877,7 +1989,7 @@ describe('AppContainer State Management', () => {
expect(mockSetText).toHaveBeenCalledWith('previous message');
unmount();
unmount!();
});
it('input history is independent from conversation history (survives /clear)', async () => {
@@ -1890,7 +2002,13 @@ describe('AppContainer State Management', () => {
initializeFromLogger: vi.fn(),
});
const { unmount } = renderAppContainer();
let rerender: (tree: ReactElement) => void;
let unmount;
await act(async () => {
const result = renderAppContainer();
rerender = result.rerender;
unmount = result.unmount;
});
// Verify userMessages is populated from inputHistory
await waitFor(() =>
@@ -1908,12 +2026,17 @@ describe('AppContainer State Management', () => {
loadHistory: vi.fn(),
});
await act(async () => {
// Rerender to apply the new mock.
rerender(getAppContainer());
});
// Verify that userMessages still contains the input history
// (it should not be affected by clearing conversation history)
expect(capturedUIState.userMessages).toContain('first prompt');
expect(capturedUIState.userMessages).toContain('second prompt');
unmount();
unmount!();
});
});
@@ -1928,7 +2051,11 @@ describe('AppContainer State Management', () => {
// Clear previous calls
mocks.mockStdout.write.mockClear();
const { unmount } = renderAppContainer();
let compUnmount: () => void = () => {};
await act(async () => {
const { unmount } = renderAppContainer();
compUnmount = unmount;
});
// Allow async effects to run
await waitFor(() => expect(capturedUIState).toBeTruthy());
@@ -1944,7 +2071,7 @@ describe('AppContainer State Management', () => {
);
expect(clearTerminalCalls).toHaveLength(0);
unmount();
compUnmount();
});
});
});
+5 -19
View File
@@ -35,7 +35,6 @@ import {
type IdeContext,
type UserTierId,
type UserFeedbackPayload,
DEFAULT_GEMINI_FLASH_MODEL,
IdeClient,
ideContextStore,
getErrorMessage,
@@ -50,7 +49,6 @@ import {
coreEvents,
CoreEvent,
refreshServerHierarchicalMemory,
type ModelChangedPayload,
type MemoryChangedPayload,
writeToStdout,
disableMouseEvents,
@@ -256,12 +254,7 @@ export const AppContainer = (props: AppContainerProps) => {
);
// Helper to determine the effective model, considering the fallback state.
const getEffectiveModel = useCallback(() => {
if (config.isInFallbackMode()) {
return DEFAULT_GEMINI_FLASH_MODEL;
}
return config.getModel();
}, [config]);
const getEffectiveModel = useCallback(() => config.getModel(), [config]);
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
@@ -340,22 +333,15 @@ export const AppContainer = (props: AppContainerProps) => {
// Subscribe to fallback mode and model changes from core
useEffect(() => {
const handleFallbackModeChanged = () => {
const effectiveModel = getEffectiveModel();
setCurrentModel(effectiveModel);
const handleModelChanged = () => {
setCurrentModel(config.getModel());
};
const handleModelChanged = (payload: ModelChangedPayload) => {
setCurrentModel(payload.model);
};
coreEvents.on(CoreEvent.FallbackModeChanged, handleFallbackModeChanged);
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
return () => {
coreEvents.off(CoreEvent.FallbackModeChanged, handleFallbackModeChanged);
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
};
}, [getEffectiveModel]);
}, [getEffectiveModel, config]);
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
@@ -1438,7 +1424,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
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',
'Gemini 3 Flash and Pro are now available. \nEnable "Preview features" in /settings. \nLearn more at https://goo.gle/enable-preview-features',
);
}
}
@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { modelCommand } from './modelCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Config } from '@google/gemini-cli-core';
describe('modelCommand', () => {
let mockContext: CommandContext;
@@ -29,6 +30,21 @@ describe('modelCommand', () => {
});
});
it('should call refreshUserQuota if config is available', async () => {
if (!modelCommand.action) {
throw new Error('The model command must have an action.');
}
const mockRefreshUserQuota = vi.fn();
mockContext.services.config = {
refreshUserQuota: mockRefreshUserQuota,
} as unknown as Config;
await modelCommand.action(mockContext, '');
expect(mockRefreshUserQuota).toHaveBeenCalled();
});
it('should have the correct name and description', () => {
expect(modelCommand.name).toBe('model');
expect(modelCommand.description).toBe(
+14 -5
View File
@@ -4,15 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
import {
type CommandContext,
CommandKind,
type SlashCommand,
} from './types.js';
export const modelCommand: SlashCommand = {
name: 'model',
description: 'Opens a dialog to configure the model',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async () => ({
type: 'dialog',
dialog: 'model',
}),
action: async (context: CommandContext) => {
if (context.services.config) {
await context.services.config.refreshUserQuota();
}
return {
type: 'dialog',
dialog: 'model',
};
},
};
@@ -10,6 +10,7 @@ import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { formatDuration } from '../utils/formatters.js';
import type { Config } from '@google/gemini-cli-core';
describe('statsCommand', () => {
let mockContext: CommandContext;
@@ -45,6 +46,26 @@ describe('statsCommand', () => {
);
});
it('should fetch and display quota if config is available', async () => {
if (!statsCommand.action) throw new Error('Command has no action');
const mockQuota = { buckets: [] };
const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota);
mockContext.services.config = {
refreshUserQuota: mockRefreshUserQuota,
} as unknown as Config;
await statsCommand.action(mockContext, '');
expect(mockRefreshUserQuota).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
quotas: mockQuota,
}),
expect.any(Number),
);
});
it('should display model stats when using the "model" subcommand', () => {
const modelSubCommand = statsCommand.subCommands?.find(
(sc) => sc.name === 'model',
+2 -6
View File
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CodeAssistServer, getCodeAssistServer } from '@google/gemini-cli-core';
import type { HistoryItemStats } from '../types.js';
import { MessageType } from '../types.js';
import { formatDuration } from '../utils/formatters.js';
@@ -35,11 +34,8 @@ async function defaultSessionView(context: CommandContext) {
};
if (context.services.config) {
const server = getCodeAssistServer(context.services.config);
if (server instanceof CodeAssistServer && server.projectId) {
const quota = await server.retrieveUserQuota({
project: server.projectId,
});
const quota = await context.services.config.refreshUserQuota();
if (quota) {
statsItem.quotas = quota;
}
}
+7 -2
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 && (
<>
{' '}
@@ -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.');
});
});
});
+125 -41
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}>
@@ -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();
});
});
});
@@ -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>
);
}
@@ -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)"`;
@@ -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>
)}
/>
@@ -261,7 +261,7 @@ describe('useQuotaAndFallback', () => {
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.',
'We are currently experiencing high demand.',
);
// Simulate the user choosing to continue with the fallback model
@@ -283,7 +283,7 @@ describe('useQuotaAndFallback', () => {
const lastCall = (mockHistoryManager.addItem as Mock).mock
.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain('Switched to fallback model.');
expect(lastCall.text).toContain('Switched to fallback model model-B');
});
}
@@ -316,9 +316,9 @@ describe('useQuotaAndFallback', () => {
const message = request!.message;
expect(message).toBe(
`It seems like you don't have access to Gemini 3.
`It seems like you don't have access to gemini-3-pro-preview.
Learn more at https://goo.gle/enable-preview-features
To disable Gemini 3, disable "Preview features" in /settings.`,
To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
);
// Simulate the user choosing to switch
@@ -415,7 +415,9 @@ To disable Gemini 3, disable "Preview features" in /settings.`,
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.');
expect(lastCall.text).toContain(
'Switched to fallback model gemini-flash',
);
});
it('should show a special message when falling back from the preview model', async () => {
@@ -449,7 +451,7 @@ To disable Gemini 3, disable "Preview features" in /settings.`,
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-2.5-pro. We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.`,
`Switched to fallback model gemini-2.5-pro`,
);
});
@@ -484,7 +486,7 @@ To disable Gemini 3, disable "Preview features" in /settings.`,
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-2.5-flash.`,
`Switched to fallback model gemini-2.5-flash`,
);
});
});
@@ -14,6 +14,7 @@ import {
type UserTierId,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
VALID_GEMINI_MODELS,
} from '@google/gemini-cli-core';
import { useCallback, useEffect, useRef, useState } from 'react';
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -68,19 +69,28 @@ export function useQuotaAndFallback({
`Usage limit reached for ${usageLimitReachedModel}.`,
error.retryDelayMs ? getResetTimeMessage(error.retryDelayMs) : null,
`/stats for usage details`,
`/model to switch models.`,
`/auth to switch to API key.`,
].filter(Boolean);
message = messageLines.join('\n');
} else if (error instanceof ModelNotFoundError) {
} else if (
error instanceof ModelNotFoundError &&
VALID_GEMINI_MODELS.has(failedModel)
) {
isModelNotFoundError = true;
const messageLines = [
`It seems like you don't have access to Gemini 3.`,
`It seems like you don't have access to ${failedModel}.`,
`Learn more at https://goo.gle/enable-preview-features`,
`To disable Gemini 3, disable "Preview features" in /settings.`,
`To disable ${failedModel}, disable "Preview features" in /settings.`,
];
message = messageLines.join('\n');
} else {
message = `${failedModel} is currently experiencing high demand. We apologize and appreciate your patience.`;
const messageLines = [
`We are currently experiencing high demand.`,
'We apologize and appreciate your patience.',
'/model to switch models.',
];
message = messageLines.join('\n');
}
setModelSwitchedFromQuotaError(true);
@@ -120,30 +130,20 @@ export function useQuotaAndFallback({
isDialogPending.current = false; // Reset the flag here
if (choice === 'retry_always') {
// If we were recovering from a Preview Model failure, show a specific message.
if (proQuotaRequest.failedModel === PREVIEW_GEMINI_MODEL) {
const showPeriodicalCheckMessage =
!proQuotaRequest.isModelNotFoundError &&
proQuotaRequest.fallbackModel === DEFAULT_GEMINI_MODEL;
historyManager.addItem(
{
type: MessageType.INFO,
text: `Switched to fallback model ${proQuotaRequest.fallbackModel}. ${showPeriodicalCheckMessage ? `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(),
);
}
// Explicitly set the model to the fallback model to persist the user's choice.
// This ensures the Footer updates and future turns use this model.
config.setModel(proQuotaRequest.fallbackModel);
historyManager.addItem(
{
type: MessageType.INFO,
text: `Switched to fallback model ${proQuotaRequest.fallbackModel}`,
},
Date.now(),
);
}
},
[proQuotaRequest, historyManager],
[proQuotaRequest, historyManager, config],
);
return {