mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39feeecfe1 | |||
| bfc61e0eb4 | |||
| 635ed2f62a | |||
| 92516fe370 | |||
| 054205696f | |||
| fc7db9fd19 | |||
| 9dfada129b | |||
| 1a38f92c69 | |||
| 52863f5a8c | |||
| 381669dce0 | |||
| 707b3e85d5 | |||
| 7d36cc004f | |||
| cb4f0c6fa4 | |||
| b0f38104d7 | |||
| 7469ea0fca | |||
| 00fdb30211 | |||
| 0fe8492569 | |||
| 76387d22ae | |||
| a362b7b382 | |||
| 71308caf05 | |||
| 6396ab1ccb | |||
| 12531a06f8 | |||
| d1cd69a20d | |||
| 156d0347ee | |||
| 62346875e4 | |||
| 13e013230b | |||
| bb6a336ca9 | |||
| f14d0c6a17 | |||
| b611f9a519 | |||
| d3bca5d97a | |||
| 94f4e027f8 | |||
| 95b7d69d5b | |||
| f605628624 | |||
| 2238802e97 |
@@ -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()'
|
||||
|
||||
@@ -43,19 +43,23 @@ jobs:
|
||||
|
||||
// 1. Fetch maintainers for verification
|
||||
let maintainerLogins = new Set();
|
||||
let teamFetchSucceeded = false;
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug: 'gemini-cli-maintainers'
|
||||
});
|
||||
maintainerLogins = new Set(members.map(m => m.login));
|
||||
maintainerLogins = new Set(members.map(m => m.login.toLowerCase()));
|
||||
teamFetchSucceeded = true;
|
||||
core.info(`Successfully fetched ${maintainerLogins.size} team members from gemini-cli-maintainers`);
|
||||
} catch (e) {
|
||||
core.warning('Failed to fetch team members');
|
||||
core.warning(`Failed to fetch team members from gemini-cli-maintainers: ${e.message}. Falling back to author_association only.`);
|
||||
}
|
||||
|
||||
const isMaintainer = (login, assoc) => {
|
||||
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
|
||||
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
return isTeamMember || isRepoMaintainer;
|
||||
};
|
||||
|
||||
// 2. Determine which PRs to check
|
||||
@@ -153,7 +157,17 @@ jobs:
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
|
||||
|
||||
let lastActivity = new Date(0);
|
||||
// Skip PRs that were created less than 30 days ago - they cannot be stale yet
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
if (prCreatedAt > thirtyDaysAgo) {
|
||||
const daysOld = Math.floor((Date.now() - prCreatedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
core.info(`PR #${pr.number} was created ${daysOld} days ago. Skipping staleness check.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize lastActivity to PR creation date (not epoch) as a safety baseline.
|
||||
// This ensures we never incorrectly mark a PR as stale due to failed activity lookups.
|
||||
let lastActivity = new Date(pr.created_at);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
@@ -177,8 +191,12 @@ jobs:
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch reviews/comments for PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
// For maintainer PRs, the PR creation itself counts as maintainer activity.
|
||||
// (Now redundant since we initialize to pr.created_at, but kept for clarity)
|
||||
if (maintainerPr) {
|
||||
const d = new Date(pr.created_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -97,7 +97,6 @@ they appear in the UI.
|
||||
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
@@ -107,13 +106,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
|
||||
|
||||
|
||||
+3
-3
@@ -15,7 +15,7 @@ as security auditing, cloud deployments, or codebase migrations—without
|
||||
cluttering the model's immediate context window.
|
||||
|
||||
Gemini autonomously decides when to employ a skill based on your request and the
|
||||
skill's description. When a relevant skill is identified, the model "pulls in"
|
||||
skill's description. When a relevant skill is identifieddd, the model "pulls in"
|
||||
the full instructions and resources required to complete the task using the
|
||||
`activate_skill` tool.
|
||||
|
||||
@@ -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](./creating-skills.md)
|
||||
guide.
|
||||
To create your own skills, see see the
|
||||
[Create Agent Skills](./creating-skills.md) guide.
|
||||
|
||||
@@ -264,7 +264,7 @@ primary ways of releasing extensions are via a Git repository or through GitHub
|
||||
Releases. Using a public Git repository is the simplest method.
|
||||
|
||||
For detailed instructions on both methods, please refer to the
|
||||
[Extension Releasing Guide](./releasing.md).
|
||||
[Extension Releasing Guide](./broken-link/releasing.md).
|
||||
|
||||
## Conclusion
|
||||
|
||||
|
||||
@@ -215,14 +215,6 @@ a few things you can try in order of recommendation:
|
||||
MCP servers of their own. This should not be used as an airtight security
|
||||
mechanism.
|
||||
|
||||
- **`autoAccept`** (boolean):
|
||||
- **Description:** Controls whether the CLI automatically accepts and executes
|
||||
tool calls that are considered safe (e.g., read-only operations) without
|
||||
explicit user confirmation. If set to `true`, the CLI will bypass the
|
||||
confirmation prompt for tools deemed safe.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"autoAccept": true`
|
||||
|
||||
- **`theme`** (string):
|
||||
- **Description:** Sets the visual [theme](../cli/themes.md) for Gemini CLI.
|
||||
- **Default:** `"Default"`
|
||||
|
||||
@@ -661,11 +661,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.autoAccept`** (boolean):
|
||||
- **Description:** Automatically accept and execute tool calls that are
|
||||
considered safe (e.g., read-only operations).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`tools.approvalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
@@ -733,12 +728,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 +768,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`
|
||||
|
||||
@@ -564,11 +564,17 @@ describe('run_shell_command', () => {
|
||||
|
||||
it('rejects invalid shell expressions', async () => {
|
||||
await rig.setup('rejects invalid shell expressions', {
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
settings: {
|
||||
tools: {
|
||||
core: ['run_shell_command'],
|
||||
allowed: ['run_shell_command(echo)'], // Specifically allow echo
|
||||
},
|
||||
},
|
||||
});
|
||||
const invalidCommand = getInvalidCommand();
|
||||
const result = await rig.run({
|
||||
args: `I am testing the error handling of the run_shell_command tool. Please attempt to run the following command, which I know has invalid syntax: \`${invalidCommand}\`. If the command fails as expected, please return the word FAIL, otherwise return the word SUCCESS.`,
|
||||
approvalMode: 'default', // Use default mode so safety fallback triggers confirmation
|
||||
});
|
||||
expect(result).toContain('FAIL');
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -164,7 +164,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should handle complex mixed configurations', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Allows read-only tools
|
||||
allowed: ['custom-tool', 'my-server__special-tool'],
|
||||
exclude: ['glob', 'dangerous-tool'],
|
||||
},
|
||||
@@ -438,7 +437,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify priority ordering works correctly in practice', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Priority 50
|
||||
allowed: ['specific-tool'], // Priority 100
|
||||
exclude: ['blocked-tool'], // Priority 200
|
||||
},
|
||||
@@ -614,7 +612,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify rules are created with correct priorities', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true,
|
||||
allowed: ['tool1', 'tool2'],
|
||||
exclude: ['tool3'],
|
||||
},
|
||||
|
||||
@@ -124,7 +124,6 @@ describe('settings-validation', () => {
|
||||
},
|
||||
tools: {
|
||||
sandbox: 'inherit',
|
||||
autoAccept: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1041,17 +1041,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
autoAccept: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Accept',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: oneLine`
|
||||
Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
@@ -1179,16 +1168,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 +1257,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',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -368,7 +371,9 @@ describe('AppContainer State Management', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
// Add other properties if AppContainer uses them
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
mockedUseLogger.mockReturnValue({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
@@ -1193,12 +1198,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 +1236,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 +1305,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 +1343,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 +1380,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 +1434,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 +1493,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 +1505,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 +1563,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 +1614,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 +1637,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 +1699,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 +1740,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 +1780,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 +1883,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',
|
||||
});
|
||||
|
||||
@@ -1952,7 +1902,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => void;
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let mockHandleSlashCommand: Mock;
|
||||
let mockCancelOngoingRequest: Mock;
|
||||
let rerender: () => void;
|
||||
@@ -1987,9 +1937,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the keypress handler from the AppContainer
|
||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
||||
handleGlobalKeypress = callback;
|
||||
});
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock slash command handler
|
||||
mockHandleSlashCommand = vi.fn();
|
||||
@@ -2005,11 +1957,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,
|
||||
});
|
||||
|
||||
@@ -2017,6 +1965,9 @@ describe('AppContainer State Management', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
@@ -2030,11 +1981,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();
|
||||
@@ -2079,19 +2027,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('CTRL+D', () => {
|
||||
it('should do nothing if text buffer is not empty', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'd', ctrl: true }, 2);
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should quit on second press if buffer is empty', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
@@ -2106,6 +2041,50 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
lines: ['some text'],
|
||||
cursor: [0, 9], // At the end
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
// Capture return value
|
||||
let result = true;
|
||||
const originalPressKey = (key: Partial<Key>) => {
|
||||
act(() => {
|
||||
result = handleGlobalKeypress({
|
||||
name: 'd',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
});
|
||||
rerender();
|
||||
};
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
|
||||
// AppContainer's handler should return true if it reaches it
|
||||
expect(result).toBe(true);
|
||||
// But it should only be called once, so count is 1, not quitting yet.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
// Now count is 2, it should quit.
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
@@ -2125,7 +2104,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => void;
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
|
||||
@@ -2155,9 +2134,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
||||
handleGlobalKeypress = callback;
|
||||
});
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -2510,6 +2491,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 +2555,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 +2705,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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -579,6 +532,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
});
|
||||
const bufferRef = useRef(buffer);
|
||||
useEffect(() => {
|
||||
bufferRef.current = buffer;
|
||||
}, [buffer]);
|
||||
|
||||
const stableSetText = useCallback((text: string) => {
|
||||
bufferRef.current.setText(text);
|
||||
}, []);
|
||||
|
||||
// Initialize input history from logger (past sessions)
|
||||
useEffect(() => {
|
||||
@@ -835,6 +796,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,7 +823,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
setText: (text: string) => buffer.setText(text),
|
||||
toggleBackgroundShell: () => {
|
||||
toggleBackgroundShellRef.current();
|
||||
if (!isBackgroundShellVisibleRef.current) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShellsRef.current.size > 1) {
|
||||
setIsBackgroundShellListOpenRef.current(true);
|
||||
} else {
|
||||
setIsBackgroundShellListOpenRef.current(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
setText: stableSetText,
|
||||
}),
|
||||
[
|
||||
setAuthState,
|
||||
@@ -876,7 +852,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openPermissionsDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleDebugProfiler,
|
||||
buffer,
|
||||
stableSetText,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -885,7 +861,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
confirmationRequest,
|
||||
confirmationRequest: commandConfirmationRequest,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
@@ -902,6 +878,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 +985,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 +1013,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 +1185,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({
|
||||
@@ -1384,7 +1413,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (ctrlCPressCount > 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/quit', undefined, undefined, false);
|
||||
} else {
|
||||
} else if (ctrlCPressCount > 0) {
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
setCtrlCPressCount(0);
|
||||
ctrlCTimerRef.current = null;
|
||||
@@ -1403,7 +1432,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (ctrlDPressCount > 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/quit', undefined, undefined, false);
|
||||
} else {
|
||||
} else if (ctrlDPressCount > 0) {
|
||||
ctrlDTimerRef.current = setTimeout(() => {
|
||||
setCtrlDPressCount(0);
|
||||
ctrlDTimerRef.current = null;
|
||||
@@ -1444,7 +1473,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
});
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
(key: Key): boolean => {
|
||||
if (copyModeEnabled) {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
@@ -1464,10 +1493,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?.();
|
||||
@@ -1475,9 +1500,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCtrlCPressCount((prev) => prev + 1);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.EXIT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
return false;
|
||||
}
|
||||
setCtrlDPressCount((prev) => prev + 1);
|
||||
return true;
|
||||
}
|
||||
@@ -1520,9 +1542,8 @@ 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))
|
||||
) {
|
||||
if (key.name === 'tab' && key.shift) {
|
||||
// Always change focus
|
||||
@@ -1530,26 +1551,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;
|
||||
@@ -1561,11 +1628,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
ideContextState,
|
||||
setCtrlCPressCount,
|
||||
buffer.text.length,
|
||||
setCtrlDPressCount,
|
||||
handleSlashCommand,
|
||||
cancelOngoingRequest,
|
||||
askUserRequest,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
@@ -1573,6 +1638,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCopyModeEnabled,
|
||||
copyModeEnabled,
|
||||
isAlternateBuffer,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
backgroundShells,
|
||||
isBackgroundShellVisible,
|
||||
setIsBackgroundShellListOpen,
|
||||
lastOutputTimeRef,
|
||||
tabFocusTimeoutRef,
|
||||
handleWarning,
|
||||
],
|
||||
);
|
||||
@@ -1586,7 +1658,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 +1675,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, [
|
||||
streamingState,
|
||||
thought,
|
||||
confirmationRequest,
|
||||
commandConfirmationRequest,
|
||||
shouldShowActionRequiredTitle,
|
||||
shouldShowSilentWorkingTitle,
|
||||
settings.merged.ui.showStatusInTitle,
|
||||
@@ -1678,11 +1751,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 +1865,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
confirmationRequest,
|
||||
commandConfirmationRequest,
|
||||
authConsentRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
@@ -1853,6 +1927,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isRestarting,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
@@ -1862,6 +1938,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
backgroundShells,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
}),
|
||||
@@ -1890,7 +1970,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
confirmationRequest,
|
||||
commandConfirmationRequest,
|
||||
authConsentRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
@@ -1951,6 +2032,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
historyManager,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
@@ -1963,6 +2046,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
config,
|
||||
settingsNonce,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShells,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
],
|
||||
@@ -2010,7 +2097,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 +2173,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
setAuthContext,
|
||||
newAgents,
|
||||
config,
|
||||
@@ -2113,15 +2208,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),
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface CommandContext {
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
// Helper to write to stdin with proper act() wrapping
|
||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
@@ -41,6 +42,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -105,6 +107,7 @@ describe('AskUserDialog', () => {
|
||||
questions={questions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -124,6 +127,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -153,12 +157,57 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ useAlternateBuffer: true, expectedArrows: false },
|
||||
{ useAlternateBuffer: false, expectedArrows: true },
|
||||
])(
|
||||
'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)',
|
||||
({ useAlternateBuffer, expectedArrows }) => {
|
||||
it(`shows scroll arrows correctly when useAlternateBuffer is ${useAlternateBuffer}`, 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
|
||||
/>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
if (expectedArrows) {
|
||||
expect(lastFrame()).toContain('▲');
|
||||
expect(lastFrame()).toContain('▼');
|
||||
} else {
|
||||
expect(lastFrame()).not.toContain('▲');
|
||||
expect(lastFrame()).not.toContain('▼');
|
||||
}
|
||||
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}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -209,6 +258,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -222,6 +272,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -235,6 +286,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -265,6 +317,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -306,6 +359,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -373,6 +427,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -401,6 +456,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -445,6 +501,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -480,6 +537,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -512,6 +570,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -533,6 +592,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -554,6 +614,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -588,6 +649,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -618,6 +680,7 @@ describe('AskUserDialog', () => {
|
||||
questions={mixedQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -664,6 +727,7 @@ describe('AskUserDialog', () => {
|
||||
questions={mixedQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -713,6 +777,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -738,6 +803,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -783,6 +849,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -841,6 +908,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -872,4 +940,72 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Choose an option',
|
||||
header: 'Context Test',
|
||||
options: Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i + 1}`,
|
||||
description: `Description ${i + 1}`,
|
||||
})),
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUIState = {
|
||||
availableTerminalHeight: 5, // Small height to force scroll arrows
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<UIStateContext.Provider value={mockUIState}>
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
/>
|
||||
</UIStateContext.Provider>,
|
||||
{ useAlternateBuffer: false },
|
||||
);
|
||||
|
||||
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
|
||||
expect(lastFrame()).toContain('▲');
|
||||
expect(lastFrame()).toContain('▼');
|
||||
});
|
||||
|
||||
it('does NOT truncate the question when in alternate buffer mode even with small height', () => {
|
||||
const longQuestion =
|
||||
'This is a very long question ' + 'with many words '.repeat(10);
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: longQuestion,
|
||||
header: 'Alternate Buffer Test',
|
||||
options: [{ label: 'Option 1', description: 'Desc 1' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUIState = {
|
||||
availableTerminalHeight: 5,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<UIStateContext.Provider value={mockUIState}>
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={40} // Small width to force wrapping
|
||||
/>
|
||||
</UIStateContext.Provider>,
|
||||
{ useAlternateBuffer: true },
|
||||
);
|
||||
|
||||
// Should NOT contain the truncation message
|
||||
expect(lastFrame()).not.toContain('hidden ...');
|
||||
// Should contain the full long question (or at least its parts)
|
||||
expect(lastFrame()).toContain('This is a very long question');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,10 +24,12 @@ 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';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface AskUserDialogState {
|
||||
answers: { [key: string]: string };
|
||||
@@ -121,6 +123,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 +162,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 +179,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 +208,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 +220,14 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
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 +253,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 +285,28 @@ 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 =
|
||||
availableHeight && !isAlternateBuffer
|
||||
? Math.max(1, availableHeight - overhead)
|
||||
: undefined;
|
||||
|
||||
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}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
@@ -381,6 +406,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 +417,13 @@ 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 isAlternateBuffer = useAlternateBuffer();
|
||||
const numOptions =
|
||||
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
|
||||
const numLen = String(numOptions).length;
|
||||
@@ -407,15 +432,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 +563,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 +608,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
|
||||
[
|
||||
isCustomOptionFocused,
|
||||
customBuffer,
|
||||
onEditingCustomOption,
|
||||
customOptionText,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
@@ -698,31 +725,50 @@ 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 = availableHeight
|
||||
? Math.max(1, availableHeight - overhead)
|
||||
: undefined;
|
||||
const questionHeight =
|
||||
listHeight && !isAlternateBuffer
|
||||
? Math.min(15, Math.max(1, listHeight - 4))
|
||||
: undefined;
|
||||
const maxItemsToShow =
|
||||
listHeight && questionHeight
|
||||
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
|
||||
: selectionItems.length;
|
||||
|
||||
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}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<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 +850,19 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onActiveTextInputChange,
|
||||
width,
|
||||
availableHeight: availableHeightProp,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const availableHeight =
|
||||
availableHeightProp ??
|
||||
(uiState?.constrainHeight !== false
|
||||
? uiState?.availableTerminalHeight
|
||||
: undefined);
|
||||
|
||||
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 +893,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 +1075,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 +1088,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 +1099,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', () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -45,6 +45,8 @@ import { StreamingState } from '../types.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
|
||||
vi.mock('../hooks/useShellHistory.js');
|
||||
vi.mock('../hooks/useCommandCompletion.js');
|
||||
@@ -169,7 +171,16 @@ describe('InputPrompt', () => {
|
||||
allVisualLines: [''],
|
||||
visualCursor: [0, 0],
|
||||
visualScrollRow: 0,
|
||||
handleInput: vi.fn(),
|
||||
handleInput: vi.fn((key: Key) => {
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (mockBuffer.text.length > 0) {
|
||||
mockBuffer.setText('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
move: vi.fn(),
|
||||
moveToOffset: vi.fn((offset: number) => {
|
||||
mockBuffer.cursor = [0, offset];
|
||||
@@ -499,6 +510,23 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear the buffer and reset completion on Ctrl+C', async () => {
|
||||
mockBuffer.text = 'some text';
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u0003'); // Ctrl+C
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith('');
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('clipboard image paste', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
|
||||
@@ -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);
|
||||
@@ -476,6 +482,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
key.name === 'escape' &&
|
||||
(streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.name === 'paste') {
|
||||
// Record paste time to prevent accidental auto-submission
|
||||
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
||||
@@ -598,6 +612,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||
setBannerVisible(false);
|
||||
onClearScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setReverseSearchActive(true);
|
||||
setTextBeforeReverseSearch(buffer.text);
|
||||
@@ -605,12 +625,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||
setBannerVisible(false);
|
||||
onClearScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (reverseSearchActive || commandSearchActive) {
|
||||
const isCommandSearch = commandSearchActive;
|
||||
|
||||
@@ -875,14 +889,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.move('end');
|
||||
return true;
|
||||
}
|
||||
// Ctrl+C (Clear input)
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Kill line commands
|
||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||
@@ -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;
|
||||
@@ -924,17 +933,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
const handled = buffer.handleInput(key);
|
||||
|
||||
// Clear ghost text when user types regular characters (not navigation/control keys)
|
||||
if (
|
||||
completion.promptCompletion.text &&
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.alt &&
|
||||
!key.ctrl &&
|
||||
!key.cmd
|
||||
) {
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
if (handled) {
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
resetCompletionState();
|
||||
}
|
||||
|
||||
// Clear ghost text when user types regular characters (not navigation/control keys)
|
||||
if (
|
||||
completion.promptCompletion.text &&
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.alt &&
|
||||
!key.ctrl &&
|
||||
!key.cmd
|
||||
) {
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
}
|
||||
}
|
||||
return handled;
|
||||
},
|
||||
@@ -967,11 +982,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
onSubmit,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
backgroundShells.size,
|
||||
backgroundShellHeight,
|
||||
history,
|
||||
streamingState,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
|
||||
useKeypress(handleInput, {
|
||||
isActive: !isEmbeddedShellFocused,
|
||||
priority: true,
|
||||
});
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -1391,7 +1391,6 @@ describe('SettingsDialog', () => {
|
||||
},
|
||||
tools: {
|
||||
enableInteractiveShell: true,
|
||||
autoAccept: true,
|
||||
useRipgrep: true,
|
||||
},
|
||||
security: {
|
||||
@@ -1484,7 +1483,6 @@ describe('SettingsDialog', () => {
|
||||
userSettings: {
|
||||
tools: {
|
||||
enableInteractiveShell: true,
|
||||
autoAccept: false,
|
||||
useRipgrep: true,
|
||||
truncateToolOutputThreshold: 25000,
|
||||
truncateToolOutputLines: 500,
|
||||
@@ -1537,7 +1535,6 @@ describe('SettingsDialog', () => {
|
||||
},
|
||||
tools: {
|
||||
enableInteractiveShell: false,
|
||||
autoAccept: false,
|
||||
useRipgrep: false,
|
||||
},
|
||||
security: {
|
||||
|
||||
@@ -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,169 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
|
||||
"Choose an option
|
||||
|
||||
▲
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
▼
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
|
||||
"Choose an option
|
||||
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
3. Option 3
|
||||
Description 3
|
||||
4. Option 4
|
||||
Description 4
|
||||
5. Option 5
|
||||
Description 5
|
||||
6. Option 6
|
||||
Description 6
|
||||
7. Option 7
|
||||
Description 7
|
||||
8. Option 8
|
||||
Description 8
|
||||
9. Option 9
|
||||
Description 9
|
||||
10. Option 10
|
||||
Description 10
|
||||
11. Option 11
|
||||
Description 11
|
||||
12. Option 12
|
||||
Description 12
|
||||
13. Option 13
|
||||
Description 13
|
||||
14. Option 14
|
||||
Description 14
|
||||
15. Option 15
|
||||
Description 15
|
||||
16. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
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 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()}
|
||||
/>
|
||||
);
|
||||
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
|
||||
▼"
|
||||
`;
|
||||
+2
-4
@@ -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`] = `
|
||||
|
||||
@@ -1515,6 +1515,50 @@ describe('useTextBuffer', () => {
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
});
|
||||
|
||||
it('should handle CLEAR_INPUT (Ctrl+C)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
initialText: 'hello',
|
||||
viewport,
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
expect(getBufferState(result).text).toBe('hello');
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = result.current.handleInput({
|
||||
name: 'c',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\u0003',
|
||||
});
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
});
|
||||
|
||||
it('should NOT handle CLEAR_INPUT if buffer is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
let handled = true;
|
||||
act(() => {
|
||||
handled = result.current.handleInput({
|
||||
name: 'c',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\u0003',
|
||||
});
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle "Backspace" key', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
|
||||
@@ -2930,6 +2930,13 @@ export function useTextBuffer({
|
||||
move('end');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (text.length > 0) {
|
||||
setText('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||||
deleteWordLeft();
|
||||
return true;
|
||||
@@ -2943,6 +2950,13 @@ export function useTextBuffer({
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
|
||||
const lastLineIdx = lines.length - 1;
|
||||
if (
|
||||
cursorRow === lastLineIdx &&
|
||||
cursorCol === cpLen(lines[lastLineIdx] ?? '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
del();
|
||||
return true;
|
||||
}
|
||||
@@ -2974,6 +2988,8 @@ export function useTextBuffer({
|
||||
cursorCol,
|
||||
lines,
|
||||
singleLine,
|
||||
setText,
|
||||
text,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,7 +24,14 @@ import { coreEvents } from '@google/gemini-cli-core';
|
||||
// Mock modules
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('path');
|
||||
vi.mock('../../utils/sessionUtils.js');
|
||||
vi.mock('../../utils/sessionUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../utils/sessionUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getSessionFiles: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp';
|
||||
const MOCKED_CHATS_DIR = '/test/project/temp/chats';
|
||||
@@ -178,6 +185,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,
|
||||
|
||||
@@ -13,10 +13,12 @@ import type {
|
||||
ConversationRecord,
|
||||
ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { partListUnionToString, coreEvents } from '@google/gemini-cli-core';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import { MessageType, ToolCallStatus } from '../types.js';
|
||||
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
export { convertSessionToHistoryFormats };
|
||||
|
||||
export const useSessionBrowser = (
|
||||
config: Config,
|
||||
@@ -111,179 +113,3 @@ export const useSessionBrowser = (
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts session/conversation data into UI history and Gemini client history formats.
|
||||
*/
|
||||
export function convertSessionToHistoryFormats(
|
||||
messages: ConversationRecord['messages'],
|
||||
): {
|
||||
uiHistory: HistoryItemWithoutId[];
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
|
||||
} {
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Add the message only if it has content
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
let messageType: MessageType;
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
messageType = MessageType.USER;
|
||||
break;
|
||||
case 'info':
|
||||
messageType = MessageType.INFO;
|
||||
break;
|
||||
case 'error':
|
||||
messageType = MessageType.ERROR;
|
||||
break;
|
||||
case 'warning':
|
||||
messageType = MessageType.WARNING;
|
||||
break;
|
||||
default:
|
||||
messageType = MessageType.GEMINI;
|
||||
break;
|
||||
}
|
||||
|
||||
uiHistory.push({
|
||||
type: messageType,
|
||||
text: contentString,
|
||||
});
|
||||
}
|
||||
|
||||
// Add tool calls if present
|
||||
if (
|
||||
msg.type !== 'user' &&
|
||||
'toolCalls' in msg &&
|
||||
msg.toolCalls &&
|
||||
msg.toolCalls.length > 0
|
||||
) {
|
||||
uiHistory.push({
|
||||
type: 'tool_group',
|
||||
tools: msg.toolCalls.map((tool) => ({
|
||||
callId: tool.id,
|
||||
name: tool.displayName || tool.name,
|
||||
description: tool.description || '',
|
||||
renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true,
|
||||
status:
|
||||
tool.status === 'success'
|
||||
? ToolCallStatus.Success
|
||||
: ToolCallStatus.Error,
|
||||
resultDisplay: tool.resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Gemini client history format
|
||||
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Skip system/error messages and user slash commands
|
||||
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type === 'user') {
|
||||
// Skip user slash commands
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (
|
||||
contentString.trim().startsWith('/') ||
|
||||
contentString.trim().startsWith('?')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add regular user message
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: [{ text: contentString }],
|
||||
});
|
||||
} else if (msg.type === 'gemini') {
|
||||
// Handle Gemini messages with potential tool calls
|
||||
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
|
||||
|
||||
if (hasToolCalls) {
|
||||
// Create model message with function calls
|
||||
const modelParts: Part[] = [];
|
||||
|
||||
// Add text content if present
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
modelParts.push({ text: contentString });
|
||||
}
|
||||
|
||||
// Add function calls
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
modelParts.push({
|
||||
functionCall: {
|
||||
name: toolCall.name,
|
||||
args: toolCall.args,
|
||||
...(toolCall.id && { id: toolCall.id }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
});
|
||||
|
||||
// Create single function response message with all tool call responses
|
||||
const functionResponseParts: Part[] = [];
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
if (toolCall.result) {
|
||||
// Convert PartListUnion result to function response format
|
||||
let responseData: Part;
|
||||
|
||||
if (typeof toolCall.result === 'string') {
|
||||
responseData = {
|
||||
functionResponse: {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
response: {
|
||||
output: toolCall.result,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (Array.isArray(toolCall.result)) {
|
||||
// toolCall.result is an array containing properly formatted
|
||||
// function responses
|
||||
functionResponseParts.push(...(toolCall.result as Part[]));
|
||||
continue;
|
||||
} else {
|
||||
// Fallback for non-array results
|
||||
responseData = toolCall.result;
|
||||
}
|
||||
|
||||
functionResponseParts.push(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add user message if we have function responses
|
||||
if (functionResponseParts.length > 0) {
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Regular Gemini message without tool calls
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: [{ text: contentString }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uiHistory,
|
||||
clientHistory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 │
|
||||
└───────────────────────────┴─────┴────┘
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import { checkExhaustive } from './checks.js';
|
||||
import {
|
||||
MessageType,
|
||||
ToolCallStatus,
|
||||
type HistoryItemWithoutId,
|
||||
} from '../ui/types.js';
|
||||
|
||||
/**
|
||||
* Constant for the resume "latest" identifier.
|
||||
@@ -514,3 +521,190 @@ export class SessionSelector {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts session/conversation data into UI history and Gemini client history formats.
|
||||
*/
|
||||
export function convertSessionToHistoryFormats(
|
||||
messages: ConversationRecord['messages'],
|
||||
): {
|
||||
uiHistory: HistoryItemWithoutId[];
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
|
||||
} {
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
|
||||
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);
|
||||
const uiText = displayContentString || contentString;
|
||||
|
||||
if (uiText.trim()) {
|
||||
let messageType: MessageType;
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
messageType = MessageType.USER;
|
||||
break;
|
||||
case 'info':
|
||||
messageType = MessageType.INFO;
|
||||
break;
|
||||
case 'error':
|
||||
messageType = MessageType.ERROR;
|
||||
break;
|
||||
case 'warning':
|
||||
messageType = MessageType.WARNING;
|
||||
break;
|
||||
case 'gemini':
|
||||
messageType = MessageType.GEMINI;
|
||||
break;
|
||||
default:
|
||||
checkExhaustive(msg);
|
||||
messageType = MessageType.GEMINI;
|
||||
break;
|
||||
}
|
||||
|
||||
uiHistory.push({
|
||||
type: messageType,
|
||||
text: uiText,
|
||||
});
|
||||
}
|
||||
|
||||
// Add tool calls if present
|
||||
if (
|
||||
msg.type !== 'user' &&
|
||||
'toolCalls' in msg &&
|
||||
msg.toolCalls &&
|
||||
msg.toolCalls.length > 0
|
||||
) {
|
||||
uiHistory.push({
|
||||
type: 'tool_group',
|
||||
tools: msg.toolCalls.map((tool) => ({
|
||||
callId: tool.id,
|
||||
name: tool.displayName || tool.name,
|
||||
description: tool.description || '',
|
||||
renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true,
|
||||
status:
|
||||
tool.status === 'success'
|
||||
? ToolCallStatus.Success
|
||||
: ToolCallStatus.Error,
|
||||
resultDisplay: tool.resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Gemini client history format
|
||||
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Skip system/error messages and user slash commands
|
||||
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type === 'user') {
|
||||
// Skip user slash commands
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (
|
||||
contentString.trim().startsWith('/') ||
|
||||
contentString.trim().startsWith('?')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add regular user message
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: Array.isArray(msg.content)
|
||||
? (msg.content as Part[])
|
||||
: [{ text: contentString }],
|
||||
});
|
||||
} else if (msg.type === 'gemini') {
|
||||
// Handle Gemini messages with potential tool calls
|
||||
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
|
||||
|
||||
if (hasToolCalls) {
|
||||
// Create model message with function calls
|
||||
const modelParts: Part[] = [];
|
||||
|
||||
// Add text content if present
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
modelParts.push({ text: contentString });
|
||||
}
|
||||
|
||||
// Add function calls
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
modelParts.push({
|
||||
functionCall: {
|
||||
name: toolCall.name,
|
||||
args: toolCall.args,
|
||||
...(toolCall.id && { id: toolCall.id }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
});
|
||||
|
||||
// Create single function response message with all tool call responses
|
||||
const functionResponseParts: Part[] = [];
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
if (toolCall.result) {
|
||||
// Convert PartListUnion result to function response format
|
||||
let responseData: Part;
|
||||
|
||||
if (typeof toolCall.result === 'string') {
|
||||
responseData = {
|
||||
functionResponse: {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
response: {
|
||||
output: toolCall.result,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (Array.isArray(toolCall.result)) {
|
||||
// toolCall.result is an array containing properly formatted
|
||||
// function responses
|
||||
functionResponseParts.push(...(toolCall.result as Part[]));
|
||||
continue;
|
||||
} else {
|
||||
// Fallback for non-array results
|
||||
responseData = toolCall.result;
|
||||
}
|
||||
|
||||
functionResponseParts.push(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add user message if we have function responses
|
||||
if (functionResponseParts.length > 0) {
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Regular Gemini message without tool calls
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: [{ text: contentString }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uiHistory,
|
||||
clientHistory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type Mocked,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { GeminiAgent } from './zedIntegration.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AuthType, type Config } from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import {
|
||||
SessionSelector,
|
||||
convertSessionToHistoryFormats,
|
||||
} from '../utils/sessionUtils.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/sessionUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
SessionSelector: vi.fn(),
|
||||
convertSessionToHistoryFormats: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('GeminiAgent Session Resume', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
let mockArgv: CliArgs;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let agent: GeminiAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn().mockResolvedValue(undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getFileSystemService: vi.fn(),
|
||||
setFileSystemService: vi.fn(),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
resumeChat: vi.fn().mockResolvedValue(undefined),
|
||||
getChat: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as Mocked<LoadedSettings>;
|
||||
mockArgv = {} as unknown as CliArgs;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig);
|
||||
|
||||
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
|
||||
});
|
||||
|
||||
it('should advertise loadSession capability', async () => {
|
||||
const response = await agent.initialize({
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
});
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
});
|
||||
|
||||
it('should load a session, resume chat, and stream all message types', async () => {
|
||||
const sessionId = 'existing-session-id';
|
||||
const sessionData = {
|
||||
sessionId,
|
||||
messages: [
|
||||
{ type: 'user', content: [{ text: 'Hello' }] },
|
||||
{
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Hi there' }],
|
||||
thoughts: [{ subject: 'Thinking', description: 'about greeting' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
displayName: 'Test Tool',
|
||||
status: 'success',
|
||||
resultDisplay: 'Tool output',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Trying a write' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'write_file',
|
||||
displayName: 'Write File',
|
||||
status: 'error',
|
||||
resultDisplay: 'Permission denied',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
|
||||
});
|
||||
|
||||
(SessionSelector as unknown as Mock).mockImplementation(() => ({
|
||||
resolveSession: vi.fn().mockResolvedValue({
|
||||
sessionData,
|
||||
sessionPath: '/path/to/session.json',
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockClientHistory = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
];
|
||||
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
|
||||
clientHistory: mockClientHistory,
|
||||
uiHistory: [],
|
||||
});
|
||||
|
||||
const response = await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(response).toEqual({});
|
||||
|
||||
// Verify resumeChat received the correct arguments
|
||||
expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith(
|
||||
mockClientHistory,
|
||||
expect.objectContaining({
|
||||
conversation: sessionData,
|
||||
filePath: '/path/to/session.json',
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// User message
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: expect.objectContaining({ text: 'Hello' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Agent thought
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: expect.objectContaining({
|
||||
text: '**Thinking**\nabout greeting',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Agent message
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: expect.objectContaining({ text: 'Hi there' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Successful tool call → 'completed'
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-1',
|
||||
status: 'completed',
|
||||
title: 'Test Tool',
|
||||
kind: 'read',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: 'Tool output' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Failed tool call → 'failed'
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-2',
|
||||
status: 'failed',
|
||||
title: 'Write File',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe('GeminiAgent', () => {
|
||||
|
||||
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
|
||||
expect(response.authMethods).toHaveLength(3);
|
||||
expect(response.agentCapabilities?.loadSession).toBe(false);
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
});
|
||||
|
||||
it('should authenticate correctly', async () => {
|
||||
@@ -273,6 +273,7 @@ describe('Session', () => {
|
||||
mockChat = {
|
||||
sendMessageStream: vi.fn(),
|
||||
addHistory: vi.fn(),
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
} as unknown as Mocked<GeminiChat>;
|
||||
mockTool = {
|
||||
kind: 'native',
|
||||
@@ -293,6 +294,7 @@ describe('Session', () => {
|
||||
} as unknown as Mocked<MessageBus>;
|
||||
mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue({}),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getFileService: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ToolResult,
|
||||
ToolCallConfirmationDetails,
|
||||
FilterFilesOptions,
|
||||
ConversationRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
AuthType,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
createWorkingStdio,
|
||||
startupProfiler,
|
||||
Kind,
|
||||
partListUnionToString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -47,6 +49,10 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import {
|
||||
SessionSelector,
|
||||
convertSessionToHistoryFormats,
|
||||
} from '../utils/sessionUtils.js';
|
||||
|
||||
export async function runZedIntegration(
|
||||
config: Config,
|
||||
@@ -107,7 +113,7 @@ export class GeminiAgent {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
authMethods,
|
||||
agentCapabilities: {
|
||||
loadSession: false,
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
audio: true,
|
||||
@@ -146,23 +152,11 @@ export class GeminiAgent {
|
||||
mcpServers,
|
||||
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
||||
|
||||
let isAuthenticated = false;
|
||||
if (this.settings.merged.security.auth.selectedType) {
|
||||
try {
|
||||
await config.refreshAuth(
|
||||
this.settings.merged.security.auth.selectedType,
|
||||
);
|
||||
isAuthenticated = true;
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
const config = await this.initializeSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
);
|
||||
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
@@ -184,6 +178,88 @@ export class GeminiAgent {
|
||||
};
|
||||
}
|
||||
|
||||
async loadSession({
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
}: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse> {
|
||||
const config = await this.initializeSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
const { sessionData, sessionPath } =
|
||||
await sessionSelector.resolveSession(sessionId);
|
||||
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
const { clientHistory } = convertSessionToHistoryFormats(
|
||||
sessionData.messages,
|
||||
);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
await geminiClient.initialize();
|
||||
await geminiClient.resumeChat(clientHistory, {
|
||||
conversation: sessionData,
|
||||
filePath: sessionPath,
|
||||
});
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
geminiClient.getChat(),
|
||||
config,
|
||||
this.connection,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Stream history back to client
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.streamHistory(sessionData.messages);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private async initializeSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
): Promise<Config> {
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
if (!selectedAuthType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 1. Create config WITHOUT initializing it (no MCP servers started yet)
|
||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
||||
|
||||
// 2. Authenticate BEFORE initializing configuration or starting MCP servers.
|
||||
// This satisfies the security requirement to verify the user before executing
|
||||
// potentially unsafe server definitions.
|
||||
try {
|
||||
await config.refreshAuth(selectedAuthType);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 3. Now that we are authenticated, it is safe to initialize the config
|
||||
// which starts the MCP servers and other heavy resources.
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async newSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
@@ -228,8 +304,6 @@ export class GeminiAgent {
|
||||
|
||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -269,6 +343,73 @@ export class Session {
|
||||
this.pendingPrompt = null;
|
||||
}
|
||||
|
||||
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
|
||||
for (const msg of messages) {
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
|
||||
if (msg.type === 'user') {
|
||||
if (contentString.trim()) {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: contentString },
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'gemini') {
|
||||
// Thoughts
|
||||
if (msg.thoughts) {
|
||||
for (const thought of msg.thoughts) {
|
||||
const thoughtText = `**${thought.subject}**\n${thought.description}`;
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: thoughtText },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Message text
|
||||
if (contentString.trim()) {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: contentString },
|
||||
});
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
if (msg.toolCalls) {
|
||||
for (const toolCall of msg.toolCalls) {
|
||||
const toolCallContent: acp.ToolCallContent[] = [];
|
||||
if (toolCall.resultDisplay) {
|
||||
if (typeof toolCall.resultDisplay === 'string') {
|
||||
toolCallContent.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: toolCall.resultDisplay },
|
||||
});
|
||||
} else if ('fileName' in toolCall.resultDisplay) {
|
||||
toolCallContent.push({
|
||||
type: 'diff',
|
||||
path: toolCall.resultDisplay.fileName,
|
||||
oldText: toolCall.resultDisplay.originalContent,
|
||||
newText: toolCall.resultDisplay.newContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tool = this.config.getToolRegistry().getTool(toolCall.name);
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: toolCall.id,
|
||||
status: toolCall.status === 'success' ? 'completed' : 'failed',
|
||||
title: toolCall.displayName || toolCall.name,
|
||||
content: toolCallContent,
|
||||
kind: tool ? toAcpToolKind(tool.kind) : 'other',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
this.pendingPrompt?.abort();
|
||||
const pendingSend = new AbortController();
|
||||
@@ -533,6 +674,33 @@ export class Session {
|
||||
),
|
||||
);
|
||||
|
||||
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
|
||||
{
|
||||
status: 'success',
|
||||
request: {
|
||||
callId,
|
||||
name: fc.name,
|
||||
args,
|
||||
isClientInitiated: false,
|
||||
prompt_id: promptId,
|
||||
},
|
||||
tool,
|
||||
invocation,
|
||||
response: {
|
||||
callId,
|
||||
responseParts: convertToFunctionResponse(
|
||||
fc.name,
|
||||
callId,
|
||||
toolResult.llmContent,
|
||||
this.config.getActiveModel(),
|
||||
),
|
||||
resultDisplay: toolResult.returnDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return convertToFunctionResponse(
|
||||
fc.name,
|
||||
callId,
|
||||
@@ -551,6 +719,35 @@ export class Session {
|
||||
],
|
||||
});
|
||||
|
||||
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
|
||||
{
|
||||
status: 'error',
|
||||
request: {
|
||||
callId,
|
||||
name: fc.name,
|
||||
args,
|
||||
isClientInitiated: false,
|
||||
prompt_id: promptId,
|
||||
},
|
||||
tool,
|
||||
response: {
|
||||
callId,
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: fc.name ?? '',
|
||||
response: { error: error.message },
|
||||
},
|
||||
},
|
||||
],
|
||||
resultDisplay: error.message,
|
||||
error,
|
||||
errorType: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -983,6 +1180,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}`);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user