mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-26 01:31:03 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9affc72d3a | |||
| 44c67daa07 | |||
| 37f24a94e8 | |||
| 900e0c9f05 | |||
| 7fb0e1d1c1 | |||
| 64642f2718 | |||
| 171ecdef18 | |||
| 45866d8e0a | |||
| 1795ffaf36 | |||
| 8468a48bff | |||
| 9bd6ec6664 |
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Generated
+7
-7
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } },
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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).',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+5
-5
@@ -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) │
|
||||
│ │"
|
||||
`;
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user