diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7b6b850f71..a0811306be 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -179,12 +179,16 @@ jobs:
- name: 'Smoke test npx installation'
run: |
- # Create a temporary directory to avoid picking up local node_modules
- mkdir -p ../npx-test
- cd ../npx-test
- # Run npx pointing to the checkout directory. This simulates a user
- # installing the package from a git reference.
- npx ${{ github.workspace }} --version
+ # 1. Package the project into a tarball
+ TARBALL=$(npm pack | tail -n 1)
+
+ # 2. Move to a fresh directory for isolation
+ mkdir -p ../smoke-test-dir
+ mv "$TARBALL" ../smoke-test-dir/
+ cd ../smoke-test-dir
+
+ # 3. Run npx from the tarball
+ npx "./$TARBALL" --version
- name: 'Wait for file system sync'
run: 'sleep 2'
@@ -263,12 +267,16 @@ jobs:
- name: 'Smoke test npx installation'
run: |
- # Create a temporary directory to avoid picking up local node_modules
- mkdir -p ../npx-test
- cd ../npx-test
- # Run npx pointing to the checkout directory. This simulates a user
- # installing the package from a git reference.
- npx ${{ github.workspace }} --version
+ # 1. Package the project into a tarball
+ TARBALL=$(npm pack | tail -n 1)
+
+ # 2. Move to a fresh directory for isolation
+ mkdir -p ../smoke-test-dir
+ mv "$TARBALL" ../smoke-test-dir/
+ cd ../smoke-test-dir
+
+ # 3. Run npx from the tarball
+ npx "./$TARBALL" --version
- name: 'Wait for file system sync'
run: 'sleep 2'
@@ -416,12 +424,17 @@ jobs:
- name: 'Smoke test npx installation'
run: |
- # Create a temporary directory to avoid picking up local node_modules
- New-Item -ItemType Directory -Force -Path ../npx-test
- Set-Location ../npx-test
- # Run npx pointing to the checkout directory. This simulates a user
- # installing the package from a git reference.
- npx ${{ github.workspace }} --version
+ # 1. Package the project into a tarball
+ $PACK_OUTPUT = npm pack
+ $TARBALL = $PACK_OUTPUT[-1]
+
+ # 2. Move to a fresh directory for isolation
+ New-Item -ItemType Directory -Force -Path ../smoke-test-dir
+ Move-Item $TARBALL ../smoke-test-dir/
+ Set-Location ../smoke-test-dir
+
+ # 3. Run npx from the tarball
+ npx "./$TARBALL" --version
shell: 'pwsh'
ci:
diff --git a/docs/cli/commands.md b/docs/cli/commands.md
index e79c374e71..fe0198d626 100644
--- a/docs/cli/commands.md
+++ b/docs/cli/commands.md
@@ -264,6 +264,9 @@ Slash commands provide meta-level control over the CLI itself.
modify them as desired. Changes to some settings are applied immediately,
while others require a restart.
+- **`/shells`** (or **`/bashes`**)
+ - **Description:** Toggle the background shells view. This allows you to view
+ and manage long-running processes that you've sent to the background.
- **`/setup-github`**
- **Description:** Set up GitHub Actions to triage issues and review PRs with
Gemini.
diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md
index 5cfa26cf92..a1a28665b9 100644
--- a/docs/cli/keyboard-shortcuts.md
+++ b/docs/cli/keyboard-shortcuts.md
@@ -23,7 +23,7 @@ available combinations.
| Move the cursor to the end of the line. | `Ctrl + E`
`End (no Shift, Ctrl)` |
| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` |
-| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)`
`Ctrl + B` |
+| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`
`Ctrl + F` |
| Move the cursor one word to the left. | `Ctrl + Left Arrow`
`Alt + Left Arrow`
`Alt + B` |
| Move the cursor one word to the right. | `Ctrl + Right Arrow`
`Alt + Right Arrow`
`Alt + F` |
@@ -106,6 +106,14 @@ 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`
`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` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
diff --git a/docs/cli/settings.md b/docs/cli/settings.md
index a581875a35..ab637aed3e 100644
--- a/docs/cli/settings.md
+++ b/docs/cli/settings.md
@@ -107,13 +107,14 @@ they appear in the UI.
### Security
-| UI Label | Setting | Description | Default |
-| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------- | ------- |
-| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
-| 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` |
-| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
-| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
+| UI Label | Setting | Description | Default |
+| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
+| 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` |
+| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
### Experimental
diff --git a/docs/cli/skills.md b/docs/cli/skills.md
index b8a71fde86..297bd80ed4 100644
--- a/docs/cli/skills.md
+++ b/docs/cli/skills.md
@@ -108,5 +108,5 @@ gemini skills disable my-expertise --scope workspace
## Creating your own skills
-To create your own skills, see the
-[Create Agent Skills](./guides/creating-skills.md) guide.
+To create your own skills, see the [Create Agent Skills](./creating-skills.md)
+guide.
diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md
index 75bb9c46c4..5ce8231a51 100644
--- a/docs/get-started/configuration.md
+++ b/docs/get-started/configuration.md
@@ -773,6 +773,13 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
+- **`security.allowedExtensions`** (array):
+ - **Description:** List of Regex patterns for allowed extensions. If nonempty,
+ only extensions that match the patterns in this list are allowed. Overrides
+ the blockGitExtensions setting.
+ - **Default:** `[]`
+ - **Requires restart:** Yes
+
- **`security.folderTrust.enabled`** (boolean):
- **Description:** Setting to track whether Folder trust is enabled.
- **Default:** `false`
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index 54bb3e704a..2ca11be668 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -1255,7 +1255,7 @@ describe('Approval mode tool exclusion logic', () => {
});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
- 'Cannot start in YOLO mode since it is disabled by your admin',
+ 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
@@ -2928,7 +2928,7 @@ describe('loadCliConfig disableYoloMode', () => {
security: { disableYoloMode: true },
});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
- 'Cannot start in YOLO mode since it is disabled by your admin',
+ 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
});
@@ -2960,7 +2960,7 @@ describe('loadCliConfig secureModeEnabled', () => {
});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
- 'Cannot start in YOLO mode since it is disabled by your admin',
+ 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
@@ -2974,7 +2974,7 @@ describe('loadCliConfig secureModeEnabled', () => {
});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
- 'Cannot start in YOLO mode since it is disabled by your admin',
+ 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 6d79b1d4c1..0c5063faee 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -38,6 +38,7 @@ import {
type OutputFormat,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
+ getAdminErrorMessage,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -550,7 +551,7 @@ export async function loadCliConfig(
);
}
throw new FatalConfigError(
- 'Cannot start in YOLO mode since it is disabled by your admin',
+ getAdminErrorMessage('YOLO mode', undefined /* config */),
);
}
} else if (approvalMode === ApprovalMode.YOLO) {
diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts
index 75c4924c32..9e19109eda 100644
--- a/packages/cli/src/config/extension-manager.ts
+++ b/packages/cli/src/config/extension-manager.ts
@@ -144,6 +144,26 @@ export class ExtensionManager extends ExtensionLoader {
previousExtensionConfig?: ExtensionConfig,
): Promise {
if (
+ this.settings.security?.allowedExtensions &&
+ this.settings.security?.allowedExtensions.length > 0
+ ) {
+ const extensionAllowed = this.settings.security?.allowedExtensions.some(
+ (pattern) => {
+ try {
+ return new RegExp(pattern).test(installMetadata.source);
+ } catch (e) {
+ throw new Error(
+ `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`,
+ );
+ }
+ },
+ );
+ if (!extensionAllowed) {
+ throw new Error(
+ `Installing extension from source "${installMetadata.source}" is not allowed by the "allowedExtensions" security setting.`,
+ );
+ }
+ } else if (
(installMetadata.type === 'git' ||
installMetadata.type === 'github-release') &&
this.settings.security.blockGitExtensions
@@ -152,6 +172,7 @@ export class ExtensionManager extends ExtensionLoader {
'Installing extensions from remote sources is disallowed by your current settings.',
);
}
+
const isUpdate = !!previousExtensionConfig;
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
@@ -522,10 +543,39 @@ Would you like to attempt to install via "git clone" instead?`,
const installMetadata = loadInstallMetadata(extensionDir);
let effectiveExtensionPath = extensionDir;
if (
+ this.settings.security?.allowedExtensions &&
+ this.settings.security?.allowedExtensions.length > 0
+ ) {
+ if (!installMetadata?.source) {
+ throw new Error(
+ `Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`,
+ );
+ }
+ const extensionAllowed = this.settings.security?.allowedExtensions.some(
+ (pattern) => {
+ try {
+ return new RegExp(pattern).test(installMetadata?.source);
+ } catch (e) {
+ throw new Error(
+ `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`,
+ );
+ }
+ },
+ );
+ if (!extensionAllowed) {
+ debugLogger.warn(
+ `Failed to load extension ${extensionDir}. This extension is not allowed by the "allowedExtensions" security setting.`,
+ );
+ return null;
+ }
+ } else if (
(installMetadata?.type === 'git' ||
installMetadata?.type === 'github-release') &&
this.settings.security.blockGitExtensions
) {
+ debugLogger.warn(
+ `Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`,
+ );
return null;
}
diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts
index 7acaf2cc67..0148fc7729 100644
--- a/packages/cli/src/config/extension.test.ts
+++ b/packages/cli/src/config/extension.test.ts
@@ -622,6 +622,7 @@ describe('extension tests', () => {
});
it('should not load github extensions if blockGitExtensions is set', async () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
@@ -645,6 +646,73 @@ describe('extension tests', () => {
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension).toBeUndefined();
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Extensions from remote sources is disallowed by your current settings.',
+ ),
+ );
+ consoleSpy.mockRestore();
+ });
+
+ it('should load allowed extensions if the allowlist is set.', async () => {
+ createExtension({
+ extensionsDir: userExtensionsDir,
+ name: 'my-ext',
+ version: '1.0.0',
+ installMetadata: {
+ type: 'git',
+ source: 'http://allowed.com/foo/bar',
+ },
+ });
+ const extensionAllowlistSetting = createTestMergedSettings({
+ security: {
+ allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
+ },
+ });
+ extensionManager = new ExtensionManager({
+ workspaceDir: tempWorkspaceDir,
+ requestConsent: mockRequestConsent,
+ requestSetting: mockPromptForSettings,
+ settings: extensionAllowlistSetting,
+ });
+ const extensions = await extensionManager.loadExtensions();
+
+ expect(extensions).toHaveLength(1);
+ expect(extensions[0].name).toBe('my-ext');
+ });
+
+ it('should not load disallowed extensions if the allowlist is set.', async () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ createExtension({
+ extensionsDir: userExtensionsDir,
+ name: 'my-ext',
+ version: '1.0.0',
+ installMetadata: {
+ type: 'git',
+ source: 'http://notallowed.com/foo/bar',
+ },
+ });
+ const extensionAllowlistSetting = createTestMergedSettings({
+ security: {
+ allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
+ },
+ });
+ extensionManager = new ExtensionManager({
+ workspaceDir: tempWorkspaceDir,
+ requestConsent: mockRequestConsent,
+ requestSetting: mockPromptForSettings,
+ settings: extensionAllowlistSetting,
+ });
+ const extensions = await extensionManager.loadExtensions();
+ const extension = extensions.find((e) => e.name === 'my-ext');
+
+ expect(extension).toBeUndefined();
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'This extension is not allowed by the "allowedExtensions" security setting',
+ ),
+ );
+ consoleSpy.mockRestore();
});
it('should not load any extensions if admin.extensions.enabled is false', async () => {
@@ -1116,6 +1184,30 @@ describe('extension tests', () => {
);
});
+ it('should not install a disallowed extension if the allowlist is set', async () => {
+ const gitUrl = 'https://somehost.com/somerepo.git';
+ const allowedExtensionsSetting = createTestMergedSettings({
+ security: {
+ allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
+ },
+ });
+ extensionManager = new ExtensionManager({
+ workspaceDir: tempWorkspaceDir,
+ requestConsent: mockRequestConsent,
+ requestSetting: mockPromptForSettings,
+ settings: allowedExtensionsSetting,
+ });
+ await extensionManager.loadExtensions();
+ await expect(
+ extensionManager.installOrUpdateExtension({
+ source: gitUrl,
+ type: 'git',
+ }),
+ ).rejects.toThrow(
+ `Installing extension from source "${gitUrl}" is not allowed by the "allowedExtensions" security setting.`,
+ );
+ });
+
it('should prompt for trust if workspace is not trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index 0d32ae2922..9b6a903a4b 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/config/keyBindings.ts
@@ -72,6 +72,15 @@ export enum Command {
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
+ BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape',
+ BACKGROUND_SHELL_SELECT = 'backgroundShellSelect',
+ TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell',
+ TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList',
+ KILL_BACKGROUND_SHELL = 'backgroundShell.kill',
+ UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
+ UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
+ SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
+
// App Controls
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
SHOW_FULL_TODOS = 'app.showFullTodos',
@@ -139,7 +148,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
],
[Command.MOVE_LEFT]: [
{ key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
- { key: 'b', ctrl: true },
],
[Command.MOVE_RIGHT]: [
{ key: 'right', shift: false, alt: false, ctrl: false, cmd: false },
@@ -265,6 +273,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
+ [Command.TOGGLE_BACKGROUND_SHELL]: [{ key: 'b', ctrl: true }],
+ [Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }],
+ [Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }],
+ [Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }],
+ [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab', shift: false }],
+ [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
+ { key: 'tab', shift: false },
+ ],
+ [Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
+ [Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
[Command.SHOW_MORE_LINES]: [
{ key: 'o', ctrl: true },
{ key: 's', ctrl: true },
@@ -379,6 +397,14 @@ export const commandCategories: readonly CommandCategory[] = [
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
+ Command.TOGGLE_BACKGROUND_SHELL,
+ Command.TOGGLE_BACKGROUND_SHELL_LIST,
+ Command.KILL_BACKGROUND_SHELL,
+ Command.BACKGROUND_SHELL_SELECT,
+ Command.BACKGROUND_SHELL_ESCAPE,
+ Command.UNFOCUS_BACKGROUND_SHELL,
+ Command.UNFOCUS_BACKGROUND_SHELL_LIST,
+ Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.FOCUS_SHELL_INPUT,
Command.UNFOCUS_SHELL_INPUT,
Command.CLEAR_SCREEN,
@@ -470,6 +496,14 @@ export const commandDescriptions: Readonly> = {
'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.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 65e42332d6..a34163ccb3 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -1268,6 +1268,17 @@ const SETTINGS_SCHEMA = {
description: 'Blocks installing and loading extensions from Git.',
showInDialog: true,
},
+ allowedExtensions: {
+ type: 'array',
+ label: 'Extension Source Regex Allowlist',
+ category: 'Security',
+ requiresRestart: true,
+ default: [] as string[],
+ description:
+ 'List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.',
+ showInDialog: true,
+ items: { type: 'string' },
+ },
folderTrust: {
type: 'object',
label: 'Folder Trust',
diff --git a/packages/cli/src/deferred.test.ts b/packages/cli/src/deferred.test.ts
index 4ea5eb791d..8b9fb87f7a 100644
--- a/packages/cli/src/deferred.test.ts
+++ b/packages/cli/src/deferred.test.ts
@@ -16,11 +16,10 @@ import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import type { MergedSettings } from './config/settings.js';
import type { MockInstance } from 'vitest';
-const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({
+const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
mockRunExitCleanup: vi.fn(),
- mockDebugLogger: {
- log: vi.fn(),
- error: vi.fn(),
+ mockCoreEvents: {
+ emitFeedback: vi.fn(),
},
}));
@@ -28,7 +27,7 @@ vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
...actual,
- debugLogger: mockDebugLogger,
+ coreEvents: mockCoreEvents,
};
});
@@ -55,8 +54,7 @@ describe('deferred', () => {
describe('runDeferredCommand', () => {
it('should do nothing if no deferred command is set', async () => {
await runDeferredCommand(createMockSettings());
- expect(mockDebugLogger.log).not.toHaveBeenCalled();
- expect(mockDebugLogger.error).not.toHaveBeenCalled();
+ expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
@@ -85,8 +83,9 @@ describe('deferred', () => {
const settings = createMockSettings({ mcp: { enabled: false } });
await runDeferredCommand(settings);
- expect(mockDebugLogger.error).toHaveBeenCalledWith(
- 'Error: MCP is disabled by your admin.',
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
@@ -102,8 +101,9 @@ describe('deferred', () => {
const settings = createMockSettings({ extensions: { enabled: false } });
await runDeferredCommand(settings);
- expect(mockDebugLogger.error).toHaveBeenCalledWith(
- 'Error: Extensions are disabled by your admin.',
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ 'Extensions is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
@@ -119,8 +119,9 @@ describe('deferred', () => {
const settings = createMockSettings({ skills: { enabled: false } });
await runDeferredCommand(settings);
- expect(mockDebugLogger.error).toHaveBeenCalledWith(
- 'Error: Agent skills are disabled by your admin.',
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
@@ -183,8 +184,9 @@ describe('deferred', () => {
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
await runDeferredCommand(mcpSettings);
- expect(mockDebugLogger.error).toHaveBeenCalledWith(
- 'Error: MCP is disabled by your admin.',
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
diff --git a/packages/cli/src/deferred.ts b/packages/cli/src/deferred.ts
index 73fac6d1ce..309233ba45 100644
--- a/packages/cli/src/deferred.ts
+++ b/packages/cli/src/deferred.ts
@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
-import { debugLogger, ExitCodes } from '@google/gemini-cli-core';
+import {
+ coreEvents,
+ ExitCodes,
+ getAdminErrorMessage,
+} from '@google/gemini-cli-core';
import { runExitCleanup } from './utils/cleanup.js';
import type { MergedSettings } from './config/settings.js';
import process from 'node:process';
@@ -30,7 +34,10 @@ export async function runDeferredCommand(settings: MergedSettings) {
const commandName = deferredCommand.commandName;
if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
- debugLogger.error('Error: MCP is disabled by your admin.');
+ coreEvents.emitFeedback(
+ 'error',
+ getAdminErrorMessage('MCP', undefined /* config */),
+ );
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
@@ -39,13 +46,19 @@ export async function runDeferredCommand(settings: MergedSettings) {
commandName === 'extensions' &&
adminSettings?.extensions?.enabled === false
) {
- debugLogger.error('Error: Extensions are disabled by your admin.');
+ coreEvents.emitFeedback(
+ 'error',
+ getAdminErrorMessage('Extensions', undefined /* config */),
+ );
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
- debugLogger.error('Error: Agent skills are disabled by your admin.');
+ coreEvents.emitFeedback(
+ 'error',
+ getAdminErrorMessage('Agent skills', undefined /* config */),
+ );
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts
index 3217645211..d0e21b6b6d 100644
--- a/packages/cli/src/nonInteractiveCli.test.ts
+++ b/packages/cli/src/nonInteractiveCli.test.ts
@@ -258,6 +258,9 @@ describe('runNonInteractive', () => {
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
+ undefined,
+ false,
+ 'Test input',
);
expect(getWrittenOutput()).toBe('Hello World\n');
// Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
@@ -374,6 +377,9 @@ describe('runNonInteractive', () => {
[{ text: 'Tool response' }],
expect.any(AbortSignal),
'prompt-id-2',
+ undefined,
+ false,
+ undefined,
);
expect(getWrittenOutput()).toBe('Final answer\n');
});
@@ -531,6 +537,9 @@ describe('runNonInteractive', () => {
],
expect.any(AbortSignal),
'prompt-id-3',
+ undefined,
+ false,
+ undefined,
);
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
});
@@ -670,6 +679,9 @@ describe('runNonInteractive', () => {
processedParts,
expect.any(AbortSignal),
'prompt-id-7',
+ undefined,
+ false,
+ rawInput,
);
// 6. Assert the final output is correct
@@ -703,6 +715,9 @@ describe('runNonInteractive', () => {
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
+ undefined,
+ false,
+ 'Test input',
);
expect(processStdoutSpy).toHaveBeenCalledWith(
JSON.stringify(
@@ -833,6 +848,9 @@ describe('runNonInteractive', () => {
[{ text: 'Empty response test' }],
expect.any(AbortSignal),
'prompt-id-empty',
+ undefined,
+ false,
+ 'Empty response test',
);
// This should output JSON with empty response but include stats
@@ -967,6 +985,9 @@ describe('runNonInteractive', () => {
[{ text: 'Prompt from command' }],
expect.any(AbortSignal),
'prompt-id-slash',
+ undefined,
+ false,
+ '/testcommand',
);
expect(getWrittenOutput()).toBe('Response from command\n');
@@ -1010,6 +1031,9 @@ describe('runNonInteractive', () => {
[{ text: 'Slash command output' }],
expect.any(AbortSignal),
'prompt-id-slash',
+ undefined,
+ false,
+ '/help',
);
expect(getWrittenOutput()).toBe('Response to slash command\n');
handleSlashCommandSpy.mockRestore();
@@ -1184,6 +1208,9 @@ describe('runNonInteractive', () => {
[{ text: '/unknowncommand' }],
expect.any(AbortSignal),
'prompt-id-unknown',
+ undefined,
+ false,
+ '/unknowncommand',
);
expect(getWrittenOutput()).toBe('Response to unknown\n');
diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts
index 9535fbded2..a2ca92a4e8 100644
--- a/packages/cli/src/nonInteractiveCli.ts
+++ b/packages/cli/src/nonInteractiveCli.ts
@@ -301,6 +301,9 @@ export async function runNonInteractive({
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
+ undefined,
+ false,
+ turnCount === 1 ? input : undefined,
);
let responseText = '';
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index 3c32549ef2..75cbe74cc2 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -12,7 +12,11 @@ import {
type CommandContext,
} from '../ui/commands/types.js';
import type { MessageActionReturn, Config } from '@google/gemini-cli-core';
-import { isNightly, startupProfiler } from '@google/gemini-cli-core';
+import {
+ isNightly,
+ startupProfiler,
+ getAdminErrorMessage,
+} from '@google/gemini-cli-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
@@ -47,6 +51,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
+import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
@@ -101,7 +106,10 @@ export class BuiltinCommandLoader implements ICommandLoader {
): Promise => ({
type: 'message',
messageType: 'error',
- content: 'Extensions are disabled by your admin.',
+ content: getAdminErrorMessage(
+ 'Extensions',
+ this.config ?? undefined,
+ ),
}),
},
]
@@ -126,7 +134,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
): Promise => ({
type: 'message',
messageType: 'error',
- content: 'MCP is disabled by your admin.',
+ content: getAdminErrorMessage('MCP', this.config ?? undefined),
}),
},
]
@@ -157,13 +165,17 @@ export class BuiltinCommandLoader implements ICommandLoader {
): Promise => ({
type: 'message',
messageType: 'error',
- content: 'Agent skills are disabled by your admin.',
+ content: getAdminErrorMessage(
+ 'Agent skills',
+ this.config ?? undefined,
+ ),
}),
},
]
: [skillsCommand]
: []),
settingsCommand,
+ shellsCommand,
vimCommand,
setupGithubCommand,
terminalSetupCommand,
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index 18744ee2b4..a9e997a859 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -157,6 +157,9 @@ const baseMockUiState = {
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: undefined,
+ activePtyId: undefined,
+ backgroundShells: new Map(),
+ backgroundShellHeight: 0,
};
export const mockAppState: AppState = {
@@ -201,7 +204,11 @@ const mockUIActions: UIActions = {
handleApiKeyCancel: vi.fn(),
setBannerVisible: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
+ dismissBackgroundShell: vi.fn(),
+ setActiveBackgroundShellPid: vi.fn(),
+ setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
+ handleWarning: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index 81df8b9574..bd663ba195 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -88,6 +88,7 @@ describe('App', () => {
defaultText: 'Mock Banner Text',
warningText: '',
},
+ backgroundShells: new Map(),
};
it('should render main content and composer when not quitting', () => {
@@ -234,7 +235,6 @@ describe('App', () => {
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
- expect(lastFrame()).toContain('1 of 1');
expect(lastFrame()).toContain('Composer');
expect(lastFrame()).toMatchSnapshot();
});
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 5170b60f62..ef0a24cd92 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -32,13 +32,7 @@ import {
UserAccountManager,
type ContentGeneratorConfig,
type AgentDefinition,
- MessageBusType,
- QuestionType,
} from '@google/gemini-cli-core';
-import {
- AskUserActionsContext,
- type AskUserState,
-} from './contexts/AskUserActionsContext.js';
// Mock coreEvents
const mockCoreEvents = vi.hoisted(() => ({
@@ -124,11 +118,9 @@ vi.mock('ink', async (importOriginal) => {
// so we can assert against them in our tests.
let capturedUIState: UIState;
let capturedUIActions: UIActions;
-let capturedAskUserRequest: AskUserState | null;
function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!;
capturedUIActions = useContext(UIActionsContext)!;
- capturedAskUserRequest = useContext(AskUserActionsContext)?.request ?? null;
return null;
}
@@ -269,6 +261,25 @@ describe('AppContainer State Management', () => {
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
+ const DEFAULT_GEMINI_STREAM_MOCK = {
+ streamingState: 'idle',
+ submitQuery: vi.fn(),
+ initError: null,
+ pendingHistoryItems: [],
+ thought: null,
+ cancelOngoingRequest: vi.fn(),
+ handleApprovalModeChange: vi.fn(),
+ activePtyId: null,
+ loopDetectionConfirmationRequest: null,
+ backgroundShellCount: 0,
+ isBackgroundShellVisible: false,
+ toggleBackgroundShell: vi.fn(),
+ backgroundCurrentShell: vi.fn(),
+ backgroundShells: new Map(),
+ registerBackgroundShell: vi.fn(),
+ dismissBackgroundShell: vi.fn(),
+ };
+
beforeEach(() => {
vi.clearAllMocks();
@@ -279,7 +290,6 @@ describe('AppContainer State Management', () => {
mocks.mockStdout.write.mockClear();
capturedUIState = null!;
- capturedAskUserRequest = null;
// **Provide a default return value for EVERY mocked hook.**
mockedUseQuotaAndFallback.mockReturnValue({
@@ -334,14 +344,7 @@ describe('AppContainer State Management', () => {
handleNewMessage: vi.fn(),
clearConsoleMessages: vi.fn(),
});
- mockedUseGeminiStream.mockReturnValue({
- streamingState: 'idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- cancelOngoingRequest: vi.fn(),
- });
+ mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
mockedUseFolderTrust.mockReturnValue({
isFolderTrustDialogOpen: false,
@@ -1193,12 +1196,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state as Active
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Some thought' },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1234,12 +1234,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Some thought' },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1306,12 +1303,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const thoughtSubject = 'Processing request';
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: thoughtSubject },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1347,14 +1341,7 @@ describe('AppContainer State Management', () => {
} as unknown as LoadedSettings;
// Mock the streaming state as Idle with no thought
- mockedUseGeminiStream.mockReturnValue({
- streamingState: 'idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- cancelOngoingRequest: vi.fn(),
- });
+ mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
// Act: Render the container
const { unmount } = renderAppContainer({
@@ -1391,12 +1378,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const thoughtSubject = 'Confirm tool execution';
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'waiting_for_confirmation',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: thoughtSubject },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1448,16 +1432,11 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty but not focused
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
- cancelOngoingRequest: vi.fn(),
pendingToolCalls: [],
- handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
- loopDetectionConfirmationRequest: null,
lastOutputTime: startTime + 100, // Trigger aggressive delay
retryStatus: null,
});
@@ -1512,12 +1491,9 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty with redirection active
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
- cancelOngoingRequest: vi.fn(),
pendingToolCalls: [
{
request: {
@@ -1527,9 +1503,7 @@ describe('AppContainer State Management', () => {
status: 'executing',
} as unknown as TrackedToolCall,
],
- handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
- loopDetectionConfirmationRequest: null,
lastOutputTime: startTime,
retryStatus: null,
});
@@ -1587,16 +1561,11 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty with NO output since operation started (silent)
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
- cancelOngoingRequest: vi.fn(),
pendingToolCalls: [],
- handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
- loopDetectionConfirmationRequest: null,
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
retryStatus: null,
});
@@ -1643,12 +1612,9 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty but not focused
let lastOutputTime = startTime + 1000;
mockedUseGeminiStream.mockImplementation(() => ({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
- cancelOngoingRequest: vi.fn(),
activePtyId: 'pty-1',
lastOutputTime,
}));
@@ -1669,12 +1635,9 @@ describe('AppContainer State Management', () => {
// Update lastOutputTime to simulate new output
lastOutputTime = startTime + 21000;
mockedUseGeminiStream.mockImplementation(() => ({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
- cancelOngoingRequest: vi.fn(),
activePtyId: 'pty-1',
lastOutputTime,
}));
@@ -1734,12 +1697,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought with a short subject
const shortTitle = 'Short';
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: shortTitle },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1778,12 +1738,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const title = 'Test Title';
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: title },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1821,12 +1778,8 @@ describe('AppContainer State Management', () => {
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1928,12 +1881,7 @@ describe('AppContainer State Management', () => {
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
mockedUseGeminiStream.mockReturnValue({
- streamingState: 'idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- cancelOngoingRequest: vi.fn(),
+ ...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 'some-id',
});
@@ -2005,11 +1953,7 @@ describe('AppContainer State Management', () => {
// Mock request cancellation
mockCancelOngoingRequest = vi.fn();
mockedUseGeminiStream.mockReturnValue({
- streamingState: 'idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
+ ...DEFAULT_GEMINI_STREAM_MOCK,
cancelOngoingRequest: mockCancelOngoingRequest,
});
@@ -2030,11 +1974,8 @@ describe('AppContainer State Management', () => {
describe('CTRL+C', () => {
it('should cancel ongoing request on first press', async () => {
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
cancelOngoingRequest: mockCancelOngoingRequest,
});
await setupKeypressTest();
@@ -2574,12 +2515,7 @@ describe('AppContainer State Management', () => {
});
mockedUseGeminiStream.mockReturnValue({
- streamingState: 'idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- cancelOngoingRequest: vi.fn(),
+ ...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 'some-pty-id', // Make sure activePtyId is set
});
@@ -2729,41 +2665,6 @@ describe('AppContainer State Management', () => {
unmount!();
});
-
- it('should show ask user dialog when request is received', async () => {
- let unmount: () => void;
- await act(async () => {
- const result = renderAppContainer();
- unmount = result.unmount;
- });
-
- const questions = [
- {
- question: 'What is your favorite color?',
- header: 'Color Preference',
- type: QuestionType.TEXT,
- },
- ];
-
- await act(async () => {
- await mockConfig.getMessageBus().publish({
- type: MessageBusType.ASK_USER_REQUEST,
- questions,
- correlationId: 'test-id',
- });
- });
-
- await waitFor(
- () => {
- expect(capturedAskUserRequest).not.toBeNull();
- expect(capturedAskUserRequest?.questions).toEqual(questions);
- expect(capturedAskUserRequest?.correlationId).toBe('test-id');
- },
- { timeout: 2000 },
- );
-
- unmount!();
- });
});
describe('Regression Tests', () => {
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index c792094969..e1d23115ca 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -31,10 +31,6 @@ import {
} from './types.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
-import {
- AskUserActionsProvider,
- type AskUserState,
-} from './contexts/AskUserActionsContext.js';
import {
type EditorType,
type Config,
@@ -70,8 +66,6 @@ import {
SessionEndReason,
generateSummary,
type ConsentRequestPayload,
- MessageBusType,
- type AskUserRequest,
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
} from '@google/gemini-cli-core';
@@ -99,6 +93,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
+import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
@@ -138,6 +133,7 @@ import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
+import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
@@ -259,6 +255,10 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
+ const toggleBackgroundShellRef = useRef<() => void>(() => {});
+ const isBackgroundShellVisibleRef = useRef(false);
+ const backgroundShellsRef = useRef