mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -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'
|
- name: 'Smoke test bundle'
|
||||||
run: 'node ./bundle/gemini.js --version'
|
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'
|
- name: 'Wait for file system sync'
|
||||||
run: 'sleep 2'
|
run: 'sleep 2'
|
||||||
|
|
||||||
@@ -252,6 +265,19 @@ jobs:
|
|||||||
- name: 'Smoke test bundle'
|
- name: 'Smoke test bundle'
|
||||||
run: 'node ./bundle/gemini.js --version'
|
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'
|
- name: 'Wait for file system sync'
|
||||||
run: 'sleep 2'
|
run: 'sleep 2'
|
||||||
|
|
||||||
@@ -396,6 +422,21 @@ jobs:
|
|||||||
run: 'node ./bundle/gemini.js --version'
|
run: 'node ./bundle/gemini.js --version'
|
||||||
shell: 'pwsh'
|
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:
|
ci:
|
||||||
name: 'CI'
|
name: 'CI'
|
||||||
if: 'always()'
|
if: 'always()'
|
||||||
|
|||||||
@@ -43,19 +43,23 @@ jobs:
|
|||||||
|
|
||||||
// 1. Fetch maintainers for verification
|
// 1. Fetch maintainers for verification
|
||||||
let maintainerLogins = new Set();
|
let maintainerLogins = new Set();
|
||||||
|
let teamFetchSucceeded = false;
|
||||||
try {
|
try {
|
||||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||||
org: context.repo.owner,
|
org: context.repo.owner,
|
||||||
team_slug: 'gemini-cli-maintainers'
|
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) {
|
} 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) => {
|
const isMaintainer = (login, assoc) => {
|
||||||
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
|
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||||
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||||
|
return isTeamMember || isRepoMaintainer;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Determine which PRs to check
|
// 2. Determine which PRs to check
|
||||||
@@ -153,7 +157,17 @@ jobs:
|
|||||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||||
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
|
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 {
|
try {
|
||||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
@@ -177,8 +191,12 @@ jobs:
|
|||||||
if (d > lastActivity) lastActivity = d;
|
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) {
|
if (maintainerPr) {
|
||||||
const d = new Date(pr.created_at);
|
const d = new Date(pr.created_at);
|
||||||
if (d > lastActivity) lastActivity = d;
|
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,
|
modify them as desired. Changes to some settings are applied immediately,
|
||||||
while others require a restart.
|
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`**
|
- **`/setup-github`**
|
||||||
- **Description:** Set up GitHub Actions to triage issues and review PRs with
|
- **Description:** Set up GitHub Actions to triage issues and review PRs with
|
||||||
Gemini.
|
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 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 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 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 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 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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 shell input from the gemini input. | `Tab (no Shift)` |
|
||||||
| Focus the Gemini input from the shell input. | `Tab` |
|
| Focus the Gemini input from the shell input. | `Tab` |
|
||||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
| 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` |
|
| 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` |
|
| 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"` |
|
| 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` |
|
| 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` |
|
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||||
@@ -107,13 +106,14 @@ they appear in the UI.
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
| UI Label | Setting | Description | Default |
|
| UI Label | Setting | Description | Default |
|
||||||
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------- | ------- |
|
| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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. | `[]` |
|
||||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `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` |
|
||||||
|
|
||||||
### Experimental
|
### Experimental
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -15,7 +15,7 @@ as security auditing, cloud deployments, or codebase migrations—without
|
|||||||
cluttering the model's immediate context window.
|
cluttering the model's immediate context window.
|
||||||
|
|
||||||
Gemini autonomously decides when to employ a skill based on your request and the
|
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
|
the full instructions and resources required to complete the task using the
|
||||||
`activate_skill` tool.
|
`activate_skill` tool.
|
||||||
|
|
||||||
@@ -108,5 +108,5 @@ gemini skills disable my-expertise --scope workspace
|
|||||||
|
|
||||||
## Creating your own skills
|
## Creating your own skills
|
||||||
|
|
||||||
To create your own skills, see the
|
To create your own skills, see see the
|
||||||
[Create Agent Skills](./guides/creating-skills.md) guide.
|
[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.
|
Releases. Using a public Git repository is the simplest method.
|
||||||
|
|
||||||
For detailed instructions on both methods, please refer to the
|
For detailed instructions on both methods, please refer to the
|
||||||
[Extension Releasing Guide](./releasing.md).
|
[Extension Releasing Guide](./broken-link/releasing.md).
|
||||||
|
|
||||||
## Conclusion
|
## 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
|
MCP servers of their own. This should not be used as an airtight security
|
||||||
mechanism.
|
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):
|
- **`theme`** (string):
|
||||||
- **Description:** Sets the visual [theme](../cli/themes.md) for Gemini CLI.
|
- **Description:** Sets the visual [theme](../cli/themes.md) for Gemini CLI.
|
||||||
- **Default:** `"Default"`
|
- **Default:** `"Default"`
|
||||||
|
|||||||
@@ -661,11 +661,6 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
performance.
|
performance.
|
||||||
- **Default:** `true`
|
- **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):
|
- **`tools.approvalMode`** (enum):
|
||||||
- **Description:** The default approval mode for tool execution. 'default'
|
- **Description:** The default approval mode for tool execution. 'default'
|
||||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
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`
|
- **Default:** `true`
|
||||||
- **Requires restart:** Yes
|
- **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`
|
||||||
|
|
||||||
- **`mcp.serverCommand`** (string):
|
- **`mcp.serverCommand`** (string):
|
||||||
@@ -779,6 +768,13 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
- **Requires restart:** Yes
|
- **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):
|
- **`security.folderTrust.enabled`** (boolean):
|
||||||
- **Description:** Setting to track whether Folder trust is enabled.
|
- **Description:** Setting to track whether Folder trust is enabled.
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
|||||||
@@ -564,11 +564,17 @@ describe('run_shell_command', () => {
|
|||||||
|
|
||||||
it('rejects invalid shell expressions', async () => {
|
it('rejects invalid shell expressions', async () => {
|
||||||
await rig.setup('rejects invalid shell expressions', {
|
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 invalidCommand = getInvalidCommand();
|
||||||
const result = await rig.run({
|
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.`,
|
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');
|
expect(result).toContain('FAIL');
|
||||||
|
|
||||||
|
|||||||
@@ -596,9 +596,9 @@ describe('parseArguments', () => {
|
|||||||
|
|
||||||
it('should set isCommand to true for hooks command', async () => {
|
it('should set isCommand to true for hooks command', async () => {
|
||||||
process.argv = ['node', 'script.js', 'hooks', 'migrate'];
|
process.argv = ['node', 'script.js', 'hooks', 'migrate'];
|
||||||
// Hooks command enabled via tools settings
|
// Hooks command enabled via hooksConfig settings
|
||||||
const settings = createTestMergedSettings({
|
const settings = createTestMergedSettings({
|
||||||
tools: { enableHooks: true },
|
hooksConfig: { enabled: true },
|
||||||
});
|
});
|
||||||
const argv = await parseArguments(settings);
|
const argv = await parseArguments(settings);
|
||||||
expect(argv.isCommand).toBe(true);
|
expect(argv.isCommand).toBe(true);
|
||||||
@@ -1255,7 +1255,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
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 },
|
security: { disableYoloMode: true },
|
||||||
});
|
});
|
||||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
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(
|
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(
|
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,
|
type OutputFormat,
|
||||||
coreEvents,
|
coreEvents,
|
||||||
GEMINI_MODEL_ALIAS_AUTO,
|
GEMINI_MODEL_ALIAS_AUTO,
|
||||||
|
getAdminErrorMessage,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
type Settings,
|
type Settings,
|
||||||
@@ -308,7 +309,7 @@ export async function parseArguments(
|
|||||||
yargsInstance.command(skillsCommand);
|
yargsInstance.command(skillsCommand);
|
||||||
}
|
}
|
||||||
// Register hooks command if hooks are enabled
|
// Register hooks command if hooks are enabled
|
||||||
if (settings.tools?.enableHooks) {
|
if (settings.hooksConfig.enabled) {
|
||||||
yargsInstance.command(hooksCommand);
|
yargsInstance.command(hooksCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,7 +551,7 @@ export async function loadCliConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new FatalConfigError(
|
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) {
|
} else if (approvalMode === ApprovalMode.YOLO) {
|
||||||
@@ -790,10 +791,8 @@ export async function loadCliConfig(
|
|||||||
acceptRawOutputRisk: argv.acceptRawOutputRisk,
|
acceptRawOutputRisk: argv.acceptRawOutputRisk,
|
||||||
modelConfigServiceConfig: settings.modelConfigs,
|
modelConfigServiceConfig: settings.modelConfigs,
|
||||||
// TODO: loading of hooks based on workspace trust
|
// TODO: loading of hooks based on workspace trust
|
||||||
enableHooks:
|
enableHooks: settings.hooksConfig.enabled,
|
||||||
(settings.tools?.enableHooks ?? true) &&
|
enableHooksUI: settings.hooksConfig.enabled,
|
||||||
(settings.hooksConfig?.enabled ?? true),
|
|
||||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
|
||||||
hooks: settings.hooks || {},
|
hooks: settings.hooks || {},
|
||||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||||
projectHooks: projectHooks || {},
|
projectHooks: projectHooks || {},
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ System using model: \${MODEL_NAME}
|
|||||||
settings: createTestMergedSettings({
|
settings: createTestMergedSettings({
|
||||||
telemetry: { enabled: false },
|
telemetry: { enabled: false },
|
||||||
experimental: { extensionConfig: true },
|
experimental: { extensionConfig: true },
|
||||||
tools: { enableHooks: true },
|
|
||||||
hooksConfig: { enabled: true },
|
hooksConfig: { enabled: true },
|
||||||
}),
|
}),
|
||||||
requestConsent: vi.fn().mockResolvedValue(true),
|
requestConsent: vi.fn().mockResolvedValue(true),
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ describe('ExtensionManager theme loading', () => {
|
|||||||
experimental: { extensionConfig: true },
|
experimental: { extensionConfig: true },
|
||||||
security: { blockGitExtensions: false },
|
security: { blockGitExtensions: false },
|
||||||
admin: { extensions: { enabled: true }, mcp: { enabled: true } },
|
admin: { extensions: { enabled: true }, mcp: { enabled: true } },
|
||||||
tools: { enableHooks: true },
|
|
||||||
}),
|
}),
|
||||||
requestConsent: async () => true,
|
requestConsent: async () => true,
|
||||||
requestSetting: async () => '',
|
requestSetting: async () => '',
|
||||||
|
|||||||
@@ -144,6 +144,26 @@ export class ExtensionManager extends ExtensionLoader {
|
|||||||
previousExtensionConfig?: ExtensionConfig,
|
previousExtensionConfig?: ExtensionConfig,
|
||||||
): Promise<GeminiCLIExtension> {
|
): Promise<GeminiCLIExtension> {
|
||||||
if (
|
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 === 'git' ||
|
||||||
installMetadata.type === 'github-release') &&
|
installMetadata.type === 'github-release') &&
|
||||||
this.settings.security.blockGitExtensions
|
this.settings.security.blockGitExtensions
|
||||||
@@ -152,6 +172,7 @@ export class ExtensionManager extends ExtensionLoader {
|
|||||||
'Installing extensions from remote sources is disallowed by your current settings.',
|
'Installing extensions from remote sources is disallowed by your current settings.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUpdate = !!previousExtensionConfig;
|
const isUpdate = !!previousExtensionConfig;
|
||||||
let newExtensionConfig: ExtensionConfig | null = null;
|
let newExtensionConfig: ExtensionConfig | null = null;
|
||||||
let localSourcePath: string | undefined;
|
let localSourcePath: string | undefined;
|
||||||
@@ -522,10 +543,39 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
const installMetadata = loadInstallMetadata(extensionDir);
|
const installMetadata = loadInstallMetadata(extensionDir);
|
||||||
let effectiveExtensionPath = extensionDir;
|
let effectiveExtensionPath = extensionDir;
|
||||||
if (
|
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 === 'git' ||
|
||||||
installMetadata?.type === 'github-release') &&
|
installMetadata?.type === 'github-release') &&
|
||||||
this.settings.security.blockGitExtensions
|
this.settings.security.blockGitExtensions
|
||||||
) {
|
) {
|
||||||
|
debugLogger.warn(
|
||||||
|
`Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,10 +689,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
};
|
};
|
||||||
|
|
||||||
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||||
if (
|
if (this.settings.hooksConfig.enabled) {
|
||||||
this.settings.tools.enableHooks &&
|
|
||||||
this.settings.hooksConfig.enabled
|
|
||||||
) {
|
|
||||||
hooks = await this.loadExtensionHooks(
|
hooks = await this.loadExtensionHooks(
|
||||||
effectiveExtensionPath,
|
effectiveExtensionPath,
|
||||||
hydrationContext,
|
hydrationContext,
|
||||||
|
|||||||
@@ -622,6 +622,7 @@ describe('extension tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not load github extensions if blockGitExtensions is set', async () => {
|
it('should not load github extensions if blockGitExtensions is set', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
createExtension({
|
createExtension({
|
||||||
extensionsDir: userExtensionsDir,
|
extensionsDir: userExtensionsDir,
|
||||||
name: 'my-ext',
|
name: 'my-ext',
|
||||||
@@ -645,6 +646,73 @@ describe('extension tests', () => {
|
|||||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||||
|
|
||||||
expect(extension).toBeUndefined();
|
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 () => {
|
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 () => {
|
it('should prompt for trust if workspace is not trusted', async () => {
|
||||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||||
isTrusted: false,
|
isTrusted: false,
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ export enum Command {
|
|||||||
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
||||||
PASTE_CLIPBOARD = 'input.paste',
|
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
|
// App Controls
|
||||||
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
|
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
|
||||||
SHOW_FULL_TODOS = 'app.showFullTodos',
|
SHOW_FULL_TODOS = 'app.showFullTodos',
|
||||||
@@ -139,7 +148,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
|||||||
],
|
],
|
||||||
[Command.MOVE_LEFT]: [
|
[Command.MOVE_LEFT]: [
|
||||||
{ key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
|
{ key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
|
||||||
{ key: 'b', ctrl: true },
|
|
||||||
],
|
],
|
||||||
[Command.MOVE_RIGHT]: [
|
[Command.MOVE_RIGHT]: [
|
||||||
{ key: 'right', shift: false, alt: false, ctrl: false, cmd: false },
|
{ 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_COPY_MODE]: [{ key: 's', ctrl: true }],
|
||||||
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
|
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
|
||||||
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: 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]: [
|
[Command.SHOW_MORE_LINES]: [
|
||||||
{ key: 'o', ctrl: true },
|
{ key: 'o', ctrl: true },
|
||||||
{ key: 's', ctrl: true },
|
{ key: 's', ctrl: true },
|
||||||
@@ -379,6 +397,14 @@ export const commandCategories: readonly CommandCategory[] = [
|
|||||||
Command.TOGGLE_YOLO,
|
Command.TOGGLE_YOLO,
|
||||||
Command.CYCLE_APPROVAL_MODE,
|
Command.CYCLE_APPROVAL_MODE,
|
||||||
Command.SHOW_MORE_LINES,
|
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.FOCUS_SHELL_INPUT,
|
||||||
Command.UNFOCUS_SHELL_INPUT,
|
Command.UNFOCUS_SHELL_INPUT,
|
||||||
Command.CLEAR_SCREEN,
|
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).',
|
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
||||||
[Command.SHOW_MORE_LINES]:
|
[Command.SHOW_MORE_LINES]:
|
||||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
'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.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
|
||||||
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
|
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
|
||||||
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
[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 () => {
|
it('should handle complex mixed configurations', async () => {
|
||||||
const settings: Settings = {
|
const settings: Settings = {
|
||||||
tools: {
|
tools: {
|
||||||
autoAccept: true, // Allows read-only tools
|
|
||||||
allowed: ['custom-tool', 'my-server__special-tool'],
|
allowed: ['custom-tool', 'my-server__special-tool'],
|
||||||
exclude: ['glob', 'dangerous-tool'],
|
exclude: ['glob', 'dangerous-tool'],
|
||||||
},
|
},
|
||||||
@@ -438,7 +437,6 @@ describe('Policy Engine Integration Tests', () => {
|
|||||||
it('should verify priority ordering works correctly in practice', async () => {
|
it('should verify priority ordering works correctly in practice', async () => {
|
||||||
const settings: Settings = {
|
const settings: Settings = {
|
||||||
tools: {
|
tools: {
|
||||||
autoAccept: true, // Priority 50
|
|
||||||
allowed: ['specific-tool'], // Priority 100
|
allowed: ['specific-tool'], // Priority 100
|
||||||
exclude: ['blocked-tool'], // Priority 200
|
exclude: ['blocked-tool'], // Priority 200
|
||||||
},
|
},
|
||||||
@@ -614,7 +612,6 @@ describe('Policy Engine Integration Tests', () => {
|
|||||||
it('should verify rules are created with correct priorities', async () => {
|
it('should verify rules are created with correct priorities', async () => {
|
||||||
const settings: Settings = {
|
const settings: Settings = {
|
||||||
tools: {
|
tools: {
|
||||||
autoAccept: true,
|
|
||||||
allowed: ['tool1', 'tool2'],
|
allowed: ['tool1', 'tool2'],
|
||||||
exclude: ['tool3'],
|
exclude: ['tool3'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ describe('settings-validation', () => {
|
|||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
sandbox: 'inherit',
|
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: {
|
approvalMode: {
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
label: 'Approval Mode',
|
label: 'Approval Mode',
|
||||||
@@ -1179,16 +1168,6 @@ const SETTINGS_SCHEMA = {
|
|||||||
`,
|
`,
|
||||||
showInDialog: true,
|
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.',
|
description: 'Blocks installing and loading extensions from Git.',
|
||||||
showInDialog: true,
|
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: {
|
folderTrust: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
label: 'Folder Trust',
|
label: 'Folder Trust',
|
||||||
|
|||||||
@@ -16,11 +16,10 @@ import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
|||||||
import type { MergedSettings } from './config/settings.js';
|
import type { MergedSettings } from './config/settings.js';
|
||||||
import type { MockInstance } from 'vitest';
|
import type { MockInstance } from 'vitest';
|
||||||
|
|
||||||
const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({
|
const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
|
||||||
mockRunExitCleanup: vi.fn(),
|
mockRunExitCleanup: vi.fn(),
|
||||||
mockDebugLogger: {
|
mockCoreEvents: {
|
||||||
log: vi.fn(),
|
emitFeedback: vi.fn(),
|
||||||
error: vi.fn(),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
|||||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
debugLogger: mockDebugLogger,
|
coreEvents: mockCoreEvents,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,8 +54,7 @@ describe('deferred', () => {
|
|||||||
describe('runDeferredCommand', () => {
|
describe('runDeferredCommand', () => {
|
||||||
it('should do nothing if no deferred command is set', async () => {
|
it('should do nothing if no deferred command is set', async () => {
|
||||||
await runDeferredCommand(createMockSettings());
|
await runDeferredCommand(createMockSettings());
|
||||||
expect(mockDebugLogger.log).not.toHaveBeenCalled();
|
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
|
||||||
expect(mockDebugLogger.error).not.toHaveBeenCalled();
|
|
||||||
expect(mockExit).not.toHaveBeenCalled();
|
expect(mockExit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,8 +83,9 @@ describe('deferred', () => {
|
|||||||
const settings = createMockSettings({ mcp: { enabled: false } });
|
const settings = createMockSettings({ mcp: { enabled: false } });
|
||||||
await runDeferredCommand(settings);
|
await runDeferredCommand(settings);
|
||||||
|
|
||||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
'Error: MCP is disabled by your admin.',
|
'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(mockRunExitCleanup).toHaveBeenCalled();
|
||||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||||
@@ -102,8 +101,9 @@ describe('deferred', () => {
|
|||||||
const settings = createMockSettings({ extensions: { enabled: false } });
|
const settings = createMockSettings({ extensions: { enabled: false } });
|
||||||
await runDeferredCommand(settings);
|
await runDeferredCommand(settings);
|
||||||
|
|
||||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
'Error: Extensions are disabled by your admin.',
|
'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(mockRunExitCleanup).toHaveBeenCalled();
|
||||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||||
@@ -119,8 +119,9 @@ describe('deferred', () => {
|
|||||||
const settings = createMockSettings({ skills: { enabled: false } });
|
const settings = createMockSettings({ skills: { enabled: false } });
|
||||||
await runDeferredCommand(settings);
|
await runDeferredCommand(settings);
|
||||||
|
|
||||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
'Error: Agent skills are disabled by your admin.',
|
'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(mockRunExitCleanup).toHaveBeenCalled();
|
||||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||||
@@ -183,8 +184,9 @@ describe('deferred', () => {
|
|||||||
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
|
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
|
||||||
await runDeferredCommand(mcpSettings);
|
await runDeferredCommand(mcpSettings);
|
||||||
|
|
||||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
'Error: MCP is disabled by your admin.',
|
'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
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
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 { runExitCleanup } from './utils/cleanup.js';
|
||||||
import type { MergedSettings } from './config/settings.js';
|
import type { MergedSettings } from './config/settings.js';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
@@ -30,7 +34,10 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
|||||||
const commandName = deferredCommand.commandName;
|
const commandName = deferredCommand.commandName;
|
||||||
|
|
||||||
if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
|
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();
|
await runExitCleanup();
|
||||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||||
}
|
}
|
||||||
@@ -39,13 +46,19 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
|||||||
commandName === 'extensions' &&
|
commandName === 'extensions' &&
|
||||||
adminSettings?.extensions?.enabled === false
|
adminSettings?.extensions?.enabled === false
|
||||||
) {
|
) {
|
||||||
debugLogger.error('Error: Extensions are disabled by your admin.');
|
coreEvents.emitFeedback(
|
||||||
|
'error',
|
||||||
|
getAdminErrorMessage('Extensions', undefined /* config */),
|
||||||
|
);
|
||||||
await runExitCleanup();
|
await runExitCleanup();
|
||||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
|
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();
|
await runExitCleanup();
|
||||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,9 @@ describe('runNonInteractive', () => {
|
|||||||
[{ text: 'Test input' }],
|
[{ text: 'Test input' }],
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-1',
|
'prompt-id-1',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'Test input',
|
||||||
);
|
);
|
||||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||||
// Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
|
// Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
|
||||||
@@ -374,6 +377,9 @@ describe('runNonInteractive', () => {
|
|||||||
[{ text: 'Tool response' }],
|
[{ text: 'Tool response' }],
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-2',
|
'prompt-id-2',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||||
});
|
});
|
||||||
@@ -531,6 +537,9 @@ describe('runNonInteractive', () => {
|
|||||||
],
|
],
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-3',
|
'prompt-id-3',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||||
});
|
});
|
||||||
@@ -670,6 +679,9 @@ describe('runNonInteractive', () => {
|
|||||||
processedParts,
|
processedParts,
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-7',
|
'prompt-id-7',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
rawInput,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. Assert the final output is correct
|
// 6. Assert the final output is correct
|
||||||
@@ -703,6 +715,9 @@ describe('runNonInteractive', () => {
|
|||||||
[{ text: 'Test input' }],
|
[{ text: 'Test input' }],
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-1',
|
'prompt-id-1',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'Test input',
|
||||||
);
|
);
|
||||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -833,6 +848,9 @@ describe('runNonInteractive', () => {
|
|||||||
[{ text: 'Empty response test' }],
|
[{ text: 'Empty response test' }],
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-empty',
|
'prompt-id-empty',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'Empty response test',
|
||||||
);
|
);
|
||||||
|
|
||||||
// This should output JSON with empty response but include stats
|
// This should output JSON with empty response but include stats
|
||||||
@@ -967,6 +985,9 @@ describe('runNonInteractive', () => {
|
|||||||
[{ text: 'Prompt from command' }],
|
[{ text: 'Prompt from command' }],
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-slash',
|
'prompt-id-slash',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'/testcommand',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getWrittenOutput()).toBe('Response from command\n');
|
expect(getWrittenOutput()).toBe('Response from command\n');
|
||||||
@@ -1010,6 +1031,9 @@ describe('runNonInteractive', () => {
|
|||||||
[{ text: 'Slash command output' }],
|
[{ text: 'Slash command output' }],
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-slash',
|
'prompt-id-slash',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'/help',
|
||||||
);
|
);
|
||||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||||
handleSlashCommandSpy.mockRestore();
|
handleSlashCommandSpy.mockRestore();
|
||||||
@@ -1184,6 +1208,9 @@ describe('runNonInteractive', () => {
|
|||||||
[{ text: '/unknowncommand' }],
|
[{ text: '/unknowncommand' }],
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-unknown',
|
'prompt-id-unknown',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'/unknowncommand',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getWrittenOutput()).toBe('Response to unknown\n');
|
expect(getWrittenOutput()).toBe('Response to unknown\n');
|
||||||
|
|||||||
@@ -301,6 +301,9 @@ export async function runNonInteractive({
|
|||||||
currentMessages[0]?.parts || [],
|
currentMessages[0]?.parts || [],
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
prompt_id,
|
prompt_id,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
turnCount === 1 ? input : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
type CommandContext,
|
type CommandContext,
|
||||||
} from '../ui/commands/types.js';
|
} from '../ui/commands/types.js';
|
||||||
import type { MessageActionReturn, Config } from '@google/gemini-cli-core';
|
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 { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||||
import { authCommand } from '../ui/commands/authCommand.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 { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||||
|
import { shellsCommand } from '../ui/commands/shellsCommand.js';
|
||||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||||
@@ -101,7 +106,10 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
): Promise<MessageActionReturn> => ({
|
): Promise<MessageActionReturn> => ({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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> => ({
|
): Promise<MessageActionReturn> => ({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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> => ({
|
): Promise<MessageActionReturn> => ({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Agent skills are disabled by your admin.',
|
content: getAdminErrorMessage(
|
||||||
|
'Agent skills',
|
||||||
|
this.config ?? undefined,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [skillsCommand]
|
: [skillsCommand]
|
||||||
: []),
|
: []),
|
||||||
settingsCommand,
|
settingsCommand,
|
||||||
|
shellsCommand,
|
||||||
vimCommand,
|
vimCommand,
|
||||||
setupGithubCommand,
|
setupGithubCommand,
|
||||||
terminalSetupCommand,
|
terminalSetupCommand,
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ const baseMockUiState = {
|
|||||||
terminalHeight: 40,
|
terminalHeight: 40,
|
||||||
currentModel: 'gemini-pro',
|
currentModel: 'gemini-pro',
|
||||||
terminalBackgroundColor: undefined,
|
terminalBackgroundColor: undefined,
|
||||||
|
activePtyId: undefined,
|
||||||
|
backgroundShells: new Map(),
|
||||||
|
backgroundShellHeight: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockAppState: AppState = {
|
export const mockAppState: AppState = {
|
||||||
@@ -201,7 +204,11 @@ const mockUIActions: UIActions = {
|
|||||||
handleApiKeyCancel: vi.fn(),
|
handleApiKeyCancel: vi.fn(),
|
||||||
setBannerVisible: vi.fn(),
|
setBannerVisible: vi.fn(),
|
||||||
setEmbeddedShellFocused: vi.fn(),
|
setEmbeddedShellFocused: vi.fn(),
|
||||||
|
dismissBackgroundShell: vi.fn(),
|
||||||
|
setActiveBackgroundShellPid: vi.fn(),
|
||||||
|
setIsBackgroundShellListOpen: vi.fn(),
|
||||||
setAuthContext: vi.fn(),
|
setAuthContext: vi.fn(),
|
||||||
|
handleWarning: vi.fn(),
|
||||||
handleRestart: vi.fn(),
|
handleRestart: vi.fn(),
|
||||||
handleNewAgentsSelect: vi.fn(),
|
handleNewAgentsSelect: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ describe('App', () => {
|
|||||||
defaultText: 'Mock Banner Text',
|
defaultText: 'Mock Banner Text',
|
||||||
warningText: '',
|
warningText: '',
|
||||||
},
|
},
|
||||||
|
backgroundShells: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should render main content and composer when not quitting', () => {
|
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('Tips for getting started');
|
||||||
expect(lastFrame()).toContain('Notifications');
|
expect(lastFrame()).toContain('Notifications');
|
||||||
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
|
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
|
||||||
expect(lastFrame()).toContain('1 of 1');
|
|
||||||
expect(lastFrame()).toContain('Composer');
|
expect(lastFrame()).toContain('Composer');
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,13 +32,7 @@ import {
|
|||||||
UserAccountManager,
|
UserAccountManager,
|
||||||
type ContentGeneratorConfig,
|
type ContentGeneratorConfig,
|
||||||
type AgentDefinition,
|
type AgentDefinition,
|
||||||
MessageBusType,
|
|
||||||
QuestionType,
|
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
|
||||||
AskUserActionsContext,
|
|
||||||
type AskUserState,
|
|
||||||
} from './contexts/AskUserActionsContext.js';
|
|
||||||
|
|
||||||
// Mock coreEvents
|
// Mock coreEvents
|
||||||
const mockCoreEvents = vi.hoisted(() => ({
|
const mockCoreEvents = vi.hoisted(() => ({
|
||||||
@@ -124,11 +118,9 @@ vi.mock('ink', async (importOriginal) => {
|
|||||||
// so we can assert against them in our tests.
|
// so we can assert against them in our tests.
|
||||||
let capturedUIState: UIState;
|
let capturedUIState: UIState;
|
||||||
let capturedUIActions: UIActions;
|
let capturedUIActions: UIActions;
|
||||||
let capturedAskUserRequest: AskUserState | null;
|
|
||||||
function TestContextConsumer() {
|
function TestContextConsumer() {
|
||||||
capturedUIState = useContext(UIStateContext)!;
|
capturedUIState = useContext(UIStateContext)!;
|
||||||
capturedUIActions = useContext(UIActionsContext)!;
|
capturedUIActions = useContext(UIActionsContext)!;
|
||||||
capturedAskUserRequest = useContext(AskUserActionsContext)?.request ?? null;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +261,25 @@ describe('AppContainer State Management', () => {
|
|||||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||||
const mockedUseHookDisplayState = useHookDisplayState 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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
@@ -279,7 +290,6 @@ describe('AppContainer State Management', () => {
|
|||||||
mocks.mockStdout.write.mockClear();
|
mocks.mockStdout.write.mockClear();
|
||||||
|
|
||||||
capturedUIState = null!;
|
capturedUIState = null!;
|
||||||
capturedAskUserRequest = null;
|
|
||||||
|
|
||||||
// **Provide a default return value for EVERY mocked hook.**
|
// **Provide a default return value for EVERY mocked hook.**
|
||||||
mockedUseQuotaAndFallback.mockReturnValue({
|
mockedUseQuotaAndFallback.mockReturnValue({
|
||||||
@@ -334,14 +344,7 @@ describe('AppContainer State Management', () => {
|
|||||||
handleNewMessage: vi.fn(),
|
handleNewMessage: vi.fn(),
|
||||||
clearConsoleMessages: vi.fn(),
|
clearConsoleMessages: vi.fn(),
|
||||||
});
|
});
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
|
||||||
streamingState: 'idle',
|
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: null,
|
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
});
|
|
||||||
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
|
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
|
||||||
mockedUseFolderTrust.mockReturnValue({
|
mockedUseFolderTrust.mockReturnValue({
|
||||||
isFolderTrustDialogOpen: false,
|
isFolderTrustDialogOpen: false,
|
||||||
@@ -368,7 +371,9 @@ describe('AppContainer State Management', () => {
|
|||||||
mockedUseTextBuffer.mockReturnValue({
|
mockedUseTextBuffer.mockReturnValue({
|
||||||
text: '',
|
text: '',
|
||||||
setText: vi.fn(),
|
setText: vi.fn(),
|
||||||
// Add other properties if AppContainer uses them
|
lines: [''],
|
||||||
|
cursor: [0, 0],
|
||||||
|
handleInput: vi.fn().mockReturnValue(false),
|
||||||
});
|
});
|
||||||
mockedUseLogger.mockReturnValue({
|
mockedUseLogger.mockReturnValue({
|
||||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||||
@@ -1193,12 +1198,9 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Mock the streaming state as Active
|
// Mock the streaming state as Active
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: 'Some thought' },
|
thought: { subject: 'Some thought' },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act: Render the container
|
// Act: Render the container
|
||||||
@@ -1234,12 +1236,9 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Mock the streaming state
|
// Mock the streaming state
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: 'Some thought' },
|
thought: { subject: 'Some thought' },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act: Render the container
|
// Act: Render the container
|
||||||
@@ -1306,12 +1305,9 @@ describe('AppContainer State Management', () => {
|
|||||||
// Mock the streaming state and thought
|
// Mock the streaming state and thought
|
||||||
const thoughtSubject = 'Processing request';
|
const thoughtSubject = 'Processing request';
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: thoughtSubject },
|
thought: { subject: thoughtSubject },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act: Render the container
|
// Act: Render the container
|
||||||
@@ -1347,14 +1343,7 @@ describe('AppContainer State Management', () => {
|
|||||||
} as unknown as LoadedSettings;
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
// Mock the streaming state as Idle with no thought
|
// Mock the streaming state as Idle with no thought
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
|
||||||
streamingState: 'idle',
|
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: null,
|
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act: Render the container
|
// Act: Render the container
|
||||||
const { unmount } = renderAppContainer({
|
const { unmount } = renderAppContainer({
|
||||||
@@ -1391,12 +1380,9 @@ describe('AppContainer State Management', () => {
|
|||||||
// Mock the streaming state and thought
|
// Mock the streaming state and thought
|
||||||
const thoughtSubject = 'Confirm tool execution';
|
const thoughtSubject = 'Confirm tool execution';
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'waiting_for_confirmation',
|
streamingState: 'waiting_for_confirmation',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: thoughtSubject },
|
thought: { subject: thoughtSubject },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act: Render the container
|
// Act: Render the container
|
||||||
@@ -1448,16 +1434,11 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Mock an active shell pty but not focused
|
// Mock an active shell pty but not focused
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: 'Executing shell command' },
|
thought: { subject: 'Executing shell command' },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
pendingToolCalls: [],
|
pendingToolCalls: [],
|
||||||
handleApprovalModeChange: vi.fn(),
|
|
||||||
activePtyId: 'pty-1',
|
activePtyId: 'pty-1',
|
||||||
loopDetectionConfirmationRequest: null,
|
|
||||||
lastOutputTime: startTime + 100, // Trigger aggressive delay
|
lastOutputTime: startTime + 100, // Trigger aggressive delay
|
||||||
retryStatus: null,
|
retryStatus: null,
|
||||||
});
|
});
|
||||||
@@ -1512,12 +1493,9 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Mock an active shell pty with redirection active
|
// Mock an active shell pty with redirection active
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: 'Executing shell command' },
|
thought: { subject: 'Executing shell command' },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
pendingToolCalls: [
|
pendingToolCalls: [
|
||||||
{
|
{
|
||||||
request: {
|
request: {
|
||||||
@@ -1527,9 +1505,7 @@ describe('AppContainer State Management', () => {
|
|||||||
status: 'executing',
|
status: 'executing',
|
||||||
} as unknown as TrackedToolCall,
|
} as unknown as TrackedToolCall,
|
||||||
],
|
],
|
||||||
handleApprovalModeChange: vi.fn(),
|
|
||||||
activePtyId: 'pty-1',
|
activePtyId: 'pty-1',
|
||||||
loopDetectionConfirmationRequest: null,
|
|
||||||
lastOutputTime: startTime,
|
lastOutputTime: startTime,
|
||||||
retryStatus: null,
|
retryStatus: null,
|
||||||
});
|
});
|
||||||
@@ -1587,16 +1563,11 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Mock an active shell pty with NO output since operation started (silent)
|
// Mock an active shell pty with NO output since operation started (silent)
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: 'Executing shell command' },
|
thought: { subject: 'Executing shell command' },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
pendingToolCalls: [],
|
pendingToolCalls: [],
|
||||||
handleApprovalModeChange: vi.fn(),
|
|
||||||
activePtyId: 'pty-1',
|
activePtyId: 'pty-1',
|
||||||
loopDetectionConfirmationRequest: null,
|
|
||||||
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
|
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
|
||||||
retryStatus: null,
|
retryStatus: null,
|
||||||
});
|
});
|
||||||
@@ -1643,12 +1614,9 @@ describe('AppContainer State Management', () => {
|
|||||||
// Mock an active shell pty but not focused
|
// Mock an active shell pty but not focused
|
||||||
let lastOutputTime = startTime + 1000;
|
let lastOutputTime = startTime + 1000;
|
||||||
mockedUseGeminiStream.mockImplementation(() => ({
|
mockedUseGeminiStream.mockImplementation(() => ({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: 'Executing shell command' },
|
thought: { subject: 'Executing shell command' },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
activePtyId: 'pty-1',
|
activePtyId: 'pty-1',
|
||||||
lastOutputTime,
|
lastOutputTime,
|
||||||
}));
|
}));
|
||||||
@@ -1669,12 +1637,9 @@ describe('AppContainer State Management', () => {
|
|||||||
// Update lastOutputTime to simulate new output
|
// Update lastOutputTime to simulate new output
|
||||||
lastOutputTime = startTime + 21000;
|
lastOutputTime = startTime + 21000;
|
||||||
mockedUseGeminiStream.mockImplementation(() => ({
|
mockedUseGeminiStream.mockImplementation(() => ({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: 'Executing shell command' },
|
thought: { subject: 'Executing shell command' },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
activePtyId: 'pty-1',
|
activePtyId: 'pty-1',
|
||||||
lastOutputTime,
|
lastOutputTime,
|
||||||
}));
|
}));
|
||||||
@@ -1734,12 +1699,9 @@ describe('AppContainer State Management', () => {
|
|||||||
// Mock the streaming state and thought with a short subject
|
// Mock the streaming state and thought with a short subject
|
||||||
const shortTitle = 'Short';
|
const shortTitle = 'Short';
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: shortTitle },
|
thought: { subject: shortTitle },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act: Render the container
|
// Act: Render the container
|
||||||
@@ -1778,12 +1740,9 @@ describe('AppContainer State Management', () => {
|
|||||||
// Mock the streaming state and thought
|
// Mock the streaming state and thought
|
||||||
const title = 'Test Title';
|
const title = 'Test Title';
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: { subject: title },
|
thought: { subject: title },
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act: Render the container
|
// Act: Render the container
|
||||||
@@ -1821,12 +1780,8 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Mock the streaming state
|
// Mock the streaming state
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: null,
|
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act: Render the container
|
// Act: Render the container
|
||||||
@@ -1928,12 +1883,7 @@ describe('AppContainer State Management', () => {
|
|||||||
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
|
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
|
||||||
|
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
streamingState: 'idle',
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: null,
|
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
activePtyId: 'some-id',
|
activePtyId: 'some-id',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1952,7 +1902,7 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||||
let handleGlobalKeypress: (key: Key) => void;
|
let handleGlobalKeypress: (key: Key) => boolean;
|
||||||
let mockHandleSlashCommand: Mock;
|
let mockHandleSlashCommand: Mock;
|
||||||
let mockCancelOngoingRequest: Mock;
|
let mockCancelOngoingRequest: Mock;
|
||||||
let rerender: () => void;
|
let rerender: () => void;
|
||||||
@@ -1987,9 +1937,11 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Capture the keypress handler from the AppContainer
|
// Capture the keypress handler from the AppContainer
|
||||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
mockedUseKeypress.mockImplementation(
|
||||||
handleGlobalKeypress = callback;
|
(callback: (key: Key) => boolean) => {
|
||||||
});
|
handleGlobalKeypress = callback;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Mock slash command handler
|
// Mock slash command handler
|
||||||
mockHandleSlashCommand = vi.fn();
|
mockHandleSlashCommand = vi.fn();
|
||||||
@@ -2005,11 +1957,7 @@ describe('AppContainer State Management', () => {
|
|||||||
// Mock request cancellation
|
// Mock request cancellation
|
||||||
mockCancelOngoingRequest = vi.fn();
|
mockCancelOngoingRequest = vi.fn();
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
streamingState: 'idle',
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: null,
|
|
||||||
cancelOngoingRequest: mockCancelOngoingRequest,
|
cancelOngoingRequest: mockCancelOngoingRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2017,6 +1965,9 @@ describe('AppContainer State Management', () => {
|
|||||||
mockedUseTextBuffer.mockReturnValue({
|
mockedUseTextBuffer.mockReturnValue({
|
||||||
text: '',
|
text: '',
|
||||||
setText: vi.fn(),
|
setText: vi.fn(),
|
||||||
|
lines: [''],
|
||||||
|
cursor: [0, 0],
|
||||||
|
handleInput: vi.fn().mockReturnValue(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -2030,11 +1981,8 @@ describe('AppContainer State Management', () => {
|
|||||||
describe('CTRL+C', () => {
|
describe('CTRL+C', () => {
|
||||||
it('should cancel ongoing request on first press', async () => {
|
it('should cancel ongoing request on first press', async () => {
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
streamingState: 'responding',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: null,
|
|
||||||
cancelOngoingRequest: mockCancelOngoingRequest,
|
cancelOngoingRequest: mockCancelOngoingRequest,
|
||||||
});
|
});
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
@@ -2079,19 +2027,6 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('CTRL+D', () => {
|
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 () => {
|
it('should quit on second press if buffer is empty', async () => {
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
@@ -2106,6 +2041,50 @@ describe('AppContainer State Management', () => {
|
|||||||
unmount();
|
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 () => {
|
it('should reset press count after a timeout', async () => {
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
@@ -2125,7 +2104,7 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Copy Mode (CTRL+S)', () => {
|
describe('Copy Mode (CTRL+S)', () => {
|
||||||
let handleGlobalKeypress: (key: Key) => void;
|
let handleGlobalKeypress: (key: Key) => boolean;
|
||||||
let rerender: () => void;
|
let rerender: () => void;
|
||||||
let unmount: () => void;
|
let unmount: () => void;
|
||||||
|
|
||||||
@@ -2155,9 +2134,11 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.mockStdout.write.mockClear();
|
mocks.mockStdout.write.mockClear();
|
||||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
mockedUseKeypress.mockImplementation(
|
||||||
handleGlobalKeypress = callback;
|
(callback: (key: Key) => boolean) => {
|
||||||
});
|
handleGlobalKeypress = callback;
|
||||||
|
},
|
||||||
|
);
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2510,6 +2491,59 @@ describe('AppContainer State Management', () => {
|
|||||||
expect(capturedUIState.activeHooks).toEqual(mockHooks);
|
expect(capturedUIState.activeHooks).toEqual(mockHooks);
|
||||||
unmount!();
|
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', () => {
|
describe('Shell Interaction', () => {
|
||||||
@@ -2521,12 +2555,7 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
streamingState: 'idle',
|
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||||
submitQuery: vi.fn(),
|
|
||||||
initError: null,
|
|
||||||
pendingHistoryItems: [],
|
|
||||||
thought: null,
|
|
||||||
cancelOngoingRequest: vi.fn(),
|
|
||||||
activePtyId: 'some-pty-id', // Make sure activePtyId is set
|
activePtyId: 'some-pty-id', // Make sure activePtyId is set
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2676,41 +2705,6 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
unmount!();
|
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', () => {
|
describe('Regression Tests', () => {
|
||||||
|
|||||||
@@ -27,13 +27,10 @@ import {
|
|||||||
type HistoryItemWithoutId,
|
type HistoryItemWithoutId,
|
||||||
type HistoryItemToolGroup,
|
type HistoryItemToolGroup,
|
||||||
AuthState,
|
AuthState,
|
||||||
|
type ConfirmationRequest,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { MessageType, StreamingState } from './types.js';
|
import { MessageType, StreamingState } from './types.js';
|
||||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||||
import {
|
|
||||||
AskUserActionsProvider,
|
|
||||||
type AskUserState,
|
|
||||||
} from './contexts/AskUserActionsContext.js';
|
|
||||||
import {
|
import {
|
||||||
type EditorType,
|
type EditorType,
|
||||||
type Config,
|
type Config,
|
||||||
@@ -68,8 +65,7 @@ import {
|
|||||||
SessionStartSource,
|
SessionStartSource,
|
||||||
SessionEndReason,
|
SessionEndReason,
|
||||||
generateSummary,
|
generateSummary,
|
||||||
MessageBusType,
|
type ConsentRequestPayload,
|
||||||
type AskUserRequest,
|
|
||||||
type AgentsDiscoveredPayload,
|
type AgentsDiscoveredPayload,
|
||||||
ChangeAuthRequestedError,
|
ChangeAuthRequestedError,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
@@ -97,6 +93,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
|
|||||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||||
import { useLogger } from './hooks/useLogger.js';
|
import { useLogger } from './hooks/useLogger.js';
|
||||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||||
|
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
|
||||||
import { useVim } from './hooks/vim.js';
|
import { useVim } from './hooks/vim.js';
|
||||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||||
import { type InitializationResult } from '../core/initializer.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 { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||||
import { useBanner } from './hooks/useBanner.js';
|
import { useBanner } from './hooks/useBanner.js';
|
||||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||||
|
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
|
||||||
import {
|
import {
|
||||||
WARNING_PROMPT_DURATION_MS,
|
WARNING_PROMPT_DURATION_MS,
|
||||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||||
@@ -257,6 +255,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
);
|
);
|
||||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||||
const [pendingRestorePrompt, setPendingRestorePrompt] = 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 [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
|
||||||
|
|
||||||
const [shellModeActive, setShellModeActive] = useState(false);
|
const [shellModeActive, setShellModeActive] = useState(false);
|
||||||
@@ -336,11 +338,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
AgentDefinition | undefined
|
AgentDefinition | undefined
|
||||||
>();
|
>();
|
||||||
|
|
||||||
// AskUser dialog state
|
|
||||||
const [askUserRequest, setAskUserRequest] = useState<AskUserState | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const openAgentConfigDialog = useCallback(
|
const openAgentConfigDialog = useCallback(
|
||||||
(name: string, displayName: string, definition: AgentDefinition) => {
|
(name: string, displayName: string, definition: AgentDefinition) => {
|
||||||
setSelectedAgentName(name);
|
setSelectedAgentName(name);
|
||||||
@@ -358,56 +355,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
setSelectedAgentDefinition(undefined);
|
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(
|
const toggleDebugProfiler = useCallback(
|
||||||
() => setShowDebugProfiler((prev) => !prev),
|
() => setShowDebugProfiler((prev) => !prev),
|
||||||
[],
|
[],
|
||||||
@@ -487,6 +434,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
registerCleanup(async () => {
|
registerCleanup(async () => {
|
||||||
// Turn off mouse scroll.
|
// Turn off mouse scroll.
|
||||||
disableMouseEvents();
|
disableMouseEvents();
|
||||||
|
|
||||||
|
// Kill all background shells
|
||||||
|
for (const pid of backgroundShellsRef.current.keys()) {
|
||||||
|
ShellExecutionService.kill(pid);
|
||||||
|
}
|
||||||
|
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.disconnect();
|
await ideClient.disconnect();
|
||||||
|
|
||||||
@@ -579,6 +532,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
shellModeActive,
|
shellModeActive,
|
||||||
getPreferredEditor,
|
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)
|
// Initialize input history from logger (past sessions)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -835,6 +796,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
const { toggleVimEnabled } = useVimMode();
|
const { toggleVimEnabled } = useVimMode();
|
||||||
|
|
||||||
|
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
const slashCommandActions = useMemo(
|
const slashCommandActions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
openAuthDialog: () => setAuthState(AuthState.Updating),
|
openAuthDialog: () => setAuthState(AuthState.Updating),
|
||||||
@@ -858,7 +823,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
toggleDebugProfiler,
|
toggleDebugProfiler,
|
||||||
dispatchExtensionStateUpdate,
|
dispatchExtensionStateUpdate,
|
||||||
addConfirmUpdateExtensionRequest,
|
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,
|
setAuthState,
|
||||||
@@ -876,7 +852,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
openPermissionsDialog,
|
openPermissionsDialog,
|
||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
toggleDebugProfiler,
|
toggleDebugProfiler,
|
||||||
buffer,
|
stableSetText,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -885,7 +861,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
slashCommands,
|
slashCommands,
|
||||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
confirmationRequest,
|
confirmationRequest: commandConfirmationRequest,
|
||||||
} = useSlashCommandProcessor(
|
} = useSlashCommandProcessor(
|
||||||
config,
|
config,
|
||||||
settings,
|
settings,
|
||||||
@@ -902,6 +878,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
setCustomDialog,
|
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 () => {
|
const performMemoryRefresh = useCallback(async () => {
|
||||||
historyManager.addItem(
|
historyManager.addItem(
|
||||||
{
|
{
|
||||||
@@ -989,6 +985,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
activePtyId,
|
activePtyId,
|
||||||
loopDetectionConfirmationRequest,
|
loopDetectionConfirmationRequest,
|
||||||
lastOutputTime,
|
lastOutputTime,
|
||||||
|
backgroundShellCount,
|
||||||
|
isBackgroundShellVisible,
|
||||||
|
toggleBackgroundShell,
|
||||||
|
backgroundCurrentShell,
|
||||||
|
backgroundShells,
|
||||||
|
dismissBackgroundShell,
|
||||||
retryStatus,
|
retryStatus,
|
||||||
} = useGeminiStream(
|
} = useGeminiStream(
|
||||||
config.getGeminiClient(),
|
config.getGeminiClient(),
|
||||||
@@ -1011,7 +1013,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
embeddedShellFocused,
|
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);
|
const lastOutputTimeRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
lastOutputTimeRef.current = lastOutputTime;
|
lastOutputTimeRef.current = lastOutputTime;
|
||||||
}, [lastOutputTime]);
|
}, [lastOutputTime]);
|
||||||
@@ -1160,7 +1185,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
// Compute available terminal height based on controls measurement
|
// Compute available terminal height based on controls measurement
|
||||||
const availableTerminalHeight = Math.max(
|
const availableTerminalHeight = Math.max(
|
||||||
0,
|
0,
|
||||||
terminalHeight - controlsHeight - staticExtraHeight - 2,
|
terminalHeight -
|
||||||
|
controlsHeight -
|
||||||
|
staticExtraHeight -
|
||||||
|
2 -
|
||||||
|
backgroundShellHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
config.setShellExecutionConfig({
|
config.setShellExecutionConfig({
|
||||||
@@ -1384,7 +1413,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
if (ctrlCPressCount > 1) {
|
if (ctrlCPressCount > 1) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
handleSlashCommand('/quit', undefined, undefined, false);
|
handleSlashCommand('/quit', undefined, undefined, false);
|
||||||
} else {
|
} else if (ctrlCPressCount > 0) {
|
||||||
ctrlCTimerRef.current = setTimeout(() => {
|
ctrlCTimerRef.current = setTimeout(() => {
|
||||||
setCtrlCPressCount(0);
|
setCtrlCPressCount(0);
|
||||||
ctrlCTimerRef.current = null;
|
ctrlCTimerRef.current = null;
|
||||||
@@ -1403,7 +1432,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
if (ctrlDPressCount > 1) {
|
if (ctrlDPressCount > 1) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
handleSlashCommand('/quit', undefined, undefined, false);
|
handleSlashCommand('/quit', undefined, undefined, false);
|
||||||
} else {
|
} else if (ctrlDPressCount > 0) {
|
||||||
ctrlDTimerRef.current = setTimeout(() => {
|
ctrlDTimerRef.current = setTimeout(() => {
|
||||||
setCtrlDPressCount(0);
|
setCtrlDPressCount(0);
|
||||||
ctrlDTimerRef.current = null;
|
ctrlDTimerRef.current = null;
|
||||||
@@ -1444,7 +1473,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleGlobalKeypress = useCallback(
|
const handleGlobalKeypress = useCallback(
|
||||||
(key: Key) => {
|
(key: Key): boolean => {
|
||||||
if (copyModeEnabled) {
|
if (copyModeEnabled) {
|
||||||
setCopyModeEnabled(false);
|
setCopyModeEnabled(false);
|
||||||
enableMouseEvents();
|
enableMouseEvents();
|
||||||
@@ -1464,10 +1493,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (keyMatchers[Command.QUIT](key)) {
|
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.
|
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||||
// This should happen regardless of the count.
|
// This should happen regardless of the count.
|
||||||
cancelOngoingRequest?.();
|
cancelOngoingRequest?.();
|
||||||
@@ -1475,9 +1500,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
setCtrlCPressCount((prev) => prev + 1);
|
setCtrlCPressCount((prev) => prev + 1);
|
||||||
return true;
|
return true;
|
||||||
} else if (keyMatchers[Command.EXIT](key)) {
|
} else if (keyMatchers[Command.EXIT](key)) {
|
||||||
if (buffer.text.length > 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setCtrlDPressCount((prev) => prev + 1);
|
setCtrlDPressCount((prev) => prev + 1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1520,9 +1542,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
setConstrainHeight(false);
|
setConstrainHeight(false);
|
||||||
return true;
|
return true;
|
||||||
} else if (
|
} else if (
|
||||||
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) &&
|
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
|
||||||
activePtyId &&
|
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||||
embeddedShellFocused
|
|
||||||
) {
|
) {
|
||||||
if (key.name === 'tab' && key.shift) {
|
if (key.name === 'tab' && key.shift) {
|
||||||
// Always change focus
|
// Always change focus
|
||||||
@@ -1530,26 +1551,72 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (embeddedShellFocused) {
|
||||||
|
handleWarning('Press Shift+Tab to focus out.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// If the shell hasn't produced output in the last 100ms, it's considered idle.
|
// If the shell hasn't produced output in the last 100ms, it's considered idle.
|
||||||
const isIdle = now - lastOutputTimeRef.current >= 100;
|
const isIdle = now - lastOutputTimeRef.current >= 100;
|
||||||
if (isIdle) {
|
if (isIdle && !activePtyId) {
|
||||||
if (tabFocusTimeoutRef.current) {
|
if (tabFocusTimeoutRef.current) {
|
||||||
clearTimeout(tabFocusTimeoutRef.current);
|
clearTimeout(tabFocusTimeoutRef.current);
|
||||||
}
|
}
|
||||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
toggleBackgroundShell();
|
||||||
tabFocusTimeoutRef.current = null;
|
if (!isBackgroundShellVisible) {
|
||||||
// If the shell produced output since the tab press, we assume it handled the tab
|
// We are about to show it, so focus it
|
||||||
// (e.g. autocomplete) so we should not toggle focus.
|
setEmbeddedShellFocused(true);
|
||||||
if (lastOutputTimeRef.current > now) {
|
if (backgroundShells.size > 1) {
|
||||||
handleWarning('Press Shift+Tab to focus out.');
|
setIsBackgroundShellListOpen(true);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setEmbeddedShellFocused(false);
|
} else {
|
||||||
}, 100);
|
// 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;
|
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 true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -1561,11 +1628,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
config,
|
config,
|
||||||
ideContextState,
|
ideContextState,
|
||||||
setCtrlCPressCount,
|
setCtrlCPressCount,
|
||||||
buffer.text.length,
|
|
||||||
setCtrlDPressCount,
|
setCtrlDPressCount,
|
||||||
handleSlashCommand,
|
handleSlashCommand,
|
||||||
cancelOngoingRequest,
|
cancelOngoingRequest,
|
||||||
askUserRequest,
|
|
||||||
activePtyId,
|
activePtyId,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
settings.merged.general.debugKeystrokeLogging,
|
settings.merged.general.debugKeystrokeLogging,
|
||||||
@@ -1573,6 +1638,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
setCopyModeEnabled,
|
setCopyModeEnabled,
|
||||||
copyModeEnabled,
|
copyModeEnabled,
|
||||||
isAlternateBuffer,
|
isAlternateBuffer,
|
||||||
|
backgroundCurrentShell,
|
||||||
|
toggleBackgroundShell,
|
||||||
|
backgroundShells,
|
||||||
|
isBackgroundShellVisible,
|
||||||
|
setIsBackgroundShellListOpen,
|
||||||
|
lastOutputTimeRef,
|
||||||
|
tabFocusTimeoutRef,
|
||||||
handleWarning,
|
handleWarning,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1586,7 +1658,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
const paddedTitle = computeTerminalTitle({
|
const paddedTitle = computeTerminalTitle({
|
||||||
streamingState,
|
streamingState,
|
||||||
thoughtSubject: thought?.subject,
|
thoughtSubject: thought?.subject,
|
||||||
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
|
isConfirming:
|
||||||
|
!!commandConfirmationRequest || shouldShowActionRequiredTitle,
|
||||||
isSilentWorking: shouldShowSilentWorkingTitle,
|
isSilentWorking: shouldShowSilentWorkingTitle,
|
||||||
folderName: basename(config.getTargetDir()),
|
folderName: basename(config.getTargetDir()),
|
||||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||||
@@ -1602,7 +1675,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
}, [
|
}, [
|
||||||
streamingState,
|
streamingState,
|
||||||
thought,
|
thought,
|
||||||
confirmationRequest,
|
commandConfirmationRequest,
|
||||||
shouldShowActionRequiredTitle,
|
shouldShowActionRequiredTitle,
|
||||||
shouldShowSilentWorkingTitle,
|
shouldShowSilentWorkingTitle,
|
||||||
settings.merged.ui.showStatusInTitle,
|
settings.merged.ui.showStatusInTitle,
|
||||||
@@ -1678,11 +1751,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
const nightly = props.version.includes('nightly');
|
const nightly = props.version.includes('nightly');
|
||||||
|
|
||||||
const dialogsVisible =
|
const dialogsVisible =
|
||||||
!!askUserRequest ||
|
|
||||||
shouldShowIdePrompt ||
|
shouldShowIdePrompt ||
|
||||||
isFolderTrustDialogOpen ||
|
isFolderTrustDialogOpen ||
|
||||||
adminSettingsChanged ||
|
adminSettingsChanged ||
|
||||||
!!confirmationRequest ||
|
!!commandConfirmationRequest ||
|
||||||
|
!!authConsentRequest ||
|
||||||
!!customDialog ||
|
!!customDialog ||
|
||||||
confirmUpdateExtensionRequests.length > 0 ||
|
confirmUpdateExtensionRequests.length > 0 ||
|
||||||
!!loopDetectionConfirmationRequest ||
|
!!loopDetectionConfirmationRequest ||
|
||||||
@@ -1792,7 +1865,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
confirmationRequest,
|
commandConfirmationRequest,
|
||||||
|
authConsentRequest,
|
||||||
confirmUpdateExtensionRequests,
|
confirmUpdateExtensionRequests,
|
||||||
loopDetectionConfirmationRequest,
|
loopDetectionConfirmationRequest,
|
||||||
geminiMdFileCount,
|
geminiMdFileCount,
|
||||||
@@ -1853,6 +1927,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isRestarting,
|
isRestarting,
|
||||||
extensionsUpdateState,
|
extensionsUpdateState,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
|
backgroundShellCount,
|
||||||
|
isBackgroundShellVisible,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
showDebugProfiler,
|
showDebugProfiler,
|
||||||
customDialog,
|
customDialog,
|
||||||
@@ -1862,6 +1938,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
bannerVisible,
|
bannerVisible,
|
||||||
terminalBackgroundColor: config.getTerminalBackground(),
|
terminalBackgroundColor: config.getTerminalBackground(),
|
||||||
settingsNonce,
|
settingsNonce,
|
||||||
|
backgroundShells,
|
||||||
|
activeBackgroundShellPid,
|
||||||
|
backgroundShellHeight,
|
||||||
|
isBackgroundShellListOpen,
|
||||||
adminSettingsChanged,
|
adminSettingsChanged,
|
||||||
newAgents,
|
newAgents,
|
||||||
}),
|
}),
|
||||||
@@ -1890,7 +1970,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
confirmationRequest,
|
commandConfirmationRequest,
|
||||||
|
authConsentRequest,
|
||||||
confirmUpdateExtensionRequests,
|
confirmUpdateExtensionRequests,
|
||||||
loopDetectionConfirmationRequest,
|
loopDetectionConfirmationRequest,
|
||||||
geminiMdFileCount,
|
geminiMdFileCount,
|
||||||
@@ -1951,6 +2032,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
currentModel,
|
currentModel,
|
||||||
extensionsUpdateState,
|
extensionsUpdateState,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
|
backgroundShellCount,
|
||||||
|
isBackgroundShellVisible,
|
||||||
historyManager,
|
historyManager,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
showDebugProfiler,
|
showDebugProfiler,
|
||||||
@@ -1963,6 +2046,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
bannerVisible,
|
bannerVisible,
|
||||||
config,
|
config,
|
||||||
settingsNonce,
|
settingsNonce,
|
||||||
|
backgroundShellHeight,
|
||||||
|
isBackgroundShellListOpen,
|
||||||
|
activeBackgroundShellPid,
|
||||||
|
backgroundShells,
|
||||||
adminSettingsChanged,
|
adminSettingsChanged,
|
||||||
newAgents,
|
newAgents,
|
||||||
],
|
],
|
||||||
@@ -2010,7 +2097,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
handleApiKeySubmit,
|
handleApiKeySubmit,
|
||||||
handleApiKeyCancel,
|
handleApiKeyCancel,
|
||||||
setBannerVisible,
|
setBannerVisible,
|
||||||
|
handleWarning,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
|
dismissBackgroundShell,
|
||||||
|
setActiveBackgroundShellPid,
|
||||||
|
setIsBackgroundShellListOpen,
|
||||||
setAuthContext,
|
setAuthContext,
|
||||||
handleRestart: async () => {
|
handleRestart: async () => {
|
||||||
if (process.send) {
|
if (process.send) {
|
||||||
@@ -2082,7 +2173,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
handleApiKeySubmit,
|
handleApiKeySubmit,
|
||||||
handleApiKeyCancel,
|
handleApiKeyCancel,
|
||||||
setBannerVisible,
|
setBannerVisible,
|
||||||
|
handleWarning,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
|
dismissBackgroundShell,
|
||||||
|
setActiveBackgroundShellPid,
|
||||||
|
setIsBackgroundShellListOpen,
|
||||||
setAuthContext,
|
setAuthContext,
|
||||||
newAgents,
|
newAgents,
|
||||||
config,
|
config,
|
||||||
@@ -2113,15 +2208,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||||
<AskUserActionsProvider
|
<ShellFocusContext.Provider value={isFocused}>
|
||||||
request={askUserRequest}
|
<App />
|
||||||
onSubmit={handleAskUserSubmit}
|
</ShellFocusContext.Provider>
|
||||||
onCancel={handleAskUserCancel}
|
|
||||||
>
|
|
||||||
<ShellFocusContext.Provider value={isFocused}>
|
|
||||||
<App />
|
|
||||||
</ShellFocusContext.Provider>
|
|
||||||
</AskUserActionsProvider>
|
|
||||||
</ToolActionsProvider>
|
</ToolActionsProvider>
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
</ConfigContext.Provider>
|
</ConfigContext.Provider>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ Tips for getting started:
|
|||||||
4. /help for more information.
|
4. /help for more information.
|
||||||
HistoryItemDisplay
|
HistoryItemDisplay
|
||||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
│ Action Required 1 of 1 │
|
│ Action Required │
|
||||||
│ │
|
│ │
|
||||||
│ ? ls list directory │
|
│ ? ls list directory │
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ describe('hooksCommand', () => {
|
|||||||
hooksConfig?: {
|
hooksConfig?: {
|
||||||
disabled?: string[];
|
disabled?: string[];
|
||||||
};
|
};
|
||||||
tools?: {
|
|
||||||
enableHooks?: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
setValue: ReturnType<typeof vi.fn>;
|
setValue: ReturnType<typeof vi.fn>;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 () => {
|
it('should display panel when no hooks are configured', async () => {
|
||||||
mockHookSystem.getAllHooks.mockReturnValue([]);
|
mockHookSystem.getAllHooks.mockReturnValue([]);
|
||||||
(mockContext.services.settings.merged as Record<string, unknown>)[
|
(mockContext.services.settings.merged as Record<string, unknown>)[
|
||||||
'tools'
|
'hooksConfig'
|
||||||
] = { enableHooks: true };
|
] = { enabled: true };
|
||||||
|
|
||||||
const panelCmd = hooksCommand.subCommands!.find(
|
const panelCmd = hooksCommand.subCommands!.find(
|
||||||
(cmd) => cmd.name === 'panel',
|
(cmd) => cmd.name === 'panel',
|
||||||
@@ -215,8 +212,8 @@ describe('hooksCommand', () => {
|
|||||||
|
|
||||||
mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
|
mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
|
||||||
(mockContext.services.settings.merged as Record<string, unknown>)[
|
(mockContext.services.settings.merged as Record<string, unknown>)[
|
||||||
'tools'
|
'hooksConfig'
|
||||||
] = { enableHooks: true };
|
] = { enabled: true };
|
||||||
|
|
||||||
const panelCmd = hooksCommand.subCommands!.find(
|
const panelCmd = hooksCommand.subCommands!.find(
|
||||||
(cmd) => cmd.name === 'panel',
|
(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,
|
(name: string) => skills.find((s) => s.name === name) ?? null,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
getContentGenerator: vi.fn(),
|
||||||
} as unknown as Config,
|
} as unknown as Config,
|
||||||
settings: {
|
settings: {
|
||||||
merged: createTestMergedSettings({ skills: { disabled: [] } }),
|
merged: createTestMergedSettings({ skills: { disabled: [] } }),
|
||||||
@@ -367,7 +368,7 @@ describe('skillsCommand', () => {
|
|||||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: MessageType.ERROR,
|
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),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
@@ -385,7 +386,7 @@ describe('skillsCommand', () => {
|
|||||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: MessageType.ERROR,
|
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),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ import {
|
|||||||
CommandKind,
|
CommandKind,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import {
|
import {
|
||||||
MessageType,
|
|
||||||
type HistoryItemSkillsList,
|
|
||||||
type HistoryItemInfo,
|
type HistoryItemInfo,
|
||||||
|
type HistoryItemSkillsList,
|
||||||
|
MessageType,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
|
||||||
import { enableSkill, disableSkill } from '../../utils/skillSettings.js';
|
|
||||||
|
import { getAdminErrorMessage } from '@google/gemini-cli-core';
|
||||||
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
|
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
|
||||||
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
|
||||||
async function listAction(
|
async function listAction(
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
@@ -83,7 +85,10 @@ async function disableAction(
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: 'Agent skills are disabled by your admin.',
|
text: getAdminErrorMessage(
|
||||||
|
'Agent skills',
|
||||||
|
context.services.config ?? undefined,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -141,7 +146,10 @@ async function enableAction(
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: 'Agent skills are disabled by your admin.',
|
text: getAdminErrorMessage(
|
||||||
|
'Agent skills',
|
||||||
|
context.services.config ?? undefined,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export interface CommandContext {
|
|||||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||||
removeComponent: () => void;
|
removeComponent: () => void;
|
||||||
|
toggleBackgroundShell: () => void;
|
||||||
};
|
};
|
||||||
// Session-specific data
|
// Session-specific data
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
|
|||||||
import { waitFor } from '../../test-utils/async.js';
|
import { waitFor } from '../../test-utils/async.js';
|
||||||
import { AskUserDialog } from './AskUserDialog.js';
|
import { AskUserDialog } from './AskUserDialog.js';
|
||||||
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
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
|
// Helper to write to stdin with proper act() wrapping
|
||||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||||
@@ -41,6 +42,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={authQuestion}
|
questions={authQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -105,6 +107,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={questions}
|
questions={questions}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -124,6 +127,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={authQuestion}
|
questions={authQuestion}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ 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 () => {
|
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
|
||||||
const { stdin, lastFrame } = renderWithProviders(
|
const { stdin, lastFrame } = renderWithProviders(
|
||||||
<AskUserDialog
|
<AskUserDialog
|
||||||
questions={authQuestion}
|
questions={authQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -209,6 +258,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -222,6 +272,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={authQuestion}
|
questions={authQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -235,6 +286,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={authQuestion}
|
questions={authQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -265,6 +317,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -306,6 +359,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -373,6 +427,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -401,6 +456,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -445,6 +501,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -480,6 +537,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -512,6 +570,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={textQuestion}
|
questions={textQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -533,6 +592,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={textQuestion}
|
questions={textQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -554,6 +614,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={textQuestion}
|
questions={textQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -588,6 +649,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={textQuestion}
|
questions={textQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -618,6 +680,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={mixedQuestions}
|
questions={mixedQuestions}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -664,6 +727,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={mixedQuestions}
|
questions={mixedQuestions}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -713,6 +777,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={textQuestion}
|
questions={textQuestion}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -738,6 +803,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={textQuestion}
|
questions={textQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -783,6 +849,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
@@ -841,6 +908,7 @@ describe('AskUserDialog', () => {
|
|||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
/>,
|
/>,
|
||||||
{ 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 { checkExhaustive } from '../../utils/checks.js';
|
||||||
import { TextInput } from './shared/TextInput.js';
|
import { TextInput } from './shared/TextInput.js';
|
||||||
import { useTextBuffer } from './shared/text-buffer.js';
|
import { useTextBuffer } from './shared/text-buffer.js';
|
||||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
|
||||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||||
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
||||||
import { DialogFooter } from './shared/DialogFooter.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 {
|
interface AskUserDialogState {
|
||||||
answers: { [key: string]: string };
|
answers: { [key: string]: string };
|
||||||
@@ -121,6 +123,14 @@ interface AskUserDialogProps {
|
|||||||
* Useful for managing global keypress handlers.
|
* Useful for managing global keypress handlers.
|
||||||
*/
|
*/
|
||||||
onActiveTextInputChange?: (active: boolean) => void;
|
onActiveTextInputChange?: (active: boolean) => void;
|
||||||
|
/**
|
||||||
|
* Width of the dialog.
|
||||||
|
*/
|
||||||
|
width: number;
|
||||||
|
/**
|
||||||
|
* Height constraint for scrollable content.
|
||||||
|
*/
|
||||||
|
availableHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReviewViewProps {
|
interface ReviewViewProps {
|
||||||
@@ -152,12 +162,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box flexDirection="column">
|
||||||
flexDirection="column"
|
|
||||||
borderStyle="round"
|
|
||||||
paddingX={1}
|
|
||||||
borderColor={theme.border.default}
|
|
||||||
>
|
|
||||||
{progressHeader}
|
{progressHeader}
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
<Text bold color={theme.text.primary}>
|
<Text bold color={theme.text.primary}>
|
||||||
@@ -174,15 +179,19 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{questions.map((q, i) => (
|
<Box flexDirection="column">
|
||||||
<Box key={i} marginBottom={0}>
|
{questions.map((q, i) => (
|
||||||
<Text color={theme.text.secondary}>{q.header}</Text>
|
<Box key={i} marginBottom={0}>
|
||||||
<Text color={theme.text.secondary}> → </Text>
|
<Text color={theme.text.secondary}>{q.header}</Text>
|
||||||
<Text color={answers[i] ? theme.text.primary : theme.status.warning}>
|
<Text color={theme.text.secondary}> → </Text>
|
||||||
{answers[i] || '(not answered)'}
|
<Text
|
||||||
</Text>
|
color={answers[i] ? theme.text.primary : theme.status.warning}
|
||||||
</Box>
|
>
|
||||||
))}
|
{answers[i] || '(not answered)'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
<DialogFooter
|
<DialogFooter
|
||||||
primaryAction="Enter to submit"
|
primaryAction="Enter to submit"
|
||||||
navigationActions="Tab/Shift+Tab to edit answers"
|
navigationActions="Tab/Shift+Tab to edit answers"
|
||||||
@@ -199,6 +208,7 @@ interface TextQuestionViewProps {
|
|||||||
onSelectionChange?: (answer: string) => void;
|
onSelectionChange?: (answer: string) => void;
|
||||||
onEditingCustomOption?: (editing: boolean) => void;
|
onEditingCustomOption?: (editing: boolean) => void;
|
||||||
availableWidth: number;
|
availableWidth: number;
|
||||||
|
availableHeight?: number;
|
||||||
initialAnswer?: string;
|
initialAnswer?: string;
|
||||||
progressHeader?: React.ReactNode;
|
progressHeader?: React.ReactNode;
|
||||||
keyboardHints?: React.ReactNode;
|
keyboardHints?: React.ReactNode;
|
||||||
@@ -210,12 +220,14 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
|||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
onEditingCustomOption,
|
onEditingCustomOption,
|
||||||
availableWidth,
|
availableWidth,
|
||||||
|
availableHeight,
|
||||||
initialAnswer,
|
initialAnswer,
|
||||||
progressHeader,
|
progressHeader,
|
||||||
keyboardHints,
|
keyboardHints,
|
||||||
}) => {
|
}) => {
|
||||||
|
const isAlternateBuffer = useAlternateBuffer();
|
||||||
const prefix = '> ';
|
const prefix = '> ';
|
||||||
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
|
const horizontalPadding = 1; // 1 for cursor
|
||||||
const bufferWidth =
|
const bufferWidth =
|
||||||
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
|
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
|
||||||
|
|
||||||
@@ -241,12 +253,15 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
|||||||
const handleExtraKeys = useCallback(
|
const handleExtraKeys = useCallback(
|
||||||
(key: Key) => {
|
(key: Key) => {
|
||||||
if (keyMatchers[Command.QUIT](key)) {
|
if (keyMatchers[Command.QUIT](key)) {
|
||||||
|
if (textValue === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
buffer.setText('');
|
buffer.setText('');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[buffer],
|
[buffer, textValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||||
@@ -270,18 +285,28 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
|||||||
|
|
||||||
const placeholder = question.placeholder || 'Enter your response';
|
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 (
|
return (
|
||||||
<Box
|
<Box flexDirection="column">
|
||||||
flexDirection="column"
|
|
||||||
borderStyle="round"
|
|
||||||
paddingX={1}
|
|
||||||
borderColor={theme.border.default}
|
|
||||||
>
|
|
||||||
{progressHeader}
|
{progressHeader}
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
<Text bold color={theme.text.primary}>
|
<MaxSizedBox
|
||||||
{question.question}
|
maxHeight={questionHeight}
|
||||||
</Text>
|
maxWidth={availableWidth}
|
||||||
|
overflowDirection="bottom"
|
||||||
|
>
|
||||||
|
<Text bold color={theme.text.primary}>
|
||||||
|
{question.question}
|
||||||
|
</Text>
|
||||||
|
</MaxSizedBox>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
@@ -381,6 +406,7 @@ interface ChoiceQuestionViewProps {
|
|||||||
onSelectionChange?: (answer: string) => void;
|
onSelectionChange?: (answer: string) => void;
|
||||||
onEditingCustomOption?: (editing: boolean) => void;
|
onEditingCustomOption?: (editing: boolean) => void;
|
||||||
availableWidth: number;
|
availableWidth: number;
|
||||||
|
availableHeight?: number;
|
||||||
initialAnswer?: string;
|
initialAnswer?: string;
|
||||||
progressHeader?: React.ReactNode;
|
progressHeader?: React.ReactNode;
|
||||||
keyboardHints?: React.ReactNode;
|
keyboardHints?: React.ReactNode;
|
||||||
@@ -391,14 +417,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
|||||||
onAnswer,
|
onAnswer,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
onEditingCustomOption,
|
onEditingCustomOption,
|
||||||
|
availableWidth,
|
||||||
|
availableHeight,
|
||||||
initialAnswer,
|
initialAnswer,
|
||||||
progressHeader,
|
progressHeader,
|
||||||
keyboardHints,
|
keyboardHints,
|
||||||
}) => {
|
}) => {
|
||||||
const uiState = useContext(UIStateContext);
|
const isAlternateBuffer = useAlternateBuffer();
|
||||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
|
||||||
const availableWidth = terminalWidth;
|
|
||||||
|
|
||||||
const numOptions =
|
const numOptions =
|
||||||
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
|
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
|
||||||
const numLen = String(numOptions).length;
|
const numLen = String(numOptions).length;
|
||||||
@@ -407,15 +432,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
|||||||
const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " "
|
const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " "
|
||||||
const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓"
|
const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓"
|
||||||
const cursorPadding = 1; // Extra character for cursor at end of line
|
const cursorPadding = 1; // Extra character for cursor at end of line
|
||||||
const outerBoxPadding = 4; // border (2) + paddingX (2)
|
|
||||||
|
|
||||||
const horizontalPadding =
|
const horizontalPadding =
|
||||||
outerBoxPadding +
|
radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding;
|
||||||
radioWidth +
|
|
||||||
numberWidth +
|
|
||||||
checkboxWidth +
|
|
||||||
checkmarkWidth +
|
|
||||||
cursorPadding;
|
|
||||||
|
|
||||||
const bufferWidth = availableWidth - horizontalPadding;
|
const bufferWidth = availableWidth - horizontalPadding;
|
||||||
|
|
||||||
@@ -544,6 +563,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
|||||||
(key: Key) => {
|
(key: Key) => {
|
||||||
// If focusing custom option, handle Ctrl+C
|
// If focusing custom option, handle Ctrl+C
|
||||||
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
|
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
|
||||||
|
if (customOptionText === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
customBuffer.setText('');
|
customBuffer.setText('');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -586,7 +608,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
|
[
|
||||||
|
isCustomOptionFocused,
|
||||||
|
customBuffer,
|
||||||
|
onEditingCustomOption,
|
||||||
|
customOptionText,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||||
@@ -698,31 +725,50 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
|||||||
}
|
}
|
||||||
}, [customOptionText, isCustomOptionSelected, question.multiSelect]);
|
}, [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 (
|
return (
|
||||||
<Box
|
<Box flexDirection="column">
|
||||||
flexDirection="column"
|
|
||||||
borderStyle="round"
|
|
||||||
paddingX={1}
|
|
||||||
borderColor={theme.border.default}
|
|
||||||
>
|
|
||||||
{progressHeader}
|
{progressHeader}
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={TITLE_MARGIN}>
|
||||||
<Text bold color={theme.text.primary}>
|
<MaxSizedBox
|
||||||
{question.question}
|
maxHeight={questionHeight}
|
||||||
</Text>
|
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>
|
</Box>
|
||||||
{question.multiSelect && (
|
|
||||||
<Text color={theme.text.secondary} italic>
|
|
||||||
{' '}
|
|
||||||
(Select all that apply)
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<BaseSelectionList<OptionItem>
|
<BaseSelectionList<OptionItem>
|
||||||
items={selectionItems}
|
items={selectionItems}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onHighlight={handleHighlight}
|
onHighlight={handleHighlight}
|
||||||
focusKey={isCustomOptionFocused ? 'other' : undefined}
|
focusKey={isCustomOptionFocused ? 'other' : undefined}
|
||||||
|
maxItemsToShow={maxItemsToShow}
|
||||||
|
showScrollArrows={true}
|
||||||
renderItem={(item, context) => {
|
renderItem={(item, context) => {
|
||||||
const optionItem = item.value;
|
const optionItem = item.value;
|
||||||
const isChecked =
|
const isChecked =
|
||||||
@@ -804,14 +850,19 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
onActiveTextInputChange,
|
onActiveTextInputChange,
|
||||||
|
width,
|
||||||
|
availableHeight: availableHeightProp,
|
||||||
}) => {
|
}) => {
|
||||||
|
const uiState = useContext(UIStateContext);
|
||||||
|
const availableHeight =
|
||||||
|
availableHeightProp ??
|
||||||
|
(uiState?.constrainHeight !== false
|
||||||
|
? uiState?.availableTerminalHeight
|
||||||
|
: undefined);
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
|
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
|
||||||
const { answers, isEditingCustomOption, submitted } = state;
|
const { answers, isEditingCustomOption, submitted } = state;
|
||||||
|
|
||||||
const uiState = useContext(UIStateContext);
|
|
||||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
|
||||||
const availableWidth = terminalWidth;
|
|
||||||
|
|
||||||
const reviewTabIndex = questions.length;
|
const reviewTabIndex = questions.length;
|
||||||
const tabCount =
|
const tabCount =
|
||||||
questions.length > 1 ? questions.length + 1 : questions.length;
|
questions.length > 1 ? questions.length + 1 : questions.length;
|
||||||
@@ -842,9 +893,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
|||||||
if (keyMatchers[Command.ESCAPE](key)) {
|
if (keyMatchers[Command.ESCAPE](key)) {
|
||||||
onCancel();
|
onCancel();
|
||||||
return true;
|
return true;
|
||||||
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
|
} else if (keyMatchers[Command.QUIT](key)) {
|
||||||
onCancel();
|
if (!isEditingCustomOption) {
|
||||||
return true;
|
onCancel();
|
||||||
|
}
|
||||||
|
// Return false to let ctrl-C bubble up to AppContainer for exit flow
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
@@ -1021,7 +1075,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
|||||||
onAnswer={handleAnswer}
|
onAnswer={handleAnswer}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
onEditingCustomOption={handleEditingCustomOption}
|
onEditingCustomOption={handleEditingCustomOption}
|
||||||
availableWidth={availableWidth}
|
availableWidth={width}
|
||||||
|
availableHeight={availableHeight}
|
||||||
initialAnswer={answers[currentQuestionIndex]}
|
initialAnswer={answers[currentQuestionIndex]}
|
||||||
progressHeader={progressHeader}
|
progressHeader={progressHeader}
|
||||||
keyboardHints={keyboardHints}
|
keyboardHints={keyboardHints}
|
||||||
@@ -1033,7 +1088,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
|||||||
onAnswer={handleAnswer}
|
onAnswer={handleAnswer}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
onEditingCustomOption={handleEditingCustomOption}
|
onEditingCustomOption={handleEditingCustomOption}
|
||||||
availableWidth={availableWidth}
|
availableWidth={width}
|
||||||
|
availableHeight={availableHeight}
|
||||||
initialAnswer={answers[currentQuestionIndex]}
|
initialAnswer={answers[currentQuestionIndex]}
|
||||||
progressHeader={progressHeader}
|
progressHeader={progressHeader}
|
||||||
keyboardHints={keyboardHints}
|
keyboardHints={keyboardHints}
|
||||||
@@ -1043,7 +1099,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width={availableWidth}
|
width={width}
|
||||||
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
|
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
|
||||||
>
|
>
|
||||||
{questionView}
|
{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}
|
questions={choiceQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
|
width={120}
|
||||||
|
availableHeight={20}
|
||||||
/>,
|
/>,
|
||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|||||||
nightly: false,
|
nightly: false,
|
||||||
isTrustedFolder: true,
|
isTrustedFolder: true,
|
||||||
activeHooks: [],
|
activeHooks: [],
|
||||||
|
isBackgroundShellVisible: false,
|
||||||
|
embeddedShellFocused: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
}) as UIState;
|
}) as UIState;
|
||||||
|
|
||||||
@@ -310,6 +312,32 @@ describe('Composer', () => {
|
|||||||
expect(output).toContain('LoadingIndicator');
|
expect(output).toContain('LoadingIndicator');
|
||||||
expect(output).not.toContain('Should not show during confirmation');
|
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', () => {
|
describe('Message Queue Display', () => {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
flexGrow={0}
|
flexGrow={0}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
{!uiState.embeddedShellFocused && (
|
{(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
|
||||||
<LoadingIndicator
|
<LoadingIndicator
|
||||||
thought={
|
thought={
|
||||||
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface ContextSummaryDisplayProps {
|
|||||||
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
|
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
|
||||||
ideContext?: IdeContext;
|
ideContext?: IdeContext;
|
||||||
skillCount: number;
|
skillCount: number;
|
||||||
|
backgroundProcessCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||||
@@ -27,6 +28,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||||||
blockedMcpServers,
|
blockedMcpServers,
|
||||||
ideContext,
|
ideContext,
|
||||||
skillCount,
|
skillCount,
|
||||||
|
backgroundProcessCount = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const { columns: terminalWidth } = useTerminalSize();
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
const isNarrow = isNarrowWidth(terminalWidth);
|
const isNarrow = isNarrowWidth(terminalWidth);
|
||||||
@@ -39,7 +41,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||||||
mcpServerCount === 0 &&
|
mcpServerCount === 0 &&
|
||||||
blockedMcpServerCount === 0 &&
|
blockedMcpServerCount === 0 &&
|
||||||
openFileCount === 0 &&
|
openFileCount === 0 &&
|
||||||
skillCount === 0
|
skillCount === 0 &&
|
||||||
|
backgroundProcessCount === 0
|
||||||
) {
|
) {
|
||||||
return <Text> </Text>; // Render an empty space to reserve height
|
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' : ''}`;
|
return `${skillCount} skill${skillCount > 1 ? 's' : ''}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const summaryParts = [openFilesText, geminiMdText, mcpText, skillText].filter(
|
const backgroundText = (() => {
|
||||||
Boolean,
|
if (backgroundProcessCount === 0) {
|
||||||
);
|
return '';
|
||||||
|
}
|
||||||
|
return `${backgroundProcessCount} Background process${
|
||||||
|
backgroundProcessCount > 1 ? 'es' : ''
|
||||||
|
}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const summaryParts = [
|
||||||
|
openFilesText,
|
||||||
|
geminiMdText,
|
||||||
|
mcpText,
|
||||||
|
skillText,
|
||||||
|
backgroundText,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
if (isNarrow) {
|
if (isNarrow) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ describe('DialogManager', () => {
|
|||||||
isFolderTrustDialogOpen: false,
|
isFolderTrustDialogOpen: false,
|
||||||
loopDetectionConfirmationRequest: null,
|
loopDetectionConfirmationRequest: null,
|
||||||
confirmationRequest: null,
|
confirmationRequest: null,
|
||||||
|
consentRequest: null,
|
||||||
isThemeDialogOpen: false,
|
isThemeDialogOpen: false,
|
||||||
isSettingsDialogOpen: false,
|
isSettingsDialogOpen: false,
|
||||||
isModelDialogOpen: false,
|
isModelDialogOpen: false,
|
||||||
@@ -137,7 +138,11 @@ describe('DialogManager', () => {
|
|||||||
'LoopDetectionConfirmation',
|
'LoopDetectionConfirmation',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{ confirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
|
{ commandConfirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
|
||||||
|
'ConsentPrompt',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ authConsentRequest: { prompt: 'bar', onConfirm: vi.fn() } },
|
||||||
'ConsentPrompt',
|
'ConsentPrompt',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ import process from 'node:process';
|
|||||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
|
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
|
||||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||||
import { AskUserDialog } from './AskUserDialog.js';
|
|
||||||
import { useAskUserActions } from '../contexts/AskUserActionsContext.js';
|
|
||||||
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
||||||
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||||
|
|
||||||
@@ -59,22 +57,6 @@ export const DialogManager = ({
|
|||||||
terminalWidth: uiTerminalWidth,
|
terminalWidth: uiTerminalWidth,
|
||||||
} = uiState;
|
} = uiState;
|
||||||
|
|
||||||
const {
|
|
||||||
request: askUserRequest,
|
|
||||||
submit: askUserSubmit,
|
|
||||||
cancel: askUserCancel,
|
|
||||||
} = useAskUserActions();
|
|
||||||
|
|
||||||
if (askUserRequest) {
|
|
||||||
return (
|
|
||||||
<AskUserDialog
|
|
||||||
questions={askUserRequest.questions}
|
|
||||||
onSubmit={askUserSubmit}
|
|
||||||
onCancel={askUserCancel}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.adminSettingsChanged) {
|
if (uiState.adminSettingsChanged) {
|
||||||
return <AdminSettingsChangedDialog />;
|
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 (
|
return (
|
||||||
<ConsentPrompt
|
<ConsentPrompt
|
||||||
prompt={uiState.confirmationRequest.prompt}
|
prompt={uiState.commandConfirmationRequest.prompt}
|
||||||
onConfirm={uiState.confirmationRequest.onConfirm}
|
onConfirm={uiState.commandConfirmationRequest.onConfirm}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (uiState.authConsentRequest) {
|
||||||
|
return (
|
||||||
|
<ConsentPrompt
|
||||||
|
prompt={uiState.authConsentRequest.prompt}
|
||||||
|
onConfirm={uiState.authConsentRequest.onConfirm}
|
||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import { StreamingState } from '../types.js';
|
|||||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||||
import type { UIState } from '../contexts/UIStateContext.js';
|
import type { UIState } from '../contexts/UIStateContext.js';
|
||||||
import { isLowColorDepth } from '../utils/terminalUtils.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/useShellHistory.js');
|
||||||
vi.mock('../hooks/useCommandCompletion.js');
|
vi.mock('../hooks/useCommandCompletion.js');
|
||||||
@@ -169,7 +171,16 @@ describe('InputPrompt', () => {
|
|||||||
allVisualLines: [''],
|
allVisualLines: [''],
|
||||||
visualCursor: [0, 0],
|
visualCursor: [0, 0],
|
||||||
visualScrollRow: 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(),
|
move: vi.fn(),
|
||||||
moveToOffset: vi.fn((offset: number) => {
|
moveToOffset: vi.fn((offset: number) => {
|
||||||
mockBuffer.cursor = [0, offset];
|
mockBuffer.cursor = [0, offset];
|
||||||
@@ -499,6 +510,23 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
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', () => {
|
describe('clipboard image paste', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||||
|
|||||||
@@ -152,8 +152,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
const kittyProtocol = useKittyKeyboardProtocol();
|
const kittyProtocol = useKittyKeyboardProtocol();
|
||||||
const isShellFocused = useShellFocusState();
|
const isShellFocused = useShellFocusState();
|
||||||
const { setEmbeddedShellFocused } = useUIActions();
|
const { setEmbeddedShellFocused } = useUIActions();
|
||||||
const { terminalWidth, activePtyId, history, terminalBackgroundColor } =
|
const {
|
||||||
useUIState();
|
terminalWidth,
|
||||||
|
activePtyId,
|
||||||
|
history,
|
||||||
|
terminalBackgroundColor,
|
||||||
|
backgroundShells,
|
||||||
|
backgroundShellHeight,
|
||||||
|
} = useUIState();
|
||||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||||
const escPressCount = useRef(0);
|
const escPressCount = useRef(0);
|
||||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||||
@@ -476,6 +482,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
key.name === 'escape' &&
|
||||||
|
(streamingState === StreamingState.Responding ||
|
||||||
|
streamingState === StreamingState.WaitingForConfirmation)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (key.name === 'paste') {
|
if (key.name === 'paste') {
|
||||||
// Record paste time to prevent accidental auto-submission
|
// Record paste time to prevent accidental auto-submission
|
||||||
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
||||||
@@ -598,6 +612,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||||
|
setBannerVisible(false);
|
||||||
|
onClearScreen();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||||
setReverseSearchActive(true);
|
setReverseSearchActive(true);
|
||||||
setTextBeforeReverseSearch(buffer.text);
|
setTextBeforeReverseSearch(buffer.text);
|
||||||
@@ -605,12 +625,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
|
||||||
setBannerVisible(false);
|
|
||||||
onClearScreen();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reverseSearchActive || commandSearchActive) {
|
if (reverseSearchActive || commandSearchActive) {
|
||||||
const isCommandSearch = commandSearchActive;
|
const isCommandSearch = commandSearchActive;
|
||||||
|
|
||||||
@@ -875,14 +889,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
buffer.move('end');
|
buffer.move('end');
|
||||||
return true;
|
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
|
// Kill line commands
|
||||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
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 (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
||||||
// If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
|
// 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);
|
setEmbeddedShellFocused(true);
|
||||||
}
|
}
|
||||||
return 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
|
// Fall back to the text buffer's default input handling for all other keys
|
||||||
const handled = buffer.handleInput(key);
|
const handled = buffer.handleInput(key);
|
||||||
|
|
||||||
// Clear ghost text when user types regular characters (not navigation/control keys)
|
if (handled) {
|
||||||
if (
|
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||||
completion.promptCompletion.text &&
|
resetCompletionState();
|
||||||
key.sequence &&
|
}
|
||||||
key.sequence.length === 1 &&
|
|
||||||
!key.alt &&
|
// Clear ghost text when user types regular characters (not navigation/control keys)
|
||||||
!key.ctrl &&
|
if (
|
||||||
!key.cmd
|
completion.promptCompletion.text &&
|
||||||
) {
|
key.sequence &&
|
||||||
completion.promptCompletion.clear();
|
key.sequence.length === 1 &&
|
||||||
setExpandedSuggestionIndex(-1);
|
!key.alt &&
|
||||||
|
!key.ctrl &&
|
||||||
|
!key.cmd
|
||||||
|
) {
|
||||||
|
completion.promptCompletion.clear();
|
||||||
|
setExpandedSuggestionIndex(-1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return handled;
|
return handled;
|
||||||
},
|
},
|
||||||
@@ -967,11 +982,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
|
backgroundShells.size,
|
||||||
|
backgroundShellHeight,
|
||||||
history,
|
history,
|
||||||
|
streamingState,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
|
useKeypress(handleInput, {
|
||||||
|
isActive: !isEmbeddedShellFocused,
|
||||||
|
priority: true,
|
||||||
|
});
|
||||||
|
|
||||||
const linesToRender = buffer.viewportVisualLines;
|
const linesToRender = buffer.viewportVisualLines;
|
||||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ describe('RewindViewer', () => {
|
|||||||
{
|
{
|
||||||
description: 'removes reference markers',
|
description: 'removes reference markers',
|
||||||
prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`,
|
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',
|
description: 'strips expanded MCP resource content',
|
||||||
@@ -263,10 +264,23 @@ describe('RewindViewer', () => {
|
|||||||
'\nContent from @server3:mcp://demo-resource:\n' +
|
'\nContent from @server3:mcp://demo-resource:\n' +
|
||||||
'This is the content of the demo resource.\n' +
|
'This is the content of the demo resource.\n' +
|
||||||
`--- End of content ---`,
|
`--- 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([
|
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 onRewind = vi.fn();
|
||||||
const { lastFrame, stdin } = renderWithProviders(
|
const { lastFrame, stdin } = renderWithProviders(
|
||||||
@@ -289,6 +303,15 @@ describe('RewindViewer', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(lastFrame()).toContain('Confirm Rewind');
|
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 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> = ({
|
export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||||
conversation,
|
conversation,
|
||||||
onExit,
|
onExit,
|
||||||
@@ -162,10 +170,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
(m) => m.id === selectedMessageId,
|
(m) => m.id === selectedMessageId,
|
||||||
);
|
);
|
||||||
if (userPrompt) {
|
if (userPrompt) {
|
||||||
const originalUserText = userPrompt.content
|
const cleanedText = getCleanedRewindText(userPrompt);
|
||||||
? partToString(userPrompt.content)
|
|
||||||
: '';
|
|
||||||
const cleanedText = stripReferenceContent(originalUserText);
|
|
||||||
setIsRewinding(true);
|
setIsRewinding(true);
|
||||||
await onRewind(selectedMessageId, cleanedText, outcome);
|
await onRewind(selectedMessageId, cleanedText, outcome);
|
||||||
}
|
}
|
||||||
@@ -224,7 +229,9 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
isSelected ? theme.status.success : theme.text.primary
|
isSelected ? theme.status.success : theme.text.primary
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{partToString(userPrompt.content)}
|
{partToString(
|
||||||
|
userPrompt.displayContent || userPrompt.content,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.secondary}>
|
<Text color={theme.text.secondary}>
|
||||||
Cancel rewind and stay here
|
Cancel rewind and stay here
|
||||||
@@ -235,10 +242,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
|
|
||||||
const stats = getStats(userPrompt);
|
const stats = getStats(userPrompt);
|
||||||
const firstFileName = stats?.details?.at(0)?.fileName;
|
const firstFileName = stats?.details?.at(0)?.fileName;
|
||||||
const originalUserText = userPrompt.content
|
const cleanedText = getCleanedRewindText(userPrompt);
|
||||||
? partToString(userPrompt.content)
|
|
||||||
: '';
|
|
||||||
const cleanedText = stripReferenceContent(originalUserText);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
|||||||
@@ -1391,7 +1391,6 @@ describe('SettingsDialog', () => {
|
|||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
enableInteractiveShell: true,
|
enableInteractiveShell: true,
|
||||||
autoAccept: true,
|
|
||||||
useRipgrep: true,
|
useRipgrep: true,
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
@@ -1484,7 +1483,6 @@ describe('SettingsDialog', () => {
|
|||||||
userSettings: {
|
userSettings: {
|
||||||
tools: {
|
tools: {
|
||||||
enableInteractiveShell: true,
|
enableInteractiveShell: true,
|
||||||
autoAccept: false,
|
|
||||||
useRipgrep: true,
|
useRipgrep: true,
|
||||||
truncateToolOutputThreshold: 25000,
|
truncateToolOutputThreshold: 25000,
|
||||||
truncateToolOutputLines: 500,
|
truncateToolOutputLines: 500,
|
||||||
@@ -1537,7 +1535,6 @@ describe('SettingsDialog', () => {
|
|||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
enableInteractiveShell: false,
|
enableInteractiveShell: false,
|
||||||
autoAccept: false,
|
|
||||||
useRipgrep: false,
|
useRipgrep: false,
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type React from 'react';
|
|||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||||
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
|
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
|
||||||
|
import { Command, keyMatchers } from '../keyMatchers.js';
|
||||||
|
|
||||||
export interface ShellInputPromptProps {
|
export interface ShellInputPromptProps {
|
||||||
activeShellPtyId: number | null;
|
activeShellPtyId: number | null;
|
||||||
@@ -31,22 +32,31 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
|
|||||||
const handleInput = useCallback(
|
const handleInput = useCallback(
|
||||||
(key: Key) => {
|
(key: Key) => {
|
||||||
if (!focus || !activeShellPtyId) {
|
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') {
|
if (key.ctrl && key.shift && key.name === 'up') {
|
||||||
ShellExecutionService.scrollPty(activeShellPtyId, -1);
|
ShellExecutionService.scrollPty(activeShellPtyId, -1);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.ctrl && key.shift && key.name === 'down') {
|
if (key.ctrl && key.shift && key.name === 'down') {
|
||||||
ShellExecutionService.scrollPty(activeShellPtyId, 1);
|
ShellExecutionService.scrollPty(activeShellPtyId, 1);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ansiSequence = keyToAnsi(key);
|
const ansiSequence = keyToAnsi(key);
|
||||||
if (ansiSequence) {
|
if (ansiSequence) {
|
||||||
handleShellInputSubmit(ansiSequence);
|
handleShellInputSubmit(ansiSequence);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
[focus, handleShellInputSubmit, activeShellPtyId],
|
[focus, handleShellInputSubmit, activeShellPtyId],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,8 +15,14 @@ import type { TextBuffer } from './shared/text-buffer.js';
|
|||||||
|
|
||||||
// Mock child components to simplify testing
|
// Mock child components to simplify testing
|
||||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||||
ContextSummaryDisplay: (props: { skillCount: number }) => (
|
ContextSummaryDisplay: (props: {
|
||||||
<Text>Mock Context Summary Display (Skills: {props.skillCount})</Text>
|
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,
|
ideContextState: null,
|
||||||
geminiMdFileCount: 0,
|
geminiMdFileCount: 0,
|
||||||
contextFileNames: [],
|
contextFileNames: [],
|
||||||
|
backgroundShellCount: 0,
|
||||||
buffer: { text: '' },
|
buffer: { text: '' },
|
||||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -227,4 +234,15 @@ describe('StatusDisplay', () => {
|
|||||||
);
|
);
|
||||||
expect(lastFrame()).toBe('');
|
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() ?? []
|
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
|
||||||
}
|
}
|
||||||
skillCount={config.getSkillManager().getDisplayableSkills().length}
|
skillCount={config.getSkillManager().getDisplayableSkills().length}
|
||||||
|
backgroundProcessCount={uiState.backgroundShellCount}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
|
|||||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||||
import { StickyHeader } from './StickyHeader.js';
|
import { StickyHeader } from './StickyHeader.js';
|
||||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.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 {
|
interface ToolConfirmationQueueProps {
|
||||||
confirmingTool: ConfirmingToolState;
|
confirmingTool: ConfirmingToolState;
|
||||||
@@ -55,6 +70,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const borderColor = theme.status.warning;
|
const borderColor = theme.status.warning;
|
||||||
|
const hideToolIdentity = tool.confirmationDetails?.type === 'ask_user';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OverflowProvider>
|
<OverflowProvider>
|
||||||
@@ -67,25 +83,31 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
|||||||
>
|
>
|
||||||
<Box flexDirection="column" width={mainAreaWidth - 4}>
|
<Box flexDirection="column" width={mainAreaWidth - 4}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box marginBottom={1} justifyContent="space-between">
|
<Box
|
||||||
|
marginBottom={hideToolIdentity ? 0 : 1}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
<Text color={theme.status.warning} bold>
|
<Text color={theme.status.warning} bold>
|
||||||
Action Required
|
{getConfirmationHeader(tool.confirmationDetails)}
|
||||||
</Text>
|
|
||||||
<Text color={theme.text.secondary}>
|
|
||||||
{index} of {total}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{total > 1 && (
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{index} of {total}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Tool Identity (Context) */}
|
{!hideToolIdentity && (
|
||||||
<Box>
|
<Box>
|
||||||
<ToolStatusIndicator status={tool.status} name={tool.name} />
|
<ToolStatusIndicator status={tool.status} name={tool.name} />
|
||||||
<ToolInfo
|
<ToolInfo
|
||||||
name={tool.name}
|
name={tool.name}
|
||||||
status={tool.status}
|
status={tool.status}
|
||||||
description={tool.description}
|
description={tool.description}
|
||||||
emphasis="high"
|
emphasis="high"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</StickyHeader>
|
</StickyHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -1,138 +1,169 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// 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`] = `
|
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"What should we name this component?
|
||||||
│ What should we name this component? │
|
|
||||||
│ │
|
> e.g., UserProfileCard
|
||||||
│ > e.g., UserProfileCard │
|
|
||||||
│ │
|
|
||||||
│ │
|
Enter to submit · Esc to cancel"
|
||||||
│ Enter to submit · Esc to cancel │
|
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = `
|
exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"Enter the variable name:
|
||||||
│ Enter the variable name: │
|
|
||||||
│ │
|
> Enter your response
|
||||||
│ > Enter your response │
|
|
||||||
│ │
|
|
||||||
│ │
|
Enter to submit · Esc to cancel"
|
||||||
│ Enter to submit · Esc to cancel │
|
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = `
|
exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"Enter the database connection string:
|
||||||
│ Enter the database connection string: │
|
|
||||||
│ │
|
> Enter your response
|
||||||
│ > Enter your response │
|
|
||||||
│ │
|
|
||||||
│ │
|
Enter to submit · Esc to cancel"
|
||||||
│ Enter to submit · Esc to cancel │
|
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
|
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
|
||||||
"╭─────────────────────────────────────────────────────────────────╮
|
"← □ Tests │ □ Docs │ ≡ Review →
|
||||||
│ ← □ Tests │ □ Docs │ ≡ Review → │
|
|
||||||
│ │
|
Review your answers:
|
||||||
│ Review your answers: │
|
|
||||||
│ │
|
⚠ You have 2 unanswered questions
|
||||||
│ ⚠ You have 2 unanswered questions │
|
|
||||||
│ │
|
Tests → (not answered)
|
||||||
│ Tests → (not answered) │
|
Docs → (not answered)
|
||||||
│ Docs → (not answered) │
|
|
||||||
│ │
|
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
|
||||||
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
|
|
||||||
╰─────────────────────────────────────────────────────────────────╯"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`AskUserDialog > hides progress header for single question 1`] = `
|
exports[`AskUserDialog > hides progress header for single question 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"Which authentication method should we use?
|
||||||
│ Which authentication method should we use? │
|
|
||||||
│ │
|
● 1. OAuth 2.0
|
||||||
│ ● 1. OAuth 2.0 │
|
Industry standard, supports SSO
|
||||||
│ Industry standard, supports SSO │
|
2. JWT tokens
|
||||||
│ 2. JWT tokens │
|
Stateless, good for APIs
|
||||||
│ Stateless, good for APIs │
|
3. Enter a custom value
|
||||||
│ 3. Enter a custom value │
|
|
||||||
│ │
|
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`AskUserDialog > renders question and options 1`] = `
|
exports[`AskUserDialog > renders question and options 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"Which authentication method should we use?
|
||||||
│ Which authentication method should we use? │
|
|
||||||
│ │
|
● 1. OAuth 2.0
|
||||||
│ ● 1. OAuth 2.0 │
|
Industry standard, supports SSO
|
||||||
│ Industry standard, supports SSO │
|
2. JWT tokens
|
||||||
│ 2. JWT tokens │
|
Stateless, good for APIs
|
||||||
│ Stateless, good for APIs │
|
3. Enter a custom value
|
||||||
│ 3. Enter a custom value │
|
|
||||||
│ │
|
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = `
|
exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"← □ Framework │ □ Styling │ ≡ Review →
|
||||||
│ ← □ Framework │ □ Styling │ ≡ Review → │
|
|
||||||
│ │
|
Which framework?
|
||||||
│ Which framework? │
|
|
||||||
│ │
|
● 1. React
|
||||||
│ ● 1. React │
|
Component library
|
||||||
│ Component library │
|
2. Vue
|
||||||
│ 2. Vue │
|
Progressive framework
|
||||||
│ Progressive framework │
|
3. Enter a custom value
|
||||||
│ 3. Enter a custom value │
|
|
||||||
│ │
|
Enter to select · ←/→ to switch questions · Esc to cancel"
|
||||||
│ Enter to select · ←/→ to switch questions · Esc to cancel │
|
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`AskUserDialog > shows keyboard hints 1`] = `
|
exports[`AskUserDialog > shows keyboard hints 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"Which authentication method should we use?
|
||||||
│ Which authentication method should we use? │
|
|
||||||
│ │
|
● 1. OAuth 2.0
|
||||||
│ ● 1. OAuth 2.0 │
|
Industry standard, supports SSO
|
||||||
│ Industry standard, supports SSO │
|
2. JWT tokens
|
||||||
│ 2. JWT tokens │
|
Stateless, good for APIs
|
||||||
│ Stateless, good for APIs │
|
3. Enter a custom value
|
||||||
│ 3. Enter a custom value │
|
|
||||||
│ │
|
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
|
exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"← □ Database │ □ ORM │ ≡ Review →
|
||||||
│ ← □ Database │ □ ORM │ ≡ Review → │
|
|
||||||
│ │
|
Which database should we use?
|
||||||
│ Which database should we use? │
|
|
||||||
│ │
|
● 1. PostgreSQL
|
||||||
│ ● 1. PostgreSQL │
|
Relational database
|
||||||
│ Relational database │
|
2. MongoDB
|
||||||
│ 2. MongoDB │
|
Document database
|
||||||
│ Document database │
|
3. Enter a custom value
|
||||||
│ 3. Enter a custom value │
|
|
||||||
│ │
|
Enter to select · ←/→ to switch questions · Esc to cancel"
|
||||||
│ Enter to select · ←/→ to switch questions · Esc to cancel │
|
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
|
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
|
||||||
"╭─────────────────────────────────────────────────────────────────╮
|
"← □ License │ □ README │ ≡ Review →
|
||||||
│ ← □ License │ □ README │ ≡ Review → │
|
|
||||||
│ │
|
Review your answers:
|
||||||
│ Review your answers: │
|
|
||||||
│ │
|
⚠ You have 2 unanswered questions
|
||||||
│ ⚠ You have 2 unanswered questions │
|
|
||||||
│ │
|
License → (not answered)
|
||||||
│ License → (not answered) │
|
README → (not answered)
|
||||||
│ README → (not answered) │
|
|
||||||
│ │
|
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
|
||||||
│ 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`] = `
|
exports[`RewindViewer > Interaction Selection > 'cancels on Escape' > confirmation-dialog 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// 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 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 > 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."`;
|
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`] = `
|
exports[`ToolConfirmationQueue > calculates availableContentHeight based on availableTerminalHeight from UI state 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||||
│ Action Required 1 of 1 │
|
│ Action Required │
|
||||||
│ │
|
│ │
|
||||||
│ ? replace edit file │
|
│ ? 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`] = `
|
exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||||
│ Action Required 1 of 1 │
|
│ Action Required │
|
||||||
│ │
|
│ │
|
||||||
│ ? replace edit file │
|
│ ? 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`] = `
|
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||||
│ Action Required 1 of 1 │
|
│ Action Required │
|
||||||
│ │
|
│ │
|
||||||
│ ? replace edit file │
|
│ ? replace edit file │
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type SerializableConfirmationDetails,
|
type SerializableConfirmationDetails,
|
||||||
type ToolCallConfirmationDetails,
|
type ToolCallConfirmationDetails,
|
||||||
type Config,
|
type Config,
|
||||||
|
type ToolConfirmationPayload,
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
hasRedirection,
|
hasRedirection,
|
||||||
debugLogger,
|
debugLogger,
|
||||||
@@ -25,12 +26,14 @@ import { sanitizeForDisplay } from '../../utils/textUtils.js';
|
|||||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||||
|
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||||
import {
|
import {
|
||||||
REDIRECTION_WARNING_NOTE_LABEL,
|
REDIRECTION_WARNING_NOTE_LABEL,
|
||||||
REDIRECTION_WARNING_NOTE_TEXT,
|
REDIRECTION_WARNING_NOTE_TEXT,
|
||||||
REDIRECTION_WARNING_TIP_LABEL,
|
REDIRECTION_WARNING_TIP_LABEL,
|
||||||
REDIRECTION_WARNING_TIP_TEXT,
|
REDIRECTION_WARNING_TIP_TEXT,
|
||||||
} from '../../textConstants.js';
|
} from '../../textConstants.js';
|
||||||
|
import { AskUserDialog } from '../AskUserDialog.js';
|
||||||
|
|
||||||
export interface ToolConfirmationMessageProps {
|
export interface ToolConfirmationMessageProps {
|
||||||
callId: string;
|
callId: string;
|
||||||
@@ -59,9 +62,12 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
const allowPermanentApproval =
|
const allowPermanentApproval =
|
||||||
settings.merged.security.enablePermanentToolApproval;
|
settings.merged.security.enablePermanentToolApproval;
|
||||||
|
|
||||||
|
const handlesOwnUI = confirmationDetails.type === 'ask_user';
|
||||||
|
const isTrustedFolder = config.isTrustedFolder();
|
||||||
|
|
||||||
const handleConfirm = useCallback(
|
const handleConfirm = useCallback(
|
||||||
(outcome: ToolConfirmationOutcome) => {
|
(outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => {
|
||||||
void confirm(callId, outcome).catch((error: unknown) => {
|
void confirm(callId, outcome, payload).catch((error: unknown) => {
|
||||||
debugLogger.error(
|
debugLogger.error(
|
||||||
`Failed to handle tool confirmation for ${callId}:`,
|
`Failed to handle tool confirmation for ${callId}:`,
|
||||||
error,
|
error,
|
||||||
@@ -71,15 +77,18 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
[confirm, callId],
|
[confirm, callId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isTrustedFolder = config.isTrustedFolder();
|
|
||||||
|
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key) => {
|
(key) => {
|
||||||
if (!isFocused) return false;
|
if (!isFocused) return false;
|
||||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
if (keyMatchers[Command.ESCAPE](key)) {
|
||||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||||
return true;
|
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;
|
return false;
|
||||||
},
|
},
|
||||||
{ isActive: isFocused },
|
{ isActive: isFocused },
|
||||||
@@ -180,7 +189,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
value: ToolConfirmationOutcome.Cancel,
|
value: ToolConfirmationOutcome.Cancel,
|
||||||
key: 'No, suggest changes (esc)',
|
key: 'No, suggest changes (esc)',
|
||||||
});
|
});
|
||||||
} else {
|
} else if (confirmationDetails.type === 'mcp') {
|
||||||
// mcp tool confirmation
|
// mcp tool confirmation
|
||||||
options.push({
|
options.push({
|
||||||
label: 'Allow once',
|
label: 'Allow once',
|
||||||
@@ -251,6 +260,23 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
let question = '';
|
let question = '';
|
||||||
const options = getOptions();
|
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.type === 'edit') {
|
||||||
if (!confirmationDetails.isModifying) {
|
if (!confirmationDetails.isModifying) {
|
||||||
question = `Apply this change?`;
|
question = `Apply this change?`;
|
||||||
@@ -265,7 +291,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
}
|
}
|
||||||
} else if (confirmationDetails.type === 'info') {
|
} else if (confirmationDetails.type === 'info') {
|
||||||
question = `Do you want to proceed?`;
|
question = `Do you want to proceed?`;
|
||||||
} else {
|
} else if (confirmationDetails.type === 'mcp') {
|
||||||
// mcp tool confirmation
|
// mcp tool confirmation
|
||||||
const mcpProps = confirmationDetails;
|
const mcpProps = confirmationDetails;
|
||||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||||
@@ -387,7 +413,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
} else {
|
} else if (confirmationDetails.type === 'mcp') {
|
||||||
// mcp tool confirmation
|
// mcp tool confirmation
|
||||||
const mcpProps = confirmationDetails;
|
const mcpProps = confirmationDetails;
|
||||||
|
|
||||||
@@ -405,6 +431,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
getOptions,
|
getOptions,
|
||||||
availableBodyContentHeight,
|
availableBodyContentHeight,
|
||||||
terminalWidth,
|
terminalWidth,
|
||||||
|
handleConfirm,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (confirmationDetails.type === 'edit') {
|
if (confirmationDetails.type === 'edit') {
|
||||||
@@ -429,32 +456,38 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
|
<Box
|
||||||
{/* Body Content (Diff Renderer or Command Info) */}
|
flexDirection="column"
|
||||||
{/* No separate context display here anymore for edits */}
|
paddingTop={0}
|
||||||
<Box flexGrow={1} flexShrink={1} overflow="hidden">
|
paddingBottom={handlesOwnUI ? 0 : 1}
|
||||||
<MaxSizedBox
|
>
|
||||||
maxHeight={availableBodyContentHeight()}
|
{handlesOwnUI ? (
|
||||||
maxWidth={terminalWidth}
|
bodyContent
|
||||||
overflowDirection="top"
|
) : (
|
||||||
>
|
<>
|
||||||
{bodyContent}
|
<Box flexGrow={1} flexShrink={1} overflow="hidden">
|
||||||
</MaxSizedBox>
|
<MaxSizedBox
|
||||||
</Box>
|
maxHeight={availableBodyContentHeight()}
|
||||||
|
maxWidth={terminalWidth}
|
||||||
|
overflowDirection="top"
|
||||||
|
>
|
||||||
|
{bodyContent}
|
||||||
|
</MaxSizedBox>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Confirmation Question */}
|
<Box marginBottom={1} flexShrink={0}>
|
||||||
<Box marginBottom={1} flexShrink={0}>
|
<Text color={theme.text.primary}>{question}</Text>
|
||||||
<Text color={theme.text.primary}>{question}</Text>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Select Input for Options */}
|
<Box flexShrink={0}>
|
||||||
<Box flexShrink={0}>
|
<RadioButtonSelect
|
||||||
<RadioButtonSelect
|
items={options}
|
||||||
items={options}
|
onSelect={handleSelect}
|
||||||
onSelect={handleSelect}
|
isFocused={isFocused}
|
||||||
isFocused={isFocused}
|
/>
|
||||||
/>
|
</Box>
|
||||||
</Box>
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -475,14 +475,7 @@ describe('BaseSelectionList', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const output = lastFrame();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
// 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('▼');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -493,15 +486,7 @@ describe('BaseSelectionList', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const output = lastFrame();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
// 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('▼');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -512,32 +497,18 @@ describe('BaseSelectionList', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const output = lastFrame();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
// 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('▼');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show both arrows dimmed when list fits entirely', () => {
|
it('should not show arrows when list fits entirely', () => {
|
||||||
const { lastFrame } = renderComponent({
|
const { lastFrame } = renderComponent({
|
||||||
items,
|
items,
|
||||||
maxItemsToShow: 5,
|
maxItemsToShow: 5,
|
||||||
showScrollArrows: true,
|
showScrollArrows: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const output = lastFrame();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
// 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('▼');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function BaseSelectionList<
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{/* Use conditional coloring instead of conditional rendering */}
|
{/* Use conditional coloring instead of conditional rendering */}
|
||||||
{showScrollArrows && (
|
{showScrollArrows && items.length > maxItemsToShow && (
|
||||||
<Text
|
<Text
|
||||||
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
|
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
|
||||||
>
|
>
|
||||||
@@ -172,7 +172,7 @@ export function BaseSelectionList<
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{showScrollArrows && (
|
{showScrollArrows && items.length > maxItemsToShow && (
|
||||||
<Text
|
<Text
|
||||||
color={
|
color={
|
||||||
scrollOffset + maxItemsToShow < items.length
|
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
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`DescriptiveRadioButtonSelect > should render correctly with custom props 1`] = `
|
exports[`DescriptiveRadioButtonSelect > should render correctly with custom props 1`] = `
|
||||||
"▲
|
" 1. Foo Title
|
||||||
1. Foo Title
|
|
||||||
This is Foo.
|
This is Foo.
|
||||||
● 2. Bar Title
|
● 2. Bar Title
|
||||||
This is Bar.
|
This is Bar.
|
||||||
3. Baz Title
|
3. Baz Title
|
||||||
This is Baz.
|
This is Baz."
|
||||||
▼"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
|
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
|
||||||
|
|||||||
@@ -1515,6 +1515,50 @@ describe('useTextBuffer', () => {
|
|||||||
expect(getBufferState(result).text).toBe('');
|
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', () => {
|
it('should handle "Backspace" key', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({
|
useTextBuffer({
|
||||||
|
|||||||
@@ -2930,6 +2930,13 @@ export function useTextBuffer({
|
|||||||
move('end');
|
move('end');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||||
|
if (text.length > 0) {
|
||||||
|
setText('');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||||||
deleteWordLeft();
|
deleteWordLeft();
|
||||||
return true;
|
return true;
|
||||||
@@ -2943,6 +2950,13 @@ export function useTextBuffer({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
|
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
|
||||||
|
const lastLineIdx = lines.length - 1;
|
||||||
|
if (
|
||||||
|
cursorRow === lastLineIdx &&
|
||||||
|
cursorCol === cpLen(lines[lastLineIdx] ?? '')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
del();
|
del();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2974,6 +2988,8 @@ export function useTextBuffer({
|
|||||||
cursorCol,
|
cursorCol,
|
||||||
lines,
|
lines,
|
||||||
singleLine,
|
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, move up/down with k/j or the arrow keys…',
|
||||||
'In menus, select an item by typing its number…',
|
'In menus, select an item by typing its number…',
|
||||||
"If you're using an IDE, see the context with Ctrl+G…",
|
"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
|
// Keyboard shortcut tips end here
|
||||||
// Command tips start here
|
// Command tips start here
|
||||||
'Show version info with /about…',
|
'Show version info with /about…',
|
||||||
|
|||||||
@@ -67,7 +67,11 @@ export interface UIActions {
|
|||||||
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
||||||
handleApiKeyCancel: () => void;
|
handleApiKeyCancel: () => void;
|
||||||
setBannerVisible: (visible: boolean) => void;
|
setBannerVisible: (visible: boolean) => void;
|
||||||
|
handleWarning: (message: string) => void;
|
||||||
setEmbeddedShellFocused: (value: boolean) => void;
|
setEmbeddedShellFocused: (value: boolean) => void;
|
||||||
|
dismissBackgroundShell: (pid: number) => void;
|
||||||
|
setActiveBackgroundShellPid: (pid: number) => void;
|
||||||
|
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
|
||||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||||
handleRestart: () => void;
|
handleRestart: () => void;
|
||||||
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface ValidationDialogRequest {
|
|||||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||||
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
||||||
|
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||||
|
|
||||||
export interface UIState {
|
export interface UIState {
|
||||||
history: HistoryItem[];
|
history: HistoryItem[];
|
||||||
@@ -80,7 +81,8 @@ export interface UIState {
|
|||||||
slashCommands: readonly SlashCommand[] | undefined;
|
slashCommands: readonly SlashCommand[] | undefined;
|
||||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||||
commandContext: CommandContext;
|
commandContext: CommandContext;
|
||||||
confirmationRequest: ConfirmationRequest | null;
|
commandConfirmationRequest: ConfirmationRequest | null;
|
||||||
|
authConsentRequest: ConfirmationRequest | null;
|
||||||
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
||||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||||
geminiMdFileCount: number;
|
geminiMdFileCount: number;
|
||||||
@@ -141,6 +143,8 @@ export interface UIState {
|
|||||||
isRestarting: boolean;
|
isRestarting: boolean;
|
||||||
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
||||||
activePtyId: number | undefined;
|
activePtyId: number | undefined;
|
||||||
|
backgroundShellCount: number;
|
||||||
|
isBackgroundShellVisible: boolean;
|
||||||
embeddedShellFocused: boolean;
|
embeddedShellFocused: boolean;
|
||||||
showDebugProfiler: boolean;
|
showDebugProfiler: boolean;
|
||||||
showFullTodos: boolean;
|
showFullTodos: boolean;
|
||||||
@@ -154,6 +158,10 @@ export interface UIState {
|
|||||||
customDialog: React.ReactNode | null;
|
customDialog: React.ReactNode | null;
|
||||||
terminalBackgroundColor: TerminalBackgroundColor;
|
terminalBackgroundColor: TerminalBackgroundColor;
|
||||||
settingsNonce: number;
|
settingsNonce: number;
|
||||||
|
backgroundShells: Map<number, BackgroundShell>;
|
||||||
|
activeBackgroundShellPid: number | null;
|
||||||
|
backgroundShellHeight: number;
|
||||||
|
isBackgroundShellListOpen: boolean;
|
||||||
adminSettingsChanged: boolean;
|
adminSettingsChanged: boolean;
|
||||||
newAgents: AgentDefinition[] | null;
|
newAgents: AgentDefinition[] | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ exports[`useReactToolScheduler > should handle live output updates 1`] = `
|
|||||||
{
|
{
|
||||||
"callId": "liveCall",
|
"callId": "liveCall",
|
||||||
"contentLength": 12,
|
"contentLength": 12,
|
||||||
|
"data": undefined,
|
||||||
"error": undefined,
|
"error": undefined,
|
||||||
"errorType": undefined,
|
"errorType": undefined,
|
||||||
"outputFile": undefined,
|
"outputFile": undefined,
|
||||||
@@ -26,6 +27,7 @@ exports[`useReactToolScheduler > should handle tool requiring confirmation - app
|
|||||||
{
|
{
|
||||||
"callId": "callConfirm",
|
"callId": "callConfirm",
|
||||||
"contentLength": 16,
|
"contentLength": 16,
|
||||||
|
"data": undefined,
|
||||||
"error": undefined,
|
"error": undefined,
|
||||||
"errorType": undefined,
|
"errorType": undefined,
|
||||||
"outputFile": undefined,
|
"outputFile": undefined,
|
||||||
@@ -75,6 +77,7 @@ exports[`useReactToolScheduler > should schedule and execute a tool call success
|
|||||||
{
|
{
|
||||||
"callId": "call1",
|
"callId": "call1",
|
||||||
"contentLength": 11,
|
"contentLength": 11,
|
||||||
|
"data": undefined,
|
||||||
"error": undefined,
|
"error": undefined,
|
||||||
"errorType": undefined,
|
"errorType": undefined,
|
||||||
"outputFile": undefined,
|
"outputFile": undefined,
|
||||||
|
|||||||
@@ -19,12 +19,34 @@ import {
|
|||||||
|
|
||||||
const mockIsBinary = vi.hoisted(() => vi.fn());
|
const mockIsBinary = vi.hoisted(() => vi.fn());
|
||||||
const mockShellExecutionService = 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) => {
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
ShellExecutionService: { execute: mockShellExecutionService },
|
ShellExecutionService: {
|
||||||
|
execute: mockShellExecutionService,
|
||||||
|
kill: mockShellKill,
|
||||||
|
background: mockShellBackground,
|
||||||
|
subscribe: mockShellSubscribe,
|
||||||
|
onExit: mockShellOnExit,
|
||||||
|
},
|
||||||
isBinary: mockIsBinary,
|
isBinary: mockIsBinary,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -113,7 +135,13 @@ describe('useShellCommandProcessor', () => {
|
|||||||
|
|
||||||
const renderProcessorHook = () => {
|
const renderProcessorHook = () => {
|
||||||
let hookResult: ReturnType<typeof useShellCommandProcessor>;
|
let hookResult: ReturnType<typeof useShellCommandProcessor>;
|
||||||
function TestComponent() {
|
let renderCount = 0;
|
||||||
|
function TestComponent({
|
||||||
|
isWaitingForConfirmation,
|
||||||
|
}: {
|
||||||
|
isWaitingForConfirmation?: boolean;
|
||||||
|
}) {
|
||||||
|
renderCount++;
|
||||||
hookResult = useShellCommandProcessor(
|
hookResult = useShellCommandProcessor(
|
||||||
addItemToHistoryMock,
|
addItemToHistoryMock,
|
||||||
setPendingHistoryItemMock,
|
setPendingHistoryItemMock,
|
||||||
@@ -122,16 +150,25 @@ describe('useShellCommandProcessor', () => {
|
|||||||
mockConfig,
|
mockConfig,
|
||||||
mockGeminiClient,
|
mockGeminiClient,
|
||||||
setShellInputFocusedMock,
|
setShellInputFocusedMock,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
isWaitingForConfirmation,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
render(<TestComponent />);
|
const { rerender } = render(<TestComponent />);
|
||||||
return {
|
return {
|
||||||
result: {
|
result: {
|
||||||
get current() {
|
get current() {
|
||||||
return hookResult;
|
return hookResult;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getRenderCount: () => renderCount,
|
||||||
|
rerender: (isWaitingForConfirmation?: boolean) =>
|
||||||
|
rerender(
|
||||||
|
<TestComponent isWaitingForConfirmation={isWaitingForConfirmation} />,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -723,4 +760,403 @@ describe('useShellCommandProcessor', () => {
|
|||||||
expect(result.current.activeShellPtyId).toBeNull();
|
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,
|
IndividualToolCallDisplay,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { ToolCallStatus } from '../types.js';
|
import { ToolCallStatus } from '../types.js';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useReducer, useRef, useEffect } from 'react';
|
||||||
import type {
|
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
|
||||||
AnsiOutput,
|
|
||||||
Config,
|
|
||||||
GeminiClient,
|
|
||||||
ShellExecutionResult,
|
|
||||||
} from '@google/gemini-cli-core';
|
|
||||||
import { isBinary, ShellExecutionService } from '@google/gemini-cli-core';
|
import { isBinary, ShellExecutionService } from '@google/gemini-cli-core';
|
||||||
import { type PartListUnion } from '@google/genai';
|
import { type PartListUnion } from '@google/genai';
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
@@ -26,8 +21,15 @@ import path from 'node:path';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { themeManager } from '../../ui/themes/theme-manager.js';
|
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;
|
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||||
|
const RESTORE_VISIBILITY_DELAY_MS = 300;
|
||||||
const MAX_OUTPUT_LENGTH = 10000;
|
const MAX_OUTPUT_LENGTH = 10000;
|
||||||
|
|
||||||
function addShellCommandToGeminiHistory(
|
function addShellCommandToGeminiHistory(
|
||||||
@@ -75,9 +77,190 @@ export const useShellCommandProcessor = (
|
|||||||
setShellInputFocused: (value: boolean) => void,
|
setShellInputFocused: (value: boolean) => void,
|
||||||
terminalWidth?: number,
|
terminalWidth?: number,
|
||||||
terminalHeight?: number,
|
terminalHeight?: number,
|
||||||
|
activeToolPtyId?: number,
|
||||||
|
isWaitingForConfirmation?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const [activeShellPtyId, setActiveShellPtyId] = useState<number | null>(null);
|
const [state, dispatch] = useReducer(shellReducer, initialState);
|
||||||
const [lastShellOutputTime, setLastShellOutputTime] = useState<number>(0);
|
|
||||||
|
// 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(
|
const handleShellCommand = useCallback(
|
||||||
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
|
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
|
||||||
@@ -109,9 +292,7 @@ export const useShellCommandProcessor = (
|
|||||||
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
|
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const executeCommand = async (
|
const executeCommand = async () => {
|
||||||
resolve: (value: void | PromiseLike<void>) => void,
|
|
||||||
) => {
|
|
||||||
let cumulativeStdout: string | AnsiOutput = '';
|
let cumulativeStdout: string | AnsiOutput = '';
|
||||||
let isBinaryStream = false;
|
let isBinaryStream = false;
|
||||||
let binaryBytesReceived = 0;
|
let binaryBytesReceived = 0;
|
||||||
@@ -151,84 +332,90 @@ export const useShellCommandProcessor = (
|
|||||||
defaultBg: activeTheme.colors.Background,
|
defaultBg: activeTheme.colors.Background,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { pid, result } = await ShellExecutionService.execute(
|
const { pid, result: resultPromise } =
|
||||||
commandToExecute,
|
await ShellExecutionService.execute(
|
||||||
targetDir,
|
commandToExecute,
|
||||||
(event) => {
|
targetDir,
|
||||||
let shouldUpdate = false;
|
(event) => {
|
||||||
switch (event.type) {
|
let shouldUpdate = false;
|
||||||
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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute the display string based on the *current* state.
|
switch (event.type) {
|
||||||
let currentDisplayOutput: string | AnsiOutput;
|
case 'data':
|
||||||
if (isBinaryStream) {
|
if (isBinaryStream) break;
|
||||||
if (binaryBytesReceived > 0) {
|
if (typeof event.chunk === 'string') {
|
||||||
currentDisplayOutput = `[Receiving binary output... ${formatBytes(
|
if (typeof cumulativeStdout === 'string') {
|
||||||
binaryBytesReceived,
|
cumulativeStdout += event.chunk;
|
||||||
)} received]`;
|
} else {
|
||||||
} 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 =
|
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) {
|
||||||
if (shouldUpdate) {
|
dispatch({ type: 'SET_OUTPUT_TIME', time: Date.now() });
|
||||||
setLastShellOutputTime(Date.now());
|
setPendingHistoryItem((prevItem) => {
|
||||||
setPendingHistoryItem((prevItem) => {
|
if (prevItem?.type === 'tool_group') {
|
||||||
if (prevItem?.type === 'tool_group') {
|
return {
|
||||||
return {
|
...prevItem,
|
||||||
...prevItem,
|
tools: prevItem.tools.map((tool) =>
|
||||||
tools: prevItem.tools.map((tool) =>
|
tool.callId === callId
|
||||||
tool.callId === callId
|
? { ...tool, resultDisplay: currentDisplayOutput }
|
||||||
? { ...tool, resultDisplay: currentDisplayOutput }
|
: tool,
|
||||||
: tool,
|
),
|
||||||
),
|
};
|
||||||
};
|
}
|
||||||
}
|
return prevItem;
|
||||||
return prevItem;
|
});
|
||||||
});
|
}
|
||||||
}
|
},
|
||||||
},
|
abortSignal,
|
||||||
abortSignal,
|
config.getEnableInteractiveShell(),
|
||||||
config.getEnableInteractiveShell(),
|
shellExecutionConfig,
|
||||||
shellExecutionConfig,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
executionPid = pid;
|
executionPid = pid;
|
||||||
if (pid) {
|
if (pid) {
|
||||||
setActiveShellPtyId(pid);
|
dispatch({ type: 'SET_ACTIVE_PTY', pid });
|
||||||
setPendingHistoryItem((prevItem) => {
|
setPendingHistoryItem((prevItem) => {
|
||||||
if (prevItem?.type === 'tool_group') {
|
if (prevItem?.type === 'tool_group') {
|
||||||
return {
|
return {
|
||||||
@@ -242,94 +429,69 @@ export const useShellCommandProcessor = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
const result = await resultPromise;
|
||||||
.then((result: ShellExecutionResult) => {
|
setPendingHistoryItem(null);
|
||||||
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)) {
|
let mainContent: string;
|
||||||
mainContent =
|
if (isBinary(result.rawOutput)) {
|
||||||
'[Command produced binary output, which is not shown.]';
|
mainContent =
|
||||||
} else {
|
'[Command produced binary output, which is not shown.]';
|
||||||
mainContent =
|
} else {
|
||||||
result.output.trim() || '(Command produced no output)';
|
mainContent =
|
||||||
}
|
result.output.trim() || '(Command produced no output)';
|
||||||
|
}
|
||||||
|
|
||||||
let finalOutput = mainContent;
|
let finalOutput = mainContent;
|
||||||
let finalStatus = ToolCallStatus.Success;
|
let finalStatus = ToolCallStatus.Success;
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
finalStatus = ToolCallStatus.Error;
|
finalStatus = ToolCallStatus.Error;
|
||||||
finalOutput = `${result.error.message}\n${finalOutput}`;
|
finalOutput = `${result.error.message}\n${finalOutput}`;
|
||||||
} else if (result.aborted) {
|
} else if (result.aborted) {
|
||||||
finalStatus = ToolCallStatus.Canceled;
|
finalStatus = ToolCallStatus.Canceled;
|
||||||
finalOutput = `Command was cancelled.\n${finalOutput}`;
|
finalOutput = `Command was cancelled.\n${finalOutput}`;
|
||||||
} else if (result.signal) {
|
} else if (result.backgrounded) {
|
||||||
finalStatus = ToolCallStatus.Error;
|
finalStatus = ToolCallStatus.Success;
|
||||||
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
|
finalOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
|
||||||
} else if (result.exitCode !== 0) {
|
} else if (result.signal) {
|
||||||
finalStatus = ToolCallStatus.Error;
|
finalStatus = ToolCallStatus.Error;
|
||||||
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
|
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)) {
|
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||||
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
|
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
|
||||||
if (finalPwd && finalPwd !== targetDir) {
|
if (finalPwd && finalPwd !== targetDir) {
|
||||||
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
|
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
|
||||||
finalOutput = `${warning}\n\n${finalOutput}`;
|
finalOutput = `${warning}\n\n${finalOutput}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalToolDisplay: IndividualToolCallDisplay = {
|
const finalToolDisplay: IndividualToolCallDisplay = {
|
||||||
...initialToolDisplay,
|
...initialToolDisplay,
|
||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
resultDisplay: finalOutput,
|
resultDisplay: finalOutput,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the complete, contextual result to the local UI history.
|
if (finalStatus !== ToolCallStatus.Canceled) {
|
||||||
// We skip this for cancelled commands because useGeminiStream handles the
|
addItemToHistory(
|
||||||
// immediate addition of the cancelled item to history to prevent flickering/duplicates.
|
{
|
||||||
if (finalStatus !== ToolCallStatus.Canceled) {
|
type: 'tool_group',
|
||||||
addItemToHistory(
|
tools: [finalToolDisplay],
|
||||||
{
|
} as HistoryItemWithoutId,
|
||||||
type: 'tool_group',
|
userMessageTimestamp,
|
||||||
tools: [finalToolDisplay],
|
);
|
||||||
} as HistoryItemWithoutId,
|
}
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the same complete, contextual result to the LLM's history.
|
addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
|
||||||
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();
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// This block handles synchronous errors from `execute`
|
|
||||||
setPendingHistoryItem(null);
|
setPendingHistoryItem(null);
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
addItemToHistory(
|
addItemToHistory(
|
||||||
@@ -339,23 +501,18 @@ export const useShellCommandProcessor = (
|
|||||||
},
|
},
|
||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
// Perform cleanup here as well
|
abortSignal.removeEventListener('abort', abortHandler);
|
||||||
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||||
fs.unlinkSync(pwdFilePath);
|
fs.unlinkSync(pwdFilePath);
|
||||||
}
|
}
|
||||||
setActiveShellPtyId(null);
|
|
||||||
|
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
||||||
setShellInputFocused(false);
|
setShellInputFocused(false);
|
||||||
resolve(); // Resolve the promise to unblock `onExec`
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const execPromise = new Promise<void>((resolve) => {
|
onExec(executeCommand());
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
executeCommand(resolve);
|
|
||||||
});
|
|
||||||
|
|
||||||
onExec(execPromise);
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -368,8 +525,26 @@ export const useShellCommandProcessor = (
|
|||||||
setShellInputFocused,
|
setShellInputFocused,
|
||||||
terminalHeight,
|
terminalHeight,
|
||||||
terminalWidth,
|
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(),
|
toggleDebugProfiler: vi.fn(),
|
||||||
dispatchExtensionStateUpdate: vi.fn(),
|
dispatchExtensionStateUpdate: vi.fn(),
|
||||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||||
|
toggleBackgroundShell: vi.fn(),
|
||||||
setText: vi.fn(),
|
setText: vi.fn(),
|
||||||
},
|
},
|
||||||
new Map(), // extensionsUpdateState
|
new Map(), // extensionsUpdateState
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ interface SlashCommandProcessorActions {
|
|||||||
toggleDebugProfiler: () => void;
|
toggleDebugProfiler: () => void;
|
||||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||||
|
toggleBackgroundShell: () => void;
|
||||||
setText: (text: string) => void;
|
setText: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +238,7 @@ export const useSlashCommandProcessor = (
|
|||||||
addConfirmUpdateExtensionRequest:
|
addConfirmUpdateExtensionRequest:
|
||||||
actions.addConfirmUpdateExtensionRequest,
|
actions.addConfirmUpdateExtensionRequest,
|
||||||
removeComponent: () => setCustomDialog(null),
|
removeComponent: () => setCustomDialog(null),
|
||||||
|
toggleBackgroundShell: actions.toggleBackgroundShell,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
stats: session.stats,
|
stats: session.stats,
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ vi.mock('@google/gemini-cli-core', async () => {
|
|||||||
return {
|
return {
|
||||||
...actualServerModule,
|
...actualServerModule,
|
||||||
Config: vi.fn(),
|
Config: vi.fn(),
|
||||||
|
getAdminErrorMessage: vi.fn(
|
||||||
|
(featureName: string) => `[Mock] ${featureName} is disabled`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +55,9 @@ interface MockConfigInstanceShape {
|
|||||||
getUserMemory: Mock<() => string>;
|
getUserMemory: Mock<() => string>;
|
||||||
getGeminiMdFileCount: Mock<() => number>;
|
getGeminiMdFileCount: Mock<() => number>;
|
||||||
getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>;
|
getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>;
|
||||||
|
getRemoteAdminSettings: Mock<
|
||||||
|
() => { strictModeDisabled?: boolean; mcpEnabled?: boolean } | undefined
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseKeypressHandler = (key: Key) => void;
|
type UseKeypressHandler = (key: Key) => void;
|
||||||
@@ -109,6 +115,9 @@ describe('useApprovalModeIndicator', () => {
|
|||||||
.mockReturnValue({ discoverTools: vi.fn() }) as Mock<
|
.mockReturnValue({ discoverTools: vi.fn() }) as Mock<
|
||||||
() => { discoverTools: Mock<() => void> }
|
() => { discoverTools: Mock<() => void> }
|
||||||
>,
|
>,
|
||||||
|
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined) as Mock<
|
||||||
|
() => { strictModeDisabled?: boolean } | undefined
|
||||||
|
>,
|
||||||
};
|
};
|
||||||
instanceSetApprovalModeMock.mockImplementation((value: ApprovalMode) => {
|
instanceSetApprovalModeMock.mockImplementation((value: ApprovalMode) => {
|
||||||
instanceGetApprovalModeMock.mockReturnValue(value);
|
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', () => {
|
it('should not enable YOLO mode when Ctrl+Y is pressed and add an info message', () => {
|
||||||
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
|
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
|
||||||
|
mockConfigInstance.getRemoteAdminSettings.mockReturnValue({
|
||||||
|
strictModeDisabled: true,
|
||||||
|
});
|
||||||
const mockAddItem = vi.fn();
|
const mockAddItem = vi.fn();
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useApprovalModeIndicator({
|
useApprovalModeIndicator({
|
||||||
@@ -544,6 +556,58 @@ describe('useApprovalModeIndicator', () => {
|
|||||||
// The mode should not change
|
// The mode should not change
|
||||||
expect(result.current).toBe(ApprovalMode.DEFAULT);
|
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', () => {
|
it('should call onApprovalModeChange when switching to YOLO mode', () => {
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
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 { useKeypress } from './useKeypress.js';
|
||||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
import type { HistoryItemWithoutId } from '../types.js';
|
import type { HistoryItemWithoutId } from '../types.js';
|
||||||
@@ -41,10 +45,19 @@ export function useApprovalModeIndicator({
|
|||||||
config.getApprovalMode() !== ApprovalMode.YOLO
|
config.getApprovalMode() !== ApprovalMode.YOLO
|
||||||
) {
|
) {
|
||||||
if (addItem) {
|
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(
|
addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.WARNING,
|
type: MessageType.WARNING,
|
||||||
text: 'You cannot enter YOLO mode since it is disabled in your settings.',
|
text,
|
||||||
},
|
},
|
||||||
Date.now(),
|
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(),
|
recordToolCalls: vi.fn(),
|
||||||
getConversationFile: vi.fn(),
|
getConversationFile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
this.getCurrentSequenceModel = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue('gemini-2.0-flash-exp');
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -652,6 +655,9 @@ describe('useGeminiStream', () => {
|
|||||||
expectedMergedResponse,
|
expectedMergedResponse,
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-2',
|
'prompt-id-2',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
expectedMergedResponse,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1054,6 +1060,9 @@ describe('useGeminiStream', () => {
|
|||||||
toolCallResponseParts,
|
toolCallResponseParts,
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'prompt-id-4',
|
'prompt-id-4',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
toolCallResponseParts,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1495,6 +1504,9 @@ describe('useGeminiStream', () => {
|
|||||||
'This is the actual prompt from the command file.',
|
'This is the actual prompt from the command file.',
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'/my-custom-command',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
|
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
|
||||||
@@ -1521,6 +1533,9 @@ describe('useGeminiStream', () => {
|
|||||||
'',
|
'',
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'/emptycmd',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1539,6 +1554,9 @@ describe('useGeminiStream', () => {
|
|||||||
'// This is a line comment',
|
'// This is a line comment',
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'// This is a line comment',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1557,6 +1575,9 @@ describe('useGeminiStream', () => {
|
|||||||
'/* This is a block comment */',
|
'/* This is a block comment */',
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'/* This is a block comment */',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2389,6 +2410,9 @@ describe('useGeminiStream', () => {
|
|||||||
processedQueryParts, // Argument 1: The parts array directly
|
processedQueryParts, // Argument 1: The parts array directly
|
||||||
expect.any(AbortSignal), // Argument 2: An AbortSignal
|
expect.any(AbortSignal), // Argument 2: An AbortSignal
|
||||||
expect.any(String), // Argument 3: The prompt_id string
|
expect.any(String), // Argument 3: The prompt_id string
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
rawQuery,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2928,6 +2952,9 @@ describe('useGeminiStream', () => {
|
|||||||
'test query',
|
'test query',
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'test query',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3075,6 +3102,9 @@ describe('useGeminiStream', () => {
|
|||||||
'second query',
|
'second query',
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
'second query',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import type {
|
|||||||
ServerGeminiStreamEvent as GeminiEvent,
|
ServerGeminiStreamEvent as GeminiEvent,
|
||||||
ThoughtSummary,
|
ThoughtSummary,
|
||||||
ToolCallRequestInfo,
|
ToolCallRequestInfo,
|
||||||
|
ToolCallResponseInfo,
|
||||||
GeminiErrorEventValue,
|
GeminiErrorEventValue,
|
||||||
RetryAttemptPayload,
|
RetryAttemptPayload,
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
@@ -72,6 +73,7 @@ import {
|
|||||||
type TrackedCompletedToolCall,
|
type TrackedCompletedToolCall,
|
||||||
type TrackedCancelledToolCall,
|
type TrackedCancelledToolCall,
|
||||||
type TrackedWaitingToolCall,
|
type TrackedWaitingToolCall,
|
||||||
|
type TrackedExecutingToolCall,
|
||||||
} from './useToolScheduler.js';
|
} from './useToolScheduler.js';
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -79,12 +81,34 @@ import { useSessionStats } from '../contexts/SessionContext.js';
|
|||||||
import { useKeypress } from './useKeypress.js';
|
import { useKeypress } from './useKeypress.js';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
|
|
||||||
|
type ToolResponseWithParts = ToolCallResponseInfo & {
|
||||||
|
llmContent?: PartListUnion;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ShellToolData {
|
||||||
|
pid?: number;
|
||||||
|
command?: string;
|
||||||
|
initialOutput?: string;
|
||||||
|
}
|
||||||
|
|
||||||
enum StreamProcessingStatus {
|
enum StreamProcessingStatus {
|
||||||
Completed,
|
Completed,
|
||||||
UserCancelled,
|
UserCancelled,
|
||||||
Error,
|
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 {
|
function showCitations(settings: LoadedSettings): boolean {
|
||||||
const enabled = settings.merged.ui.showCitations;
|
const enabled = settings.merged.ui.showCitations;
|
||||||
if (enabled !== undefined) {
|
if (enabled !== undefined) {
|
||||||
@@ -401,14 +425,11 @@ export const useGeminiStream = (
|
|||||||
}, [toolCalls, pushedToolCallIds, config]);
|
}, [toolCalls, pushedToolCallIds, config]);
|
||||||
|
|
||||||
const activeToolPtyId = useMemo(() => {
|
const activeToolPtyId = useMemo(() => {
|
||||||
const executingShellTool = toolCalls?.find(
|
const executingShellTool = toolCalls.find(
|
||||||
(tc) =>
|
(tc) =>
|
||||||
tc.status === 'executing' && tc.request.name === 'run_shell_command',
|
tc.status === 'executing' && tc.request.name === 'run_shell_command',
|
||||||
);
|
);
|
||||||
if (executingShellTool) {
|
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
|
||||||
return (executingShellTool as { pid?: number }).pid;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [toolCalls]);
|
}, [toolCalls]);
|
||||||
|
|
||||||
const lastQueryRef = useRef<PartListUnion | null>(null);
|
const lastQueryRef = useRef<PartListUnion | null>(null);
|
||||||
@@ -426,18 +447,30 @@ export const useGeminiStream = (
|
|||||||
await done;
|
await done;
|
||||||
setIsResponding(false);
|
setIsResponding(false);
|
||||||
}, []);
|
}, []);
|
||||||
const { handleShellCommand, activeShellPtyId, lastShellOutputTime } =
|
|
||||||
useShellCommandProcessor(
|
const {
|
||||||
addItem,
|
handleShellCommand,
|
||||||
setPendingHistoryItem,
|
activeShellPtyId,
|
||||||
onExec,
|
lastShellOutputTime,
|
||||||
onDebugMessage,
|
backgroundShellCount,
|
||||||
config,
|
isBackgroundShellVisible,
|
||||||
geminiClient,
|
toggleBackgroundShell,
|
||||||
setShellInputFocused,
|
backgroundCurrentShell,
|
||||||
terminalWidth,
|
registerBackgroundShell,
|
||||||
terminalHeight,
|
dismissBackgroundShell,
|
||||||
);
|
backgroundShells,
|
||||||
|
} = useShellCommandProcessor(
|
||||||
|
addItem,
|
||||||
|
setPendingHistoryItem,
|
||||||
|
onExec,
|
||||||
|
onDebugMessage,
|
||||||
|
config,
|
||||||
|
geminiClient,
|
||||||
|
setShellInputFocused,
|
||||||
|
terminalWidth,
|
||||||
|
terminalHeight,
|
||||||
|
activeToolPtyId,
|
||||||
|
);
|
||||||
|
|
||||||
const activePtyId = activeShellPtyId || activeToolPtyId;
|
const activePtyId = activeShellPtyId || activeToolPtyId;
|
||||||
|
|
||||||
@@ -1222,6 +1255,9 @@ export const useGeminiStream = (
|
|||||||
queryToSend,
|
queryToSend,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
prompt_id!,
|
prompt_id!,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
query,
|
||||||
);
|
);
|
||||||
const processingStatus = await processGeminiStreamEvents(
|
const processingStatus = await processGeminiStreamEvents(
|
||||||
stream,
|
stream,
|
||||||
@@ -1404,6 +1440,25 @@ export const useGeminiStream = (
|
|||||||
!processedMemoryToolsRef.current.has(t.request.callId),
|
!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) {
|
if (newSuccessfulMemorySaves.length > 0) {
|
||||||
// Perform the refresh only if there are new ones.
|
// Perform the refresh only if there are new ones.
|
||||||
void performMemoryRefresh();
|
void performMemoryRefresh();
|
||||||
@@ -1510,6 +1565,7 @@ export const useGeminiStream = (
|
|||||||
performMemoryRefresh,
|
performMemoryRefresh,
|
||||||
modelSwitchedFromQuotaError,
|
modelSwitchedFromQuotaError,
|
||||||
addItem,
|
addItem,
|
||||||
|
registerBackgroundShell,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1599,6 +1655,12 @@ export const useGeminiStream = (
|
|||||||
activePtyId,
|
activePtyId,
|
||||||
loopDetectionConfirmationRequest,
|
loopDetectionConfirmationRequest,
|
||||||
lastOutputTime,
|
lastOutputTime,
|
||||||
|
backgroundShellCount,
|
||||||
|
isBackgroundShellVisible,
|
||||||
|
toggleBackgroundShell,
|
||||||
|
backgroundCurrentShell,
|
||||||
|
backgroundShells,
|
||||||
|
dismissBackgroundShell,
|
||||||
retryStatus,
|
retryStatus,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export type TrackedWaitingToolCall = WaitingToolCall & {
|
|||||||
};
|
};
|
||||||
export type TrackedExecutingToolCall = ExecutingToolCall & {
|
export type TrackedExecutingToolCall = ExecutingToolCall & {
|
||||||
responseSubmittedToGemini?: boolean;
|
responseSubmittedToGemini?: boolean;
|
||||||
pid?: number;
|
|
||||||
};
|
};
|
||||||
export type TrackedCompletedToolCall = CompletedToolCall & {
|
export type TrackedCompletedToolCall = CompletedToolCall & {
|
||||||
responseSubmittedToGemini?: boolean;
|
responseSubmittedToGemini?: boolean;
|
||||||
@@ -134,7 +133,15 @@ export function useReactToolScheduler(
|
|||||||
...coreTc,
|
...coreTc,
|
||||||
responseSubmittedToGemini,
|
responseSubmittedToGemini,
|
||||||
liveOutput,
|
liveOutput,
|
||||||
pid: coreTc.pid,
|
};
|
||||||
|
} else if (
|
||||||
|
coreTc.status === 'success' ||
|
||||||
|
coreTc.status === 'error' ||
|
||||||
|
coreTc.status === 'cancelled'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...coreTc,
|
||||||
|
responseSubmittedToGemini,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ import { coreEvents } from '@google/gemini-cli-core';
|
|||||||
// Mock modules
|
// Mock modules
|
||||||
vi.mock('fs/promises');
|
vi.mock('fs/promises');
|
||||||
vi.mock('path');
|
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_PROJECT_TEMP_DIR = '/test/project/temp';
|
||||||
const MOCKED_CHATS_DIR = '/test/project/temp/chats';
|
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', () => {
|
it('should filter out slash commands from client history but keep in UI', () => {
|
||||||
const messages: MessageRecord[] = [
|
const messages: MessageRecord[] = [
|
||||||
{ type: 'user', content: '/help' } as MessageRecord,
|
{ type: 'user', content: '/help' } as MessageRecord,
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import type {
|
|||||||
ConversationRecord,
|
ConversationRecord,
|
||||||
ResumedSessionData,
|
ResumedSessionData,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type { Part } from '@google/genai';
|
import { coreEvents } from '@google/gemini-cli-core';
|
||||||
import { partListUnionToString, coreEvents } from '@google/gemini-cli-core';
|
|
||||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
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 = (
|
export const useSessionBrowser = (
|
||||||
config: Config,
|
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,
|
command: Command.MOVE_LEFT,
|
||||||
positive: [createKey('left'), createKey('b', { ctrl: true })],
|
positive: [createKey('left')],
|
||||||
negative: [createKey('left', { ctrl: true }), createKey('b')],
|
negative: [
|
||||||
|
createKey('left', { ctrl: true }),
|
||||||
|
createKey('b'),
|
||||||
|
createKey('b', { ctrl: true }),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: Command.MOVE_RIGHT,
|
command: Command.MOVE_RIGHT,
|
||||||
@@ -285,7 +289,10 @@ describe('keyMatchers', () => {
|
|||||||
{
|
{
|
||||||
command: Command.SHOW_ERROR_DETAILS,
|
command: Command.SHOW_ERROR_DETAILS,
|
||||||
positive: [createKey('f12')],
|
positive: [createKey('f12')],
|
||||||
negative: [createKey('o', { ctrl: true }), createKey('f11')],
|
negative: [
|
||||||
|
createKey('o', { ctrl: true }),
|
||||||
|
createKey('b', { ctrl: true }),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: Command.SHOW_FULL_TODOS,
|
command: Command.SHOW_FULL_TODOS,
|
||||||
@@ -357,6 +364,16 @@ describe('keyMatchers', () => {
|
|||||||
positive: [createKey('tab', { shift: true })],
|
positive: [createKey('tab', { shift: true })],
|
||||||
negative: [createKey('tab')],
|
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', () => {
|
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 { useFlickerDetector } from '../hooks/useFlickerDetector.js';
|
||||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||||
import { CopyModeWarning } from '../components/CopyModeWarning.js';
|
import { CopyModeWarning } from '../components/CopyModeWarning.js';
|
||||||
|
import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
|
||||||
|
import { StreamingState } from '../types.js';
|
||||||
|
|
||||||
export const DefaultAppLayout: React.FC = () => {
|
export const DefaultAppLayout: React.FC = () => {
|
||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
@@ -37,6 +39,24 @@ export const DefaultAppLayout: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<MainContent />
|
<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
|
<Box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
ref={uiState.mainControlsRef}
|
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) => {},
|
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||||
removeComponent: () => {},
|
removeComponent: () => {},
|
||||||
|
toggleBackgroundShell: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,5 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// 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`] = `
|
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`] = `
|
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 * as fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
|
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.
|
* 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.protocolVersion).toBe(acp.PROTOCOL_VERSION);
|
||||||
expect(response.authMethods).toHaveLength(3);
|
expect(response.authMethods).toHaveLength(3);
|
||||||
expect(response.agentCapabilities?.loadSession).toBe(false);
|
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should authenticate correctly', async () => {
|
it('should authenticate correctly', async () => {
|
||||||
@@ -273,6 +273,7 @@ describe('Session', () => {
|
|||||||
mockChat = {
|
mockChat = {
|
||||||
sendMessageStream: vi.fn(),
|
sendMessageStream: vi.fn(),
|
||||||
addHistory: vi.fn(),
|
addHistory: vi.fn(),
|
||||||
|
recordCompletedToolCalls: vi.fn(),
|
||||||
} as unknown as Mocked<GeminiChat>;
|
} as unknown as Mocked<GeminiChat>;
|
||||||
mockTool = {
|
mockTool = {
|
||||||
kind: 'native',
|
kind: 'native',
|
||||||
@@ -293,6 +294,7 @@ describe('Session', () => {
|
|||||||
} as unknown as Mocked<MessageBus>;
|
} as unknown as Mocked<MessageBus>;
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||||
|
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||||
getPreviewFeatures: vi.fn().mockReturnValue({}),
|
getPreviewFeatures: vi.fn().mockReturnValue({}),
|
||||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||||
getFileService: vi.fn().mockReturnValue({
|
getFileService: vi.fn().mockReturnValue({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
ToolResult,
|
ToolResult,
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
FilterFilesOptions,
|
FilterFilesOptions,
|
||||||
|
ConversationRecord,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
createWorkingStdio,
|
createWorkingStdio,
|
||||||
startupProfiler,
|
startupProfiler,
|
||||||
Kind,
|
Kind,
|
||||||
|
partListUnionToString,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import * as acp from '@agentclientprotocol/sdk';
|
import * as acp from '@agentclientprotocol/sdk';
|
||||||
import { AcpFileSystemService } from './fileSystemService.js';
|
import { AcpFileSystemService } from './fileSystemService.js';
|
||||||
@@ -47,6 +49,10 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { CliArgs } from '../config/config.js';
|
import type { CliArgs } from '../config/config.js';
|
||||||
import { loadCliConfig } from '../config/config.js';
|
import { loadCliConfig } from '../config/config.js';
|
||||||
import { runExitCleanup } from '../utils/cleanup.js';
|
import { runExitCleanup } from '../utils/cleanup.js';
|
||||||
|
import {
|
||||||
|
SessionSelector,
|
||||||
|
convertSessionToHistoryFormats,
|
||||||
|
} from '../utils/sessionUtils.js';
|
||||||
|
|
||||||
export async function runZedIntegration(
|
export async function runZedIntegration(
|
||||||
config: Config,
|
config: Config,
|
||||||
@@ -107,7 +113,7 @@ export class GeminiAgent {
|
|||||||
protocolVersion: acp.PROTOCOL_VERSION,
|
protocolVersion: acp.PROTOCOL_VERSION,
|
||||||
authMethods,
|
authMethods,
|
||||||
agentCapabilities: {
|
agentCapabilities: {
|
||||||
loadSession: false,
|
loadSession: true,
|
||||||
promptCapabilities: {
|
promptCapabilities: {
|
||||||
image: true,
|
image: true,
|
||||||
audio: true,
|
audio: true,
|
||||||
@@ -146,23 +152,11 @@ export class GeminiAgent {
|
|||||||
mcpServers,
|
mcpServers,
|
||||||
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
const config = await this.initializeSessionConfig(
|
||||||
|
sessionId,
|
||||||
let isAuthenticated = false;
|
cwd,
|
||||||
if (this.settings.merged.security.auth.selectedType) {
|
mcpServers,
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.clientCapabilities?.fs) {
|
if (this.clientCapabilities?.fs) {
|
||||||
const acpFileSystemService = new AcpFileSystemService(
|
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(
|
async newSessionConfig(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
@@ -228,8 +304,6 @@ export class GeminiAgent {
|
|||||||
|
|
||||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||||
|
|
||||||
await config.initialize();
|
|
||||||
startupProfiler.flush(config);
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +343,73 @@ export class Session {
|
|||||||
this.pendingPrompt = null;
|
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> {
|
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||||
this.pendingPrompt?.abort();
|
this.pendingPrompt?.abort();
|
||||||
const pendingSend = new AbortController();
|
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(
|
return convertToFunctionResponse(
|
||||||
fc.name,
|
fc.name,
|
||||||
callId,
|
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);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -983,6 +1180,9 @@ function toPermissionOptions(
|
|||||||
},
|
},
|
||||||
...basicPermissionOptions,
|
...basicPermissionOptions,
|
||||||
];
|
];
|
||||||
|
case 'ask_user':
|
||||||
|
// askuser doesn't need "always allow" options since it's asking questions
|
||||||
|
return [...basicPermissionOptions];
|
||||||
default: {
|
default: {
|
||||||
const unreachable: never = confirmation;
|
const unreachable: never = confirmation;
|
||||||
throw new Error(`Unexpected: ${unreachable}`);
|
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 type { ToolRegistry } from '../tools/tool-registry.js';
|
||||||
import { ThinkingLevel } from '@google/genai';
|
import { ThinkingLevel } from '@google/genai';
|
||||||
import type { AcknowledgedAgentsService } from './acknowledgedAgents.js';
|
import type { AcknowledgedAgentsService } from './acknowledgedAgents.js';
|
||||||
|
import { PolicyDecision } from '../policy/types.js';
|
||||||
|
|
||||||
vi.mock('./agentLoader.js', () => ({
|
vi.mock('./agentLoader.js', () => ({
|
||||||
loadAgentsFromDirectory: vi
|
loadAgentsFromDirectory: vi
|
||||||
@@ -657,6 +658,114 @@ describe('AgentRegistry', () => {
|
|||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
expect(registry.getAllDefinitions()).toHaveLength(100);
|
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', () => {
|
describe('reload', () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type ModelConfig,
|
type ModelConfig,
|
||||||
ModelConfigService,
|
ModelConfigService,
|
||||||
} from '../services/modelConfigService.js';
|
} from '../services/modelConfigService.js';
|
||||||
|
import { PolicyDecision } from '../policy/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the model config alias for a given agent definition.
|
* Returns the model config alias for a given agent definition.
|
||||||
@@ -266,6 +267,39 @@ export class AgentRegistry {
|
|||||||
this.agents.set(mergedDefinition.name, mergedDefinition);
|
this.agents.set(mergedDefinition.name, mergedDefinition);
|
||||||
|
|
||||||
this.registerModelConfigs(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>(
|
private isAgentEnabled<TOutput extends z.ZodTypeAny>(
|
||||||
@@ -342,6 +376,7 @@ export class AgentRegistry {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.agents.set(definition.name, definition);
|
this.agents.set(definition.name, definition);
|
||||||
|
this.addAgentPolicy(definition);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugLogger.warn(
|
debugLogger.warn(
|
||||||
`[AgentRegistry] Error loading A2A agent "${definition.name}":`,
|
`[AgentRegistry] Error loading A2A agent "${definition.name}":`,
|
||||||
|
|||||||
@@ -17,8 +17,15 @@ import {
|
|||||||
fetchAdminControls,
|
fetchAdminControls,
|
||||||
sanitizeAdminSettings,
|
sanitizeAdminSettings,
|
||||||
stopAdminControlsPolling,
|
stopAdminControlsPolling,
|
||||||
|
getAdminErrorMessage,
|
||||||
} from './admin_controls.js';
|
} from './admin_controls.js';
|
||||||
import type { CodeAssistServer } from '../server.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', () => {
|
describe('Admin Controls', () => {
|
||||||
let mockServer: CodeAssistServer;
|
let mockServer: CodeAssistServer;
|
||||||
@@ -370,6 +377,57 @@ describe('Admin Controls', () => {
|
|||||||
|
|
||||||
// The poll should not have fired again
|
// The poll should not have fired again
|
||||||
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
|
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,
|
type FetchAdminControlsResponse,
|
||||||
FetchAdminControlsResponseSchema,
|
FetchAdminControlsResponseSchema,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
import { getCodeAssistServer } from '../codeAssist.js';
|
||||||
|
import type { Config } from '../../config/config.js';
|
||||||
|
|
||||||
let pollingInterval: NodeJS.Timeout | undefined;
|
let pollingInterval: NodeJS.Timeout | undefined;
|
||||||
let currentSettings: FetchAdminControlsResponse | undefined;
|
let currentSettings: FetchAdminControlsResponse | undefined;
|
||||||
@@ -132,3 +134,20 @@ export function stopAdminControlsPolling() {
|
|||||||
pollingInterval = undefined;
|
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