diff --git a/docs/cli/model.md b/docs/cli/model.md index ff965303d0..9da7dc4c4f 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -1,12 +1,13 @@ # Gemini CLI model selection (`/model` command) -Select your Gemini CLI model. The `/model` command opens a dialog where you can -configure the model used by Gemini CLI, giving you more control over your -results. +Select your Gemini CLI model. The `/model` command lets you configure the model +used by Gemini CLI, giving you more control over your results. Use **Pro** +models for complex tasks and reasoning, **Flash** models for high speed results, +or the (recommended) **Auto** setting to choose the best model for your tasks. -**Note:** The `/model` command (and the `--model` flag) does not override the -model used by sub-agents. Consequently, even when using the `/model` flag you -may see other models used in your model usage reports. +> **Note:** The `/model` command (and the `--model` flag) does not override the +> model used by sub-agents. Consequently, even when using the `/model` flag you +> may see other models used in your model usage reports. ## How to use the `/model` command @@ -16,26 +17,25 @@ Use the following command in Gemini CLI: /model ``` -Running this command will open a dialog with your model options: +Running this command will open a dialog with your options: -| Option | Description | Models | -| ------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Auto (recommended) | Let the system choose the best model for your task. | gemini-3-pro-preview (if enabled), gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite | -| Pro | For complex tasks that require deep reasoning and creativity. | gemini-3-pro-preview (if enabled), gemini-2.5-pro | -| Flash | For tasks that need a balance of speed and reasoning. | gemini-2.5-flash | -| Flash-Lite | For simple tasks that need to be done quickly. | gemini-2.5-flash-lite | +| Option | Description | Models | +| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- | +| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) | +| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash | +| Manual | Select a specific model. | Any available model. | -### Gemini 3 Pro and preview features +We recommend selecting one of the above **Auto** options. However, you can +select **Manual** to select a specific model from those available. -Note: Gemini 3 is not currently available on all account types. To learn more -about Gemini 3 access, refer to -[Gemini 3 Pro on Gemini CLI](../get-started/gemini-3.md). +### Gemini 3 and preview features -To enable Gemini 3 Pro (if available), enable -[**Preview features** by using the `settings` command](../cli/settings.md). Once -enabled, Gemini CLI will attempt to use Gemini 3 Pro when you select **Auto** or -**Pro**. Both **Auto** and **Pro** will try to use Gemini 3 Pro before falling -back to Gemini 2.5 Pro. +> **Note:** Gemini 3 is not currently available on all account types. To learn +> more about Gemini 3 access, refer to +> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md). + +To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable +[**Preview Features** by using the `settings` command](../cli/settings.md). You can also use the `--model` flag to specify a particular Gemini model on startup. For more details, refer to the @@ -46,16 +46,16 @@ Gemini CLI. ## Best practices for model selection -- **Default to Auto (recommended).** For most users, the _Auto (recommended)_ - model provides a balance between speed and performance, automatically - selecting the correct model based on the complexity of the task. Example: - Developing a web application could include a mix of complex tasks (building - architecture and scaffolding the project) and simple tasks (generating CSS). +- **Default to Auto.** For most users, the _Auto_ option model provides a + balance between speed and performance, automatically selecting the correct + model based on the complexity of the task. Example: Developing a web + application could include a mix of complex tasks (building architecture and + scaffolding the project) and simple tasks (generating CSS). - **Switch to Pro if you aren't getting the results you want.** If you think you - need your model to be a little "smarter," use Pro. Pro will provide you with - the highest levels of reasoning and creativity. Example: A complex or - multi-stage debugging task. + need your model to be a little "smarter," you can manually select Pro. Pro + will provide you with the highest levels of reasoning and creativity. Example: + A complex or multi-stage debugging task. - **Switch to Flash or Flash-Lite if you need faster results.** If you need a simple response quickly, Flash or Flash-Lite is the best option. Example: diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index ff4160128b..e3415eccfa 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -367,6 +367,12 @@ their corresponding top-level category object in your `settings.json` file. "model": "gemini-3-pro-preview" } }, + "gemini-3-flash-preview": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3-flash-preview" + } + }, "gemini-2.5-pro": { "extends": "chat-base-2.5", "modelConfig": { @@ -496,6 +502,11 @@ their corresponding top-level category object in your `settings.json` file. "model": "gemini-3-pro-preview" } }, + "chat-compression-3-flash": { + "modelConfig": { + "model": "gemini-3-flash-preview" + } + }, "chat-compression-2.5-pro": { "modelConfig": { "model": "gemini-2.5-pro" @@ -821,7 +832,7 @@ their corresponding top-level category object in your `settings.json` file. - **`experimental.codebaseInvestigatorSettings.model`** (string): - **Description:** The model to use for the Codebase Investigator agent. - - **Default:** `"pro"` + - **Default:** `"auto"` - **Requires restart:** Yes #### `hooks` diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index 6aad9d3acb..e0f6f8a1a7 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -1,56 +1,37 @@ -# Gemini 3 Pro on Gemini CLI +# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI -We’re excited to bring Gemini 3 Pro to Gemini CLI. Gemini 3 Pro is **currently -available** on Gemini CLI to all of the following subscribers: +Gemini 3 Pro and Gemini 3 Flash are now available on Gemini CLI! Currently, most +paid customers of Gemini CLI will have access to both Gemini 3 Pro and Gemini 3 +Flash, including the following subscribers: -- Google AI Ultra (except Google AI Ultra for Business). -- Google AI Pro. -- Gemini Code Assist Standard (requires +- Google AI Pro and Google AI Ultra (excluding business customers). +- Gemini Code Assist Standard and Enterprise (requires [administrative enablement](#administrator-instructions)). -- Gemini Code Assist Enterprise (requires - [administrative enablement](#administrator-instructions)). -- Paid Gemini API key holders. -- Paid Vertex API key holders. +- Paid Gemini API and Vertex API key holders. -For **everyone else**, we're gradually expanding access -[through a waitlist](https://goo.gle/geminicli-waitlist-signup). If you don't -have one of the listed subscriptions, sign up for the waitlist to access Gemini -3 Pro once approved. +For free tier users: -**Note:** Whether you’re automatically granted access or accepted from the -waitlist, you’ll still need to enable Gemini 3 Pro -[using the `/settings` command](../cli/settings.md). +- If you signed up for the waitlist, please check your email for details. We’ve + onboarded everyone who signed up to the previously available waitlist. +- If you were not on our waitlist, we’re rolling out additional access gradually + to ensure the experience remains fast and reliable. Stay tuned for more + details. -## How to join the waitlist +## How to get started with Gemini 3 on Gemini CLI -Users not automatically granted access will need to join the waitlist. Follow -these instructions to sign up: +Get started by upgrading Gemini CLI to the latest version (0.21.1): -- Install Gemini CLI. -- Authenticate using the **Login with Google** option. You’ll see a banner that - says “Gemini 3 is now available.” If you do not see this banner, update your - installation of Gemini CLI to the most recent version. -- Fill out this Google form: - [Access Gemini 3 in Gemini CLI](https://goo.gle/geminicli-waitlist-signup). - Provide the email address of the account you used to authenticate with Gemini - CLI. +```bash +npm install -g @google/gemini-cli@latest +``` -Users will be onboarded in batches, subject to availability. When you’ve been -granted access to Gemini 3 Pro, you’ll receive an acceptance email to your -submitted email address. +After you’ve confirmed your version is 0.21.1 or later: -**Note:** Please wait until you have been approved to use Gemini 3 Pro to enable -**Preview Features**. If enabled early, the CLI will fallback to Gemini 2.5 Pro. +1. Use the `/settings` command in Gemini CLI. +2. Toggle **Preview Features** to `true`. +3. Run `/model` and select **Auto (Gemini 3)**. -## How to use Gemini 3 Pro with Gemini CLI - -Once you receive your acceptance email–or if you are automatically granted -access–you still need to enable Gemini 3 Pro within Gemini CLI. - -To enable Gemini 3 Pro, use the `/settings` command in Gemini CLI and set -**Preview Features** to `true`. - -For more information, see [Gemini CLI Settings](../cli/settings.md). +For more information, see [Gemini CLI model selection](../cli/model.md). ### Usage limits and fallback @@ -68,10 +49,10 @@ There may be times when the Gemini 3 Pro model is overloaded. When that happens, Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro or fallback to Gemini 2.5 Pro. -**Note:** The **Keep trying** option uses exponential backoff, in which Gemini -CLI waits longer between each retry, when the system is busy. If the retry -doesn't happen immediately, please wait a few minutes for the request to -process. +> **Note:** The **Keep trying** option uses exponential backoff, in which Gemini +> CLI waits longer between each retry, when the system is busy. If the retry +> doesn't happen immediately, please wait a few minutes for the request to +> process. ### Model selection and routing types @@ -92,7 +73,7 @@ manage your usage limits: To learn more about selecting a model and routing, refer to [Gemini CLI Model Selection](../cli/model.md). -## How to enable Gemini 3 Pro with Gemini CLI on Gemini Code Assist +## How to enable Gemini 3 with Gemini CLI on Gemini Code Assist If you're using Gemini Code Assist Standard or Gemini Code Assist Enterprise, enabling Gemini 3 Pro on Gemini CLI requires configuring your release channels. @@ -123,7 +104,7 @@ then: - Use the `/settings` command. - Set **Preview Features** to `true`. -Restart Gemini CLI and you should have access to Gemini 3 Pro. +Restart Gemini CLI and you should have access to Gemini 3. ## Need help? diff --git a/docs/sidebar.json b/docs/sidebar.json index 5472011ed1..fe07dc123e 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -24,7 +24,7 @@ "slug": "docs/get-started" }, { - "label": "Gemini 3 Pro on Gemini CLI", + "label": "Gemini 3 on Gemini CLI", "slug": "docs/get-started/gemini-3" }, { diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0bf30ebf91..ff8be73536 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1283,7 +1283,7 @@ describe('loadCliConfig model selection', () => { argv, ); - expect(config.getModel()).toBe('auto'); + expect(config.getModel()).toBe('auto-gemini-2.5'); }); it('always prefers model from argv', async () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c266e2ecc3..255eb6f1be 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -32,6 +32,7 @@ import { loadServerHierarchicalMemory, WEB_FETCH_TOOL_NAME, getVersion, + PREVIEW_GEMINI_MODEL_AUTO, } from '@google/gemini-cli-core'; import type { Settings } from './settings.js'; @@ -569,7 +570,9 @@ export async function loadCliConfig( extraExcludes.length > 0 ? extraExcludes : undefined, ); - const defaultModel = DEFAULT_GEMINI_MODEL_AUTO; + const defaultModel = settings.general?.previewFeatures + ? PREVIEW_GEMINI_MODEL_AUTO + : DEFAULT_GEMINI_MODEL_AUTO; const resolvedModel: string = argv.model || process.env['GEMINI_MODEL'] || diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 7028c36b82..1559cbe78c 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -291,7 +291,7 @@ describe('Settings Loading and Merging', () => { theme: 'legacy-dark', vimMode: true, contextFileName: 'LEGACY_CONTEXT.md', - model: 'gemini-pro', + model: 'gemini-2.5-pro', mcpServers: { 'legacy-server-1': { command: 'npm', @@ -329,7 +329,7 @@ describe('Settings Loading and Merging', () => { fileName: 'LEGACY_CONTEXT.md', }, model: { - name: 'gemini-pro', + name: 'gemini-2.5-pro', }, mcpServers: { 'legacy-server-1': { @@ -1960,7 +1960,7 @@ describe('Settings Loading and Merging', () => { usageStatisticsEnabled: false, }, model: { - name: 'gemini-pro', + name: 'gemini-2.5-pro', }, context: { fileName: 'CONTEXT.md', @@ -1999,7 +1999,7 @@ describe('Settings Loading and Merging', () => { vimMode: true, theme: 'dark', usageStatisticsEnabled: false, - model: 'gemini-pro', + model: 'gemini-2.5-pro', contextFileName: 'CONTEXT.md', includeDirectories: ['/src'], sandbox: true, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e35da0fdc1..5b8f75af60 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -19,7 +19,7 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, - GEMINI_MODEL_ALIAS_PRO, + GEMINI_MODEL_ALIAS_AUTO, } from '@google/gemini-cli-core'; import type { CustomTheme } from '../ui/themes/theme.js'; import type { SessionRetentionSettings } from './settings.js'; @@ -1394,7 +1394,7 @@ const SETTINGS_SCHEMA = { label: 'Model', category: 'Experimental', requiresRestart: true, - default: GEMINI_MODEL_ALIAS_PRO, + default: GEMINI_MODEL_ALIAS_AUTO, description: 'The model to use for the Codebase Investigator agent.', showInDialog: false, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 894fd06568..4d9349196d 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -91,6 +91,7 @@ const mockConfig = { isTrustedFolder: () => true, getIdeMode: () => false, getEnableInteractiveShell: () => true, + getPreviewFeatures: () => false, }; const configProxy = new Proxy(mockConfig, { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index e9684434ba..1d7fce5aa9 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -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(); }); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 55ccc7438f..8b304c2342 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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', ); } } diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 8bab5962b9..ed2da93a1c 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -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( diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index d355d0521a..fd89223a7c 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -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', + }; + }, }; diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 13d007ac2e..2a054ecc4d 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -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', diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 5657d826b1..718da86f69 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -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; } } diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 6a803a39eb..358342faa1 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -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 = () => { - {model} + {getDisplayString(model, config.getPreviewFeatures())} + /model {!hideContextPercentage && ( <> {' '} diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index ebbf921348..390be01b74 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -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> = {}, - contextValue: Partial | 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( - - - , - ); +// 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('', () => { + const mockSetModel = vi.fn(); + const mockGetModel = vi.fn(); + const mockGetPreviewFeatures = vi.fn(); + const mockOnClose = vi.fn(); + const mockGetHasAccessToPreviewModel = vi.fn(); + + interface MockConfig extends Partial { + 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( + + + + + , + ); - 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( - - - , - ); + 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( - - - , - ); - - // 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.'); + }); }); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index caec692e01..5be22beda8 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -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 ( Select Model - - - {header} - - {subheader} + + {header && ( + + + {header} + + + )} + {subheader && {subheader}} diff --git a/packages/cli/src/ui/components/ProQuotaDialog.test.tsx b/packages/cli/src/ui/components/ProQuotaDialog.test.tsx index 51c3de02cd..42746678e2 100644 --- a/packages/cli/src/ui/components/ProQuotaDialog.test.tsx +++ b/packages/cli/src/ui/components/ProQuotaDialog.test.tsx @@ -33,7 +33,7 @@ describe('ProQuotaDialog', () => { const { unmount } = render( { unmount(); }); + it('should render "Keep trying" and "Stop" options when failed model and fallback model are the same', () => { + const { unmount } = render( + , + ); + + 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( { }); 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( { 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( - , - ); - - 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( - , - ); - - const output = lastFrame(); - expect(output).toContain( - 'Note: You can always use /model to select a different option.', - ); - unmount(); - }); - }); }); diff --git a/packages/cli/src/ui/components/ProQuotaDialog.tsx b/packages/cli/src/ui/components/ProQuotaDialog.tsx index cf7ae2a518..cda3937a7f 100644 --- a/packages/cli/src/ui/components/ProQuotaDialog.tsx +++ b/packages/cli/src/ui/components/ProQuotaDialog.tsx @@ -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 ( + + {parts.map((part, index) => { + if (part.startsWith('/')) { + return ( + + {part} + + ); + } + return {part}; + })} + + ); + }; + return ( - - {message} - + {renderMessage(message)} - - {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.'} - ); } diff --git a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap index 83c0fb0dba..be6f152e9f 100644 --- a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`