Compare commits

...

11 Commits

Author SHA1 Message Date
gemini-cli-robot 9affc72d3a chore(release): v0.28.0-preview.5 2026-02-06 21:25:17 +00:00
gemini-cli-robot 44c67daa07 fix(patch): cherry-pick ec5836c to release/v0.28.0-preview.4-pr-18343 to patch version v0.28.0-preview.4 and create version 0.28.0-preview.5 (#18472) 2026-02-06 13:01:51 -08:00
gemini-cli-robot 37f24a94e8 chore(release): v0.28.0-preview.4 2026-02-06 20:29:55 +00:00
gemini-cli-robot 900e0c9f05 fix(patch): cherry-pick 1cae5ab to release/v0.28.0-preview.3-pr-18376 to patch version v0.28.0-preview.3 and create version 0.28.0-preview.4 (#18463)
Co-authored-by: Peter Friese <peter@peterfriese.de>
2026-02-06 19:51:00 +00:00
gemini-cli-robot 7fb0e1d1c1 chore(release): v0.28.0-preview.3 2026-02-06 19:05:25 +00:00
gemini-cli-robot 64642f2718 fix(patch): cherry-pick d86b1f7 to release/v0.28.0-preview.2-pr-18447 to patch version v0.28.0-preview.2 and create version 0.28.0-preview.3 (#18450)
Co-authored-by: christine betts <chrstn@uw.edu>
2026-02-06 18:21:09 +00:00
gemini-cli-robot 171ecdef18 chore(release): v0.28.0-preview.2 2026-02-05 21:17:47 +00:00
gemini-cli-robot 45866d8e0a fix(patch): cherry-pick 9ca7300 to release/v0.28.0-preview.1-pr-18395 to patch version v0.28.0-preview.1 and create version 0.28.0-preview.2 (#18400)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-05 20:55:31 +00:00
gemini-cli-robot 1795ffaf36 chore(release): v0.28.0-preview.1 2026-02-05 15:38:07 +00:00
gemini-cli-robot 8468a48bff fix(patch): cherry-pick aba8c5f to release/v0.28.0-preview.0-pr-17806 to patch version v0.28.0-preview.0 and create version 0.28.0-preview.1 (#18307) 2026-02-04 08:07:42 -08:00
gemini-cli-robot 9bd6ec6664 chore(release): v0.28.0-preview.0 2026-02-03 23:58:27 +00:00
58 changed files with 1459 additions and 591 deletions
+11 -10
View File
@@ -106,16 +106,17 @@ available combinations.
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
| Ctrl+B | `Ctrl + B` |
| Ctrl+L | `Ctrl + L` |
| Ctrl+K | `Ctrl + K` |
| Enter | `Enter` |
| Esc | `Esc` |
| Shift+Tab | `Shift + Tab` |
| Tab | `Tab (no Shift)` |
| Tab | `Tab (no Shift)` |
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
| Focus the Gemini input from the shell input. | `Tab` |
| Toggle current background shell visibility. | `Ctrl + B` |
| Toggle background shell list. | `Ctrl + L` |
| Kill the active background shell. | `Ctrl + K` |
| Confirm selection in background shell list. | `Enter` |
| Dismiss background shell list. | `Esc` |
| Move focus from background shell to Gemini. | `Shift + Tab` |
| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
| Show warning when trying to unfocus background shell via Tab. | `Tab (no Shift)` |
| Show warning when trying to unfocus shell input via Tab. | `Tab (no Shift)` |
| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
| Move focus from the shell back to Gemini. | `Shift + Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
| Restart the application. | `R` |
| Suspend the application (not yet implemented). | `Ctrl + Z` |
+1 -1
View File
@@ -115,7 +115,7 @@ they appear in the UI.
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
### Experimental
+2 -2
View File
@@ -792,7 +792,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`security.folderTrust.enabled`** (boolean):
- **Description:** Setting to track whether Folder trust is enabled.
- **Default:** `false`
- **Default:** `true`
- **Requires restart:** Yes
- **`security.environmentVariableRedaction.allowed`** (array):
@@ -866,7 +866,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.extensionConfig`** (boolean):
- **Description:** Enable requesting and fetching of extension settings.
- **Default:** `false`
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.enableEventDrivenScheduler`** (boolean):
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"workspaces": [
"packages/*"
],
@@ -17999,7 +17999,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -18055,7 +18055,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -18142,7 +18142,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -18300,7 +18300,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18317,7 +18317,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0-nightly.20260128.adc8e11bb"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0-preview.5"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"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.28.0-nightly.20260128.adc8e11bb"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0-preview.5"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
+2 -2
View File
@@ -1556,12 +1556,12 @@ describe('loadCliConfig folderTrust', () => {
expect(config.getFolderTrust()).toBe(true);
});
it('should be false by default', async () => {
it('should be true by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getFolderTrust()).toBe(false);
expect(config.getFolderTrust()).toBe(true);
});
});
@@ -108,6 +108,7 @@ describe('ExtensionManager Settings Scope', () => {
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
security: { folderTrust: { enabled: false } },
}),
});
@@ -146,6 +147,7 @@ describe('ExtensionManager Settings Scope', () => {
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
security: { folderTrust: { enabled: false } },
}),
});
@@ -182,6 +184,7 @@ describe('ExtensionManager Settings Scope', () => {
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
security: { folderTrust: { enabled: false } },
}),
});
+21 -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',
@@ -281,6 +282,7 @@ 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]: [
@@ -288,7 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 's', 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 }],
@@ -405,6 +407,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 +499,23 @@ 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.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).',
+315 -40
View File
@@ -67,13 +67,14 @@ import {
getSystemSettingsPath,
getSystemDefaultsPath,
type Settings,
saveSettings,
type SettingsFile,
saveSettings,
getDefaultsFromSchema,
loadEnvironment,
migrateDeprecatedSettings,
SettingScope,
LoadedSettings,
sanitizeEnvVar,
} from './settings.js';
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -82,6 +83,7 @@ import {
MergeStrategy,
type SettingsSchema,
} from './settingsSchema.js';
import { createMockSettings } from '../test-utils/settings.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
// Use the (mocked) GEMINI_DIR for consistency
@@ -1746,6 +1748,7 @@ describe('Settings Loading and Merging', () => {
isFolderTrustEnabled = true,
isWorkspaceTrustedValue = true as boolean | undefined,
}) {
delete process.env['GEMINI_API_KEY']; // reset
delete process.env['TESTTEST']; // reset
const geminiEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
@@ -1779,7 +1782,8 @@ describe('Settings Loading and Merging', () => {
const normalizedP = path.resolve(p.toString());
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (normalizedP === geminiEnvPath) return 'TESTTEST=1234';
if (normalizedP === geminiEnvPath)
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
return '{}';
},
);
@@ -1793,6 +1797,7 @@ describe('Settings Loading and Merging', () => {
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).toEqual('1234');
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('does not load env files from untrusted spaces', () => {
@@ -1817,6 +1822,36 @@ describe('Settings Loading and Merging', () => {
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
expect(process.env['TESTTEST']).not.toEqual('1234');
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
});
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = loadSettings(MOCK_WORKSPACE_DIR);
settings.merged.tools.sandbox = true;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
// TESTTEST is NOT in the whitelist, so it should be blocked.
expect(process.env['TESTTEST']).not.toEqual('1234');
});
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled via CLI flag', () => {
const originalArgv = [...process.argv];
process.argv.push('-s');
try {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Ensure sandbox is NOT in settings to test argv sniffing
settings.merged.tools.sandbox = undefined;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
expect(process.env['TESTTEST']).not.toEqual('1234');
} finally {
process.argv = originalArgv;
}
});
});
@@ -1975,29 +2010,7 @@ describe('Settings Loading and Merging', () => {
},
};
const loadedSettings = new LoadedSettings(
{
path: getSystemSettingsPath(),
settings: {},
originalSettings: {},
},
{
path: getSystemDefaultsPath(),
settings: {},
originalSettings: {},
},
{
path: USER_SETTINGS_PATH,
settings: userSettingsContent as unknown as Settings,
originalSettings: userSettingsContent as unknown as Settings,
},
{
path: MOCK_WORKSPACE_SETTINGS_PATH,
settings: {},
originalSettings: {},
},
true,
);
const loadedSettings = createMockSettings(userSettingsContent);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
@@ -2166,11 +2179,8 @@ describe('Settings Loading and Merging', () => {
describe('saveSettings', () => {
it('should save settings using updateSettingsFilePreservingFormat', () => {
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
const settingsFile = {
path: '/mock/settings.json',
settings: { ui: { theme: 'dark' } },
originalSettings: { ui: { theme: 'dark' } },
} as unknown as SettingsFile;
const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user;
settingsFile.path = '/mock/settings.json';
saveSettings(settingsFile);
@@ -2184,11 +2194,8 @@ describe('Settings Loading and Merging', () => {
const mockFsMkdirSync = vi.mocked(fs.mkdirSync);
mockFsExistsSync.mockReturnValue(false);
const settingsFile = {
path: '/mock/new/dir/settings.json',
settings: {},
originalSettings: {},
} as unknown as SettingsFile;
const settingsFile = createMockSettings({}).user;
settingsFile.path = '/mock/new/dir/settings.json';
saveSettings(settingsFile);
@@ -2205,11 +2212,8 @@ describe('Settings Loading and Merging', () => {
throw error;
});
const settingsFile = {
path: '/mock/settings.json',
settings: {},
originalSettings: {},
} as unknown as SettingsFile;
const settingsFile = createMockSettings({}).user;
settingsFile.path = '/mock/settings.json';
saveSettings(settingsFile);
@@ -2412,6 +2416,277 @@ describe('Settings Loading and Merging', () => {
});
});
});
describe('Security and Sandbox', () => {
let originalArgv: string[];
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalArgv = [...process.argv];
originalEnv = { ...process.env };
// Clear relevant env vars
delete process.env['GEMINI_API_KEY'];
delete process.env['GOOGLE_API_KEY'];
delete process.env['GOOGLE_CLOUD_PROJECT'];
delete process.env['GOOGLE_CLOUD_LOCATION'];
delete process.env['CLOUD_SHELL'];
delete process.env['MALICIOUS_VAR'];
delete process.env['FOO'];
vi.resetAllMocks();
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
});
describe('sandbox detection', () => {
it('should detect sandbox when -s is a real flag', () => {
process.argv = ['node', 'gemini', '-s', 'some prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'FOO=bar\nGEMINI_API_KEY=secret',
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
// If sandboxed and untrusted, FOO should NOT be loaded, but GEMINI_API_KEY should be.
expect(process.env['FOO']).toBeUndefined();
expect(process.env['GEMINI_API_KEY']).toBe('secret');
});
it('should detect sandbox when --sandbox is a real flag', () => {
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe('secret');
});
it('should ignore sandbox flags if they appear after --', () => {
process.argv = ['node', 'gemini', '--', '-s', 'some prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
it('should NOT be tricked by positional arguments that look like flags', () => {
process.argv = ['node', 'gemini', 'my -s prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
});
describe('env var sanitization', () => {
it('should strictly enforce whitelist in untrusted/sandboxed mode', () => {
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue(`
GEMINI_API_KEY=secret-key
MALICIOUS_VAR=should-be-ignored
GOOGLE_API_KEY=another-secret
`);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe('secret-key');
expect(process.env['GOOGLE_API_KEY']).toBe('another-secret');
expect(process.env['MALICIOUS_VAR']).toBeUndefined();
});
it('should sanitize shell injection characters in whitelisted env vars in untrusted mode', () => {
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
const maliciousPayload = 'key-$(whoami)-`id`-&|;><*?[]{}';
vi.mocked(fs.readFileSync).mockReturnValue(
`GEMINI_API_KEY=${maliciousPayload}`,
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
// sanitizeEnvVar: value.replace(/[^a-zA-Z0-9\-_./]/g, '')
expect(process.env['GEMINI_API_KEY']).toBe('key-whoami-id-');
});
it('should allow . and / in whitelisted env vars but sanitize other characters in untrusted mode', () => {
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
const complexPayload = 'secret-123/path.to/somewhere;rm -rf /';
vi.mocked(fs.readFileSync).mockReturnValue(
`GEMINI_API_KEY=${complexPayload}`,
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe(
'secret-123/path.to/somewhererm-rf/',
);
});
it('should NOT sanitize variables from trusted sources', () => {
process.argv = ['node', 'gemini', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('FOO=$(bar)');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
// Trusted source, no sanitization
expect(process.env['FOO']).toBe('$(bar)');
});
it('should load environment variables normally when workspace is TRUSTED even if "sandboxed"', () => {
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue(`
GEMINI_API_KEY=un-sanitized;key!
MALICIOUS_VAR=allowed-because-trusted
`);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe('un-sanitized;key!');
expect(process.env['MALICIOUS_VAR']).toBe('allowed-because-trusted');
});
it('should sanitize value in sanitizeEnvVar helper', () => {
expect(sanitizeEnvVar('$(calc)')).toBe('calc');
expect(sanitizeEnvVar('`rm -rf /`')).toBe('rm-rf/');
expect(sanitizeEnvVar('normal-project-123')).toBe('normal-project-123');
expect(sanitizeEnvVar('us-central1')).toBe('us-central1');
});
});
describe('Cloud Shell security', () => {
it('should handle Cloud Shell special defaults securely when untrusted', () => {
process.env['CLOUD_SHELL'] = 'true';
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
// No .env file
vi.mocked(fs.existsSync).mockReturnValue(false);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
});
it('should sanitize GOOGLE_CLOUD_PROJECT in Cloud Shell when loaded from .env in untrusted mode', () => {
process.env['CLOUD_SHELL'] = 'true';
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'GOOGLE_CLOUD_PROJECT=attacker-project;inject',
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe(
'attacker-projectinject',
);
});
});
});
});
describe('LoadedSettings Isolation and Serializability', () => {
+56 -11
View File
@@ -77,6 +77,21 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const AUTH_ENV_VAR_WHITELIST = [
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION',
];
/**
* Sanitizes an environment variable value to prevent shell injection.
* Restricts values to a safe character set: alphanumeric, -, _, ., /
*/
export function sanitizeEnvVar(value: string): string {
return value.replace(/[^a-zA-Z0-9\-_./]/g, '');
}
export function getSystemSettingsPath(): string {
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
@@ -439,26 +454,30 @@ function findEnvFile(startDir: string): string | null {
}
}
export function setUpCloudShellEnvironment(envFilePath: string | null): void {
export function setUpCloudShellEnvironment(
envFilePath: string | null,
isTrusted: boolean,
isSandboxed: boolean,
): void {
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
// set by the user using "gcloud config set project" we do not want to
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
// one of the .env files, we set the Cloud Shell-specific default here.
let value = 'cloudshell-gca';
if (envFilePath && fs.existsSync(envFilePath)) {
const envFileContent = fs.readFileSync(envFilePath);
const parsedEnv = dotenv.parse(envFileContent);
if (parsedEnv['GOOGLE_CLOUD_PROJECT']) {
// .env file takes precedence in Cloud Shell
process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT'];
} else {
// If not in .env, set to default and override global
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
value = parsedEnv['GOOGLE_CLOUD_PROJECT'];
if (!isTrusted && isSandboxed) {
value = sanitizeEnvVar(value);
}
}
} else {
// If no .env file, set to default and override global
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
}
process.env['GOOGLE_CLOUD_PROJECT'] = value;
}
export function loadEnvironment(
@@ -469,13 +488,29 @@ export function loadEnvironment(
const envFilePath = findEnvFile(workspaceDir);
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
if (trustResult.isTrusted !== true) {
const isTrusted = trustResult.isTrusted ?? false;
// Check settings OR check process.argv directly since this might be called
// before arguments are fully parsed. This is a best-effort sniffing approach
// that happens early in the CLI lifecycle. It is designed to detect the
// sandbox flag before the full command-line parser is initialized to ensure
// security constraints are applied when loading environment variables.
const args = process.argv.slice(2);
const doubleDashIndex = args.indexOf('--');
const relevantArgs =
doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex);
const isSandboxed =
!!settings.tools?.sandbox ||
relevantArgs.includes('-s') ||
relevantArgs.includes('--sandbox');
if (trustResult.isTrusted !== true && !isSandboxed) {
return;
}
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
setUpCloudShellEnvironment(envFilePath);
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
}
if (envFilePath) {
@@ -491,6 +526,16 @@ export function loadEnvironment(
for (const key in parsedEnv) {
if (Object.hasOwn(parsedEnv, key)) {
let value = parsedEnv[key];
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
if (!isTrusted && isSandboxed) {
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
continue;
}
// Sanitize the value for untrusted sources
value = sanitizeEnvVar(value);
}
// If it's a project .env file, skip loading excluded variables.
if (isProjectEnvFile && excludedVars.includes(key)) {
continue;
@@ -498,7 +543,7 @@ export function loadEnvironment(
// Load variable only if it's not already set in the environment.
if (!Object.hasOwn(process.env, key)) {
process.env[key] = parsedEnv[key];
process.env[key] = value;
}
}
}
@@ -294,7 +294,7 @@ describe('SettingsSchema', () => {
expect(
getSettingsSchema().security.properties.folderTrust.properties.enabled
.default,
).toBe(false);
).toBe(true);
expect(
getSettingsSchema().security.properties.folderTrust.properties.enabled
.showInDialog,
+2 -2
View File
@@ -1312,7 +1312,7 @@ const SETTINGS_SCHEMA = {
label: 'Folder Trust',
category: 'Security',
requiresRestart: true,
default: false,
default: true,
description: 'Setting to track whether Folder trust is enabled.',
showInDialog: true,
},
@@ -1486,7 +1486,7 @@ const SETTINGS_SCHEMA = {
label: 'Extension Configuration',
category: 'Experimental',
requiresRestart: true,
default: false,
default: true,
description: 'Enable requesting and fetching of extension settings.',
showInDialog: false,
},
+65 -2
View File
@@ -5,7 +5,11 @@
*/
import * as osActual from 'node:os';
import { FatalConfigError, ideContextStore } from '@google/gemini-cli-core';
import {
FatalConfigError,
ideContextStore,
AuthType,
} from '@google/gemini-cli-core';
import {
describe,
it,
@@ -26,6 +30,9 @@ import {
isWorkspaceTrusted,
resetTrustedFoldersForTesting,
} from './trustedFolders.js';
import { loadEnvironment, getSettingsSchema } from './settings.js';
import { createMockSettings } from '../test-utils/settings.js';
import { validateAuthMethod } from './auth.js';
import type { Settings } from './settings.js';
vi.mock('os', async (importOriginal) => {
@@ -53,7 +60,7 @@ vi.mock('fs', async (importOriginal) => {
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
realpathSync: vi.fn((p) => p),
realpathSync: vi.fn().mockImplementation((p) => p),
};
});
vi.mock('strip-json-comments', () => ({
@@ -578,6 +585,62 @@ describe('invalid trust levels', () => {
});
});
describe('Verification: Auth and Trust Interaction', () => {
let mockCwd: string;
const mockRules: Record<string, TrustLevel> = {};
beforeEach(() => {
vi.stubEnv('GEMINI_API_KEY', '');
resetTrustedFoldersForTesting();
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
if (p === path.resolve(mockCwd, '.env')) {
return 'GEMINI_API_KEY=shhh-secret';
}
return '{}';
});
vi.spyOn(fs, 'existsSync').mockImplementation(
(p) =>
p === getTrustedFoldersPath() || p === path.resolve(mockCwd, '.env'),
);
});
afterEach(() => {
vi.unstubAllEnvs();
Object.keys(mockRules).forEach((key) => delete mockRules[key]);
});
it('should verify loadEnvironment returns early and validateAuthMethod fails when untrusted', () => {
// 1. Mock untrusted workspace
mockCwd = '/home/user/untrusted';
mockRules[mockCwd] = TrustLevel.DO_NOT_TRUST;
// 2. Load environment (should return early)
const settings = createMockSettings({
security: { folderTrust: { enabled: true } },
});
loadEnvironment(settings.merged, mockCwd);
// 3. Verify env var NOT loaded
expect(process.env['GEMINI_API_KEY']).toBe('');
// 4. Verify validateAuthMethod fails
const result = validateAuthMethod(AuthType.USE_GEMINI);
expect(result).toContain(
'you must specify the GEMINI_API_KEY environment variable',
);
});
it('should identify if sandbox flag is available in Settings', () => {
const schema = getSettingsSchema();
expect(schema.tools.properties).toBeDefined();
expect('sandbox' in schema.tools.properties).toBe(true);
});
});
describe('Trusted Folders realpath caching', () => {
beforeEach(() => {
resetTrustedFoldersForTesting();
+1 -1
View File
@@ -250,7 +250,7 @@ export function saveTrustedFolders(
/** Is folder trust feature enabled per the current applied settings */
export function isFolderTrustEnabled(settings: Settings): boolean {
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? false;
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true;
return folderTrustSetting;
}
+27 -18
View File
@@ -13,7 +13,7 @@ import {
} from './deferred.js';
import { ExitCodes } from '@google/gemini-cli-core';
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import type { MergedSettings } from './config/settings.js';
import { createMockSettings } from './test-utils/settings.js';
import type { MockInstance } from 'vitest';
const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
@@ -46,14 +46,9 @@ describe('deferred', () => {
setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command
});
const createMockSettings = (adminSettings: unknown = {}): MergedSettings =>
({
admin: adminSettings,
}) as unknown as MergedSettings;
describe('runDeferredCommand', () => {
it('should do nothing if no deferred command is set', async () => {
await runDeferredCommand(createMockSettings());
await runDeferredCommand(createMockSettings().merged);
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
@@ -66,7 +61,9 @@ describe('deferred', () => {
commandName: 'mcp',
});
const settings = createMockSettings({ mcp: { enabled: true } });
const settings = createMockSettings({
merged: { admin: { mcp: { enabled: true } } },
}).merged;
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
expect(mockRunExitCleanup).toHaveBeenCalled();
@@ -80,7 +77,9 @@ describe('deferred', () => {
commandName: 'mcp',
});
const settings = createMockSettings({ mcp: { enabled: false } });
const settings = createMockSettings({
merged: { admin: { mcp: { enabled: false } } },
}).merged;
await runDeferredCommand(settings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -98,7 +97,9 @@ describe('deferred', () => {
commandName: 'extensions',
});
const settings = createMockSettings({ extensions: { enabled: false } });
const settings = createMockSettings({
merged: { admin: { extensions: { enabled: false } } },
}).merged;
await runDeferredCommand(settings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -116,7 +117,9 @@ describe('deferred', () => {
commandName: 'skills',
});
const settings = createMockSettings({ skills: { enabled: false } });
const settings = createMockSettings({
merged: { admin: { skills: { enabled: false } } },
}).merged;
await runDeferredCommand(settings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -135,7 +138,7 @@ describe('deferred', () => {
commandName: 'mcp',
});
const settings = createMockSettings({}); // No admin settings
const settings = createMockSettings({}).merged; // No admin settings
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
@@ -163,7 +166,7 @@ describe('deferred', () => {
expect(originalHandler).not.toHaveBeenCalled();
// Now manually run it to verify it captured correctly
await runDeferredCommand(createMockSettings());
await runDeferredCommand(createMockSettings().merged);
expect(originalHandler).toHaveBeenCalledWith(argv);
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
@@ -181,7 +184,9 @@ describe('deferred', () => {
const deferredMcp = defer(commandModule, 'mcp');
await deferredMcp.handler({} as ArgumentsCamelCase);
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
const mcpSettings = createMockSettings({
merged: { admin: { mcp: { enabled: false } } },
}).merged;
await runDeferredCommand(mcpSettings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -205,10 +210,14 @@ describe('deferred', () => {
// confirming it didn't capture 'mcp', 'extensions', or 'skills'
// and defaulted to 'unknown' (or something else safe).
const settings = createMockSettings({
mcp: { enabled: false },
extensions: { enabled: false },
skills: { enabled: false },
});
merged: {
admin: {
mcp: { enabled: false },
extensions: { enabled: false },
skills: { enabled: false },
},
},
}).merged;
await runDeferredCommand(settings);
+2 -15
View File
@@ -10,7 +10,7 @@ import type React from 'react';
import { vi } from 'vitest';
import { act, useState } from 'react';
import os from 'node:os';
import { LoadedSettings, type Settings } from '../config/settings.js';
import { LoadedSettings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
@@ -32,6 +32,7 @@ import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
import { createMockSettings } from './settings.js';
export const persistentStateMock = new FakePersistentState();
@@ -135,20 +136,6 @@ export const mockSettings = new LoadedSettings(
[],
);
export const createMockSettings = (
overrides: Partial<Settings>,
): LoadedSettings => {
const settings = overrides as Settings;
return new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings, originalSettings: settings },
{ path: '', settings: {}, originalSettings: {} },
true,
[],
);
};
// A minimal mock UIState to satisfy the context provider.
// Tests that need specific UIState values should provide their own.
const baseMockUiState = {
+79
View File
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
LoadedSettings,
createTestMergedSettings,
type SettingsError,
} from '../config/settings.js';
export interface MockSettingsFile {
settings: any;
originalSettings: any;
path: string;
}
interface CreateMockSettingsOptions {
system?: MockSettingsFile;
systemDefaults?: MockSettingsFile;
user?: MockSettingsFile;
workspace?: MockSettingsFile;
isTrusted?: boolean;
errors?: SettingsError[];
merged?: any;
[key: string]: any;
}
/**
* Creates a mock LoadedSettings object for testing.
*
* @param overrides - Partial settings or LoadedSettings properties to override.
* If 'merged' is provided, it overrides the computed merged settings.
* Any functions in overrides are assigned directly to the LoadedSettings instance.
*/
export const createMockSettings = (
overrides: CreateMockSettingsOptions = {},
): LoadedSettings => {
const {
system,
systemDefaults,
user,
workspace,
isTrusted,
errors,
merged: mergedOverride,
...settingsOverrides
} = overrides;
const loaded = new LoadedSettings(
(system as any) || { path: '', settings: {}, originalSettings: {} },
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
(user as any) || {
path: '',
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
);
if (mergedOverride) {
// @ts-expect-error - overriding private field for testing
loaded._merged = createTestMergedSettings(mergedOverride);
}
// Assign any function overrides (e.g., vi.fn() for methods)
for (const key in overrides) {
if (typeof overrides[key] === 'function') {
(loaded as any)[key] = overrides[key];
}
}
return loaded;
};
+154
View File
@@ -1940,6 +1940,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)', () => {
+57 -66
View File
@@ -1288,24 +1288,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, WARNING_PROMPT_DURATION_MS);
}, []);
useEffect(() => {
const handleSelectionWarning = () => {
handleWarning('Press Ctrl-S to enter selection mode to copy text.');
};
const handlePasteTimeout = () => {
handleWarning('Paste Timed out. Possibly due to slow connection.');
};
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
return () => {
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
// Handle timeout cleanup on unmount
useEffect(
() => () => {
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
},
[],
);
useEffect(() => {
const handlePasteTimeout = () => {
handleWarning('Paste Timed out. Possibly due to slow connection.');
};
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
return () => {
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
};
}, [handleWarning]);
@@ -1500,71 +1502,60 @@ 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 {
handleWarning('Use Shift+Tab to unfocus');
}
}, 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;
@@ -1607,7 +1598,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useEffect(() => {
// Respect hideWindowTitle settings
@@ -86,6 +86,11 @@ describe('directoryCommand', () => {
settings: {
merged: {
memoryDiscoveryMaxDirs: 1000,
security: {
folderTrust: {
enabled: 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()}
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { CliSpinner } from './CliSpinner.js';
import { debugState } from '../debug.js';
import { describe, it, expect, beforeEach } from 'vitest';
@@ -15,6 +15,7 @@ import {
} from '../contexts/UIActionsContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
@@ -24,7 +25,6 @@ vi.mock('../contexts/VimModeContext.js', () => ({
}));
import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { mergeSettings } from '../../config/settings.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
@@ -168,21 +168,6 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
const createMockSettings = (merged = {}) => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
return {
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
hideFooter: false,
showMemoryUsage: false,
...merged,
},
},
};
};
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderComposer = (
uiState: UIState,
@@ -207,7 +192,7 @@ describe('Composer', () => {
describe('Footer Display Settings', () => {
it('renders Footer by default when hideFooter is false', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ hideFooter: false });
const settings = createMockSettings({ ui: { hideFooter: false } });
const { lastFrame } = renderComposer(uiState, settings);
@@ -216,7 +201,7 @@ describe('Composer', () => {
it('does NOT render Footer when hideFooter is true', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ hideFooter: true });
const settings = createMockSettings({ ui: { hideFooter: true } });
const { lastFrame } = renderComposer(uiState, settings);
@@ -245,8 +230,10 @@ describe('Composer', () => {
getDebugMode: vi.fn(() => true),
});
const settings = createMockSettings({
hideFooter: false,
showMemoryUsage: true,
ui: {
hideFooter: false,
showMemoryUsage: true,
},
});
// Mock vim mode for this test
const { useVimMode } = await import('../contexts/VimModeContext.js');
@@ -101,9 +101,7 @@ describe('FolderTrustDialog', () => {
);
// Unmount immediately (before 250ms)
act(() => {
unmount();
});
unmount();
await vi.advanceTimersByTimeAsync(250);
expect(relaunchApp).not.toHaveBeenCalled();
@@ -5,10 +5,8 @@
*/
import { describe, it, expect, vi } from 'vitest';
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { Footer } from './Footer.js';
import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { act, useState } from 'react';
import type { InputPromptProps } from './InputPrompt.js';
@@ -924,15 +924,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return false;
}
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
// If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
if (
activePtyId ||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
) {
setEmbeddedShellFocused(true);
return true;
}
return true;
return false;
}
// Fall back to the text buffer's default input handling for all other keys
@@ -26,6 +26,7 @@ import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
@@ -58,56 +59,6 @@ enum TerminalKeys {
BACKSPACE = '\u0008',
}
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {}, ...systemSettings },
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...systemSettings,
},
path: '/system/settings.json',
},
{
settings: {},
originalSettings: {},
path: '/system/system-defaults.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
path: '/workspace/settings.json',
},
true,
[],
);
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('../../config/settingsSchema.js')>();
@@ -639,11 +590,23 @@ describe('SettingsDialog', () => {
});
it('should show different values for different scopes', () => {
const settings = createMockSettings(
{ vimMode: true }, // User settings
{ vimMode: false }, // System settings
{ autoUpdate: false }, // Workspace settings
);
const settings = createMockSettings({
user: {
settings: { vimMode: true },
originalSettings: { vimMode: true },
path: '',
},
system: {
settings: { vimMode: false },
originalSettings: { vimMode: false },
path: '',
},
workspace: {
settings: { autoUpdate: false },
originalSettings: { autoUpdate: false },
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -733,11 +696,23 @@ describe('SettingsDialog', () => {
describe('Specific Settings Behavior', () => {
it('should show correct display values for settings with different states', () => {
const settings = createMockSettings(
{ vimMode: true, hideTips: false }, // User settings
{ hideWindowTitle: true }, // System settings
{ ideMode: false }, // Workspace settings
);
const settings = createMockSettings({
user: {
settings: { vimMode: true, hideTips: false },
originalSettings: { vimMode: true, hideTips: false },
path: '',
},
system: {
settings: { hideWindowTitle: true },
originalSettings: { hideWindowTitle: true },
path: '',
},
workspace: {
settings: { ideMode: false },
originalSettings: { ideMode: false },
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -794,11 +769,13 @@ describe('SettingsDialog', () => {
describe('Settings Display Values', () => {
it('should show correct values for inherited settings', () => {
const settings = createMockSettings(
{},
{ vimMode: true, hideWindowTitle: false }, // System settings
{},
);
const settings = createMockSettings({
system: {
settings: { vimMode: true, hideWindowTitle: false },
originalSettings: { vimMode: true, hideWindowTitle: false },
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -809,11 +786,18 @@ describe('SettingsDialog', () => {
});
it('should show override indicator for overridden settings', () => {
const settings = createMockSettings(
{ vimMode: false }, // User overrides
{ vimMode: true }, // System default
{},
);
const settings = createMockSettings({
user: {
settings: { vimMode: false },
originalSettings: { vimMode: false },
path: '',
},
system: {
settings: { vimMode: true },
originalSettings: { vimMode: true },
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -983,11 +967,13 @@ describe('SettingsDialog', () => {
describe('Error Recovery', () => {
it('should handle malformed settings gracefully', () => {
// Create settings with potentially problematic values
const settings = createMockSettings(
{ vimMode: null as unknown as boolean }, // Invalid value
{},
{},
);
const settings = createMockSettings({
user: {
settings: { vimMode: null as unknown as boolean },
originalSettings: { vimMode: null as unknown as boolean },
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -1198,11 +1184,13 @@ describe('SettingsDialog', () => {
stdin.write('\r'); // Commit
});
settings = createMockSettings(
{ 'a.string.setting': 'new value' },
{},
{},
);
settings = createMockSettings({
user: {
settings: { 'a.string.setting': 'new value' },
originalSettings: { 'a.string.setting': 'new value' },
path: '',
},
});
rerender(
<KeypressProvider>
<SettingsDialog settings={settings} onSelect={onSelect} />
@@ -1550,11 +1538,23 @@ describe('SettingsDialog', () => {
])(
'should render $name correctly',
({ userSettings, systemSettings, workspaceSettings, stdinActions }) => {
const settings = createMockSettings(
userSettings,
systemSettings,
workspaceSettings,
);
const settings = createMockSettings({
user: {
settings: userSettings,
originalSettings: userSettings,
path: '',
},
system: {
settings: systemSettings,
originalSettings: systemSettings,
path: '',
},
workspace: {
settings: workspaceSettings,
originalSettings: workspaceSettings,
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame, stdin } = renderDialog(settings, onSelect);
@@ -8,6 +8,12 @@ import { render } from '../../test-utils/render.js';
import { ShellInputPrompt } from './ShellInputPrompt.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { useUIActions, type UIActions } from '../contexts/UIActionsContext.js';
// Mock useUIActions
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(),
}));
// Mock useKeypress
const mockUseKeypress = vi.fn();
@@ -31,9 +37,13 @@ vi.mock('@google/gemini-cli-core', async () => {
describe('ShellInputPrompt', () => {
const mockWriteToPty = vi.mocked(ShellExecutionService.writeToPty);
const mockScrollPty = vi.mocked(ShellExecutionService.scrollPty);
const mockHandleWarning = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useUIActions).mockReturnValue({
handleWarning: mockHandleWarning,
} as Partial<UIActions> as UIActions);
});
it('renders nothing', () => {
@@ -43,6 +53,23 @@ describe('ShellInputPrompt', () => {
expect(lastFrame()).toBe('');
});
it('sends tab to pty', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
const handler = mockUseKeypress.mock.calls[0][0];
handler({
name: 'tab',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '\t',
});
expect(mockWriteToPty).toHaveBeenCalledWith(1, '\t');
});
it.each([
['a', 'a'],
['b', 'b'],
@@ -40,6 +40,11 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
return false;
}
// Allow unfocus to bubble up
if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) {
return false;
}
if (key.ctrl && key.shift && key.name === 'up') {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return true;
@@ -11,6 +11,7 @@ import { StatusDisplay } from './StatusDisplay.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
import type { TextBuffer } from './shared/text-buffer.js';
// Mock child components to simplify testing
@@ -65,14 +66,6 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
const createMockSettings = (merged = {}) => ({
merged: {
hooksConfig: { notifications: true },
ui: { hideContextSummary: false },
...merged,
},
});
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderStatusDisplay = (
props: { hideContextSummary: boolean } = { hideContextSummary: false },
@@ -8,52 +8,10 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ThemeDialog } from './ThemeDialog.js';
import { LoadedSettings } from '../../config/settings.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js';
import { act } from 'react';
const createMockSettings = (
userSettings = {},
workspaceSettings = {},
systemSettings = {},
): LoadedSettings =>
new LoadedSettings(
{
settings: { ui: { customThemes: {} }, ...systemSettings },
originalSettings: { ui: { customThemes: {} }, ...systemSettings },
path: '/system/settings.json',
},
{
settings: {},
originalSettings: {},
path: '/system/system-defaults.json',
},
{
settings: {
ui: { customThemes: {} },
...userSettings,
},
originalSettings: {
ui: { customThemes: {} },
...userSettings,
},
path: '/user/settings.json',
},
{
settings: {
ui: { customThemes: {} },
...workspaceSettings,
},
originalSettings: {
ui: { customThemes: {} },
...workspaceSettings,
},
path: '/workspace/settings.json',
},
true,
[],
);
describe('ThemeDialog Snapshots', () => {
const baseProps = {
onSelect: vi.fn(),
@@ -2,16 +2,16 @@
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1003) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1003) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ │
│ Select Process (Enter to select, Esc to cancel):
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
│ │
│ 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
@@ -21,23 +21,23 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f log.txt (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm ... 2: tail... (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ │
│ Select Process (Enter to select, Esc to cancel):
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
│ │
│ ● 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
@@ -46,9 +46,9 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1002) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1002) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ │
│ Select Process (Enter to select, Esc to cancel):
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
│ │
│ 1. npm start (PID: 1001) │
│ ● 2. tail -f log.txt (PID: 1002) │
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { act } from 'react';
import React from 'react';
import {
ShellToolMessage,
type ShellToolMessageProps,
@@ -77,16 +77,6 @@ describe('<ShellToolMessage />', () => {
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
};
// Helper to render with context
const renderWithContext = (
ui: React.ReactElement,
streamingState: StreamingState,
) =>
renderWithProviders(ui, {
uiActions,
uiState: { streamingState },
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -140,40 +130,5 @@ describe('<ShellToolMessage />', () => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
});
});
it('resets focus when shell finishes', async () => {
let updateStatus: (s: ToolCallStatus) => void = () => {};
const Wrapper = () => {
const [status, setStatus] = React.useState(ToolCallStatus.Executing);
updateStatus = setStatus;
return (
<ShellToolMessage
{...shellProps}
status={status}
embeddedShellFocused={true}
activeShellPtyId={1}
ptyId={1}
/>
);
};
const { lastFrame } = renderWithContext(<Wrapper />, StreamingState.Idle);
// Verify it is initially focused
await waitFor(() => {
expect(lastFrame()).toContain('(Focused)');
});
// Now update status to Success
await act(async () => {
updateStatus(ToolCallStatus.Success);
});
// Should call setEmbeddedShellFocused(false) because isThisShellFocused became false
await waitFor(() => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
});
});
});
@@ -89,20 +89,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
const wasFocusedRef = React.useRef(false);
React.useEffect(() => {
if (isThisShellFocused) {
wasFocusedRef.current = true;
} else if (wasFocusedRef.current) {
if (embeddedShellFocused) {
setEmbeddedShellFocused(false);
}
wasFocusedRef.current = false;
}
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
const { shouldShowFocusHint } = useFocusHint(
isThisShellFocusable,
isThisShellFocused,
@@ -10,10 +10,8 @@ import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import {
renderWithProviders,
createMockSettings,
} from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
renderWithProviders,
createMockSettings,
} from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
@@ -77,7 +77,7 @@ describe('Focus Hint', () => {
// Now it SHOULD contain the focus hint
expect(lastFrame()).toMatchSnapshot('after-delay-no-output');
expect(lastFrame()).toContain('(tab to focus)');
expect(lastFrame()).toContain('(Tab to focus)');
});
it('shows focus hint after delay with output', async () => {
@@ -95,7 +95,7 @@ describe('Focus Hint', () => {
});
expect(lastFrame()).toMatchSnapshot('after-delay-with-output');
expect(lastFrame()).toContain('(tab to focus)');
expect(lastFrame()).toContain('(Tab to focus)');
});
});
@@ -116,7 +116,7 @@ describe('Focus Hint', () => {
// The focus hint should be visible
expect(lastFrame()).toMatchSnapshot('long-description');
expect(lastFrame()).toContain('(tab to focus)');
expect(lastFrame()).toContain('(Tab to focus)');
// The name should still be visible
expect(lastFrame()).toContain(SHELL_COMMAND_NAME);
});
@@ -22,6 +22,8 @@ import {
type ToolResultDisplay,
} from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import { Command } from '../../../config/keyBindings.js';
export const STATUS_INDICATOR_WIDTH = 3;
@@ -117,7 +119,9 @@ export const FocusHint: React.FC<{
return (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
{isThisShellFocused
? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
: `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
</Text>
</Box>
);
@@ -2,7 +2,7 @@
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -14,7 +14,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even wit
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -26,7 +26,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with out
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -38,7 +38,7 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -50,6 +50,6 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output >
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (tab to focus) │
│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
│ │"
`;
+7 -2
View File
@@ -104,10 +104,15 @@ export function shellReducer(
}
shell.output = newOutput;
const nextState = { ...state, lastShellOutputTime: Date.now() };
if (state.isBackgroundShellVisible) {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
return {
...nextState,
backgroundShells: new Map(state.backgroundShells),
};
}
return state;
return nextState;
}
case 'SYNC_BACKGROUND_SHELLS': {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
+1 -1
View File
@@ -27,7 +27,7 @@ export const useFolderTrust = (
const [isRestarting, setIsRestarting] = useState(false);
const startupMessageSent = useRef(false);
const folderTrust = settings.merged.security.folderTrust.enabled;
const folderTrust = settings.merged.security.folderTrust.enabled ?? true;
useEffect(() => {
const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged);
@@ -474,12 +474,6 @@ export const useGeminiStream = (
const activePtyId = activeShellPtyId || activeToolPtyId;
useEffect(() => {
if (!activePtyId) {
setShellInputFocused(false);
}
}, [activePtyId, setShellInputFocused]);
const prevActiveShellPtyIdRef = useRef<number | null>(null);
useEffect(() => {
if (
@@ -88,7 +88,8 @@ export const usePermissionsModifyTrust = (
);
const [needsRestart, setNeedsRestart] = useState(false);
const isFolderTrustEnabled = !!settings.merged.security.folderTrust.enabled;
const isFolderTrustEnabled =
settings.merged.security.folderTrust.enabled ?? true;
const updateTrustLevel = useCallback(
(trustLevel: TrustLevel) => {
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { formatKeyBinding, formatCommand } from './keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
describe('keybindingUtils', () => {
describe('formatKeyBinding', () => {
it('formats simple keys', () => {
expect(formatKeyBinding({ key: 'a' })).toBe('A');
expect(formatKeyBinding({ key: 'return' })).toBe('Enter');
expect(formatKeyBinding({ key: 'escape' })).toBe('Esc');
});
it('formats modifiers', () => {
expect(formatKeyBinding({ key: 'c', ctrl: true })).toBe('Ctrl+C');
expect(formatKeyBinding({ key: 'z', cmd: true })).toBe('Cmd+Z');
expect(formatKeyBinding({ key: 'up', shift: true })).toBe('Shift+Up');
expect(formatKeyBinding({ key: 'left', alt: true })).toBe('Alt+Left');
});
it('formats multiple modifiers in order', () => {
expect(formatKeyBinding({ key: 'z', ctrl: true, shift: true })).toBe(
'Ctrl+Shift+Z',
);
expect(
formatKeyBinding({
key: 'a',
ctrl: true,
alt: true,
shift: true,
cmd: true,
}),
).toBe('Ctrl+Alt+Shift+Cmd+A');
});
});
describe('formatCommand', () => {
it('formats default commands', () => {
expect(formatCommand(Command.QUIT)).toBe('Ctrl+C');
expect(formatCommand(Command.SUBMIT)).toBe('Enter');
expect(formatCommand(Command.TOGGLE_BACKGROUND_SHELL)).toBe('Ctrl+B');
});
it('returns empty string for unknown commands', () => {
expect(formatCommand('unknown.command' as unknown as Command)).toBe('');
});
});
});
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Command,
type KeyBinding,
type KeyBindingConfig,
defaultKeyBindings,
} from '../../config/keyBindings.js';
/**
* Maps internal key names to user-friendly display names.
*/
const KEY_NAME_MAP: Record<string, string> = {
return: 'Enter',
escape: 'Esc',
backspace: 'Backspace',
delete: 'Delete',
up: 'Up',
down: 'Down',
left: 'Left',
right: 'Right',
pageup: 'Page Up',
pagedown: 'Page Down',
home: 'Home',
end: 'End',
tab: 'Tab',
space: 'Space',
};
/**
* Formats a single KeyBinding into a human-readable string (e.g., "Ctrl+C").
*/
export function formatKeyBinding(binding: KeyBinding): string {
const parts: string[] = [];
if (binding.ctrl) parts.push('Ctrl');
if (binding.alt) parts.push('Alt');
if (binding.shift) parts.push('Shift');
if (binding.cmd) parts.push('Cmd');
const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
parts.push(keyName);
return parts.join('+');
}
/**
* Formats the primary keybinding for a command.
*/
export function formatCommand(
command: Command,
config: KeyBindingConfig = defaultKeyBindings,
): string {
const bindings = config[command];
if (!bindings || bindings.length === 0) {
return '';
}
// Use the first binding as the primary one for display
return formatKeyBinding(bindings[0]);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
-4
View File
@@ -1777,10 +1777,6 @@ export class Config {
* @returns true if the path is allowed, false otherwise.
*/
isPathAllowed(absolutePath: string): boolean {
if (this.interactive && path.isAbsolute(absolutePath)) {
return true;
}
const realpath = (p: string) => {
let resolved: string;
try {
+33 -8
View File
@@ -42,6 +42,7 @@ import { AuthProviderType } from '../config/config.js';
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { XcodeMcpBridgeFixTransport } from './xcode-mcp-fix-transport.js';
import type { CallableTool, FunctionCall, Part, Tool } from '@google/genai';
import { basename } from 'node:path';
@@ -1871,7 +1872,7 @@ export async function createTransport(
}
if (mcpServerConfig.command) {
const transport = new StdioClientTransport({
let transport: Transport = new StdioClientTransport({
command: mcpServerConfig.command,
args: mcpServerConfig.args || [],
env: sanitizeEnvironment(
@@ -1894,14 +1895,38 @@ export async function createTransport(
cwd: mcpServerConfig.cwd,
stderr: 'pipe',
});
// Fix for Xcode 26.3 mcpbridge non-compliant responses
// It returns JSON in `content` instead of `structuredContent`
if (
mcpServerConfig.command === 'xcrun' &&
mcpServerConfig.args?.includes('mcpbridge')
) {
transport = new XcodeMcpBridgeFixTransport(transport);
}
if (debugMode) {
transport.stderr!.on('data', (data) => {
const stderrStr = data.toString().trim();
debugLogger.debug(
`[DEBUG] [MCP STDERR (${mcpServerName})]: `,
stderrStr,
);
});
// The `XcodeMcpBridgeFixTransport` wrapper hides the underlying `StdioClientTransport`,
// which exposes `stderr` for debug logging. We need to unwrap it to attach the listener.
const underlyingTransport =
transport instanceof XcodeMcpBridgeFixTransport
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(transport as any).transport
: transport;
if (
underlyingTransport instanceof StdioClientTransport &&
underlyingTransport.stderr
) {
underlyingTransport.stderr.on('data', (data) => {
const stderrStr = data.toString().trim();
debugLogger.debug(
`[DEBUG] [MCP STDERR (${mcpServerName})]: `,
stderrStr,
);
});
}
}
return transport;
}
@@ -0,0 +1,120 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { EventEmitter } from 'node:events';
import { XcodeMcpBridgeFixTransport } from './xcode-mcp-fix-transport.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
// Mock Transport that simulates the mcpbridge behavior
class MockBadMcpBridgeTransport extends EventEmitter implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
async start() {}
async close() {}
async send(_message: JSONRPCMessage) {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emitMessage(msg: any) {
this.onmessage?.(msg);
}
}
describe('Xcode MCP Bridge Fix', () => {
it('intercepts and fixes the non-compliant mcpbridge response', async () => {
const mockTransport = new MockBadMcpBridgeTransport();
const fixTransport = new XcodeMcpBridgeFixTransport(mockTransport);
// We need to capture what the fixTransport emits to its listeners
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: any[] = [];
fixTransport.onmessage = (msg) => {
messages.push(msg);
};
await fixTransport.start();
// SCENARIO 1: Bad response from Xcode
// It has `content` stringified JSON, but misses `structuredContent`
const badPayload = {
jsonrpc: '2.0',
id: 1,
result: {
content: [
{
type: 'text',
text: JSON.stringify({
windows: [{ title: 'HelloWorld', path: '/path/to/project' }],
}),
},
],
// Missing: structuredContent
},
};
mockTransport.emitMessage(badPayload);
// Verify the message received by the client (listener of fixTransport)
const fixedMsg = messages.find((m) => m.id === 1);
expect(fixedMsg).toBeDefined();
expect(fixedMsg.result.structuredContent).toBeDefined();
expect(fixedMsg.result.structuredContent.windows[0].title).toBe(
'HelloWorld',
);
// SCENARIO 2: Good response (should be untouched)
const goodPayload = {
jsonrpc: '2.0',
id: 2,
result: {
content: [{ type: 'text', text: 'normal text' }],
structuredContent: { some: 'data' },
},
};
mockTransport.emitMessage(goodPayload);
const goodMsg = messages.find((m) => m.id === 2);
expect(goodMsg).toBeDefined();
expect(goodMsg.result.structuredContent).toEqual({ some: 'data' });
});
it('ignores responses that cannot be parsed as JSON', async () => {
const mockTransport = new MockBadMcpBridgeTransport();
const fixTransport = new XcodeMcpBridgeFixTransport(mockTransport);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: any[] = [];
fixTransport.onmessage = (msg) => {
messages.push(msg);
};
await fixTransport.start();
const nonJsonPayload = {
jsonrpc: '2.0',
id: 3,
result: {
content: [
{
type: 'text',
text: "Just some plain text that isn't JSON",
},
],
},
};
mockTransport.emitMessage(nonJsonPayload);
const msg = messages.find((m) => m.id === 3);
expect(msg).toBeDefined();
expect(msg.result.structuredContent).toBeUndefined();
expect(msg.result.content[0].text).toBe(
"Just some plain text that isn't JSON",
);
});
});
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type {
JSONRPCMessage,
JSONRPCResponse,
} from '@modelcontextprotocol/sdk/types.js';
import { EventEmitter } from 'node:events';
/**
* A wrapper transport that intercepts messages from Xcode's mcpbridge and fixes
* non-compliant responses.
*
* Issue: Xcode 26.3's mcpbridge returns tool results in `content` but misses
* `structuredContent` when the tool has an output schema.
*
* Fix: Parse the text content as JSON and populate `structuredContent`.
*/
export class XcodeMcpBridgeFixTransport
extends EventEmitter
implements Transport
{
constructor(private readonly transport: Transport) {
super();
// Forward messages from the underlying transport
this.transport.onmessage = (message) => {
this.handleMessage(message);
};
this.transport.onclose = () => {
this.onclose?.();
};
this.transport.onerror = (error) => {
this.onerror?.(error);
};
}
// Transport interface implementation
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
async start(): Promise<void> {
await this.transport.start();
}
async close(): Promise<void> {
await this.transport.close();
}
async send(message: JSONRPCMessage): Promise<void> {
await this.transport.send(message);
}
private handleMessage(message: JSONRPCMessage) {
if (this.isJsonResponse(message)) {
this.fixStructuredContent(message);
}
this.onmessage?.(message);
}
private isJsonResponse(message: JSONRPCMessage): message is JSONRPCResponse {
return 'result' in message || 'error' in message;
}
private fixStructuredContent(response: JSONRPCResponse) {
if (!('result' in response)) return;
// We can cast because we verified 'result' is in response,
// but TS might still be picky if the type is a strict union.
// Let's treat it safely.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = response.result as any;
// Check if we have content but missing structuredContent
if (
result.content &&
Array.isArray(result.content) &&
result.content.length > 0 &&
!result.structuredContent
) {
const firstItem = result.content[0];
if (firstItem.type === 'text' && typeof firstItem.text === 'string') {
try {
// Attempt to parse the text as JSON
const parsed = JSON.parse(firstItem.text);
// If successful, populate structuredContent
result.structuredContent = parsed;
} catch (_) {
// Ignored: Content is likely plain text, not JSON.
}
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+61 -28
View File
@@ -272,6 +272,27 @@ export class InteractiveRun {
}
}
function isObject(item: any): item is Record<string, any> {
return !!(item && typeof item === 'object' && !Array.isArray(item));
}
function deepMerge(target: any, source: any): any {
if (!isObject(target) || !isObject(source)) {
return source;
}
const output = { ...target };
Object.keys(source).forEach((key) => {
const targetValue = target[key];
const sourceValue = source[key];
if (isObject(targetValue) && isObject(sourceValue)) {
output[key] = deepMerge(targetValue, sourceValue);
} else {
output[key] = sourceValue;
}
});
return output;
}
export class TestRig {
testDir: string | null = null;
homeDir: string | null = null;
@@ -316,44 +337,56 @@ export class TestRig {
const projectGeminiDir = join(this.testDir!, GEMINI_DIR);
mkdirSync(projectGeminiDir, { recursive: true });
const userGeminiDir = join(this.homeDir!, GEMINI_DIR);
mkdirSync(userGeminiDir, { recursive: true });
// In sandbox mode, use an absolute path for telemetry inside the container
// The container mounts the test directory at the same path as the host
const telemetryPath = join(this.homeDir!, 'telemetry.log'); // Always use home directory for telemetry
const settings = {
general: {
// Nightly releases sometimes becomes out of sync with local code and
// triggers auto-update, which causes tests to fail.
disableAutoUpdate: true,
previewFeatures: false,
},
telemetry: {
enabled: true,
target: 'local',
otlpEndpoint: '',
outfile: telemetryPath,
},
security: {
auth: {
selectedType: 'gemini-api-key',
const settings = deepMerge(
{
general: {
// Nightly releases sometimes becomes out of sync with local code and
// triggers auto-update, which causes tests to fail.
disableAutoUpdate: true,
previewFeatures: false,
},
telemetry: {
enabled: true,
target: 'local',
otlpEndpoint: '',
outfile: telemetryPath,
},
security: {
auth: {
selectedType: 'gemini-api-key',
},
folderTrust: {
enabled: false,
},
},
ui: {
useAlternateBuffer: true,
},
model: {
name: DEFAULT_GEMINI_MODEL,
},
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
// Don't show the IDE connection dialog when running from VsCode
ide: { enabled: false, hasSeenNudge: true },
},
ui: {
useAlternateBuffer: true,
},
model: {
name: DEFAULT_GEMINI_MODEL,
},
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
// Don't show the IDE connection dialog when running from VsCode
ide: { enabled: false, hasSeenNudge: true },
...overrideSettings, // Allow tests to override/add settings
};
overrideSettings ?? {},
);
writeFileSync(
join(projectGeminiDir, 'settings.json'),
JSON.stringify(settings, null, 2),
);
writeFileSync(
join(userGeminiDir, 'settings.json'),
JSON.stringify(settings, null, 2),
);
}
createFile(fileName: string, content: string) {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0-preview.5",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+4 -4
View File
@@ -1307,8 +1307,8 @@
"enabled": {
"title": "Folder Trust",
"description": "Setting to track whether Folder trust is enabled.",
"markdownDescription": "Setting to track whether Folder trust is enabled.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"markdownDescription": "Setting to track whether Folder trust is enabled.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
}
},
@@ -1445,8 +1445,8 @@
"extensionConfig": {
"title": "Extension Configuration",
"description": "Enable requesting and fetching of extension settings.",
"markdownDescription": "Enable requesting and fetching of extension settings.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"markdownDescription": "Enable requesting and fetching of extension settings.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"enableEventDrivenScheduler": {