Compare commits

..

16 Commits

Author SHA1 Message Date
Sehoon Shon 3e2ccade9d Merge branch 'main' into test-workflow-validation 2026-01-30 15:08:39 -05:00
Jerop Kipruto 71308caf05 feat(plan): refactor ToolConfirmationPayload to union type (#17980) 2026-01-30 19:51:45 +00:00
kevinjwang1 6396ab1ccb Add allowedExtensions setting (#17695) 2026-01-30 19:43:51 +00:00
Sehoon Shon 12531a06f8 run npx pointing to the specific commit SHA (#17970)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-01-30 19:42:29 +00:00
lottielin d1cd69a20d feat(core): enable overriding CODE_ASSIST_API_VERSION with env var (#17942)
Co-authored-by: Charlotte Lin <charlotteclin@google.com>
2026-01-30 19:12:04 +00:00
Jerop Kipruto 62346875e4 feat(plan): reuse standard tool confirmation for AskUser tool (#17864)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-01-30 18:32:21 +00:00
christine betts 13e013230b Fix broken link in docs (#17959) 2026-01-30 18:27:25 +00:00
Jacob Richman bb6a336ca9 Fix bugs where Rewind and Resume showed Ugly and 100X too verbose content. (#17940) 2026-01-30 18:09:27 +00:00
Shreya Keshive f14d0c6a17 feat(admin): provide actionable error messages for disabled features (#17815) 2026-01-30 18:05:22 +00:00
Gal Zahavi b611f9a519 feat: Implement background shell commands (#14849) 2026-01-30 17:53:09 +00:00
Abhi d3bca5d97a feat(core): implement dynamic policy registration for subagents (#17838) 2026-01-30 16:57:54 +00:00
Bryan Morgan 5c34cf7dae docs: dummy change to trigger CI 2026-01-30 11:20:54 -05:00
Bryan Morgan 94f4e027f8 feat(ci): add npx smoke test to verify installability (#17927) 2026-01-30 16:09:59 +00:00
Abhi 95b7d69d5b refactor(hooks): remove legacy tools.enableHooks setting (#17867) 2026-01-30 15:15:48 +00:00
Abhi f605628624 perf(core): optimize token calculation and add support for multimodal tool responses (#17835) 2026-01-30 15:15:00 +00:00
Emily Hedlund 2238802e97 feat(core): implement interactive and non-interactive consent for OAuth (#17699) 2026-01-30 14:57:34 +00:00
129 changed files with 6284 additions and 1533 deletions
+41
View File
@@ -177,6 +177,19 @@ jobs:
- name: 'Smoke test bundle'
run: 'node ./bundle/gemini.js --version'
- name: 'Smoke test npx installation'
run: |
# 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'
@@ -252,6 +265,19 @@ jobs:
- name: 'Smoke test bundle'
run: 'node ./bundle/gemini.js --version'
- name: 'Smoke test npx installation'
run: |
# 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'
@@ -396,6 +422,21 @@ jobs:
run: 'node ./bundle/gemini.js --version'
shell: 'pwsh'
- name: 'Smoke test npx installation'
run: |
# 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:
name: 'CI'
if: 'always()'
+3
View File
@@ -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.
+9 -1
View File
@@ -23,7 +23,7 @@ available combinations.
| Move the cursor to the end of the line. | `Ctrl + E`<br />`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)`<br />`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)`<br />`Ctrl + F` |
| Move the cursor one word to the left. | `Ctrl + Left Arrow`<br />`Alt + Left Arrow`<br />`Alt + B` |
| Move the cursor one word to the right. | `Ctrl + Right Arrow`<br />`Alt + Right Arrow`<br />`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`<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` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
+8 -7
View File
@@ -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
+2 -2
View File
@@ -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.
+7 -6
View File
@@ -733,12 +733,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.enableHooks`** (boolean):
- **Description:** Enables the hooks system experiment. When disabled, the
hooks system is completely deactivated regardless of other settings.
- **Default:** `true`
- **Requires restart:** Yes
#### `mcp`
- **`mcp.serverCommand`** (string):
@@ -779,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`
+2
View File
@@ -147,3 +147,5 @@ This documentation is organized into the following sections:
the terms of service and privacy notices applicable to your use of Gemini CLI.
We hope this documentation helps you make the most of Gemini CLI!
<!-- workflow validation test -->
+6 -6
View File
@@ -596,9 +596,9 @@ describe('parseArguments', () => {
it('should set isCommand to true for hooks command', async () => {
process.argv = ['node', 'script.js', 'hooks', 'migrate'];
// Hooks command enabled via tools settings
// Hooks command enabled via hooksConfig settings
const settings = createTestMergedSettings({
tools: { enableHooks: true },
hooksConfig: { enabled: true },
});
const argv = await parseArguments(settings);
expect(argv.isCommand).toBe(true);
@@ -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',
);
});
+5 -6
View File
@@ -38,6 +38,7 @@ import {
type OutputFormat,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -308,7 +309,7 @@ export async function parseArguments(
yargsInstance.command(skillsCommand);
}
// Register hooks command if hooks are enabled
if (settings.tools?.enableHooks) {
if (settings.hooksConfig.enabled) {
yargsInstance.command(hooksCommand);
}
@@ -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) {
@@ -790,10 +791,8 @@ export async function loadCliConfig(
acceptRawOutputRisk: argv.acceptRawOutputRisk,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks:
(settings.tools?.enableHooks ?? true) &&
(settings.hooksConfig?.enabled ?? true),
enableHooksUI: settings.tools?.enableHooks ?? true,
enableHooks: settings.hooksConfig.enabled,
enableHooksUI: settings.hooksConfig.enabled,
hooks: settings.hooks || {},
disabledHooks: settings.hooksConfig?.disabled || [],
projectHooks: projectHooks || {},
@@ -227,7 +227,6 @@ System using model: \${MODEL_NAME}
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
tools: { enableHooks: true },
hooksConfig: { enabled: true },
}),
requestConsent: vi.fn().mockResolvedValue(true),
@@ -51,7 +51,6 @@ describe('ExtensionManager theme loading', () => {
experimental: { extensionConfig: true },
security: { blockGitExtensions: false },
admin: { extensions: { enabled: true }, mcp: { enabled: true } },
tools: { enableHooks: true },
}),
requestConsent: async () => true,
requestSetting: async () => '',
+51 -4
View File
@@ -144,6 +144,26 @@ export class ExtensionManager extends ExtensionLoader {
previousExtensionConfig?: ExtensionConfig,
): Promise<GeminiCLIExtension> {
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;
}
@@ -639,10 +689,7 @@ Would you like to attempt to install via "git clone" instead?`,
};
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
if (
this.settings.tools.enableHooks &&
this.settings.hooksConfig.enabled
) {
if (this.settings.hooksConfig.enabled) {
hooks = await this.loadExtensionHooks(
effectiveExtensionPath,
hydrationContext,
+92
View File
@@ -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,
+35 -1
View File
@@ -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<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.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
-9
View File
@@ -338,10 +338,6 @@ export class LoadedSettings {
}
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
coreEvents.emitFeedback(
'info',
'saving: ' + key + ', ' + value + ', scope: ' + scope,
);
const settingsFile = this.forScope(scope);
setNestedProperty(settingsFile.settings, key, value);
setNestedProperty(settingsFile.originalSettings, key, value);
@@ -731,10 +727,6 @@ export function migrateDeprecatedSettings(
}
if (modified) {
coreEvents.emitFeedback(
'info',
'setting new general for scope:' + scope,
);
loadedSettings.setValue(scope, 'general', newGeneral);
anyModified = true;
}
@@ -855,7 +847,6 @@ export function saveSettings(settingsFile: SettingsFile): void {
settingsToSave as Record<string, unknown>,
);
} catch (error) {
coreEvents.emitFeedback('error', 'here' + error, error);
coreEvents.emitFeedback(
'error',
'There was an error saving your latest settings changes.',
+11 -10
View File
@@ -1179,16 +1179,6 @@ const SETTINGS_SCHEMA = {
`,
showInDialog: true,
},
enableHooks: {
type: 'boolean',
label: 'Enable Hooks System (Experimental)',
category: 'Advanced',
requiresRestart: true,
default: true,
description:
'Enables the hooks system experiment. When disabled, the hooks system is completely deactivated regardless of other settings.',
showInDialog: false,
},
},
},
@@ -1278,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',
+17 -15
View File
@@ -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',
);
});
+17 -4
View File
@@ -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);
}
-1
View File
@@ -371,7 +371,6 @@ export async function main() {
process.env['CLOUD_SHELL'] === 'true' ||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'
) {
coreEvents.emitFeedback('info', 'saving compute_adc');
settings.setValue(
SettingScope.User,
'security.auth.selectedType',
@@ -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');
+3
View File
@@ -301,6 +301,9 @@ export async function runNonInteractive({
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
undefined,
false,
turnCount === 1 ? input : undefined,
);
let responseText = '';
@@ -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<MessageActionReturn> => ({
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<MessageActionReturn> => ({
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<MessageActionReturn> => ({
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,
+7
View File
@@ -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(),
};
+1 -1
View File
@@ -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();
});
+90 -136
View File
@@ -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();
@@ -2510,6 +2451,59 @@ describe('AppContainer State Management', () => {
expect(capturedUIState.activeHooks).toEqual(mockHooks);
unmount!();
});
it('handles consent request events', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
const handler = mockCoreEvents.on.mock.calls.find(
(call: unknown[]) => call[0] === CoreEvent.ConsentRequest,
)?.[1];
expect(handler).toBeDefined();
const onConfirm = vi.fn();
const payload = {
prompt: 'Do you consent?',
onConfirm,
};
act(() => {
handler(payload);
});
expect(capturedUIState.authConsentRequest).toBeDefined();
expect(capturedUIState.authConsentRequest?.prompt).toBe(
'Do you consent?',
);
act(() => {
capturedUIState.authConsentRequest?.onConfirm(true);
});
expect(onConfirm).toHaveBeenCalledWith(true);
expect(capturedUIState.authConsentRequest).toBeNull();
unmount!();
});
it('unsubscribes from ConsentRequest on unmount', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
expect(mockCoreEvents.off).toHaveBeenCalledWith(
CoreEvent.ConsentRequest,
expect.any(Function),
);
});
});
describe('Shell Interaction', () => {
@@ -2521,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
});
@@ -2676,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', () => {
+185 -98
View File
@@ -27,13 +27,10 @@ import {
type HistoryItemWithoutId,
type HistoryItemToolGroup,
AuthState,
type ConfirmationRequest,
} 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,
@@ -68,8 +65,7 @@ import {
SessionStartSource,
SessionEndReason,
generateSummary,
MessageBusType,
type AskUserRequest,
type ConsentRequestPayload,
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
} from '@google/gemini-cli-core';
@@ -97,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';
@@ -136,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,
@@ -257,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<boolean>(false);
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
const [shellModeActive, setShellModeActive] = useState(false);
@@ -336,11 +338,6 @@ export const AppContainer = (props: AppContainerProps) => {
AgentDefinition | undefined
>();
// AskUser dialog state
const [askUserRequest, setAskUserRequest] = useState<AskUserState | null>(
null,
);
const openAgentConfigDialog = useCallback(
(name: string, displayName: string, definition: AgentDefinition) => {
setSelectedAgentName(name);
@@ -358,56 +355,6 @@ export const AppContainer = (props: AppContainerProps) => {
setSelectedAgentDefinition(undefined);
}, []);
// Subscribe to ASK_USER_REQUEST messages from the message bus
useEffect(() => {
const messageBus = config.getMessageBus();
const handler = (msg: AskUserRequest) => {
setAskUserRequest({
questions: msg.questions,
correlationId: msg.correlationId,
});
};
messageBus.subscribe(MessageBusType.ASK_USER_REQUEST, handler);
return () => {
messageBus.unsubscribe(MessageBusType.ASK_USER_REQUEST, handler);
};
}, [config]);
// Handler to submit ask_user answers
const handleAskUserSubmit = useCallback(
async (answers: { [questionIndex: string]: string }) => {
if (!askUserRequest) return;
const messageBus = config.getMessageBus();
await messageBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: askUserRequest.correlationId,
answers,
});
setAskUserRequest(null);
},
[config, askUserRequest],
);
// Handler to cancel ask_user dialog
const handleAskUserCancel = useCallback(async () => {
if (!askUserRequest) return;
const messageBus = config.getMessageBus();
await messageBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: askUserRequest.correlationId,
answers: {},
cancelled: true,
});
setAskUserRequest(null);
}, [config, askUserRequest]);
const toggleDebugProfiler = useCallback(
() => setShowDebugProfiler((prev) => !prev),
[],
@@ -487,6 +434,12 @@ export const AppContainer = (props: AppContainerProps) => {
registerCleanup(async () => {
// Turn off mouse scroll.
disableMouseEvents();
// Kill all background shells
for (const pid of backgroundShellsRef.current.keys()) {
ShellExecutionService.kill(pid);
}
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
@@ -835,6 +788,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { toggleVimEnabled } = useVimMode();
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
() => {},
);
const slashCommandActions = useMemo(
() => ({
openAuthDialog: () => setAuthState(AuthState.Updating),
@@ -858,6 +815,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
toggleBackgroundShell: () => {
toggleBackgroundShellRef.current();
if (!isBackgroundShellVisibleRef.current) {
setEmbeddedShellFocused(true);
if (backgroundShellsRef.current.size > 1) {
setIsBackgroundShellListOpenRef.current(true);
} else {
setIsBackgroundShellListOpenRef.current(false);
}
}
},
setText: (text: string) => buffer.setText(text),
}),
[
@@ -885,7 +853,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
slashCommands,
pendingHistoryItems: pendingSlashCommandHistoryItems,
commandContext,
confirmationRequest,
confirmationRequest: commandConfirmationRequest,
} = useSlashCommandProcessor(
config,
settings,
@@ -902,6 +870,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
setCustomDialog,
);
const [authConsentRequest, setAuthConsentRequest] =
useState<ConfirmationRequest | null>(null);
useEffect(() => {
const handleConsentRequest = (payload: ConsentRequestPayload) => {
setAuthConsentRequest({
prompt: payload.prompt,
onConfirm: (confirmed: boolean) => {
setAuthConsentRequest(null);
payload.onConfirm(confirmed);
},
});
};
coreEvents.on(CoreEvent.ConsentRequest, handleConsentRequest);
return () => {
coreEvents.off(CoreEvent.ConsentRequest, handleConsentRequest);
};
}, []);
const performMemoryRefresh = useCallback(async () => {
historyManager.addItem(
{
@@ -989,6 +977,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
} = useGeminiStream(
config.getGeminiClient(),
@@ -1011,7 +1005,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
embeddedShellFocused,
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
const {
activeBackgroundShellPid,
setIsBackgroundShellListOpen,
isBackgroundShellListOpen,
setActiveBackgroundShellPid,
backgroundShellHeight,
} = useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
});
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
const lastOutputTimeRef = useRef(0);
useEffect(() => {
lastOutputTimeRef.current = lastOutputTime;
}, [lastOutputTime]);
@@ -1160,7 +1177,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Compute available terminal height based on controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - staticExtraHeight - 2,
terminalHeight -
controlsHeight -
staticExtraHeight -
2 -
backgroundShellHeight,
);
config.setShellExecutionConfig({
@@ -1464,10 +1485,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
if (keyMatchers[Command.QUIT](key)) {
// Skip when ask_user dialog is open (use Esc to cancel instead)
if (askUserRequest) {
return;
}
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
// This should happen regardless of the count.
cancelOngoingRequest?.();
@@ -1520,9 +1537,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight(false);
return true;
} else if (
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) &&
activePtyId &&
embeddedShellFocused
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
(activePtyId ||
(isBackgroundShellVisible && backgroundShells.size > 0)) &&
buffer.text.length === 0
) {
if (key.name === 'tab' && key.shift) {
// Always change focus
@@ -1530,26 +1548,72 @@ Logging in with Google... Restarting Gemini CLI to continue.
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) {
if (isIdle && !activePtyId) {
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
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;
toggleBackgroundShell();
if (!isBackgroundShellVisible) {
// We are about to show it, so focus it
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
setEmbeddedShellFocused(false);
}, 100);
} 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;
}
handleWarning('Press Shift+Tab to focus out.');
// Not idle, just focus it
setEmbeddedShellFocused(true);
return true;
} 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) {
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);
}
}
}
return true;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
if (!embeddedShellFocused) {
setEmbeddedShellFocused(true);
}
setIsBackgroundShellListOpen(true);
}
return true;
}
return false;
@@ -1565,7 +1629,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setCtrlDPressCount,
handleSlashCommand,
cancelOngoingRequest,
askUserRequest,
activePtyId,
embeddedShellFocused,
settings.merged.general.debugKeystrokeLogging,
@@ -1573,11 +1636,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
setCopyModeEnabled,
copyModeEnabled,
isAlternateBuffer,
backgroundCurrentShell,
toggleBackgroundShell,
backgroundShells,
isBackgroundShellVisible,
setIsBackgroundShellListOpen,
lastOutputTimeRef,
tabFocusTimeoutRef,
handleWarning,
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useEffect(() => {
// Respect hideWindowTitle settings
@@ -1586,7 +1656,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
const paddedTitle = computeTerminalTitle({
streamingState,
thoughtSubject: thought?.subject,
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
isConfirming:
!!commandConfirmationRequest || shouldShowActionRequiredTitle,
isSilentWorking: shouldShowSilentWorkingTitle,
folderName: basename(config.getTargetDir()),
showThoughts: !!settings.merged.ui.showStatusInTitle,
@@ -1602,7 +1673,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, [
streamingState,
thought,
confirmationRequest,
commandConfirmationRequest,
shouldShowActionRequiredTitle,
shouldShowSilentWorkingTitle,
settings.merged.ui.showStatusInTitle,
@@ -1678,11 +1749,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
const nightly = props.version.includes('nightly');
const dialogsVisible =
!!askUserRequest ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
adminSettingsChanged ||
!!confirmationRequest ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
!!customDialog ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
@@ -1792,7 +1863,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
confirmationRequest,
commandConfirmationRequest,
authConsentRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
@@ -1853,6 +1925,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isRestarting,
extensionsUpdateState,
activePtyId,
backgroundShellCount,
isBackgroundShellVisible,
embeddedShellFocused,
showDebugProfiler,
customDialog,
@@ -1862,6 +1936,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
backgroundShells,
activeBackgroundShellPid,
backgroundShellHeight,
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
}),
@@ -1890,7 +1968,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
confirmationRequest,
commandConfirmationRequest,
authConsentRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
@@ -1951,6 +2030,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
extensionsUpdateState,
activePtyId,
backgroundShellCount,
isBackgroundShellVisible,
historyManager,
embeddedShellFocused,
showDebugProfiler,
@@ -1963,6 +2044,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
config,
settingsNonce,
backgroundShellHeight,
isBackgroundShellListOpen,
activeBackgroundShellPid,
backgroundShells,
adminSettingsChanged,
newAgents,
],
@@ -2010,7 +2095,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
handleRestart: async () => {
if (process.send) {
@@ -2082,7 +2171,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
newAgents,
config,
@@ -2113,15 +2206,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
}}
>
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
<AskUserActionsProvider
request={askUserRequest}
onSubmit={handleAskUserSubmit}
onCancel={handleAskUserCancel}
>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
</AskUserActionsProvider>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
</ConfigContext.Provider>
@@ -125,7 +125,7 @@ Tips for getting started:
4. /help for more information.
HistoryItemDisplay
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? ls list directory │
│ │
@@ -30,9 +30,6 @@ describe('hooksCommand', () => {
hooksConfig?: {
disabled?: string[];
};
tools?: {
enableHooks?: boolean;
};
};
setValue: ReturnType<typeof vi.fn>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -187,8 +184,8 @@ describe('hooksCommand', () => {
it('should display panel when no hooks are configured', async () => {
mockHookSystem.getAllHooks.mockReturnValue([]);
(mockContext.services.settings.merged as Record<string, unknown>)[
'tools'
] = { enableHooks: true };
'hooksConfig'
] = { enabled: true };
const panelCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'panel',
@@ -215,8 +212,8 @@ describe('hooksCommand', () => {
mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
(mockContext.services.settings.merged as Record<string, unknown>)[
'tools'
] = { enableHooks: true };
'hooksConfig'
] = { enabled: true };
const panelCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'panel',
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { shellsCommand } from './shellsCommand.js';
import type { CommandContext } from './types.js';
describe('shellsCommand', () => {
it('should call toggleBackgroundShell', async () => {
const toggleBackgroundShell = vi.fn();
const context = {
ui: {
toggleBackgroundShell,
},
} as unknown as CommandContext;
if (shellsCommand.action) {
await shellsCommand.action(context, '');
}
expect(toggleBackgroundShell).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(shellsCommand.name).toBe('shells');
expect(shellsCommand.altNames).toContain('bashes');
});
it('should auto-execute', () => {
expect(shellsCommand.autoExecute).toBe(true);
});
});
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
export const shellsCommand: SlashCommand = {
name: 'shells',
altNames: ['bashes'],
kind: CommandKind.BUILT_IN,
description: 'Toggle background shells view',
autoExecute: true,
action: async (context) => {
context.ui.toggleBackgroundShell();
},
};
@@ -58,6 +58,7 @@ describe('skillsCommand', () => {
(name: string) => skills.find((s) => s.name === name) ?? null,
),
}),
getContentGenerator: vi.fn(),
} as unknown as Config,
settings: {
merged: createTestMergedSettings({ skills: { disabled: [] } }),
@@ -367,7 +368,7 @@ describe('skillsCommand', () => {
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Agent skills are disabled by your admin.',
text: '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.any(Number),
);
@@ -385,7 +386,7 @@ describe('skillsCommand', () => {
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Agent skills are disabled by your admin.',
text: '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.any(Number),
);
+14 -6
View File
@@ -11,13 +11,15 @@ import {
CommandKind,
} from './types.js';
import {
MessageType,
type HistoryItemSkillsList,
type HistoryItemInfo,
type HistoryItemSkillsList,
MessageType,
} from '../types.js';
import { SettingScope } from '../../config/settings.js';
import { enableSkill, disableSkill } from '../../utils/skillSettings.js';
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
import { getAdminErrorMessage } from '@google/gemini-cli-core';
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
import { SettingScope } from '../../config/settings.js';
async function listAction(
context: CommandContext,
@@ -83,7 +85,10 @@ async function disableAction(
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Agent skills are disabled by your admin.',
text: getAdminErrorMessage(
'Agent skills',
context.services.config ?? undefined,
),
},
Date.now(),
);
@@ -141,7 +146,10 @@ async function enableAction(
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Agent skills are disabled by your admin.',
text: getAdminErrorMessage(
'Agent skills',
context.services.config ?? undefined,
),
},
Date.now(),
);
+1
View File
@@ -84,6 +84,7 @@ export interface CommandContext {
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundShell: () => void;
};
// Session-specific data
session: {
@@ -41,6 +41,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -105,6 +107,8 @@ describe('AskUserDialog', () => {
questions={questions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -124,6 +128,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -153,12 +159,42 @@ describe('AskUserDialog', () => {
});
});
it('shows scroll arrows when options exceed available height', async () => {
const questions: Question[] = [
{
question: 'Choose an option',
header: 'Scroll Test',
options: Array.from({ length: 15 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
})),
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
availableHeight={10} // Small height to force scrolling
/>,
);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -209,6 +245,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -222,6 +260,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -235,6 +275,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -265,6 +307,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -306,6 +350,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -373,6 +419,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -401,6 +449,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -445,6 +495,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -480,6 +532,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -512,6 +566,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -533,6 +589,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -554,6 +612,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -588,6 +648,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -618,6 +680,8 @@ describe('AskUserDialog', () => {
questions={mixedQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -664,6 +728,8 @@ describe('AskUserDialog', () => {
questions={mixedQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -713,6 +779,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -738,6 +806,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={onCancel}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -783,6 +853,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -841,6 +913,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -5,14 +5,7 @@
*/
import type React from 'react';
import {
useCallback,
useMemo,
useRef,
useEffect,
useReducer,
useContext,
} from 'react';
import { useCallback, useMemo, useRef, useEffect, useReducer } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { Question } from '@google/gemini-cli-core';
@@ -24,10 +17,10 @@ import { keyMatchers, Command } from '../keyMatchers.js';
import { checkExhaustive } from '../../utils/checks.js';
import { TextInput } from './shared/TextInput.js';
import { useTextBuffer } from './shared/text-buffer.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
interface AskUserDialogState {
answers: { [key: string]: string };
@@ -121,6 +114,14 @@ interface AskUserDialogProps {
* Useful for managing global keypress handlers.
*/
onActiveTextInputChange?: (active: boolean) => void;
/**
* Width of the dialog.
*/
width: number;
/**
* Height constraint for scrollable content.
*/
availableHeight: number;
}
interface ReviewViewProps {
@@ -152,12 +153,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
);
return (
<Box
flexDirection="column"
borderStyle="round"
paddingX={1}
borderColor={theme.border.default}
>
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
@@ -174,15 +170,19 @@ const ReviewView: React.FC<ReviewViewProps> = ({
</Box>
)}
{questions.map((q, i) => (
<Box key={i} marginBottom={0}>
<Text color={theme.text.secondary}>{q.header}</Text>
<Text color={theme.text.secondary}> </Text>
<Text color={answers[i] ? theme.text.primary : theme.status.warning}>
{answers[i] || '(not answered)'}
</Text>
</Box>
))}
<Box flexDirection="column">
{questions.map((q, i) => (
<Box key={i} marginBottom={0}>
<Text color={theme.text.secondary}>{q.header}</Text>
<Text color={theme.text.secondary}> </Text>
<Text
color={answers[i] ? theme.text.primary : theme.status.warning}
>
{answers[i] || '(not answered)'}
</Text>
</Box>
))}
</Box>
<DialogFooter
primaryAction="Enter to submit"
navigationActions="Tab/Shift+Tab to edit answers"
@@ -199,6 +199,7 @@ interface TextQuestionViewProps {
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number;
availableHeight: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -210,12 +211,13 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
onSelectionChange,
onEditingCustomOption,
availableWidth,
availableHeight,
initialAnswer,
progressHeader,
keyboardHints,
}) => {
const prefix = '> ';
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
const horizontalPadding = 1; // 1 for cursor
const bufferWidth =
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
@@ -241,12 +243,15 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
const handleExtraKeys = useCallback(
(key: Key) => {
if (keyMatchers[Command.QUIT](key)) {
if (textValue === '') {
return false;
}
buffer.setText('');
return true;
}
return false;
},
[buffer],
[buffer, textValue],
);
useKeypress(handleExtraKeys, { isActive: true, priority: true });
@@ -270,18 +275,21 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
const placeholder = question.placeholder || 'Enter your response';
const HEADER_HEIGHT = progressHeader ? 2 : 0;
const INPUT_HEIGHT = 2; // TextInput + margin
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT;
const questionHeight = Math.max(1, availableHeight - overhead);
return (
<Box
flexDirection="column"
borderStyle="round"
paddingX={1}
borderColor={theme.border.default}
>
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
</MaxSizedBox>
</Box>
<Box flexDirection="row" marginBottom={1}>
@@ -381,6 +389,7 @@ interface ChoiceQuestionViewProps {
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number;
availableHeight: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -391,14 +400,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
onAnswer,
onSelectionChange,
onEditingCustomOption,
availableWidth,
availableHeight,
initialAnswer,
progressHeader,
keyboardHints,
}) => {
const uiState = useContext(UIStateContext);
const terminalWidth = uiState?.terminalWidth ?? 80;
const availableWidth = terminalWidth;
const numOptions =
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
const numLen = String(numOptions).length;
@@ -407,15 +414,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " "
const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓"
const cursorPadding = 1; // Extra character for cursor at end of line
const outerBoxPadding = 4; // border (2) + paddingX (2)
const horizontalPadding =
outerBoxPadding +
radioWidth +
numberWidth +
checkboxWidth +
checkmarkWidth +
cursorPadding;
radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding;
const bufferWidth = availableWidth - horizontalPadding;
@@ -544,6 +545,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
(key: Key) => {
// If focusing custom option, handle Ctrl+C
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
if (customOptionText === '') {
return false;
}
customBuffer.setText('');
return true;
}
@@ -586,7 +590,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
}
return false;
},
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
[
isCustomOptionFocused,
customBuffer,
onEditingCustomOption,
customOptionText,
],
);
useKeypress(handleExtraKeys, { isActive: true, priority: true });
@@ -698,31 +707,41 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
}
}, [customOptionText, isCustomOptionSelected, question.multiSelect]);
const HEADER_HEIGHT = progressHeader ? 2 : 0;
const TITLE_MARGIN = 1;
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
const listHeight = Math.max(1, availableHeight - overhead);
const questionHeight = Math.min(3, Math.max(1, listHeight - 4));
const maxItemsToShow = Math.max(
1,
Math.floor((listHeight - questionHeight) / 2),
);
return (
<Box
flexDirection="column"
borderStyle="round"
paddingX={1}
borderColor={theme.border.default}
>
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
<Box marginBottom={TITLE_MARGIN}>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
<Text bold color={theme.text.primary}>
{question.question}
{question.multiSelect && (
<Text color={theme.text.secondary} italic>
{' '}
(Select all that apply)
</Text>
)}
</Text>
</MaxSizedBox>
</Box>
{question.multiSelect && (
<Text color={theme.text.secondary} italic>
{' '}
(Select all that apply)
</Text>
)}
<BaseSelectionList<OptionItem>
items={selectionItems}
onSelect={handleSelect}
onHighlight={handleHighlight}
focusKey={isCustomOptionFocused ? 'other' : undefined}
maxItemsToShow={maxItemsToShow}
showScrollArrows={true}
renderItem={(item, context) => {
const optionItem = item.value;
const isChecked =
@@ -804,14 +823,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onSubmit,
onCancel,
onActiveTextInputChange,
width,
availableHeight,
}) => {
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
const { answers, isEditingCustomOption, submitted } = state;
const uiState = useContext(UIStateContext);
const terminalWidth = uiState?.terminalWidth ?? 80;
const availableWidth = terminalWidth;
const reviewTabIndex = questions.length;
const tabCount =
questions.length > 1 ? questions.length + 1 : questions.length;
@@ -842,9 +859,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
if (keyMatchers[Command.ESCAPE](key)) {
onCancel();
return true;
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
onCancel();
return true;
} else if (keyMatchers[Command.QUIT](key)) {
if (!isEditingCustomOption) {
onCancel();
}
// Return false to let ctrl-C bubble up to AppContainer for exit flow
return false;
}
return false;
},
@@ -1021,7 +1041,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onAnswer={handleAnswer}
onSelectionChange={handleSelectionChange}
onEditingCustomOption={handleEditingCustomOption}
availableWidth={availableWidth}
availableWidth={width}
availableHeight={availableHeight}
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
@@ -1033,7 +1054,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onAnswer={handleAnswer}
onSelectionChange={handleSelectionChange}
onEditingCustomOption={handleEditingCustomOption}
availableWidth={availableWidth}
availableWidth={width}
availableHeight={availableHeight}
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
@@ -1043,7 +1065,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
return (
<Box
flexDirection="column"
width={availableWidth}
width={width}
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
>
{questionView}
@@ -0,0 +1,459 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { act } from 'react';
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
import { ScrollProvider } from '../contexts/ScrollProvider.js';
import { Box } from 'ink';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Mock dependencies
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
const mockSetIsBackgroundShellListOpen = vi.fn();
const mockHandleWarning = vi.fn();
const mockSetEmbeddedShellFocused = vi.fn();
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: () => ({
dismissBackgroundShell: mockDismissBackgroundShell,
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
handleWarning: mockHandleWarning,
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
ShellExecutionService: {
resizePty: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
},
};
});
// Mock AnsiOutputText since it's a complex component
vi.mock('./AnsiOutput.js', () => ({
AnsiOutputText: ({ data }: { data: string | unknown }) => {
if (typeof data === 'string') return <>{data}</>;
// Simple serialization for object data
return <>{JSON.stringify(data)}</>;
},
}));
// Mock useKeypress
let keypressHandlers: Array<{ handler: KeypressHandler; isActive: boolean }> =
[];
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn((handler, { isActive }) => {
keypressHandlers.push({ handler, isActive });
}),
}));
const simulateKey = (key: Partial<Key>) => {
const fullKey: Key = createMockKey(key);
keypressHandlers.forEach(({ handler, isActive }) => {
if (isActive) {
handler(fullKey);
}
});
};
vi.mock('../contexts/MouseContext.js', () => ({
useMouseContext: vi.fn(() => ({
subscribe: vi.fn(),
unsubscribe: vi.fn(),
})),
useMouse: vi.fn(),
}));
// Mock ScrollableList
vi.mock('./shared/ScrollableList.js', () => ({
SCROLL_TO_ITEM_END: 999999,
ScrollableList: vi.fn(
({
data,
renderItem,
}: {
data: BackgroundShell[];
renderItem: (props: {
item: BackgroundShell;
index: number;
}) => React.ReactNode;
}) => (
<Box flexDirection="column">
{data.map((item: BackgroundShell, index: number) => (
<Box key={index}>{renderItem({ item, index })}</Box>
))}
</Box>
),
),
}));
const createMockKey = (overrides: Partial<Key>): Key => ({
name: '',
ctrl: false,
alt: false,
cmd: false,
shift: false,
insertable: false,
sequence: '',
...overrides,
});
describe('<BackgroundShellDisplay />', () => {
const mockShells = new Map<number, BackgroundShell>();
const shell1: BackgroundShell = {
pid: 1001,
command: 'npm start',
output: 'Starting server...',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
};
const shell2: BackgroundShell = {
pid: 1002,
command: 'tail -f log.txt',
output: 'Log entry 1',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
};
beforeEach(() => {
vi.clearAllMocks();
mockShells.clear();
mockShells.set(shell1.pid, shell1);
mockShells.set(shell2.pid, shell2);
keypressHandlers = [];
});
it('renders the output of the active shell', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders tabs for multiple shells', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('highlights the focused state', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true} // Focused
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('resizes the PTY on mount and when dimensions change', async () => {
const { rerender } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
76,
21,
);
rerender(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
height={30}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
96,
27,
);
});
it('renders the process list when isListOpenProp is true', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
act(() => {
simulateKey({ name: 'down' });
});
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
act(() => {
simulateKey({ name: 'l', ctrl: true });
});
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
});
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
// Initial state: shell1 (active) is highlighted
// Move to shell2
act(() => {
simulateKey({ name: 'down' });
});
// Press Ctrl+K
act(() => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
});
it('kills the active process when Ctrl+K is pressed in output view', 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: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
});
it('scrolls to active shell when list opens', async () => {
// shell2 is active
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell2.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('keeps exit code status color even when selected', async () => {
const exitedShell: BackgroundShell = {
pid: 1003,
command: 'exit 0',
output: '',
isBinary: false,
binaryBytesReceived: 0,
status: 'exited',
exitCode: 0,
};
mockShells.set(exitedShell.pid, exitedShell);
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={exitedShell.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
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();
});
});
@@ -0,0 +1,460 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useEffect, useState, useRef } from 'react';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { theme } from '../semantic-colors.js';
import {
ShellExecutionService,
type AnsiOutput,
type AnsiLine,
type AnsiToken,
} from '@google/gemini-cli-core';
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 {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
interface BackgroundShellDisplayProps {
shells: Map<number, BackgroundShell>;
activePid: number;
width: number;
height: number;
isFocused: boolean;
isListOpenProp: boolean;
}
const CONTENT_PADDING_X = 1;
const BORDER_WIDTH = 2; // Left and Right border
const HEADER_HEIGHT = 3; // 2 for border, 1 for header
const TAB_DISPLAY_HORIZONTAL_PADDING = 4;
const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
const commandFirstLine = command.split('\n')[0];
return cpLen(commandFirstLine) > maxWidth
? `${cpSlice(commandFirstLine, 0, maxWidth - 3)}...`
: commandFirstLine;
};
export const BackgroundShellDisplay = ({
shells,
activePid,
width,
height,
isFocused,
isListOpenProp,
}: BackgroundShellDisplayProps) => {
const {
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
handleWarning,
setEmbeddedShellFocused,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
activeShell?.output || '',
);
const [highlightedPid, setHighlightedPid] = useState<number | null>(
activePid,
);
const outputRef = useRef<ScrollableListRef<AnsiLine | string>>(null);
const subscribedRef = useRef(false);
useEffect(() => {
if (!activePid) return;
const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2);
const ptyHeight = Math.max(1, height - HEADER_HEIGHT);
ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight);
}, [activePid, width, height]);
useEffect(() => {
if (!activePid) {
setOutput('');
return;
}
// Set initial output from the shell object
const shell = shells.get(activePid);
if (shell) {
setOutput(shell.output);
}
subscribedRef.current = false;
// Subscribe to live updates for the active shell
const unsubscribe = ShellExecutionService.subscribe(activePid, (event) => {
if (event.type === 'data') {
if (typeof event.chunk === 'string') {
if (!subscribedRef.current) {
// Initial synchronous update contains full history
setOutput(event.chunk);
} else {
// Subsequent updates are deltas for child_process
setOutput((prev) =>
typeof prev === 'string' ? prev + event.chunk : event.chunk,
);
}
} else {
// PTY always sends full AnsiOutput
setOutput(event.chunk);
}
}
});
subscribedRef.current = true;
return () => {
unsubscribe();
subscribedRef.current = false;
};
}, [activePid, shells]);
// Sync highlightedPid with activePid when list opens
useEffect(() => {
if (isListOpenProp) {
setHighlightedPid(activePid);
}
}, [isListOpenProp, activePid]);
useKeypress(
(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
// RadioButtonSelect handles Enter -> onSelect
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
setIsBackgroundShellListOpen(false);
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
if (highlightedPid) {
dismissBackgroundShell(highlightedPid);
// If we killed the active one, the list might update via props
}
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (highlightedPid) {
setActiveBackgroundShellPid(highlightedPid);
}
setIsBackgroundShellListOpen(false);
return true;
}
return false;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
dismissBackgroundShell(activeShell.pid);
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
setIsBackgroundShellListOpen(true);
return true;
}
if (keyMatchers[Command.BACKGROUND_SHELL_SELECT](key)) {
ShellExecutionService.writeToPty(activeShell.pid, '\r');
return true;
} else if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) {
ShellExecutionService.writeToPty(activeShell.pid, '\b');
return true;
} else if (key.sequence) {
ShellExecutionService.writeToPty(activeShell.pid, key.sequence);
return true;
}
return false;
},
{ 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 renderTabs = () => {
const shellList = Array.from(shells.values()).filter(
(s) => s.status === 'running',
);
const pidInfoWidth = getCachedStringWidth(
` (PID: ${activePid}) ${isFocused ? '(Focused)' : ''}`,
);
const availableWidth =
width -
TAB_DISPLAY_HORIZONTAL_PADDING -
getCachedStringWidth(helpText) -
pidInfoWidth;
let currentWidth = 0;
const tabs = [];
for (let i = 0; i < shellList.length; i++) {
const shell = shellList[i];
// Account for " i: " (length 4 if i < 9) and spaces (length 2)
const labelOverhead = 4 + (i + 1).toString().length;
const maxTabLabelLength = Math.max(
1,
Math.floor(availableWidth / shellList.length) - labelOverhead,
);
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxTabLabelLength,
);
const label = ` ${i + 1}: ${truncatedCommand} `;
const labelWidth = getCachedStringWidth(label);
// If this is the only shell, we MUST show it (truncated if necessary)
// even if it exceeds availableWidth, as there are no alternatives.
if (i > 0 && currentWidth + labelWidth > availableWidth) {
break;
}
const isActive = shell.pid === activePid;
tabs.push(
<Text
key={shell.pid}
color={isActive ? theme.text.primary : theme.text.secondary}
bold={isActive}
>
{label}
</Text>,
);
currentWidth += labelWidth;
}
if (shellList.length > tabs.length && !isListOpenProp) {
const overflowLabel = ` ... (${commandDescriptions[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
// We want at least 10 chars for the overflow or we favor the first tab.
const shouldShowOverflow =
tabs.length > 1 || availableWidth - currentWidth >= overflowWidth;
if (shouldShowOverflow) {
tabs.push(
<Text key="overflow" color={theme.status.warning} bold>
{overflowLabel}
</Text>,
);
}
}
return tabs;
};
const renderProcessList = () => {
const maxCommandLength = Math.max(
0,
width - BORDER_WIDTH - CONTENT_PADDING_X * 2 - 10,
);
const items: Array<RadioSelectItem<number>> = Array.from(
shells.values(),
).map((shell, index) => {
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxCommandLength,
);
let label = `${index + 1}: ${truncatedCommand} (PID: ${shell.pid})`;
if (shell.status === 'exited') {
label += ` (Exit Code: ${shell.exitCode})`;
}
return {
key: shell.pid.toString(),
value: shell.pid,
label,
};
});
const initialIndex = items.findIndex((item) => item.value === activePid);
return (
<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):`}
</Text>
</Box>
<Box flexGrow={1} width="100%">
<RadioButtonSelect
items={items}
initialIndex={initialIndex >= 0 ? initialIndex : 0}
onSelect={(pid) => {
setActiveBackgroundShellPid(pid);
setIsBackgroundShellListOpen(false);
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header
renderItem={(
item,
{ isSelected: _isSelected, titleColor: _titleColor },
) => {
// Custom render to handle exit code coloring if needed,
// or just use default. The default RadioButtonSelect renderer
// handles standard label.
// But we want to color exit code differently?
// The previous implementation colored exit code green/red.
// Let's reimplement that.
// We need access to shell details here.
// We can put shell details in the item or lookup.
// Lookup from shells map.
const shell = shells.get(item.value);
if (!shell) return <Text>{item.label}</Text>;
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxCommandLength,
);
return (
<Text>
{truncatedCommand} (PID: {shell.pid})
{shell.status === 'exited' ? (
<Text
color={
shell.exitCode === 0
? theme.status.success
: theme.status.error
}
>
{' '}
(Exit Code: {shell.exitCode})
</Text>
) : null}
</Text>
);
}}
/>
</Box>
</Box>
);
};
const renderOutput = () => {
const lines = typeof output === 'string' ? output.split('\n') : output;
return (
<ScrollableList
ref={outputRef}
data={lines}
renderItem={({ item: line, index }) => {
if (typeof line === 'string') {
return <Text key={index}>{line}</Text>;
}
return (
<Text key={index} wrap="truncate">
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
);
}}
estimatedItemHeight={() => 1}
keyExtractor={(_, index) => index.toString()}
hasFocus={isFocused}
initialScrollIndex={SCROLL_TO_ITEM_END}
/>
);
};
return (
<Box
flexDirection="column"
height="100%"
width="100%"
borderStyle="single"
borderColor={isFocused ? theme.border.focused : undefined}
>
<Box
flexDirection="row"
justifyContent="space-between"
borderStyle="single"
borderBottom={false}
borderLeft={false}
borderRight={false}
borderTop={false}
paddingX={1}
borderColor={isFocused ? theme.border.focused : undefined}
>
<Box flexDirection="row">
{renderTabs()}
<Text bold>
{' '}
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
</Text>
</Box>
<Text color={theme.text.accent}>{helpText}</Text>
</Box>
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
{isListOpenProp ? renderProcessList() : renderOutput()}
</Box>
</Box>
);
};
@@ -34,6 +34,8 @@ describe('Key Bubbling Regression', () => {
questions={choiceQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -133,6 +133,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
nightly: false,
isTrustedFolder: true,
activeHooks: [],
isBackgroundShellVisible: false,
embeddedShellFocused: false,
...overrides,
}) as UIState;
@@ -310,6 +312,32 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show during confirmation');
});
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: true,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
});
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
});
});
describe('Message Queue Display', () => {
+1 -1
View File
@@ -54,7 +54,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexGrow={0}
flexShrink={0}
>
{!uiState.embeddedShellFocused && (
{(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
<LoadingIndicator
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
@@ -18,6 +18,7 @@ interface ContextSummaryDisplayProps {
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
ideContext?: IdeContext;
skillCount: number;
backgroundProcessCount?: number;
}
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
@@ -27,6 +28,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
blockedMcpServers,
ideContext,
skillCount,
backgroundProcessCount = 0,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
@@ -39,7 +41,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
mcpServerCount === 0 &&
blockedMcpServerCount === 0 &&
openFileCount === 0 &&
skillCount === 0
skillCount === 0 &&
backgroundProcessCount === 0
) {
return <Text> </Text>; // Render an empty space to reserve height
}
@@ -93,9 +96,22 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
return `${skillCount} skill${skillCount > 1 ? 's' : ''}`;
})();
const summaryParts = [openFilesText, geminiMdText, mcpText, skillText].filter(
Boolean,
);
const backgroundText = (() => {
if (backgroundProcessCount === 0) {
return '';
}
return `${backgroundProcessCount} Background process${
backgroundProcessCount > 1 ? 'es' : ''
}`;
})();
const summaryParts = [
openFilesText,
geminiMdText,
mcpText,
skillText,
backgroundText,
].filter(Boolean);
if (isNarrow) {
return (
@@ -80,6 +80,7 @@ describe('DialogManager', () => {
isFolderTrustDialogOpen: false,
loopDetectionConfirmationRequest: null,
confirmationRequest: null,
consentRequest: null,
isThemeDialogOpen: false,
isSettingsDialogOpen: false,
isModelDialogOpen: false,
@@ -137,7 +138,11 @@ describe('DialogManager', () => {
'LoopDetectionConfirmation',
],
[
{ confirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
{ commandConfirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
'ConsentPrompt',
],
[
{ authConsentRequest: { prompt: 'bar', onConfirm: vi.fn() } },
'ConsentPrompt',
],
[
@@ -32,8 +32,6 @@ import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { AskUserDialog } from './AskUserDialog.js';
import { useAskUserActions } from '../contexts/AskUserActionsContext.js';
import { NewAgentsNotification } from './NewAgentsNotification.js';
import { AgentConfigDialog } from './AgentConfigDialog.js';
@@ -59,22 +57,6 @@ export const DialogManager = ({
terminalWidth: uiTerminalWidth,
} = uiState;
const {
request: askUserRequest,
submit: askUserSubmit,
cancel: askUserCancel,
} = useAskUserActions();
if (askUserRequest) {
return (
<AskUserDialog
questions={askUserRequest.questions}
onSubmit={askUserSubmit}
onCancel={askUserCancel}
/>
);
}
if (uiState.adminSettingsChanged) {
return <AdminSettingsChangedDialog />;
}
@@ -134,11 +116,24 @@ export const DialogManager = ({
/>
);
}
if (uiState.confirmationRequest) {
// commandConfirmationRequest and authConsentRequest are kept separate
// to avoid focus deadlocks and state race conditions between the
// synchronous command loop and the asynchronous auth flow.
if (uiState.commandConfirmationRequest) {
return (
<ConsentPrompt
prompt={uiState.confirmationRequest.prompt}
onConfirm={uiState.confirmationRequest.onConfirm}
prompt={uiState.commandConfirmationRequest.prompt}
onConfirm={uiState.commandConfirmationRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.authConsentRequest) {
return (
<ConsentPrompt
prompt={uiState.authConsentRequest.prompt}
onConfirm={uiState.authConsentRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
+14 -3
View File
@@ -152,8 +152,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions();
const { terminalWidth, activePtyId, history, terminalBackgroundColor } =
useUIState();
const {
terminalWidth,
activePtyId,
history,
terminalBackgroundColor,
backgroundShells,
backgroundShellHeight,
} = useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const escPressCount = useRef(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -915,7 +921,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
// If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
if (activePtyId) {
if (
activePtyId ||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
) {
setEmbeddedShellFocused(true);
}
return true;
@@ -967,6 +976,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onSubmit,
activePtyId,
setEmbeddedShellFocused,
backgroundShells.size,
backgroundShellHeight,
history,
],
);
@@ -254,6 +254,7 @@ describe('RewindViewer', () => {
{
description: 'removes reference markers',
prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`,
expected: 'some command @file',
},
{
description: 'strips expanded MCP resource content',
@@ -263,10 +264,23 @@ describe('RewindViewer', () => {
'\nContent from @server3:mcp://demo-resource:\n' +
'This is the content of the demo resource.\n' +
`--- End of content ---`,
expected: 'read @server3:mcp://demo-resource hello',
},
])('$description', async ({ prompt }) => {
{
description: 'uses displayContent if present and does not strip',
prompt: `raw content with markers\n--- Content from referenced files ---\nblah\n--- End of content ---`,
displayContent: 'clean display content',
expected: 'clean display content',
},
])('$description', async ({ prompt, displayContent, expected }) => {
const conversation = createConversation([
{ type: 'user', content: prompt, id: '1', timestamp: '1' },
{
type: 'user',
content: prompt,
displayContent,
id: '1',
timestamp: '1',
},
]);
const onRewind = vi.fn();
const { lastFrame, stdin } = renderWithProviders(
@@ -289,6 +303,15 @@ describe('RewindViewer', () => {
await waitFor(() => {
expect(lastFrame()).toContain('Confirm Rewind');
});
// Confirm
act(() => {
stdin.write('\r');
});
await waitFor(() => {
expect(onRewind).toHaveBeenCalledWith('1', expected, expect.anything());
});
});
});
@@ -35,6 +35,14 @@ interface RewindViewerProps {
const MAX_LINES_PER_BOX = 2;
const getCleanedRewindText = (userPrompt: MessageRecord): string => {
const contentToUse = userPrompt.displayContent || userPrompt.content;
const originalUserText = contentToUse ? partToString(contentToUse) : '';
return userPrompt.displayContent
? originalUserText
: stripReferenceContent(originalUserText);
};
export const RewindViewer: React.FC<RewindViewerProps> = ({
conversation,
onExit,
@@ -162,10 +170,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
(m) => m.id === selectedMessageId,
);
if (userPrompt) {
const originalUserText = userPrompt.content
? partToString(userPrompt.content)
: '';
const cleanedText = stripReferenceContent(originalUserText);
const cleanedText = getCleanedRewindText(userPrompt);
setIsRewinding(true);
await onRewind(selectedMessageId, cleanedText, outcome);
}
@@ -224,7 +229,9 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
isSelected ? theme.status.success : theme.text.primary
}
>
{partToString(userPrompt.content)}
{partToString(
userPrompt.displayContent || userPrompt.content,
)}
</Text>
<Text color={theme.text.secondary}>
Cancel rewind and stay here
@@ -235,10 +242,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
const stats = getStats(userPrompt);
const firstFileName = stats?.details?.at(0)?.fileName;
const originalUserText = userPrompt.content
? partToString(userPrompt.content)
: '';
const cleanedText = stripReferenceContent(originalUserText);
const cleanedText = getCleanedRewindText(userPrompt);
return (
<Box flexDirection="column" marginBottom={1}>
@@ -9,6 +9,7 @@ import type React from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
import { Command, keyMatchers } from '../keyMatchers.js';
export interface ShellInputPromptProps {
activeShellPtyId: number | null;
@@ -31,22 +32,31 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
const handleInput = useCallback(
(key: Key) => {
if (!focus || !activeShellPtyId) {
return;
return false;
}
// Allow background shell toggle to bubble up
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return false;
}
if (key.ctrl && key.shift && key.name === 'up') {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return;
return true;
}
if (key.ctrl && key.shift && key.name === 'down') {
ShellExecutionService.scrollPty(activeShellPtyId, 1);
return;
return true;
}
const ansiSequence = keyToAnsi(key);
if (ansiSequence) {
handleShellInputSubmit(ansiSequence);
return true;
}
return false;
},
[focus, handleShellInputSubmit, activeShellPtyId],
);
@@ -15,8 +15,14 @@ import type { TextBuffer } from './shared/text-buffer.js';
// Mock child components to simplify testing
vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: (props: { skillCount: number }) => (
<Text>Mock Context Summary Display (Skills: {props.skillCount})</Text>
ContextSummaryDisplay: (props: {
skillCount: number;
backgroundProcessCount: number;
}) => (
<Text>
Mock Context Summary Display (Skills: {props.skillCount}, Shells:{' '}
{props.backgroundProcessCount})
</Text>
),
}));
@@ -41,6 +47,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
backgroundShellCount: 0,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
@@ -227,4 +234,15 @@ describe('StatusDisplay', () => {
);
expect(lastFrame()).toBe('');
});
it('passes backgroundShellCount to ContextSummaryDisplay', () => {
const uiState = createMockUIState({
backgroundShellCount: 3,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toContain('Shells: 3');
});
});
@@ -81,6 +81,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getDisplayableSkills().length}
backgroundProcessCount={uiState.backgroundShellCount}
/>
);
}
@@ -16,6 +16,21 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
function getConfirmationHeader(
details: SerializableConfirmationDetails | undefined,
): string {
const headers: Partial<
Record<SerializableConfirmationDetails['type'], string>
> = {
ask_user: 'Answer Questions',
};
if (!details?.type) {
return 'Action Required';
}
return headers[details.type] ?? 'Action Required';
}
interface ToolConfirmationQueueProps {
confirmingTool: ConfirmingToolState;
@@ -55,6 +70,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
: undefined;
const borderColor = theme.status.warning;
const hideToolIdentity = tool.confirmationDetails?.type === 'ask_user';
return (
<OverflowProvider>
@@ -67,25 +83,31 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
>
<Box flexDirection="column" width={mainAreaWidth - 4}>
{/* Header */}
<Box marginBottom={1} justifyContent="space-between">
<Box
marginBottom={hideToolIdentity ? 0 : 1}
justifyContent="space-between"
>
<Text color={theme.status.warning} bold>
Action Required
</Text>
<Text color={theme.text.secondary}>
{index} of {total}
{getConfirmationHeader(tool.confirmationDetails)}
</Text>
{total > 1 && (
<Text color={theme.text.secondary}>
{index} of {total}
</Text>
)}
</Box>
{/* Tool Identity (Context) */}
<Box>
<ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo
name={tool.name}
status={tool.status}
description={tool.description}
emphasis="high"
/>
</Box>
{!hideToolIdentity && (
<Box>
<ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo
name={tool.name}
status={tool.status}
description={tool.description}
emphasis="high"
/>
</Box>
)}
</Box>
</StickyHeader>
@@ -1,138 +1,131 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ What should we name this component? │
│ │
│ > e.g., UserProfileCard │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"What should we name this component?
> e.g., UserProfileCard
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Enter the variable name: │
│ │
│ > Enter your response │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Enter the variable name:
> Enter your response
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Enter the database connection string: │
│ │
│ > Enter your response │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Enter the database connection string:
> Enter your response
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
"╭─────────────────────────────────────────────────────────────────╮
│ ← □ Tests │ □ Docs │ ≡ Review → │
│ │
│ Review your answers: │
│ │
│ ⚠ You have 2 unanswered questions │
│ │
│ Tests → (not answered)
│ Docs → (not answered) │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰─────────────────────────────────────────────────────────────────╯"
"← □ Tests │ □ Docs │ ≡ Review →
Review your answers:
⚠ You have 2 unanswered questions
Tests → (not answered)
Docs → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
`;
exports[`AskUserDialog > hides progress header for single question 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
│ 2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Which authentication method should we use?
● 1. OAuth 2.0
Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > renders question and options 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
│ 2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Which authentication method should we use?
● 1. OAuth 2.0
Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ← □ Framework │ □ Styling │ ≡ Review → │
│ │
│ Which framework? │
│ │
│ ● 1. React │
│ Component library │
│ 2. Vue │
│ Progressive framework │
│ 3. Enter a custom value │
│ │
│ Enter to select · ←/→ to switch questions · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"← □ Framework │ □ Styling │ ≡ Review →
Which framework?
● 1. React
Component library
2. Vue
Progressive framework
3. Enter a custom value
Enter to select · ←/→ to switch questions · Esc to cancel"
`;
exports[`AskUserDialog > shows keyboard hints 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
│ 2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Which authentication method should we use?
● 1. OAuth 2.0
Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ← □ Database │ □ ORM │ ≡ Review → │
│ │
│ Which database should we use? │
│ │
│ ● 1. PostgreSQL │
│ Relational database │
│ 2. MongoDB │
│ Document database │
│ 3. Enter a custom value │
│ │
│ Enter to select · ←/→ to switch questions · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"← □ Database │ □ ORM │ ≡ Review →
Which database should we use?
● 1. PostgreSQL
Relational database
2. MongoDB
Document database
3. Enter a custom value
Enter to select · ←/→ to switch questions · Esc to cancel"
`;
exports[`AskUserDialog > shows scroll arrows when options exceed available height 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
"╭─────────────────────────────────────────────────────────────────╮
│ ← □ License │ □ README │ ≡ Review → │
│ │
│ Review your answers: │
│ │
│ ⚠ You have 2 unanswered questions │
│ │
│ License → (not answered)
│ README → (not answered) │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰─────────────────────────────────────────────────────────────────╯"
"← □ License │ □ README │ ≡ Review →
Review your answers:
⚠ You have 2 unanswered questions
License → (not answered)
README → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
`;
@@ -0,0 +1,56 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 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 │
│ │
│ Select Process (Enter to select, Esc to cancel): │
│ │
│ 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
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 │
│ 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 │
│ 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 │
│ │
│ Select Process (Enter to select, Esc to cancel): │
│ │
│ ● 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
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 │
│ │
│ Select Process (Enter to select, Esc to cancel): │
│ │
│ 1. npm start (PID: 1001) │
│ ● 2. tail -f log.txt (PID: 1002) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
@@ -34,6 +34,23 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Content Filtering > 'uses displayContent if present and do…' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ clean display content │
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Interaction Selection > 'cancels on Escape' > confirmation-dialog 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2)"`;
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except system md) 1`] = `"Press Ctrl+C again to exit."`;
exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
@@ -2,7 +2,7 @@
exports[`ToolConfirmationQueue > calculates availableContentHeight based on availableTerminalHeight from UI state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? replace edit file │
│ │
@@ -22,7 +22,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? replace edit file │
│ │
@@ -44,7 +44,7 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? replace edit file │
│ │
@@ -13,6 +13,7 @@ import {
type SerializableConfirmationDetails,
type ToolCallConfirmationDetails,
type Config,
type ToolConfirmationPayload,
ToolConfirmationOutcome,
hasRedirection,
debugLogger,
@@ -25,12 +26,14 @@ import { sanitizeForDisplay } from '../../utils/textUtils.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
REDIRECTION_WARNING_TIP_LABEL,
REDIRECTION_WARNING_TIP_TEXT,
} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
export interface ToolConfirmationMessageProps {
callId: string;
@@ -59,9 +62,12 @@ export const ToolConfirmationMessage: React.FC<
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval;
const handlesOwnUI = confirmationDetails.type === 'ask_user';
const isTrustedFolder = config.isTrustedFolder();
const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome) => {
void confirm(callId, outcome).catch((error: unknown) => {
(outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => {
void confirm(callId, outcome, payload).catch((error: unknown) => {
debugLogger.error(
`Failed to handle tool confirmation for ${callId}:`,
error,
@@ -71,15 +77,18 @@ export const ToolConfirmationMessage: React.FC<
[confirm, callId],
);
const isTrustedFolder = config.isTrustedFolder();
useKeypress(
(key) => {
if (!isFocused) return false;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
if (keyMatchers[Command.ESCAPE](key)) {
handleConfirm(ToolConfirmationOutcome.Cancel);
return true;
}
if (keyMatchers[Command.QUIT](key)) {
// Return false to let ctrl-C bubble up to AppContainer for exit flow.
// AppContainer will call cancelOngoingRequest which will cancel the tool.
return false;
}
return false;
},
{ isActive: isFocused },
@@ -180,7 +189,7 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.Cancel,
key: 'No, suggest changes (esc)',
});
} else {
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
options.push({
label: 'Allow once',
@@ -251,6 +260,23 @@ export const ToolConfirmationMessage: React.FC<
let question = '';
const options = getOptions();
if (confirmationDetails.type === 'ask_user') {
bodyContent = (
<AskUserDialog
questions={confirmationDetails.questions}
onSubmit={(answers) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight() ?? 10}
/>
);
return { question: '', bodyContent, options: [] };
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
@@ -265,7 +291,7 @@ export const ToolConfirmationMessage: React.FC<
}
} else if (confirmationDetails.type === 'info') {
question = `Do you want to proceed?`;
} else {
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
@@ -387,7 +413,7 @@ export const ToolConfirmationMessage: React.FC<
)}
</Box>
);
} else {
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
@@ -405,6 +431,7 @@ export const ToolConfirmationMessage: React.FC<
getOptions,
availableBodyContentHeight,
terminalWidth,
handleConfirm,
]);
if (confirmationDetails.type === 'edit') {
@@ -429,32 +456,38 @@ export const ToolConfirmationMessage: React.FC<
}
return (
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden">
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}
overflowDirection="top"
>
{bodyContent}
</MaxSizedBox>
</Box>
<Box
flexDirection="column"
paddingTop={0}
paddingBottom={handlesOwnUI ? 0 : 1}
>
{handlesOwnUI ? (
bodyContent
) : (
<>
<Box flexGrow={1} flexShrink={1} overflow="hidden">
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}
overflowDirection="top"
>
{bodyContent}
</MaxSizedBox>
</Box>
{/* Confirmation Question */}
<Box marginBottom={1} flexShrink={0}>
<Text color={theme.text.primary}>{question}</Text>
</Box>
<Box marginBottom={1} flexShrink={0}>
<Text color={theme.text.primary}>{question}</Text>
</Box>
{/* Select Input for Options */}
<Box flexShrink={0}>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box>
<Box flexShrink={0}>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box>
</>
)}
</Box>
);
};
@@ -475,14 +475,7 @@ describe('BaseSelectionList', () => {
);
await waitFor(() => {
const output = lastFrame();
// At the top, should show first 3 items
expect(output).toContain('Item 1');
expect(output).toContain('Item 3');
expect(output).not.toContain('Item 4');
// Both arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -493,15 +486,7 @@ describe('BaseSelectionList', () => {
);
await waitFor(() => {
const output = lastFrame();
// After scrolling to middle, should see items around index 5
expect(output).toContain('Item 4');
expect(output).toContain('Item 6');
expect(output).not.toContain('Item 3');
expect(output).not.toContain('Item 7');
// Both scroll arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -512,32 +497,18 @@ describe('BaseSelectionList', () => {
);
await waitFor(() => {
const output = lastFrame();
// At the end, should show last 3 items
expect(output).toContain('Item 8');
expect(output).toContain('Item 10');
expect(output).not.toContain('Item 7');
// Both arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
it('should show both arrows dimmed when list fits entirely', () => {
it('should not show arrows when list fits entirely', () => {
const { lastFrame } = renderComponent({
items,
maxItemsToShow: 5,
showScrollArrows: true,
});
const output = lastFrame();
// Should show all items since maxItemsToShow > items.length
expect(output).toContain('Item A');
expect(output).toContain('Item B');
expect(output).toContain('Item C');
// Both arrows should be visible but dimmed (this test doesn't need waitFor since no scrolling occurs)
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
});
@@ -100,7 +100,7 @@ export function BaseSelectionList<
return (
<Box flexDirection="column">
{/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && (
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
>
@@ -172,7 +172,7 @@ export function BaseSelectionList<
);
})}
{showScrollArrows && (
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={
scrollOffset + maxItemsToShow < items.length
@@ -0,0 +1,31 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should not show arrows when list fits entirely 1`] = `
"● 1. Item A
2. Item B
3. Item C"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 1`] = `
"▲
8. Item 8
9. Item 9
● 10. Item 10
▼"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = `
"▲
4. Item 4
5. Item 5
● 6. Item 6
▼"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = `
"▲
● 1. Item 1
2. Item 2
3. Item 3
▼"
`;
@@ -1,14 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DescriptiveRadioButtonSelect > should render correctly with custom props 1`] = `
"
1. Foo Title
" 1. Foo Title
This is Foo.
● 2. Bar Title
This is Bar.
3. Baz Title
This is Baz.
▼"
This is Baz."
`;
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
+2
View File
@@ -116,6 +116,8 @@ export const INFORMATIVE_TIPS = [
'In menus, move up/down with k/j or the arrow keys…',
'In menus, select an item by typing its number…',
"If you're using an IDE, see the context with Ctrl+G…",
'Toggle background shells with Ctrl+B or /shells...',
'Toggle the background shell process list with Ctrl+L...',
// Keyboard shortcut tips end here
// Command tips start here
'Show version info with /about…',
@@ -67,7 +67,11 @@ export interface UIActions {
handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void;
setBannerVisible: (visible: boolean) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundShell: (pid: number) => void;
setActiveBackgroundShellPid: (pid: number) => void;
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
handleRestart: () => void;
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
@@ -50,6 +50,7 @@ export interface ValidationDialogRequest {
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
export interface UIState {
history: HistoryItem[];
@@ -80,7 +81,8 @@ export interface UIState {
slashCommands: readonly SlashCommand[] | undefined;
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext;
confirmationRequest: ConfirmationRequest | null;
commandConfirmationRequest: ConfirmationRequest | null;
authConsentRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
geminiMdFileCount: number;
@@ -141,6 +143,8 @@ export interface UIState {
isRestarting: boolean;
extensionsUpdateState: Map<string, ExtensionUpdateState>;
activePtyId: number | undefined;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
@@ -154,6 +158,10 @@ export interface UIState {
customDialog: React.ReactNode | null;
terminalBackgroundColor: TerminalBackgroundColor;
settingsNonce: number;
backgroundShells: Map<number, BackgroundShell>;
activeBackgroundShellPid: number | null;
backgroundShellHeight: number;
isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
}
@@ -4,6 +4,7 @@ exports[`useReactToolScheduler > should handle live output updates 1`] = `
{
"callId": "liveCall",
"contentLength": 12,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
@@ -26,6 +27,7 @@ exports[`useReactToolScheduler > should handle tool requiring confirmation - app
{
"callId": "callConfirm",
"contentLength": 16,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
@@ -75,6 +77,7 @@ exports[`useReactToolScheduler > should schedule and execute a tool call success
{
"callId": "call1",
"contentLength": 11,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
@@ -19,12 +19,34 @@ import {
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
const mockShellKill = vi.hoisted(() => vi.fn());
const mockShellBackground = vi.hoisted(() => vi.fn());
const mockShellSubscribe = vi.hoisted(() =>
vi.fn<
(pid: number, listener: (event: ShellOutputEvent) => void) => () => void
>(() => vi.fn()),
); // Returns unsubscribe
const mockShellOnExit = vi.hoisted(() =>
vi.fn<
(
pid: number,
callback: (exitCode: number, signal?: number) => void,
) => () => void
>(() => vi.fn()),
);
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
ShellExecutionService: { execute: mockShellExecutionService },
ShellExecutionService: {
execute: mockShellExecutionService,
kill: mockShellKill,
background: mockShellBackground,
subscribe: mockShellSubscribe,
onExit: mockShellOnExit,
},
isBinary: mockIsBinary,
};
});
@@ -113,7 +135,13 @@ describe('useShellCommandProcessor', () => {
const renderProcessorHook = () => {
let hookResult: ReturnType<typeof useShellCommandProcessor>;
function TestComponent() {
let renderCount = 0;
function TestComponent({
isWaitingForConfirmation,
}: {
isWaitingForConfirmation?: boolean;
}) {
renderCount++;
hookResult = useShellCommandProcessor(
addItemToHistoryMock,
setPendingHistoryItemMock,
@@ -122,16 +150,25 @@ describe('useShellCommandProcessor', () => {
mockConfig,
mockGeminiClient,
setShellInputFocusedMock,
undefined,
undefined,
undefined,
isWaitingForConfirmation,
);
return null;
}
render(<TestComponent />);
const { rerender } = render(<TestComponent />);
return {
result: {
get current() {
return hookResult;
},
},
getRenderCount: () => renderCount,
rerender: (isWaitingForConfirmation?: boolean) =>
rerender(
<TestComponent isWaitingForConfirmation={isWaitingForConfirmation} />,
),
};
};
@@ -723,4 +760,403 @@ describe('useShellCommandProcessor', () => {
expect(result.current.activeShellPtyId).toBeNull();
});
});
describe('Background Shell Management', () => {
it('should register a background shell and update count', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
expect(result.current.backgroundShellCount).toBe(1);
const shell = result.current.backgroundShells.get(1001);
expect(shell).toEqual(
expect.objectContaining({
pid: 1001,
command: 'bg-cmd',
output: 'initial',
}),
);
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
expect(mockShellSubscribe).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
});
it('should toggle background shell visibility', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
expect(result.current.isBackgroundShellVisible).toBe(false);
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(false);
});
it('should show info message when toggling background shells if none are active', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.toggleBackgroundShell();
});
expect(addItemToHistoryMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'No background shells are currently active.',
}),
expect.any(Number),
);
expect(result.current.isBackgroundShellVisible).toBe(false);
});
it('should dismiss a background shell and remove it from state', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.dismissBackgroundShell(1001);
});
expect(mockShellKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundShellCount).toBe(0);
expect(result.current.backgroundShells.has(1001)).toBe(false);
});
it('should handle backgrounding the current shell', async () => {
// Simulate an active shell
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
mockShellOutputCallback = callback;
return Promise.resolve({
pid: 555,
result: new Promise((resolve) => {
resolveExecutionPromise = resolve;
}),
});
});
const { result } = renderProcessorHook();
await act(async () => {
result.current.handleShellCommand('top', new AbortController().signal);
});
expect(result.current.activeShellPtyId).toBe(555);
act(() => {
result.current.backgroundCurrentShell();
});
expect(mockShellBackground).toHaveBeenCalledWith(555);
// The actual state update happens when the promise resolves with backgrounded: true
// which is handled in handleShellCommand's .then block.
// We simulate that here:
await act(async () => {
resolveExecutionPromise(
createMockServiceResult({
backgrounded: true,
pid: 555,
output: 'running...',
}),
);
});
// Wait for promise resolution
await act(async () => await onExecMock.mock.calls[0][0]);
expect(result.current.backgroundShellCount).toBe(1);
expect(result.current.activeShellPtyId).toBeNull();
});
it('should persist background shell on successful exit and mark as exited', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(888, 'auto-exit', '');
});
// Find the exit callback registered
const exitCallback = mockShellOnExit.mock.calls.find(
(call) => call[0] === 888,
)?.[1];
expect(exitCallback).toBeDefined();
if (exitCallback) {
act(() => {
exitCallback(0);
});
}
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
const shell = result.current.backgroundShells.get(888);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
});
it('should persist background shell on failed exit', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(999, 'fail-exit', '');
});
const exitCallback = mockShellOnExit.mock.calls.find(
(call) => call[0] === 999,
)?.[1];
expect(exitCallback).toBeDefined();
if (exitCallback) {
act(() => {
exitCallback(1);
});
}
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
const shell = result.current.backgroundShells.get(999);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(1);
// Now dismiss it
act(() => {
result.current.dismissBackgroundShell(999);
});
expect(result.current.backgroundShellCount).toBe(0);
});
it('should NOT trigger re-render on background shell output when visible', async () => {
const { result, getRenderCount } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundShell();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
if (subscribeCallback) {
act(() => {
subscribeCallback({ type: 'data', chunk: ' + updated' });
});
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.output).toBe('initial + updated');
});
it('should NOT trigger re-render on background shell output when hidden', async () => {
const { result, getRenderCount } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Ensure background shells are hidden (default)
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
if (subscribeCallback) {
act(() => {
subscribeCallback({ type: 'data', chunk: ' + updated' });
});
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.output).toBe('initial + updated');
});
it('should trigger re-render on binary progress when visible', async () => {
const { result, getRenderCount } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundShell();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
if (subscribeCallback) {
act(() => {
subscribeCallback({ type: 'binary_progress', bytesReceived: 1024 });
});
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.isBinary).toBe(true);
expect(shell?.binaryBytesReceived).toBe(1024);
});
it('should NOT hide background shell when model is responding without confirmation', async () => {
const { result, rerender } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Simulate model responding (not waiting for confirmation)
act(() => {
rerender(false); // isWaitingForConfirmation = false
});
// Should stay visible
expect(result.current.isBackgroundShellVisible).toBe(true);
});
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
const { result, rerender } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Simulate tool confirmation showing up
act(() => {
rerender(true); // isWaitingForConfirmation = true
});
// Should be hidden
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Simulate confirmation accepted (waiting for PTY start)
act(() => {
rerender(false);
});
// Should STAY hidden during the 300ms gap
expect(result.current.isBackgroundShellVisible).toBe(false);
// 4. Wait for restore delay
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
it('should auto-hide background shell when foreground shell starts and restore when it ends', async () => {
const { result } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
// Wait for PID to be set
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
// Should be hidden automatically
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Complete foreground shell
act(() => {
resolveExecutionPromise(createMockServiceResult());
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(null));
// Should be restored automatically (after delay)
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
it('should NOT restore background shell if it was manually hidden during foreground execution', async () => {
const { result } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Manually toggle visibility (e.g. user wants to peek)
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 4. Complete foreground shell
act(() => {
resolveExecutionPromise(createMockServiceResult());
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(null));
// It should NOT change visibility because manual toggle cleared the auto-restore flag
// After delay it should stay true (as it was manually toggled to true)
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
});
});
+348 -173
View File
@@ -9,13 +9,8 @@ import type {
IndividualToolCallDisplay,
} from '../types.js';
import { ToolCallStatus } from '../types.js';
import { useCallback, useState } from 'react';
import type {
AnsiOutput,
Config,
GeminiClient,
ShellExecutionResult,
} from '@google/gemini-cli-core';
import { useCallback, useReducer, useRef, useEffect } from 'react';
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
import { isBinary, ShellExecutionService } from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -26,8 +21,15 @@ import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import { themeManager } from '../../ui/themes/theme-manager.js';
import {
shellReducer,
initialState,
type BackgroundShell,
} from './shellReducer.js';
export { type BackgroundShell };
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const RESTORE_VISIBILITY_DELAY_MS = 300;
const MAX_OUTPUT_LENGTH = 10000;
function addShellCommandToGeminiHistory(
@@ -75,9 +77,190 @@ export const useShellCommandProcessor = (
setShellInputFocused: (value: boolean) => void,
terminalWidth?: number,
terminalHeight?: number,
activeToolPtyId?: number,
isWaitingForConfirmation?: boolean,
) => {
const [activeShellPtyId, setActiveShellPtyId] = useState<number | null>(null);
const [lastShellOutputTime, setLastShellOutputTime] = useState<number>(0);
const [state, dispatch] = useReducer(shellReducer, initialState);
// Consolidate stable tracking into a single manager object
const manager = useRef<{
wasVisibleBeforeForeground: boolean;
restoreTimeout: NodeJS.Timeout | null;
backgroundedPids: Set<number>;
subscriptions: Map<number, () => void>;
} | null>(null);
if (!manager.current) {
manager.current = {
wasVisibleBeforeForeground: false,
restoreTimeout: null,
backgroundedPids: new Set(),
subscriptions: new Map(),
};
}
const m = manager.current;
const activePtyId = state.activeShellPtyId || activeToolPtyId;
useEffect(() => {
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
if (isForegroundActive) {
if (m.restoreTimeout) {
clearTimeout(m.restoreTimeout);
m.restoreTimeout = null;
}
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
m.wasVisibleBeforeForeground = true;
dispatch({ type: 'SET_VISIBILITY', visible: false });
}
} else if (m.wasVisibleBeforeForeground && !m.restoreTimeout) {
// Restore if it was automatically hidden, with a small delay to avoid
// flickering between model turn segments.
m.restoreTimeout = setTimeout(() => {
dispatch({ type: 'SET_VISIBILITY', visible: true });
m.wasVisibleBeforeForeground = false;
m.restoreTimeout = null;
}, RESTORE_VISIBILITY_DELAY_MS);
}
return () => {
if (m.restoreTimeout) {
clearTimeout(m.restoreTimeout);
}
};
}, [
activePtyId,
isWaitingForConfirmation,
state.isBackgroundShellVisible,
m,
dispatch,
]);
useEffect(
() => () => {
// Unsubscribe from all background shell events on unmount
for (const unsubscribe of m.subscriptions.values()) {
unsubscribe();
}
m.subscriptions.clear();
},
[m],
);
const toggleBackgroundShell = useCallback(() => {
if (state.backgroundShells.size > 0) {
const willBeVisible = !state.isBackgroundShellVisible;
dispatch({ type: 'TOGGLE_VISIBILITY' });
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
// If we are manually showing it during foreground, we set the restore flag
// so that useEffect doesn't immediately hide it again.
// If we are manually hiding it, we clear the restore flag so it stays hidden.
if (willBeVisible && isForegroundActive) {
m.wasVisibleBeforeForeground = true;
} else {
m.wasVisibleBeforeForeground = false;
}
if (willBeVisible) {
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
}
} else {
dispatch({ type: 'SET_VISIBILITY', visible: false });
addItemToHistory(
{
type: 'info',
text: 'No background shells are currently active.',
},
Date.now(),
);
}
}, [
addItemToHistory,
state.backgroundShells.size,
state.isBackgroundShellVisible,
activePtyId,
isWaitingForConfirmation,
m,
dispatch,
]);
const backgroundCurrentShell = useCallback(() => {
const pidToBackground = state.activeShellPtyId || activeToolPtyId;
if (pidToBackground) {
ShellExecutionService.background(pidToBackground);
m.backgroundedPids.add(pidToBackground);
// Ensure backgrounding is silent and doesn't trigger restoration
m.wasVisibleBeforeForeground = false;
if (m.restoreTimeout) {
clearTimeout(m.restoreTimeout);
m.restoreTimeout = null;
}
}
}, [state.activeShellPtyId, activeToolPtyId, m]);
const dismissBackgroundShell = useCallback(
(pid: number) => {
const shell = state.backgroundShells.get(pid);
if (shell) {
if (shell.status === 'running') {
ShellExecutionService.kill(pid);
}
dispatch({ type: 'DISMISS_SHELL', pid });
m.backgroundedPids.delete(pid);
// Unsubscribe from updates
const unsubscribe = m.subscriptions.get(pid);
if (unsubscribe) {
unsubscribe();
m.subscriptions.delete(pid);
}
}
},
[state.backgroundShells, dispatch, m],
);
const registerBackgroundShell = useCallback(
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
// Subscribe to process exit directly
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
dispatch({
type: 'UPDATE_SHELL',
pid,
update: { status: 'exited', exitCode: code },
});
m.backgroundedPids.delete(pid);
});
// Subscribe to future updates (data only)
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
if (event.type === 'data') {
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
} else if (event.type === 'binary_detected') {
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
} else if (event.type === 'binary_progress') {
dispatch({
type: 'UPDATE_SHELL',
pid,
update: {
isBinary: true,
binaryBytesReceived: event.bytesReceived,
},
});
}
});
m.subscriptions.set(pid, () => {
exitUnsubscribe();
dataUnsubscribe();
});
},
[dispatch, m],
);
const handleShellCommand = useCallback(
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
@@ -109,9 +292,7 @@ export const useShellCommandProcessor = (
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
}
const executeCommand = async (
resolve: (value: void | PromiseLike<void>) => void,
) => {
const executeCommand = async () => {
let cumulativeStdout: string | AnsiOutput = '';
let isBinaryStream = false;
let binaryBytesReceived = 0;
@@ -151,84 +332,90 @@ export const useShellCommandProcessor = (
defaultBg: activeTheme.colors.Background,
};
const { pid, result } = await ShellExecutionService.execute(
commandToExecute,
targetDir,
(event) => {
let shouldUpdate = false;
switch (event.type) {
case 'data':
// Do not process text data if we've already switched to binary mode.
if (isBinaryStream) break;
// PTY provides the full screen state, so we just replace.
// Child process provides chunks, so we append.
if (config.getEnableInteractiveShell()) {
cumulativeStdout = event.chunk;
shouldUpdate = true;
} else if (
typeof event.chunk === 'string' &&
typeof cumulativeStdout === 'string'
) {
cumulativeStdout += event.chunk;
shouldUpdate = true;
}
break;
case 'binary_detected':
isBinaryStream = true;
// Force an immediate UI update to show the binary detection message.
shouldUpdate = true;
break;
case 'binary_progress':
isBinaryStream = true;
binaryBytesReceived = event.bytesReceived;
shouldUpdate = true;
break;
default: {
throw new Error('An unhandled ShellOutputEvent was found.');
}
}
const { pid, result: resultPromise } =
await ShellExecutionService.execute(
commandToExecute,
targetDir,
(event) => {
let shouldUpdate = false;
// Compute the display string based on the *current* state.
let currentDisplayOutput: string | AnsiOutput;
if (isBinaryStream) {
if (binaryBytesReceived > 0) {
currentDisplayOutput = `[Receiving binary output... ${formatBytes(
binaryBytesReceived,
)} received]`;
} else {
switch (event.type) {
case 'data':
if (isBinaryStream) break;
if (typeof event.chunk === 'string') {
if (typeof cumulativeStdout === 'string') {
cumulativeStdout += event.chunk;
} else {
cumulativeStdout = event.chunk;
}
} else {
// AnsiOutput (PTY) is always the full state
cumulativeStdout = event.chunk;
}
shouldUpdate = true;
break;
case 'binary_detected':
isBinaryStream = true;
shouldUpdate = true;
break;
case 'binary_progress':
isBinaryStream = true;
binaryBytesReceived = event.bytesReceived;
shouldUpdate = true;
break;
case 'exit':
// No action needed for exit event during streaming
break;
default:
throw new Error('An unhandled ShellOutputEvent was found.');
}
if (executionPid && m.backgroundedPids.has(executionPid)) {
// If already backgrounded, let the background shell subscription handle it.
dispatch({
type: 'APPEND_SHELL_OUTPUT',
pid: executionPid,
chunk:
event.type === 'data' ? event.chunk : cumulativeStdout,
});
return;
}
let currentDisplayOutput: string | AnsiOutput;
if (isBinaryStream) {
currentDisplayOutput =
'[Binary output detected. Halting stream...]';
binaryBytesReceived > 0
? `[Receiving binary output... ${formatBytes(binaryBytesReceived)} received]`
: '[Binary output detected. Halting stream...]';
} else {
currentDisplayOutput = cumulativeStdout;
}
} else {
currentDisplayOutput = cumulativeStdout;
}
// Throttle pending UI updates, but allow forced updates.
if (shouldUpdate) {
setLastShellOutputTime(Date.now());
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
...prevItem,
tools: prevItem.tools.map((tool) =>
tool.callId === callId
? { ...tool, resultDisplay: currentDisplayOutput }
: tool,
),
};
}
return prevItem;
});
}
},
abortSignal,
config.getEnableInteractiveShell(),
shellExecutionConfig,
);
if (shouldUpdate) {
dispatch({ type: 'SET_OUTPUT_TIME', time: Date.now() });
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
...prevItem,
tools: prevItem.tools.map((tool) =>
tool.callId === callId
? { ...tool, resultDisplay: currentDisplayOutput }
: tool,
),
};
}
return prevItem;
});
}
},
abortSignal,
config.getEnableInteractiveShell(),
shellExecutionConfig,
);
executionPid = pid;
if (pid) {
setActiveShellPtyId(pid);
dispatch({ type: 'SET_ACTIVE_PTY', pid });
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
@@ -242,94 +429,69 @@ export const useShellCommandProcessor = (
});
}
result
.then((result: ShellExecutionResult) => {
setPendingHistoryItem(null);
const result = await resultPromise;
setPendingHistoryItem(null);
let mainContent: string;
if (result.backgrounded && result.pid) {
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
}
if (isBinary(result.rawOutput)) {
mainContent =
'[Command produced binary output, which is not shown.]';
} else {
mainContent =
result.output.trim() || '(Command produced no output)';
}
let mainContent: string;
if (isBinary(result.rawOutput)) {
mainContent =
'[Command produced binary output, which is not shown.]';
} else {
mainContent =
result.output.trim() || '(Command produced no output)';
}
let finalOutput = mainContent;
let finalStatus = ToolCallStatus.Success;
let finalOutput = mainContent;
let finalStatus = ToolCallStatus.Success;
if (result.error) {
finalStatus = ToolCallStatus.Error;
finalOutput = `${result.error.message}\n${finalOutput}`;
} else if (result.aborted) {
finalStatus = ToolCallStatus.Canceled;
finalOutput = `Command was cancelled.\n${finalOutput}`;
} else if (result.signal) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
} else if (result.exitCode !== 0) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
}
if (result.error) {
finalStatus = ToolCallStatus.Error;
finalOutput = `${result.error.message}\n${finalOutput}`;
} else if (result.aborted) {
finalStatus = ToolCallStatus.Canceled;
finalOutput = `Command was cancelled.\n${finalOutput}`;
} else if (result.backgrounded) {
finalStatus = ToolCallStatus.Success;
finalOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
} else if (result.signal) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
} else if (result.exitCode !== 0) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
}
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
if (finalPwd && finalPwd !== targetDir) {
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
finalOutput = `${warning}\n\n${finalOutput}`;
}
}
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
if (finalPwd && finalPwd !== targetDir) {
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
finalOutput = `${warning}\n\n${finalOutput}`;
}
}
const finalToolDisplay: IndividualToolCallDisplay = {
...initialToolDisplay,
status: finalStatus,
resultDisplay: finalOutput,
};
const finalToolDisplay: IndividualToolCallDisplay = {
...initialToolDisplay,
status: finalStatus,
resultDisplay: finalOutput,
};
// Add the complete, contextual result to the local UI history.
// We skip this for cancelled commands because useGeminiStream handles the
// immediate addition of the cancelled item to history to prevent flickering/duplicates.
if (finalStatus !== ToolCallStatus.Canceled) {
addItemToHistory(
{
type: 'tool_group',
tools: [finalToolDisplay],
} as HistoryItemWithoutId,
userMessageTimestamp,
);
}
if (finalStatus !== ToolCallStatus.Canceled) {
addItemToHistory(
{
type: 'tool_group',
tools: [finalToolDisplay],
} as HistoryItemWithoutId,
userMessageTimestamp,
);
}
// Add the same complete, contextual result to the LLM's history.
addShellCommandToGeminiHistory(
geminiClient,
rawQuery,
finalOutput,
);
})
.catch((err) => {
setPendingHistoryItem(null);
const errorMessage =
err instanceof Error ? err.message : String(err);
addItemToHistory(
{
type: 'error',
text: `An unexpected error occurred: ${errorMessage}`,
},
userMessageTimestamp,
);
})
.finally(() => {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
setActiveShellPtyId(null);
setShellInputFocused(false);
resolve();
});
addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
} catch (err) {
// This block handles synchronous errors from `execute`
setPendingHistoryItem(null);
const errorMessage = err instanceof Error ? err.message : String(err);
addItemToHistory(
@@ -339,23 +501,18 @@ export const useShellCommandProcessor = (
},
userMessageTimestamp,
);
// Perform cleanup here as well
} finally {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
setActiveShellPtyId(null);
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
setShellInputFocused(false);
resolve(); // Resolve the promise to unblock `onExec`
}
};
const execPromise = new Promise<void>((resolve) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
executeCommand(resolve);
});
onExec(execPromise);
onExec(executeCommand());
return true;
},
[
@@ -368,8 +525,26 @@ export const useShellCommandProcessor = (
setShellInputFocused,
terminalHeight,
terminalWidth,
registerBackgroundShell,
m,
dispatch,
],
);
return { handleShellCommand, activeShellPtyId, lastShellOutputTime };
const backgroundShellCount = Array.from(
state.backgroundShells.values(),
).filter((s: BackgroundShell) => s.status === 'running').length;
return {
handleShellCommand,
activeShellPtyId: state.activeShellPtyId,
lastShellOutputTime: state.lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible: state.isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells: state.backgroundShells,
};
};
@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
shellReducer,
initialState,
type ShellState,
type ShellAction,
} from './shellReducer.js';
describe('shellReducer', () => {
it('should return the initial state', () => {
// @ts-expect-error - testing default case
expect(shellReducer(initialState, { type: 'UNKNOWN' })).toEqual(
initialState,
);
});
it('should handle SET_ACTIVE_PTY', () => {
const action: ShellAction = { type: 'SET_ACTIVE_PTY', pid: 12345 };
const state = shellReducer(initialState, action);
expect(state.activeShellPtyId).toBe(12345);
});
it('should handle SET_OUTPUT_TIME', () => {
const now = Date.now();
const action: ShellAction = { type: 'SET_OUTPUT_TIME', time: now };
const state = shellReducer(initialState, action);
expect(state.lastShellOutputTime).toBe(now);
});
it('should handle SET_VISIBILITY', () => {
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
const state = shellReducer(initialState, action);
expect(state.isBackgroundShellVisible).toBe(true);
});
it('should handle TOGGLE_VISIBILITY', () => {
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
let state = shellReducer(initialState, action);
expect(state.isBackgroundShellVisible).toBe(true);
state = shellReducer(state, action);
expect(state.isBackgroundShellVisible).toBe(false);
});
it('should handle REGISTER_SHELL', () => {
const action: ShellAction = {
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
expect(state.backgroundShells.has(1001)).toBe(true);
expect(state.backgroundShells.get(1001)).toEqual({
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
});
});
it('should not REGISTER_SHELL if PID already exists', () => {
const action: ShellAction = {
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
const state2 = shellReducer(state, { ...action, command: 'other' });
expect(state2).toBe(state);
expect(state2.backgroundShells.get(1001)?.command).toBe('ls');
});
it('should handle UPDATE_SHELL', () => {
const registeredState = shellReducer(initialState, {
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
});
const action: ShellAction = {
type: 'UPDATE_SHELL',
pid: 1001,
update: { status: 'exited', exitCode: 0 },
};
const state = shellReducer(registeredState, action);
const shell = state.backgroundShells.get(1001);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
// Map should be new
expect(state.backgroundShells).not.toBe(registeredState.backgroundShells);
});
it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => {
const visibleState: ShellState = {
...initialState,
isBackgroundShellVisible: true,
backgroundShells: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = {
type: 'APPEND_SHELL_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(visibleState, action);
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
// Drawer is visible, so we expect a NEW map object to trigger React re-render
expect(state.backgroundShells).not.toBe(visibleState.backgroundShells);
});
it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => {
const hiddenState: ShellState = {
...initialState,
isBackgroundShellVisible: false,
backgroundShells: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = {
type: 'APPEND_SHELL_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(hiddenState, action);
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
expect(state.backgroundShells).toBe(hiddenState.backgroundShells);
});
it('should handle SYNC_BACKGROUND_SHELLS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' };
const state = shellReducer(initialState, action);
expect(state.backgroundShells).not.toBe(initialState.backgroundShells);
});
it('should handle DISMISS_SHELL', () => {
const registeredState: ShellState = {
...initialState,
isBackgroundShellVisible: true,
backgroundShells: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 };
const state = shellReducer(registeredState, action);
expect(state.backgroundShells.has(1001)).toBe(false);
expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnsiOutput } from '@google/gemini-cli-core';
export interface BackgroundShell {
pid: number;
command: string;
output: string | AnsiOutput;
isBinary: boolean;
binaryBytesReceived: number;
status: 'running' | 'exited';
exitCode?: number;
}
export interface ShellState {
activeShellPtyId: number | null;
lastShellOutputTime: number;
backgroundShells: Map<number, BackgroundShell>;
isBackgroundShellVisible: boolean;
}
export type ShellAction =
| { type: 'SET_ACTIVE_PTY'; pid: number | null }
| { type: 'SET_OUTPUT_TIME'; time: number }
| { type: 'SET_VISIBILITY'; visible: boolean }
| { type: 'TOGGLE_VISIBILITY' }
| {
type: 'REGISTER_SHELL';
pid: number;
command: string;
initialOutput: string | AnsiOutput;
}
| { type: 'UPDATE_SHELL'; pid: number; update: Partial<BackgroundShell> }
| { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| { type: 'SYNC_BACKGROUND_SHELLS' }
| { type: 'DISMISS_SHELL'; pid: number };
export const initialState: ShellState = {
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundShells: new Map(),
isBackgroundShellVisible: false,
};
export function shellReducer(
state: ShellState,
action: ShellAction,
): ShellState {
switch (action.type) {
case 'SET_ACTIVE_PTY':
return { ...state, activeShellPtyId: action.pid };
case 'SET_OUTPUT_TIME':
return { ...state, lastShellOutputTime: action.time };
case 'SET_VISIBILITY':
return { ...state, isBackgroundShellVisible: action.visible };
case 'TOGGLE_VISIBILITY':
return {
...state,
isBackgroundShellVisible: !state.isBackgroundShellVisible,
};
case 'REGISTER_SHELL': {
if (state.backgroundShells.has(action.pid)) return state;
const nextShells = new Map(state.backgroundShells);
nextShells.set(action.pid, {
pid: action.pid,
command: action.command,
output: action.initialOutput,
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
});
return { ...state, backgroundShells: nextShells };
}
case 'UPDATE_SHELL': {
const shell = state.backgroundShells.get(action.pid);
if (!shell) return state;
const nextShells = new Map(state.backgroundShells);
const updatedShell = { ...shell, ...action.update };
// Maintain insertion order, move to end if status changed to exited
if (action.update.status === 'exited') {
nextShells.delete(action.pid);
}
nextShells.set(action.pid, updatedShell);
return { ...state, backgroundShells: nextShells };
}
case 'APPEND_SHELL_OUTPUT': {
const shell = state.backgroundShells.get(action.pid);
if (!shell) return state;
// Note: we mutate the shell object in the map for background updates
// to avoid re-rendering if the drawer is not visible.
// This is an intentional performance optimization for the CLI.
let newOutput = shell.output;
if (typeof action.chunk === 'string') {
newOutput =
typeof shell.output === 'string'
? shell.output + action.chunk
: action.chunk;
} else {
newOutput = action.chunk;
}
shell.output = newOutput;
if (state.isBackgroundShellVisible) {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
}
return state;
}
case 'SYNC_BACKGROUND_SHELLS': {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
}
case 'DISMISS_SHELL': {
const nextShells = new Map(state.backgroundShells);
nextShells.delete(action.pid);
return {
...state,
backgroundShells: nextShells,
isBackgroundShellVisible:
nextShells.size === 0 ? false : state.isBackgroundShellVisible,
};
}
default:
return state;
}
}
@@ -213,6 +213,7 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
toggleBackgroundShell: vi.fn(),
setText: vi.fn(),
},
new Map(), // extensionsUpdateState
@@ -82,6 +82,7 @@ interface SlashCommandProcessorActions {
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
toggleBackgroundShell: () => void;
setText: (text: string) => void;
}
@@ -237,6 +238,7 @@ export const useSlashCommandProcessor = (
addConfirmUpdateExtensionRequest:
actions.addConfirmUpdateExtensionRequest,
removeComponent: () => setCustomDialog(null),
toggleBackgroundShell: actions.toggleBackgroundShell,
},
session: {
stats: session.stats,
@@ -30,6 +30,9 @@ vi.mock('@google/gemini-cli-core', async () => {
return {
...actualServerModule,
Config: vi.fn(),
getAdminErrorMessage: vi.fn(
(featureName: string) => `[Mock] ${featureName} is disabled`,
),
};
});
@@ -52,6 +55,9 @@ interface MockConfigInstanceShape {
getUserMemory: Mock<() => string>;
getGeminiMdFileCount: Mock<() => number>;
getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>;
getRemoteAdminSettings: Mock<
() => { strictModeDisabled?: boolean; mcpEnabled?: boolean } | undefined
>;
}
type UseKeypressHandler = (key: Key) => void;
@@ -109,6 +115,9 @@ describe('useApprovalModeIndicator', () => {
.mockReturnValue({ discoverTools: vi.fn() }) as Mock<
() => { discoverTools: Mock<() => void> }
>,
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined) as Mock<
() => { strictModeDisabled?: boolean } | undefined
>,
};
instanceSetApprovalModeMock.mockImplementation((value: ApprovalMode) => {
instanceGetApprovalModeMock.mockReturnValue(value);
@@ -517,6 +526,9 @@ describe('useApprovalModeIndicator', () => {
it('should not enable YOLO mode when Ctrl+Y is pressed and add an info message', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.getRemoteAdminSettings.mockReturnValue({
strictModeDisabled: true,
});
const mockAddItem = vi.fn();
const { result } = renderHook(() =>
useApprovalModeIndicator({
@@ -544,6 +556,58 @@ describe('useApprovalModeIndicator', () => {
// The mode should not change
expect(result.current).toBe(ApprovalMode.DEFAULT);
});
it('should show admin error message when YOLO mode is disabled by admin', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.getRemoteAdminSettings.mockReturnValue({
mcpEnabled: true,
});
const mockAddItem = vi.fn();
renderHook(() =>
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.WARNING,
text: '[Mock] YOLO mode is disabled',
},
expect.any(Number),
);
});
it('should show default error message when admin settings are empty', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.getRemoteAdminSettings.mockReturnValue({});
const mockAddItem = vi.fn();
renderHook(() =>
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.WARNING,
text: 'You cannot enter YOLO mode since it is disabled in your settings.',
},
expect.any(Number),
);
});
});
it('should call onApprovalModeChange when switching to YOLO mode', () => {
@@ -5,7 +5,11 @@
*/
import { useState, useEffect } from 'react';
import { ApprovalMode, type Config } from '@google/gemini-cli-core';
import {
ApprovalMode,
type Config,
getAdminErrorMessage,
} from '@google/gemini-cli-core';
import { useKeypress } from './useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { HistoryItemWithoutId } from '../types.js';
@@ -41,10 +45,19 @@ export function useApprovalModeIndicator({
config.getApprovalMode() !== ApprovalMode.YOLO
) {
if (addItem) {
let text =
'You cannot enter YOLO mode since it is disabled in your settings.';
const adminSettings = config.getRemoteAdminSettings();
const hasSettings =
adminSettings && Object.keys(adminSettings).length > 0;
if (hasSettings && !adminSettings.strictModeDisabled) {
text = getAdminErrorMessage('YOLO mode', config);
}
addItem(
{
type: MessageType.WARNING,
text: 'You cannot enter YOLO mode since it is disabled in your settings.',
text,
},
Date.now(),
);
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
useBackgroundShellManager,
type BackgroundShellManagerProps,
} from './useBackgroundShellManager.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { type BackgroundShell } from './shellReducer.js';
describe('useBackgroundShellManager', () => {
const setEmbeddedShellFocused = vi.fn();
const terminalHeight = 30;
beforeEach(() => {
vi.clearAllMocks();
});
const renderHook = (props: BackgroundShellManagerProps) => {
let hookResult: ReturnType<typeof useBackgroundShellManager>;
function TestComponent({ p }: { p: BackgroundShellManagerProps }) {
hookResult = useBackgroundShellManager(p);
return null;
}
const { rerender } = render(<TestComponent p={props} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: BackgroundShellManagerProps) =>
rerender(<TestComponent p={newProps} />),
};
};
it('should initialize with correct default values', () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result } = renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.isBackgroundShellListOpen).toBe(false);
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.backgroundShellHeight).toBe(0);
});
it('should auto-select the first background shell when added', () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result, rerender } = renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
const newShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
rerender({
backgroundShells: newShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(123);
});
it('should reset state when all shells are removed', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result, rerender } = renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setIsBackgroundShellListOpen(true);
});
expect(result.current.isBackgroundShellListOpen).toBe(true);
rerender({
backgroundShells: new Map(),
backgroundShellCount: 0,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.isBackgroundShellListOpen).toBe(false);
});
it('should unfocus embedded shell when no shells are active', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false, // Background shell not visible
activePtyId: null, // No foreground shell
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('should calculate backgroundShellHeight correctly when visible', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result } = renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight: 100,
});
// 100 * 0.3 = 30
expect(result.current.backgroundShellHeight).toBe(30);
});
it('should maintain current active shell if it still exists', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
[456, {} as BackgroundShell],
]);
const { result, rerender } = renderHook({
backgroundShells,
backgroundShellCount: 2,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setActiveBackgroundShellPid(456);
});
expect(result.current.activeBackgroundShellPid).toBe(456);
// Remove the OTHER shell
const updatedShells = new Map<number, BackgroundShell>([
[456, {} as BackgroundShell],
]);
rerender({
backgroundShells: updatedShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(456);
});
});
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { type BackgroundShell } from './shellCommandProcessor.js';
export interface BackgroundShellManagerProps {
backgroundShells: Map<number, BackgroundShell>;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
activePtyId: number | null | undefined;
embeddedShellFocused: boolean;
setEmbeddedShellFocused: (focused: boolean) => void;
terminalHeight: number;
}
export function useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
}: BackgroundShellManagerProps) {
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
useState(false);
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
number | null
>(null);
useEffect(() => {
if (backgroundShells.size === 0) {
if (activeBackgroundShellPid !== null) {
setActiveBackgroundShellPid(null);
}
if (isBackgroundShellListOpen) {
setIsBackgroundShellListOpen(false);
}
} else if (
activeBackgroundShellPid === null ||
!backgroundShells.has(activeBackgroundShellPid)
) {
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
setActiveBackgroundShellPid(backgroundShells.keys().next().value ?? null);
}
}, [
backgroundShells,
activeBackgroundShellPid,
backgroundShellCount,
isBackgroundShellListOpen,
]);
useEffect(() => {
if (embeddedShellFocused) {
const hasActiveForegroundShell = !!activePtyId;
const hasVisibleBackgroundShell =
isBackgroundShellVisible && backgroundShells.size > 0;
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
setEmbeddedShellFocused(false);
}
}
}, [
isBackgroundShellVisible,
backgroundShells,
embeddedShellFocused,
backgroundShellCount,
activePtyId,
setEmbeddedShellFocused,
]);
const backgroundShellHeight = useMemo(
() =>
isBackgroundShellVisible && backgroundShells.size > 0
? Math.max(Math.floor(terminalHeight * 0.3), 5)
: 0,
[isBackgroundShellVisible, backgroundShells.size, terminalHeight],
);
return {
isBackgroundShellListOpen,
setIsBackgroundShellListOpen,
activeBackgroundShellPid,
setActiveBackgroundShellPid,
backgroundShellHeight,
};
}
@@ -68,6 +68,9 @@ const MockedGeminiClientClass = vi.hoisted(() =>
recordToolCalls: vi.fn(),
getConversationFile: vi.fn(),
});
this.getCurrentSequenceModel = vi
.fn()
.mockReturnValue('gemini-2.0-flash-exp');
}),
);
@@ -652,6 +655,9 @@ describe('useGeminiStream', () => {
expectedMergedResponse,
expect.any(AbortSignal),
'prompt-id-2',
undefined,
false,
expectedMergedResponse,
);
});
@@ -1054,6 +1060,9 @@ describe('useGeminiStream', () => {
toolCallResponseParts,
expect.any(AbortSignal),
'prompt-id-4',
undefined,
false,
toolCallResponseParts,
);
});
@@ -1495,6 +1504,9 @@ describe('useGeminiStream', () => {
'This is the actual prompt from the command file.',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'/my-custom-command',
);
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
@@ -1521,6 +1533,9 @@ describe('useGeminiStream', () => {
'',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'/emptycmd',
);
});
});
@@ -1539,6 +1554,9 @@ describe('useGeminiStream', () => {
'// This is a line comment',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'// This is a line comment',
);
});
});
@@ -1557,6 +1575,9 @@ describe('useGeminiStream', () => {
'/* This is a block comment */',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'/* This is a block comment */',
);
});
});
@@ -2389,6 +2410,9 @@ describe('useGeminiStream', () => {
processedQueryParts, // Argument 1: The parts array directly
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
undefined,
false,
rawQuery,
);
});
@@ -2928,6 +2952,9 @@ describe('useGeminiStream', () => {
'test query',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'test query',
);
});
});
@@ -3075,6 +3102,9 @@ describe('useGeminiStream', () => {
'second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'second query',
);
});
});
+79 -17
View File
@@ -43,6 +43,7 @@ import type {
ServerGeminiStreamEvent as GeminiEvent,
ThoughtSummary,
ToolCallRequestInfo,
ToolCallResponseInfo,
GeminiErrorEventValue,
RetryAttemptPayload,
ToolCallConfirmationDetails,
@@ -72,6 +73,7 @@ import {
type TrackedCompletedToolCall,
type TrackedCancelledToolCall,
type TrackedWaitingToolCall,
type TrackedExecutingToolCall,
} from './useToolScheduler.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
@@ -79,12 +81,34 @@ import { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js';
import type { LoadedSettings } from '../../config/settings.js';
type ToolResponseWithParts = ToolCallResponseInfo & {
llmContent?: PartListUnion;
};
interface ShellToolData {
pid?: number;
command?: string;
initialOutput?: string;
}
enum StreamProcessingStatus {
Completed,
UserCancelled,
Error,
}
function isShellToolData(data: unknown): data is ShellToolData {
if (typeof data !== 'object' || data === null) {
return false;
}
const d = data as Partial<ShellToolData>;
return (
(d.pid === undefined || typeof d.pid === 'number') &&
(d.command === undefined || typeof d.command === 'string') &&
(d.initialOutput === undefined || typeof d.initialOutput === 'string')
);
}
function showCitations(settings: LoadedSettings): boolean {
const enabled = settings.merged.ui.showCitations;
if (enabled !== undefined) {
@@ -401,14 +425,11 @@ export const useGeminiStream = (
}, [toolCalls, pushedToolCallIds, config]);
const activeToolPtyId = useMemo(() => {
const executingShellTool = toolCalls?.find(
const executingShellTool = toolCalls.find(
(tc) =>
tc.status === 'executing' && tc.request.name === 'run_shell_command',
);
if (executingShellTool) {
return (executingShellTool as { pid?: number }).pid;
}
return undefined;
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
}, [toolCalls]);
const lastQueryRef = useRef<PartListUnion | null>(null);
@@ -426,18 +447,30 @@ export const useGeminiStream = (
await done;
setIsResponding(false);
}, []);
const { handleShellCommand, activeShellPtyId, lastShellOutputTime } =
useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
onDebugMessage,
config,
geminiClient,
setShellInputFocused,
terminalWidth,
terminalHeight,
);
const {
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
onDebugMessage,
config,
geminiClient,
setShellInputFocused,
terminalWidth,
terminalHeight,
activeToolPtyId,
);
const activePtyId = activeShellPtyId || activeToolPtyId;
@@ -1222,6 +1255,9 @@ export const useGeminiStream = (
queryToSend,
abortSignal,
prompt_id!,
undefined,
false,
query,
);
const processingStatus = await processGeminiStreamEvents(
stream,
@@ -1404,6 +1440,25 @@ export const useGeminiStream = (
!processedMemoryToolsRef.current.has(t.request.callId),
);
// Handle backgrounded shell tools
completedAndReadyToSubmitTools.forEach((t) => {
const isShell = t.request.name === 'run_shell_command';
// Access result from the tracked tool call response
const response = t.response as ToolResponseWithParts;
const rawData = response?.data;
const data = isShellToolData(rawData) ? rawData : undefined;
// Use data.pid for shell commands moved to the background.
const pid = data?.pid;
if (isShell && pid) {
const command = (data?.['command'] as string) ?? 'shell';
const initialOutput = (data?.['initialOutput'] as string) ?? '';
registerBackgroundShell(pid, command, initialOutput);
}
});
if (newSuccessfulMemorySaves.length > 0) {
// Perform the refresh only if there are new ones.
void performMemoryRefresh();
@@ -1510,6 +1565,7 @@ export const useGeminiStream = (
performMemoryRefresh,
modelSwitchedFromQuotaError,
addItem,
registerBackgroundShell,
],
);
@@ -1599,6 +1655,12 @@ export const useGeminiStream = (
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
};
};
@@ -40,7 +40,6 @@ export type TrackedWaitingToolCall = WaitingToolCall & {
};
export type TrackedExecutingToolCall = ExecutingToolCall & {
responseSubmittedToGemini?: boolean;
pid?: number;
};
export type TrackedCompletedToolCall = CompletedToolCall & {
responseSubmittedToGemini?: boolean;
@@ -134,7 +133,15 @@ export function useReactToolScheduler(
...coreTc,
responseSubmittedToGemini,
liveOutput,
pid: coreTc.pid,
};
} else if (
coreTc.status === 'success' ||
coreTc.status === 'error' ||
coreTc.status === 'cancelled'
) {
return {
...coreTc,
responseSubmittedToGemini,
};
} else {
return {
@@ -178,6 +178,30 @@ describe('convertSessionToHistoryFormats', () => {
});
});
it('should prioritize displayContent for UI history but use content for client history', () => {
const messages: MessageRecord[] = [
{
type: 'user',
content: [{ text: 'Expanded content' }],
displayContent: [{ text: 'User input' }],
} as MessageRecord,
];
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory).toHaveLength(1);
expect(result.uiHistory[0]).toMatchObject({
type: 'user',
text: 'User input',
});
expect(result.clientHistory).toHaveLength(1);
expect(result.clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Expanded content' }],
});
});
it('should filter out slash commands from client history but keep in UI', () => {
const messages: MessageRecord[] = [
{ type: 'user', content: '/help' } as MessageRecord,
+15 -3
View File
@@ -15,6 +15,7 @@ import type {
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { partListUnionToString, coreEvents } from '@google/gemini-cli-core';
import { checkExhaustive } from '../../utils/checks.js';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import { MessageType, ToolCallStatus } from '../types.js';
@@ -125,8 +126,13 @@ export function convertSessionToHistoryFormats(
for (const msg of messages) {
// Add the message only if it has content
const displayContentString = msg.displayContent
? partListUnionToString(msg.displayContent)
: undefined;
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
const uiText = displayContentString || contentString;
if (uiText.trim()) {
let messageType: MessageType;
switch (msg.type) {
case 'user':
@@ -141,14 +147,18 @@ export function convertSessionToHistoryFormats(
case 'warning':
messageType = MessageType.WARNING;
break;
case 'gemini':
messageType = MessageType.GEMINI;
break;
default:
checkExhaustive(msg);
messageType = MessageType.GEMINI;
break;
}
uiHistory.push({
type: messageType,
text: contentString,
text: uiText,
});
}
@@ -199,7 +209,9 @@ export function convertSessionToHistoryFormats(
// Add regular user message
clientHistory.push({
role: 'user',
parts: [{ text: contentString }],
parts: Array.isArray(msg.content)
? (msg.content as Part[])
: [{ text: contentString }],
});
} else if (msg.type === 'gemini') {
// Handle Gemini messages with potential tool calls
+20 -3
View File
@@ -59,8 +59,12 @@ describe('keyMatchers', () => {
},
{
command: Command.MOVE_LEFT,
positive: [createKey('left'), createKey('b', { ctrl: true })],
negative: [createKey('left', { ctrl: true }), createKey('b')],
positive: [createKey('left')],
negative: [
createKey('left', { ctrl: true }),
createKey('b'),
createKey('b', { ctrl: true }),
],
},
{
command: Command.MOVE_RIGHT,
@@ -285,7 +289,10 @@ describe('keyMatchers', () => {
{
command: Command.SHOW_ERROR_DETAILS,
positive: [createKey('f12')],
negative: [createKey('o', { ctrl: true }), createKey('f11')],
negative: [
createKey('o', { ctrl: true }),
createKey('b', { ctrl: true }),
],
},
{
command: Command.SHOW_FULL_TODOS,
@@ -357,6 +364,16 @@ describe('keyMatchers', () => {
positive: [createKey('tab', { shift: true })],
negative: [createKey('tab')],
},
{
command: Command.TOGGLE_BACKGROUND_SHELL,
positive: [createKey('b', { ctrl: true })],
negative: [createKey('f10'), createKey('b')],
},
{
command: Command.TOGGLE_BACKGROUND_SHELL_LIST,
positive: [createKey('l', { ctrl: true })],
negative: [createKey('l')],
},
];
describe('Data-driven key binding matches original logic', () => {
@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DefaultAppLayout } from './DefaultAppLayout.js';
import { StreamingState } from '../types.js';
import { Text } from 'ink';
import type { UIState } from '../contexts/UIStateContext.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
// Mock dependencies
const mockUIState = {
rootUiRef: { current: null },
terminalHeight: 24,
terminalWidth: 80,
mainAreaWidth: 80,
backgroundShells: new Map<number, BackgroundShell>(),
activeBackgroundShellPid: null as number | null,
backgroundShellHeight: 10,
embeddedShellFocused: false,
dialogsVisible: false,
streamingState: StreamingState.Idle,
isBackgroundShellListOpen: false,
mainControlsRef: { current: null },
customDialog: null,
historyManager: { addItem: vi.fn() },
history: [],
pendingHistoryItems: [],
slashCommands: [],
constrainHeight: false,
availableTerminalHeight: 20,
activePtyId: null,
isBackgroundShellVisible: true,
} as unknown as UIState;
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => mockUIState,
}));
vi.mock('../hooks/useFlickerDetector.js', () => ({
useFlickerDetector: vi.fn(),
}));
vi.mock('../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: vi.fn(() => false),
}));
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: () => ({
getAccessibility: vi.fn(() => ({
enableLoadingPhrases: true,
})),
}),
}));
// Mock child components to simplify output
vi.mock('../components/LoadingIndicator.js', () => ({
LoadingIndicator: () => <Text>LoadingIndicator</Text>,
}));
vi.mock('../components/MainContent.js', () => ({
MainContent: () => <Text>MainContent</Text>,
}));
vi.mock('../components/Notifications.js', () => ({
Notifications: () => <Text>Notifications</Text>,
}));
vi.mock('../components/DialogManager.js', () => ({
DialogManager: () => <Text>DialogManager</Text>,
}));
vi.mock('../components/Composer.js', () => ({
Composer: () => <Text>Composer</Text>,
}));
vi.mock('../components/ExitWarning.js', () => ({
ExitWarning: () => <Text>ExitWarning</Text>,
}));
vi.mock('../components/CopyModeWarning.js', () => ({
CopyModeWarning: () => <Text>CopyModeWarning</Text>,
}));
vi.mock('../components/BackgroundShellDisplay.js', () => ({
BackgroundShellDisplay: () => <Text>BackgroundShellDisplay</Text>,
}));
const createMockShell = (pid: number): BackgroundShell => ({
pid,
command: 'test command',
output: 'test output',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
});
describe('<DefaultAppLayout />', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock state defaults
mockUIState.backgroundShells = new Map();
mockUIState.activeBackgroundShellPid = null;
mockUIState.streamingState = StreamingState.Idle;
});
it('renders BackgroundShellDisplay when shells exist and active', () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
const { lastFrame } = render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
});
it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
const { lastFrame } = render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
});
it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
mockUIState.streamingState = StreamingState.Responding;
const { lastFrame } = render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -15,6 +15,8 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { CopyModeWarning } from '../components/CopyModeWarning.js';
import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
import { StreamingState } from '../types.js';
export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
@@ -37,6 +39,24 @@ export const DefaultAppLayout: React.FC = () => {
>
<MainContent />
{uiState.isBackgroundShellVisible &&
uiState.backgroundShells.size > 0 &&
uiState.activeBackgroundShellPid &&
uiState.backgroundShellHeight > 0 &&
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
<Box height={uiState.backgroundShellHeight} flexShrink={0}>
<BackgroundShellDisplay
shells={uiState.backgroundShells}
activePid={uiState.activeBackgroundShellPid}
width={uiState.terminalWidth}
height={uiState.backgroundShellHeight}
isFocused={
uiState.embeddedShellFocused && !uiState.dialogsVisible
}
isListOpenProp={uiState.isBackgroundShellListOpen}
/>
</Box>
)}
<Box
flexDirection="column"
ref={uiState.mainControlsRef}
@@ -0,0 +1,35 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DefaultAppLayout /> > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = `
"MainContent
Notifications
CopyModeWarning
Composer
ExitWarning"
`;
exports[`<DefaultAppLayout /> > renders BackgroundShellDisplay when shells exist and active 1`] = `
"MainContent
BackgroundShellDisplay
Notifications
CopyModeWarning
Composer
ExitWarning"
`;
exports[`<DefaultAppLayout /> > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
"MainContent
BackgroundShellDisplay
Notifications
CopyModeWarning
Composer
ExitWarning"
`;
@@ -29,5 +29,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
addConfirmUpdateExtensionRequest: (_request) => {},
removeComponent: () => {},
toggleBackgroundShell: () => {},
};
}
@@ -1,35 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`TableRenderer > handles empty rows 1`] = `
"
┌──────┬──────┬────────┐
│ Name │ Role │ Status │
├──────┼──────┼────────┤
└──────┴──────┴────────┘
"
`;
exports[`TableRenderer > handles markdown content in cells 1`] = `
"
┌───────┬──────────┬────────┐
│ Name │ Role │ Status │
├───────┼──────────┼────────┤
│ Alice │ Engineer │ Active │
└───────┴──────────┴────────┘
"
`;
exports[`TableRenderer > handles rows with missing cells 1`] = `
"
┌───────┬──────────┬────────┐
│ Name │ Role │ Status │
├───────┼──────────┼────────┤
│ Alice │ Engineer │
│ Bob │
└───────┴──────────┴────────┘
"
`;
exports[`TableRenderer > renders a 3x3 table correctly 1`] = `
"
┌──────────────┬──────────────┬──────────────┐
@@ -42,18 +12,6 @@ exports[`TableRenderer > renders a 3x3 table correctly 1`] = `
"
`;
exports[`TableRenderer > renders a simple table correctly 1`] = `
"
┌─────────┬──────────┬──────────┐
│ Name │ Role │ Status │
├─────────┼──────────┼──────────┤
│ Alice │ Engineer │ Active │
│ Bob │ Designer │ Inactive │
│ Charlie │ Manager │ Active │
└─────────┴──────────┴──────────┘
"
`;
exports[`TableRenderer > renders a table with long headers and 4 columns correctly 1`] = `
"
┌──────────────────┬──────────────────┬───────────────────┬──────────────────┐
@@ -65,25 +23,3 @@ exports[`TableRenderer > renders a table with long headers and 4 columns correct
└──────────────────┴──────────────────┴───────────────────┴──────────────────┘
"
`;
exports[`TableRenderer > truncates content when terminal width is small 1`] = `
"
┌────────┬─────────┬─────────┐
│ Name │ Role │ Status │
├────────┼─────────┼─────────┤
│ Alice │ Engi... │ Active │
│ Bob │ Desi... │ Inac... │
│ Cha... │ Manager │ Active │
└────────┴─────────┴─────────┘
"
`;
exports[`TableRenderer > truncates long markdown content correctly 1`] = `
"
┌───────────────────────────┬─────┬────┐
│ Name │ Rol │ St │
├───────────────────────────┼─────┼────┤
│ Alice with a very long... │ Eng │ Ac │
└───────────────────────────┴─────┴────┘
"
`;
@@ -983,6 +983,9 @@ function toPermissionOptions(
},
...basicPermissionOptions,
];
case 'ask_user':
// askuser doesn't need "always allow" options since it's asking questions
return [...basicPermissionOptions];
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);
+109
View File
@@ -26,6 +26,7 @@ import type { ConfigParameters } from '../config/config.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import { ThinkingLevel } from '@google/genai';
import type { AcknowledgedAgentsService } from './acknowledgedAgents.js';
import { PolicyDecision } from '../policy/types.js';
vi.mock('./agentLoader.js', () => ({
loadAgentsFromDirectory: vi
@@ -657,6 +658,114 @@ describe('AgentRegistry', () => {
await Promise.all(promises);
expect(registry.getAllDefinitions()).toHaveLength(100);
});
it('should dynamically register an ALLOW policy for local agents', async () => {
const agent: AgentDefinition = {
...MOCK_AGENT_V1,
name: 'PolicyTestAgent',
};
const policyEngine = mockConfig.getPolicyEngine();
const addRuleSpy = vi.spyOn(policyEngine, 'addRule');
await registry.testRegisterAgent(agent);
expect(addRuleSpy).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'PolicyTestAgent',
decision: PolicyDecision.ALLOW,
priority: 1.05,
}),
);
});
it('should dynamically register an ASK_USER policy for remote agents', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'RemotePolicyAgent',
description: 'A remote agent',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockResolvedValue({ name: 'RemotePolicyAgent' }),
} as unknown as A2AClientManager);
const policyEngine = mockConfig.getPolicyEngine();
const addRuleSpy = vi.spyOn(policyEngine, 'addRule');
await registry.testRegisterAgent(remoteAgent);
expect(addRuleSpy).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'RemotePolicyAgent',
decision: PolicyDecision.ASK_USER,
priority: 1.05,
}),
);
});
it('should not register a policy if a USER policy already exists', async () => {
const agent: AgentDefinition = {
...MOCK_AGENT_V1,
name: 'ExistingUserPolicyAgent',
};
const policyEngine = mockConfig.getPolicyEngine();
// Mock hasRuleForTool to return true when ignoreDynamic=true (simulating a user policy)
vi.spyOn(policyEngine, 'hasRuleForTool').mockImplementation(
(toolName, ignoreDynamic) =>
toolName === 'ExistingUserPolicyAgent' && ignoreDynamic === true,
);
const addRuleSpy = vi.spyOn(policyEngine, 'addRule');
await registry.testRegisterAgent(agent);
expect(addRuleSpy).not.toHaveBeenCalled();
});
it('should replace an existing dynamic policy when an agent is overwritten', async () => {
const localAgent: AgentDefinition = {
...MOCK_AGENT_V1,
name: 'OverwrittenAgent',
};
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'OverwrittenAgent',
description: 'A remote agent',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockResolvedValue({ name: 'OverwrittenAgent' }),
} as unknown as A2AClientManager);
const policyEngine = mockConfig.getPolicyEngine();
const removeRuleSpy = vi.spyOn(policyEngine, 'removeRulesForTool');
const addRuleSpy = vi.spyOn(policyEngine, 'addRule');
// 1. Register local
await registry.testRegisterAgent(localAgent);
expect(addRuleSpy).toHaveBeenLastCalledWith(
expect.objectContaining({ decision: PolicyDecision.ALLOW }),
);
// 2. Overwrite with remote
await registry.testRegisterAgent(remoteAgent);
// Verify old dynamic rule was removed
expect(removeRuleSpy).toHaveBeenCalledWith(
'OverwrittenAgent',
'AgentRegistry (Dynamic)',
);
// Verify new dynamic rule (remote -> ASK_USER) was added
expect(addRuleSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
toolName: 'OverwrittenAgent',
decision: PolicyDecision.ASK_USER,
}),
);
});
});
describe('reload', () => {
+35
View File
@@ -21,6 +21,7 @@ import {
type ModelConfig,
ModelConfigService,
} from '../services/modelConfigService.js';
import { PolicyDecision } from '../policy/types.js';
/**
* Returns the model config alias for a given agent definition.
@@ -266,6 +267,39 @@ export class AgentRegistry {
this.agents.set(mergedDefinition.name, mergedDefinition);
this.registerModelConfigs(mergedDefinition);
this.addAgentPolicy(mergedDefinition);
}
private addAgentPolicy(definition: AgentDefinition<z.ZodTypeAny>): void {
const policyEngine = this.config.getPolicyEngine();
if (!policyEngine) {
return;
}
// If the user has explicitly defined a policy for this tool, respect it.
// ignoreDynamic=true means we only check for rules NOT added by this registry.
if (policyEngine.hasRuleForTool(definition.name, true)) {
if (this.config.getDebugMode()) {
debugLogger.log(
`[AgentRegistry] User policy exists for '${definition.name}', skipping dynamic registration.`,
);
}
return;
}
// Clean up any old dynamic policy for this tool (e.g. if we are overwriting an agent)
policyEngine.removeRulesForTool(definition.name, 'AgentRegistry (Dynamic)');
// Add the new dynamic policy
policyEngine.addRule({
toolName: definition.name,
decision:
definition.kind === 'local'
? PolicyDecision.ALLOW
: PolicyDecision.ASK_USER,
priority: 1.05,
source: 'AgentRegistry (Dynamic)',
});
}
private isAgentEnabled<TOutput extends z.ZodTypeAny>(
@@ -342,6 +376,7 @@ export class AgentRegistry {
);
}
this.agents.set(definition.name, definition);
this.addAgentPolicy(definition);
} catch (e) {
debugLogger.warn(
`[AgentRegistry] Error loading A2A agent "${definition.name}":`,
@@ -17,8 +17,15 @@ import {
fetchAdminControls,
sanitizeAdminSettings,
stopAdminControlsPolling,
getAdminErrorMessage,
} from './admin_controls.js';
import type { CodeAssistServer } from '../server.js';
import type { Config } from '../../config/config.js';
import { getCodeAssistServer } from '../codeAssist.js';
vi.mock('../codeAssist.js', () => ({
getCodeAssistServer: vi.fn(),
}));
describe('Admin Controls', () => {
let mockServer: CodeAssistServer;
@@ -370,6 +377,57 @@ describe('Admin Controls', () => {
// The poll should not have fired again
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
});
});
describe('getAdminErrorMessage', () => {
let mockConfig: Config;
beforeEach(() => {
mockConfig = {} as Config;
});
it('should include feature name and project ID when present', () => {
vi.mocked(getCodeAssistServer).mockReturnValue({
projectId: 'test-project-123',
} as CodeAssistServer);
const message = getAdminErrorMessage('Code Completion', mockConfig);
expect(message).toBe(
'Code Completion is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli?project=test-project-123',
);
});
it('should include feature name but OMIT project ID when missing', () => {
vi.mocked(getCodeAssistServer).mockReturnValue({
projectId: undefined,
} as CodeAssistServer);
const message = getAdminErrorMessage('Chat', mockConfig);
expect(message).toBe(
'Chat is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
it('should include feature name but OMIT project ID when server is undefined', () => {
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
const message = getAdminErrorMessage('Chat', mockConfig);
expect(message).toBe(
'Chat is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
it('should include feature name but OMIT project ID when config is undefined', () => {
const message = getAdminErrorMessage('Chat', undefined);
expect(message).toBe(
'Chat is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
);
});
});
});
@@ -11,6 +11,8 @@ import {
type FetchAdminControlsResponse,
FetchAdminControlsResponseSchema,
} from '../types.js';
import { getCodeAssistServer } from '../codeAssist.js';
import type { Config } from '../../config/config.js';
let pollingInterval: NodeJS.Timeout | undefined;
let currentSettings: FetchAdminControlsResponse | undefined;
@@ -132,3 +134,20 @@ export function stopAdminControlsPolling() {
pollingInterval = undefined;
}
}
/**
* Returns a standardized error message for features disabled by admin settings.
*
* @param featureName The name of the disabled feature
* @param config The application config
* @returns The formatted error message
*/
export function getAdminErrorMessage(
featureName: string,
config: Config | undefined,
): string {
const server = config ? getCodeAssistServer(config) : undefined;
const projectId = server?.projectId;
const projectParam = projectId ? `?project=${projectId}` : '';
return `${featureName} is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli${projectParam}`;
}
+93 -1
View File
@@ -9,6 +9,7 @@ import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
getOauthClient,
getConsentForOauth,
resetOauthClientForTesting,
clearCachedCredentialFile,
clearOauthClientCache,
@@ -29,8 +30,12 @@ import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js';
import { GEMINI_DIR, homedir as pathsHomedir } from '../utils/paths.js';
import { debugLogger } from '../utils/debugLogger.js';
import { writeToStdout } from '../utils/stdio.js';
import { FatalCancellationError } from '../utils/errors.js';
import {
FatalAuthenticationError,
FatalCancellationError,
} from '../utils/errors.js';
import process from 'node:process';
import { coreEvents } from '../utils/events.js';
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
@@ -88,6 +93,13 @@ const mockConfig = {
global.fetch = vi.fn();
describe('oauth2', () => {
beforeEach(() => {
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1);
vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => {
payload.onConfirm(true);
});
});
describe('with encrypted flag false', () => {
let tempHomeDir: string;
@@ -1503,4 +1515,84 @@ describe('oauth2', () => {
expect(fs.existsSync(credsPath)).toBe(true); // The unencrypted file should remain
});
});
describe('getConsentForOauth', () => {
it('should use coreEvents when listeners are present', async () => {
vi.restoreAllMocks();
const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest');
const mockListenerCount = vi
.spyOn(coreEvents, 'listenerCount')
.mockReturnValue(1);
mockEmitConsentRequest.mockImplementation((payload) => {
payload.onConfirm(true);
});
const result = await getConsentForOauth();
expect(result).toBe(true);
expect(mockEmitConsentRequest).toHaveBeenCalled();
mockListenerCount.mockRestore();
mockEmitConsentRequest.mockRestore();
});
it('should use readline when no listeners are present and stdin is a TTY', async () => {
vi.restoreAllMocks();
const mockListenerCount = vi
.spyOn(coreEvents, 'listenerCount')
.mockReturnValue(0);
const originalIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
const mockReadline = {
on: vi.fn((event, callback) => {
if (event === 'line') {
callback('y');
}
}),
close: vi.fn(),
};
(readline.createInterface as Mock).mockReturnValue(mockReadline);
const result = await getConsentForOauth();
expect(result).toBe(true);
expect(readline.createInterface).toHaveBeenCalled();
expect(writeToStdout).toHaveBeenCalledWith(
expect.stringContaining('Do you want to continue? [Y/n]: '),
);
mockListenerCount.mockRestore();
Object.defineProperty(process.stdin, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
});
it('should throw FatalAuthenticationError when no listeners and not a TTY', async () => {
vi.restoreAllMocks();
const mockListenerCount = vi
.spyOn(coreEvents, 'listenerCount')
.mockReturnValue(0);
const originalIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', {
value: false,
configurable: true,
});
await expect(getConsentForOauth()).rejects.toThrow(
FatalAuthenticationError,
);
mockListenerCount.mockRestore();
Object.defineProperty(process.stdin, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
});
});
});
+52
View File
@@ -269,6 +269,11 @@ async function initOauthClient(
await triggerPostAuthCallbacks(client.credentials);
} else {
const userConsent = await getConsentForOauth();
if (!userConsent) {
throw new FatalCancellationError('Authentication cancelled by user.');
}
const webLogin = await authWithWeb(client);
coreEvents.emit(CoreEvent.UserFeedback, {
@@ -372,6 +377,53 @@ async function initOauthClient(
return client;
}
export async function getConsentForOauth(): Promise<boolean> {
const prompt =
'Code Assist login required. Opening authentication page in your browser. ';
if (coreEvents.listenerCount(CoreEvent.ConsentRequest) === 0) {
if (!process.stdin.isTTY) {
throw new FatalAuthenticationError(
'Code Assist login required, but interactive consent could not be obtained.\n' +
'Please run Gemini CLI in an interactive terminal to authenticate, or use NO_BROWSER=true for manual authentication.',
);
}
return getOauthConsentNonInteractive(prompt);
}
return getOauthConsentInteractive(prompt);
}
async function getOauthConsentNonInteractive(prompt: string) {
const rl = readline.createInterface({
input: process.stdin,
output: createWorkingStdio().stdout,
terminal: true,
});
const fullPrompt = prompt + 'Do you want to continue? [Y/n]: ';
writeToStdout(`\n${fullPrompt}`);
return new Promise<boolean>((resolve) => {
rl.on('line', (answer) => {
rl.close();
resolve(['y', ''].includes(answer.trim().toLowerCase()));
});
});
}
async function getOauthConsentInteractive(prompt: string) {
const fullPrompt = prompt + '\n\nDo you want to continue?';
return new Promise<boolean>((resolve) => {
coreEvents.emitConsentRequest({
prompt: fullPrompt,
onConfirm: (confirmed: boolean) => {
resolve(confirmed);
},
});
});
}
export async function getOauthClient(
authType: AuthType,
config: Config,
@@ -327,6 +327,22 @@ describe('CodeAssistServer', () => {
const url = server.getMethodUrl('testMethod');
expect(url).toBe('https://custom-endpoint.com/v1internal:testMethod');
});
it('should use the CODE_ASSIST_API_VERSION environment variable if set', () => {
process.env['CODE_ASSIST_API_VERSION'] = 'v2beta';
const server = new CodeAssistServer({} as never);
const url = server.getMethodUrl('testMethod');
expect(url).toBe('https://cloudcode-pa.googleapis.com/v2beta:testMethod');
});
it('should use default value if CODE_ASSIST_API_VERSION env var is empty', () => {
process.env['CODE_ASSIST_API_VERSION'] = '';
const server = new CodeAssistServer({} as never);
const url = server.getMethodUrl('testMethod');
expect(url).toBe(
'https://cloudcode-pa.googleapis.com/v1internal:testMethod',
);
});
});
it('should call the generateContentStream endpoint and parse SSE', async () => {
+3 -1
View File
@@ -374,7 +374,9 @@ export class CodeAssistServer implements ContentGenerator {
private getBaseUrl(): string {
const endpoint =
process.env['CODE_ASSIST_ENDPOINT'] ?? CODE_ASSIST_ENDPOINT;
return `${endpoint}/${CODE_ASSIST_API_VERSION}`;
const version =
process.env['CODE_ASSIST_API_VERSION'] || CODE_ASSIST_API_VERSION;
return `${endpoint}/${version}`;
}
getMethodUrl(method: string): string {
+11 -1
View File
@@ -65,7 +65,12 @@ export interface ToolConfirmationResponse {
* Data-only versions of ToolCallConfirmationDetails for bus transmission.
*/
export type SerializableConfirmationDetails =
| { type: 'info'; title: string; prompt: string; urls?: string[] }
| {
type: 'info';
title: string;
prompt: string;
urls?: string[];
}
| {
type: 'edit';
title: string;
@@ -90,6 +95,11 @@ export type SerializableConfirmationDetails =
serverName: string;
toolName: string;
toolDisplayName: string;
}
| {
type: 'ask_user';
title: string;
questions: Question[];
};
export interface UpdatePolicy {
+12
View File
@@ -890,6 +890,7 @@ ${JSON.stringify(
{ model: 'default-routed-model' },
initialRequest,
expect.any(AbortSignal),
undefined,
);
});
@@ -1707,6 +1708,7 @@ ${JSON.stringify(
{ model: 'routed-model' },
[{ text: 'Hi' }],
expect.any(AbortSignal),
undefined,
);
});
@@ -1724,6 +1726,7 @@ ${JSON.stringify(
{ model: 'routed-model' },
[{ text: 'Hi' }],
expect.any(AbortSignal),
undefined,
);
// Second turn
@@ -1741,6 +1744,7 @@ ${JSON.stringify(
{ model: 'routed-model' },
[{ text: 'Continue' }],
expect.any(AbortSignal),
undefined,
);
});
@@ -1758,6 +1762,7 @@ ${JSON.stringify(
{ model: 'routed-model' },
[{ text: 'Hi' }],
expect.any(AbortSignal),
undefined,
);
// New prompt
@@ -1779,6 +1784,7 @@ ${JSON.stringify(
{ model: 'new-routed-model' },
[{ text: 'A new topic' }],
expect.any(AbortSignal),
undefined,
);
});
@@ -1806,6 +1812,7 @@ ${JSON.stringify(
{ model: 'original-model' },
[{ text: 'Hi' }],
expect.any(AbortSignal),
undefined,
);
mockRouterService.route.mockResolvedValue({
@@ -1828,6 +1835,7 @@ ${JSON.stringify(
{ model: 'fallback-model' },
[{ text: 'Continue' }],
expect.any(AbortSignal),
undefined,
);
});
});
@@ -1912,6 +1920,7 @@ ${JSON.stringify(
{ model: 'default-routed-model' },
initialRequest,
expect.any(AbortSignal),
undefined,
);
// Second call with "Please continue."
@@ -1920,6 +1929,7 @@ ${JSON.stringify(
{ model: 'default-routed-model' },
[{ text: 'System: Please continue.' }],
expect.any(AbortSignal),
undefined,
);
});
@@ -2332,6 +2342,7 @@ ${JSON.stringify(
expect.objectContaining({ model: 'model-a' }),
expect.anything(),
expect.anything(),
undefined,
);
});
@@ -3183,6 +3194,7 @@ ${JSON.stringify(
expect.anything(),
[{ text: 'Please explain' }],
expect.anything(),
undefined,
);
});
+14 -2
View File
@@ -532,6 +532,7 @@ export class GeminiClient {
prompt_id: string,
boundedTurns: number,
isInvalidStreamRetry: boolean,
displayContent?: PartListUnion,
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
// Re-initialize turn (it was empty before if in loop, or new instance)
let turn = new Turn(this.getChat(), prompt_id);
@@ -647,7 +648,12 @@ export class GeminiClient {
yield { type: GeminiEventType.ModelInfo, value: modelToUse };
}
this.currentSequenceModel = modelToUse;
const resultStream = turn.run(modelConfigKey, request, linkedSignal);
const resultStream = turn.run(
modelConfigKey,
request,
linkedSignal,
displayContent,
);
let isError = false;
let isInvalidStream = false;
@@ -708,6 +714,7 @@ export class GeminiClient {
prompt_id,
boundedTurns - 1,
true,
displayContent,
);
return turn;
}
@@ -739,7 +746,8 @@ export class GeminiClient {
signal,
prompt_id,
boundedTurns - 1,
// isInvalidStreamRetry is false
false, // isInvalidStreamRetry is false
displayContent,
);
return turn;
}
@@ -754,6 +762,7 @@ export class GeminiClient {
prompt_id: string,
turns: number = MAX_TURNS,
isInvalidStreamRetry: boolean = false,
displayContent?: PartListUnion,
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
if (!isInvalidStreamRetry) {
this.config.resetTurn();
@@ -809,6 +818,7 @@ export class GeminiClient {
prompt_id,
boundedTurns,
isInvalidStreamRetry,
displayContent,
);
// Fire AfterAgent hook if we have a turn and no pending tools
@@ -860,6 +870,8 @@ export class GeminiClient {
signal,
prompt_id,
boundedTurns - 1,
false,
displayContent,
);
}
}
@@ -1847,6 +1847,83 @@ describe('CoreToolScheduler Sequential Execution', () => {
modifyWithEditorSpy.mockRestore();
});
it('should handle inline modify with empty new content', async () => {
// Mock the modifiable check to return true for this test
const isModifiableSpy = vi
.spyOn(modifiableToolModule, 'isModifiableDeclarativeTool')
.mockReturnValue(true);
const mockTool = new MockModifiableTool();
const mockToolRegistry = {
getTool: () => mockTool,
getAllToolNames: () => [],
} as unknown as ToolRegistry;
const mockConfig = createMockConfig({
getToolRegistry: () => mockToolRegistry,
isInteractive: () => true,
});
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
getPreferredEditor: () => 'vscode',
});
// Manually inject a waiting tool call
const callId = 'call-1';
const toolCall: WaitingToolCall = {
status: 'awaiting_approval',
request: {
callId,
name: 'mockModifiableTool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: mockTool,
invocation: {} as unknown as ToolInvocation<
Record<string, unknown>,
ToolResult
>,
confirmationDetails: {
type: 'edit',
title: 'Confirm',
fileName: 'test.txt',
filePath: 'test.txt',
fileDiff: 'diff',
originalContent: 'old',
newContent: 'new',
onConfirm: async () => {},
},
startTime: Date.now(),
};
const schedulerInternals = scheduler as unknown as {
toolCalls: ToolCall[];
toolModifier: { applyInlineModify: Mock };
};
schedulerInternals.toolCalls = [toolCall];
const applyInlineModifySpy = vi
.spyOn(schedulerInternals.toolModifier, 'applyInlineModify')
.mockResolvedValue({
updatedParams: { content: '' },
updatedDiff: 'diff-empty',
});
await scheduler.handleConfirmationResponse(
callId,
async () => {},
ToolConfirmationOutcome.ProceedOnce,
new AbortController().signal,
{ newContent: '' } as ToolConfirmationPayload,
);
expect(applyInlineModifySpy).toHaveBeenCalled();
isModifiableSpy.mockRestore();
});
it('should pass serverName to policy engine for DiscoveredMCPTool', async () => {
const mockMcpTool = {
tool: async () => ({ functionDeclarations: [] }),
+1 -1
View File
@@ -789,7 +789,7 @@ export class CoreToolScheduler {
} else {
// If the client provided new content, apply it and wait for
// re-confirmation.
if (payload?.newContent && toolCall) {
if (payload && 'newContent' in payload && toolCall) {
const result = await this.toolModifier.applyInlineModify(
toolCall as WaitingToolCall,
payload,
+18 -3
View File
@@ -268,6 +268,7 @@ export class GeminiChat {
* @param message - The list of messages to send.
* @param prompt_id - The ID of the prompt.
* @param signal - An abort signal for this message.
* @param displayContent - An optional user-friendly version of the message to record.
* @return The model's response.
*
* @example
@@ -286,6 +287,7 @@ export class GeminiChat {
message: PartListUnion,
prompt_id: string,
signal: AbortSignal,
displayContent?: PartListUnion,
): Promise<AsyncGenerator<StreamEvent>> {
await this.sendPromise;
@@ -302,12 +304,25 @@ export class GeminiChat {
// Record user input - capture complete message with all parts (text, files, images, etc.)
// but skip recording function responses (tool call results) as they should be stored in tool call records
if (!isFunctionResponse(userContent)) {
const userMessage = Array.isArray(message) ? message : [message];
const userMessageContent = partListUnionToString(toParts(userMessage));
const userMessageParts = userContent.parts || [];
const userMessageContent = partListUnionToString(userMessageParts);
let finalDisplayContent: Part[] | undefined = undefined;
if (displayContent !== undefined) {
const displayParts = toParts(
Array.isArray(displayContent) ? displayContent : [displayContent],
);
const displayContentString = partListUnionToString(displayParts);
if (displayContentString !== userMessageContent) {
finalDisplayContent = displayParts;
}
}
this.chatRecordingService.recordMessage({
model,
type: 'user',
content: userMessageContent,
content: userMessageParts,
displayContent: finalDisplayContent,
});
}

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