Make merged settings non-nullable and fix all lints related to that. (#16647)

This commit is contained in:
Jacob Richman
2026-01-15 09:26:10 -08:00
committed by GitHub
parent 2b6bfe4097
commit f7f38e2b9e
59 changed files with 964 additions and 744 deletions
+1 -2
View File
@@ -230,8 +230,7 @@ export async function handleMigrateFromClaude() {
const settings = loadSettings(workingDir);
// Merge migrated hooks with existing hooks
const existingHooks =
(settings.merged.hooks as Record<string, unknown>) || {};
const existingHooks = settings.merged.hooks as Record<string, unknown>;
const mergedHooks = { ...existingHooks, ...migratedHooks };
// Update settings (setValue automatically saves)
+27 -6
View File
@@ -6,15 +6,20 @@
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { listMcpServers } from './list.js';
import { loadSettings } from '../../config/settings.js';
import { loadSettings, mergeSettings } from '../../config/settings.js';
import { createTransport, debugLogger } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionStorage } from '../../config/extensions/storage.js';
import { ExtensionManager } from '../../config/extension-manager.js';
vi.mock('../../config/settings.js', () => ({
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
}));
};
});
vi.mock('../../config/extensions/storage.js', () => ({
ExtensionStorage: {
getUserExtensionsDir: vi.fn(),
@@ -32,11 +37,16 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
CONNECTING: 'CONNECTING',
DISCONNECTED: 'DISCONNECTED',
},
Storage: vi.fn().mockImplementation((_cwd: string) => ({
Storage: Object.assign(
vi.fn().mockImplementation((_cwd: string) => ({
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json',
getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash',
})),
{
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
},
),
GEMINI_DIR: '.gemini',
getErrorMessage: (e: unknown) =>
e instanceof Error ? e.message : String(e),
@@ -96,7 +106,10 @@ describe('mcp list command', () => {
});
it('should display message when no servers configured', async () => {
mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } });
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: { ...defaultMergedSettings, mcpServers: {} },
});
await listMcpServers();
@@ -104,8 +117,10 @@ describe('mcp list command', () => {
});
it('should display different server types with connected status', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: {
...defaultMergedSettings,
mcpServers: {
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
'sse-server': { url: 'https://example.com/sse' },
@@ -138,8 +153,10 @@ describe('mcp list command', () => {
});
it('should display disconnected status when connection fails', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: {
...defaultMergedSettings,
mcpServers: {
'test-server': { command: '/test/server' },
},
@@ -158,9 +175,13 @@ describe('mcp list command', () => {
});
it('should merge extension servers with config servers', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: { 'config-server': { command: '/config/server' } },
...defaultMergedSettings,
mcpServers: {
'config-server': { command: '/config/server' },
},
},
});
+2 -3
View File
@@ -35,7 +35,7 @@ async function getMcpServersFromConfig(): Promise<
requestSetting: promptForSetting,
});
const extensions = await extensionManager.loadExtensions();
const mcpServers = { ...(settings.merged.mcpServers || {}) };
const mcpServers = { ...settings.merged.mcpServers };
for (const extension of extensions) {
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
if (mcpServers[key]) {
@@ -63,8 +63,7 @@ async function testMCPConnection(
const sanitizationConfig = {
enableEnvironmentVariableRedaction: true,
allowedEnvironmentVariables: [],
blockedEnvironmentVariables:
settings.merged.advanced?.excludedEnvVars || [],
blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
};
let transport;
@@ -22,8 +22,9 @@ import {
Config,
DEFAULT_FILE_FILTERING_OPTIONS,
} from '@google/gemini-cli-core';
import type { Settings } from './settingsSchema.js';
import { createTestMergedSettings } from './settings.js';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
export const server = setupServer();
@@ -212,7 +213,7 @@ describe('Configuration Integration Tests', () => {
const originalArgv = process.argv;
try {
process.argv = argv;
const parsedArgs = await parseArguments({} as Settings);
const parsedArgs = await parseArguments(createTestMergedSettings());
expect(parsedArgs.approvalMode).toBe(expected.approvalMode);
expect(parsedArgs.prompt).toBe(expected.prompt);
expect(parsedArgs.yolo).toBe(expected.yolo);
@@ -235,7 +236,9 @@ describe('Configuration Integration Tests', () => {
const originalArgv = process.argv;
try {
process.argv = argv;
await expect(parseArguments({} as Settings)).rejects.toThrow();
await expect(
parseArguments(createTestMergedSettings()),
).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
File diff suppressed because it is too large Load Diff
+24 -20
View File
@@ -38,8 +38,12 @@ import {
type OutputFormat,
GEMINI_MODEL_ALIAS_AUTO,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import { saveModelChange, loadSettings } from './settings.js';
import {
type Settings,
type MergedSettings,
saveModelChange,
loadSettings,
} from './settings.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
@@ -54,7 +58,6 @@ import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { runExitCleanup } from '../utils/cleanup.js';
import { getEnableHooks, getEnableHooksUI } from './settingsSchema.js';
export interface CliArgs {
query: string | undefined;
@@ -82,7 +85,9 @@ export interface CliArgs {
recordResponses: string | undefined;
}
export async function parseArguments(settings: Settings): Promise<CliArgs> {
export async function parseArguments(
settings: MergedSettings,
): Promise<CliArgs> {
const rawArgv = hideBin(process.argv);
const yargsInstance = yargs(rawArgv)
.locale('en')
@@ -280,16 +285,16 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
return true;
});
if (settings?.experimental?.extensionManagement ?? true) {
if (settings.experimental.extensionManagement) {
yargsInstance.command(extensionsCommand);
}
if (settings?.experimental?.skills ?? false) {
if (settings.experimental.skills) {
yargsInstance.command(skillsCommand);
}
// Register hooks command if hooks are enabled
if (getEnableHooksUI(settings)) {
if (settings.tools.enableHooks) {
yargsInstance.command(hooksCommand);
}
@@ -392,7 +397,7 @@ export interface LoadCliConfigOptions {
}
export async function loadCliConfig(
settings: Settings,
settings: MergedSettings,
sessionId: string,
argv: CliArgs,
options: LoadCliConfigOptions = {},
@@ -590,10 +595,7 @@ export async function loadCliConfig(
}
}
const excludeTools = mergeExcludeTools(
settings,
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const excludeTools = mergeExcludeTools(settings, extraExcludes);
// Create a settings object that includes CLI overrides for policy generation
const effectiveSettings: Settings = {
@@ -742,15 +744,17 @@ export async function loadCliConfig(
disableLLMCorrection: settings.tools?.disableLLMCorrection,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks: getEnableHooks(settings),
enableHooksUI: getEnableHooksUI(settings),
enableHooks:
(settings.tools?.enableHooks ?? true) &&
(settings.hooks?.enabled ?? false),
enableHooksUI: settings.tools?.enableHooks ?? true,
hooks: settings.hooks || {},
projectHooks: projectHooks || {},
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
onReload: async () => {
const refreshedSettings = loadSettings(cwd);
return {
disabledSkills: refreshedSettings.merged.skills?.disabled,
disabledSkills: refreshedSettings.merged.skills.disabled,
agents: refreshedSettings.merged.agents,
};
},
@@ -758,12 +762,12 @@ export async function loadCliConfig(
}
function mergeExcludeTools(
settings: Settings,
extraExcludes?: string[] | undefined,
settings: MergedSettings,
extraExcludes: string[] = [],
): string[] {
const allExcludeTools = new Set([
...(settings.tools?.exclude || []),
...(extraExcludes || []),
...(settings.tools.exclude || []),
...extraExcludes,
]);
return [...allExcludeTools];
return Array.from(allExcludeTools);
}
@@ -10,7 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { debugLogger } from '@google/gemini-cli-core';
import { type Settings } from './settings.js';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
@@ -52,10 +52,9 @@ describe('ExtensionManager agents loading', () => {
fs.mkdirSync(extensionsDir, { recursive: true });
extensionManager = new ExtensionManager({
settings: {
settings: createTestMergedSettings({
telemetry: { enabled: false },
trustedFolders: [tempDir],
} as unknown as Settings,
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import type { Settings } from './settings.js';
import { createTestMergedSettings } from './settings.js';
import {
loadAgentsFromDirectory,
loadSkillsFromDir,
@@ -105,14 +105,10 @@ describe('ExtensionManager Settings Scope', () => {
workspaceDir: tempWorkspace,
requestConsent: async () => true,
requestSetting: async () => '',
settings: {
telemetry: {
enabled: false,
},
experimental: {
extensionConfig: true,
},
} as Settings,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
});
const extensions = await extensionManager.loadExtensions();
@@ -147,14 +143,10 @@ describe('ExtensionManager Settings Scope', () => {
workspaceDir: tempWorkspace,
requestConsent: async () => true,
requestSetting: async () => '',
settings: {
telemetry: {
enabled: false,
},
experimental: {
extensionConfig: true,
},
} as Settings,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
});
const extensions = await extensionManager.loadExtensions();
@@ -187,14 +179,10 @@ describe('ExtensionManager Settings Scope', () => {
workspaceDir: tempWorkspace,
requestConsent: async () => true,
requestSetting: async () => '',
settings: {
telemetry: {
enabled: false,
},
experimental: {
extensionConfig: true,
},
} as Settings,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
});
const extensions = await extensionManager.loadExtensions();
@@ -10,7 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import { type Settings } from './settings.js';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
@@ -58,10 +58,9 @@ describe('ExtensionManager skills validation', () => {
fs.mkdirSync(extensionsDir, { recursive: true });
extensionManager = new ExtensionManager({
settings: {
settings: createTestMergedSettings({
telemetry: { enabled: false },
trustedFolders: [tempDir],
} as unknown as Settings,
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
@@ -134,10 +133,9 @@ describe('ExtensionManager skills validation', () => {
// 3. Create a fresh ExtensionManager to force loading from disk
const newExtensionManager = new ExtensionManager({
settings: {
settings: createTestMergedSettings({
telemetry: { enabled: false },
trustedFolders: [tempDir],
} as unknown as Settings,
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
+12 -20
View File
@@ -9,7 +9,7 @@ import * as path from 'node:path';
import { stat } from 'node:fs/promises';
import chalk from 'chalk';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { type Settings, SettingScope } from './settings.js';
import { type MergedSettings, SettingScope } from './settings.js';
import { createHash, randomUUID } from 'node:crypto';
import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
import {
@@ -68,11 +68,10 @@ import {
ExtensionSettingScope,
} from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { getEnableHooks } from './settingsSchema.js';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
settings: Settings;
settings: MergedSettings;
requestConsent: (consent: string) => Promise<boolean>;
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
workspaceDir: string;
@@ -86,7 +85,7 @@ interface ExtensionManagerParams {
*/
export class ExtensionManager extends ExtensionLoader {
private extensionEnablementManager: ExtensionEnablementManager;
private settings: Settings;
private settings: MergedSettings;
private requestConsent: (consent: string) => Promise<boolean>;
private requestSetting:
| ((setting: ExtensionSetting) => Promise<string>)
@@ -143,7 +142,7 @@ export class ExtensionManager extends ExtensionLoader {
if (
(installMetadata.type === 'git' ||
installMetadata.type === 'github-release') &&
this.settings.security?.blockGitExtensions
this.settings.security.blockGitExtensions
) {
throw new Error(
'Installing extensions from remote sources is disallowed by your current settings.',
@@ -287,10 +286,7 @@ Would you like to attempt to install via "git clone" instead?`,
}
await fs.promises.mkdir(destinationPath, { recursive: true });
if (
this.requestSetting &&
(this.settings.experimental?.extensionConfig ?? false)
) {
if (this.requestSetting && this.settings.experimental.extensionConfig) {
if (isUpdate) {
await maybePromptForSettings(
newExtensionConfig,
@@ -308,8 +304,7 @@ Would you like to attempt to install via "git clone" instead?`,
}
}
const missingSettings =
(this.settings.experimental?.extensionConfig ?? false)
const missingSettings = this.settings.experimental.extensionConfig
? await getMissingSettings(
newExtensionConfig,
extensionId,
@@ -478,7 +473,7 @@ Would you like to attempt to install via "git clone" instead?`,
throw new Error('Extensions already loaded, only load extensions once.');
}
if (this.settings.admin?.extensions?.enabled === false) {
if (this.settings.admin.extensions.enabled === false) {
this.loadedExtensions = [];
return this.loadedExtensions;
}
@@ -511,7 +506,7 @@ Would you like to attempt to install via "git clone" instead?`,
if (
(installMetadata?.type === 'git' ||
installMetadata?.type === 'github-release') &&
this.settings.security?.blockGitExtensions
this.settings.security.blockGitExtensions
) {
return null;
}
@@ -535,7 +530,7 @@ Would you like to attempt to install via "git clone" instead?`,
let userSettings: Record<string, string> = {};
let workspaceSettings: Record<string, string> = {};
if (this.settings.experimental?.extensionConfig ?? false) {
if (this.settings.experimental.extensionConfig) {
userSettings = await getScopedEnvContents(
config,
extensionId,
@@ -553,10 +548,7 @@ Would you like to attempt to install via "git clone" instead?`,
config = resolveEnvVarsInObject(config, customEnv);
const resolvedSettings: ResolvedExtensionSetting[] = [];
if (
config.settings &&
(this.settings.experimental?.extensionConfig ?? false)
) {
if (config.settings && this.settings.experimental.extensionConfig) {
for (const setting of config.settings) {
const value = customEnv[setting.envVar];
let scope: 'user' | 'workspace' | undefined;
@@ -600,7 +592,7 @@ Would you like to attempt to install via "git clone" instead?`,
}
if (config.mcpServers) {
if (this.settings.admin?.mcp?.enabled === false) {
if (this.settings.admin.mcp.enabled === false) {
config.mcpServers = undefined;
} else {
config.mcpServers = Object.fromEntries(
@@ -619,7 +611,7 @@ Would you like to attempt to install via "git clone" instead?`,
.filter((contextFilePath) => fs.existsSync(contextFilePath));
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
if (getEnableHooks(this.settings)) {
if (this.settings.tools.enableHooks && this.settings.hooks.enabled) {
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
extensionPath: effectiveExtensionPath,
workspacePath: this.workspaceDir,
+12 -17
View File
@@ -26,7 +26,11 @@ import {
loadAgentsFromDirectory,
loadSkillsFromDir,
} from '@google/gemini-cli-core';
import { loadSettings, SettingScope } from './settings.js';
import {
loadSettings,
createTestMergedSettings,
SettingScope,
} from './settings.js';
import {
isWorkspaceTrusted,
resetTrustedFoldersForTesting,
@@ -201,7 +205,7 @@ describe('extension tests', () => {
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
const settings = loadSettings(tempWorkspaceDir).merged;
(settings.experimental ??= {}).extensionConfig = true;
settings.experimental.extensionConfig = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
@@ -628,11 +632,9 @@ describe('extension tests', () => {
},
});
const blockGitExtensionsSetting = {
security: {
blockGitExtensions: true,
},
};
const blockGitExtensionsSetting = createTestMergedSettings({
security: { blockGitExtensions: true },
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
@@ -652,7 +654,6 @@ describe('extension tests', () => {
version: '1.0.0',
});
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
(loadedSettings.admin ??= {}).extensions ??= {};
loadedSettings.admin.extensions.enabled = false;
extensionManager = new ExtensionManager({
@@ -676,7 +677,6 @@ describe('extension tests', () => {
},
});
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
(loadedSettings.admin ??= {}).mcp ??= {};
loadedSettings.admin.mcp.enabled = false;
extensionManager = new ExtensionManager({
@@ -701,7 +701,6 @@ describe('extension tests', () => {
},
});
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
(loadedSettings.admin ??= {}).mcp ??= {};
loadedSettings.admin.mcp.enabled = true;
extensionManager = new ExtensionManager({
@@ -837,7 +836,6 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
if (!settings.hooks) settings.hooks = {};
settings.hooks.enabled = true;
extensionManager = new ExtensionManager({
@@ -873,7 +871,6 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
if (!settings.hooks) settings.hooks = {};
settings.hooks.enabled = false;
extensionManager = new ExtensionManager({
@@ -1098,11 +1095,9 @@ describe('extension tests', () => {
it('should not install a github extension if blockGitExtensions is set', async () => {
const gitUrl = 'https://somehost.com/somerepo.git';
const blockGitExtensionsSetting = {
security: {
blockGitExtensions: true,
},
};
const blockGitExtensionsSetting = createTestMergedSettings({
security: { blockGitExtensions: true },
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
@@ -11,7 +11,6 @@ import * as fs from 'node:fs';
import { getMissingSettings } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import type { Settings } from '../settings.js';
import {
KeychainTokenStorage,
debugLogger,
@@ -21,6 +20,7 @@ import {
} from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { createTestMergedSettings } from '../settings.js';
vi.mock('node:fs', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -247,12 +247,10 @@ describe('extensionUpdates', () => {
const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
settings: {
telemetry: {
enabled: false,
},
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
} as unknown as Settings,
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive
});
+37 -13
View File
@@ -24,12 +24,24 @@ import { DefaultDark } from '../ui/themes/default.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import {
type Settings,
type MergedSettings,
type MemoryImportFormat,
type MergeStrategy,
type SettingsSchema,
type SettingDefinition,
getSettingsSchema,
} from './settingsSchema.js';
export {
type Settings,
type MergedSettings,
type MemoryImportFormat,
type MergeStrategy,
type SettingsSchema,
type SettingDefinition,
getSettingsSchema,
};
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -59,8 +71,6 @@ function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
return current?.mergeStrategy;
}
export type { Settings, MemoryImportFormat };
export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
@@ -201,10 +211,7 @@ export function getDefaultsFromSchema(
for (const key in schema) {
const definition = schema[key];
if (definition.properties) {
const childDefaults = getDefaultsFromSchema(definition.properties);
if (Object.keys(childDefaults).length > 0) {
defaults[key] = childDefaults;
}
defaults[key] = getDefaultsFromSchema(definition.properties);
} else if (definition.default !== undefined) {
defaults[key] = definition.default;
}
@@ -212,13 +219,13 @@ export function getDefaultsFromSchema(
return defaults as Settings;
}
function mergeSettings(
export function mergeSettings(
system: Settings,
systemDefaults: Settings,
user: Settings,
workspace: Settings,
isTrusted: boolean,
): Settings {
): MergedSettings {
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
const schemaDefaults = getDefaultsFromSchema();
@@ -236,7 +243,24 @@ function mergeSettings(
user,
safeWorkspace,
system,
) as Settings;
) as MergedSettings;
}
/**
* Creates a fully populated MergedSettings object for testing purposes.
* It merges the provided overrides with the default settings from the schema.
*
* @param overrides Partial settings to override the defaults.
* @returns A complete MergedSettings object.
*/
export function createTestMergedSettings(
overrides: Partial<Settings> = {},
): MergedSettings {
return customDeepMerge(
getMergeStrategyForPath,
getDefaultsFromSchema(),
overrides,
) as MergedSettings;
}
export class LoadedSettings {
@@ -264,14 +288,14 @@ export class LoadedSettings {
readonly isTrusted: boolean;
readonly errors: SettingsError[];
private _merged: Settings;
private _merged: MergedSettings;
private _remoteAdminSettings: Partial<Settings> | undefined;
get merged(): Settings {
get merged(): MergedSettings {
return this._merged;
}
private computeMergedSettings(): Settings {
private computeMergedSettings(): MergedSettings {
const merged = mergeSettings(
this.system.settings,
this.systemDefaults.settings,
@@ -293,7 +317,7 @@ export class LoadedSettings {
(path: string[]) => getMergeStrategyForPath(['admin', ...path]),
adminDefaults,
this._remoteAdminSettings?.admin ?? {},
) as Settings['admin'];
) as MergedSettings['admin'];
}
return merged;
}
+15 -9
View File
@@ -14,6 +14,7 @@ import type {
BugCommandSettings,
TelemetrySettings,
AuthType,
AgentOverride,
} from '@google/gemini-cli-core';
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
@@ -799,7 +800,7 @@ const SETTINGS_SCHEMA = {
label: 'Agent Overrides',
category: 'Advanced',
requiresRestart: true,
default: {},
default: {} as Record<string, AgentOverride>,
description:
'Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.',
showInDialog: false,
@@ -2262,12 +2263,17 @@ type InferSettings<T extends SettingsSchema> = {
: T[K]['default'];
};
type InferMergedSettings<T extends SettingsSchema> = {
-readonly [K in keyof T]-?: T[K] extends { properties: SettingsSchema }
? InferMergedSettings<T[K]['properties']>
: T[K]['type'] extends 'enum'
? T[K]['options'] extends readonly SettingEnumOption[]
? T[K]['options'][number]['value']
: T[K]['default']
: T[K]['default'] extends boolean
? boolean
: T[K]['default'];
};
export type Settings = InferSettings<SettingsSchemaType>;
export function getEnableHooksUI(settings: Settings): boolean {
return settings.tools?.enableHooks ?? true;
}
export function getEnableHooks(settings: Settings): boolean {
return getEnableHooksUI(settings) && (settings.hooks?.enabled ?? false);
}
export type MergedSettings = InferMergedSettings<SettingsSchemaType>;
+1 -1
View File
@@ -127,7 +127,7 @@ describe('initializer', () => {
});
it('should handle undefined auth type', async () => {
mockSettings.merged.security!.auth!.selectedType = undefined;
mockSettings.merged.security.auth.selectedType = undefined;
const result = await initializeApp(
mockConfig as unknown as Config,
mockSettings,
+2 -2
View File
@@ -39,13 +39,13 @@ export async function initializeApp(
const authHandle = startupProfiler.start('authenticate');
const authError = await performInitialAuth(
config,
settings.merged.security?.auth?.selectedType,
settings.merged.security.auth.selectedType,
);
authHandle?.end();
const themeError = validateTheme(settings);
const shouldOpenAuthDialog =
settings.merged.security?.auth?.selectedType === undefined || !!authError;
settings.merged.security.auth.selectedType === undefined || !!authError;
logCliConfiguration(
config,
+1 -1
View File
@@ -46,7 +46,7 @@ describe('theme', () => {
});
it('should return null if theme is undefined', () => {
mockSettings.merged.ui!.theme = undefined;
mockSettings.merged.ui.theme = undefined;
const result = validateTheme(mockSettings);
expect(result).toBeNull();
expect(themeManager.findThemeByName).not.toHaveBeenCalled();
+1 -1
View File
@@ -13,7 +13,7 @@ import { type LoadedSettings } from '../config/settings.js';
* @returns An error message if the theme is not found, otherwise null.
*/
export function validateTheme(settings: LoadedSettings): string | null {
const effectiveTheme = settings.merged.ui?.theme;
const effectiveTheme = settings.merged.ui.theme;
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
return `Theme "${effectiveTheme}" not found.`;
}
+78 -59
View File
@@ -23,8 +23,30 @@ import {
import os from 'node:os';
import v8 from 'node:v8';
import { type CliArgs } from './config/config.js';
import { type LoadedSettings } from './config/settings.js';
import {
type LoadedSettings,
type Settings,
createTestMergedSettings,
} from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
function createMockSettings(
overrides: Record<string, unknown> = {},
): LoadedSettings {
const merged = createTestMergedSettings(
(overrides['merged'] as Partial<Settings>) || {},
);
return {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
errors: [],
...overrides,
merged,
} as unknown as LoadedSettings;
}
import {
type Config,
type ResumedSessionData,
@@ -108,26 +130,19 @@ class MockProcessExitError extends Error {
}
// Mock dependencies
vi.mock('./config/settings.js', () => ({
loadSettings: vi.fn().mockReturnValue({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
vi.mock('./config/settings.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./config/settings.js')>();
return {
...actual,
loadSettings: vi.fn().mockImplementation(() => ({
merged: actual.getDefaultsFromSchema(),
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
}),
migrateDeprecatedSettings: vi.fn(),
SettingScope: {
User: 'user',
Workspace: 'workspace',
System: 'system',
SystemDefaults: 'system-defaults',
},
}));
})),
saveModelChange: vi.fn(),
getDefaultsFromSchema: actual.getDefaultsFromSchema,
};
});
vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({
terminalCapabilityManager: {
@@ -443,17 +458,15 @@ describe('gemini.tsx main function kitty protocol', () => {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
},
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
} as never);
}),
);
vi.mocked(parseArguments).mockResolvedValue({
model: undefined,
sandbox: undefined,
@@ -505,7 +518,8 @@ describe('gemini.tsx main function kitty protocol', () => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
advanced: {},
security: { auth: {} },
@@ -514,8 +528,8 @@ describe('gemini.tsx main function kitty protocol', () => {
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
@@ -594,7 +608,8 @@ describe('gemini.tsx main function kitty protocol', () => {
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadSettings).mockReturnValue({
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
advanced: {},
security: { auth: {} },
@@ -603,8 +618,8 @@ describe('gemini.tsx main function kitty protocol', () => {
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
}),
);
const mockConfig = {
isInteractive: () => false,
@@ -665,7 +680,8 @@ describe('gemini.tsx main function kitty protocol', () => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
advanced: {},
security: { auth: {} },
@@ -674,8 +690,8 @@ describe('gemini.tsx main function kitty protocol', () => {
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
@@ -753,13 +769,14 @@ describe('gemini.tsx main function kitty protocol', () => {
});
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
vi.mocked(loadSettings).mockReturnValue({
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
@@ -839,13 +856,14 @@ describe('gemini.tsx main function kitty protocol', () => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
@@ -918,13 +936,14 @@ describe('gemini.tsx main function kitty protocol', () => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
@@ -1034,10 +1053,11 @@ describe('gemini.tsx main function exit codes', () => {
);
const { loadSettings } = await import('./config/settings.js');
vi.mocked(loadCliConfig).mockResolvedValue({} as Config);
vi.mocked(loadSettings).mockReturnValue({
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { security: { auth: {} }, ui: {} },
errors: [],
} as never);
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: true,
} as unknown as CliArgs);
@@ -1066,14 +1086,13 @@ describe('gemini.tsx main function exit codes', () => {
vi.mocked(loadCliConfig).mockResolvedValue({
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
security: { auth: { selectedType: 'google', useExternal: false } },
ui: {},
},
workspace: { settings: {} },
errors: [],
} as never);
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
vi.mock('./config/auth.js', () => ({
validateAuthMethod: vi.fn().mockReturnValue(null),
@@ -1131,11 +1150,11 @@ describe('gemini.tsx main function exit codes', () => {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
},
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { security: { auth: {} }, ui: {} },
workspace: { settings: {} },
errors: [],
} as never);
}),
);
vi.mocked(parseArguments).mockResolvedValue({
resume: 'invalid-session',
} as unknown as CliArgs);
@@ -1200,11 +1219,11 @@ describe('gemini.tsx main function exit codes', () => {
},
getRemoteAdminSettings: () => undefined,
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { security: { auth: {} }, ui: {} },
workspace: { settings: {} },
errors: [],
} as never);
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
Object.defineProperty(process.stdin, 'isTTY', {
value: true, // Simulate TTY so it doesn't try to read stdin
+18 -19
View File
@@ -213,12 +213,12 @@ export async function startInteractiveUI(
<SettingsContext.Provider value={settings}>
<KeypressProvider
config={config}
debugKeystrokeLogging={settings.merged.general?.debugKeystrokeLogging}
debugKeystrokeLogging={settings.merged.general.debugKeystrokeLogging}
>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
debugKeystrokeLogging={
settings.merged.general?.debugKeystrokeLogging
settings.merged.general.debugKeystrokeLogging
}
>
<ScrollProvider>
@@ -263,8 +263,7 @@ export async function startInteractiveUI(
patchConsole: false,
alternateBuffer: useAlternateBuffer,
incrementalRendering:
settings.merged.ui?.incrementalRendering !== false &&
useAlternateBuffer,
settings.merged.ui.incrementalRendering !== false && useAlternateBuffer,
},
);
@@ -336,13 +335,13 @@ export async function main() {
registerCleanup(consolePatcher.cleanup);
dns.setDefaultResultOrder(
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder),
);
// Set a default auth type if one isn't set or is set to a legacy type
if (
!settings.merged.security?.auth?.selectedType ||
settings.merged.security?.auth?.selectedType === AuthType.LEGACY_CLOUD_SHELL
!settings.merged.security.auth.selectedType ||
settings.merged.security.auth.selectedType === AuthType.LEGACY_CLOUD_SHELL
) {
if (
process.env['CLOUD_SHELL'] === 'true' ||
@@ -364,8 +363,8 @@ export async function main() {
// the sandbox because the sandbox will interfere with the Oauth2 web
// redirect.
if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
settings.merged.security.auth.selectedType &&
!settings.merged.security.auth.useExternal
) {
try {
if (partialConfig.isInteractive()) {
@@ -381,8 +380,8 @@ export async function main() {
);
} else {
const authType = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
settings.merged.security.auth.selectedType,
settings.merged.security.auth.useExternal,
partialConfig,
settings,
);
@@ -403,7 +402,7 @@ export async function main() {
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env['SANDBOX']) {
const memoryArgs = settings.merged.advanced?.autoConfigureMemory
const memoryArgs = settings.merged.advanced.autoConfigureMemory
? getNodeMemoryArgs(isDebugMode)
: [];
const sandboxConfig = await loadSandboxConfig(settings.merged, argv);
@@ -506,7 +505,7 @@ export async function main() {
// Handle --list-sessions flag
if (config.getListSessions()) {
// Attempt auth for summary generation (gracefully skips if not configured)
const authType = settings.merged.security?.auth?.selectedType;
const authType = settings.merged.security.auth.selectedType;
if (authType) {
try {
await config.refreshAuth(authType);
@@ -566,7 +565,7 @@ export async function main() {
initAppHandle?.end();
if (
settings.merged.security?.auth?.selectedType ===
settings.merged.security.auth.selectedType ===
AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed()
) {
@@ -678,8 +677,8 @@ export async function main() {
);
const authType = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
settings.merged.security.auth.selectedType,
settings.merged.security.auth.useExternal,
config,
settings,
);
@@ -705,14 +704,14 @@ export async function main() {
}
function setWindowTitle(title: string, settings: LoadedSettings) {
if (!settings.merged.ui?.hideWindowTitle) {
if (!settings.merged.ui.hideWindowTitle) {
// Initial state before React loop starts
const windowTitle = computeTerminalTitle({
streamingState: StreamingState.Idle,
isConfirming: false,
folderName: title,
showThoughts: !!settings.merged.ui?.showStatusInTitle,
useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true,
showThoughts: !!settings.merged.ui.showStatusInTitle,
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
});
writeToStdout(`\x1b]0;${windowTitle}\x07`);
@@ -7,6 +7,7 @@
import { vi } from 'vitest';
import type { CommandContext } from '../ui/commands/types.js';
import type { LoadedSettings } from '../config/settings.js';
import { mergeSettings } from '../config/settings.js';
import type { GitService } from '@google/gemini-cli-core';
import type { SessionStatsState } from '../ui/contexts/SessionContext.js';
@@ -27,6 +28,8 @@ type DeepPartial<T> = T extends object
export const createMockCommandContext = (
overrides: DeepPartial<CommandContext> = {},
): CommandContext => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const defaultMocks: CommandContext = {
invocation: {
raw: '',
@@ -35,7 +38,11 @@ export const createMockCommandContext = (
},
services: {
config: null,
settings: { merged: {} } as LoadedSettings,
settings: {
merged: defaultMergedSettings,
setValue: vi.fn(),
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
logger: {
log: vi.fn(),
+2 -2
View File
@@ -136,7 +136,7 @@ describe('App', () => {
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
} as UIState;
mockLoadedSettings.merged.ui = { useAlternateBuffer: true };
mockLoadedSettings.merged.ui.useAlternateBuffer = true;
const { lastFrame } = renderWithProviders(<App />, quittingUIState);
@@ -144,7 +144,7 @@ describe('App', () => {
expect(lastFrame()).toContain('Quitting...');
// Reset settings
mockLoadedSettings.merged.ui = { useAlternateBuffer: false };
mockLoadedSettings.merged.ui.useAlternateBuffer = false;
});
it('should render dialog manager when dialogs are visible', () => {
+33 -18
View File
@@ -82,7 +82,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
import ansiEscapes from 'ansi-escapes';
import type { LoadedSettings } from '../config/settings.js';
import { type LoadedSettings, mergeSettings } from '../config/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
@@ -380,14 +380,17 @@ describe('AppContainer State Management', () => {
);
// Mock LoadedSettings
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockSettings = {
merged: {
...defaultMergedSettings,
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: false,
theme: 'default',
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
},
@@ -507,8 +510,10 @@ describe('AppContainer State Management', () => {
describe('Settings Integration', () => {
it('handles settings with all display options disabled', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const settingsAllHidden = {
merged: {
...defaultMergedSettings,
hideBanner: true,
hideFooter: true,
hideTips: true,
@@ -526,8 +531,10 @@ describe('AppContainer State Management', () => {
});
it('handles settings with memory usage enabled', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const settingsWithMemory = {
merged: {
...defaultMergedSettings,
hideBanner: false,
hideFooter: false,
hideTips: false,
@@ -574,7 +581,7 @@ describe('AppContainer State Management', () => {
it('handles undefined settings gracefully', async () => {
const undefinedSettings = {
merged: {},
merged: mergeSettings({}, {}, {}, {}, true),
} as LoadedSettings;
let unmount: () => void;
@@ -991,12 +998,13 @@ describe('AppContainer State Management', () => {
it('should update terminal title with Working… when showStatusInTitle is false', () => {
// Arrange: Set up mock settings with showStatusInTitle disabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithShowStatusFalse = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
},
@@ -1073,12 +1081,13 @@ describe('AppContainer State Management', () => {
it('should not update terminal title when hideWindowTitle is true', () => {
// Arrange: Set up mock settings with hideWindowTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithHideTitleTrue = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: true,
},
@@ -1101,12 +1110,13 @@ describe('AppContainer State Management', () => {
it('should update terminal title with thought subject when in active state', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
@@ -1143,12 +1153,13 @@ describe('AppContainer State Management', () => {
it('should update terminal title with default text when in Idle state and no thought subject', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
@@ -1184,12 +1195,13 @@ describe('AppContainer State Management', () => {
it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
@@ -1392,12 +1404,13 @@ describe('AppContainer State Management', () => {
it('should pad title to exactly 80 characters', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
@@ -1435,12 +1448,13 @@ describe('AppContainer State Management', () => {
it('should use correct ANSI escape code format', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
@@ -1802,12 +1816,13 @@ describe('AppContainer State Management', () => {
const setupCopyModeTest = async (isAlternateMode = false) => {
// Update settings for this test run
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const testSettings = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
useAlternateBuffer: isAlternateMode,
},
},
+27 -27
View File
@@ -392,8 +392,8 @@ export const AppContainer = (props: AppContainerProps) => {
}, []);
const getPreferredEditor = useCallback(
() => settings.merged.general?.preferredEditor as EditorType,
[settings.merged.general?.preferredEditor],
() => settings.merged.general.preferredEditor as EditorType,
[settings.merged.general.preferredEditor],
);
const buffer = useTextBuffer({
@@ -443,7 +443,7 @@ export const AppContainer = (props: AppContainerProps) => {
useEffect(() => {
if (
!(settings.merged.ui?.hideBanner || config.getScreenReader()) &&
!(settings.merged.ui.hideBanner || config.getScreenReader()) &&
bannerVisible &&
bannerText
) {
@@ -603,17 +603,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Check for enforced auth type mismatch
useEffect(() => {
if (
settings.merged.security?.auth?.enforcedType &&
settings.merged.security?.auth.selectedType &&
settings.merged.security?.auth.enforcedType !==
settings.merged.security?.auth.selectedType
settings.merged.security.auth.enforcedType &&
settings.merged.security.auth.selectedType &&
settings.merged.security.auth.enforcedType !==
settings.merged.security.auth.selectedType
) {
onAuthError(
`Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`,
`Authentication is enforced to be ${settings.merged.security.auth.enforcedType}, but you are currently using ${settings.merged.security.auth.selectedType}.`,
);
} else if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
settings.merged.security.auth.selectedType &&
!settings.merged.security.auth.useExternal
) {
// We skip validation for Gemini API key here because it might be stored
// in the keychain, which we can't check synchronously.
@@ -630,9 +630,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}
}, [
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.enforcedType,
settings.merged.security?.auth?.useExternal,
settings.merged.security.auth.selectedType,
settings.merged.security.auth.enforcedType,
settings.merged.security.auth.useExternal,
onAuthError,
]);
@@ -951,8 +951,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING),
1,
),
pager: settings.merged.tools?.shell?.pager,
showColor: settings.merged.tools?.shell?.showColor,
pager: settings.merged.tools.shell.pager,
showColor: settings.merged.tools.shell.showColor,
sanitizationConfig: config.sanitizationConfig,
});
@@ -960,13 +960,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Context file names computation
const contextFileNames = useMemo(() => {
const fromSettings = settings.merged.context?.fileName;
const fromSettings = settings.merged.context.fileName;
return fromSettings
? Array.isArray(fromSettings)
? fromSettings
: [fromSettings]
: getAllGeminiMdFilenames();
}, [settings.merged.context?.fileName]);
}, [settings.merged.context.fileName]);
// Initial prompt handling
const initialPrompt = useMemo(() => config.getQuestion(), [config]);
const initialPromptSubmitted = useRef(false);
@@ -1040,7 +1040,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const shouldShowIdePrompt = Boolean(
currentIDE &&
!config.getIdeMode() &&
!settings.merged.ide?.hasSeenNudge &&
!settings.merged.ide.hasSeenNudge &&
!idePromptAnswered,
);
@@ -1221,7 +1221,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
settings.merged.ui.customWittyPhrases,
!!activePtyId && !embeddedShellFocused,
lastOutputTime,
retryStatus,
@@ -1237,7 +1237,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
// Debug log keystrokes if enabled
if (settings.merged.general?.debugKeystrokeLogging) {
if (settings.merged.general.debugKeystrokeLogging) {
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
@@ -1337,7 +1337,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
cancelOngoingRequest,
activePtyId,
embeddedShellFocused,
settings.merged.general?.debugKeystrokeLogging,
settings.merged.general.debugKeystrokeLogging,
refreshStatic,
setCopyModeEnabled,
copyModeEnabled,
@@ -1351,7 +1351,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Update terminal title with Gemini CLI status and thoughts
useEffect(() => {
// Respect hideWindowTitle settings
if (settings.merged.ui?.hideWindowTitle) return;
if (settings.merged.ui.hideWindowTitle) return;
const paddedTitle = computeTerminalTitle({
streamingState,
@@ -1361,8 +1361,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
!!confirmationRequest ||
showShellActionRequired,
folderName: basename(config.getTargetDir()),
showThoughts: !!settings.merged.ui?.showStatusInTitle,
useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true,
showThoughts: !!settings.merged.ui.showStatusInTitle,
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
});
// Only update the title if it's different from the last value we set
@@ -1377,9 +1377,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
shellConfirmationRequest,
confirmationRequest,
showShellActionRequired,
settings.merged.ui?.showStatusInTitle,
settings.merged.ui?.dynamicWindowTitle,
settings.merged.ui?.hideWindowTitle,
settings.merged.ui.showStatusInTitle,
settings.merged.ui.dynamicWindowTitle,
settings.merged.ui.hideWindowTitle,
config,
stdout,
]);
+7 -7
View File
@@ -152,7 +152,7 @@ describe('AuthDialog', () => {
});
it('filters auth types when enforcedType is set', () => {
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(1);
@@ -160,7 +160,7 @@ describe('AuthDialog', () => {
});
it('sets initial index to 0 when enforcedType is set', () => {
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(initialIndex).toBe(0);
@@ -170,7 +170,7 @@ describe('AuthDialog', () => {
it.each([
{
setup: () => {
props.settings.merged.security!.auth!.selectedType =
props.settings.merged.security.auth.selectedType =
AuthType.USE_VERTEX_AI;
},
expected: AuthType.USE_VERTEX_AI,
@@ -290,7 +290,7 @@ describe('AuthDialog', () => {
mockedValidateAuthMethod.mockReturnValue(null);
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
// Simulate that the user has already authenticated once
props.settings.merged.security!.auth!.selectedType =
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
renderWithProviders(<AuthDialog {...props} />);
@@ -349,7 +349,7 @@ describe('AuthDialog', () => {
{
desc: 'calls onAuthError on escape if no auth method is set',
setup: () => {
props.settings.merged.security!.auth!.selectedType = undefined;
props.settings.merged.security.auth.selectedType = undefined;
},
expectations: (p: typeof props) => {
expect(p.onAuthError).toHaveBeenCalledWith(
@@ -360,7 +360,7 @@ describe('AuthDialog', () => {
{
desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set',
setup: () => {
props.settings.merged.security!.auth!.selectedType =
props.settings.merged.security.auth.selectedType =
AuthType.USE_GEMINI;
},
expectations: (p: typeof props) => {
@@ -392,7 +392,7 @@ describe('AuthDialog', () => {
});
it('renders correctly with enforced auth type', () => {
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
expect(lastFrame()).toMatchSnapshot();
});
+5 -5
View File
@@ -78,9 +78,9 @@ export function AuthDialog({
},
];
if (settings.merged.security?.auth?.enforcedType) {
if (settings.merged.security.auth.enforcedType) {
items = items.filter(
(item) => item.value === settings.merged.security?.auth?.enforcedType,
(item) => item.value === settings.merged.security.auth.enforcedType,
);
}
@@ -94,7 +94,7 @@ export function AuthDialog({
}
let initialAuthIndex = items.findIndex((item) => {
if (settings.merged.security?.auth?.selectedType) {
if (settings.merged.security.auth.selectedType) {
return item.value === settings.merged.security.auth.selectedType;
}
@@ -108,7 +108,7 @@ export function AuthDialog({
return item.value === AuthType.LOGIN_WITH_GOOGLE;
});
if (settings.merged.security?.auth?.enforcedType) {
if (settings.merged.security.auth.enforcedType) {
initialAuthIndex = 0;
}
@@ -171,7 +171,7 @@ export function AuthDialog({
if (authError) {
return;
}
if (settings.merged.security?.auth?.selectedType === undefined) {
if (settings.merged.security.auth.selectedType === undefined) {
// Prevent exiting if no auth method is set
onAuthError(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
+3 -3
View File
@@ -20,11 +20,11 @@ export function validateAuthMethodWithSettings(
authType: AuthType,
settings: LoadedSettings,
): string | null {
const enforcedType = settings.merged.security?.auth?.enforcedType;
const enforcedType = settings.merged.security.auth.enforcedType;
if (enforcedType && enforcedType !== authType) {
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
}
if (settings.merged.security?.auth?.useExternal) {
if (settings.merged.security.auth.useExternal) {
return null;
}
// If using Gemini API key, we don't validate it here as we might need to prompt for it.
@@ -80,7 +80,7 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
return;
}
const authType = settings.merged.security?.auth?.selectedType;
const authType = settings.merged.security.auth.selectedType;
if (!authType) {
if (process.env['GEMINI_API_KEY']) {
onAuthError(
+1 -1
View File
@@ -33,7 +33,7 @@ export const aboutCommand: SlashCommand = {
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getVersion();
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
context.services.settings.merged.security.auth.selectedType || '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient = await getIdeClientName(context);
@@ -7,7 +7,7 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { agentsCommand } from './agentsCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Config, AgentOverride } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { MessageType } from '../types.js';
import { enableAgent, disableAgent } from '../../utils/agentSettings.js';
@@ -148,12 +148,9 @@ describe('agentsCommand', () => {
reload: reloadSpy,
});
// Add agent to disabled overrides so validation passes
(
mockContext.services.settings.merged.agents!.overrides as Record<
string,
AgentOverride
>
)['test-agent'] = { disabled: true };
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
disabled: true,
};
vi.mocked(enableAgent).mockReturnValue({
status: 'success',
@@ -266,12 +263,9 @@ describe('agentsCommand', () => {
it('should show info message if agent is already disabled', async () => {
mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]);
(
mockContext.services.settings.merged.agents!.overrides as Record<
string,
AgentOverride
>
)['test-agent'] = { disabled: true };
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
disabled: true,
};
const disableCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'disable',
+3 -13
View File
@@ -12,7 +12,6 @@ import type {
import { CommandKind } from './types.js';
import { MessageType, type HistoryItemAgentsList } from '../types.js';
import { SettingScope } from '../../config/settings.js';
import type { AgentOverride } from '@google/gemini-cli-core';
import { disableAgent, enableAgent } from '../../utils/agentSettings.js';
import { renderAgentActionFeedback } from '../../utils/agentUtils.js';
@@ -84,10 +83,7 @@ async function enableAction(
}
const allAgents = agentRegistry.getAllAgentNames();
const overrides = (settings.merged.agents?.overrides ?? {}) as Record<
string,
AgentOverride
>;
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.keys(overrides).filter(
(name) => overrides[name]?.disabled === true,
);
@@ -157,10 +153,7 @@ async function disableAction(
}
const allAgents = agentRegistry.getAllAgentNames();
const overrides = (settings.merged.agents?.overrides ?? {}) as Record<
string,
AgentOverride
>;
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.keys(overrides).filter(
(name) => overrides[name]?.disabled === true,
);
@@ -211,10 +204,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) {
const { config, settings } = context.services;
if (!config) return [];
const overrides = (settings.merged.agents?.overrides ?? {}) as Record<
string,
AgentOverride
>;
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.entries(overrides)
.filter(([_, override]) => override?.disabled === true)
.map(([name]) => name);
@@ -271,9 +271,10 @@ describe('hooksCommand', () => {
it('should enable a hook and update settings', async () => {
// Update the context's settings with disabled hooks
mockContext.services.settings.merged.hooks = {
disabled: ['test-hook', 'other-hook'],
};
mockContext.services.settings.merged.hooks.disabled = [
'test-hook',
'other-hook',
];
const enableCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'enable',
@@ -401,9 +402,7 @@ describe('hooksCommand', () => {
});
it('should disable a hook and update settings', async () => {
mockContext.services.settings.merged.hooks = {
disabled: [],
};
mockContext.services.settings.merged.hooks.disabled = [];
const disableCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'disable',
@@ -432,9 +431,7 @@ describe('hooksCommand', () => {
it('should return info when hook is already disabled', async () => {
// Update the context's settings with the hook already disabled
mockContext.services.settings.merged.hooks = {
disabled: ['test-hook'],
};
mockContext.services.settings.merged.hooks.disabled = ['test-hook'];
const disableCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'disable',
@@ -455,9 +452,7 @@ describe('hooksCommand', () => {
});
it('should handle error when disabling hook fails', async () => {
mockContext.services.settings.merged.hooks = {
disabled: [],
};
mockContext.services.settings.merged.hooks.disabled = [];
mockSettings.setValue.mockImplementationOnce(() => {
throw new Error('Failed to save settings');
});
+2 -4
View File
@@ -76,8 +76,7 @@ async function enableAction(
// Get current disabled hooks from settings
const settings = context.services.settings;
const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]);
const disabledHooks = settings.merged.hooks.disabled;
// Remove from disabled list if present
const newDisabledHooks = disabledHooks.filter(
(name: string) => name !== hookName,
@@ -143,8 +142,7 @@ async function disableAction(
// Get current disabled hooks from settings
const settings = context.services.settings;
const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]);
const disabledHooks = settings.merged.hooks.disabled;
// Add to disabled list if not already present
if (!disabledHooks.includes(hookName)) {
const newDisabledHooks = [...disabledHooks, hookName];
+2 -2
View File
@@ -26,7 +26,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
return (
<Box flexDirection="column">
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
{!(settings.merged.ui.hideBanner || config.getScreenReader()) && (
<>
<Header version={version} nightly={nightly} />
{bannerVisible && bannerText && (
@@ -38,7 +38,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
)}
</>
)}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
{!(settings.merged.ui.hideTips || config.getScreenReader()) && (
<Tips config={config} />
)}
</Box>
@@ -24,6 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({
}));
import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { mergeSettings } from '../../config/settings.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
@@ -163,13 +164,20 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
const createMockSettings = (merged = {}) => ({
const createMockSettings = (merged = {}) => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
return {
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
hideFooter: false,
showMemoryUsage: false,
...merged,
},
});
},
};
};
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderComposer = (
+2 -4
View File
@@ -82,9 +82,7 @@ export const Composer = () => {
<Box
marginTop={1}
justifyContent={
settings.merged.ui?.hideContextSummary
? 'flex-start'
: 'space-between'
settings.merged.ui.hideContextSummary ? 'flex-start' : 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
@@ -153,7 +151,7 @@ export const Composer = () => {
/>
)}
{!settings.merged.ui?.hideFooter && !isScreenReaderEnabled && <Footer />}
{!settings.merged.ui.hideFooter && !isScreenReaderEnabled && <Footer />}
</Box>
);
};
@@ -124,12 +124,12 @@ export function EditorSettingsDialog({
let mergedEditorName = 'None';
if (
settings.merged.general?.preferredEditor &&
isEditorAvailable(settings.merged.general?.preferredEditor)
settings.merged.general.preferredEditor &&
isEditorAvailable(settings.merged.general.preferredEditor)
) {
mergedEditorName =
EDITOR_DISPLAY_NAMES[
settings.merged.general?.preferredEditor as EditorType
settings.merged.general.preferredEditor as EditorType
];
}
+5 -6
View File
@@ -59,12 +59,11 @@ export const Footer: React.FC = () => {
};
const showMemoryUsage =
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false;
const hideCWD = settings.merged.ui?.footer?.hideCWD;
const hideSandboxStatus = settings.merged.ui?.footer?.hideSandboxStatus;
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo;
const hideContextPercentage =
settings.merged.ui?.footer?.hideContextPercentage;
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
const hideCWD = settings.merged.ui.footer.hideCWD;
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
const hideContextPercentage = settings.merged.ui.footer.hideContextPercentage;
const pathLength = Math.max(20, Math.floor(mainAreaWidth * 0.25));
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
@@ -52,14 +52,11 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (
uiState.activeHooks.length > 0 &&
(settings.merged.hooks?.notifications ?? true)
) {
if (uiState.activeHooks.length > 0 && settings.merged.hooks.notifications) {
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
}
if (!settings.merged.ui?.hideContextSummary && !hideContextSummary) {
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
return (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
@@ -95,7 +95,7 @@ export function ThemeDialog({
const [highlightedThemeName, setHighlightedThemeName] = useState<string>(
() => {
// If a theme is already set, use it.
if (settings.merged.ui?.theme) {
if (settings.merged.ui.theme) {
return settings.merged.ui.theme;
}
@@ -113,7 +113,7 @@ export function ThemeDialog({
const customThemes =
selectedScope === SettingScope.User
? settings.user.settings.ui?.customThemes || {}
: settings.merged.ui?.customThemes || {};
: settings.merged.ui.customThemes;
const builtInThemes = themeManager
.getAvailableThemes()
.filter((theme) => theme.type !== 'custom');
@@ -42,7 +42,7 @@ export const ToolConfirmationMessage: React.FC<
const settings = useSettings();
const allowPermanentApproval =
settings.merged.security?.enablePermanentToolApproval ?? false;
settings.merged.security.enablePermanentToolApproval;
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
@@ -32,7 +32,7 @@ export const VimModeProvider = ({
children: React.ReactNode;
settings: LoadedSettings;
}) => {
const initialVimEnabled = settings.merged.general?.vimMode ?? false;
const initialVimEnabled = settings.merged.general.vimMode;
const [vimEnabled, setVimEnabled] = useState(initialVimEnabled);
const [vimMode, setVimMode] = useState<VimMode>(
initialVimEnabled ? 'NORMAL' : 'INSERT',
@@ -40,13 +40,13 @@ export const VimModeProvider = ({
useEffect(() => {
// Initialize vimEnabled from settings on mount
const enabled = settings.merged.general?.vimMode ?? false;
const enabled = settings.merged.general.vimMode;
setVimEnabled(enabled);
// When vim mode is enabled, always start in NORMAL mode
if (enabled) {
setVimMode('NORMAL');
}
}, [settings.merged.general?.vimMode]);
}, [settings.merged.general.vimMode]);
const toggleVimEnabled = useCallback(async () => {
const newValue = !vimEnabled;
@@ -8,7 +8,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean =>
settings.merged.ui?.useAlternateBuffer === true;
settings.merged.ui.useAlternateBuffer === true;
export const useAlternateBuffer = (): boolean => {
const settings = useSettings();
+1 -1
View File
@@ -27,7 +27,7 @@ export const useFolderTrust = (
const [isRestarting, setIsRestarting] = useState(false);
const startupMessageSent = useRef(false);
const folderTrust = settings.merged.security?.folderTrust?.enabled;
const folderTrust = settings.merged.security.folderTrust.enabled;
useEffect(() => {
const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged);
+2 -2
View File
@@ -83,7 +83,7 @@ enum StreamProcessingStatus {
}
function showCitations(settings: LoadedSettings): boolean {
const enabled = settings?.merged?.ui?.showCitations;
const enabled = settings.merged.ui.showCitations;
if (enabled !== undefined) {
return enabled;
}
@@ -782,7 +782,7 @@ export const useGeminiStream = (
const handleChatModelEvent = useCallback(
(eventValue: string, userMessageTimestamp: number) => {
if (!settings?.merged?.ui?.showModelInfoInChat) {
if (!settings.merged.ui.showModelInfoInChat) {
return;
}
if (pendingHistoryItemRef.current) {
@@ -85,7 +85,7 @@ export const usePermissionsModifyTrust = (
);
const [needsRestart, setNeedsRestart] = useState(false);
const isFolderTrustEnabled = !!settings.merged.security?.folderTrust?.enabled;
const isFolderTrustEnabled = !!settings.merged.security.folderTrust.enabled;
const updateTrustLevel = useCallback(
(trustLevel: TrustLevel) => {
@@ -32,7 +32,7 @@ export function createShowMemoryAction(
const currentMemory = config.getUserMemory();
const fileCount = config.getGeminiMdFileCount();
const contextFileName = settings.merged.context?.fileName;
const contextFileName = settings.merged.context.fileName;
const contextFileNames = Array.isArray(contextFileName)
? contextFileName
: [contextFileName];
+4 -4
View File
@@ -67,7 +67,7 @@ export const useThemeCommand = (
const closeThemeDialog = useCallback(() => {
// Re-apply the saved theme to revert any preview changes from highlighting
applyTheme(loadedSettings.merged.ui?.theme);
applyTheme(loadedSettings.merged.ui.theme);
setIsThemeDialogOpen(false);
}, [applyTheme, loadedSettings]);
@@ -88,10 +88,10 @@ export const useThemeCommand = (
return;
}
loadedSettings.setValue(scope, 'ui.theme', themeName); // Update the merged settings
if (loadedSettings.merged.ui?.customThemes) {
themeManager.loadCustomThemes(loadedSettings.merged.ui?.customThemes);
if (loadedSettings.merged.ui.customThemes) {
themeManager.loadCustomThemes(loadedSettings.merged.ui.customThemes);
}
applyTheme(loadedSettings.merged.ui?.theme); // Apply the current theme
applyTheme(loadedSettings.merged.ui.theme); // Apply the current theme
setThemeError(null);
} finally {
setIsThemeDialogOpen(false); // Close the dialog
+1 -1
View File
@@ -149,7 +149,7 @@ export function colorizeCode({
const activeTheme = theme || themeManager.getActiveTheme();
const showLineNumbers = hideLineNumbers
? false
: (settings?.merged.ui?.showLineNumbers ?? true);
: settings.merged.ui.showLineNumbers;
const useMaxSizedBox = !isAlternateBufferEnabled(settings);
try {
+1 -1
View File
@@ -27,7 +27,7 @@ export const calculateMainAreaWidth = (
terminalWidth: number,
settings: LoadedSettings,
): number => {
if (settings.merged.ui?.useFullWidth) {
if (settings.merged.ui.useFullWidth) {
if (isAlternateBufferEnabled(settings)) {
return terminalWidth - 1;
}
@@ -46,7 +46,7 @@ describe('checkForUpdates', () => {
});
it('should return null if disableUpdateNag is true', async () => {
mockSettings.merged.general!.disableUpdateNag = true;
mockSettings.merged.general.disableUpdateNag = true;
const result = await checkForUpdates(mockSettings);
expect(result).toBeNull();
expect(getPackageJson).not.toHaveBeenCalled();
+1 -1
View File
@@ -51,7 +51,7 @@ export async function checkForUpdates(
settings: LoadedSettings,
): Promise<UpdateObject | null> {
try {
if (settings.merged.general?.disableUpdateNag) {
if (settings.merged.general.disableUpdateNag) {
return null;
}
// Skip update check when running from source (development mode)
+5 -8
View File
@@ -10,7 +10,6 @@ import {
type LoadedSettings,
} from '../config/settings.js';
import type { ModifiedScope } from './skillSettings.js';
import type { AgentOverride } from '@google/gemini-cli-core';
export type AgentActionStatus = 'success' | 'no-op' | 'error';
@@ -44,8 +43,8 @@ export function enableAgent(
for (const scope of writableScopes) {
if (isLoadableSettingScope(scope)) {
const scopePath = settings.forScope(scope).path;
const agentOverrides = settings.forScope(scope).settings.agents
?.overrides as Record<string, AgentOverride> | undefined;
const agentOverrides =
settings.forScope(scope).settings.agents?.overrides;
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
if (isDisabled) {
@@ -105,9 +104,7 @@ export function disableAgent(
}
const scopePath = settings.forScope(scope).path;
const agentOverrides = settings.forScope(scope).settings.agents?.overrides as
| Record<string, AgentOverride>
| undefined;
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
if (isDisabled) {
@@ -128,8 +125,8 @@ export function disableAgent(
const alreadyDisabledInOther: ModifiedScope[] = [];
if (isLoadableSettingScope(otherScope)) {
const otherOverrides = settings.forScope(otherScope).settings.agents
?.overrides as Record<string, AgentOverride> | undefined;
const otherOverrides =
settings.forScope(otherScope).settings.agents?.overrides;
if (otherOverrides?.[agentName]?.disabled === true) {
alreadyDisabledInOther.push({
scope: otherScope,
@@ -14,6 +14,7 @@ import EventEmitter from 'node:events';
import type { ChildProcess } from 'node:child_process';
import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js';
import { MessageType } from '../ui/types.js';
import { mergeSettings } from '../config/settings.js';
vi.mock('./installationInfo.js', async () => {
const actual = await vi.importActual('./installationInfo.js');
@@ -49,12 +50,9 @@ describe('handleAutoUpdate', () => {
message: 'An update is available!',
};
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockSettings = {
merged: {
general: {
disableAutoUpdate: false,
},
},
merged: defaultMergedSettings,
} as LoadedSettings;
mockChildProcess = Object.assign(new EventEmitter(), {
@@ -82,7 +80,7 @@ describe('handleAutoUpdate', () => {
});
it('should do nothing if update nag is disabled', () => {
mockSettings.merged.general!.disableUpdateNag = true;
mockSettings.merged.general.disableUpdateNag = true;
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockGetInstallationInfo).not.toHaveBeenCalled();
expect(updateEventEmitter.emit).not.toHaveBeenCalled();
@@ -90,7 +88,7 @@ describe('handleAutoUpdate', () => {
});
it('should emit "update-received" but not update if auto-updates are disabled', () => {
mockSettings.merged.general!.disableAutoUpdate = true;
mockSettings.merged.general.disableAutoUpdate = true;
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm i -g @google/gemini-cli@latest',
updateMessage: 'Please update manually.',
+4 -4
View File
@@ -23,20 +23,20 @@ export function handleAutoUpdate(
return;
}
if (settings.merged.tools?.sandbox || process.env['GEMINI_SANDBOX']) {
if (settings.merged.tools.sandbox || process.env['GEMINI_SANDBOX']) {
updateEventEmitter.emit('update-info', {
message: `${info.message}\nAutomatic update is not available in sandbox mode.`,
});
return;
}
if (settings.merged.general?.disableUpdateNag) {
if (settings.merged.general.disableUpdateNag) {
return;
}
const installationInfo = getInstallationInfo(
projectRoot,
settings.merged.general?.disableAutoUpdate ?? false,
settings.merged.general.disableAutoUpdate,
);
if (
@@ -58,7 +58,7 @@ export function handleAutoUpdate(
if (
!installationInfo.updateCommand ||
settings.merged.general?.disableAutoUpdate
settings.merged.general.disableAutoUpdate
) {
return;
}
+4 -4
View File
@@ -32,14 +32,14 @@ export async function setupTerminalAndTheme(
}
// Load custom themes from settings
themeManager.loadCustomThemes(settings.merged.ui?.customThemes);
themeManager.loadCustomThemes(settings.merged.ui.customThemes);
if (settings.merged.ui?.theme) {
if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) {
if (settings.merged.ui.theme) {
if (!themeManager.setActiveTheme(settings.merged.ui.theme)) {
// If the theme is not found during initial load, log a warning and continue.
// The useThemeCommand hook in AppContainer.tsx will handle opening the dialog.
debugLogger.warn(
`Warning: Theme "${settings.merged.ui?.theme}" not found.`,
`Warning: Theme "${settings.merged.ui.theme}" not found.`,
);
}
} else {
@@ -295,7 +295,7 @@ describe('validateNonInterActiveAuth', () => {
});
it('succeeds if effectiveAuthType matches enforcedAuthType', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
mockSettings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createLocalMockConfig({});
await validateNonInteractiveAuth(
@@ -309,8 +309,7 @@ describe('validateNonInterActiveAuth', () => {
});
it('exits if configuredAuthType does not match enforcedAuthType', async () => {
mockSettings.merged.security!.auth!.enforcedType =
AuthType.LOGIN_WITH_GOOGLE;
mockSettings.merged.security.auth.enforcedType = AuthType.LOGIN_WITH_GOOGLE;
const nonInteractiveConfig = createLocalMockConfig({
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
});
@@ -336,8 +335,7 @@ describe('validateNonInterActiveAuth', () => {
});
it('exits if auth from env var does not match enforcedAuthType', async () => {
mockSettings.merged.security!.auth!.enforcedType =
AuthType.LOGIN_WITH_GOOGLE;
mockSettings.merged.security.auth.enforcedType = AuthType.LOGIN_WITH_GOOGLE;
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createLocalMockConfig({
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
@@ -398,7 +396,7 @@ describe('validateNonInterActiveAuth', () => {
});
it(`prints JSON error when enforced auth mismatches current auth and exits with code ${ExitCodes.FATAL_AUTHENTICATION_ERROR}`, async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
mockSettings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const nonInteractiveConfig = createLocalMockConfig({
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
getContentGeneratorConfig: vi
@@ -39,7 +39,7 @@ export async function validateNonInteractiveAuth(
try {
const effectiveAuthType = configuredAuthType || getAuthTypeFromEnv();
const enforcedType = settings.merged.security?.auth?.enforcedType;
const enforcedType = settings.merged.security.auth.enforcedType;
if (enforcedType && effectiveAuthType !== enforcedType) {
const message = effectiveAuthType
? `The enforced authentication type is '${enforcedType}', but the current type is '${effectiveAuthType}'. Please re-authenticate with the correct type.`
@@ -121,7 +121,7 @@ export class GeminiAgent {
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
const method = z.nativeEnum(AuthType).parse(methodId);
const selectedAuthType = this.settings.merged.security?.auth?.selectedType;
const selectedAuthType = this.settings.merged.security.auth.selectedType;
// Only clear credentials when switching to a different auth method
if (selectedAuthType && selectedAuthType !== method) {
@@ -147,7 +147,7 @@ export class GeminiAgent {
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
let isAuthenticated = false;
if (this.settings.merged.security?.auth?.selectedType) {
if (this.settings.merged.security.auth.selectedType) {
try {
await config.refreshAuth(
this.settings.merged.security.auth.selectedType,
+2 -1
View File
@@ -312,7 +312,8 @@ describe('Telemetry SDK', () => {
expect(GcpTraceExporter).not.toHaveBeenCalled();
// 2. Set project ID and emit post_auth event
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project');
vi.stubEnv('OTLP_GOOGLE_CLOUD_PROJECT', 'test-project');
const mockCredentials = {
client_email: 'test@example.com',