mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-10 10:00:53 -07:00
Merge branch 'main' into gemini-cli-startup
This commit is contained in:
@@ -1807,7 +1807,7 @@ describe('loadCliConfig model selection', () => {
|
||||
});
|
||||
|
||||
it('always prefers model from argv', async () => {
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview'];
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings({
|
||||
@@ -1819,11 +1819,11 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-2.5-flash-preview');
|
||||
expect(config.getModel()).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('selects the model from argv if provided', async () => {
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview'];
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings({
|
||||
@@ -1833,7 +1833,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-2.5-flash-preview');
|
||||
expect(config.getModel()).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('selects the default auto model if provided via auto alias', async () => {
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
type HierarchicalMemory,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
isValidModelOrAlias,
|
||||
getValidModelsAndAliases,
|
||||
getAdminErrorMessage,
|
||||
isHeadlessMode,
|
||||
Config,
|
||||
@@ -696,6 +698,18 @@ export async function loadCliConfig(
|
||||
const specifiedModel =
|
||||
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
|
||||
|
||||
// Validate the model if one was explicitly specified
|
||||
if (specifiedModel && specifiedModel !== GEMINI_MODEL_ALIAS_AUTO) {
|
||||
if (!isValidModelOrAlias(specifiedModel)) {
|
||||
const validModels = getValidModelsAndAliases();
|
||||
|
||||
throw new FatalConfigError(
|
||||
`Invalid model: "${specifiedModel}"\n\n` +
|
||||
`Valid models and aliases:\n${validModels.map((m) => ` - ${m}`).join('\n')}\n\n` +
|
||||
`Use /model to switch models interactively.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const resolvedModel =
|
||||
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
|
||||
? defaultModel
|
||||
|
||||
@@ -12,12 +12,13 @@ import { ExtensionManager } from './extension-manager.js';
|
||||
import { createTestMergedSettings, type MergedSettings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
import { themeManager } from '../ui/themes/theme-manager.js';
|
||||
import {
|
||||
TrustLevel,
|
||||
loadTrustedFolders,
|
||||
isWorkspaceTrusted,
|
||||
} from './trustedFolders.js';
|
||||
import { getRealPath } from '@google/gemini-cli-core';
|
||||
import { getRealPath, type CustomTheme } from '@google/gemini-cli-core';
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
|
||||
|
||||
@@ -38,6 +39,26 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const testTheme: CustomTheme = {
|
||||
type: 'custom',
|
||||
name: 'MyTheme',
|
||||
background: {
|
||||
primary: '#282828',
|
||||
diff: { added: '#2b3312', removed: '#341212' },
|
||||
},
|
||||
text: {
|
||||
primary: '#ebdbb2',
|
||||
secondary: '#a89984',
|
||||
link: '#83a598',
|
||||
accent: '#d3869b',
|
||||
},
|
||||
status: {
|
||||
success: '#b8bb26',
|
||||
warning: '#fabd2f',
|
||||
error: '#fb4934',
|
||||
},
|
||||
};
|
||||
|
||||
describe('ExtensionManager', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
@@ -65,6 +86,7 @@ describe('ExtensionManager', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
themeManager.clearExtensionThemes();
|
||||
try {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
} catch (_e) {
|
||||
@@ -484,4 +506,45 @@ describe('ExtensionManager', () => {
|
||||
).rejects.toThrow(/already installed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('early theme registration', () => {
|
||||
it('should register themes with ThemeManager during loadExtensions for active extensions', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'themed-ext',
|
||||
version: '1.0.0',
|
||||
themes: [testTheme],
|
||||
});
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
expect(themeManager.getCustomThemeNames()).toContain(
|
||||
'MyTheme (themed-ext)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not register themes for inactive extensions', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'disabled-ext',
|
||||
version: '1.0.0',
|
||||
themes: [testTheme],
|
||||
});
|
||||
|
||||
// Disable the extension by creating an enablement override
|
||||
const manager = new ExtensionManager({
|
||||
enabledExtensionOverrides: ['none'],
|
||||
settings: createTestMergedSettings(),
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null,
|
||||
});
|
||||
|
||||
await manager.loadExtensions();
|
||||
|
||||
expect(themeManager.getCustomThemeNames()).not.toContain(
|
||||
'MyTheme (disabled-ext)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -576,7 +576,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
protected override async startExtension(extension: GeminiCLIExtension) {
|
||||
await super.startExtension(extension);
|
||||
if (extension.themes) {
|
||||
if (extension.themes && !themeManager.hasExtensionThemes(extension.name)) {
|
||||
themeManager.registerExtensionThemes(extension.name, extension.themes);
|
||||
}
|
||||
}
|
||||
@@ -636,6 +636,13 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
this.loadedExtensions = builtExtensions;
|
||||
|
||||
// Register extension themes early so they're available at startup.
|
||||
for (const ext of this.loadedExtensions) {
|
||||
if (ext.isActive && ext.themes) {
|
||||
themeManager.registerExtensionThemes(ext.name, ext.themes);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)),
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('upgradeCommand', () => {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
}),
|
||||
getUserTierName: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
@@ -115,4 +116,23 @@ describe('upgradeCommand', () => {
|
||||
});
|
||||
expect(openBrowserSecurely).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return info message for ultra tiers', async () => {
|
||||
vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue(
|
||||
'Advanced Ultra',
|
||||
);
|
||||
|
||||
if (!upgradeCommand.action) {
|
||||
throw new Error('The upgrade command must have an action.');
|
||||
}
|
||||
|
||||
const result = await upgradeCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'You are already on the highest tier: Advanced Ultra.',
|
||||
});
|
||||
expect(openBrowserSecurely).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
shouldLaunchBrowser,
|
||||
UPGRADE_URL_PAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { isUltraTier } from '../../utils/tierUtils.js';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,15 @@ export const upgradeCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
const tierName = context.services.config?.getUserTierName();
|
||||
if (isUltraTier(tierName)) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `You are already on the highest tier: ${tierName}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldLaunchBrowser()) {
|
||||
return {
|
||||
type: 'message',
|
||||
|
||||
@@ -87,6 +87,7 @@ export const DialogManager = ({
|
||||
!!uiState.quota.proQuotaRequest.isModelNotFoundError
|
||||
}
|
||||
authType={uiState.quota.proQuotaRequest.authType}
|
||||
tierName={config?.getUserTierName()}
|
||||
onChoice={uiActions.handleProQuotaChoice}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -202,6 +202,40 @@ describe('ProQuotaDialog', () => {
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT render upgrade option for LOGIN_WITH_GOOGLE if tier is Ultra', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message="free tier quota error"
|
||||
isTerminalQuotaError={true}
|
||||
isModelNotFoundError={false}
|
||||
authType={AuthType.LOGIN_WITH_GOOGLE}
|
||||
tierName="Gemini Advanced Ultra"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Switch to gemini-2.5-flash',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when it is a capacity error', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { AuthType } from '@google/gemini-cli-core';
|
||||
import { isUltraTier } from '../../utils/tierUtils.js';
|
||||
|
||||
interface ProQuotaDialogProps {
|
||||
failedModel: string;
|
||||
@@ -17,6 +18,7 @@ interface ProQuotaDialogProps {
|
||||
isTerminalQuotaError: boolean;
|
||||
isModelNotFoundError?: boolean;
|
||||
authType?: AuthType;
|
||||
tierName?: string;
|
||||
onChoice: (
|
||||
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
|
||||
) => void;
|
||||
@@ -29,6 +31,7 @@ export function ProQuotaDialog({
|
||||
isTerminalQuotaError,
|
||||
isModelNotFoundError,
|
||||
authType,
|
||||
tierName,
|
||||
onChoice,
|
||||
}: ProQuotaDialogProps): React.JSX.Element {
|
||||
let items;
|
||||
@@ -47,6 +50,8 @@ export function ProQuotaDialog({
|
||||
},
|
||||
];
|
||||
} else if (isModelNotFoundError || isTerminalQuotaError) {
|
||||
const isUltra = isUltraTier(tierName);
|
||||
|
||||
// free users and out of quota users on G1 pro and Cloud Console gets an option to upgrade
|
||||
items = [
|
||||
{
|
||||
@@ -54,7 +59,7 @@ export function ProQuotaDialog({
|
||||
value: 'retry_always' as const,
|
||||
key: 'retry_always',
|
||||
},
|
||||
...(authType === AuthType.LOGIN_WITH_GOOGLE
|
||||
...(authType === AuthType.LOGIN_WITH_GOOGLE && !isUltra
|
||||
? [
|
||||
{
|
||||
label: 'Upgrade for higher limits',
|
||||
|
||||
@@ -182,4 +182,23 @@ describe('<UserIdentity />', () => {
|
||||
expect(output).toContain('/upgrade');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render /upgrade indicator for ultra tiers', async () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
model: 'gemini-pro',
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Advanced Ultra');
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<UserIdentity config={mockConfig} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Plan: Advanced Ultra');
|
||||
expect(output).not.toContain('/upgrade');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
UserAccountManager,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { isUltraTier } from '../../utils/tierUtils.js';
|
||||
|
||||
interface UserIdentityProps {
|
||||
config: Config;
|
||||
@@ -33,6 +34,8 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
|
||||
[config, authType],
|
||||
);
|
||||
|
||||
const isUltra = useMemo(() => isUltraTier(tierName), [tierName]);
|
||||
|
||||
if (!authType) {
|
||||
return null;
|
||||
}
|
||||
@@ -60,7 +63,7 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
<Text bold>Plan:</Text> {tierName}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> /upgrade</Text>
|
||||
{!isUltra && <Text color={theme.text.secondary}> /upgrade</Text>}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -174,11 +174,6 @@ class ThemeManager {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Registering extension themes for "${extensionName}":`,
|
||||
customThemes,
|
||||
);
|
||||
|
||||
for (const customThemeConfig of customThemes) {
|
||||
const namespacedName = `${customThemeConfig.name} (${extensionName})`;
|
||||
|
||||
@@ -240,6 +235,17 @@ class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if themes for a given extension are already registered.
|
||||
* @param extensionName The name of the extension.
|
||||
* @returns True if any themes from the extension are registered.
|
||||
*/
|
||||
hasExtensionThemes(extensionName: string): boolean {
|
||||
return Array.from(this.extensionThemes.keys()).some((name) =>
|
||||
name.endsWith(`(${extensionName})`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all registered extension themes.
|
||||
* This is primarily for testing purposes to reset state between tests.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isUltraTier } from './tierUtils.js';
|
||||
|
||||
describe('tierUtils', () => {
|
||||
describe('isUltraTier', () => {
|
||||
it('should return true if tier name contains "ultra" (case-insensitive)', () => {
|
||||
expect(isUltraTier('Advanced Ultra')).toBe(true);
|
||||
expect(isUltraTier('gemini ultra')).toBe(true);
|
||||
expect(isUltraTier('ULTRA')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if tier name does not contain "ultra"', () => {
|
||||
expect(isUltraTier('Free')).toBe(false);
|
||||
expect(isUltraTier('Pro')).toBe(false);
|
||||
expect(isUltraTier('Standard')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if tier name is undefined', () => {
|
||||
expect(isUltraTier(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if the given tier name corresponds to an "Ultra" tier.
|
||||
*
|
||||
* @param tierName The name of the user's tier.
|
||||
* @returns True if the tier is an "Ultra" tier, false otherwise.
|
||||
*/
|
||||
export function isUltraTier(tierName?: string): boolean {
|
||||
return !!tierName?.toLowerCase().includes('ultra');
|
||||
}
|
||||
Reference in New Issue
Block a user