fix(ui): handle headless execution in credits and upgrade dialogs (#21850)

This commit is contained in:
Gaurav
2026-03-10 07:54:15 -07:00
committed by GitHub
parent 94ab449e65
commit 47e4f6b13f
7 changed files with 129 additions and 7 deletions

View File

@@ -11,6 +11,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import {
AuthType,
openBrowserSecurely,
shouldLaunchBrowser,
UPGRADE_URL_PAGE,
} from '@google/gemini-cli-core';
@@ -20,6 +21,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...actual,
openBrowserSecurely: vi.fn(),
shouldLaunchBrowser: vi.fn().mockReturnValue(true),
UPGRADE_URL_PAGE: 'https://goo.gle/set-up-gemini-code-assist',
};
});
@@ -96,4 +98,21 @@ describe('upgradeCommand', () => {
content: 'Failed to open upgrade page: Failed to open',
});
});
it('should return URL message when shouldLaunchBrowser returns false', async () => {
vi.mocked(shouldLaunchBrowser).mockReturnValue(false);
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: `Please open this URL in a browser: ${UPGRADE_URL_PAGE}`,
});
expect(openBrowserSecurely).not.toHaveBeenCalled();
});
});

View File

@@ -7,6 +7,7 @@
import {
AuthType,
openBrowserSecurely,
shouldLaunchBrowser,
UPGRADE_URL_PAGE,
} from '@google/gemini-cli-core';
import type { SlashCommand } from './types.js';
@@ -35,6 +36,14 @@ export const upgradeCommand: SlashCommand = {
};
}
if (!shouldLaunchBrowser()) {
return {
type: 'message',
messageType: 'info',
content: `Please open this URL in a browser: ${UPGRADE_URL_PAGE}`,
};
}
try {
await openBrowserSecurely(UPGRADE_URL_PAGE);
} catch (error) {

View File

@@ -18,6 +18,12 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
"
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2

View File

@@ -15,6 +15,7 @@ import {
shouldAutoUseCredits,
shouldShowOverageMenu,
shouldShowEmptyWalletMenu,
shouldLaunchBrowser,
logBillingEvent,
G1_CREDIT_TYPE,
UserTierId,
@@ -32,6 +33,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
shouldShowEmptyWalletMenu: vi.fn(),
logBillingEvent: vi.fn(),
openBrowserSecurely: vi.fn(),
shouldLaunchBrowser: vi.fn().mockReturnValue(true),
};
});
@@ -237,4 +239,49 @@ describe('handleCreditsFlow', () => {
expect(isDialogPending.current).toBe(false);
expect(mockSetEmptyWalletRequest).toHaveBeenCalledWith(null);
});
describe('headless mode (shouldLaunchBrowser=false)', () => {
beforeEach(() => {
vi.mocked(shouldLaunchBrowser).mockReturnValue(false);
});
it('should show manage URL in history when manage selected in headless mode', async () => {
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
const flowPromise = handleCreditsFlow(makeArgs());
const request = mockSetOverageMenuRequest.mock.calls[0][0];
request.resolve('manage');
const result = await flowPromise;
expect(result).toBe('stop');
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Please open this URL in a browser:'),
}),
expect.any(Number),
);
});
it('should show credits URL in history when get_credits selected in headless mode', async () => {
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
const flowPromise = handleCreditsFlow(makeArgs());
const request = mockSetEmptyWalletRequest.mock.calls[0][0];
// Trigger onGetCredits callback and wait for it
await request.onGetCredits();
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Please open this URL in a browser:'),
}),
expect.any(Number),
);
request.resolve('get_credits');
await flowPromise;
});
});
});

View File

@@ -14,6 +14,7 @@ import {
shouldShowOverageMenu,
shouldShowEmptyWalletMenu,
openBrowserSecurely,
shouldLaunchBrowser,
logBillingEvent,
OverageMenuShownEvent,
OverageOptionSelectedEvent,
@@ -159,10 +160,23 @@ async function handleOverageMenu(
case 'use_fallback':
return 'retry_always';
case 'manage':
case 'manage': {
logCreditPurchaseClick(config, 'manage', usageLimitReachedModel);
await openG1Url('activity', G1_UTM_CAMPAIGNS.MANAGE_ACTIVITY);
const manageUrl = await openG1Url(
'activity',
G1_UTM_CAMPAIGNS.MANAGE_ACTIVITY,
);
if (manageUrl) {
args.historyManager.addItem(
{
type: MessageType.INFO,
text: `Please open this URL in a browser: ${manageUrl}`,
},
Date.now(),
);
}
return 'stop';
}
case 'stop':
default:
@@ -205,13 +219,25 @@ async function handleEmptyWalletMenu(
failedModel: usageLimitReachedModel,
fallbackModel,
resetTime,
onGetCredits: () => {
onGetCredits: async () => {
logCreditPurchaseClick(
config,
'empty_wallet_menu',
usageLimitReachedModel,
);
void openG1Url('credits', G1_UTM_CAMPAIGNS.EMPTY_WALLET_ADD_CREDITS);
const creditsUrl = await openG1Url(
'credits',
G1_UTM_CAMPAIGNS.EMPTY_WALLET_ADD_CREDITS,
);
if (creditsUrl) {
args.historyManager.addItem(
{
type: MessageType.INFO,
text: `Please open this URL in a browser: ${creditsUrl}`,
},
Date.now(),
);
}
},
resolve,
});
@@ -272,11 +298,16 @@ function logCreditPurchaseClick(
async function openG1Url(
path: 'activity' | 'credits',
campaign: string,
): Promise<void> {
): Promise<string | undefined> {
try {
const userEmail = new UserAccountManager().getCachedGoogleAccount() ?? '';
await openBrowserSecurely(buildG1Url(path, userEmail, campaign));
const url = buildG1Url(path, userEmail, campaign);
if (!shouldLaunchBrowser()) {
return url;
}
await openBrowserSecurely(url);
} catch {
// Ignore browser open errors
}
return undefined;
}