Merge branch 'main' into feat/card-component

This commit is contained in:
Mark McLaughlin
2026-02-10 14:22:34 -08:00
committed by GitHub
599 changed files with 26223 additions and 9065 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.30.0-nightly.20260210.a2174751d",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -117,6 +117,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const agentSettings = persistedState._agentSettings;
const config = await this.getConfig(agentSettings, sdkTask.id);
const contextId: string =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(metadata['_contextId'] as string) || sdkTask.contextId;
const runtimeTask = await Task.create(
sdkTask.id,
@@ -140,6 +141,7 @@ export class CoderAgentExecutor implements AgentExecutor {
agentSettingsInput?: AgentSettings,
eventBus?: ExecutionEventBus,
): Promise<TaskWrapper> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = agentSettingsInput || ({} as AgentSettings);
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
@@ -290,6 +292,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const contextId: string =
userMessage.contextId ||
sdkTask?.contextId ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(sdkTask?.metadata?.['_contextId'] as string) ||
uuidv4();
@@ -385,6 +388,7 @@ export class CoderAgentExecutor implements AgentExecutor {
}
} else {
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = userMessage.metadata?.[
'coderAgent'
] as AgentSettings;
+10 -1
View File
@@ -378,6 +378,7 @@ export class Task {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
this.pendingToolConfirmationDetails.set(
tc.request.callId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
tc.confirmationDetails as ToolCallConfirmationDetails,
);
}
@@ -411,7 +412,7 @@ export class Task {
);
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
@@ -465,12 +466,14 @@ export class Task {
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys<T>,
>(from: T, ...fields: K[]): Partial<T> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const ret = {} as Pick<T, K>;
for (const field of fields) {
if (field in from) {
ret[field] = from[field];
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return ret as Partial<T>;
}
@@ -493,6 +496,7 @@ export class Task {
);
if (tc.tool) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
serializableToolCall.tool = this._pickFields(
tc.tool,
'name',
@@ -622,8 +626,11 @@ export class Task {
request.args['new_string']
) {
const newContent = await this.getProposedContent(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['file_path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['old_string'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['new_string'] as string,
);
return { ...request, args: { ...request.args, newContent } };
@@ -719,6 +726,7 @@ export class Task {
case GeminiEventType.Error:
default: {
// Block scope for lexical declaration
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
const errorMessage =
errorEvent.value?.error.message ?? 'Unknown error from LLM stream';
@@ -807,6 +815,7 @@ export class Task {
if (confirmationDetails.type === 'edit') {
const payload = part.data['newContent']
? ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
newContent: part.data['newContent'] as string,
} as ToolConfirmationPayload)
: undefined;
+1
View File
@@ -85,6 +85,7 @@ export class InitCommand implements Command {
if (!context.agentExecutor) {
throw new Error('Agent executor not found in context.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentExecutor = context.agentExecutor as CoderAgentExecutor;
const agentSettings: AgentSettings = {
@@ -41,9 +41,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
return mockConfig;
}),
loadServerHierarchicalMemory: vi
.fn()
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: { global: '', extension: '', project: '' },
fileCount: 0,
filePaths: [],
}),
startupProfiler: {
flush: vi.fn(),
},
+2 -5
View File
@@ -18,7 +18,6 @@ import {
loadServerHierarchicalMemory,
GEMINI_DIR,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_GEMINI_MODEL,
type ExtensionLoader,
startupProfiler,
PREVIEW_GEMINI_MODEL,
@@ -60,9 +59,7 @@ export async function loadConfig(
const configParams: ConfigParameters = {
sessionId: taskId,
model: settings.general?.previewFeatures
? PREVIEW_GEMINI_MODEL
: DEFAULT_GEMINI_MODEL,
model: PREVIEW_GEMINI_MODEL,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
targetDir: workspaceDir, // Or a specific directory the agent operates on
@@ -80,6 +77,7 @@ export async function loadConfig(
cwd: workspaceDir,
telemetry: {
enabled: settings.telemetry?.enabled,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
target: settings.telemetry?.target as TelemetryTarget,
otlpEndpoint:
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
@@ -104,7 +102,6 @@ export async function loadConfig(
trustedFolder: true,
extensionLoader,
checkpointing,
previewFeatures: settings.general?.previewFeatures,
interactive: true,
enableInteractiveShell: true,
ptyInfo: 'auto',
@@ -93,6 +93,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = JSON.parse(configContent) as ExtensionConfig;
if (!config.name || !config.version) {
logger.error(
@@ -107,6 +108,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
.map((contextFileName) => path.join(extensionDir, contextFileName))
.filter((contextFilePath) => fs.existsSync(contextFilePath));
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
name: config.name,
version: config.version,
@@ -140,6 +142,7 @@ export function loadInstallMetadata(
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (e) {
@@ -89,67 +89,6 @@ describe('loadSettings', () => {
vi.restoreAllMocks();
});
it('should load nested previewFeatures from user settings', () => {
const settings = {
general: {
previewFeatures: true,
},
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
const result = loadSettings(mockWorkspaceDir);
expect(result.general?.previewFeatures).toBe(true);
});
it('should load nested previewFeatures from workspace settings', () => {
const settings = {
general: {
previewFeatures: true,
},
};
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(settings));
const result = loadSettings(mockWorkspaceDir);
expect(result.general?.previewFeatures).toBe(true);
});
it('should prioritize workspace settings over user settings', () => {
const userSettings = {
general: {
previewFeatures: false,
},
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = {
general: {
previewFeatures: true,
},
};
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
const result = loadSettings(mockWorkspaceDir);
expect(result.general?.previewFeatures).toBe(true);
});
it('should handle missing previewFeatures', () => {
const settings = {
general: {},
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
const result = loadSettings(mockWorkspaceDir);
expect(result.general?.previewFeatures).toBeUndefined();
});
it('should load other top-level settings correctly', () => {
const settings = {
showMemoryUsage: true,
+4 -3
View File
@@ -31,9 +31,6 @@ export interface Settings {
showMemoryUsage?: boolean;
checkpointing?: CheckpointingSettings;
folderTrust?: boolean;
general?: {
previewFeatures?: boolean;
};
// Git-aware file filtering settings
fileFiltering?: {
@@ -70,6 +67,7 @@ export function loadSettings(workspaceDir: string): Settings {
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedUserSettings = JSON.parse(
stripJsonComments(userContent),
) as Settings;
@@ -92,6 +90,7 @@ export function loadSettings(workspaceDir: string): Settings {
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
@@ -142,10 +141,12 @@ function resolveEnvVarsInObject<T>(obj: T): T {
}
if (typeof obj === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return resolveEnvVarsInString(obj) as unknown as T;
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
}
+2
View File
@@ -118,6 +118,7 @@ async function handleExecuteCommand(
const eventHandler = (event: AgentExecutionEvent) => {
const jsonRpcResponse = {
jsonrpc: '2.0',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
id: 'taskId' in event ? event.taskId : (event as Message).messageId,
result: event,
};
@@ -206,6 +207,7 @@ export async function createApp() {
expressApp.post('/tasks', async (req, res) => {
try {
const taskId = uuidv4();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = req.body.agentSettings as
| AgentSettings
| undefined;
@@ -95,6 +95,7 @@ export class GCSTaskStore implements TaskStore {
await this.ensureBucketInitialized();
const taskId = task.id;
const persistedState = getPersistedState(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
task.metadata as PersistedTaskMetadata,
);
+1
View File
@@ -125,6 +125,7 @@ export const METADATA_KEY = '__persistedState';
export function getPersistedState(
metadata: PersistedTaskMetadata,
): PersistedStateMetadata | undefined {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
}
+13 -5
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import type {
Task as SDKTask,
TaskStatusUpdateEvent,
@@ -12,11 +13,11 @@ import type {
import {
ApprovalMode,
DEFAULT_GEMINI_MODEL,
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
GeminiClient,
HookSystem,
PolicyDecision,
tmpdir,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
@@ -25,6 +26,8 @@ import { expect, vi } from 'vitest';
export function createMockConfig(
overrides: Partial<Config> = {},
): Partial<Config> {
const tmpDir = tmpdir();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn(),
@@ -39,15 +42,15 @@ export function createMockConfig(
getWorkspaceContext: vi.fn().mockReturnValue({
isPathWithinWorkspace: () => true,
}),
getTargetDir: () => '/test',
getTargetDir: () => tmpDir,
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
storage: {
getProjectTempDir: () => '/tmp',
getProjectTempCheckpointsDir: () => '/tmp/checkpoints',
getProjectTempDir: () => tmpDir,
getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'),
} as Storage,
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
getDebugMode: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }),
@@ -147,6 +150,7 @@ export function assertUniqueFinalEventIsLast(
events: SendStreamingMessageSuccessResponse[],
) {
// Final event is input-required & final
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const finalEvent = events[events.length - 1].result as TaskStatusUpdateEvent;
expect(finalEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'state-change',
@@ -156,9 +160,11 @@ export function assertUniqueFinalEventIsLast(
// There is only one event with final and its the last
expect(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
events.filter((e) => (e.result as TaskStatusUpdateEvent).final).length,
).toBe(1);
expect(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
events.findIndex((e) => (e.result as TaskStatusUpdateEvent).final),
).toBe(events.length - 1);
}
@@ -167,11 +173,13 @@ export function assertTaskCreationAndWorkingStatus(
events: SendStreamingMessageSuccessResponse[],
) {
// Initial task creation event
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const taskEvent = events[0].result as SDKTask;
expect(taskEvent.kind).toBe('task');
expect(taskEvent.status.state).toBe('submitted');
// Status update: working
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const workingEvent = events[1].result as TaskStatusUpdateEvent;
expect(workingEvent.kind).toBe('status-update');
expect(workingEvent.status.state).toBe('working');
+10 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.30.0-nightly.20260210.a2174751d",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -34,10 +34,11 @@
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"@types/update-notifier": "^6.0.8",
"ansi-escapes": "^7.3.0",
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
"diff": "^8.0.3",
@@ -46,7 +47,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.8",
"ink": "npm:@jrichman/ink@6.4.10",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -54,8 +55,8 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"react": "^19.2.0",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"string-width": "^8.1.0",
@@ -64,27 +65,21 @@
"tar": "^7.5.2",
"tinygradient": "^1.1.5",
"undici": "^7.10.0",
"wrap-ansi": "9.0.2",
"ws": "^8.16.0",
"yargs": "^17.7.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/runtime": "^7.27.6",
"@google/gemini-cli-test-utils": "file:../test-utils",
"@types/archiver": "^6.0.3",
"@types/command-exists": "^1.2.3",
"@types/dotenv": "^6.1.1",
"@types/hast": "^3.0.4",
"@types/node": "^20.11.24",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/semver": "^7.7.0",
"@types/shell-quote": "^1.7.5",
"@types/tar": "^6.1.13",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"archiver": "^7.0.1",
"ink-testing-library": "^4.0.0",
"pretty-format": "^30.0.2",
"react-dom": "^19.2.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
@@ -71,6 +71,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
extensionManager,
name,
setting,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
@@ -79,6 +80,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
await configureExtension(
extensionManager,
name,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
@@ -86,6 +88,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
else {
await configureAllExtensions(
extensionManager,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
@@ -79,7 +79,9 @@ export const disableCommand: CommandModule = {
}),
handler: async (argv) => {
await handleDisable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
@@ -105,7 +105,9 @@ export const enableCommand: CommandModule = {
}),
handler: async (argv) => {
await handleEnable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
@@ -99,10 +99,15 @@ export const installCommand: CommandModule = {
}),
handler: async (argv) => {
await handleInstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
source: argv['source'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ref: argv['ref'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
autoUpdate: argv['auto-update'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
allowPreRelease: argv['pre-release'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
+6 -1
View File
@@ -5,6 +5,7 @@
*/
import type { CommandModule } from 'yargs';
import chalk from 'chalk';
import {
debugLogger,
type ExtensionInstallMetadata,
@@ -49,7 +50,9 @@ export async function handleLink(args: InstallArgs) {
const extension =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(
`Extension "${extension.name}" linked successfully and enabled.`,
chalk.green(
`Extension "${extension.name}" linked successfully and enabled.`,
),
);
} catch (error) {
debugLogger.error(getErrorMessage(error));
@@ -76,7 +79,9 @@ export const linkCommand: CommandModule = {
.check((_) => true),
handler: async (argv) => {
await handleLink({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
@@ -62,6 +62,7 @@ export const listCommand: CommandModule = {
}),
handler: async (argv) => {
await handleList({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
outputFormat: argv['output-format'] as 'text' | 'json',
});
await exitCli();
@@ -98,7 +98,9 @@ export const newCommand: CommandModule = {
},
handler: async (args) => {
await handleNew({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: args['path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
template: args['template'] as string | undefined,
});
await exitCli();
@@ -71,6 +71,7 @@ export const uninstallCommand: CommandModule = {
}),
handler: async (argv) => {
await handleUninstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
names: argv['names'] as string[],
});
await exitCli();
@@ -155,7 +155,9 @@ export const updateCommand: CommandModule = {
}),
handler: async (argv) => {
await handleUpdate({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
all: argv['all'] as boolean | undefined,
});
await exitCli();
@@ -100,6 +100,7 @@ export const validateCommand: CommandModule = {
}),
handler: async (args) => {
await handleValidate({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: args['path'] as string,
});
await exitCli();
@@ -70,6 +70,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
return claudeHook;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const hook = claudeHook as Record<string, unknown>;
const migrated: Record<string, unknown> = {};
@@ -107,10 +108,12 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
return {};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = claudeConfig as Record<string, unknown>;
const geminiHooks: Record<string, unknown> = {};
// Check if there's a hooks section
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const hooksSection = config['hooks'] as Record<string, unknown> | undefined;
if (!hooksSection || typeof hooksSection !== 'object') {
return {};
@@ -130,6 +133,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
return def;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const definition = def as Record<string, unknown>;
const migratedDef: Record<string, unknown> = {};
@@ -179,6 +183,7 @@ export async function handleMigrateFromClaude() {
sourceFile = claudeLocalSettingsPath;
try {
const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
string,
unknown
@@ -192,6 +197,7 @@ export async function handleMigrateFromClaude() {
sourceFile = claudeSettingsPath;
try {
const content = fs.readFileSync(claudeSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
string,
unknown
@@ -259,6 +265,7 @@ export const migrateCommand: CommandModule = {
default: false,
}),
handler: async (argv) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const args = argv as unknown as MigrateArgs;
if (args.fromClaude) {
await handleMigrateFromClaude();
+14
View File
@@ -219,24 +219,38 @@ export const addCommand: CommandModule = {
.middleware((argv) => {
// Handle -- separator args as server args if present
if (argv['--']) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const existingArgs = (argv['args'] as Array<string | number>) || [];
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['args'] = [...existingArgs, ...(argv['--'] as string[])];
}
}),
handler: async (argv) => {
await addMcpServer(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['commandOrUrl'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['args'] as Array<string | number>,
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
transport: argv['transport'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
env: argv['env'] as string[],
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
header: argv['header'] as string[],
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
timeout: argv['timeout'] as number | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
trust: argv['trust'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
description: argv['description'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
includeTools: argv['includeTools'] as string[] | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
excludeTools: argv['excludeTools'] as string[] | undefined,
},
);
@@ -32,6 +32,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...original,
createTransport: vi.fn(),
MCPServerStatus: {
CONNECTED: 'CONNECTED',
CONNECTING: 'CONNECTING',
@@ -223,4 +224,46 @@ describe('mcp list command', () => {
),
);
});
it('should filter servers based on admin allowlist passed in settings', async () => {
const settingsWithAllowlist = mergeSettings({}, {}, {}, {}, true);
settingsWithAllowlist.admin = {
secureModeEnabled: false,
extensions: { enabled: true },
skills: { enabled: true },
mcp: {
enabled: true,
config: {
'allowed-server': { url: 'http://allowed' },
},
},
};
settingsWithAllowlist.mcpServers = {
'allowed-server': { command: 'cmd1' },
'forbidden-server': { command: 'cmd2' },
};
mockedLoadSettings.mockReturnValue({
merged: settingsWithAllowlist,
});
mockClient.connect.mockResolvedValue(undefined);
mockClient.ping.mockResolvedValue(undefined);
await listMcpServers(settingsWithAllowlist);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('allowed-server'),
);
expect(debugLogger.log).not.toHaveBeenCalledWith(
expect.stringContaining('forbidden-server'),
);
expect(mockedCreateTransport).toHaveBeenCalledWith(
'allowed-server',
expect.objectContaining({ url: 'http://allowed' }), // Should use admin config
false,
expect.anything(),
);
});
});
+41 -14
View File
@@ -6,12 +6,14 @@
// File for 'gemini mcp list' command
import type { CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import { type MergedSettings, loadSettings } from '../../config/settings.js';
import type { MCPServerConfig } from '@google/gemini-cli-core';
import {
MCPServerStatus,
createTransport,
debugLogger,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
} from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionManager } from '../../config/extension-manager.js';
@@ -24,18 +26,24 @@ const COLOR_YELLOW = '\u001b[33m';
const COLOR_RED = '\u001b[31m';
const RESET_COLOR = '\u001b[0m';
export async function getMcpServersFromConfig(): Promise<
Record<string, MCPServerConfig>
> {
const settings = loadSettings();
export async function getMcpServersFromConfig(
settings?: MergedSettings,
): Promise<{
mcpServers: Record<string, MCPServerConfig>;
blockedServerNames: string[];
}> {
if (!settings) {
settings = loadSettings().merged;
}
const extensionManager = new ExtensionManager({
settings: settings.merged,
settings,
workspaceDir: process.cwd(),
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
});
const extensions = await extensionManager.loadExtensions();
const mcpServers = { ...settings.merged.mcpServers };
const mcpServers = { ...settings.mcpServers };
for (const extension of extensions) {
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
if (mcpServers[key]) {
@@ -47,7 +55,11 @@ export async function getMcpServersFromConfig(): Promise<
};
});
}
return mcpServers;
const adminAllowlist = settings.admin?.mcp?.config;
const filteredResult = applyAdminAllowlist(mcpServers, adminAllowlist);
return filteredResult;
}
async function testMCPConnection(
@@ -103,12 +115,23 @@ async function getServerStatus(
return testMCPConnection(serverName, server);
}
export async function listMcpServers(): Promise<void> {
const mcpServers = await getMcpServersFromConfig();
export async function listMcpServers(settings?: MergedSettings): Promise<void> {
const { mcpServers, blockedServerNames } =
await getMcpServersFromConfig(settings);
const serverNames = Object.keys(mcpServers);
if (blockedServerNames.length > 0) {
const message = getAdminBlockedMcpServersMessage(
blockedServerNames,
undefined,
);
debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n');
}
if (serverNames.length === 0) {
debugLogger.log('No MCP servers configured.');
if (blockedServerNames.length === 0) {
debugLogger.log('No MCP servers configured.');
}
return;
}
@@ -154,11 +177,15 @@ export async function listMcpServers(): Promise<void> {
}
}
export const listCommand: CommandModule = {
interface ListArgs {
settings?: MergedSettings;
}
export const listCommand: CommandModule<object, ListArgs> = {
command: 'list',
describe: 'List all configured MCP servers',
handler: async () => {
await listMcpServers();
handler: async (argv) => {
await listMcpServers(argv.settings);
await exitCli();
},
};
+2
View File
@@ -55,7 +55,9 @@ export const removeCommand: CommandModule = {
choices: ['user', 'project'],
}),
handler: async (argv) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
await removeMcpServer(argv['name'] as string, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
@@ -53,6 +53,7 @@ export const disableCommand: CommandModule = {
? SettingScope.Workspace
: SettingScope.User;
await handleDisable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
scope,
});
@@ -40,6 +40,7 @@ export const enableCommand: CommandModule = {
}),
handler: async (argv) => {
await handleEnable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
});
await exitCli();
@@ -102,9 +102,13 @@ export const installCommand: CommandModule = {
}),
handler: async (argv) => {
await handleInstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
source: argv['source'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as 'user' | 'workspace',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
+3
View File
@@ -84,8 +84,11 @@ export const linkCommand: CommandModule = {
}),
handler: async (argv) => {
await handleLink({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as 'user' | 'workspace',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
+2
View File
@@ -18,6 +18,7 @@ export async function handleList(args: { all?: boolean }) {
const config = await loadCliConfig(
settings.merged,
'skills-list-session',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
{
debug: false,
} as Partial<CliArgs> as CliArgs,
@@ -72,6 +73,7 @@ export const listCommand: CommandModule = {
default: false,
}),
handler: async (argv) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
await handleList({ all: argv['all'] as boolean });
await exitCli();
},
@@ -64,7 +64,9 @@ export const uninstallCommand: CommandModule = {
}),
handler: async (argv) => {
await handleUninstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as 'user' | 'workspace',
});
await exitCli();
+94 -14
View File
@@ -141,6 +141,22 @@ vi.mock('@google/gemini-cli-core', async () => {
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
})),
isHeadlessMode: vi.fn((opts) => {
if (process.env['VITEST'] === 'true') {
return (
!!opts?.prompt ||
(!!process.stdin && !process.stdin.isTTY) ||
(!!process.stdout && !process.stdout.isTTY)
);
}
return (
!!opts?.prompt ||
process.env['CI'] === 'true' ||
process.env['GITHUB_ACTIONS'] === 'true' ||
(!!process.stdin && !process.stdin.isTTY) ||
(!!process.stdout && !process.stdout.isTTY)
);
}),
};
});
@@ -154,6 +170,8 @@ vi.mock('./extension-manager.js', () => {
// Global setup to ensure clean environment for all tests in this file
const originalArgv = process.argv;
const originalGeminiModel = process.env['GEMINI_MODEL'];
const originalStdoutIsTTY = process.stdout.isTTY;
const originalStdinIsTTY = process.stdin.isTTY;
beforeEach(() => {
delete process.env['GEMINI_MODEL'];
@@ -162,6 +180,18 @@ beforeEach(() => {
ExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
// Default to interactive mode for tests unless otherwise specified
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
configurable: true,
writable: true,
});
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
writable: true,
});
});
afterEach(() => {
@@ -171,6 +201,16 @@ afterEach(() => {
} else {
delete process.env['GEMINI_MODEL'];
}
Object.defineProperty(process.stdout, 'isTTY', {
value: originalStdoutIsTTY,
configurable: true,
writable: true,
});
Object.defineProperty(process.stdin, 'isTTY', {
value: originalStdinIsTTY,
configurable: true,
writable: true,
});
});
describe('parseArguments', () => {
@@ -249,6 +289,16 @@ describe('parseArguments', () => {
});
describe('positional arguments and @commands', () => {
beforeEach(() => {
// Default to headless mode for these tests as they mostly expect one-shot behavior
process.stdin.isTTY = false;
Object.defineProperty(process.stdout, 'isTTY', {
value: false,
configurable: true,
writable: true,
});
});
it.each([
{
description:
@@ -379,8 +429,12 @@ describe('parseArguments', () => {
);
it('should include a startup message when converting positional query to interactive prompt', async () => {
const originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = true;
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
configurable: true,
writable: true,
});
process.argv = ['node', 'script.js', 'hello'];
try {
@@ -389,7 +443,7 @@ describe('parseArguments', () => {
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
);
} finally {
process.stdin.isTTY = originalIsTTY;
// beforeEach handles resetting
}
});
});
@@ -1511,7 +1565,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
});
const config = await loadCliConfig(settings, 'test-session', argv);
const mergedServers = config.getMcpServers();
const mergedServers = config.getMcpServers() ?? {};
expect(mergedServers).toHaveProperty('serverA');
expect(mergedServers).not.toHaveProperty('serverB');
});
@@ -1569,9 +1623,9 @@ describe('loadCliConfig with admin.mcp.config', () => {
});
const config = await loadCliConfig(settings, 'test-session', argv);
const mergedServers = config.getMcpServers();
const mergedServers = config.getMcpServers() ?? {};
expect(mergedServers).not.toHaveProperty('serverC');
expect(Object.keys(mergedServers || {})).toHaveLength(0);
expect(Object.keys(mergedServers)).toHaveLength(0);
});
it('should merge local fields and prefer admin tool filters', async () => {
@@ -1601,7 +1655,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
});
const config = await loadCliConfig(settings, 'test-session', argv);
const serverA = config.getMcpServers()?.['serverA'];
const serverA = (config.getMcpServers() ?? {})['serverA'];
expect(serverA).toMatchObject({
timeout: 1234,
includeTools: ['admin_tool'],
@@ -1683,7 +1737,7 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('auto-gemini-2.5');
expect(config.getModel()).toBe('auto-gemini-3');
});
it('always prefers model from argv', async () => {
@@ -1727,19 +1781,34 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('auto-gemini-2.5');
expect(config.getModel()).toBe('auto-gemini-3');
});
});
describe('loadCliConfig folderTrust', () => {
let originalVitest: string | undefined;
let originalIntegrationTest: string | undefined;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
originalVitest = process.env['VITEST'];
originalIntegrationTest = process.env['GEMINI_CLI_INTEGRATION_TEST'];
delete process.env['VITEST'];
delete process.env['GEMINI_CLI_INTEGRATION_TEST'];
});
afterEach(() => {
if (originalVitest !== undefined) {
process.env['VITEST'] = originalVitest;
}
if (originalIntegrationTest !== undefined) {
process.env['GEMINI_CLI_INTEGRATION_TEST'] = originalIntegrationTest;
}
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -1798,10 +1867,11 @@ describe('loadCliConfig with includeDirectories', () => {
vi.restoreAllMocks();
});
it('should combine and resolve paths from settings and CLI arguments', async () => {
it.skip('should combine and resolve paths from settings and CLI arguments', async () => {
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
process.argv = [
'node',
'script.js',
'--include-directories',
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
@@ -2555,7 +2625,7 @@ describe('loadCliConfig approval mode', () => {
it('should use approvalMode from settings when no CLI flags are set', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'auto_edit' },
general: { defaultApprovalMode: 'auto_edit' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2567,7 +2637,7 @@ describe('loadCliConfig approval mode', () => {
it('should prioritize --approval-mode flag over settings', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'default' },
general: { defaultApprovalMode: 'default' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2579,7 +2649,7 @@ describe('loadCliConfig approval mode', () => {
it('should prioritize --yolo flag over settings', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'auto_edit' },
general: { defaultApprovalMode: 'auto_edit' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2589,7 +2659,7 @@ describe('loadCliConfig approval mode', () => {
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'plan' },
general: { defaultApprovalMode: 'plan' },
experimental: { plan: true },
});
const argv = await parseArguments(settings);
@@ -2600,7 +2670,7 @@ describe('loadCliConfig approval mode', () => {
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'plan' },
general: { defaultApprovalMode: 'plan' },
experimental: { plan: false },
});
const argv = await parseArguments(settings);
@@ -2779,6 +2849,16 @@ describe('Output format', () => {
describe('parseArguments with positional prompt', () => {
const originalArgv = process.argv;
beforeEach(() => {
// Default to headless mode for these tests as they mostly expect one-shot behavior
process.stdin.isTTY = false;
Object.defineProperty(process.stdout, 'isTTY', {
value: false,
configurable: true,
writable: true,
});
});
afterEach(() => {
process.argv = originalArgv;
});
+39 -52
View File
@@ -15,7 +15,6 @@ import {
setGeminiMdFilename as setServerGeminiMdFilename,
getCurrentGeminiMdFilename,
ApprovalMode,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
@@ -33,16 +32,17 @@ import {
ASK_USER_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
isHeadlessMode,
Config,
} from '@google/gemini-cli-core';
import type {
MCPServerConfig,
HookDefinition,
HookEventName,
OutputFormat,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
type HookDefinition,
type HookEventName,
type OutputFormat,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -280,6 +280,7 @@ export async function parseArguments(
.check((argv) => {
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
// This guard safely checks if any positional argument was provided.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const query = argv['query'] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
@@ -297,6 +298,7 @@ export async function parseArguments(
if (
argv['outputFormat'] &&
!['text', 'json', 'stream-json'].includes(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['outputFormat'] as string,
)
) {
@@ -345,6 +347,7 @@ export async function parseArguments(
}
// Normalize query args: handle both quoted "@path file" and unquoted @path file
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const queryArg = (result as { query?: string | string[] | undefined }).query;
const q: string | undefined = Array.isArray(queryArg)
? queryArg.join(' ')
@@ -352,7 +355,7 @@ export async function parseArguments(
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
if (q && !result['prompt']) {
if (process.stdin.isTTY) {
if (!isHeadlessMode()) {
startupMessages.push(
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
);
@@ -368,6 +371,7 @@ export async function parseArguments(
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return result as unknown as CliArgs;
}
@@ -436,7 +440,11 @@ export async function loadCliConfig(
const ideMode = settings.ide?.enabled ?? false;
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
const folderTrust =
process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true' ||
process.env['VITEST'] === 'true'
? false
: (settings.security?.folderTrust?.enabled ?? false);
const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false;
// Set the context filename in the server's memoryTool module BEFORE loading memory
@@ -472,6 +480,7 @@ export async function loadCliConfig(
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
});
@@ -479,7 +488,7 @@ export async function loadCliConfig(
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let memoryContent = '';
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
@@ -510,8 +519,8 @@ export async function loadCliConfig(
const rawApprovalMode =
argv.approvalMode ||
(argv.yolo ? 'yolo' : undefined) ||
((settings.tools?.approvalMode as string) !== 'yolo'
? settings.tools.approvalMode
((settings.general?.defaultApprovalMode as string) !== 'yolo'
? settings.general?.defaultApprovalMode
: undefined);
if (rawApprovalMode) {
@@ -575,6 +584,7 @@ export async function loadCliConfig(
let telemetrySettings;
try {
telemetrySettings = await resolveTelemetrySettings({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
env: process.env as unknown as Record<string, string | undefined>,
settings: settings.telemetry,
});
@@ -592,7 +602,9 @@ export async function loadCliConfig(
const interactive =
!!argv.promptInteractive ||
!!argv.experimentalAcp ||
(process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand);
(!isHeadlessMode({ prompt: argv.prompt }) &&
!argv.query &&
!argv.isCommand);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const allowedToolsSet = new Set(allowedTools);
@@ -662,9 +674,7 @@ export async function loadCliConfig(
);
policyEngineConfig.nonInteractive = !interactive;
const defaultModel = settings.general?.previewFeatures
? PREVIEW_GEMINI_MODEL_AUTO
: DEFAULT_GEMINI_MODEL_AUTO;
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
const specifiedModel =
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
@@ -695,38 +705,17 @@ export async function loadCliConfig(
let mcpServers = mcpEnabled ? settings.mcpServers : {};
if (mcpEnabled && adminAllowlist && Object.keys(adminAllowlist).length > 0) {
const filteredMcpServers: Record<string, MCPServerConfig> = {};
for (const [serverId, localConfig] of Object.entries(mcpServers)) {
const adminConfig = adminAllowlist[serverId];
if (adminConfig) {
const mergedConfig = {
...localConfig,
url: adminConfig.url,
type: adminConfig.type,
trust: adminConfig.trust,
};
// Remove local connection details
delete mergedConfig.command;
delete mergedConfig.args;
delete mergedConfig.env;
delete mergedConfig.cwd;
delete mergedConfig.httpUrl;
delete mergedConfig.tcp;
if (
(adminConfig.includeTools && adminConfig.includeTools.length > 0) ||
(adminConfig.excludeTools && adminConfig.excludeTools.length > 0)
) {
mergedConfig.includeTools = adminConfig.includeTools;
mergedConfig.excludeTools = adminConfig.excludeTools;
}
filteredMcpServers[serverId] = mergedConfig;
}
}
mcpServers = filteredMcpServers;
const result = applyAdminAllowlist(mcpServers, adminAllowlist);
mcpServers = result.mcpServers;
mcpServerCommand = undefined;
if (result.blockedServerNames && result.blockedServerNames.length > 0) {
const message = getAdminBlockedMcpServersMessage(
result.blockedServerNames,
undefined,
);
coreEvents.emitConsoleLog('warn', message);
}
}
return new Config({
@@ -740,7 +729,6 @@ export async function loadCliConfig(
settings.context?.loadMemoryFromIncludeDirectories || false,
debugMode,
question,
previewFeatures: settings.general?.previewFeatures,
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
@@ -801,11 +789,11 @@ export async function loadCliConfig(
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
enableEventDrivenScheduler:
settings.experimental?.enableEventDrivenScheduler,
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
toolOutputMasking: settings.experimental?.toolOutputMasking,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
@@ -823,11 +811,10 @@ export async function loadCliConfig(
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
eventEmitter: coreEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
output: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
fakeResponses: argv.fakeResponses,
@@ -16,10 +16,11 @@ import {
vi,
afterEach,
} from 'vitest';
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionManager } from './extension-manager.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { GEMINI_DIR, type Config } from '@google/gemini-cli-core';
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
import { createTestMergedSettings, SettingScope } from './settings.js';
describe('ExtensionManager theme loading', () => {
@@ -29,7 +30,7 @@ describe('ExtensionManager theme loading', () => {
beforeAll(async () => {
tempHomeDir = await fs.promises.mkdtemp(
path.join(fs.realpathSync('/tmp'), 'gemini-cli-test-'),
path.join(tmpdir(), 'gemini-cli-test-'),
);
});
@@ -85,6 +86,7 @@ describe('ExtensionManager theme loading', () => {
await extensionManager.loadExtensions();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
getEnableExtensionReloading: () => false,
getMcpClientManager: () => ({
@@ -170,6 +172,7 @@ describe('ExtensionManager theme loading', () => {
await extensionManager.loadExtensions();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
getWorkingDir: () => tempHomeDir,
shouldLoadMemoryFromIncludeDirectories: () => false,
+39 -7
View File
@@ -48,6 +48,8 @@ import {
type HookEventName,
type ResolvedExtensionSetting,
coreEvents,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
} from '@google/gemini-cli-core';
import { maybeRequestConsentOrFail } from './extensions/consent.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
@@ -186,7 +188,10 @@ export class ExtensionManager extends ExtensionLoader {
)
) {
const trustedFolders = loadTrustedFolders();
trustedFolders.setValue(this.workspaceDir, TrustLevel.TRUST_FOLDER);
await trustedFolders.setValue(
this.workspaceDir,
TrustLevel.TRUST_FOLDER,
);
} else {
throw new Error(
`Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`,
@@ -661,12 +666,33 @@ Would you like to attempt to install via "git clone" instead?`,
if (this.settings.admin.mcp.enabled === false) {
config.mcpServers = undefined;
} else {
config.mcpServers = Object.fromEntries(
Object.entries(config.mcpServers).map(([key, value]) => [
key,
filterMcpConfig(value),
]),
);
// Apply admin allowlist if configured
const adminAllowlist = this.settings.admin.mcp.config;
if (adminAllowlist && Object.keys(adminAllowlist).length > 0) {
const result = applyAdminAllowlist(
config.mcpServers,
adminAllowlist,
);
config.mcpServers = result.mcpServers;
if (result.blockedServerNames.length > 0) {
const message = getAdminBlockedMcpServersMessage(
result.blockedServerNames,
undefined,
);
coreEvents.emitConsoleLog('warn', message);
}
}
// Then apply local filtering/sanitization
if (config.mcpServers) {
config.mcpServers = Object.fromEntries(
Object.entries(config.mcpServers).map(([key, value]) => [
key,
filterMcpConfig(value),
]),
);
}
}
}
@@ -704,6 +730,7 @@ Would you like to attempt to install via "git clone" instead?`,
if (Object.keys(hookEnv).length > 0) {
for (const eventName of Object.keys(hooks)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const eventHooks = hooks[eventName as HookEventName];
if (eventHooks) {
for (const definition of eventHooks) {
@@ -800,13 +827,16 @@ Would you like to attempt to install via "git clone" instead?`,
}
try {
const configContent = await fs.promises.readFile(configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const rawConfig = JSON.parse(configContent) as ExtensionConfig;
if (!rawConfig.name || !rawConfig.version) {
throw new Error(
`Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawConfig as unknown as JsonObject,
{
extensionPath: extensionDir,
@@ -852,6 +882,7 @@ Would you like to attempt to install via "git clone" instead?`,
// Hydrate variables in the hooks configuration
const hydratedHooks = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawHooks.hooks as unknown as JsonObject,
{
...context,
@@ -862,6 +893,7 @@ Would you like to attempt to install via "git clone" instead?`,
return hydratedHooks;
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
return undefined; // File not found is not an error here.
}
+1
View File
@@ -47,6 +47,7 @@ export function loadInstallMetadata(
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (_e) {
@@ -0,0 +1,227 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from './extensionRegistryClient.js';
import { fetchWithTimeout } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', () => ({
fetchWithTimeout: vi.fn(),
}));
const mockExtensions: RegistryExtension[] = [
{
id: 'ext1',
rank: 1,
url: 'https://github.com/test/ext1',
fullName: 'test/ext1',
repoDescription: 'Test extension 1',
stars: 100,
lastUpdated: '2025-01-01T00:00:00Z',
extensionName: 'extension-one',
extensionVersion: '1.0.0',
extensionDescription: 'First test extension',
avatarUrl: 'https://example.com/avatar1.png',
hasMCP: true,
hasContext: false,
isGoogleOwned: false,
licenseKey: 'mit',
hasHooks: false,
hasCustomCommands: false,
hasSkills: false,
},
{
id: 'ext2',
rank: 2,
url: 'https://github.com/test/ext2',
fullName: 'test/ext2',
repoDescription: 'Test extension 2',
stars: 50,
lastUpdated: '2025-01-02T00:00:00Z',
extensionName: 'extension-two',
extensionVersion: '0.5.0',
extensionDescription: 'Second test extension',
avatarUrl: 'https://example.com/avatar2.png',
hasMCP: false,
hasContext: true,
isGoogleOwned: true,
licenseKey: 'apache-2.0',
hasHooks: false,
hasCustomCommands: false,
hasSkills: false,
},
{
id: 'ext3',
rank: 3,
url: 'https://github.com/test/ext3',
fullName: 'test/ext3',
repoDescription: 'Test extension 3',
stars: 10,
lastUpdated: '2025-01-03T00:00:00Z',
extensionName: 'extension-three',
extensionVersion: '0.1.0',
extensionDescription: 'Third test extension',
avatarUrl: 'https://example.com/avatar3.png',
hasMCP: true,
hasContext: true,
isGoogleOwned: false,
licenseKey: 'gpl-3.0',
hasHooks: false,
hasCustomCommands: false,
hasSkills: false,
},
];
describe('ExtensionRegistryClient', () => {
let client: ExtensionRegistryClient;
let fetchMock: Mock;
beforeEach(() => {
ExtensionRegistryClient.resetCache();
client = new ExtensionRegistryClient();
fetchMock = fetchWithTimeout as Mock;
fetchMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should fetch and return extensions with pagination (default ranking)', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const result = await client.getExtensions(1, 2);
expect(result.extensions).toHaveLength(2);
expect(result.extensions[0].id).toBe('ext1'); // rank 1
expect(result.extensions[1].id).toBe('ext2'); // rank 2
expect(result.total).toBe(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'https://geminicli.com/extensions.json',
10000,
);
});
it('should return extensions sorted alphabetically', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const result = await client.getExtensions(1, 3, 'alphabetical');
expect(result.extensions).toHaveLength(3);
expect(result.extensions[0].id).toBe('ext1');
expect(result.extensions[1].id).toBe('ext3');
expect(result.extensions[2].id).toBe('ext2');
});
it('should return the second page of extensions', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const result = await client.getExtensions(2, 2);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].id).toBe('ext3');
expect(result.total).toBe(3);
});
it('should search extensions by name', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const results = await client.searchExtensions('one');
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0].id).toBe('ext1');
});
it('should search extensions by description', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const results = await client.searchExtensions('Second');
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0].id).toBe('ext2');
});
it('should get an extension by ID', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const result = await client.getExtension('ext2');
expect(result).toBeDefined();
expect(result?.id).toBe('ext2');
});
it('should return undefined if extension not found', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const result = await client.getExtension('non-existent');
expect(result).toBeUndefined();
});
it('should cache the fetch result', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
await client.getExtensions();
await client.getExtensions();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('should share the fetch result across instances', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockExtensions,
});
const client1 = new ExtensionRegistryClient();
const client2 = new ExtensionRegistryClient();
await client1.getExtensions();
await client2.getExtensions();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('should throw an error if fetch fails', async () => {
fetchMock.mockResolvedValue({
ok: false,
statusText: 'Not Found',
});
await expect(client.getExtensions()).rejects.toThrow(
'Failed to fetch extensions: Not Found',
);
});
});
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { fetchWithTimeout } from '@google/gemini-cli-core';
import { AsyncFzf } from 'fzf';
export interface RegistryExtension {
id: string;
rank: number;
url: string;
fullName: string;
repoDescription: string;
stars: number;
lastUpdated: string;
extensionName: string;
extensionVersion: string;
extensionDescription: string;
avatarUrl: string;
hasMCP: boolean;
hasContext: boolean;
hasHooks: boolean;
hasSkills: boolean;
hasCustomCommands: boolean;
isGoogleOwned: boolean;
licenseKey: string;
}
export class ExtensionRegistryClient {
private static readonly REGISTRY_URL =
'https://geminicli.com/extensions.json';
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
private static fetchPromise: Promise<RegistryExtension[]> | null = null;
/** @internal */
static resetCache() {
ExtensionRegistryClient.fetchPromise = null;
}
async getExtensions(
page: number = 1,
limit: number = 10,
orderBy: 'ranking' | 'alphabetical' = 'ranking',
): Promise<{ extensions: RegistryExtension[]; total: number }> {
const allExtensions = [...(await this.fetchAllExtensions())];
switch (orderBy) {
case 'ranking':
allExtensions.sort((a, b) => a.rank - b.rank);
break;
case 'alphabetical':
allExtensions.sort((a, b) =>
a.extensionName.localeCompare(b.extensionName),
);
break;
default: {
const _exhaustiveCheck: never = orderBy;
throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`);
}
}
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
return {
extensions: allExtensions.slice(startIndex, endIndex),
total: allExtensions.length,
};
}
async searchExtensions(query: string): Promise<RegistryExtension[]> {
const allExtensions = await this.fetchAllExtensions();
if (!query.trim()) {
return allExtensions;
}
const fzf = new AsyncFzf(allExtensions, {
selector: (ext: RegistryExtension) =>
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
fuzzy: 'v2',
});
const results = await fzf.find(query);
return results.map((r: { item: RegistryExtension }) => r.item);
}
async getExtension(id: string): Promise<RegistryExtension | undefined> {
const allExtensions = await this.fetchAllExtensions();
return allExtensions.find((ext) => ext.id === id);
}
private async fetchAllExtensions(): Promise<RegistryExtension[]> {
if (ExtensionRegistryClient.fetchPromise) {
return ExtensionRegistryClient.fetchPromise;
}
ExtensionRegistryClient.fetchPromise = (async () => {
try {
const response = await fetchWithTimeout(
ExtensionRegistryClient.REGISTRY_URL,
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
);
if (!response.ok) {
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as RegistryExtension[];
} catch (error) {
// Clear the promise on failure so that subsequent calls can try again
ExtensionRegistryClient.fetchPromise = null;
throw error;
}
})();
return ExtensionRegistryClient.fetchPromise;
}
}
@@ -5,23 +5,20 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
import { getMissingSettings } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import {
KeychainTokenStorage,
debugLogger,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
coreEvents,
} from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { createTestMergedSettings } from '../settings.js';
// --- Mocks ---
vi.mock('node:fs', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = await importOriginal<any>();
@@ -29,11 +26,23 @@ vi.mock('node:fs', async (importOriginal) => {
...actual,
default: {
...actual.default,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
existsSync: vi.fn(),
statSync: vi.fn(),
lstatSync: vi.fn(),
realpathSync: vi.fn((p) => p),
},
existsSync: vi.fn(),
statSync: vi.fn(),
lstatSync: vi.fn(),
realpathSync: vi.fn((p) => p),
promises: {
...actual.promises,
mkdir: vi.fn(),
writeFile: vi.fn(),
rm: vi.fn(),
cp: vi.fn(),
readFile: vi.fn(),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
};
});
@@ -49,183 +58,101 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
log: vi.fn(),
},
coreEvents: {
emitFeedback: vi.fn(), // Mock emitFeedback
emitFeedback: vi.fn(),
on: vi.fn(),
off: vi.fn(),
emitConsoleLog: vi.fn(),
},
loadSkillsFromDir: vi.fn().mockResolvedValue([]),
loadAgentsFromDirectory: vi
.fn()
.mockResolvedValue({ agents: [], errors: [] }),
logExtensionInstallEvent: vi.fn().mockResolvedValue(undefined),
logExtensionUpdateEvent: vi.fn().mockResolvedValue(undefined),
logExtensionUninstall: vi.fn().mockResolvedValue(undefined),
logExtensionEnable: vi.fn().mockResolvedValue(undefined),
logExtensionDisable: vi.fn().mockResolvedValue(undefined),
Config: vi.fn().mockImplementation(() => ({
getEnableExtensionReloading: vi.fn().mockReturnValue(true),
})),
};
});
// Mock os.homedir because ExtensionStorage uses it
vi.mock('./consent.js', () => ({
maybeRequestConsentOrFail: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./extensionSettings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./extensionSettings.js')>();
return {
...actual,
getEnvContents: vi.fn().mockResolvedValue({}),
getMissingSettings: vi.fn(), // We will mock this implementation per test
};
});
vi.mock('../trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }), // Default to trusted to simplify flow
loadTrustedFolders: vi.fn().mockReturnValue({
setValue: vi.fn().mockResolvedValue(undefined),
}),
TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER' },
}));
// Mock ExtensionStorage to avoid real FS paths
vi.mock('./storage.js', () => ({
ExtensionStorage: class {
constructor(public name: string) {}
getExtensionDir() {
return `/mock/extensions/${this.name}`;
}
static getUserExtensionsDir() {
return '/mock/extensions';
}
static createTmpDir() {
return Promise.resolve('/mock/tmp');
}
},
}));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
const mockedOs = await importOriginal<typeof import('node:os')>();
return {
...mockedOs,
homedir: vi.fn(),
homedir: vi.fn().mockReturnValue('/mock/home'),
};
});
describe('extensionUpdates', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let extensionDir: string;
let mockKeychainData: Record<string, Record<string, string>>;
beforeEach(() => {
vi.clearAllMocks();
mockKeychainData = {};
// Default fs mocks
vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
vi.mocked(fs.promises.cp).mockResolvedValue(undefined);
// Mock Keychain
vi.mocked(KeychainTokenStorage).mockImplementation(
(serviceName: string) => {
if (!mockKeychainData[serviceName]) {
mockKeychainData[serviceName] = {};
}
const keychainData = mockKeychainData[serviceName];
return {
getSecret: vi
.fn()
.mockImplementation(
async (key: string) => keychainData[key] || null,
),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
} as unknown as KeychainTokenStorage;
},
);
// Allow directories to exist by default to satisfy Config/WorkspaceContext checks
vi.mocked(fs.existsSync).mockReturnValue(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any);
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
// Setup Temp Dirs
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
// Mock ExtensionStorage to rely on our temp extension dir
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
extensionDir,
);
// Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic.
// getEnvFilePath in extensionSettings.ts:
// if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there)
// if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env'
fs.mkdirSync(extensionDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
tempWorkspaceDir = '/mock/workspace';
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('getMissingSettings', () => {
it('should return empty list if all settings are present', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
// Setup User Env
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
fs.writeFileSync(userEnvPath, 'VAR1=val1');
// Setup Keychain
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext ${extensionId}`,
);
await userKeychain.setSecret('VAR2', 'val2');
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]);
});
it('should identify missing non-sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s1');
});
it('should identify missing sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s2');
});
it('should respect settings present in workspace', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
// Setup Workspace Env
const workspaceEnvPath = path.join(
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]);
});
});
describe('ExtensionManager integration', () => {
it('should warn about missing settings after update', async () => {
// Mock ExtensionManager methods to avoid FS/Network usage
// 1. Setup Data
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.1.0',
@@ -239,31 +166,30 @@ describe('extensionUpdates', () => {
};
const installMetadata: ExtensionInstallMetadata = {
source: extensionDir,
source: '/mock/source',
type: 'local',
autoUpdate: true,
};
// 2. Setup Manager
const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive
requestSetting: null,
});
// Mock methods called by installOrUpdateExtension
// 3. Mock Internal Manager Methods
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
vi.spyOn(manager, 'getExtensions').mockReturnValue([
{
name: 'test-ext',
version: '1.0.0',
installMetadata,
path: extensionDir,
// Mocks for other required props
path: '/mock/extensions/test-ext',
contextFiles: [],
mcpServers: {},
hooks: undefined,
@@ -275,23 +201,28 @@ describe('extensionUpdates', () => {
} as unknown as GeminiCLIExtension,
]);
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
// Mock loadExtension to return something so the method doesn't crash at the end
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue(
{} as unknown as GeminiCLIExtension,
);
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined);
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({
name: 'test-ext',
version: '1.1.0',
} as GeminiCLIExtension);
// Mock fs.promises for the operations inside installOrUpdateExtension
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks
try {
await manager.installOrUpdateExtension(installMetadata, previousConfig);
} catch (_) {
// Ignore errors from copyExtension or others, we just want to verify the warning
}
// 4. Mock External Helpers
// This is the key fix: we explicitly mock `getMissingSettings` to return
// the result we expect, avoiding any real FS or logic execution during the update.
vi.mocked(getMissingSettings).mockResolvedValue([
{
name: 's1',
description: 'd1',
envVar: 'VAR1',
},
]);
// 5. Execute
await manager.installOrUpdateExtension(installMetadata, previousConfig);
// 6. Assert
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Extension "test-ext" has missing settings: s1',
@@ -45,6 +45,7 @@ export async function fetchJson<T>(
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const data = Buffer.concat(chunks).toString();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
resolve(JSON.parse(data) as T);
});
})
@@ -52,9 +52,11 @@ export function recursivelyHydrateStrings<T>(
values: VariableContext,
): T {
if (typeof obj === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return hydrateString(obj, values) as unknown as T;
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.map((item) =>
recursivelyHydrateStrings(item, values),
) as unknown as T;
@@ -64,11 +66,13 @@ export function recursivelyHydrateStrings<T>(
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(obj as Record<string, unknown>)[key],
values,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return newObj as T;
}
return obj;
+26 -11
View File
@@ -80,6 +80,7 @@ export enum Command {
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning',
// App Controls
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
@@ -90,6 +91,7 @@ export enum Command {
TOGGLE_YOLO = 'app.toggleYolo',
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
SHOW_MORE_LINES = 'app.showMoreLines',
EXPAND_PASTE = 'app.expandPaste',
FOCUS_SHELL_INPUT = 'app.focusShellInput',
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
CLEAR_SCREEN = 'app.clearScreen',
@@ -281,14 +283,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
{ key: 'tab', shift: false },
],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }],
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
[Command.SHOW_MORE_LINES]: [
{ key: 'o', ctrl: true },
{ key: 's', ctrl: true },
],
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
[Command.RESTART_APP]: [{ key: 'r' }],
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
@@ -397,6 +401,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
Command.EXPAND_PASTE,
Command.TOGGLE_BACKGROUND_SHELL,
Command.TOGGLE_BACKGROUND_SHELL_LIST,
Command.KILL_BACKGROUND_SHELL,
@@ -405,6 +410,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
Command.FOCUS_SHELL_INPUT,
Command.UNFOCUS_SHELL_INPUT,
Command.CLEAR_SCREEN,
@@ -496,16 +502,25 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
[Command.BACKGROUND_SHELL_SELECT]: 'Enter',
[Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
[Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
[Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
[Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
[Command.EXPAND_PASTE]:
'Expand or collapse a paste placeholder when cursor is over placeholder.',
[Command.BACKGROUND_SHELL_SELECT]:
'Confirm selection in background shell list.',
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
[Command.TOGGLE_BACKGROUND_SHELL]:
'Toggle current background shell visibility.',
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.',
[Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.',
[Command.UNFOCUS_BACKGROUND_SHELL]:
'Move focus from background shell to Gemini.',
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]:
'Move focus from background shell list to Gemini.',
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
'Show warning when trying to unfocus background shell via Tab.',
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
'Show warning when trying to unfocus shell input via Tab.',
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',
@@ -358,6 +358,7 @@ export class McpServerEnablementManager {
private async readConfig(): Promise<McpServerEnablementConfig> {
try {
const content = await fs.readFile(this.configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(content) as McpServerEnablementConfig;
} catch (error) {
if (
@@ -323,116 +323,65 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.DENY);
});
it('should allow write_file to plans directory in Plan mode', async () => {
const settings: Settings = {};
describe.each(['write_file', 'replace'])(
'Plan Mode policy for %s',
(toolName) => {
it(`should allow ${toolName} to plans directory`, async () => {
const settings: Settings = {};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.PLAN,
);
const engine = new PolicyEngine(config);
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.PLAN,
);
const engine = new PolicyEngine(config);
// Valid plan file paths
const validPaths = [
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md',
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md',
'/home/user/.gemini/tmp/new-temp_dir_123/plans/plan.md', // new style of temp directory
];
// Valid plan file path (64-char hex hash, .md extension, safe filename)
const validPlanPath =
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md';
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: validPlanPath } },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
for (const file_path of validPaths) {
expect(
(
await engine.check(
{ name: toolName, args: { file_path } },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
}
});
// Valid plan with underscore in filename
const validPlanPath2 =
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md';
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: validPlanPath2 } },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
});
it(`should deny ${toolName} outside plans directory`, async () => {
const settings: Settings = {};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.PLAN,
);
const engine = new PolicyEngine(config);
it('should deny write_file outside plans directory in Plan mode', async () => {
const settings: Settings = {};
const invalidPaths = [
'/project/src/file.ts', // Workspace
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md', // Subdirectory
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
];
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.PLAN,
);
const engine = new PolicyEngine(config);
// Write to workspace (not plans dir) should be denied
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: '/project/src/file.ts' } },
undefined,
)
).decision,
).toBe(PolicyDecision.DENY);
// Write to plans dir but wrong extension should be denied
const wrongExtPath =
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js';
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: wrongExtPath } },
undefined,
)
).decision,
).toBe(PolicyDecision.DENY);
// Path traversal attempt should be denied (filename contains /)
const traversalPath =
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md';
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: traversalPath } },
undefined,
)
).decision,
).toBe(PolicyDecision.DENY);
// Invalid hash length should be denied
const shortHashPath = '/home/user/.gemini/tmp/abc123/plans/plan.md';
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: shortHashPath } },
undefined,
)
).decision,
).toBe(PolicyDecision.DENY);
});
it('should deny write_file to subdirectories in Plan mode', async () => {
const settings: Settings = {};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.PLAN,
);
const engine = new PolicyEngine(config);
// Write to subdirectory should be denied
const subdirPath =
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md';
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: subdirPath } },
undefined,
)
).decision,
).toBe(PolicyDecision.DENY);
});
for (const file_path of invalidPaths) {
expect(
(
await engine.check(
{ name: toolName, args: { file_path } },
undefined,
)
).decision,
).toBe(PolicyDecision.DENY);
}
});
},
);
it('should verify priority ordering works correctly in practice', async () => {
const settings: Settings = {
@@ -485,8 +434,8 @@ describe('Policy Engine Integration Tests', () => {
expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
// Priority 50 in default tier → 1.05
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5);
// Verify the engine applies these priorities correctly
expect(
@@ -641,8 +590,8 @@ describe('Policy Engine Integration Tests', () => {
expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob');
// Priority 50 in default tier → 1.05
expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only
// Priority 70 in default tier → 1.07
expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only
// The PolicyEngine will sort these by priority when it's created
const engine = new PolicyEngine(config);
@@ -23,6 +23,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
}
if (def.type === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if (def.enum) return z.enum(def.enum as [string, ...string[]]);
return z.string();
}
@@ -40,7 +41,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
let schema;
if (def.properties) {
const shape: Record<string, z.ZodTypeAny> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
for (const [key, propDef] of Object.entries(def.properties) as any) {
let propSchema = buildZodSchemaFromJsonSchema(propDef);
if (
@@ -86,9 +87,11 @@ function buildEnumSchema(
}
const values = options.map((opt) => opt.value);
if (values.every((v) => typeof v === 'string')) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return z.enum(values as [string, ...string[]]);
} else if (values.every((v) => typeof v === 'number')) {
return z.union(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
values.map((v) => z.literal(v)) as [
z.ZodLiteral<number>,
z.ZodLiteral<number>,
@@ -97,6 +100,7 @@ function buildEnumSchema(
);
} else {
return z.union(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
values.map((v) => z.literal(v)) as [
z.ZodLiteral<unknown>,
z.ZodLiteral<unknown>,
+106 -3
View File
@@ -1936,6 +1936,40 @@ describe('Settings Loading and Merging', () => {
);
});
it('should migrate tools.approvalMode to general.defaultApprovalMode', () => {
const userSettingsContent = {
tools: {
approvalMode: 'plan',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue');
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
migrateDeprecatedSettings(loadedSettings, true);
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'general',
expect.objectContaining({ defaultApprovalMode: 'plan' }),
);
// Verify removal
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'tools',
expect.not.objectContaining({ approvalMode: 'plan' }),
);
});
it('should migrate all 4 inverted boolean settings', () => {
const userSettingsContent = {
general: {
@@ -2078,7 +2112,7 @@ describe('Settings Loading and Merging', () => {
);
});
it('should migrate disableUpdateNag to enableAutoUpdateNotification in system and system defaults settings', () => {
it('should migrate disableUpdateNag to enableAutoUpdateNotification in memory but not save for system and system defaults settings', () => {
const systemSettingsContent = {
general: {
disableUpdateNag: true,
@@ -2103,9 +2137,10 @@ describe('Settings Loading and Merging', () => {
},
);
const feedbackSpy = mockCoreEvents.emitFeedback;
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify system settings were migrated
// Verify system settings were migrated in memory
expect(settings.system.settings.general).toHaveProperty(
'enableAutoUpdateNotification',
);
@@ -2115,7 +2150,7 @@ describe('Settings Loading and Merging', () => {
],
).toBe(false);
// Verify system defaults settings were migrated
// Verify system defaults settings were migrated in memory
expect(settings.systemDefaults.settings.general).toHaveProperty(
'enableAutoUpdateNotification',
);
@@ -2127,6 +2162,74 @@ describe('Settings Loading and Merging', () => {
// Merged should also reflect it (system overrides defaults, but both are migrated)
expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false);
// Verify it was NOT saved back to disk
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
getSystemSettingsPath(),
expect.anything(),
);
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
getSystemDefaultsPath(),
expect.anything(),
);
// Verify warnings were shown
expect(feedbackSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'The system configuration contains deprecated settings',
),
);
expect(feedbackSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'The system default configuration contains deprecated settings',
),
);
});
it('should migrate experimental agent settings in system scope in memory but not save', () => {
const systemSettingsContent = {
experimental: {
codebaseInvestigatorSettings: {
enabled: true,
},
},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
},
);
const feedbackSpy = mockCoreEvents.emitFeedback;
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify it was migrated in memory
expect(settings.system.settings.agents?.overrides).toMatchObject({
codebase_investigator: {
enabled: true,
},
});
// Verify it was NOT saved back to disk
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
getSystemSettingsPath(),
expect.anything(),
);
// Verify warnings were shown
expect(feedbackSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'The system configuration contains deprecated settings: [experimental.codebaseInvestigatorSettings]',
),
);
});
it('should migrate experimental agent settings to agents overrides', () => {
+147 -34
View File
@@ -194,6 +194,7 @@ export interface SettingsFile {
originalSettings: Settings;
path: string;
rawJson?: string;
readOnly?: boolean;
}
function setNestedProperty(
@@ -212,6 +213,7 @@ function setNestedProperty(
}
const next = current[key];
if (typeof next === 'object' && next !== null) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = next as Record<string, unknown>;
} else {
// This path is invalid, so we stop.
@@ -253,6 +255,7 @@ export function mergeSettings(
// 3. User Settings
// 4. Workspace Settings
// 5. System Settings (as overrides)
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return customDeepMerge(
getMergeStrategyForPath,
schemaDefaults,
@@ -273,6 +276,7 @@ export function mergeSettings(
export function createTestMergedSettings(
overrides: Partial<Settings> = {},
): MergedSettings {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return customDeepMerge(
getMergeStrategyForPath,
getDefaultsFromSchema(),
@@ -354,6 +358,7 @@ export class LoadedSettings {
// The final admin settings are the defaults overridden by remote settings.
// Any admin settings from files are ignored.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
merged.admin = customDeepMerge(
(path: string[]) => getMergeStrategyForPath(['admin', ...path]),
adminDefaults,
@@ -378,25 +383,32 @@ export class LoadedSettings {
}
}
private isPersistable(settingsFile: SettingsFile): boolean {
return !settingsFile.readOnly;
}
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
const settingsFile = this.forScope(scope);
// Clone value to prevent reference sharing between settings and originalSettings
// Clone value to prevent reference sharing
const valueToSet =
typeof value === 'object' && value !== null
? structuredClone(value)
: value;
setNestedProperty(settingsFile.settings, key, valueToSet);
// Use a fresh clone for originalSettings to ensure total independence
setNestedProperty(
settingsFile.originalSettings,
key,
structuredClone(valueToSet),
);
if (this.isPersistable(settingsFile)) {
// Use a fresh clone for originalSettings to ensure total independence
setNestedProperty(
settingsFile.originalSettings,
key,
structuredClone(valueToSet),
);
saveSettings(settingsFile);
}
this._merged = this.computeMergedSettings();
saveSettings(settingsFile);
coreEvents.emitSettingsChanged();
}
@@ -609,6 +621,7 @@ export function loadSettings(
return { settings: {} };
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const settingsObject = rawSettings as Record<string, unknown>;
// Validate settings structure with Zod
@@ -716,24 +729,28 @@ export function loadSettings(
settings: systemSettings,
originalSettings: systemOriginalSettings,
rawJson: systemResult.rawJson,
readOnly: true,
},
{
path: systemDefaultsPath,
settings: systemDefaultSettings,
originalSettings: systemDefaultsOriginalSettings,
rawJson: systemDefaultsResult.rawJson,
readOnly: true,
},
{
path: USER_SETTINGS_PATH,
settings: userSettings,
originalSettings: userOriginalSettings,
rawJson: userResult.rawJson,
readOnly: false,
},
{
path: workspaceSettingsPath,
settings: workspaceSettings,
originalSettings: workspaceOriginalSettings,
rawJson: workspaceResult.rawJson,
readOnly: false,
},
isTrusted,
settingsErrors,
@@ -758,17 +775,26 @@ export function migrateDeprecatedSettings(
removeDeprecated = false,
): boolean {
let anyModified = false;
const systemWarnings: Map<LoadableSettingScope, string[]> = new Map();
/**
* Helper to migrate a boolean setting and track it if it's deprecated.
*/
const migrateBoolean = (
settings: Record<string, unknown>,
oldKey: string,
newKey: string,
prefix: string,
foundDeprecated?: string[],
): boolean => {
let modified = false;
const oldValue = settings[oldKey];
const newValue = settings[newKey];
if (typeof oldValue === 'boolean') {
if (foundDeprecated) {
foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey);
}
if (typeof newValue === 'boolean') {
// Both exist, trust the new one
if (removeDeprecated) {
@@ -788,7 +814,9 @@ export function migrateDeprecatedSettings(
};
const processScope = (scope: LoadableSettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
const settingsFile = loadedSettings.forScope(scope);
const settings = settingsFile.settings;
const foundDeprecated: string[] = [];
// Migrate general settings
const generalSettings = settings.general as
@@ -799,18 +827,27 @@ export function migrateDeprecatedSettings(
let modified = false;
modified =
migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') ||
modified;
migrateBoolean(
newGeneral,
'disableAutoUpdate',
'enableAutoUpdate',
'general',
foundDeprecated,
) || modified;
modified =
migrateBoolean(
newGeneral,
'disableUpdateNag',
'enableAutoUpdateNotification',
'general',
foundDeprecated,
) || modified;
if (modified) {
loadedSettings.setValue(scope, 'general', newGeneral);
anyModified = true;
if (!settingsFile.readOnly) {
anyModified = true;
}
}
}
@@ -818,6 +855,7 @@ export function migrateDeprecatedSettings(
const uiSettings = settings.ui as Record<string, unknown> | undefined;
if (uiSettings) {
const newUi = { ...uiSettings };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const accessibilitySettings = newUi['accessibility'] as
| Record<string, unknown>
| undefined;
@@ -829,11 +867,15 @@ export function migrateDeprecatedSettings(
newAccessibility,
'disableLoadingPhrases',
'enableLoadingPhrases',
'ui.accessibility',
foundDeprecated,
)
) {
newUi['accessibility'] = newAccessibility;
loadedSettings.setValue(scope, 'ui', newUi);
anyModified = true;
if (!settingsFile.readOnly) {
anyModified = true;
}
}
}
}
@@ -844,6 +886,7 @@ export function migrateDeprecatedSettings(
| undefined;
if (contextSettings) {
const newContext = { ...contextSettings };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const fileFilteringSettings = newContext['fileFiltering'] as
| Record<string, unknown>
| undefined;
@@ -855,23 +898,67 @@ export function migrateDeprecatedSettings(
newFileFiltering,
'disableFuzzySearch',
'enableFuzzySearch',
'context.fileFiltering',
foundDeprecated,
)
) {
newContext['fileFiltering'] = newFileFiltering;
loadedSettings.setValue(scope, 'context', newContext);
anyModified = true;
if (!settingsFile.readOnly) {
anyModified = true;
}
}
}
}
// Migrate tools settings
const toolsSettings = settings.tools as Record<string, unknown> | undefined;
if (toolsSettings) {
if (toolsSettings['approvalMode'] !== undefined) {
foundDeprecated.push('tools.approvalMode');
const generalSettings =
(settings.general as Record<string, unknown> | undefined) || {};
const newGeneral = { ...generalSettings };
// Only set defaultApprovalMode if it's not already set
if (newGeneral['defaultApprovalMode'] === undefined) {
newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode'];
loadedSettings.setValue(scope, 'general', newGeneral);
if (!settingsFile.readOnly) {
anyModified = true;
}
}
if (removeDeprecated) {
const newTools = { ...toolsSettings };
delete newTools['approvalMode'];
loadedSettings.setValue(scope, 'tools', newTools);
if (!settingsFile.readOnly) {
anyModified = true;
}
}
}
}
// Migrate experimental agent settings
anyModified =
migrateExperimentalSettings(
settings,
loadedSettings,
scope,
removeDeprecated,
) || anyModified;
const experimentalModified = migrateExperimentalSettings(
settings,
loadedSettings,
scope,
removeDeprecated,
foundDeprecated,
);
if (experimentalModified) {
if (!settingsFile.readOnly) {
anyModified = true;
}
}
if (settingsFile.readOnly && foundDeprecated.length > 0) {
systemWarnings.set(scope, foundDeprecated);
}
};
processScope(SettingScope.User);
@@ -879,6 +966,19 @@ export function migrateDeprecatedSettings(
processScope(SettingScope.System);
processScope(SettingScope.SystemDefaults);
if (systemWarnings.size > 0) {
for (const [scope, flags] of systemWarnings) {
const scopeName =
scope === SettingScope.SystemDefaults
? 'system default'
: scope.toLowerCase();
coreEvents.emitFeedback(
'warning',
`The ${scopeName} configuration contains deprecated settings: [${flags.join(', ')}]. These could not be migrated automatically as system settings are read-only. Please update the system configuration manually.`,
);
}
}
return anyModified;
}
@@ -926,25 +1026,39 @@ function migrateExperimentalSettings(
loadedSettings: LoadedSettings,
scope: LoadableSettingScope,
removeDeprecated: boolean,
foundDeprecated?: string[],
): boolean {
const experimentalSettings = settings.experimental as
| Record<string, unknown>
| undefined;
if (experimentalSettings) {
const agentsSettings = {
...(settings.agents as Record<string, unknown> | undefined),
};
const agentsOverrides = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
};
let modified = false;
const migrateExperimental = (
oldKey: string,
migrateFn: (oldValue: Record<string, unknown>) => void,
) => {
const old = experimentalSettings[oldKey];
if (old) {
foundDeprecated?.push(`experimental.${oldKey}`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
migrateFn(old as Record<string, unknown>);
modified = true;
}
};
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
if (experimentalSettings['codebaseInvestigatorSettings']) {
const old = experimentalSettings[
'codebaseInvestigatorSettings'
] as Record<string, unknown>;
migrateExperimental('codebaseInvestigatorSettings', (old) => {
const override = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(agentsOverrides['codebase_investigator'] as
| Record<string, unknown>
| undefined),
@@ -953,6 +1067,7 @@ function migrateExperimentalSettings(
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
const runConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(override['runConfig'] as Record<string, unknown> | undefined),
};
if (old['maxNumTurns'] !== undefined)
@@ -963,16 +1078,19 @@ function migrateExperimentalSettings(
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
const modelConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(override['modelConfig'] as Record<string, unknown> | undefined),
};
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
if (old['thinkingBudget'] !== undefined) {
const generateContentConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(modelConfig['generateContentConfig'] as
| Record<string, unknown>
| undefined),
};
const thinkingConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(generateContentConfig['thinkingConfig'] as
| Record<string, unknown>
| undefined),
@@ -985,22 +1103,17 @@ function migrateExperimentalSettings(
}
agentsOverrides['codebase_investigator'] = override;
modified = true;
}
});
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
if (experimentalSettings['cliHelpAgentSettings']) {
const old = experimentalSettings['cliHelpAgentSettings'] as Record<
string,
unknown
>;
migrateExperimental('cliHelpAgentSettings', (old) => {
const override = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
};
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
agentsOverrides['cli_help'] = override;
modified = true;
}
});
if (modified) {
agentsSettings['overrides'] = agentsOverrides;
@@ -328,30 +328,6 @@ describe('SettingsSchema', () => {
).toBe('Enable debug logging of keystrokes to the console.');
});
it('should have previewFeatures setting in schema', () => {
expect(
getSettingsSchema().general.properties.previewFeatures,
).toBeDefined();
expect(getSettingsSchema().general.properties.previewFeatures.type).toBe(
'boolean',
);
expect(
getSettingsSchema().general.properties.previewFeatures.category,
).toBe('General');
expect(
getSettingsSchema().general.properties.previewFeatures.default,
).toBe(false);
expect(
getSettingsSchema().general.properties.previewFeatures.requiresRestart,
).toBe(false);
expect(
getSettingsSchema().general.properties.previewFeatures.showInDialog,
).toBe(true);
expect(
getSettingsSchema().general.properties.previewFeatures.description,
).toBe('Enable preview features (e.g., preview models).');
});
it('should have enableAgents setting in schema', () => {
const setting = getSettingsSchema().experimental.properties.enableAgents;
expect(setting).toBeDefined();
@@ -389,20 +365,6 @@ describe('SettingsSchema', () => {
);
});
it('should have enableEventDrivenScheduler setting in schema', () => {
const setting =
getSettingsSchema().experimental.properties.enableEventDrivenScheduler;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(true);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(false);
expect(setting.description).toBe(
'Enables event-driven scheduler within the CLI session.',
);
});
it('should have hooksConfig.notifications setting in schema', () => {
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
expect(setting).toBeDefined();
+99 -52
View File
@@ -10,7 +10,6 @@
// --------------------------------------------------------------------------
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_MODEL_CONFIGS,
type MCPServerConfig,
@@ -162,15 +161,6 @@ const SETTINGS_SCHEMA = {
description: 'General application settings.',
showInDialog: false,
properties: {
previewFeatures: {
type: 'boolean',
label: 'Preview Features (e.g., models)',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable preview features (e.g., preview models).',
showInDialog: true,
},
preferredEditor: {
type: 'string',
label: 'Preferred Editor',
@@ -189,6 +179,33 @@ const SETTINGS_SCHEMA = {
description: 'Enable Vim keybindings',
showInDialog: true,
},
defaultApprovalMode: {
type: 'enum',
label: 'Default Approval Mode',
category: 'General',
requiresRestart: false,
default: 'default',
description: oneLine`
The default approval mode for tool execution.
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
and 'plan' is read-only mode. 'yolo' is not supported yet.
`,
showInDialog: true,
options: [
{ value: 'default', label: 'Default' },
{ value: 'auto_edit', label: 'Auto Edit' },
{ value: 'plan', label: 'Plan' },
],
},
devtools: {
type: 'boolean',
label: 'DevTools',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable DevTools inspector on launch.',
showInDialog: false,
},
enableAutoUpdate: {
type: 'boolean',
label: 'Enable Auto Update',
@@ -393,6 +410,19 @@ const SETTINGS_SCHEMA = {
description: 'Hide the window title bar',
showInDialog: true,
},
inlineThinkingMode: {
type: 'enum',
label: 'Inline Thinking',
category: 'UI',
requiresRestart: false,
default: 'off',
description: 'Display model thinking inline: off or full.',
showInDialog: true,
options: [
{ value: 'off', label: 'Off' },
{ value: 'full', label: 'Full' },
],
},
showStatusInTitle: {
type: 'boolean',
label: 'Show Thoughts in Title',
@@ -1071,24 +1101,7 @@ const SETTINGS_SCHEMA = {
},
},
},
approvalMode: {
type: 'enum',
label: 'Approval Mode',
category: 'Tools',
requiresRestart: false,
default: 'default',
description: oneLine`
The default approval mode for tool execution.
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
and 'plan' is read-only mode. 'yolo' is not supported yet.
`,
showInDialog: true,
options: [
{ value: 'default', label: 'Default' },
{ value: 'auto_edit', label: 'Auto Edit' },
{ value: 'plan', label: 'Plan' },
],
},
core: {
type: 'array',
label: 'Core Tools',
@@ -1158,15 +1171,6 @@ const SETTINGS_SCHEMA = {
'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.',
showInDialog: true,
},
enableToolOutputTruncation: {
type: 'boolean',
label: 'Enable Tool Output Truncation',
category: 'General',
requiresRestart: true,
default: true,
description: 'Enable truncation of large tool outputs.',
showInDialog: true,
},
truncateToolOutputThreshold: {
type: 'number',
label: 'Tool Output Truncation Threshold',
@@ -1174,16 +1178,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
description:
'Truncate tool output if it is larger than this many characters. Set to -1 to disable.',
showInDialog: true,
},
truncateToolOutputLines: {
type: 'number',
label: 'Tool Output Truncation Lines',
category: 'General',
requiresRestart: true,
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
description: 'The number of lines to keep when truncating tool output.',
'Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation.',
showInDialog: true,
},
disableLLMCorrection: {
@@ -1462,6 +1457,58 @@ const SETTINGS_SCHEMA = {
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {
toolOutputMasking: {
type: 'object',
label: 'Tool Output Masking',
category: 'Experimental',
requiresRestart: true,
ignoreInDocs: true,
default: {},
description:
'Advanced settings for tool output masking to manage context window efficiency.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Tool Output Masking',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enables tool output masking to save tokens.',
showInDialog: false,
},
toolProtectionThreshold: {
type: 'number',
label: 'Tool Protection Threshold',
category: 'Experimental',
requiresRestart: true,
default: 50000,
description:
'Minimum number of tokens to protect from masking (most recent tool outputs).',
showInDialog: false,
},
minPrunableTokensThreshold: {
type: 'number',
label: 'Min Prunable Tokens Threshold',
category: 'Experimental',
requiresRestart: true,
default: 30000,
description:
'Minimum prunable tokens required to trigger a masking pass.',
showInDialog: false,
},
protectLatestTurn: {
type: 'boolean',
label: 'Protect Latest Turn',
category: 'Experimental',
requiresRestart: true,
default: true,
description:
'Ensures the absolute latest turn is never masked, regardless of token count.',
showInDialog: false,
},
},
},
enableAgents: {
type: 'boolean',
label: 'Enable Agents',
@@ -1486,17 +1533,17 @@ const SETTINGS_SCHEMA = {
label: 'Extension Configuration',
category: 'Experimental',
requiresRestart: true,
default: false,
default: true,
description: 'Enable requesting and fetching of extension settings.',
showInDialog: false,
},
enableEventDrivenScheduler: {
extensionRegistry: {
type: 'boolean',
label: 'Event Driven Scheduler',
label: 'Extension Registry Explore UI',
category: 'Experimental',
requiresRestart: true,
default: true,
description: 'Enables event-driven scheduler within the CLI session.',
default: false,
description: 'Enable extension registry explore UI.',
showInDialog: false,
},
extensionReloading: {
@@ -134,7 +134,6 @@ describe('Settings Repro', () => {
enablePromptCompletion: false,
preferredEditor: 'vim',
vimMode: false,
previewFeatures: false,
},
security: {
auth: {
@@ -150,7 +149,6 @@ describe('Settings Repro', () => {
showColor: true,
enableInteractiveShell: true,
},
truncateToolOutputLines: 100,
},
experimental: {
useModelRouter: false,
File diff suppressed because it is too large Load Diff
+100 -20
View File
@@ -6,6 +6,8 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { lock } from 'proper-lockfile';
import {
FatalConfigError,
getErrorMessage,
@@ -13,10 +15,14 @@ import {
ideContextStore,
GEMINI_DIR,
homedir,
isHeadlessMode,
coreEvents,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
const { promises: fsPromises } = fs;
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export function getUserSettingsDir(): string {
@@ -41,6 +47,7 @@ export function isTrustLevel(
): value is TrustLevel {
return (
typeof value === 'string' &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Object.values(TrustLevel).includes(value as TrustLevel)
);
}
@@ -67,6 +74,13 @@ export interface TrustResult {
const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
@@ -150,19 +164,68 @@ export class LoadedTrustedFolders {
return undefined;
}
setValue(path: string, trustLevel: TrustLevel): void {
const originalTrustLevel = this.user.config[path];
this.user.config[path] = trustLevel;
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
if (this.errors.length > 0) {
const errorMessages = this.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
);
}
const dirPath = path.dirname(this.user.path);
if (!fs.existsSync(dirPath)) {
await fsPromises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
try {
saveTrustedFolders(this.user);
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[path];
} else {
this.user.config[path] = originalTrustLevel;
// Re-read the file to handle concurrent updates
const content = await fsPromises.readFile(this.user.path, 'utf-8');
let config: Record<string, TrustLevel>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
config = {};
}
throw e;
const originalTrustLevel = config[folderPath];
config[folderPath] = trustLevel;
this.user.config[folderPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[folderPath];
} else {
this.user.config[folderPath] = originalTrustLevel;
}
throw e;
}
} finally {
await release();
}
}
}
@@ -190,10 +253,8 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
const parsed = JSON.parse(stripJsonComments(content)) as Record<
string,
string
>;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
if (
typeof parsed !== 'object' ||
@@ -241,11 +302,26 @@ export function saveTrustedFolders(
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(
trustedFoldersFile.path,
JSON.stringify(trustedFoldersFile.config, null, 2),
{ encoding: 'utf-8', mode: 0o600 },
);
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
try {
fs.writeFileSync(tempPath, content, {
encoding: 'utf-8',
mode: 0o600,
});
fs.renameSync(tempPath, trustedFoldersFile.path);
} catch (error) {
// Clean up temp file if it was created but rename failed
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}
/** Is folder trust feature enabled per the current applied settings */
@@ -282,6 +358,10 @@ export function isWorkspaceTrusted(
workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
): TrustResult {
if (isHeadlessMode()) {
return { isTrusted: true, source: undefined };
}
if (!isFolderTrustEnabled(settings)) {
return { isTrusted: true, source: undefined };
}
+9 -1
View File
@@ -167,7 +167,15 @@ describe('deferred', () => {
// Now manually run it to verify it captured correctly
await runDeferredCommand(createMockSettings().merged);
expect(originalHandler).toHaveBeenCalledWith(argv);
expect(originalHandler).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.objectContaining({
admin: expect.objectContaining({
extensions: expect.objectContaining({ enabled: true }),
}),
}),
}),
);
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
+9 -1
View File
@@ -63,7 +63,13 @@ export async function runDeferredCommand(settings: MergedSettings) {
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
await deferredCommand.handler(deferredCommand.argv);
// Inject settings into argv
const argvWithSettings = {
...deferredCommand.argv,
settings,
};
await deferredCommand.handler(argvWithSettings);
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);
}
@@ -80,9 +86,11 @@ export function defer<T = object, U = object>(
...commandModule,
handler: (argv: ArgumentsCamelCase<U>) => {
setDeferredCommand({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
handler: commandModule.handler as (
argv: ArgumentsCamelCase,
) => void | Promise<void>,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv: argv as unknown as ArgumentsCamelCase,
commandName: parentCommandName || 'unknown',
});
+17 -9
View File
@@ -510,13 +510,19 @@ export async function main() {
projectHooks: settings.workspace.settings.hooks,
});
loadConfigHandle?.end();
// Initialize storage immediately after loading config to ensure that
// storage-related operations (like listing or resuming sessions) have
// access to the project identifier.
await config.storage.initialize();
adminControlsListner.setConfig(config);
if (config.isInteractive() && config.storage && config.getDebugMode()) {
const { registerActivityLogger } = await import(
'./utils/activityLogger.js'
if (config.isInteractive() && settings.merged.general.devtools) {
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
registerActivityLogger(config);
await setupInitialActivityLogger(config);
}
// Register config for telemetry shutdown
@@ -597,12 +603,13 @@ export async function main() {
}
// This cleanup isn't strictly needed but may help in certain situations.
process.on('SIGTERM', () => {
const restoreRawMode = () => {
process.stdin.setRawMode(wasRaw);
});
process.on('SIGINT', () => {
process.stdin.setRawMode(wasRaw);
});
};
process.off('SIGTERM', restoreRawMode);
process.on('SIGTERM', restoreRawMode);
process.off('SIGINT', restoreRawMode);
process.on('SIGINT', restoreRawMode);
}
await setupTerminalAndTheme(config, settings);
@@ -813,6 +820,7 @@ function setupAdminControlsListener() {
let config: Config | undefined;
const messageHandler = (msg: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = msg as {
type?: string;
settings?: AdminControlsSettings;
+6
View File
@@ -38,6 +38,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
disableMouseEvents: vi.fn(),
enterAlternateScreen: vi.fn(),
disableLineWrapping: vi.fn(),
ProjectRegistry: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
getShortId: vi.fn().mockReturnValue('project-slug'),
})),
};
});
@@ -73,6 +77,7 @@ vi.mock('./config/config.js', () => ({
getSandbox: vi.fn(() => false),
getQuestion: vi.fn(() => ''),
isInteractive: () => false,
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
} as unknown as Config),
parseArguments: vi.fn().mockResolvedValue({}),
isDebugMode: vi.fn(() => false),
@@ -191,6 +196,7 @@ describe('gemini.tsx main function cleanup', () => {
getEnableHooks: vi.fn(() => false),
getHookSystem: () => undefined,
initialize: vi.fn(),
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
getContentGeneratorConfig: vi.fn(),
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
+9 -9
View File
@@ -38,9 +38,9 @@ import type { LoadedSettings } from './config/settings.js';
// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
const mockRegisterActivityLogger = vi.hoisted(() => vi.fn());
vi.mock('./utils/activityLogger.js', () => ({
registerActivityLogger: mockRegisterActivityLogger,
const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn());
vi.mock('./utils/devtoolsService.js', () => ({
setupInitialActivityLogger: mockSetupInitialActivityLogger,
}));
const mockCoreEvents = vi.hoisted(() => ({
@@ -267,8 +267,8 @@ describe('runNonInteractive', () => {
// so we no longer expect shutdownTelemetry to be called directly here
});
it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is set', async () => {
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '/tmp/test.jsonl');
it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => {
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl');
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.Finished,
@@ -286,12 +286,12 @@ describe('runNonInteractive', () => {
prompt_id: 'prompt-id-activity-logger',
});
expect(mockRegisterActivityLogger).toHaveBeenCalledWith(mockConfig);
expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig);
vi.unstubAllEnvs();
});
it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is not set', async () => {
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '');
it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => {
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '');
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.Finished,
@@ -309,7 +309,7 @@ describe('runNonInteractive', () => {
prompt_id: 'prompt-id-activity-logger-off',
});
expect(mockRegisterActivityLogger).not.toHaveBeenCalled();
expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled();
vi.unstubAllEnvs();
});
+6 -4
View File
@@ -71,11 +71,11 @@ export async function runNonInteractive({
},
});
if (config.storage && process.env['GEMINI_CLI_ACTIVITY_LOG_FILE']) {
const { registerActivityLogger } = await import(
'./utils/activityLogger.js'
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
registerActivityLogger(config);
await setupInitialActivityLogger(config);
}
const { stdout: workingStdout } = createWorkingStdio();
@@ -250,6 +250,7 @@ export async function runNonInteractive({
// Otherwise, slashCommandResult falls through to the default prompt
// handling.
if (slashCommandResult) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
query = slashCommandResult as Part[];
}
}
@@ -271,6 +272,7 @@ export async function runNonInteractive({
error || 'Exiting due to an error processing the @ command.',
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
query = processedQuery as Part[];
}
@@ -85,6 +85,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({
extensionsCommand: () => ({}),
}));
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
vi.mock('../ui/commands/shortcutsCommand.js', () => ({
shortcutsCommand: {},
}));
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
vi.mock('../ui/commands/modelCommand.js', () => ({
modelCommand: { name: 'model' },
@@ -31,6 +31,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
@@ -116,6 +117,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
]
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
shortcutsCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand,
await ideCommand(),
@@ -125,6 +125,7 @@ export class FileCommandLoader implements ICommandLoader {
} catch (error) {
if (
!signal.aborted &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(error as { code?: string })?.code !== 'ENOENT'
) {
coreEvents.emitFeedback(
+7 -2
View File
@@ -5,6 +5,7 @@
*/
import { act } from 'react';
import { vi } from 'vitest';
// The waitFor from vitest doesn't properly wrap in act(), so we have to
// implement our own like the one in @testing-library/react
@@ -13,7 +14,7 @@ import { act } from 'react';
// for React state updates.
export async function waitFor(
assertion: () => void,
{ timeout = 1000, interval = 50 } = {},
{ timeout = 2000, interval = 50 } = {},
): Promise<void> {
const startTime = Date.now();
@@ -27,7 +28,11 @@ export async function waitFor(
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, interval));
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(interval);
} else {
await new Promise((resolve) => setTimeout(resolve, interval));
}
});
}
}
@@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
const invalidCharsRegex = /[\b\x1b]/;
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
const { isNot } = this as any;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
@@ -50,6 +50,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect.extend({
toHaveOnlyValidCharacters,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -38,12 +38,14 @@ export const createMockCommandContext = (
},
services: {
config: null,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
settings: {
merged: defaultMergedSettings,
setValue: vi.fn(),
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
logger: {
log: vi.fn(),
logMessage: vi.fn(),
@@ -52,6 +54,7 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, // Cast because Logger is a class.
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ui: {
addItem: vi.fn(),
clear: vi.fn(),
@@ -60,6 +63,7 @@ export const createMockCommandContext = (
setPendingItem: vi.fn(),
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleShortcutsHelp: vi.fn(),
toggleVimEnabled: vi.fn(),
openAgentConfigDialog: vi.fn(),
closeAgentConfigDialog: vi.fn(),
@@ -69,6 +73,7 @@ export const createMockCommandContext = (
} as any,
session: {
sessionShellAllowlist: new Set<string>(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
+5 -2
View File
@@ -13,6 +13,7 @@ import { createTestMergedSettings } from '../config/settings.js';
* Creates a mocked Config object with default values and allows overrides.
*/
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
({
getSandbox: vi.fn(() => undefined),
getQuestion: vi.fn(() => ''),
@@ -20,6 +21,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
setTerminalBackground: vi.fn(),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
initialize: vi.fn().mockResolvedValue(undefined),
},
getDebugMode: vi.fn(() => false),
getProjectRoot: vi.fn(() => '/'),
@@ -44,7 +46,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
isPlanEnabled: vi.fn(() => false),
isEventDrivenSchedulerEnabled: vi.fn(() => false),
getCoreTools: vi.fn(() => []),
getAllowedTools: vi.fn(() => []),
getApprovalMode: vi.fn(() => 'default'),
@@ -151,8 +152,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getAllowedMcpServers: vi.fn().mockReturnValue([]),
getBlockedMcpServers: vi.fn().mockReturnValue([]),
getExperiments: vi.fn().mockReturnValue(undefined),
getPreviewFeatures: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
...overrides,
}) as unknown as Config;
@@ -163,9 +164,11 @@ export function createMockSettings(
overrides: Record<string, unknown> = {},
): LoadedSettings {
const merged = createTestMergedSettings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(overrides['merged'] as Partial<Settings>) || {},
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
system: { settings: {} },
systemDefaults: { settings: {} },
+24 -2
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -52,6 +52,7 @@ export const render = (
terminalWidth?: number,
): ReturnType<typeof inkRender> => {
let renderResult: ReturnType<typeof inkRender> =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
undefined as unknown as ReturnType<typeof inkRender>;
act(() => {
renderResult = inkRender(tree);
@@ -113,14 +114,19 @@ const getMockConfigInternal = (): Config => {
return mockConfigInternal;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const configProxy = new Proxy({} as Config, {
get(_target, prop) {
if (prop === 'getTargetDir') {
return () =>
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
}
if (prop === 'getUseBackgroundColor') {
return () => true;
}
const internal = getMockConfigInternal();
if (prop in internal) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return internal[prop as keyof typeof internal];
}
throw new Error(`mockConfig does not have property ${String(prop)}`);
@@ -148,6 +154,12 @@ const baseMockUiState = {
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
},
};
export const mockAppState: AppState = {
@@ -191,12 +203,12 @@ const mockUIActions: UIActions = {
handleApiKeySubmit: vi.fn(),
handleApiKeyCancel: vi.fn(),
setBannerVisible: vi.fn(),
setShortcutsHelpVisible: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
handleWarning: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
@@ -209,6 +221,7 @@ export const renderWithProviders = (
uiState: providedUiState,
width,
mouseEventsEnabled = false,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = configProxy as unknown as Config,
useAlternateBuffer = true,
uiActions,
@@ -230,17 +243,20 @@ export const renderWithProviders = (
appState?: AppState;
} = {},
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
{
get(target, prop) {
if (prop in target) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return target[prop as keyof typeof target];
}
// For properties not in the base mock or provided state,
// we'll check the original proxy to see if it's a defined but
// unprovided property, and if not, throw.
if (prop in baseMockUiState) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return baseMockUiState[prop as keyof typeof baseMockUiState];
}
throw new Error(`mockUiState does not have property ${String(prop)}`);
@@ -346,7 +362,9 @@ export function renderHook<Result, Props>(
rerender: (props?: Props) => void;
unmount: () => void;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
let currentProps = options?.initialProps as Props;
function TestComponent({
@@ -377,6 +395,7 @@ export function renderHook<Result, Props>(
function rerender(props?: Props) {
if (arguments.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
currentProps = props as Props;
}
act(() => {
@@ -410,6 +429,7 @@ export function renderHookWithProviders<Result, Props>(
rerender: (props?: Props) => void;
unmount: () => void;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
let setPropsFn: ((props: Props) => void) | undefined;
@@ -431,6 +451,7 @@ export function renderHookWithProviders<Result, Props>(
act(() => {
renderResult = renderWithProviders(
<Wrapper>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
<TestComponent initialProps={options.initialProps as Props} />
</Wrapper>,
options,
@@ -440,6 +461,7 @@ export function renderHookWithProviders<Result, Props>(
function rerender(newProps?: Props) {
act(() => {
if (arguments.length > 0 && setPropsFn) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
setPropsFn(newProps as Props);
} else if (forceUpdateFn) {
forceUpdateFn();
+5
View File
@@ -51,13 +51,17 @@ export const createMockSettings = (
} = overrides;
const loaded = new LoadedSettings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(system as any) || { path: '', settings: {}, originalSettings: {} },
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(user as any) || {
path: '',
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
@@ -71,6 +75,7 @@ export const createMockSettings = (
// Assign any function overrides (e.g., vi.fn() for methods)
for (const key in overrides) {
if (typeof overrides[key] === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(loaded as any)[key] = overrides[key];
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+250 -4
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -145,13 +145,30 @@ vi.mock('./contexts/SessionContext.js');
vi.mock('./components/shared/text-buffer.js');
vi.mock('./hooks/useLogger.js');
vi.mock('./hooks/useInputHistoryStore.js');
vi.mock('./hooks/atCommandProcessor.js');
vi.mock('./hooks/useHookDisplayState.js');
vi.mock('./hooks/useBanner.js', () => ({
useBanner: vi.fn((bannerData) => ({
bannerText: (
bannerData.warningText ||
bannerData.defaultText ||
''
).replace(/\\n/g, '\n'),
})),
}));
vi.mock('./hooks/useShellInactivityStatus.js', () => ({
useShellInactivityStatus: vi.fn(() => ({
shouldShowFocusHint: false,
inactivityStatus: 'none',
})),
}));
vi.mock('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(),
}));
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
// Mock external utilities
vi.mock('../utils/events.js');
@@ -255,6 +272,7 @@ describe('AppContainer State Management', () => {
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const mockedUseTerminalTheme = useTerminalTheme as Mock;
const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
@@ -384,6 +402,10 @@ describe('AppContainer State Management', () => {
});
mockedUseHookDisplayState.mockReturnValue([]);
mockedUseTerminalTheme.mockReturnValue(undefined);
mockedUseShellInactivityStatus.mockReturnValue({
shouldShowFocusHint: false,
inactivityStatus: 'none',
});
// Mock Config
mockConfig = makeFakeConfig();
@@ -950,7 +972,7 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
// Assert that the context value is as expected
expect(capturedUIState.proQuotaRequest).toBeNull();
expect(capturedUIState.quota.proQuotaRequest).toBeNull();
});
unmount!();
});
@@ -975,7 +997,7 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
// Assert: The mock request is correctly passed through the context
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest);
});
unmount!();
});
@@ -1246,8 +1268,15 @@ describe('AppContainer State Management', () => {
});
describe('Shell Focus Action Required', () => {
beforeEach(() => {
beforeEach(async () => {
vi.useFakeTimers();
// Use real implementation for these tests to verify title updates
const actual = await vi.importActual<
typeof import('./hooks/useShellInactivityStatus.js')
>('./hooks/useShellInactivityStatus.js');
mockedUseShellInactivityStatus.mockImplementation(
actual.useShellInactivityStatus,
);
});
afterEach(() => {
@@ -1940,6 +1969,160 @@ describe('AppContainer State Management', () => {
unmount();
});
});
describe('Focus Handling (Tab / Shift+Tab)', () => {
beforeEach(() => {
// Mock activePtyId to enable focus
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 1,
});
});
it('should focus shell input on Tab', async () => {
await setupKeypressTest();
pressKey({ name: 'tab', shift: false });
expect(capturedUIState.embeddedShellFocused).toBe(true);
unmount();
});
it('should unfocus shell input on Shift+Tab', async () => {
await setupKeypressTest();
// Focus first
pressKey({ name: 'tab', shift: false });
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Unfocus via Shift+Tab
pressKey({ name: 'tab', shift: true });
expect(capturedUIState.embeddedShellFocused).toBe(false);
unmount();
});
it('should auto-unfocus when activePtyId becomes null', async () => {
// Start with active pty and focused
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 1,
});
const renderResult = render(getAppContainer());
await act(async () => {
vi.advanceTimersByTime(0);
});
// Focus it
act(() => {
handleGlobalKeypress({
name: 'tab',
shift: false,
alt: false,
ctrl: false,
cmd: false,
} as Key);
});
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Now mock activePtyId becoming null
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
});
// Rerender to trigger useEffect
await act(async () => {
renderResult.rerender(getAppContainer());
});
expect(capturedUIState.embeddedShellFocused).toBe(false);
renderResult.unmount();
});
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
const mockToggleBackgroundShell = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
});
await setupKeypressTest();
// Initially not focused
expect(capturedUIState.embeddedShellFocused).toBe(false);
// Press Tab
pressKey({ name: 'tab', shift: false });
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Should NOT have toggled (closed) the shell
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
unmount();
});
});
describe('Background Shell Toggling (CTRL+B)', () => {
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
const mockToggleBackgroundShell = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
});
await setupKeypressTest();
// Initially not focused, but visible
expect(capturedUIState.embeddedShellFocused).toBe(false);
// Press Ctrl+B
pressKey({ name: 'b', ctrl: true });
// Should have toggled (closed) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
// Should be unfocused
expect(capturedUIState.embeddedShellFocused).toBe(false);
unmount();
});
it('should show and focus background shell on Ctrl+B if hidden', async () => {
const mockToggleBackgroundShell = vi.fn();
const geminiStreamMock = {
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: false,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
};
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
await setupKeypressTest();
// Update the mock state when toggled to simulate real behavior
mockToggleBackgroundShell.mockImplementation(() => {
geminiStreamMock.isBackgroundShellVisible = true;
});
// Press Ctrl+B
pressKey({ name: 'b', ctrl: true });
// Should have toggled (shown) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
unmount();
});
});
});
describe('Copy Mode (CTRL+S)', () => {
@@ -2580,4 +2763,67 @@ describe('AppContainer State Management', () => {
compUnmount();
});
});
describe('Permission Handling', () => {
it('shows permission dialog when checkPermissions returns paths', async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
let unmount: () => void;
await act(async () => (unmount = renderAppContainer().unmount));
await waitFor(() => expect(capturedUIActions).toBeTruthy());
await act(async () =>
capturedUIActions.handleFinalSubmit('read @file.txt'),
);
expect(capturedUIState.permissionConfirmationRequest).not.toBeNull();
expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([
'/test/file.txt',
]);
await act(async () => unmount!());
});
it.each([true, false])(
'handles permissions when allowed is %s',
async (allowed) => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
const addReadOnlyPathSpy = vi.spyOn(
mockConfig.getWorkspaceContext(),
'addReadOnlyPath',
);
const { submitQuery } = mockedUseGeminiStream();
let unmount: () => void;
await act(async () => (unmount = renderAppContainer().unmount));
await waitFor(() => expect(capturedUIActions).toBeTruthy());
await act(async () =>
capturedUIActions.handleFinalSubmit('read @file.txt'),
);
await act(async () =>
capturedUIState.permissionConfirmationRequest?.onComplete({
allowed,
}),
);
if (allowed) {
expect(addReadOnlyPathSpy).toHaveBeenCalledWith('/test/file.txt');
} else {
expect(addReadOnlyPathSpy).not.toHaveBeenCalled();
}
expect(submitQuery).toHaveBeenCalledWith('read @file.txt');
expect(capturedUIState.permissionConfirmationRequest).toBeNull();
await act(async () => unmount!());
},
);
});
});
+213 -137
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -28,7 +28,10 @@ import {
type HistoryItemToolGroup,
AuthState,
type ConfirmationRequest,
type PermissionConfirmationRequest,
type QuotaStats,
} from './types.js';
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import {
@@ -53,6 +56,7 @@ import {
coreEvents,
CoreEvent,
refreshServerHierarchicalMemory,
flattenMemory,
type MemoryChangedPayload,
writeToStdout,
disableMouseEvents,
@@ -86,7 +90,6 @@ import { calculatePromptWidths } from './components/InputPrompt.js';
import { useApp, useStdout, useStdin } from 'ink';
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
import ansiEscapes from 'ansi-escapes';
import * as fs from 'node:fs';
import { basename } from 'node:path';
import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
@@ -104,7 +107,7 @@ import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { appEvents, AppEvent } from '../utils/events.js';
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
@@ -141,6 +144,8 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js';
import { isITerm2 } from './utils/terminalUtils.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -245,8 +250,9 @@ export const AppContainer = (props: AppContainerProps) => {
[defaultBannerText, warningBannerText],
);
const { bannerText } = useBanner(bannerData, config);
const { bannerText } = useBanner(bannerData);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const extensionManager = config.getExtensionLoader() as ExtensionManager;
// We are in the interactive CLI, update how we request consent and settings.
extensionManager.setRequestConsent((description) =>
@@ -318,6 +324,16 @@ export const AppContainer = (props: AppContainerProps) => {
const [currentModel, setCurrentModel] = useState(config.getModel());
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const [quotaStats, setQuotaStats] = useState<QuotaStats | undefined>(() => {
const remaining = config.getQuotaRemaining();
const limit = config.getQuotaLimit();
const resetTime = config.getQuotaResetTime();
return remaining !== undefined ||
limit !== undefined ||
resetTime !== undefined
? { remaining, limit, resetTime }
: undefined;
});
const [isConfigInitialized, setConfigInitialized] = useState(false);
@@ -420,9 +436,23 @@ export const AppContainer = (props: AppContainerProps) => {
setCurrentModel(config.getModel());
};
const handleQuotaChanged = (payload: {
remaining: number | undefined;
limit: number | undefined;
resetTime?: string;
}) => {
setQuotaStats({
remaining: payload.remaining,
limit: payload.limit,
resetTime: payload.resetTime,
});
};
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged);
return () => {
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged);
};
}, [config]);
@@ -465,15 +495,8 @@ export const AppContainer = (props: AppContainerProps) => {
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
const isValidPath = useCallback((filePath: string): boolean => {
try {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
} catch (_e) {
return false;
}
}, []);
const getPreferredEditor = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
() => settings.merged.general.preferredEditor as EditorType,
[settings.merged.general.preferredEditor],
);
@@ -483,7 +506,7 @@ export const AppContainer = (props: AppContainerProps) => {
viewport: { height: 10, width: inputWidth },
stdin,
setRawMode,
isValidPath,
escapePastedPaths: true,
shellModeActive,
getPreferredEditor,
});
@@ -524,12 +547,22 @@ export const AppContainer = (props: AppContainerProps) => {
refreshStatic();
}, [refreshStatic, isAlternateBuffer, app, config]);
const [editorError, setEditorError] = useState<string | null>(null);
const {
isEditorDialogOpen,
openEditorDialog,
handleEditorSelect,
exitEditorDialog,
} = useEditorSettings(settings, setEditorError, historyManager.addItem);
useEffect(() => {
coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose);
coreEvents.on(CoreEvent.RequestEditorSelection, openEditorDialog);
return () => {
coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose);
coreEvents.off(CoreEvent.RequestEditorSelection, openEditorDialog);
};
}, [handleEditorClose]);
}, [handleEditorClose, openEditorDialog]);
useEffect(() => {
if (
@@ -543,6 +576,9 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [bannerVisible, bannerText, settings, config, refreshStatic]);
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const {
isThemeDialogOpen,
openThemeDialog,
@@ -738,17 +774,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
onAuthError,
]);
const [editorError, setEditorError] = useState<string | null>(null);
const {
isEditorDialogOpen,
openEditorDialog,
handleEditorSelect,
exitEditorDialog,
} = useEditorSettings(settings, setEditorError, historyManager.addItem);
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
useModelCommand();
@@ -757,6 +782,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
() => {},
);
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
const slashCommandActions = useMemo(
() => ({
@@ -792,6 +818,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}
},
toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible),
setText: stableSetText,
}),
[
@@ -810,6 +837,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openPermissionsDialog,
addConfirmUpdateExtensionRequest,
toggleDebugProfiler,
setShortcutsHelpVisible,
stableSetText,
],
);
@@ -838,6 +866,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
const [authConsentRequest, setAuthConsentRequest] =
useState<ConfirmationRequest | null>(null);
const [permissionConfirmationRequest, setPermissionConfirmationRequest] =
useState<PermissionConfirmationRequest | null>(null);
useEffect(() => {
const handleConsentRequest = (payload: ConsentRequestPayload) => {
@@ -868,12 +898,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { memoryContent, fileCount } =
await refreshServerHierarchicalMemory(config);
const flattenedMemory = flattenMemory(memoryContent);
historyManager.addItem(
{
type: MessageType.INFO,
text: `Memory refreshed successfully. ${
memoryContent.length > 0
? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
flattenedMemory.length > 0
? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).`
: 'No memory content found.'
}`,
},
@@ -881,7 +913,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
);
if (config.getDebugMode()) {
debugLogger.log(
`[DEBUG] Refreshed memory content in config: ${memoryContent.substring(
`[DEBUG] Refreshed memory content in config: ${flattenedMemory.substring(
0,
200,
)}...`,
@@ -1072,11 +1104,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
);
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
async (submittedValue: string) => {
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
if (isSlash || (isIdle && isMcpReady)) {
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
setPermissionConfirmationRequest({
files: permissions,
onComplete: (result) => {
setPermissionConfirmationRequest(null);
if (result.allowed) {
permissions.forEach((p) =>
config.getWorkspaceContext().addReadOnlyPath(p),
);
}
void submitQuery(submittedValue);
},
});
addInput(submittedValue);
return;
}
}
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
@@ -1097,6 +1148,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
streamingState,
messageQueue.length,
config,
],
);
@@ -1131,11 +1183,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
useLayoutEffect(() => {
if (mainControlsRef.current) {
const fullFooterMeasurement = measureElement(mainControlsRef.current);
if (
fullFooterMeasurement.height > 0 &&
fullFooterMeasurement.height !== controlsHeight
) {
setControlsHeight(fullFooterMeasurement.height);
const roundedHeight = Math.round(fullFooterMeasurement.height);
if (roundedHeight > 0 && roundedHeight !== controlsHeight) {
setControlsHeight(roundedHeight);
}
}
}, [buffer, terminalWidth, terminalHeight, controlsHeight]);
@@ -1215,7 +1265,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
!showPrivacyNotice &&
geminiClient?.isInitialized?.()
) {
handleFinalSubmit(initialPrompt);
void handleFinalSubmit(initialPrompt);
initialPromptSubmitted.current = true;
}
}, [
@@ -1263,7 +1313,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
>();
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
const [warningMessage, setWarningMessage] = useState<string | null>(null);
const [transientMessage, showTransientMessage] = useTimedMessage<{
text: string;
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS);
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
@@ -1275,39 +1329,42 @@ Logging in with Google... Restarting Gemini CLI to continue.
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleWarning = useCallback((message: string) => {
setWarningMessage(message);
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
warningTimeoutRef.current = setTimeout(() => {
setWarningMessage(null);
}, WARNING_PROMPT_DURATION_MS);
}, []);
useEffect(() => {
const handleTransientMessage = (payload: {
message: string;
type: TransientMessageType;
}) => {
showTransientMessage({ text: payload.message, type: payload.type });
};
const handleSelectionWarning = () => {
handleWarning('Press Ctrl-S to enter selection mode to copy text.');
showTransientMessage({
text: 'Press Ctrl-S to enter selection mode to copy text.',
type: TransientMessageType.Warning,
});
};
const handlePasteTimeout = () => {
handleWarning('Paste Timed out. Possibly due to slow connection.');
showTransientMessage({
text: 'Paste Timed out. Possibly due to slow connection.',
type: TransientMessageType.Warning,
});
};
appEvents.on(AppEvent.TransientMessage, handleTransientMessage);
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
return () => {
appEvents.off(AppEvent.TransientMessage, handleTransientMessage);
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
};
}, [handleWarning]);
}, [showTransientMessage]);
useEffect(() => {
if (ideNeedsRestart) {
@@ -1407,17 +1464,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (result.userSelection === 'yes') {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleSlashCommand('/ide install');
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
} else if (result.userSelection === 'dismiss') {
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
}
setIdePromptAnswered(true);
},
@@ -1469,10 +1518,43 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
setShowErrorDetails((prev) => !prev);
if (settings.merged.general.devtools) {
void (async () => {
try {
const { startDevToolsServer } = await import(
'../utils/devtoolsService.js'
);
const { openBrowserSecurely, shouldLaunchBrowser } = await import(
'@google/gemini-cli-core'
);
const url = await startDevToolsServer(config);
if (shouldLaunchBrowser()) {
try {
await openBrowserSecurely(url);
} catch (e) {
setShowErrorDetails((prev) => !prev);
debugLogger.warn('Failed to open browser securely:', e);
}
} else {
setShowErrorDetails((prev) => !prev);
}
} catch (e) {
setShowErrorDetails(true);
debugLogger.error('Failed to start DevTools server:', e);
}
})();
} else {
setShowErrorDetails((prev) => !prev);
}
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
handleWarning('Undo has been moved to Cmd + Z or Alt/Opt + Z');
const undoMessage = isITerm2()
? 'Undo has been moved to Option + Z'
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
showTransientMessage({
text: undoMessage,
type: TransientMessageType.Warning,
});
return true;
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
setShowFullTodos((prev) => !prev);
@@ -1500,71 +1582,63 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight(false);
return true;
} else if (
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
) {
if (key.name === 'tab' && key.shift) {
// Always change focus
if (embeddedShellFocused) {
const capturedTime = lastOutputTimeRef.current;
if (tabFocusTimeoutRef.current)
clearTimeout(tabFocusTimeoutRef.current);
tabFocusTimeoutRef.current = setTimeout(() => {
if (lastOutputTimeRef.current === capturedTime) {
setEmbeddedShellFocused(false);
} else {
showTransientMessage({
text: 'Use Shift+Tab to unfocus',
type: TransientMessageType.Warning,
});
}
}, 150);
return false;
}
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
if (tabFocusTimeoutRef.current)
clearTimeout(tabFocusTimeoutRef.current);
toggleBackgroundShell();
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
return true;
}
setEmbeddedShellFocused(true);
return true;
} else if (
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) ||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key)
) {
if (embeddedShellFocused) {
setEmbeddedShellFocused(false);
return true;
}
if (embeddedShellFocused) {
handleWarning('Press Shift+Tab to focus out.');
return true;
}
const now = Date.now();
// If the shell hasn't produced output in the last 100ms, it's considered idle.
const isIdle = now - lastOutputTimeRef.current >= 100;
if (isIdle && !activePtyId) {
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
toggleBackgroundShell();
if (!isBackgroundShellVisible) {
// We are about to show it, so focus it
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
} else {
// We are about to hide it
tabFocusTimeoutRef.current = setTimeout(() => {
tabFocusTimeoutRef.current = null;
// If the shell produced output since the tab press, we assume it handled the tab
// (e.g. autocomplete) so we should not toggle focus.
if (lastOutputTimeRef.current > now) {
handleWarning('Press Shift+Tab to focus out.');
return;
}
setEmbeddedShellFocused(false);
}, 100);
}
return true;
}
// Not idle, just focus it
setEmbeddedShellFocused(true);
return true;
return false;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
backgroundCurrentShell();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
if (isBackgroundShellVisible && !embeddedShellFocused) {
toggleBackgroundShell();
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
setEmbeddedShellFocused(true);
} else {
toggleBackgroundShell();
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
} else {
setEmbeddedShellFocused(false);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
} else {
setEmbeddedShellFocused(false);
}
}
return true;
@@ -1603,11 +1677,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
setIsBackgroundShellListOpen,
lastOutputTimeRef,
tabFocusTimeoutRef,
handleWarning,
showTransientMessage,
settings.merged.general.devtools,
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useEffect(() => {
// Respect hideWindowTitle settings
@@ -1714,6 +1789,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
!!permissionConfirmationRequest ||
!!customDialog ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
@@ -1766,7 +1842,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
const fetchBannerTexts = async () => {
const [defaultBanner, warningBanner] = await Promise.all([
config.getBannerTextNoCapacityIssues(),
// TODO: temporarily disabling the banner, it will be re-added.
'',
config.getBannerTextCapacityIssues(),
]);
@@ -1774,15 +1851,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setDefaultBannerText(defaultBanner);
setWarningBannerText(warningBanner);
setBannerVisible(true);
const authType = config.getContentGeneratorConfig()?.authType;
if (
authType === AuthType.USE_GEMINI ||
authType === AuthType.USE_VERTEX_AI
) {
setDefaultBannerText(
'Gemini 3 Flash and Pro are now available. \nEnable "Preview features" in /settings. \nLearn more at https://goo.gle/enable-preview-features',
);
}
}
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -1827,6 +1895,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
authConsentRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
permissionConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,
@@ -1851,6 +1920,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
ctrlCPressedOnce: ctrlCPressCount >= 1,
ctrlDPressedOnce: ctrlDPressCount >= 1,
showEscapePrompt,
shortcutsHelpVisible,
isFocused,
elapsedTime,
currentLoadingPhrase,
@@ -1860,9 +1930,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
currentModel,
userTier,
proQuotaRequest,
validationRequest,
quota: {
userTier,
stats: quotaStats,
proQuotaRequest,
validationRequest,
},
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1891,7 +1964,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showDebugProfiler,
customDialog,
copyModeEnabled,
warningMessage,
transientMessage,
bannerData,
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
@@ -1932,6 +2005,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
authConsentRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
permissionConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,
@@ -1956,6 +2030,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
ctrlCPressCount,
ctrlDPressCount,
showEscapePrompt,
shortcutsHelpVisible,
isFocused,
elapsedTime,
currentLoadingPhrase,
@@ -1965,6 +2040,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
userTier,
quotaStats,
proQuotaRequest,
validationRequest,
contextFileNames,
@@ -1999,7 +2075,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
apiKeyDefaultValue,
authState,
copyModeEnabled,
warningMessage,
transientMessage,
bannerData,
bannerVisible,
config,
@@ -2055,7 +2131,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
handleWarning,
setShortcutsHelpVisible,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
@@ -2131,7 +2207,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
handleWarning,
setShortcutsHelpVisible,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
@@ -49,7 +49,6 @@ export function ApiAuthDialog({
width: viewportWidth,
height: 4,
},
isValidPath: () => false, // No path validation needed for API key
inputFilter: (text) =>
text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''),
singleLine: true,
+2
View File
@@ -88,8 +88,10 @@ export function AuthDialog({
const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
if (
defaultAuthTypeEnv &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType)
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
defaultAuthType = defaultAuthTypeEnv as AuthType;
}
+1
View File
@@ -113,6 +113,7 @@ export const useAuthCommand = (
const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
if (
defaultAuthType &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
!Object.values(AuthType).includes(defaultAuthType as AuthType)
) {
onAuthError(
@@ -213,6 +213,7 @@ const resumeCommand: SlashCommand = {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
uiHistory.push({
type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
text,
@@ -49,6 +49,7 @@ async function finishAddingDirectories(
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
});
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
}
@@ -129,6 +129,8 @@ describe('extensionsCommand', () => {
let mockContext: CommandContext;
const mockDispatchExtensionState = vi.fn();
let mockExtensionLoader: unknown;
let mockReloadSkills: MockedFunction<() => Promise<void>>;
let mockReloadAgents: MockedFunction<() => Promise<void>>;
beforeEach(() => {
vi.resetAllMocks();
@@ -148,12 +150,19 @@ describe('extensionsCommand', () => {
mockGetExtensions.mockReturnValue([inactiveExt, activeExt, allExt]);
vi.mocked(open).mockClear();
mockReloadAgents = vi.fn().mockResolvedValue(undefined);
mockReloadSkills = vi.fn().mockResolvedValue(undefined);
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: mockGetExtensions,
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader),
getWorkingDir: () => '/test/dir',
reloadSkills: mockReloadSkills,
getAgentRegistry: vi.fn().mockReturnValue({
reload: mockReloadAgents,
}),
},
},
ui: {
@@ -892,6 +901,27 @@ describe('extensionsCommand', () => {
type: 'RESTARTED',
payload: { name: 'ext2' },
});
expect(mockReloadSkills).toHaveBeenCalled();
expect(mockReloadAgents).toHaveBeenCalled();
});
it('handles errors during skill or agent reload', async () => {
const mockExtensions = [
{ name: 'ext1', isActive: true },
] as GeminiCLIExtension[];
mockGetExtensions.mockReturnValue(mockExtensions);
mockReloadSkills.mockRejectedValue(new Error('Failed to reload skills'));
await restartAction!(mockContext, '--all');
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
expect(mockReloadSkills).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Failed to reload skills or agents: Failed to reload skills',
}),
);
});
it('restarts only specified active extensions', async () => {
@@ -231,6 +231,18 @@ async function restartAction(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
if (failures.length < extensionsToRestart.length) {
try {
await context.services.config?.reloadSkills();
await context.services.config?.getAgentRegistry()?.reload();
} catch (error) {
context.ui.addItem({
type: MessageType.ERROR,
text: `Failed to reload skills or agents: ${getErrorMessage(error)}`,
});
}
}
if (failures.length > 0) {
const errorMessages = failures
.map((failure, index) => {
@@ -10,7 +10,6 @@ import { MessageType, type HistoryItemHelp } from '../types.js';
export const helpCommand: SlashCommand = {
name: 'help',
altNames: ['?'],
kind: CommandKind.BUILT_IN,
description: 'For help on gemini-cli',
autoExecute: true,
@@ -48,6 +48,7 @@ export const initCommand: SlashCommand = {
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return result as SlashCommandActionReturn;
},
};
@@ -60,6 +60,7 @@ const createMockMCPTool = (
{ type: 'object', properties: {} },
mockMessageBus,
undefined, // trust
undefined, // isReadOnly
undefined, // nameOverride
undefined, // cliConfig
undefined, // extensionName
@@ -19,6 +19,7 @@ import {
showMemory,
addMemory,
listMemoryFiles,
flattenMemory,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -33,7 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
refreshMemory: vi.fn(async (config) => {
if (config.isJitContextEnabled()) {
await config.getContextManager()?.refresh();
const memoryContent = config.getUserMemory() || '';
const memoryContent = original.flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
return {
type: 'message',
@@ -85,7 +86,7 @@ describe('memoryCommand', () => {
mockGetGeminiMdFileCount = vi.fn();
vi.mocked(showMemory).mockImplementation((config) => {
const memoryContent = config.getUserMemory() || '';
const memoryContent = flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
let content;
if (memoryContent.length > 0) {
@@ -93,6 +93,7 @@ export const memoryCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.ERROR,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
text: `Error refreshing memory: ${(error as Error).message}`,
},
Date.now(),
@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const shortcutsCommand: SlashCommand = {
name: 'shortcuts',
altNames: [],
kind: CommandKind.BUILT_IN,
description: 'Toggle the shortcuts panel above the input',
autoExecute: true,
action: (context) => {
context.ui.toggleShortcutsHelp();
},
};
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -54,6 +54,7 @@ describe('statsCommand', () => {
selectedAuthType: '',
tier: undefined,
userEmail: 'mock@example.com',
currentModel: undefined,
});
});
@@ -63,9 +64,20 @@ describe('statsCommand', () => {
const mockQuota = { buckets: [] };
const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota);
const mockGetUserTierName = vi.fn().mockReturnValue('Basic');
const mockGetModel = vi.fn().mockReturnValue('gemini-pro');
const mockGetQuotaRemaining = vi.fn().mockReturnValue(85);
const mockGetQuotaLimit = vi.fn().mockReturnValue(100);
const mockGetQuotaResetTime = vi
.fn()
.mockReturnValue('2025-01-01T12:00:00Z');
mockContext.services.config = {
refreshUserQuota: mockRefreshUserQuota,
getUserTierName: mockGetUserTierName,
getModel: mockGetModel,
getQuotaRemaining: mockGetQuotaRemaining,
getQuotaLimit: mockGetQuotaLimit,
getQuotaResetTime: mockGetQuotaResetTime,
} as unknown as Config;
await statsCommand.action(mockContext, '');
@@ -75,6 +87,10 @@ describe('statsCommand', () => {
expect.objectContaining({
quotas: mockQuota,
tier: 'Basic',
currentModel: 'gemini-pro',
pooledRemaining: 85,
pooledLimit: 100,
pooledResetTime: '2025-01-01T12:00:00Z',
}),
);
});
@@ -93,6 +109,9 @@ describe('statsCommand', () => {
selectedAuthType: '',
tier: undefined,
userEmail: 'mock@example.com',
currentModel: undefined,
pooledRemaining: undefined,
pooledLimit: undefined,
});
});
+14 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -44,6 +44,7 @@ async function defaultSessionView(context: CommandContext) {
const wallDuration = now.getTime() - sessionStartTime.getTime();
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const currentModel = context.services.config?.getModel();
const statsItem: HistoryItemStats = {
type: MessageType.STATS,
@@ -51,12 +52,16 @@ async function defaultSessionView(context: CommandContext) {
selectedAuthType,
userEmail,
tier,
currentModel,
};
if (context.services.config) {
const quota = await context.services.config.refreshUserQuota();
if (quota) {
statsItem.quotas = quota;
statsItem.pooledRemaining = context.services.config.getQuotaRemaining();
statsItem.pooledLimit = context.services.config.getQuotaLimit();
statsItem.pooledResetTime = context.services.config.getQuotaResetTime();
}
}
@@ -89,11 +94,19 @@ export const statsCommand: SlashCommand = {
autoExecute: true,
action: (context: CommandContext) => {
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const currentModel = context.services.config?.getModel();
const pooledRemaining = context.services.config?.getQuotaRemaining();
const pooledLimit = context.services.config?.getQuotaLimit();
const pooledResetTime = context.services.config?.getQuotaResetTime();
context.ui.addItem({
type: MessageType.MODEL_STATS,
selectedAuthType,
userEmail,
tier,
currentModel,
pooledRemaining,
pooledLimit,
pooledResetTime,
} as HistoryItemModelStats);
},
},
+1
View File
@@ -91,6 +91,7 @@ export interface CommandContext {
setConfirmationRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundShell: () => void;
toggleShortcutsHelp: () => void;
};
// Session-specific data
session: {
@@ -123,6 +123,7 @@ function getNestedValue(
for (const key of path) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = (current as Record<string, unknown>)[key];
}
return current;
@@ -144,8 +145,10 @@ function setNestedValue(
if (current[key] === undefined || current[key] === null) {
current[key] = {};
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current[key] = { ...(current[key] as Record<string, unknown>) };
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = current[key] as Record<string, unknown>;
}
@@ -265,6 +268,7 @@ export function AgentConfigDialog({
() =>
AGENT_CONFIG_FIELDS.map((field) => {
const currentValue = getNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
);
@@ -300,6 +304,7 @@ export function AgentConfigDialog({
displayValue,
isGreyedOut: currentValue === undefined,
scopeMessage: undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawValue: rawValue as string | number | boolean | undefined,
};
}),
@@ -320,6 +325,7 @@ export function AgentConfigDialog({
if (!field || field.type !== 'boolean') return;
const currentValue = getNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
);
@@ -329,6 +335,7 @@ export function AgentConfigDialog({
const newValue = !effectiveValue;
const newOverride = setNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
newValue,
@@ -369,6 +376,7 @@ export function AgentConfigDialog({
// Update pending override locally
const newOverride = setNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
parsed,
@@ -391,6 +399,7 @@ export function AgentConfigDialog({
// Remove the override (set to undefined)
const newOverride = setNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
undefined,
@@ -68,8 +68,9 @@ describe('<AnsiOutputText />', () => {
const output = lastFrame();
expect(output).toBeDefined();
const lines = output!.split('\n');
expect(lines[0]).toBe('First line');
expect(lines[1]).toBe('Third line');
expect(lines[0].trim()).toBe('First line');
expect(lines[1].trim()).toBe('');
expect(lines[2].trim()).toBe('Third line');
});
it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
@@ -89,6 +90,45 @@ describe('<AnsiOutputText />', () => {
expect(output).toContain('Line 4');
});
it('respects the maxLines prop and slices the lines correctly', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame } = render(
<AnsiOutputText data={data} maxLines={2} width={80} />,
);
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
});
it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
// availableTerminalHeight=3, maxLines=2 => show 2 lines
const { lastFrame } = render(
<AnsiOutputText
data={data}
availableTerminalHeight={3}
maxLines={2}
width={80}
/>,
);
const output = lastFrame();
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
});
it('renders a large AnsiOutput object without crashing', () => {
const largeData: AnsiOutput = [];
for (let i = 0; i < 1000; i++) {
+39 -23
View File
@@ -14,40 +14,56 @@ interface AnsiOutputProps {
data: AnsiOutput;
availableTerminalHeight?: number;
width: number;
maxLines?: number;
disableTruncation?: boolean;
}
export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
data,
availableTerminalHeight,
width,
maxLines,
disableTruncation,
}) => {
const lastLines = data.slice(
-(availableTerminalHeight && availableTerminalHeight > 0
const availableHeightLimit =
availableTerminalHeight && availableTerminalHeight > 0
? availableTerminalHeight
: DEFAULT_HEIGHT),
);
: undefined;
const numLinesRetained =
availableHeightLimit !== undefined && maxLines !== undefined
? Math.min(availableHeightLimit, maxLines)
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
const lastLines = disableTruncation ? data : data.slice(-numLinesRetained);
return (
<Box flexDirection="column" width={width} flexShrink={0}>
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
{lastLines.map((line: AnsiLine, lineIndex: number) => (
<Text key={lineIndex} wrap="truncate">
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
<Box key={lineIndex} height={1} overflow="hidden">
<AnsiLineText line={line} />
</Box>
))}
</Box>
);
};
export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
<Text>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
);
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -89,53 +89,6 @@ describe('<AppHeader />', () => {
unmount();
});
it('should render the banner when previewFeatures is disabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: false });
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
expect(lastFrame()).toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the banner when previewFeatures is enabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: true });
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
expect(lastFrame()).not.toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the default banner if shown count is 5 or more', () => {
const mockConfig = makeFakeConfig();
const uiState = {
+1 -1
View File
@@ -24,7 +24,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
const config = useConfig();
const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
const { bannerText } = useBanner(bannerData, config);
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
return (
@@ -15,8 +15,20 @@ describe('ApprovalModeIndicator', () => {
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
);
const output = lastFrame();
expect(output).toContain('accepting edits');
expect(output).toContain('(shift + tab to cycle)');
expect(output).toContain('auto-accept edits');
expect(output).toContain('shift+tab to manual');
});
it('renders correctly for AUTO_EDIT mode with plan enabled', () => {
const { lastFrame } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.AUTO_EDIT}
isPlanEnabled={true}
/>,
);
const output = lastFrame();
expect(output).toContain('auto-accept edits');
expect(output).toContain('shift+tab to manual');
});
it('renders correctly for PLAN mode', () => {
@@ -24,8 +36,8 @@ describe('ApprovalModeIndicator', () => {
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
);
const output = lastFrame();
expect(output).toContain('plan mode');
expect(output).toContain('(shift + tab to cycle)');
expect(output).toContain('plan');
expect(output).toContain('shift+tab to accept edits');
});
it('renders correctly for YOLO mode', () => {
@@ -33,16 +45,26 @@ describe('ApprovalModeIndicator', () => {
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
);
const output = lastFrame();
expect(output).toContain('YOLO mode');
expect(output).toContain('(ctrl + y to toggle)');
expect(output).toContain('YOLO');
expect(output).toContain('ctrl+y');
});
it('renders nothing for DEFAULT mode', () => {
it('renders correctly for DEFAULT mode', () => {
const { lastFrame } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
);
const output = lastFrame();
expect(output).not.toContain('accepting edits');
expect(output).not.toContain('YOLO mode');
expect(output).toContain('shift+tab to accept edits');
});
it('renders correctly for DEFAULT mode with plan enabled', () => {
const { lastFrame } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.DEFAULT}
isPlanEnabled={true}
/>,
);
const output = lastFrame();
expect(output).toContain('shift+tab to plan');
});
});
@@ -11,10 +11,12 @@ import { ApprovalMode } from '@google/gemini-cli-core';
interface ApprovalModeIndicatorProps {
approvalMode: ApprovalMode;
isPlanEnabled?: boolean;
}
export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
approvalMode,
isPlanEnabled,
}) => {
let textColor = '';
let textContent = '';
@@ -23,29 +25,39 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
switch (approvalMode) {
case ApprovalMode.AUTO_EDIT:
textColor = theme.status.warning;
textContent = 'accepting edits';
subText = ' (shift + tab to cycle)';
textContent = 'auto-accept edits';
subText = 'shift+tab to manual';
break;
case ApprovalMode.PLAN:
textColor = theme.status.success;
textContent = 'plan mode';
subText = ' (shift + tab to cycle)';
textContent = 'plan';
subText = 'shift+tab to accept edits';
break;
case ApprovalMode.YOLO:
textColor = theme.status.error;
textContent = 'YOLO mode';
subText = ' (ctrl + y to toggle)';
textContent = 'YOLO';
subText = 'ctrl+y';
break;
case ApprovalMode.DEFAULT:
default:
textColor = theme.text.accent;
textContent = '';
subText = isPlanEnabled
? 'shift+tab to plan'
: 'shift+tab to accept edits';
break;
}
return (
<Box>
<Text color={textColor}>
{textContent}
{subText && <Text color={theme.text.secondary}>{subText}</Text>}
{textContent ? textContent : null}
{subText ? (
<Text color={theme.text.secondary}>
{textContent ? ' ' : ''}
{subText}
</Text>
) : null}
</Text>
</Box>
);
@@ -285,7 +285,6 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
initialText: initialAnswer,
viewport: { width: Math.max(1, bufferWidth), height: 1 },
singleLine: true,
isValidPath: () => false,
});
const { text: textValue } = buffer;
@@ -362,7 +361,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
</Box>
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.accent}>{'> '}</Text>
<Text color={theme.status.success}>{'> '}</Text>
<TextInput
buffer={buffer}
placeholder={placeholder}
@@ -564,7 +563,6 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
initialText: initialCustomText,
viewport: { width: Math.max(1, bufferWidth), height: 1 },
singleLine: true,
isValidPath: () => false,
});
const customOptionText = customBuffer.text;
@@ -840,7 +838,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
<Box flexDirection="row">
{showCheck && (
<Text
color={isChecked ? theme.text.accent : theme.text.secondary}
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? 'x' : ' '}]
</Text>
@@ -872,7 +872,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
<Box flexDirection="row">
{showCheck && (
<Text
color={isChecked ? theme.text.accent : theme.text.secondary}
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? 'x' : ' '}]
</Text>
@@ -5,7 +5,7 @@
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
@@ -20,16 +20,12 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
const mockSetIsBackgroundShellListOpen = vi.fn();
const mockHandleWarning = vi.fn();
const mockSetEmbeddedShellFocused = vi.fn();
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: () => ({
dismissBackgroundShell: mockDismissBackgroundShell,
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
handleWarning: mockHandleWarning,
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
}),
}));
@@ -103,6 +99,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
),
}));
afterEach(() => {
vi.restoreAllMocks();
});
const createMockKey = (overrides: Partial<Key>): Key => ({
name: '',
ctrl: false,
@@ -405,55 +405,4 @@ describe('<BackgroundShellDisplay />', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('unfocuses the shell when Shift+Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab', shift: true });
});
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('shows a warning when Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab' });
});
expect(mockHandleWarning).toHaveBeenCalledWith(
'Press Shift+Tab to focus out.',
);
expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
});
});
@@ -18,7 +18,7 @@ import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { Command, keyMatchers } from '../keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { commandDescriptions } from '../../config/keyBindings.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import {
ScrollableList,
type ScrollableListRef,
@@ -64,8 +64,6 @@ export const BackgroundShellDisplay = ({
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
handleWarning,
setEmbeddedShellFocused,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
@@ -138,27 +136,6 @@ export const BackgroundShellDisplay = ({
(key) => {
if (!activeShell) return;
// Handle Shift+Tab or Tab (in list) to focus out
if (
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ||
(isListOpenProp &&
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key))
) {
setEmbeddedShellFocused(false);
return true;
}
// Handle Tab to warn but propagate
if (
!isListOpenProp &&
keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key)
) {
handleWarning(
`Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`,
);
// Fall through to allow Tab to be sent to the shell
}
if (isListOpenProp) {
// Navigation (Up/Down/Enter) is handled by RadioButtonSelect
// We only handle special keys not consumed by RadioButtonSelect or overriding them if needed
@@ -188,7 +165,7 @@ export const BackgroundShellDisplay = ({
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return true;
return false;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
@@ -216,7 +193,27 @@ export const BackgroundShellDisplay = ({
{ isActive: isFocused && !!activeShell },
);
const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`;
const helpTextParts = [
{ label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL },
{ label: 'Kill', command: Command.KILL_BACKGROUND_SHELL },
{ label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST },
];
const helpTextStr = helpTextParts
.map((p) => `${p.label} (${formatCommand(p.command)})`)
.join(' | ');
const renderHelpText = () => (
<Text>
{helpTextParts.map((p, i) => (
<Text key={p.label}>
{i > 0 ? ' | ' : ''}
{p.label} (
<Text color={theme.text.accent}>{formatCommand(p.command)}</Text>)
</Text>
))}
</Text>
);
const renderTabs = () => {
const shellList = Array.from(shells.values()).filter(
@@ -230,7 +227,7 @@ export const BackgroundShellDisplay = ({
const availableWidth =
width -
TAB_DISPLAY_HORIZONTAL_PADDING -
getCachedStringWidth(helpText) -
getCachedStringWidth(helpTextStr) -
pidInfoWidth;
let currentWidth = 0;
@@ -272,7 +269,7 @@ export const BackgroundShellDisplay = ({
}
if (shellList.length > tabs.length && !isListOpenProp) {
const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `;
const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `;
const overflowWidth = getCachedStringWidth(overflowLabel);
// If we only have one tab, ensure we don't show the overflow if it's too cramped
@@ -324,7 +321,7 @@ export const BackgroundShellDisplay = ({
<Box flexDirection="column" height="100%" width="100%">
<Box flexShrink={0} marginBottom={1} paddingTop={1}>
<Text bold>
{`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`}
{`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`}
</Text>
</Box>
<Box flexGrow={1} width="100%">
@@ -450,7 +447,7 @@ export const BackgroundShellDisplay = ({
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
</Text>
</Box>
<Text color={theme.text.accent}>{helpText}</Text>
{renderHelpText()}
</Box>
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
{isListOpenProp ? renderProcessList() : renderOutput()}

Some files were not shown because too many files have changed in this diff Show More