Merge branch 'main' into fix/headless-log

This commit is contained in:
cynthialong0-0
2026-03-16 14:55:07 -07:00
committed by GitHub
90 changed files with 4458 additions and 592 deletions
+9 -5
View File
@@ -85,6 +85,7 @@ import {
buildUserSteeringHintPrompt,
logBillingEvent,
ApiKeyUpdatedEvent,
type InjectionSource,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -1089,13 +1090,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, []);
useEffect(() => {
const hintListener = (hint: string) => {
pendingHintsRef.current.push(hint);
const hintListener = (text: string, source: InjectionSource) => {
if (source !== 'user_steering') {
return;
}
pendingHintsRef.current.push(text);
setPendingHintCount((prev) => prev + 1);
};
config.userHintService.onUserHint(hintListener);
config.injectionService.onInjection(hintListener);
return () => {
config.userHintService.offUserHint(hintListener);
config.injectionService.offInjection(hintListener);
};
}, [config]);
@@ -1259,7 +1263,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (!trimmed) {
return;
}
config.userHintService.addUserHint(trimmed);
config.injectionService.addInjection(trimmed, 'user_steering');
// Render hints with a distinct style.
historyManager.addItem({
type: 'hint',
@@ -51,7 +51,7 @@ describe('clearCommand', () => {
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
}),
userHintService: {
injectionService: {
clear: mockHintClear,
},
},
+1 -1
View File
@@ -30,7 +30,7 @@ export const clearCommand: SlashCommand = {
}
// Reset user steering hints
config?.userHintService.clear();
config?.injectionService.clear();
// Start a new conversation recording with a new session ID
// We MUST do this before calling resetChat() so the new ChatRecordingService
@@ -66,6 +66,7 @@ describe('FolderTrustDialog', () => {
mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`),
hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`),
skills: Array.from({ length: 10 }, (_, i) => `skill${i}`),
agents: [],
settings: Array.from({ length: 10 }, (_, i) => `setting${i}`),
discoveryErrors: [],
securityWarnings: [],
@@ -95,6 +96,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
@@ -125,6 +127,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
@@ -152,6 +155,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
@@ -332,6 +336,7 @@ describe('FolderTrustDialog', () => {
mcps: ['mcp1'],
hooks: ['hook1'],
skills: ['skill1'],
agents: ['agent1'],
settings: ['general', 'ui'],
discoveryErrors: [],
securityWarnings: [],
@@ -355,6 +360,8 @@ describe('FolderTrustDialog', () => {
expect(lastFrame()).toContain('- hook1');
expect(lastFrame()).toContain('• Skills (1):');
expect(lastFrame()).toContain('- skill1');
expect(lastFrame()).toContain('• Agents (1):');
expect(lastFrame()).toContain('- agent1');
expect(lastFrame()).toContain('• Setting overrides (2):');
expect(lastFrame()).toContain('- general');
expect(lastFrame()).toContain('- ui');
@@ -367,6 +374,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: [],
securityWarnings: ['Dangerous setting detected!'],
@@ -390,6 +398,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: ['Failed to load custom commands'],
securityWarnings: [],
@@ -413,6 +422,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
@@ -446,6 +456,7 @@ describe('FolderTrustDialog', () => {
mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`],
hooks: [`${ansiRed}hook-with-ansi${ansiReset}`],
skills: [`${ansiRed}skill-with-ansi${ansiReset}`],
agents: [],
settings: [`${ansiRed}setting-with-ansi${ansiReset}`],
discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`],
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
@@ -135,6 +135,7 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
{ label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
{ label: 'Hooks', items: discoveryResults?.hooks ?? [] },
{ label: 'Skills', items: discoveryResults?.skills ?? [] },
{ label: 'Agents', items: discoveryResults?.agents ?? [] },
{ label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
].filter((g) => g.items.length > 0);
@@ -19,7 +19,9 @@ import {
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
AuthType,
UserTierId,
} from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
@@ -28,8 +30,9 @@ 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');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getDisplayString: (val: string) => mockGetDisplayString(val),
@@ -40,6 +43,7 @@ vi.mock('@google/gemini-cli-core', async () => {
mockModelSlashCommandEvent(model);
}
},
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL: 'gemini-3.1-flash-lite-preview',
};
});
@@ -49,6 +53,9 @@ describe('<ModelDialog />', () => {
const mockOnClose = vi.fn();
const mockGetHasAccessToPreviewModel = vi.fn();
const mockGetGemini31LaunchedSync = vi.fn();
const mockGetProModelNoAccess = vi.fn();
const mockGetProModelNoAccessSync = vi.fn();
const mockGetUserTier = vi.fn();
interface MockConfig extends Partial<Config> {
setModel: (model: string, isTemporary?: boolean) => void;
@@ -56,6 +63,9 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: () => boolean;
getIdeMode: () => boolean;
getGemini31LaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getUserTier: () => UserTierId | undefined;
}
const mockConfig: MockConfig = {
@@ -64,6 +74,9 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
getIdeMode: () => false,
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getUserTier: mockGetUserTier,
};
beforeEach(() => {
@@ -71,6 +84,9 @@ describe('<ModelDialog />', () => {
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGetHasAccessToPreviewModel.mockReturnValue(false);
mockGetGemini31LaunchedSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
// Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => {
@@ -109,6 +125,55 @@ describe('<ModelDialog />', () => {
unmount();
});
it('renders the "manual" view initially for users with no pro access and filters Pro models with correct order', async () => {
mockGetProModelNoAccessSync.mockReturnValue(true);
mockGetProModelNoAccess.mockResolvedValue(true);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
mockGetDisplayString.mockImplementation((val: string) => val);
const { lastFrame, unmount } = await renderComponent();
const output = lastFrame();
expect(output).toContain('Select Model');
expect(output).not.toContain(DEFAULT_GEMINI_MODEL);
expect(output).not.toContain(PREVIEW_GEMINI_MODEL);
// Verify order: Flash Preview -> Flash Lite Preview -> Flash -> Flash Lite
const flashPreviewIdx = output.indexOf(PREVIEW_GEMINI_FLASH_MODEL);
const flashLitePreviewIdx = output.indexOf(
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
);
const flashIdx = output.indexOf(DEFAULT_GEMINI_FLASH_MODEL);
const flashLiteIdx = output.indexOf(DEFAULT_GEMINI_FLASH_LITE_MODEL);
expect(flashPreviewIdx).toBeLessThan(flashLitePreviewIdx);
expect(flashLitePreviewIdx).toBeLessThan(flashIdx);
expect(flashIdx).toBeLessThan(flashLiteIdx);
expect(output).not.toContain('Auto');
unmount();
});
it('closes dialog on escape in "manual" view for users with no pro access', async () => {
mockGetProModelNoAccessSync.mockReturnValue(true);
mockGetProModelNoAccess.mockResolvedValue(true);
const { stdin, waitUntilReady, unmount } = await renderComponent();
// Already in manual view
await act(async () => {
stdin.write('\u001B'); // Escape
});
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('switches to "manual" view when "Manual" is selected and uses getDisplayString for models', async () => {
mockGetDisplayString.mockImplementation((val: string) => {
if (val === DEFAULT_GEMINI_MODEL) return 'Formatted Pro Model';
@@ -369,5 +434,50 @@ describe('<ModelDialog />', () => {
});
unmount();
});
it('hides Flash Lite Preview model for users with pro access', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
unmount();
});
it('shows Flash Lite Preview model for free tier users', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
unmount();
});
});
});
+49 -6
View File
@@ -5,12 +5,13 @@
*/
import type React from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { useCallback, useContext, useMemo, useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
@@ -21,6 +22,8 @@ import {
getDisplayString,
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isProModel,
UserTierId,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
@@ -35,9 +38,26 @@ interface ModelDialogProps {
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
const settings = useSettings();
const [view, setView] = useState<'main' | 'manual'>('main');
const [hasAccessToProModel, setHasAccessToProModel] = useState<boolean>(
() => !(config?.getProModelNoAccessSync() ?? false),
);
const [view, setView] = useState<'main' | 'manual'>(() =>
config?.getProModelNoAccessSync() ? 'manual' : 'main',
);
const [persistMode, setPersistMode] = useState(false);
useEffect(() => {
async function checkAccess() {
if (!config) return;
const noAccess = await config.getProModelNoAccess();
setHasAccessToProModel(!noAccess);
if (noAccess) {
setView('manual');
}
}
void checkAccess();
}, [config]);
// Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
@@ -66,7 +86,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useKeypress(
(key) => {
if (key.name === 'escape') {
if (view === 'manual') {
if (view === 'manual' && hasAccessToProModel) {
setView('main');
} else {
onClose();
@@ -115,6 +135,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}, [shouldShowPreviewModels, manualModelSelected, useGemini31]);
const manualOptions = useMemo(() => {
const isFreeTier = config?.getUserTier() === UserTierId.FREE;
const list = [
{
value: DEFAULT_GEMINI_MODEL,
@@ -142,7 +163,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
list.unshift(
const previewOptions = [
{
value: previewProValue,
title: getDisplayString(previewProModel),
@@ -153,10 +174,32 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
key: PREVIEW_GEMINI_FLASH_MODEL,
},
);
];
if (isFreeTier) {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
key: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
});
}
list.unshift(...previewOptions);
}
if (!hasAccessToProModel) {
// Filter out all Pro models for free tier
return list.filter((option) => !isProModel(option.value));
}
return list;
}, [shouldShowPreviewModels, useGemini31, useCustomToolModel]);
}, [
shouldShowPreviewModels,
useGemini31,
useCustomToolModel,
hasAccessToProModel,
config,
]);
const options = view === 'main' ? mainOptions : manualOptions;
@@ -13,10 +13,6 @@ Tips for getting started:
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirming_tool Confirming tool description │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
Action Required (was prompted):
@@ -118,10 +118,30 @@ describe('<ToolGroupMessage />', () => {
{ config: baseMockConfig, settings: fullVerbositySettings },
);
// Should now render confirming tools
// Should now hide confirming tools (to avoid duplication with Global Queue)
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders canceled tool calls', async () => {
const toolCalls = [
createToolCall({
callId: 'canceled-tool',
name: 'canceled-tool',
status: CoreToolCallStatus.Cancelled,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig, settings: fullVerbositySettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test-tool');
expect(output).toMatchSnapshot('canceled_tool');
unmount();
});
@@ -842,7 +862,7 @@ describe('<ToolGroupMessage />', () => {
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).not.toBe('');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -110,11 +110,12 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
() =>
toolCalls.filter((t) => {
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
// We used to filter out Pending and Confirming statuses here to avoid
// duplication with the Global Queue, but this causes tools to appear to
// "vanish" from the context after approval.
// We now allow them to be visible here as well.
return displayStatus !== ToolCallStatus.Canceled;
// We hide Confirming tools from the history log because they are
// currently being rendered in the interactive ToolConfirmationQueue.
// We show everything else, including Pending (waiting to run) and
// Canceled (rejected by user), to ensure the history is complete
// and to avoid tools "vanishing" after approval.
return displayStatus !== ToolCallStatus.Confirming;
}),
[toolCalls],
@@ -49,6 +49,15 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shel
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders canceled tool calls > canceled_tool 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ - canceled-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
@@ -101,12 +101,13 @@ export const useExtensionUpdates = (
return !currentState || currentState === ExtensionUpdateState.UNKNOWN;
});
if (extensionsToCheck.length === 0) return;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
checkForAllExtensionUpdates(
void checkForAllExtensionUpdates(
extensionsToCheck,
extensionManager,
dispatchExtensionStateUpdate,
);
).catch((e) => {
debugLogger.warn(getErrorMessage(e));
});
}, [
extensions,
extensionManager,
@@ -202,12 +203,18 @@ export const useExtensionUpdates = (
);
}
if (scheduledUpdate) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.all(updatePromises).then((results) => {
const nonNullResults = results.filter((result) => result != null);
void Promise.allSettled(updatePromises).then((results) => {
const successfulUpdates = results
.filter(
(r): r is PromiseFulfilledResult<ExtensionUpdateInfo | undefined> =>
r.status === 'fulfilled',
)
.map((r) => r.value)
.filter((v): v is ExtensionUpdateInfo => v !== undefined);
scheduledUpdate.onCompleteCallbacks.forEach((callback) => {
try {
callback(nonNullResults);
callback(successfulUpdates);
} catch (e) {
debugLogger.warn(getErrorMessage(e));
}