mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-13 12:57:12 -07:00
Merge origin/main into feature/issue-17113-tool-preselection
This commit is contained in:
@@ -109,23 +109,41 @@ detailed **highlights** section for the release-specific page.
|
||||
|
||||
- **Target File**: `docs/changelogs/latest.md`
|
||||
- Perform the following edits on the target file:
|
||||
1. Update the version in the main header.
|
||||
2. Update the "Released:" date.
|
||||
1. Update the version in the main header. The line should read,
|
||||
`# Latest stable release: {{version}}`
|
||||
2. Update the rease date. The line should read,
|
||||
`Released: {{release_date_month_dd_yyyy}}`
|
||||
3. **Prepend** the processed "What's Changed" list from the temporary file
|
||||
to the existing "What's Changed" list in the file.
|
||||
4. In the "Full Changelog" URL, replace only the trailing version with the
|
||||
new patch version.
|
||||
to the existing "What's Changed" list in `latest.md`. Do not change or
|
||||
replace the existing list, **only add** to the beginning of it.
|
||||
4. In the "Full Changelog", edit **only** the end of the URL. Identify the
|
||||
last part of the URL that looks like `...{previous_version}` and update
|
||||
it to be `...{version}`.
|
||||
|
||||
Example: assume the patch version is `v0.29.1`. Change
|
||||
`Full Changelog: https://github.com/google-gemini/gemini-cli/compare/v0.28.2…v0.29.0`
|
||||
to
|
||||
`Full Changelog: https://github.com/google-gemini/gemini-cli/compare/v0.28.2…v0.29.1`
|
||||
|
||||
### B.2: Preview Patch (e.g., `v0.29.0-preview.3`)
|
||||
|
||||
- **Target File**: `docs/changelogs/preview.md`
|
||||
- Perform the following edits on the target file:
|
||||
1. Update the version in the main header.
|
||||
2. Update the "Released:" date.
|
||||
1. Update the version in the main header. The line should read,
|
||||
`# Preview release: {{version}}`
|
||||
2. Update the rease date. The line should read,
|
||||
`Released: {{release_date_month_dd_yyyy}}`
|
||||
3. **Prepend** the processed "What's Changed" list from the temporary file
|
||||
to the existing "What's Changed" list in the file.
|
||||
4. In the "Full Changelog" URL, replace only the trailing version with the
|
||||
new patch version.
|
||||
to the existing "What's Changed" list in `preview.md`. Do not change or
|
||||
replace the existing list, **only add** to the beginning of it.
|
||||
4. In the "Full Changelog", edit **only** the end of the URL. Identify the
|
||||
last part of the URL that looks like `...{previous_version}` and update
|
||||
it to be `...{version}`.
|
||||
|
||||
Example: assume the patch version is `v0.29.0-preview.1`. Change
|
||||
`Full Changelog: https://github.com/google-gemini/gemini-cli/compare/v0.28.2…v0.29.0-preview.0`
|
||||
to
|
||||
`Full Changelog: https://github.com/google-gemini/gemini-cli/compare/v0.28.2…v0.29.0-preview.1`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: pr-address-comments
|
||||
description: Use this skill if the user asks you to help them address GitHub PR comments for their current branch of the Gemini CLI. Requires `gh` CLI tool.
|
||||
---
|
||||
You are helping the user address comments on their Pull Request. These comments may have come from an automated review agent or a team member.
|
||||
|
||||
OBJECTIVE: Help the user review and address comments on their PR.
|
||||
|
||||
# Comment Review Procedure
|
||||
|
||||
1. Run the `scripts/fetch-pr-info.js` script to get PR info and state. MAKE SURE you read the entire output of the command, even if it gets truncated.
|
||||
2. Summarize the review status by analyzing the diff, commit log, and comments to see which still need to be addressed. Pay attention to the current user's comments. For resolved threads, summarize as a single line with a ✅. For open threads, provide a reference number e.g. [1] and the comment content.
|
||||
3. Present your summary of the feedback and current state and allow the user to guide you as to what to fix/address/skip. DO NOT begin fixing issues automatically.
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-env node */
|
||||
/* global console, process */
|
||||
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
async function run(cmd) {
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (_e) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const IGNORE_MESSAGES = [
|
||||
'thank you so much for your contribution to Gemini CLI!',
|
||||
"I'm currently reviewing this pull request and will post my feedback shortly.",
|
||||
'This pull request is being closed because it is not currently linked to an issue.',
|
||||
];
|
||||
|
||||
const shouldIgnore = (body) => {
|
||||
if (!body) return false;
|
||||
return IGNORE_MESSAGES.some((msg) => body.includes(msg));
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const branch = await run('git branch --show-current');
|
||||
if (!branch) {
|
||||
console.error('❌ Could not determine current git branch.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const gqlQuery = `query($branch:String!){repository(name:"gemini-cli",owner:"google-gemini"){pullRequests(headRefName:$branch,first:100){nodes{id,number,state,comments(first:100){nodes{createdAt,isMinimized,minimizedReason,author{login},body,url,authorAssociation}},reviews(first:100){nodes{id,author{login},createdAt,isMinimized,minimizedReason,body,state,comments(first:30){nodes{id,replyTo{id},author{login},createdAt,body,isMinimized,minimizedReason,path,line,startLine,originalLine,originalStartLine}}}}}}}}`;
|
||||
|
||||
const [authInfo, diff, commits, rawJson] = await Promise.all([
|
||||
run('gh auth status -a'),
|
||||
run('gh pr diff'),
|
||||
run(
|
||||
'git fetch && git log origin/main..origin/$(git branch --show-current)',
|
||||
),
|
||||
run(`gh api graphql -F branch="${branch}" -f query='${gqlQuery}'`),
|
||||
]);
|
||||
|
||||
if (!diff) {
|
||||
console.error(`⚠️ No active PR found for branch: ${branch}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n# Current GitHub user info:\n\n${authInfo}\n`);
|
||||
console.log(`\n# PR diff for current branch: ${branch}\n\n\`\`\``);
|
||||
console.log(diff);
|
||||
console.log('```');
|
||||
console.log(
|
||||
`\n# Commit history (origin/main..origin/${branch})\n\n${commits}`,
|
||||
);
|
||||
|
||||
const data = JSON.parse(rawJson || '{}');
|
||||
const prs = data?.data?.repository?.pullRequests?.nodes || [];
|
||||
|
||||
// Sort PRs by number descending so we check the newest one first
|
||||
prs.sort((a, b) => b.number - a.number);
|
||||
|
||||
const pr = prs.find((p) => p.state === 'OPEN') || prs[0];
|
||||
|
||||
if (!pr) {
|
||||
console.error('❌ No PR data found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n# PR Feedback\n');
|
||||
|
||||
// 1. General PR Comments
|
||||
const general = pr.comments.nodes.filter((c) => !shouldIgnore(c.body));
|
||||
if (general.length > 0) {
|
||||
console.log('\n💬 GENERAL COMMENTS:');
|
||||
general.forEach((c) => {
|
||||
const minimized = c.isMinimized
|
||||
? ` (Minimized: ${c.minimizedReason})`
|
||||
: '';
|
||||
console.log(
|
||||
`[${c.createdAt}] [${c.author.login}]${minimized}: ${c.body}\n`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Process ALL Review Comments into a single Thread Map
|
||||
const allInlineComments = pr.reviews.nodes.flatMap((r) => r.comments.nodes);
|
||||
const filteredInlines = allInlineComments.filter(
|
||||
(c) => !shouldIgnore(c.body),
|
||||
);
|
||||
|
||||
console.log('🔍 CODE REVIEWS & INLINE THREADS:');
|
||||
|
||||
// Print Review Summaries First
|
||||
pr.reviews.nodes.forEach((review) => {
|
||||
if (review.body && !shouldIgnore(review.body)) {
|
||||
const icon = review.state === 'APPROVED' ? '✅' : '💬';
|
||||
const minimized = review.isMinimized
|
||||
? ` (Minimized: ${review.minimizedReason})`
|
||||
: '';
|
||||
console.log(
|
||||
`\n${icon} ${review.state} by ${review.author.login} at ${review.createdAt}${minimized}: "${review.body}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Build and Print Threads
|
||||
const topLevelThreads = filteredInlines.filter((c) => !c.replyTo);
|
||||
|
||||
const printThread = (parentId, depth = 1) => {
|
||||
const indent = ' '.repeat(depth);
|
||||
filteredInlines
|
||||
.filter((c) => c.replyTo?.id === parentId)
|
||||
.forEach((reply) => {
|
||||
const minimized = reply.isMinimized
|
||||
? ` (Minimized: ${reply.minimizedReason})`
|
||||
: '';
|
||||
console.log(
|
||||
`${indent}↳ [${reply.createdAt}] ${reply.author.login}${minimized}: ${reply.body}`,
|
||||
);
|
||||
printThread(reply.id, depth + 1);
|
||||
});
|
||||
};
|
||||
|
||||
topLevelThreads.forEach((c) => {
|
||||
const start = c.startLine || c.originalStartLine;
|
||||
const end = c.line || c.originalLine;
|
||||
const range = start && end && start !== end ? `${start}-${end}` : end || '';
|
||||
const fileInfo = c.path
|
||||
? `(${c.path}${range ? `:${range}` : ''}) `
|
||||
: range
|
||||
? `(Line ${range}) `
|
||||
: '';
|
||||
const minimized = c.isMinimized ? ` (Minimized: ${c.minimizedReason})` : '';
|
||||
console.log(
|
||||
`\n💬 ${minimized}${c.author.login} | ${c.createdAt} ${fileInfo}\n${c.body}`,
|
||||
);
|
||||
printThread(c.id);
|
||||
});
|
||||
|
||||
console.log('\n');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('❌ Unexpected error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -284,8 +284,21 @@ jobs:
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
core.setFailed(`Output is not valid JSON and does not contain a JSON markdown block.\nRaw output: ${rawOutput}`);
|
||||
return;
|
||||
// If no markdown block, try to find a raw JSON object in the output.
|
||||
// The CLI may include debug/log lines (e.g. telemetry init, YOLO mode)
|
||||
// before the actual JSON response.
|
||||
const jsonObjectMatch = rawOutput.match(/(\{[\s\S]*"labels_to_set"[\s\S]*\})/);
|
||||
if (jsonObjectMatch) {
|
||||
try {
|
||||
parsedLabels = JSON.parse(jsonObjectMatch[0]);
|
||||
} catch (extractError) {
|
||||
core.setFailed(`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawOutput}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
core.setFailed(`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawOutput}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -546,7 +546,7 @@ Before submitting your documentation pull request, please:
|
||||
|
||||
If you have questions about contributing documentation:
|
||||
|
||||
- Check our [FAQ](/docs/faq.md).
|
||||
- Check our [FAQ](/docs/resources/faq.md).
|
||||
- Review existing documentation for examples.
|
||||
- Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss
|
||||
your proposed changes.
|
||||
|
||||
@@ -295,7 +295,8 @@ on GitHub.
|
||||
- **Experimental permission improvements:** We are now experimenting with a new
|
||||
policy engine in Gemini CLI. This allows users and administrators to create
|
||||
fine-grained policy for tool calls. Currently behind a flag. See
|
||||
[policy engine documentation](../core/policy-engine.md) for more information.
|
||||
[policy engine documentation](../reference/policy-engine.md) for more
|
||||
information.
|
||||
- Blog:
|
||||
[https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/](https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/)
|
||||
- **Gemini 3 support for paid:** Gemini 3 support has been rolled out to all API
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Preview release: v0.30.0-preview.1
|
||||
# Preview release: v0.30.0-preview.3
|
||||
|
||||
Released: February 19, 2026
|
||||
|
||||
@@ -311,4 +311,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#19008](https://github.com/google-gemini/gemini-cli/pull/19008)
|
||||
|
||||
**Full changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.3
|
||||
|
||||
+23
-23
@@ -26,29 +26,29 @@ and parameters.
|
||||
|
||||
## CLI Options
|
||||
|
||||
| Option | Alias | Type | Default | Description |
|
||||
| -------------------------------- | ----- | ------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging |
|
||||
| `--version` | `-v` | - | - | Show CLI version number and exit |
|
||||
| `--help` | `-h` | - | - | Show help information |
|
||||
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
|
||||
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. |
|
||||
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
|
||||
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
|
||||
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
|
||||
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
|
||||
| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
|
||||
| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** |
|
||||
| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) |
|
||||
| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../core/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) |
|
||||
| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) |
|
||||
| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit |
|
||||
| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) |
|
||||
| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit |
|
||||
| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) |
|
||||
| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) |
|
||||
| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility |
|
||||
| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` |
|
||||
| Option | Alias | Type | Default | Description |
|
||||
| -------------------------------- | ----- | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging |
|
||||
| `--version` | `-v` | - | - | Show CLI version number and exit |
|
||||
| `--help` | `-h` | - | - | Show help information |
|
||||
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
|
||||
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. |
|
||||
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
|
||||
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
|
||||
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
|
||||
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
|
||||
| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
|
||||
| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** |
|
||||
| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) |
|
||||
| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../reference/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) |
|
||||
| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) |
|
||||
| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit |
|
||||
| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) |
|
||||
| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit |
|
||||
| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) |
|
||||
| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) |
|
||||
| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility |
|
||||
| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` |
|
||||
|
||||
## Model selection
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ The most powerful tools for enterprise administration are the system-wide
|
||||
settings files. These files allow you to define a baseline configuration
|
||||
(`system-defaults.json`) and a set of overrides (`settings.json`) that apply to
|
||||
all users on a machine. For a complete overview of configuration options, see
|
||||
the [Configuration documentation](../get-started/configuration.md).
|
||||
the [Configuration documentation](../reference/configuration.md).
|
||||
|
||||
Settings are merged from four files. The precedence order for single-value
|
||||
settings (like `theme`) is:
|
||||
@@ -224,8 +224,8 @@ gemini
|
||||
|
||||
You can significantly enhance security by controlling which tools the Gemini
|
||||
model can use. This is achieved through the `tools.core` setting and the
|
||||
[Policy Engine](../core/policy-engine.md). For a list of available tools, see
|
||||
the [Tools documentation](../tools/index.md).
|
||||
[Policy Engine](../reference/policy-engine.md). For a list of available tools,
|
||||
see the [Tools documentation](../tools/index.md).
|
||||
|
||||
### Allowlisting with `coreTools`
|
||||
|
||||
@@ -245,8 +245,8 @@ on the approved list.
|
||||
|
||||
### Blocklisting with `excludeTools` (Deprecated)
|
||||
|
||||
> **Deprecated:** Use the [Policy Engine](../core/policy-engine.md) for more
|
||||
> robust control.
|
||||
> **Deprecated:** Use the [Policy Engine](../reference/policy-engine.md) for
|
||||
> more robust control.
|
||||
|
||||
Alternatively, you can add specific tools that are considered dangerous in your
|
||||
environment to a blocklist.
|
||||
@@ -289,8 +289,8 @@ unintended tool execution.
|
||||
## Managing custom tools (MCP servers)
|
||||
|
||||
If your organization uses custom tools via
|
||||
[Model-Context Protocol (MCP) servers](../core/tools-api.md), it is crucial to
|
||||
understand how server configurations are managed to apply security policies
|
||||
[Model-Context Protocol (MCP) servers](../reference/tools-api.md), it is crucial
|
||||
to understand how server configurations are managed to apply security policies
|
||||
effectively.
|
||||
|
||||
### How MCP server configurations are merged
|
||||
|
||||
@@ -88,7 +88,7 @@ More content here.
|
||||
@../shared/style-guide.md
|
||||
```
|
||||
|
||||
For more details, see the [Memory Import Processor](../core/memport.md)
|
||||
For more details, see the [Memory Import Processor](../reference/memport.md)
|
||||
documentation.
|
||||
|
||||
## Customize the context file name
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable
|
||||
|
||||
You can also use the `--model` flag to specify a particular Gemini model on
|
||||
startup. For more details, refer to the
|
||||
[configuration documentation](../get-started/configuration.md).
|
||||
[configuration documentation](../reference/configuration.md).
|
||||
|
||||
Changes to these settings will be applied to all subsequent interactions with
|
||||
Gemini CLI.
|
||||
|
||||
+43
-1
@@ -69,6 +69,7 @@ You can enter Plan Mode in three ways:
|
||||
2. **Command:** Type `/plan` in the input box.
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...". The agent will
|
||||
then call the [`enter_plan_mode`] tool to switch modes.
|
||||
- **Note:** This tool is not available when the CLI is in YOLO mode.
|
||||
|
||||
### The Planning Workflow
|
||||
|
||||
@@ -127,6 +128,47 @@ To use a skill in Plan Mode, you can explicitly ask the agent to "use the
|
||||
[skill-name] skill to plan..." or the agent may autonomously activate it based
|
||||
on the task description.
|
||||
|
||||
### Custom Plan Directory and Policies
|
||||
|
||||
By default, planning artifacts are stored in a managed temporary directory
|
||||
outside your project: `~/.gemini/tmp/<project>/<session-id>/plans/`.
|
||||
|
||||
You can configure a custom directory for plans in your `settings.json`. For
|
||||
example, to store plans in a `.gemini/plans` directory within your project:
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"plan": {
|
||||
"directory": ".gemini/plans"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To maintain the safety of Plan Mode, user-configured paths for the plans
|
||||
directory are restricted to the project root. This ensures that custom planning
|
||||
locations defined within a project's workspace cannot be used to escape and
|
||||
overwrite sensitive files elsewhere. Any user-configured directory must reside
|
||||
within the project boundary.
|
||||
|
||||
Because Plan Mode is read-only by default, using a custom directory requires
|
||||
updating your [Policy Engine] configurations to allow `write_file` and `replace`
|
||||
in that specific location. For example, to allow writing to the `.gemini/plans`
|
||||
directory within your project, create a policy file at
|
||||
`~/.gemini/policies/plan-custom-directory.toml`:
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
# Adjust the pattern to match your custom directory.
|
||||
# This example matches any .md file in a .gemini/plans directory within the project.
|
||||
argsPattern = "\"file_path\":\".*\\\\.gemini/plans/.*\\\\.md\""
|
||||
```
|
||||
|
||||
### Customizing Policies
|
||||
|
||||
Plan Mode is designed to be read-only by default to ensure safety during the
|
||||
@@ -184,7 +226,7 @@ Guide].
|
||||
[MCP tools]: /docs/tools/mcp-server.md
|
||||
[`activate_skill`]: /docs/cli/skills.md
|
||||
[experimental research sub-agents]: /docs/core/subagents.md
|
||||
[Policy Engine Guide]: /docs/core/policy-engine.md
|
||||
[Policy Engine Guide]: /docs/reference/policy-engine.md
|
||||
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
|
||||
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
|
||||
[`ask_user`]: /docs/tools/ask-user.md
|
||||
|
||||
+3
-3
@@ -167,6 +167,6 @@ gemini -s -p "run shell command: mount | grep workspace"
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Configuration](../get-started/configuration.md): Full configuration options.
|
||||
- [Commands](./commands.md): Available commands.
|
||||
- [Troubleshooting](../troubleshooting.md): General troubleshooting.
|
||||
- [Configuration](../reference/configuration.md): Full configuration options.
|
||||
- [Commands](../reference/commands.md): Available commands.
|
||||
- [Troubleshooting](../resources/troubleshooting.md): General troubleshooting.
|
||||
|
||||
+38
-36
@@ -28,6 +28,7 @@ they appear in the UI.
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | 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"` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
@@ -41,36 +42,36 @@ they appear in the UI.
|
||||
|
||||
### UI
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` |
|
||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
|
||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
||||
|
||||
### IDE
|
||||
|
||||
@@ -128,12 +129,13 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ Environment variables can be used to override the settings in the file.
|
||||
`true` or `1` will enable the feature. Any other value will disable it.
|
||||
|
||||
For detailed information about all configuration options, see the
|
||||
[Configuration guide](../get-started/configuration.md).
|
||||
[Configuration guide](../reference/configuration.md).
|
||||
|
||||
## Google Cloud telemetry
|
||||
|
||||
|
||||
+3
-3
@@ -41,8 +41,8 @@ can change the theme using the `/theme` command.
|
||||
### Theme persistence
|
||||
|
||||
Selected themes are saved in Gemini CLI's
|
||||
[configuration](../get-started/configuration.md) so your preference is
|
||||
remembered across sessions.
|
||||
[configuration](../reference/configuration.md) so your preference is remembered
|
||||
across sessions.
|
||||
|
||||
---
|
||||
|
||||
@@ -194,7 +194,7 @@ untrusted sources.
|
||||
- Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui`
|
||||
object in your `settings.json`.
|
||||
- Custom themes can be set at the user, project, or system level, and follow the
|
||||
same [configuration precedence](../get-started/configuration.md) as other
|
||||
same [configuration precedence](../reference/configuration.md) as other
|
||||
settings.
|
||||
|
||||
### Themes from extensions
|
||||
|
||||
@@ -121,6 +121,6 @@ immediately. Force a reload with:
|
||||
|
||||
- Learn about [Session management](session-management.md) to see how short-term
|
||||
history works.
|
||||
- Explore the [Command reference](../../cli/commands.md) for more `/memory`
|
||||
options.
|
||||
- Explore the [Command reference](../../reference/commands.md) for more
|
||||
`/memory` options.
|
||||
- Read the technical spec for [Project context](../../cli/gemini-md.md).
|
||||
|
||||
@@ -101,5 +101,5 @@ This creates a new branch of history without losing your original work.
|
||||
- Learn about [Checkpointing](../../cli/checkpointing.md) to understand the
|
||||
underlying safety mechanism.
|
||||
- Explore [Task planning](task-planning.md) to keep complex sessions organized.
|
||||
- See the [Command reference](../../cli/commands.md) for all `/chat` and
|
||||
- See the [Command reference](../../reference/commands.md) for all `/chat` and
|
||||
`/resume` options.
|
||||
|
||||
+7
-7
@@ -9,11 +9,11 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
|
||||
|
||||
- **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use
|
||||
specialized sub-agents for complex tasks.
|
||||
- **[Core tools API](./tools-api.md):** Information on how tools are defined,
|
||||
registered, and used by the core.
|
||||
- **[Memory Import Processor](./memport.md):** Documentation for the modular
|
||||
GEMINI.md import feature using @file.md syntax.
|
||||
- **[Policy Engine](./policy-engine.md):** Use the Policy Engine for
|
||||
- **[Core tools API](../reference/tools-api.md):** Information on how tools are
|
||||
defined, registered, and used by the core.
|
||||
- **[Memory Import Processor](../reference/memport.md):** Documentation for the
|
||||
modular GEMINI.md import feature using @file.md syntax.
|
||||
- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for
|
||||
fine-grained control over tool execution.
|
||||
|
||||
## Role of the core
|
||||
@@ -92,8 +92,8 @@ This allows you to have global, project-level, and component-level context
|
||||
files, which are all combined to provide the model with the most relevant
|
||||
information.
|
||||
|
||||
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and
|
||||
`refresh` the content of loaded `GEMINI.md` files.
|
||||
You can use the [`/memory` command](../reference/commands.md) to `show`, `add`,
|
||||
and `refresh` the content of loaded `GEMINI.md` files.
|
||||
|
||||
## Citations
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ the main agent's context or toolset.
|
||||
> ```
|
||||
>
|
||||
> **Warning:** Subagents currently operate in
|
||||
> ["YOLO mode"](../get-started/configuration.md#command-line-arguments), meaning
|
||||
> ["YOLO mode"](../reference/configuration.md#command-line-arguments), meaning
|
||||
> they may execute tools without individual user confirmation for each step.
|
||||
> Proceed with caution when defining agents with powerful tools like
|
||||
> `run_shell_command` or `write_file`.
|
||||
|
||||
@@ -130,7 +130,7 @@ The manifest file defines the extension's behavior and configuration.
|
||||
- `description`: A short summary shown in the extension gallery.
|
||||
- <a id="mcp-servers"></a>`mcpServers`: A map of Model Context Protocol (MCP)
|
||||
servers. Extension servers follow the same format as standard
|
||||
[CLI configuration](../get-started/configuration.md).
|
||||
[CLI configuration](../reference/configuration.md).
|
||||
- `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can
|
||||
also be an array of strings to load multiple context files.
|
||||
- `excludeTools`: An array of tools to block from the model. You can restrict
|
||||
|
||||
@@ -22,8 +22,8 @@ Select the authentication method that matches your situation in the table below:
|
||||
### What is my Google account type?
|
||||
|
||||
- **Individual Google accounts:** Includes all
|
||||
[free tier accounts](../quota-and-pricing/#free-usage) such as Gemini Code
|
||||
Assist for individuals, as well as paid subscriptions for
|
||||
[free tier accounts](../resources/quota-and-pricing.md#free-usage) such as
|
||||
Gemini Code Assist for individuals, as well as paid subscriptions for
|
||||
[Google AI Pro and Ultra](https://gemini.google/subscriptions/).
|
||||
|
||||
- **Organization accounts:** Accounts using paid licenses through an
|
||||
@@ -317,5 +317,5 @@ configure authentication using environment variables:
|
||||
Your authentication method affects your quotas, pricing, Terms of Service, and
|
||||
privacy notices. Review the following pages to learn more:
|
||||
|
||||
- [Gemini CLI: Quotas and Pricing](../quota-and-pricing.md).
|
||||
- [Gemini CLI: Terms of Service and Privacy Notice](../tos-privacy.md).
|
||||
- [Gemini CLI: Quotas and Pricing](../resources/quota-and-pricing.md).
|
||||
- [Gemini CLI: Terms of Service and Privacy Notice](../resources/tos-privacy.md).
|
||||
|
||||
@@ -54,7 +54,7 @@ Gemini CLI offers several ways to configure its behavior, including environment
|
||||
variables, command-line arguments, and settings files.
|
||||
|
||||
To explore your configuration options, see
|
||||
[Gemini CLI Configuration](./configuration.md).
|
||||
[Gemini CLI Configuration](../reference/configuration.md).
|
||||
|
||||
## Use
|
||||
|
||||
|
||||
@@ -420,7 +420,7 @@ When you open a project with hooks defined in `.gemini/settings.json`:
|
||||
|
||||
Hooks inherit the environment of the Gemini CLI process, which may include
|
||||
sensitive API keys. Gemini CLI provides a
|
||||
[redaction system](/docs/get-started/configuration#environment-variable-redaction)
|
||||
[redaction system](/docs/reference/configuration.md#environment-variable-redaction)
|
||||
that automatically filters variables matching sensitive patterns (e.g., `KEY`,
|
||||
`TOKEN`).
|
||||
|
||||
|
||||
+19
-46
@@ -48,41 +48,8 @@ User-focused guides and tutorials for daily development workflows.
|
||||
|
||||
## Features
|
||||
|
||||
Technical reference documentation for each capability of Gemini CLI.
|
||||
Technical documentation for each capability of Gemini CLI.
|
||||
|
||||
- **[/about](./cli/commands.md#about):** About Gemini CLI.
|
||||
- **[/auth](./get-started/authentication.md):** Authentication.
|
||||
- **[/bug](./cli/commands.md#bug):** Report a bug.
|
||||
- **[/chat](./cli/commands.md#chat):** Chat history.
|
||||
- **[/clear](./cli/commands.md#clear):** Clear screen.
|
||||
- **[/compress](./cli/commands.md#compress):** Compress context.
|
||||
- **[/copy](./cli/commands.md#copy):** Copy output.
|
||||
- **[/directory](./cli/commands.md#directory-or-dir):** Manage workspace.
|
||||
- **[/docs](./cli/commands.md#docs):** Open documentation.
|
||||
- **[/editor](./cli/commands.md#editor):** Select editor.
|
||||
- **[/extensions](./extensions/index.md):** Manage extensions.
|
||||
- **[/help](./cli/commands.md#help-or):** Show help.
|
||||
- **[/hooks](./hooks/index.md):** Hooks.
|
||||
- **[/ide](./ide-integration/index.md):** IDE integration.
|
||||
- **[/init](./cli/commands.md#init):** Initialize context.
|
||||
- **[/mcp](./tools/mcp-server.md):** MCP servers.
|
||||
- **[/memory](./cli/commands.md#memory):** Manage memory.
|
||||
- **[/model](./cli/model.md):** Model selection.
|
||||
- **[/policies](./cli/commands.md#policies):** Manage policies.
|
||||
- **[/privacy](./cli/commands.md#privacy):** Privacy notice.
|
||||
- **[/quit](./cli/commands.md#quit-or-exit):** Exit CLI.
|
||||
- **[/restore](./cli/checkpointing.md):** Restore files.
|
||||
- **[/resume](./cli/commands.md#resume):** Resume session.
|
||||
- **[/rewind](./cli/rewind.md):** Rewind.
|
||||
- **[/settings](./cli/settings.md):** Settings.
|
||||
- **[/setup-github](./cli/commands.md#setup-github):** GitHub setup.
|
||||
- **[/shells](./cli/commands.md#shells-or-bashes):** Manage processes.
|
||||
- **[/skills](./cli/skills.md):** Agent skills.
|
||||
- **[/stats](./cli/commands.md#stats):** Session statistics.
|
||||
- **[/terminal-setup](./cli/commands.md#terminal-setup):** Terminal keybindings.
|
||||
- **[/theme](./cli/themes.md):** Themes.
|
||||
- **[/tools](./cli/commands.md#tools):** List tools.
|
||||
- **[/vim](./cli/commands.md#vim):** Vim mode.
|
||||
- **[Activate skill (tool)](./tools/activate-skill.md):** Internal mechanism for
|
||||
loading expert procedures.
|
||||
- **[Ask user (tool)](./tools/ask-user.md):** Internal dialog system for
|
||||
@@ -97,12 +64,12 @@ Technical reference documentation for each capability of Gemini CLI.
|
||||
- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
|
||||
- **[Plan mode 🧪](./cli/plan-mode.md):** Use a safe, read-only mode for
|
||||
planning complex changes.
|
||||
- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific
|
||||
tasks.
|
||||
- **[Remote subagents 🧪](./core/remote-agents.md):** Connecting to and using
|
||||
remote agents.
|
||||
- **[Sandboxing](./cli/sandbox.md):** Isolate tool execution.
|
||||
- **[Shell (tool)](./tools/shell.md):** Detailed system execution parameters.
|
||||
- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific
|
||||
tasks.
|
||||
- **[Telemetry](./cli/telemetry.md):** Usage and performance metric details.
|
||||
- **[Todo (tool)](./tools/todos.md):** Progress tracking specification.
|
||||
- **[Token caching](./cli/token-caching.md):** Performance optimization.
|
||||
@@ -134,23 +101,29 @@ Settings and customization options for Gemini CLI.
|
||||
|
||||
Deep technical documentation and API specifications.
|
||||
|
||||
- **[Command reference](./cli/commands.md):** Detailed slash command guide.
|
||||
- **[Configuration reference](./get-started/configuration.md):** Settings and
|
||||
- **[Command reference](./reference/commands.md):** Detailed slash command
|
||||
guide.
|
||||
- **[Configuration reference](./reference/configuration.md):** Settings and
|
||||
environment variables.
|
||||
- **[Keyboard shortcuts](./cli/keyboard-shortcuts.md):** Productivity tips.
|
||||
- **[Memory import processor](./core/memport.md):** How Gemini CLI processes
|
||||
memory from various sources.
|
||||
- **[Policy engine](./core/policy-engine.md):** Fine-grained execution control.
|
||||
- **[Tools API](./core/tools-api.md):** The API for defining and using tools.
|
||||
- **[Keyboard shortcuts](./reference/keyboard-shortcuts.md):** Productivity
|
||||
tips.
|
||||
- **[Memory import processor](./reference/memport.md):** How Gemini CLI
|
||||
processes memory from various sources.
|
||||
- **[Policy engine](./reference/policy-engine.md):** Fine-grained execution
|
||||
control.
|
||||
- **[Tools API](./reference/tools-api.md):** The API for defining and using
|
||||
tools.
|
||||
|
||||
## Resources
|
||||
|
||||
Support, release history, and legal information.
|
||||
|
||||
- **[FAQ](./faq.md):** Answers to frequently asked questions.
|
||||
- **[FAQ](./resources/faq.md):** Answers to frequently asked questions.
|
||||
- **[Changelogs](./changelogs/index.md):** Highlights and notable changes.
|
||||
- **[Quota and pricing](./quota-and-pricing.md):** Limits and billing details.
|
||||
- **[Terms and privacy](./tos-privacy.md):** Official notices and terms.
|
||||
- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing
|
||||
details.
|
||||
- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and
|
||||
terms.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"/docs/architecture": "/docs/cli/index",
|
||||
"/docs/cli/commands": "/docs/reference/commands",
|
||||
"/docs/cli": "/docs",
|
||||
"/docs/cli/index": "/docs",
|
||||
"/docs/cli/keyboard-shortcuts": "/docs/reference/keyboard-shortcuts",
|
||||
"/docs/cli/uninstall": "/docs/resources/uninstall",
|
||||
"/docs/core/concepts": "/docs",
|
||||
"/docs/core/memport": "/docs/reference/memport",
|
||||
"/docs/core/policy-engine": "/docs/reference/policy-engine",
|
||||
"/docs/core/tools-api": "/docs/reference/tools-api",
|
||||
"/docs/faq": "/docs/resources/faq",
|
||||
"/docs/get-started/configuration": "/docs/reference/configuration",
|
||||
"/docs/get-started/configuration-v1": "/docs/reference/configuration",
|
||||
"/docs/index": "/docs",
|
||||
"/docs/quota-and-pricing": "/docs/resources/quota-and-pricing",
|
||||
"/docs/tos-privacy": "/docs/resources/tos-privacy",
|
||||
"/docs/troubleshooting": "/docs/resources/troubleshooting"
|
||||
}
|
||||
@@ -217,7 +217,7 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
model.
|
||||
- **Note:** For more details on how `GEMINI.md` files contribute to
|
||||
hierarchical memory, see the
|
||||
[CLI Configuration documentation](../get-started/configuration.md).
|
||||
[CLI Configuration documentation](./configuration.md).
|
||||
|
||||
### `/model`
|
||||
|
||||
@@ -254,7 +254,7 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
checkpoints to restore from.
|
||||
- **Usage:** `/restore [tool_call_id]`
|
||||
- **Note:** Only available if checkpointing is configured via
|
||||
[settings](../get-started/configuration.md). See
|
||||
[settings](./configuration.md). See
|
||||
[Checkpointing documentation](../cli/checkpointing.md) for more details.
|
||||
|
||||
### `/rewind`
|
||||
@@ -293,7 +293,8 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
settings that control the behavior and appearance of Gemini CLI. It is
|
||||
equivalent to manually editing the `.gemini/settings.json` file, but with
|
||||
validation and guidance to prevent errors. See the
|
||||
[settings documentation](./settings.md) for a full list of available settings.
|
||||
[settings documentation](../cli/settings.md) for a full list of available
|
||||
settings.
|
||||
- **Usage:** Simply run `/settings` and the editor will open. You can then
|
||||
browse or search for specific settings, view their current values, and modify
|
||||
them as desired. Changes to some settings are applied immediately, while
|
||||
@@ -380,7 +381,8 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
Custom commands allow you to create personalized shortcuts for your most-used
|
||||
prompts. For detailed instructions on how to create, manage, and use them,
|
||||
please see the dedicated [Custom Commands documentation](./custom-commands.md).
|
||||
please see the dedicated
|
||||
[Custom Commands documentation](../cli/custom-commands.md).
|
||||
|
||||
## Input prompt shortcuts
|
||||
|
||||
@@ -131,6 +131,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`general.plan.directory`** (string):
|
||||
- **Description:** The directory where planning artifacts are stored. If not
|
||||
specified, defaults to the system temporary directory.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`general.enablePromptCompletion`** (boolean):
|
||||
- **Description:** Enable AI-powered prompt completion suggestions while
|
||||
typing.
|
||||
@@ -305,13 +311,20 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Show the spinner during operations.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.loadingPhrases`** (enum):
|
||||
- **Description:** What to show while the model is working: tips, witty
|
||||
comments, both, or nothing.
|
||||
- **Default:** `"tips"`
|
||||
- **Values:** `"tips"`, `"witty"`, `"all"`, `"off"`
|
||||
|
||||
- **`ui.customWittyPhrases`** (array):
|
||||
- **Description:** Custom witty phrases to display during loading. When
|
||||
provided, the CLI cycles through these instead of the defaults.
|
||||
- **Default:** `[]`
|
||||
|
||||
- **`ui.accessibility.enableLoadingPhrases`** (boolean):
|
||||
- **Description:** Enable loading phrases during operations.
|
||||
- **Description:** @deprecated Use ui.loadingPhrases instead. Enable loading
|
||||
phrases during operations.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -941,8 +954,15 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.useOSC52Paste`** (boolean):
|
||||
- **Description:** Use OSC 52 sequence for pasting instead of clipboardy
|
||||
(useful for remote sessions).
|
||||
- **Description:** Use OSC 52 for pasting. This may be more robust than the
|
||||
default system when using remote terminal sessions (if your terminal is
|
||||
configured to allow it).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.useOSC52Copy`** (boolean):
|
||||
- **Description:** Use OSC 52 for copying. This may be more robust than the
|
||||
default system when using remote terminal sessions (if your terminal is
|
||||
configured to allow it).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.plan`** (boolean):
|
||||
@@ -1214,8 +1234,8 @@ within your user's home folder.
|
||||
Environment variables are a common way to configure applications, especially for
|
||||
sensitive information like API keys or for settings that might change between
|
||||
environments. For authentication setup, see the
|
||||
[Authentication documentation](./authentication.md) which covers all available
|
||||
authentication methods.
|
||||
[Authentication documentation](../get-started/authentication.md) which covers
|
||||
all available authentication methods.
|
||||
|
||||
The CLI automatically loads environment variables from an `.env` file. The
|
||||
loading order is:
|
||||
@@ -1234,7 +1254,8 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
|
||||
- **`GEMINI_API_KEY`**:
|
||||
- Your API key for the Gemini API.
|
||||
- One of several available [authentication methods](./authentication.md).
|
||||
- One of several available
|
||||
[authentication methods](../get-started/authentication.md).
|
||||
- Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env`
|
||||
file.
|
||||
- **`GEMINI_MODEL`**:
|
||||
@@ -1580,15 +1601,15 @@ conventions and context.
|
||||
about the active instructional context.
|
||||
- **Importing content:** You can modularize your context files by importing
|
||||
other Markdown files using the `@path/to/file.md` syntax. For more details,
|
||||
see the [Memory Import Processor documentation](../core/memport.md).
|
||||
see the [Memory Import Processor documentation](./memport.md).
|
||||
- **Commands for memory management:**
|
||||
- Use `/memory refresh` to force a re-scan and reload of all context files
|
||||
from all configured locations. This updates the AI's instructional context.
|
||||
- Use `/memory show` to display the combined instructional context currently
|
||||
loaded, allowing you to verify the hierarchy and content being used by the
|
||||
AI.
|
||||
- See the [Commands documentation](../cli/commands.md#memory) for full details
|
||||
on the `/memory` command and its sub-commands (`show` and `refresh`).
|
||||
- See the [Commands documentation](./commands.md#memory) for full details on
|
||||
the `/memory` command and its sub-commands (`show` and `refresh`).
|
||||
|
||||
By understanding and utilizing these configuration layers and the hierarchical
|
||||
nature of context files, you can effectively manage the AI's memory and tailor
|
||||
@@ -92,11 +92,12 @@ rule with the highest priority wins**.
|
||||
To provide a clear hierarchy, policies are organized into three tiers. Each tier
|
||||
has a designated number that forms the base of the final priority calculation.
|
||||
|
||||
| Tier | Base | Description |
|
||||
| :------ | :--- | :------------------------------------------------------------------------- |
|
||||
| Default | 1 | Built-in policies that ship with the Gemini CLI. |
|
||||
| User | 2 | Custom policies defined by the user. |
|
||||
| Admin | 3 | Policies managed by an administrator (e.g., in an enterprise environment). |
|
||||
| Tier | Base | Description |
|
||||
| :-------- | :--- | :------------------------------------------------------------------------- |
|
||||
| Default | 1 | Built-in policies that ship with the Gemini CLI. |
|
||||
| Workspace | 2 | Policies defined in the current workspace's configuration directory. |
|
||||
| User | 3 | Custom policies defined by the user. |
|
||||
| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). |
|
||||
|
||||
Within a TOML policy file, you assign a priority value from **0 to 999**. The
|
||||
engine transforms this into a final priority using the following formula:
|
||||
@@ -105,15 +106,17 @@ engine transforms this into a final priority using the following formula:
|
||||
|
||||
This system guarantees that:
|
||||
|
||||
- Admin policies always override User and Default policies.
|
||||
- User policies always override Default policies.
|
||||
- Admin policies always override User, Workspace, and Default policies.
|
||||
- User policies override Workspace and Default policies.
|
||||
- Workspace policies override Default policies.
|
||||
- You can still order rules within a single tier with fine-grained control.
|
||||
|
||||
For example:
|
||||
|
||||
- A `priority: 50` rule in a Default policy file becomes `1.050`.
|
||||
- A `priority: 100` rule in a User policy file becomes `2.100`.
|
||||
- A `priority: 20` rule in an Admin policy file becomes `3.020`.
|
||||
- A `priority: 10` rule in a Workspace policy policy file becomes `2.010`.
|
||||
- A `priority: 100` rule in a User policy file becomes `3.100`.
|
||||
- A `priority: 20` rule in an Admin policy file becomes `4.020`.
|
||||
|
||||
### Approval modes
|
||||
|
||||
@@ -156,10 +159,11 @@ User, and (if configured) Admin directories.
|
||||
|
||||
### Policy locations
|
||||
|
||||
| Tier | Type | Location |
|
||||
| :-------- | :----- | :-------------------------- |
|
||||
| **User** | Custom | `~/.gemini/policies/*.toml` |
|
||||
| **Admin** | System | _See below (OS specific)_ |
|
||||
| Tier | Type | Location |
|
||||
| :------------ | :----- | :---------------------------------------- |
|
||||
| **User** | Custom | `~/.gemini/policies/*.toml` |
|
||||
| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
|
||||
| **Admin** | System | _See below (OS specific)_ |
|
||||
|
||||
#### System-wide policies (Admin)
|
||||
|
||||
@@ -104,7 +104,7 @@ The Gemini CLI configuration is stored in two `settings.json` files:
|
||||
1. In your home directory: `~/.gemini/settings.json`.
|
||||
2. In your project's root directory: `./.gemini/settings.json`.
|
||||
|
||||
Refer to [Gemini CLI Configuration](./get-started/configuration.md) for more
|
||||
Refer to [Gemini CLI Configuration](../reference/configuration.md) for more
|
||||
details.
|
||||
|
||||
## Google AI Pro/Ultra and subscription FAQs
|
||||
@@ -10,8 +10,8 @@ and Privacy Notices applicable to those services apply to such access and use.
|
||||
Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy
|
||||
Policy.
|
||||
|
||||
**Note:** See [quotas and pricing](/docs/quota-and-pricing.md) for the quota and
|
||||
pricing details that apply to your usage of the Gemini CLI.
|
||||
**Note:** See [quotas and pricing](/docs/resources/quota-and-pricing.md) for the
|
||||
quota and pricing details that apply to your usage of the Gemini CLI.
|
||||
|
||||
## Supported authentication methods
|
||||
|
||||
@@ -93,4 +93,4 @@ backend, these Terms of Service and Privacy Notice documents apply:
|
||||
|
||||
You may opt-out from sending Gemini CLI Usage Statistics to Google by following
|
||||
the instructions available here:
|
||||
[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md#usage-statistics).
|
||||
[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md#usage-statistics).
|
||||
@@ -93,7 +93,7 @@ topics on:
|
||||
- **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations
|
||||
that are restricted by your sandbox configuration, such as writing outside
|
||||
the project directory or system temp directory.
|
||||
- **Solution:** Refer to the [Configuration: Sandboxing](./cli/sandbox.md)
|
||||
- **Solution:** Refer to the [Configuration: Sandboxing](../cli/sandbox.md)
|
||||
documentation for more information, including how to customize your sandbox
|
||||
configuration.
|
||||
|
||||
+22
-16
@@ -63,13 +63,13 @@
|
||||
"slug": "docs/extensions/index"
|
||||
},
|
||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||
{ "label": "Help", "link": "/docs/cli/commands/#help-or" },
|
||||
{ "label": "Help", "link": "/docs/reference/commands/#help-or" },
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{
|
||||
"label": "Memory management",
|
||||
"link": "/docs/cli/commands/#memory"
|
||||
"link": "/docs/reference/commands/#memory"
|
||||
},
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
@@ -85,15 +85,15 @@
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{
|
||||
"label": "Shell",
|
||||
"link": "/docs/cli/commands/#shells-or-bashes"
|
||||
"link": "/docs/reference/commands/#shells-or-bashes"
|
||||
},
|
||||
{
|
||||
"label": "Stats",
|
||||
"link": "/docs/cli/commands/#stats"
|
||||
"link": "/docs/reference/commands/#stats"
|
||||
},
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
|
||||
{ "label": "Token caching", "slug": "docs/cli/token-caching" },
|
||||
{ "label": "Tools", "link": "/docs/cli/commands/#tools" }
|
||||
{ "label": "Tools", "link": "/docs/reference/commands/#tools" }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -148,25 +148,31 @@
|
||||
{
|
||||
"label": "Reference",
|
||||
"items": [
|
||||
{ "label": "Command reference", "slug": "docs/cli/commands" },
|
||||
{ "label": "Command reference", "slug": "docs/reference/commands" },
|
||||
{
|
||||
"label": "Configuration reference",
|
||||
"slug": "docs/get-started/configuration"
|
||||
"slug": "docs/reference/configuration"
|
||||
},
|
||||
{ "label": "Keyboard shortcuts", "slug": "docs/cli/keyboard-shortcuts" },
|
||||
{ "label": "Memory import processor", "slug": "docs/core/memport" },
|
||||
{ "label": "Policy engine", "slug": "docs/core/policy-engine" },
|
||||
{ "label": "Tools API", "slug": "docs/core/tools-api" }
|
||||
{
|
||||
"label": "Keyboard shortcuts",
|
||||
"slug": "docs/reference/keyboard-shortcuts"
|
||||
},
|
||||
{ "label": "Memory import processor", "slug": "docs/reference/memport" },
|
||||
{ "label": "Policy engine", "slug": "docs/reference/policy-engine" },
|
||||
{ "label": "Tools API", "slug": "docs/reference/tools-api" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Resources",
|
||||
"items": [
|
||||
{ "label": "FAQ", "slug": "docs/faq" },
|
||||
{ "label": "Quota and pricing", "slug": "docs/quota-and-pricing" },
|
||||
{ "label": "Terms and privacy", "slug": "docs/tos-privacy" },
|
||||
{ "label": "Troubleshooting", "slug": "docs/troubleshooting" },
|
||||
{ "label": "Uninstall", "slug": "docs/cli/uninstall" }
|
||||
{ "label": "FAQ", "slug": "docs/resources/faq" },
|
||||
{
|
||||
"label": "Quota and pricing",
|
||||
"slug": "docs/resources/quota-and-pricing"
|
||||
},
|
||||
{ "label": "Terms and privacy", "slug": "docs/resources/tos-privacy" },
|
||||
{ "label": "Troubleshooting", "slug": "docs/resources/troubleshooting" },
|
||||
{ "label": "Uninstall", "slug": "docs/resources/uninstall" }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -98,5 +98,5 @@ Always review confirmation prompts carefully before allowing a tool to execute.
|
||||
## Next steps
|
||||
|
||||
- Learn how to [Provide context](../cli/gemini-md.md) to guide tool use.
|
||||
- Explore the [Command reference](../cli/commands.md) for tool-related slash
|
||||
commands.
|
||||
- Explore the [Command reference](../reference/commands.md) for tool-related
|
||||
slash commands.
|
||||
|
||||
@@ -14,8 +14,8 @@ provides direct access to the Markdown files in the `docs/` directory.
|
||||
`get_internal_docs` takes one optional argument:
|
||||
|
||||
- `path` (string, optional): The relative path to a specific documentation file
|
||||
(for example, `cli/commands.md`). If omitted, the tool returns a list of all
|
||||
available documentation paths.
|
||||
(for example, `reference/commands.md`). If omitted, the tool returns a list of
|
||||
all available documentation paths.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -40,7 +40,7 @@ Gemini CLI uses this tool to ensure technical accuracy:
|
||||
|
||||
## Next steps
|
||||
|
||||
- Explore the [Command reference](../cli/commands.md) for a detailed guide to
|
||||
slash commands.
|
||||
- See the [Configuration guide](../get-started/configuration.md) for settings
|
||||
- Explore the [Command reference](../reference/commands.md) for a detailed guide
|
||||
to slash commands.
|
||||
- See the [Configuration guide](../reference/configuration.md) for settings
|
||||
reference.
|
||||
|
||||
@@ -11,6 +11,8 @@ by the agent when you ask it to "start a plan" using natural language. In this
|
||||
mode, the agent is restricted to read-only tools to allow for safe exploration
|
||||
and planning.
|
||||
|
||||
> **Note:** This tool is not available when the CLI is in YOLO mode.
|
||||
|
||||
- **Tool name:** `enter_plan_mode`
|
||||
- **Display name:** Enter Plan Mode
|
||||
- **File:** `enter-plan-mode.ts`
|
||||
|
||||
+3
-3
@@ -131,9 +131,9 @@ configuration file.
|
||||
commands. Including the generic `run_shell_command` acts as a wildcard,
|
||||
allowing any command not explicitly blocked.
|
||||
- `tools.exclude` [DEPRECATED]: To block specific commands, use the
|
||||
[Policy Engine](../core/policy-engine.md). Historically, this setting allowed
|
||||
adding entries to the `exclude` list under the `tools` category in the format
|
||||
`run_shell_command(<command>)`. For example,
|
||||
[Policy Engine](../reference/policy-engine.md). Historically, this setting
|
||||
allowed adding entries to the `exclude` list under the `tools` category in the
|
||||
format `run_shell_command(<command>)`. For example,
|
||||
`"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` commands.
|
||||
|
||||
The validation logic is designed to be secure and flexible:
|
||||
|
||||
@@ -38,6 +38,7 @@ export default tseslint.config(
|
||||
'dist/**',
|
||||
'evals/**',
|
||||
'packages/test-utils/**',
|
||||
'.gemini/skills/**',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file2.txt"}}},{"functionCall":{"name":"write_file","args":{"file_path":"output.txt","content":"wave2"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file3.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file4.txt"}}}, {"text":"All waves completed successfully."}]},"finishReason":"STOP","index":0}]}]}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
describe('Parallel Tool Execution Integration', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it('should execute [read, read, write, read, read] in correct waves with user approval', async () => {
|
||||
rig.setup('parallel-wave-execution', {
|
||||
fakeResponsesPath: join(import.meta.dirname, 'parallel-tools.responses'),
|
||||
settings: {
|
||||
tools: {
|
||||
core: ['read_file', 'write_file'],
|
||||
approval: 'ASK', // Disable YOLO mode to show permission prompts
|
||||
confirmationRequired: ['write_file'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
rig.createFile('file1.txt', 'c1');
|
||||
rig.createFile('file2.txt', 'c2');
|
||||
rig.createFile('file3.txt', 'c3');
|
||||
rig.createFile('file4.txt', 'c4');
|
||||
rig.sync();
|
||||
|
||||
const run = await rig.runInteractive({ approvalMode: 'default' });
|
||||
|
||||
// 1. Trigger the wave
|
||||
await run.type('ok');
|
||||
await run.type('\r');
|
||||
|
||||
// 3. Wait for the write_file prompt.
|
||||
await run.expectText('Allow', 5000);
|
||||
|
||||
// 4. Press Enter to approve the write_file.
|
||||
await run.type('y');
|
||||
await run.type('\r');
|
||||
|
||||
// 5. Wait for the final model response
|
||||
await run.expectText('All waves completed successfully.', 5000);
|
||||
|
||||
// Verify all tool calls were made and succeeded in the logs
|
||||
await rig.expectToolCallSuccess(['write_file']);
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const readFiles = toolLogs.filter(
|
||||
(l) => l.toolRequest.name === 'read_file',
|
||||
);
|
||||
const writeFiles = toolLogs.filter(
|
||||
(l) => l.toolRequest.name === 'write_file',
|
||||
);
|
||||
|
||||
expect(readFiles.length).toBe(4);
|
||||
expect(writeFiles.length).toBe(1);
|
||||
expect(toolLogs.every((l) => l.toolRequest.success)).toBe(true);
|
||||
|
||||
// Check that output.txt was actually written
|
||||
expect(fs.readFileSync(join(rig.testDir!, 'output.txt'), 'utf8')).toBe(
|
||||
'wave2',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import { type Settings, createTestMergedSettings } from './settings.js';
|
||||
import {
|
||||
type Settings,
|
||||
type MergedSettings,
|
||||
createTestMergedSettings,
|
||||
} from './settings.js';
|
||||
import * as ServerConfig from '@google/gemini-cli-core';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
@@ -2599,6 +2603,21 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should pass planSettings.directory from settings to config', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
general: {
|
||||
plan: {
|
||||
directory: '.custom-plans',
|
||||
},
|
||||
},
|
||||
} as unknown as MergedSettings);
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(plansDir).toContain('.custom-plans');
|
||||
});
|
||||
|
||||
// --- Untrusted Folder Scenarios ---
|
||||
describe('when folder is NOT trusted', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -56,7 +56,10 @@ import { resolvePath } from '../utils/resolvePath.js';
|
||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { createPolicyEngineConfig } from './policy.js';
|
||||
import {
|
||||
createPolicyEngineConfig,
|
||||
resolveWorkspacePolicyState,
|
||||
} from './policy.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js';
|
||||
import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
|
||||
@@ -692,9 +695,17 @@ export async function loadCliConfig(
|
||||
policyPaths: argv.policy,
|
||||
};
|
||||
|
||||
const { workspacePoliciesDir, policyUpdateConfirmationRequest } =
|
||||
await resolveWorkspacePolicyState({
|
||||
cwd,
|
||||
trustedFolder,
|
||||
interactive,
|
||||
});
|
||||
|
||||
const policyEngineConfig = await createPolicyEngineConfig(
|
||||
effectiveSettings,
|
||||
approvalMode,
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
policyEngineConfig.nonInteractive = !interactive;
|
||||
|
||||
@@ -758,6 +769,7 @@ export async function loadCliConfig(
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
policyEngineConfig,
|
||||
policyUpdateConfirmationRequest,
|
||||
excludeTools,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
@@ -814,6 +826,7 @@ export async function loadCliConfig(
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
planSettings: settings.general.plan,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -148,13 +148,13 @@ describe('Policy Engine Integration Tests', () => {
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// MCP server allowed (priority 2.1) provides general allow for server
|
||||
// MCP server allowed (priority 2.1) provides general allow for server
|
||||
// MCP server allowed (priority 3.1) provides general allow for server
|
||||
// MCP server allowed (priority 3.1) provides general allow for server
|
||||
expect(
|
||||
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
|
||||
.decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
// But specific tool exclude (priority 2.4) wins over server allow
|
||||
// But specific tool exclude (priority 3.4) wins over server allow
|
||||
expect(
|
||||
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
|
||||
.decision,
|
||||
@@ -412,25 +412,25 @@ describe('Policy Engine Integration Tests', () => {
|
||||
|
||||
// Find rules and verify their priorities
|
||||
const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool');
|
||||
expect(blockedToolRule?.priority).toBe(2.4); // Command line exclude
|
||||
expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude
|
||||
|
||||
const blockedServerRule = rules.find(
|
||||
(r) => r.toolName === 'blocked-server__*',
|
||||
);
|
||||
expect(blockedServerRule?.priority).toBe(2.9); // MCP server exclude
|
||||
expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude
|
||||
|
||||
const specificToolRule = rules.find(
|
||||
(r) => r.toolName === 'specific-tool',
|
||||
);
|
||||
expect(specificToolRule?.priority).toBe(2.3); // Command line allow
|
||||
expect(specificToolRule?.priority).toBe(3.3); // Command line allow
|
||||
|
||||
const trustedServerRule = rules.find(
|
||||
(r) => r.toolName === 'trusted-server__*',
|
||||
);
|
||||
expect(trustedServerRule?.priority).toBe(2.2); // MCP trusted server
|
||||
expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server
|
||||
|
||||
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*');
|
||||
expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server
|
||||
expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server
|
||||
|
||||
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
|
||||
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
|
||||
@@ -577,16 +577,16 @@ describe('Policy Engine Integration Tests', () => {
|
||||
|
||||
// Verify each rule has the expected priority
|
||||
const tool3Rule = rules.find((r) => r.toolName === 'tool3');
|
||||
expect(tool3Rule?.priority).toBe(2.4); // Excluded tools (user tier)
|
||||
expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier)
|
||||
|
||||
const server2Rule = rules.find((r) => r.toolName === 'server2__*');
|
||||
expect(server2Rule?.priority).toBe(2.9); // Excluded servers (user tier)
|
||||
expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier)
|
||||
|
||||
const tool1Rule = rules.find((r) => r.toolName === 'tool1');
|
||||
expect(tool1Rule?.priority).toBe(2.3); // Allowed tools (user tier)
|
||||
expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier)
|
||||
|
||||
const server1Rule = rules.find((r) => r.toolName === 'server1__*');
|
||||
expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier)
|
||||
expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier)
|
||||
|
||||
const globRule = rules.find((r) => r.toolName === 'glob');
|
||||
// Priority 70 in default tier → 1.07
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { resolveWorkspacePolicyState } from './policy.js';
|
||||
import { writeToStderr } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock debugLogger to avoid noise in test output
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
writeToStderr: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('resolveWorkspacePolicyState', () => {
|
||||
let tempDir: string;
|
||||
let workspaceDir: string;
|
||||
let policiesDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temporary directory for the test
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-'));
|
||||
// Redirect GEMINI_CLI_HOME to the temp directory to isolate integrity storage
|
||||
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
|
||||
|
||||
workspaceDir = path.join(tempDir, 'workspace');
|
||||
fs.mkdirSync(workspaceDir);
|
||||
policiesDir = path.join(workspaceDir, '.gemini', 'policies');
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temporary directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should return empty state if folder is not trusted', async () => {
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: false,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
workspacePoliciesDir: undefined,
|
||||
policyUpdateConfirmationRequest: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return policy directory if integrity matches', async () => {
|
||||
// Set up policies directory with a file
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
// First call to establish integrity (interactive accept)
|
||||
const firstResult = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
expect(firstResult.policyUpdateConfirmationRequest).toBeDefined();
|
||||
|
||||
// Establish integrity manually as if accepted
|
||||
const { PolicyIntegrityManager } = await import('@google/gemini-cli-core');
|
||||
const integrityManager = new PolicyIntegrityManager();
|
||||
await integrityManager.acceptIntegrity(
|
||||
'workspace',
|
||||
workspaceDir,
|
||||
firstResult.policyUpdateConfirmationRequest!.newHash,
|
||||
);
|
||||
|
||||
// Second call should match
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBe(policiesDir);
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if integrity is NEW but fileCount is 0', async () => {
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return confirmation request if changed in interactive mode', async () => {
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
expect(result.policyUpdateConfirmationRequest).toEqual({
|
||||
scope: 'workspace',
|
||||
identifier: workspaceDir,
|
||||
policyDir: policiesDir,
|
||||
newHash: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should warn and auto-accept if changed in non-interactive mode', async () => {
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBe(policiesDir);
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
expect(writeToStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Automatically accepting and loading'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,18 @@ import {
|
||||
type PolicySettings,
|
||||
createPolicyEngineConfig as createCorePolicyEngineConfig,
|
||||
createPolicyUpdater as createCorePolicyUpdater,
|
||||
PolicyIntegrityManager,
|
||||
IntegrityStatus,
|
||||
Storage,
|
||||
type PolicyUpdateConfirmationRequest,
|
||||
writeToStderr,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type Settings } from './settings.js';
|
||||
|
||||
export async function createPolicyEngineConfig(
|
||||
settings: Settings,
|
||||
approvalMode: ApprovalMode,
|
||||
workspacePoliciesDir?: string,
|
||||
): Promise<PolicyEngineConfig> {
|
||||
// Explicitly construct PolicySettings from Settings to ensure type safety
|
||||
// and avoid accidental leakage of other settings properties.
|
||||
@@ -26,6 +32,7 @@ export async function createPolicyEngineConfig(
|
||||
tools: settings.tools,
|
||||
mcpServers: settings.mcpServers,
|
||||
policyPaths: settings.policyPaths,
|
||||
workspacePoliciesDir,
|
||||
};
|
||||
|
||||
return createCorePolicyEngineConfig(policySettings, approvalMode);
|
||||
@@ -37,3 +44,68 @@ export function createPolicyUpdater(
|
||||
) {
|
||||
return createCorePolicyUpdater(policyEngine, messageBus);
|
||||
}
|
||||
|
||||
export interface WorkspacePolicyState {
|
||||
workspacePoliciesDir?: string;
|
||||
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the workspace policy state by checking folder trust and policy integrity.
|
||||
*/
|
||||
export async function resolveWorkspacePolicyState(options: {
|
||||
cwd: string;
|
||||
trustedFolder: boolean;
|
||||
interactive: boolean;
|
||||
}): Promise<WorkspacePolicyState> {
|
||||
const { cwd, trustedFolder, interactive } = options;
|
||||
|
||||
let workspacePoliciesDir: string | undefined;
|
||||
let policyUpdateConfirmationRequest:
|
||||
| PolicyUpdateConfirmationRequest
|
||||
| undefined;
|
||||
|
||||
if (trustedFolder) {
|
||||
const potentialWorkspacePoliciesDir = new Storage(
|
||||
cwd,
|
||||
).getWorkspacePoliciesDir();
|
||||
const integrityManager = new PolicyIntegrityManager();
|
||||
const integrityResult = await integrityManager.checkIntegrity(
|
||||
'workspace',
|
||||
cwd,
|
||||
potentialWorkspacePoliciesDir,
|
||||
);
|
||||
|
||||
if (integrityResult.status === IntegrityStatus.MATCH) {
|
||||
workspacePoliciesDir = potentialWorkspacePoliciesDir;
|
||||
} else if (
|
||||
integrityResult.status === IntegrityStatus.NEW &&
|
||||
integrityResult.fileCount === 0
|
||||
) {
|
||||
// No workspace policies found
|
||||
workspacePoliciesDir = undefined;
|
||||
} else if (interactive) {
|
||||
// Policies changed or are new, and we are in interactive mode
|
||||
policyUpdateConfirmationRequest = {
|
||||
scope: 'workspace',
|
||||
identifier: cwd,
|
||||
policyDir: potentialWorkspacePoliciesDir,
|
||||
newHash: integrityResult.hash,
|
||||
};
|
||||
} else {
|
||||
// Non-interactive mode: warn and automatically accept/load
|
||||
await integrityManager.acceptIntegrity(
|
||||
'workspace',
|
||||
cwd,
|
||||
integrityResult.hash,
|
||||
);
|
||||
workspacePoliciesDir = potentialWorkspacePoliciesDir;
|
||||
// debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console
|
||||
writeToStderr(
|
||||
'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { workspacePoliciesDir, policyUpdateConfirmationRequest };
|
||||
}
|
||||
|
||||
@@ -2032,6 +2032,85 @@ describe('Settings Loading and Merging', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Check that enableLoadingPhrases: false was further migrated to loadingPhrases: 'off'
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui',
|
||||
expect.objectContaining({
|
||||
loadingPhrases: 'off',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate enableLoadingPhrases: false to loadingPhrases: off', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
accessibility: {
|
||||
enableLoadingPhrases: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const loadedSettings = createMockSettings(userSettingsContent);
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings);
|
||||
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui',
|
||||
expect.objectContaining({
|
||||
loadingPhrases: 'off',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not migrate enableLoadingPhrases: true to loadingPhrases', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
accessibility: {
|
||||
enableLoadingPhrases: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const loadedSettings = createMockSettings(userSettingsContent);
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings);
|
||||
|
||||
// Should not set loadingPhrases when enableLoadingPhrases is true
|
||||
const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui');
|
||||
for (const call of uiCalls) {
|
||||
const uiValue = call[2] as Record<string, unknown>;
|
||||
expect(uiValue).not.toHaveProperty('loadingPhrases');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not overwrite existing loadingPhrases during migration', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
loadingPhrases: 'witty',
|
||||
accessibility: {
|
||||
enableLoadingPhrases: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const loadedSettings = createMockSettings(userSettingsContent);
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings);
|
||||
|
||||
// Should not overwrite existing loadingPhrases
|
||||
const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui');
|
||||
for (const call of uiCalls) {
|
||||
const uiValue = call[2] as Record<string, unknown>;
|
||||
if (uiValue['loadingPhrases'] !== undefined) {
|
||||
expect(uiValue['loadingPhrases']).toBe('witty');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should prioritize new settings over deprecated ones and respect removeDeprecated flag', () => {
|
||||
|
||||
@@ -165,7 +165,10 @@ export interface SummarizeToolOutputSettings {
|
||||
tokenBudget?: number;
|
||||
}
|
||||
|
||||
export type LoadingPhrasesMode = 'tips' | 'witty' | 'all' | 'off';
|
||||
|
||||
export interface AccessibilitySettings {
|
||||
/** @deprecated Use ui.loadingPhrases instead. */
|
||||
enableLoadingPhrases?: boolean;
|
||||
screenReader?: boolean;
|
||||
}
|
||||
@@ -928,6 +931,22 @@ export function migrateDeprecatedSettings(
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate enableLoadingPhrases: false → loadingPhrases: 'off'
|
||||
const enableLP = newAccessibility['enableLoadingPhrases'];
|
||||
if (
|
||||
typeof enableLP === 'boolean' &&
|
||||
newUi['loadingPhrases'] === undefined
|
||||
) {
|
||||
if (!enableLP) {
|
||||
newUi['loadingPhrases'] = 'off';
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
foundDeprecated.push('ui.accessibility.enableLoadingPhrases');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,19 @@ describe('SettingsSchema', () => {
|
||||
).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have loadingPhrases enum property', () => {
|
||||
const definition = getSettingsSchema().ui?.properties?.loadingPhrases;
|
||||
expect(definition).toBeDefined();
|
||||
expect(definition?.type).toBe('enum');
|
||||
expect(definition?.default).toBe('tips');
|
||||
expect(definition?.options?.map((o) => o.value)).toEqual([
|
||||
'tips',
|
||||
'witty',
|
||||
'all',
|
||||
'off',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have checkpointing nested properties', () => {
|
||||
expect(
|
||||
getSettingsSchema().general?.properties?.checkpointing.properties
|
||||
@@ -94,6 +107,16 @@ describe('SettingsSchema', () => {
|
||||
).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have plan nested properties', () => {
|
||||
expect(
|
||||
getSettingsSchema().general?.properties?.plan?.properties?.directory,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().general?.properties?.plan?.properties?.directory
|
||||
.type,
|
||||
).toBe('string');
|
||||
});
|
||||
|
||||
it('should have fileFiltering nested properties', () => {
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
|
||||
@@ -266,6 +266,27 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plan: {
|
||||
type: 'object',
|
||||
label: 'Plan',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Planning features configuration.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
directory: {
|
||||
type: 'string',
|
||||
label: 'Plan Directory',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
enablePromptCompletion: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Prompt Completion',
|
||||
@@ -682,6 +703,22 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Show the spinner during operations.',
|
||||
showInDialog: true,
|
||||
},
|
||||
loadingPhrases: {
|
||||
type: 'enum',
|
||||
label: 'Loading Phrases',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 'tips',
|
||||
description:
|
||||
'What to show while the model is working: tips, witty comments, both, or nothing.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'tips', label: 'Tips' },
|
||||
{ value: 'witty', label: 'Witty' },
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
],
|
||||
},
|
||||
customWittyPhrases: {
|
||||
type: 'array',
|
||||
label: 'Custom Witty Phrases',
|
||||
@@ -710,8 +747,9 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable loading phrases during operations.',
|
||||
showInDialog: true,
|
||||
description:
|
||||
'@deprecated Use ui.loadingPhrases instead. Enable loading phrases during operations.',
|
||||
showInDialog: false,
|
||||
},
|
||||
screenReader: {
|
||||
type: 'boolean',
|
||||
@@ -1306,6 +1344,7 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
useWriteTodos: {
|
||||
type: 'boolean',
|
||||
label: 'Use WriteTodos',
|
||||
@@ -1642,7 +1681,17 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).',
|
||||
'Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).',
|
||||
showInDialog: true,
|
||||
},
|
||||
useOSC52Copy: {
|
||||
type: 'boolean',
|
||||
label: 'Use OSC 52 Copy',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).',
|
||||
showInDialog: true,
|
||||
},
|
||||
plan: {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { loadCliConfig, type CliArgs } from './config.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import * as ServerConfig from '@google/gemini-cli-core';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCheckIntegrity = vi.fn();
|
||||
const mockAcceptIntegrity = vi.fn();
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual<typeof ServerConfig>(
|
||||
'@google/gemini-cli-core',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
}),
|
||||
createPolicyEngineConfig: vi.fn().mockResolvedValue({
|
||||
rules: [],
|
||||
checkers: [],
|
||||
}),
|
||||
getVersion: vi.fn().mockResolvedValue('test-version'),
|
||||
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
|
||||
checkIntegrity: mockCheckIntegrity,
|
||||
acceptIntegrity: mockAcceptIntegrity,
|
||||
})),
|
||||
IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' },
|
||||
debugLogger: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
isHeadlessMode: vi.fn().mockReturnValue(false), // Default to interactive
|
||||
};
|
||||
});
|
||||
|
||||
describe('Workspace-Level Policy CLI Integration', () => {
|
||||
const MOCK_CWD = process.cwd();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to MATCH for existing tests
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'match',
|
||||
hash: 'test-hash',
|
||||
fileCount: 1,
|
||||
});
|
||||
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should have getWorkspacePoliciesDir on Storage class', () => {
|
||||
const storage = new ServerConfig.Storage(MOCK_CWD);
|
||||
expect(storage.getWorkspacePoliciesDir).toBeDefined();
|
||||
expect(typeof storage.getWorkspacePoliciesDir).toBe('function');
|
||||
});
|
||||
|
||||
it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = { query: 'test' } as unknown as CliArgs;
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
|
||||
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: expect.stringContaining(
|
||||
path.join('.gemini', 'policies'),
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = { query: 'test' } as unknown as CliArgs;
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
|
||||
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'new',
|
||||
hash: 'hash',
|
||||
fileCount: 0,
|
||||
});
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = { query: 'test' } as unknown as CliArgs;
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
|
||||
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'mismatch',
|
||||
hash: 'new-hash',
|
||||
fileCount: 1,
|
||||
});
|
||||
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = { prompt: 'do something' } as unknown as CliArgs;
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
|
||||
|
||||
expect(mockAcceptIntegrity).toHaveBeenCalledWith(
|
||||
'workspace',
|
||||
MOCK_CWD,
|
||||
'new-hash',
|
||||
);
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: expect.stringContaining(
|
||||
path.join('.gemini', 'policies'),
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'mismatch',
|
||||
hash: 'new-hash',
|
||||
fileCount: 1,
|
||||
});
|
||||
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = {
|
||||
query: 'test',
|
||||
promptInteractive: 'test',
|
||||
} as unknown as CliArgs;
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv, {
|
||||
cwd: MOCK_CWD,
|
||||
});
|
||||
|
||||
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
|
||||
scope: 'workspace',
|
||||
identifier: MOCK_CWD,
|
||||
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
|
||||
newHash: 'new-hash',
|
||||
});
|
||||
// In interactive mode without accept flag, it waits for user confirmation (handled by UI),
|
||||
// so it currently DOES NOT pass the directory to createPolicyEngineConfig yet.
|
||||
// The UI will handle the confirmation and reload/update.
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'new',
|
||||
hash: 'new-hash',
|
||||
fileCount: 5,
|
||||
});
|
||||
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = { query: 'test' } as unknown as CliArgs;
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv, {
|
||||
cwd: MOCK_CWD,
|
||||
});
|
||||
|
||||
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
|
||||
scope: 'workspace',
|
||||
identifier: MOCK_CWD,
|
||||
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
|
||||
newHash: 'new-hash',
|
||||
});
|
||||
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,6 @@ class XtermStdout extends EventEmitter {
|
||||
isTTY = true;
|
||||
|
||||
private lastRenderOutput: string | undefined = undefined;
|
||||
private lastRenderStaticContent: string | undefined = undefined;
|
||||
|
||||
constructor(state: TerminalState, queue: { promise: Promise<void> }) {
|
||||
super();
|
||||
@@ -109,7 +108,6 @@ class XtermStdout extends EventEmitter {
|
||||
clear = () => {
|
||||
this.state.terminal.reset();
|
||||
this.lastRenderOutput = undefined;
|
||||
this.lastRenderStaticContent = undefined;
|
||||
};
|
||||
|
||||
dispose = () => {
|
||||
@@ -118,33 +116,23 @@ class XtermStdout extends EventEmitter {
|
||||
|
||||
onRender = (staticContent: string, output: string) => {
|
||||
this.renderCount++;
|
||||
this.lastRenderStaticContent = staticContent;
|
||||
this.lastRenderOutput = output;
|
||||
this.emit('render');
|
||||
};
|
||||
|
||||
lastFrame = (options: { allowEmpty?: boolean } = {}) => {
|
||||
let result: string;
|
||||
// On Windows, xterm.js headless can sometimes have timing or rendering issues
|
||||
// that lead to duplicated content or incorrect buffer state in tests.
|
||||
// As a fallback, we can trust the raw output Ink provided during onRender.
|
||||
if (os.platform() === 'win32') {
|
||||
result =
|
||||
(this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? '');
|
||||
} else {
|
||||
const buffer = this.state.terminal.buffer.active;
|
||||
const allLines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
allLines.push(buffer.getLine(i)?.translateToString(true) ?? '');
|
||||
}
|
||||
|
||||
const trimmed = [...allLines];
|
||||
while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') {
|
||||
trimmed.pop();
|
||||
}
|
||||
result = trimmed.join('\n');
|
||||
const buffer = this.state.terminal.buffer.active;
|
||||
const allLines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
allLines.push(buffer.getLine(i)?.translateToString(true) ?? '');
|
||||
}
|
||||
|
||||
const trimmed = [...allLines];
|
||||
while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') {
|
||||
trimmed.pop();
|
||||
}
|
||||
const result = trimmed.join('\n');
|
||||
|
||||
// Normalize for cross-platform snapshot stability:
|
||||
// Normalize any \r\n to \n
|
||||
const normalized = result.replace(/\r\n/g, '\n');
|
||||
@@ -195,9 +183,7 @@ class XtermStdout extends EventEmitter {
|
||||
const currentFrame = stripAnsi(
|
||||
this.lastFrame({ allowEmpty: true }),
|
||||
).trim();
|
||||
const expectedFrame = stripAnsi(
|
||||
(this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''),
|
||||
)
|
||||
const expectedFrame = stripAnsi(this.lastRenderOutput ?? '')
|
||||
.trim()
|
||||
.replace(/\r\n/g, '\n');
|
||||
|
||||
@@ -336,7 +322,11 @@ export const render = (
|
||||
terminalWidth?: number,
|
||||
): RenderInstance => {
|
||||
const cols = terminalWidth ?? 100;
|
||||
const rows = 40;
|
||||
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
|
||||
// value was used (e.g. 40 rows). The alternatives to make things worse are
|
||||
// windows unfortunately with odd duplicate content in the backbuffer
|
||||
// which does not match actual behavior in xterm.js on windows.
|
||||
const rows = 1000;
|
||||
const terminal = new Terminal({
|
||||
cols,
|
||||
rows,
|
||||
@@ -516,6 +506,7 @@ const mockUIActions: UIActions = {
|
||||
vimHandleInput: vi.fn(),
|
||||
handleIdePromptComplete: vi.fn(),
|
||||
handleFolderTrustSelect: vi.fn(),
|
||||
setIsPolicyUpdateDialogOpen: vi.fn(),
|
||||
setConstrainHeight: vi.fn(),
|
||||
onEscapePromptChange: vi.fn(),
|
||||
refreshStatic: vi.fn(),
|
||||
|
||||
@@ -1438,6 +1438,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
||||
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
||||
|
||||
const policyUpdateConfirmationRequest =
|
||||
config.getPolicyUpdateConfirmationRequest();
|
||||
const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState(
|
||||
!!policyUpdateConfirmationRequest,
|
||||
);
|
||||
|
||||
const {
|
||||
needsRestart: ideNeedsRestart,
|
||||
restartReason: ideTrustRestartReason,
|
||||
@@ -1596,6 +1603,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
loadingPhrasesMode: settings.merged.ui.loadingPhrases,
|
||||
customWittyPhrases: settings.merged.ui.customWittyPhrases,
|
||||
});
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
@@ -1908,6 +1917,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
(shouldShowRetentionWarning && retentionCheckComplete) ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
isPolicyUpdateDialogOpen ||
|
||||
adminSettingsChanged ||
|
||||
!!commandConfirmationRequest ||
|
||||
!!authConsentRequest ||
|
||||
@@ -2135,6 +2145,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||
isPolicyUpdateDialogOpen,
|
||||
policyUpdateConfirmationRequest,
|
||||
isTrustedFolder,
|
||||
constrainHeight,
|
||||
showErrorDetails,
|
||||
@@ -2257,6 +2269,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen,
|
||||
isPolicyUpdateDialogOpen,
|
||||
policyUpdateConfirmationRequest,
|
||||
isTrustedFolder,
|
||||
constrainHeight,
|
||||
showErrorDetails,
|
||||
@@ -2354,6 +2368,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
vimHandleInput,
|
||||
handleIdePromptComplete,
|
||||
handleFolderTrustSelect,
|
||||
setIsPolicyUpdateDialogOpen,
|
||||
setConstrainHeight,
|
||||
onEscapePromptChange: handleEscapePromptChange,
|
||||
refreshStatic,
|
||||
@@ -2438,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
vimHandleInput,
|
||||
handleIdePromptComplete,
|
||||
handleFolderTrustSelect,
|
||||
setIsPolicyUpdateDialogOpen,
|
||||
setConstrainHeight,
|
||||
handleEscapePromptChange,
|
||||
refreshStatic,
|
||||
|
||||
@@ -125,6 +125,7 @@ describe('copyCommand', () => {
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
'Hi there! How can I help you?',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -143,7 +144,10 @@ describe('copyCommand', () => {
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('Part 1: Part 2: Part 3');
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
'Part 1: Part 2: Part 3',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
@@ -170,7 +174,10 @@ describe('copyCommand', () => {
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('Text part more text');
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
'Text part more text',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
@@ -201,7 +208,10 @@ describe('copyCommand', () => {
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('Second AI response');
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
'Second AI response',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
@@ -230,6 +240,11 @@ describe('copyCommand', () => {
|
||||
messageType: 'error',
|
||||
content: `Failed to copy to the clipboard. ${clipboardError.message}`,
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
'AI response',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-Error clipboard errors', async () => {
|
||||
@@ -253,6 +268,11 @@ describe('copyCommand', () => {
|
||||
messageType: 'error',
|
||||
content: `Failed to copy to the clipboard. ${rejectedValue}`,
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
'AI response',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return info message when no text parts found in AI message', async () => {
|
||||
|
||||
@@ -38,7 +38,8 @@ export const copyCommand: SlashCommand = {
|
||||
|
||||
if (lastAiOutput) {
|
||||
try {
|
||||
await copyToClipboard(lastAiOutput);
|
||||
const settings = context.services.settings.merged;
|
||||
await copyToClipboard(lastAiOutput, settings);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('planCommand', () => {
|
||||
getApprovalMode: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -43,7 +43,7 @@ export const planCommand: SlashCommand = {
|
||||
try {
|
||||
const content = await processSingleFileContent(
|
||||
approvedPlanPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.storage.getPlansDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
const fileName = path.basename(approvedPlanPath);
|
||||
|
||||
@@ -391,16 +391,16 @@ describe('Composer', () => {
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator without thought when accessibility disables loading phrases', async () => {
|
||||
it('renders LoadingIndicator without thought when loadingPhrases is off', async () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
thought: { subject: 'Hidden', description: 'Should not show' },
|
||||
});
|
||||
const config = createMockConfig({
|
||||
getAccessibility: vi.fn(() => ({ enableLoadingPhrases: false })),
|
||||
const settings = createMockSettings({
|
||||
merged: { ui: { loadingPhrases: 'off' } },
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState, undefined, config);
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
|
||||
@@ -191,7 +191,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
{showUiDetails && <TodoTray />}
|
||||
|
||||
<Box marginTop={1} width="100%" flexDirection="column">
|
||||
<Box width="100%" flexDirection="column">
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
@@ -211,12 +211,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation ||
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
@@ -255,12 +255,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation ||
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
|
||||
import { useCallback } from 'react';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -166,6 +167,15 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isPolicyUpdateDialogOpen) {
|
||||
return (
|
||||
<PolicyUpdateDialog
|
||||
config={config}
|
||||
request={uiState.policyUpdateConfirmationRequest!}
|
||||
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.loopDetectionConfirmationRequest) {
|
||||
return (
|
||||
<LoopDetectionConfirmation
|
||||
|
||||
@@ -154,7 +154,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => mockPlansDir,
|
||||
getPlansDir: () => mockPlansDir,
|
||||
},
|
||||
getFileSystemService: (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
@@ -429,7 +429,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => mockPlansDir,
|
||||
getPlansDir: () => mockPlansDir,
|
||||
},
|
||||
getFileSystemService: (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
|
||||
@@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
try {
|
||||
const pathError = await validatePlanPath(
|
||||
planPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.storage.getPlansDir(),
|
||||
config.getTargetDir(),
|
||||
);
|
||||
if (ignore) return;
|
||||
@@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
planPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.storage.getPlansDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
|
||||
import {
|
||||
type Config,
|
||||
type PolicyUpdateConfirmationRequest,
|
||||
PolicyIntegrityManager,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
const { mockAcceptIntegrity } = vi.hoisted(() => ({
|
||||
mockAcceptIntegrity: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock PolicyIntegrityManager
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
|
||||
acceptIntegrity: mockAcceptIntegrity,
|
||||
checkIntegrity: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('PolicyUpdateDialog', () => {
|
||||
let mockConfig: Config;
|
||||
let mockRequest: PolicyUpdateConfirmationRequest;
|
||||
let onClose: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
loadWorkspacePolicies: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
mockRequest = {
|
||||
scope: 'workspace',
|
||||
identifier: '/test/workspace/.gemini/policies',
|
||||
policyDir: '/test/workspace/.gemini/policies',
|
||||
newHash: 'test-hash',
|
||||
} as PolicyUpdateConfirmationRequest;
|
||||
|
||||
onClose = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly and matches snapshot', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderWithProviders(
|
||||
<PolicyUpdateDialog
|
||||
config={mockConfig}
|
||||
request={mockRequest}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
expect(output).toContain('New or changed workspace policies detected');
|
||||
expect(output).toContain('Location: /test/workspace/.gemini/policies');
|
||||
expect(output).toContain('Accept and Load');
|
||||
expect(output).toContain('Ignore');
|
||||
});
|
||||
|
||||
it('handles ACCEPT correctly', async () => {
|
||||
const { stdin } = renderWithProviders(
|
||||
<PolicyUpdateDialog
|
||||
config={mockConfig}
|
||||
request={mockRequest}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Accept is the first option, so pressing enter should select it
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PolicyIntegrityManager).toHaveBeenCalled();
|
||||
expect(mockConfig.loadWorkspacePolicies).toHaveBeenCalledWith(
|
||||
mockRequest.policyDir,
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles IGNORE correctly', async () => {
|
||||
const { stdin } = renderWithProviders(
|
||||
<PolicyUpdateDialog
|
||||
config={mockConfig}
|
||||
request={mockRequest}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Move down to Ignore option
|
||||
await act(async () => {
|
||||
stdin.write('\x1B[B'); // Down arrow
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PolicyIntegrityManager).not.toHaveBeenCalled();
|
||||
expect(mockConfig.loadWorkspacePolicies).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose when Escape key is pressed', async () => {
|
||||
const { stdin } = renderWithProviders(
|
||||
<PolicyUpdateDialog
|
||||
config={mockConfig}
|
||||
request={mockRequest}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B'); // Escape key (matches Command.ESCAPE default)
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
type Config,
|
||||
type PolicyUpdateConfirmationRequest,
|
||||
PolicyIntegrityManager,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
|
||||
export enum PolicyUpdateChoice {
|
||||
ACCEPT = 'accept',
|
||||
IGNORE = 'ignore',
|
||||
}
|
||||
|
||||
interface PolicyUpdateDialogProps {
|
||||
config: Config;
|
||||
request: PolicyUpdateConfirmationRequest;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PolicyUpdateDialog: React.FC<PolicyUpdateDialogProps> = ({
|
||||
config,
|
||||
request,
|
||||
onClose,
|
||||
}) => {
|
||||
const isProcessing = useRef(false);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (choice: PolicyUpdateChoice) => {
|
||||
if (isProcessing.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.current = true;
|
||||
try {
|
||||
if (choice === PolicyUpdateChoice.ACCEPT) {
|
||||
const integrityManager = new PolicyIntegrityManager();
|
||||
await integrityManager.acceptIntegrity(
|
||||
request.scope,
|
||||
request.identifier,
|
||||
request.newHash,
|
||||
);
|
||||
await config.loadWorkspacePolicies(request.policyDir);
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
isProcessing.current = false;
|
||||
}
|
||||
},
|
||||
[config, request, onClose],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const options: Array<RadioSelectItem<PolicyUpdateChoice>> = [
|
||||
{
|
||||
label: 'Accept and Load',
|
||||
value: PolicyUpdateChoice.ACCEPT,
|
||||
key: 'accept',
|
||||
},
|
||||
{
|
||||
label: 'Ignore (Use Default Policies)',
|
||||
value: PolicyUpdateChoice.IGNORE,
|
||||
key: 'ignore',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
padding={1}
|
||||
marginLeft={1}
|
||||
marginRight={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
New or changed {request.scope} policies detected
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>Location: {request.identifier}</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
Do you want to accept and load these policies?
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type {
|
||||
@@ -31,17 +32,27 @@ import {
|
||||
getEffectiveValue,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import {
|
||||
type SettingsValue,
|
||||
TOGGLE_TYPES,
|
||||
} from '../../config/settingsSchema.js';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useTextBuffer } from './shared/text-buffer.js';
|
||||
import {
|
||||
type SettingsDialogItem,
|
||||
BaseSettingsDialog,
|
||||
type SettingsDialogItem,
|
||||
} from './shared/BaseSettingsDialog.js';
|
||||
import { useFuzzyList } from '../hooks/useFuzzyList.js';
|
||||
|
||||
interface FzfResult {
|
||||
item: string;
|
||||
start: number;
|
||||
end: number;
|
||||
score: number;
|
||||
positions?: number[];
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
settings: LoadedSettings;
|
||||
@@ -70,6 +81,60 @@ export function SettingsDialog({
|
||||
|
||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filteredKeys, setFilteredKeys] = useState<string[]>(() =>
|
||||
getDialogSettingKeys(),
|
||||
);
|
||||
const { fzfInstance, searchMap } = useMemo(() => {
|
||||
const keys = getDialogSettingKeys();
|
||||
const map = new Map<string, string>();
|
||||
const searchItems: string[] = [];
|
||||
|
||||
keys.forEach((key) => {
|
||||
const def = getSettingDefinition(key);
|
||||
if (def?.label) {
|
||||
searchItems.push(def.label);
|
||||
map.set(def.label.toLowerCase(), key);
|
||||
}
|
||||
});
|
||||
|
||||
const fzf = new AsyncFzf(searchItems, {
|
||||
fuzzy: 'v2',
|
||||
casing: 'case-insensitive',
|
||||
});
|
||||
return { fzfInstance: fzf, searchMap: map };
|
||||
}, []);
|
||||
|
||||
// Perform search
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!searchQuery.trim() || !fzfInstance) {
|
||||
setFilteredKeys(getDialogSettingKeys());
|
||||
return;
|
||||
}
|
||||
|
||||
const doSearch = async () => {
|
||||
const results = await fzfInstance.find(searchQuery);
|
||||
|
||||
if (!active) return;
|
||||
|
||||
const matchedKeys = new Set<string>();
|
||||
results.forEach((res: FzfResult) => {
|
||||
const key = searchMap.get(res.item.toLowerCase());
|
||||
if (key) matchedKeys.add(key);
|
||||
});
|
||||
setFilteredKeys(Array.from(matchedKeys));
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
doSearch();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [searchQuery, fzfInstance, searchMap]);
|
||||
|
||||
// Local pending settings state for the selected scope
|
||||
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
||||
// Deep clone to avoid mutation
|
||||
@@ -117,8 +182,49 @@ export function SettingsDialog({
|
||||
setShowRestartPrompt(newRestartRequired.size > 0);
|
||||
}, [selectedScope, settings, globalPendingChanges]);
|
||||
|
||||
// Generate items for SearchableList
|
||||
const settingKeys = useMemo(() => getDialogSettingKeys(), []);
|
||||
// Calculate max width for the left column (Label/Description) to keep values aligned or close
|
||||
const maxLabelOrDescriptionWidth = useMemo(() => {
|
||||
const allKeys = getDialogSettingKeys();
|
||||
let max = 0;
|
||||
for (const key of allKeys) {
|
||||
const def = getSettingDefinition(key);
|
||||
if (!def) continue;
|
||||
|
||||
const scopeMessage = getScopeMessageForSetting(
|
||||
key,
|
||||
selectedScope,
|
||||
settings,
|
||||
);
|
||||
const label = def.label || key;
|
||||
const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : '');
|
||||
const lWidth = getCachedStringWidth(labelFull);
|
||||
const dWidth = def.description
|
||||
? getCachedStringWidth(def.description)
|
||||
: 0;
|
||||
|
||||
max = Math.max(max, lWidth, dWidth);
|
||||
}
|
||||
return max;
|
||||
}, [selectedScope, settings]);
|
||||
|
||||
// Get mainAreaWidth for search buffer viewport
|
||||
const { mainAreaWidth } = useUIState();
|
||||
const viewportWidth = mainAreaWidth - 8;
|
||||
|
||||
// Search input buffer
|
||||
const searchBuffer = useTextBuffer({
|
||||
initialText: '',
|
||||
initialCursorOffset: 0,
|
||||
viewport: {
|
||||
width: viewportWidth,
|
||||
height: 1,
|
||||
},
|
||||
singleLine: true,
|
||||
onChange: (text) => setSearchQuery(text),
|
||||
});
|
||||
|
||||
// Generate items for BaseSettingsDialog
|
||||
const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
|
||||
const items: SettingsDialogItem[] = useMemo(() => {
|
||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||
const mergedSettings = settings.merged;
|
||||
@@ -164,10 +270,6 @@ export function SettingsDialog({
|
||||
});
|
||||
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
|
||||
|
||||
const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
|
||||
items,
|
||||
});
|
||||
|
||||
// Scope selection handler
|
||||
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
@@ -594,12 +696,12 @@ export function SettingsDialog({
|
||||
borderColor={showRestartPrompt ? theme.status.warning : undefined}
|
||||
searchEnabled={showSearch}
|
||||
searchBuffer={searchBuffer}
|
||||
items={filteredItems}
|
||||
items={items}
|
||||
showScopeSelector={showScopeSelection}
|
||||
selectedScope={selectedScope}
|
||||
onScopeChange={handleScopeChange}
|
||||
maxItemsToShow={effectiveMaxItemsToShow}
|
||||
maxLabelWidth={maxLabelWidth}
|
||||
maxLabelWidth={maxLabelOrDescriptionWidth}
|
||||
onItemToggle={handleItemToggle}
|
||||
onEditCommit={handleEditCommit}
|
||||
onItemClear={handleItemClear}
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
readFile: vi.fn().mockResolvedValue('Plan content'),
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => '/mock/temp/plans',
|
||||
getPlansDir: () => '/mock/temp/plans',
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in idle state 1`] = `
|
||||
"
|
||||
ShortcutsHint
|
||||
" ShortcutsHint
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
ApprovalModeIndicator StatusDisplay
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
@@ -11,22 +10,19 @@ Footer
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = `
|
||||
"
|
||||
ShortcutsHint
|
||||
" ShortcutsHint
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = `
|
||||
"
|
||||
LoadingIndicator
|
||||
" LoadingIndicator
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = `
|
||||
"
|
||||
|
||||
ShortcutsHint
|
||||
────────────────────────────────────────
|
||||
ApprovalModeIndicator
|
||||
@@ -39,8 +35,7 @@ Footer
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
|
||||
"
|
||||
LoadingIndicator: Thinking ShortcutsHint
|
||||
" LoadingIndicator: Thinking ShortcutsHint
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
ApprovalModeIndicator
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
|
||||
@@ -18,8 +18,20 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -27,6 +27,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -54,6 +81,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
@@ -140,6 +194,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -167,6 +248,33 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`PolicyUpdateDialog > renders correctly and matches snapshot 1`] = `
|
||||
" ╭────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ New or changed workspace policies detected │
|
||||
│ Location: /test/workspace/.gemini/policies │
|
||||
│ Do you want to accept and load these policies? │
|
||||
│ │
|
||||
│ ● 1. Accept and Load │
|
||||
│ 2. Ignore (Use Default Policies) │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -22,6 +22,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
@@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -69,6 +69,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
@@ -78,9 +81,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -116,6 +116,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false* │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
@@ -125,9 +128,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -163,6 +163,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
@@ -172,9 +175,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -210,6 +210,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
@@ -219,9 +222,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -257,6 +257,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
@@ -266,9 +269,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ > Apply To │
|
||||
@@ -304,6 +304,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
@@ -313,9 +316,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -351,6 +351,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
@@ -360,9 +363,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -398,6 +398,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Enable Prompt Completion true* │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
@@ -407,9 +410,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
|
||||
@@ -144,30 +144,28 @@ export function BaseSettingsDialog({
|
||||
useEffect(() => {
|
||||
const prevItems = prevItemsRef.current;
|
||||
if (prevItems !== items) {
|
||||
if (items.length === 0) {
|
||||
const prevActiveItem = prevItems[activeIndex];
|
||||
if (prevActiveItem) {
|
||||
const newIndex = items.findIndex((i) => i.key === prevActiveItem.key);
|
||||
if (newIndex !== -1) {
|
||||
// Item still exists in the filtered list, keep focus on it
|
||||
setActiveIndex(newIndex);
|
||||
// Adjust scroll offset to ensure the item is visible
|
||||
let newScroll = scrollOffset;
|
||||
if (newIndex < scrollOffset) newScroll = newIndex;
|
||||
else if (newIndex >= scrollOffset + maxItemsToShow)
|
||||
newScroll = newIndex - maxItemsToShow + 1;
|
||||
|
||||
const maxScroll = Math.max(0, items.length - maxItemsToShow);
|
||||
setScrollOffset(Math.min(newScroll, maxScroll));
|
||||
} else {
|
||||
// Item was filtered out, reset to the top
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}
|
||||
} else {
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
} else {
|
||||
const prevActiveItem = prevItems[activeIndex];
|
||||
if (prevActiveItem) {
|
||||
const newIndex = items.findIndex((i) => i.key === prevActiveItem.key);
|
||||
if (newIndex !== -1) {
|
||||
// Item still exists in the filtered list, keep focus on it
|
||||
setActiveIndex(newIndex);
|
||||
// Adjust scroll offset to ensure the item is visible
|
||||
let newScroll = scrollOffset;
|
||||
if (newIndex < scrollOffset) newScroll = newIndex;
|
||||
else if (newIndex >= scrollOffset + maxItemsToShow)
|
||||
newScroll = newIndex - maxItemsToShow + 1;
|
||||
|
||||
const maxScroll = Math.max(0, items.length - maxItemsToShow);
|
||||
setScrollOffset(Math.min(newScroll, maxScroll));
|
||||
} else {
|
||||
// Item was filtered out, reset to the top
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
prevItemsRef.current = items;
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SearchableList, type SearchableListProps } from './SearchableList.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import { type GenericListItem } from '../../hooks/useFuzzyList.js';
|
||||
|
||||
// Mock UI State
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: () => ({
|
||||
mainAreaWidth: 100,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockItems: GenericListItem[] = [
|
||||
{
|
||||
key: 'item-1',
|
||||
label: 'Item One',
|
||||
description: 'Description for item one',
|
||||
},
|
||||
{
|
||||
key: 'item-2',
|
||||
label: 'Item Two',
|
||||
description: 'Description for item two',
|
||||
},
|
||||
{
|
||||
key: 'item-3',
|
||||
label: 'Item Three',
|
||||
description: 'Description for item three',
|
||||
},
|
||||
];
|
||||
|
||||
describe('SearchableList', () => {
|
||||
let mockOnSelect: ReturnType<typeof vi.fn>;
|
||||
let mockOnClose: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnSelect = vi.fn();
|
||||
mockOnClose = vi.fn();
|
||||
});
|
||||
|
||||
const renderList = (
|
||||
props: Partial<SearchableListProps<GenericListItem>> = {},
|
||||
) => {
|
||||
const defaultProps: SearchableListProps<GenericListItem> = {
|
||||
title: 'Test List',
|
||||
items: mockItems,
|
||||
onSelect: mockOnSelect,
|
||||
onClose: mockOnClose,
|
||||
...props,
|
||||
};
|
||||
|
||||
return render(
|
||||
<KeypressProvider>
|
||||
<SearchableList {...defaultProps} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
it('should render all items initially', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderList();
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
|
||||
// Check for title
|
||||
expect(frame).toContain('Test List');
|
||||
|
||||
// Check for items
|
||||
expect(frame).toContain('Item One');
|
||||
expect(frame).toContain('Item Two');
|
||||
expect(frame).toContain('Item Three');
|
||||
|
||||
// Check for descriptions
|
||||
expect(frame).toContain('Description for item one');
|
||||
});
|
||||
|
||||
it('should filter items based on search query', async () => {
|
||||
const { lastFrame, stdin } = renderList();
|
||||
|
||||
// Type "Two" into search
|
||||
await React.act(async () => {
|
||||
stdin.write('Two');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Item Two');
|
||||
expect(frame).not.toContain('Item One');
|
||||
expect(frame).not.toContain('Item Three');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show "No items found." when no items match', async () => {
|
||||
const { lastFrame, stdin } = renderList();
|
||||
|
||||
// Type something that won't match
|
||||
await React.act(async () => {
|
||||
stdin.write('xyz123');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('No items found.');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle selection with Enter', async () => {
|
||||
const { stdin } = renderList();
|
||||
|
||||
// Select first item (default active)
|
||||
await React.act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle navigation and selection', async () => {
|
||||
const { stdin } = renderList();
|
||||
|
||||
// Navigate down to second item
|
||||
await React.act(async () => {
|
||||
stdin.write('\u001B[B'); // Down Arrow
|
||||
});
|
||||
|
||||
// Select second item
|
||||
await React.act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(mockItems[1]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle close with Esc', async () => {
|
||||
const { stdin } = renderList();
|
||||
|
||||
await React.act(async () => {
|
||||
stdin.write('\u001B'); // Esc
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { TextInput } from './TextInput.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import {
|
||||
useFuzzyList,
|
||||
type GenericListItem,
|
||||
} from '../../hooks/useFuzzyList.js';
|
||||
|
||||
export interface SearchableListProps<T extends GenericListItem> {
|
||||
/** List title */
|
||||
title?: string;
|
||||
/** Available items */
|
||||
items: T[];
|
||||
/** Callback when an item is selected */
|
||||
onSelect: (item: T) => void;
|
||||
/** Callback when the list is closed (e.g. via Esc) */
|
||||
onClose?: () => void;
|
||||
/** Initial search query */
|
||||
initialSearchQuery?: string;
|
||||
/** Placeholder for search input */
|
||||
searchPlaceholder?: string;
|
||||
/** Max items to show at once */
|
||||
maxItemsToShow?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic searchable list component.
|
||||
*/
|
||||
export function SearchableList<T extends GenericListItem>({
|
||||
title,
|
||||
items,
|
||||
onSelect,
|
||||
onClose,
|
||||
initialSearchQuery = '',
|
||||
searchPlaceholder = 'Search...',
|
||||
maxItemsToShow = 10,
|
||||
}: SearchableListProps<T>): React.JSX.Element {
|
||||
const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
|
||||
items,
|
||||
initialQuery: initialSearchQuery,
|
||||
});
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
// Reset selection when filtered items change
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}, [filteredItems]);
|
||||
|
||||
// Calculate visible items
|
||||
const visibleItems = filteredItems.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxItemsToShow,
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown = scrollOffset + maxItemsToShow < filteredItems.length;
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
// Navigation
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
const newIndex =
|
||||
activeIndex > 0 ? activeIndex - 1 : filteredItems.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === filteredItems.length - 1) {
|
||||
setScrollOffset(Math.max(0, filteredItems.length - maxItemsToShow));
|
||||
} else if (newIndex < scrollOffset) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
const newIndex =
|
||||
activeIndex < filteredItems.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === 0) {
|
||||
setScrollOffset(0);
|
||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Selection
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
const item = filteredItems[activeIndex];
|
||||
if (item) {
|
||||
onSelect(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Close
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onClose?.();
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>{title}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Search Input */}
|
||||
{searchBuffer && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.focused}
|
||||
paddingX={1}
|
||||
marginBottom={1}
|
||||
>
|
||||
<TextInput
|
||||
buffer={searchBuffer}
|
||||
placeholder={searchPlaceholder}
|
||||
focus={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<Box flexDirection="column">
|
||||
{visibleItems.length === 0 ? (
|
||||
<Text color={theme.text.secondary}>No items found.</Text>
|
||||
) : (
|
||||
visibleItems.map((item, idx) => {
|
||||
const index = scrollOffset + idx;
|
||||
const isActive = index === activeIndex;
|
||||
|
||||
return (
|
||||
<Box key={item.key} flexDirection="row">
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.secondary}
|
||||
>
|
||||
{isActive ? '> ' : ' '}
|
||||
</Text>
|
||||
<Box width={maxLabelWidth + 2}>
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.primary}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{item.description && (
|
||||
<Text color={theme.text.secondary}>{item.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer/Scroll Indicators */}
|
||||
{(showScrollUp || showScrollDown) && (
|
||||
<Box marginTop={1} justifyContent="center">
|
||||
<Text color={theme.text.secondary}>
|
||||
{showScrollUp ? '▲ ' : ' '}
|
||||
{filteredItems.length} items
|
||||
{showScrollDown ? ' ▼' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export const INFORMATIVE_TIPS = [
|
||||
'Show memory usage for performance monitoring (/settings)…',
|
||||
'Show line numbers in the chat for easier reference (/settings)…',
|
||||
'Show citations to see where the model gets information (/settings)…',
|
||||
'Disable loading phrases for a quieter experience (/settings)…',
|
||||
'Customize loading phrases: tips, witty, all, or off (/settings)…',
|
||||
'Add custom witty phrases to the loading screen (settings.json)…',
|
||||
'Use alternate screen buffer to preserve shell history (/settings)…',
|
||||
'Choose a specific Gemini model for conversations (/settings)…',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface UIActions {
|
||||
vimHandleInput: (key: Key) => boolean;
|
||||
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
|
||||
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
|
||||
setIsPolicyUpdateDialogOpen: (value: boolean) => void;
|
||||
setConstrainHeight: (value: boolean) => void;
|
||||
onEscapePromptChange: (show: boolean) => void;
|
||||
refreshStatic: () => void;
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
FallbackIntent,
|
||||
ValidationIntent,
|
||||
AgentDefinition,
|
||||
PolicyUpdateConfirmationRequest,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type TransientMessageType } from '../../utils/events.js';
|
||||
import type { DOMElement } from 'ink';
|
||||
@@ -112,6 +113,8 @@ export interface UIState {
|
||||
isResuming: boolean;
|
||||
shouldShowIdePrompt: boolean;
|
||||
isFolderTrustDialogOpen: boolean;
|
||||
isPolicyUpdateDialogOpen: boolean;
|
||||
policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined;
|
||||
isTrustedFolder: boolean | undefined;
|
||||
constrainHeight: boolean;
|
||||
showErrorDetails: boolean;
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
useTextBuffer,
|
||||
type TextBuffer,
|
||||
} from '../components/shared/text-buffer.js';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
|
||||
interface FzfResult {
|
||||
item: string;
|
||||
start: number;
|
||||
end: number;
|
||||
score: number;
|
||||
positions?: number[];
|
||||
}
|
||||
|
||||
export interface GenericListItem {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
scopeMessage?: string;
|
||||
}
|
||||
|
||||
export interface UseFuzzyListProps<T extends GenericListItem> {
|
||||
items: T[];
|
||||
initialQuery?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
|
||||
export interface UseFuzzyListResult<T extends GenericListItem> {
|
||||
filteredItems: T[];
|
||||
searchBuffer: TextBuffer | undefined;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
maxLabelWidth: number;
|
||||
}
|
||||
|
||||
export function useFuzzyList<T extends GenericListItem>({
|
||||
items,
|
||||
initialQuery = '',
|
||||
onSearch,
|
||||
}: UseFuzzyListProps<T>): UseFuzzyListResult<T> {
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
||||
const [filteredKeys, setFilteredKeys] = useState<string[]>(() =>
|
||||
items.map((i) => i.key),
|
||||
);
|
||||
|
||||
// FZF instance for fuzzy searching
|
||||
const { fzfInstance, searchMap } = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
const searchItems: string[] = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
searchItems.push(item.label);
|
||||
map.set(item.label.toLowerCase(), item.key);
|
||||
});
|
||||
|
||||
const fzf = new AsyncFzf(searchItems, {
|
||||
fuzzy: 'v2',
|
||||
casing: 'case-insensitive',
|
||||
});
|
||||
return { fzfInstance: fzf, searchMap: map };
|
||||
}, [items]);
|
||||
|
||||
// Perform search
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!searchQuery.trim() || !fzfInstance) {
|
||||
setFilteredKeys(items.map((i) => i.key));
|
||||
return;
|
||||
}
|
||||
|
||||
const doSearch = async () => {
|
||||
const results = await fzfInstance.find(searchQuery);
|
||||
|
||||
if (!active) return;
|
||||
|
||||
const matchedKeys = new Set<string>();
|
||||
results.forEach((res: FzfResult) => {
|
||||
const key = searchMap.get(res.item.toLowerCase());
|
||||
if (key) matchedKeys.add(key);
|
||||
});
|
||||
setFilteredKeys(Array.from(matchedKeys));
|
||||
onSearch?.(searchQuery);
|
||||
};
|
||||
|
||||
void doSearch().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Search failed:', error);
|
||||
setFilteredKeys(items.map((i) => i.key)); // Reset to all items on error
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [searchQuery, fzfInstance, searchMap, items, onSearch]);
|
||||
|
||||
// Get mainAreaWidth for search buffer viewport from UIState
|
||||
const { mainAreaWidth } = useUIState();
|
||||
const viewportWidth = Math.max(20, mainAreaWidth - 8);
|
||||
|
||||
// Search input buffer
|
||||
const searchBuffer = useTextBuffer({
|
||||
initialText: searchQuery,
|
||||
initialCursorOffset: searchQuery.length,
|
||||
viewport: {
|
||||
width: viewportWidth,
|
||||
height: 1,
|
||||
},
|
||||
singleLine: true,
|
||||
onChange: (text) => setSearchQuery(text),
|
||||
});
|
||||
|
||||
// Filtered items to display
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery) return items;
|
||||
return items.filter((item) => filteredKeys.includes(item.key));
|
||||
}, [items, filteredKeys, searchQuery]);
|
||||
|
||||
// Calculate max label width for alignment
|
||||
const maxLabelWidth = useMemo(() => {
|
||||
let max = 0;
|
||||
// We use all items for consistent alignment even when filtered
|
||||
items.forEach((item) => {
|
||||
const labelFull =
|
||||
item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : '');
|
||||
const lWidth = getCachedStringWidth(labelFull);
|
||||
const dWidth = item.description
|
||||
? getCachedStringWidth(item.description)
|
||||
: 0;
|
||||
max = Math.max(max, lWidth, dWidth);
|
||||
});
|
||||
return max;
|
||||
}, [items]);
|
||||
|
||||
return {
|
||||
filteredItems,
|
||||
searchBuffer,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
maxLabelWidth,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import type { RetryAttemptPayload } from '@google/gemini-cli-core';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
describe('useLoadingIndicator', () => {
|
||||
beforeEach(() => {
|
||||
@@ -33,21 +34,25 @@ describe('useLoadingIndicator', () => {
|
||||
initialStreamingState: StreamingState,
|
||||
initialShouldShowFocusHint: boolean = false,
|
||||
initialRetryStatus: RetryAttemptPayload | null = null,
|
||||
loadingPhrasesMode: LoadingPhrasesMode = 'all',
|
||||
) => {
|
||||
let hookResult: ReturnType<typeof useLoadingIndicator>;
|
||||
function TestComponent({
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
mode,
|
||||
}: {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint?: boolean;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
mode?: LoadingPhrasesMode;
|
||||
}) {
|
||||
hookResult = useLoadingIndicator({
|
||||
streamingState,
|
||||
shouldShowFocusHint: !!shouldShowFocusHint,
|
||||
retryStatus: retryStatus || null,
|
||||
loadingPhrasesMode: mode,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -56,6 +61,7 @@ describe('useLoadingIndicator', () => {
|
||||
streamingState={initialStreamingState}
|
||||
shouldShowFocusHint={initialShouldShowFocusHint}
|
||||
retryStatus={initialRetryStatus}
|
||||
mode={loadingPhrasesMode}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
@@ -68,7 +74,8 @@ describe('useLoadingIndicator', () => {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint?: boolean;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
}) => rerender(<TestComponent {...newProps} />),
|
||||
mode?: LoadingPhrasesMode;
|
||||
}) => rerender(<TestComponent mode={loadingPhrasesMode} {...newProps} />),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -221,4 +228,15 @@ describe('useLoadingIndicator', () => {
|
||||
expect(result.current.currentLoadingPhrase).toContain('Trying to reach');
|
||||
expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3');
|
||||
});
|
||||
|
||||
it('should show no phrases when loadingPhrasesMode is "off"', () => {
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
null,
|
||||
'off',
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
getDisplayString,
|
||||
type RetryAttemptPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
export interface UseLoadingIndicatorProps {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint: boolean;
|
||||
retryStatus: RetryAttemptPayload | null;
|
||||
loadingPhrasesMode?: LoadingPhrasesMode;
|
||||
customWittyPhrases?: string[];
|
||||
}
|
||||
|
||||
@@ -24,6 +26,7 @@ export const useLoadingIndicator = ({
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
loadingPhrasesMode,
|
||||
customWittyPhrases,
|
||||
}: UseLoadingIndicatorProps) => {
|
||||
const [timerResetKey, setTimerResetKey] = useState(0);
|
||||
@@ -37,6 +40,7 @@ export const useLoadingIndicator = ({
|
||||
isPhraseCyclingActive,
|
||||
isWaiting,
|
||||
shouldShowFocusHint,
|
||||
loadingPhrasesMode,
|
||||
customWittyPhrases,
|
||||
);
|
||||
|
||||
|
||||
@@ -14,23 +14,27 @@ import {
|
||||
} from './usePhraseCycler.js';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
// Test component to consume the hook
|
||||
const TestComponent = ({
|
||||
isActive,
|
||||
isWaiting,
|
||||
isInteractiveShellWaiting = false,
|
||||
loadingPhrasesMode = 'all',
|
||||
customPhrases,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isWaiting: boolean;
|
||||
isInteractiveShellWaiting?: boolean;
|
||||
loadingPhrasesMode?: LoadingPhrasesMode;
|
||||
customPhrases?: string[];
|
||||
}) => {
|
||||
const phrase = usePhraseCycler(
|
||||
isActive,
|
||||
isWaiting,
|
||||
isInteractiveShellWaiting,
|
||||
loadingPhrasesMode,
|
||||
customPhrases,
|
||||
);
|
||||
return <Text>{phrase}</Text>;
|
||||
@@ -289,6 +293,7 @@ describe('usePhraseCycler', () => {
|
||||
<TestComponent
|
||||
isActive={config.isActive}
|
||||
isWaiting={false}
|
||||
loadingPhrasesMode="witty"
|
||||
customPhrases={config.customPhrases}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
|
||||
export const INTERACTIVE_SHELL_WAITING_PHRASE =
|
||||
@@ -17,23 +18,20 @@ export const INTERACTIVE_SHELL_WAITING_PHRASE =
|
||||
* @param isActive Whether the phrase cycling should be active.
|
||||
* @param isWaiting Whether to show a specific waiting phrase.
|
||||
* @param shouldShowFocusHint Whether to show the shell focus hint.
|
||||
* @param customPhrases Optional list of custom phrases to use.
|
||||
* @param loadingPhrasesMode Which phrases to show: tips, witty, all, or off.
|
||||
* @param customPhrases Optional list of custom phrases to use instead of built-in witty phrases.
|
||||
* @returns The current loading phrase.
|
||||
*/
|
||||
export const usePhraseCycler = (
|
||||
isActive: boolean,
|
||||
isWaiting: boolean,
|
||||
shouldShowFocusHint: boolean,
|
||||
loadingPhrasesMode: LoadingPhrasesMode = 'tips',
|
||||
customPhrases?: string[],
|
||||
) => {
|
||||
const loadingPhrases =
|
||||
customPhrases && customPhrases.length > 0
|
||||
? customPhrases
|
||||
: WITTY_LOADING_PHRASES;
|
||||
|
||||
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState<
|
||||
string | undefined
|
||||
>(isActive ? loadingPhrases[0] : undefined);
|
||||
>(undefined);
|
||||
|
||||
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasShownFirstRequestTipRef = useRef(false);
|
||||
@@ -55,30 +53,43 @@ export const usePhraseCycler = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
if (!isActive || loadingPhrasesMode === 'off') {
|
||||
setCurrentLoadingPhrase(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const wittyPhrases =
|
||||
customPhrases && customPhrases.length > 0
|
||||
? customPhrases
|
||||
: WITTY_LOADING_PHRASES;
|
||||
|
||||
const setRandomPhrase = () => {
|
||||
if (customPhrases && customPhrases.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * customPhrases.length);
|
||||
setCurrentLoadingPhrase(customPhrases[randomIndex]);
|
||||
} else {
|
||||
let phraseList;
|
||||
// Show a tip on the first request after startup, then continue with 1/6 chance
|
||||
if (!hasShownFirstRequestTipRef.current) {
|
||||
// Show a tip during the first request
|
||||
let phraseList: readonly string[];
|
||||
|
||||
switch (loadingPhrasesMode) {
|
||||
case 'tips':
|
||||
phraseList = INFORMATIVE_TIPS;
|
||||
hasShownFirstRequestTipRef.current = true;
|
||||
} else {
|
||||
// Roughly 1 in 6 chance to show a tip after the first request
|
||||
const showTip = Math.random() < 1 / 6;
|
||||
phraseList = showTip ? INFORMATIVE_TIPS : WITTY_LOADING_PHRASES;
|
||||
}
|
||||
const randomIndex = Math.floor(Math.random() * phraseList.length);
|
||||
setCurrentLoadingPhrase(phraseList[randomIndex]);
|
||||
break;
|
||||
case 'witty':
|
||||
phraseList = wittyPhrases;
|
||||
break;
|
||||
case 'all':
|
||||
// Show a tip on the first request after startup, then continue with 1/6 chance
|
||||
if (!hasShownFirstRequestTipRef.current) {
|
||||
phraseList = INFORMATIVE_TIPS;
|
||||
hasShownFirstRequestTipRef.current = true;
|
||||
} else {
|
||||
const showTip = Math.random() < 1 / 6;
|
||||
phraseList = showTip ? INFORMATIVE_TIPS : wittyPhrases;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
phraseList = INFORMATIVE_TIPS;
|
||||
break;
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * phraseList.length);
|
||||
setCurrentLoadingPhrase(phraseList[randomIndex]);
|
||||
};
|
||||
|
||||
// Select an initial random phrase
|
||||
@@ -95,7 +106,13 @@ export const usePhraseCycler = (
|
||||
phraseIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isActive, isWaiting, shouldShowFocusHint, customPhrases, loadingPhrases]);
|
||||
}, [
|
||||
isActive,
|
||||
isWaiting,
|
||||
shouldShowFocusHint,
|
||||
loadingPhrasesMode,
|
||||
customPhrases,
|
||||
]);
|
||||
|
||||
return currentLoadingPhrase;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
copyToClipboard,
|
||||
getUrlOpenCommand,
|
||||
} from './commandUtils.js';
|
||||
import type { Settings } from '../../config/settingsSchema.js';
|
||||
|
||||
// Constants used by OSC-52 tests
|
||||
const ESC = '\u001B';
|
||||
@@ -257,6 +258,29 @@ describe('commandUtils', () => {
|
||||
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses OSC-52 when useOSC52Copy setting is enabled', async () => {
|
||||
const testText = 'forced-osc52';
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
setTimeout(() => tty.emit('open'), 0);
|
||||
return tty;
|
||||
});
|
||||
|
||||
// NO environment signals for SSH/WSL/etc.
|
||||
const settings = {
|
||||
experimental: { useOSC52Copy: true },
|
||||
} as unknown as Settings;
|
||||
|
||||
await copyToClipboard(testText, settings);
|
||||
|
||||
const b64 = Buffer.from(testText, 'utf8').toString('base64');
|
||||
const expected = `${ESC}]52;c;${b64}${BEL}`;
|
||||
|
||||
expect(tty.write).toHaveBeenCalledTimes(1);
|
||||
expect(tty.write.mock.calls[0][0]).toBe(expected);
|
||||
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps OSC-52 for tmux when in SSH', async () => {
|
||||
const testText = 'tmux-copy';
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
|
||||
@@ -9,6 +9,7 @@ import clipboardy from 'clipboardy';
|
||||
import type { SlashCommand } from '../commands/types.js';
|
||||
import fs from 'node:fs';
|
||||
import type { Writable } from 'node:stream';
|
||||
import type { Settings } from '../../config/settingsSchema.js';
|
||||
|
||||
/**
|
||||
* Checks if a query string potentially represents an '@' command.
|
||||
@@ -157,8 +158,13 @@ const isWindowsTerminal = (): boolean =>
|
||||
|
||||
const isDumbTerm = (): boolean => (process.env['TERM'] ?? '') === 'dumb';
|
||||
|
||||
const shouldUseOsc52 = (tty: TtyTarget): boolean =>
|
||||
Boolean(tty) && !isDumbTerm() && (isSSH() || isWSL() || isWindowsTerminal());
|
||||
const shouldUseOsc52 = (tty: TtyTarget, settings?: Settings): boolean =>
|
||||
Boolean(tty) &&
|
||||
!isDumbTerm() &&
|
||||
(settings?.experimental?.useOSC52Copy ||
|
||||
isSSH() ||
|
||||
isWSL() ||
|
||||
isWindowsTerminal());
|
||||
|
||||
const safeUtf8Truncate = (buf: Buffer, maxBytes: number): Buffer => {
|
||||
if (buf.length <= maxBytes) return buf;
|
||||
@@ -237,12 +243,15 @@ const writeAll = (stream: Writable, data: string): Promise<void> =>
|
||||
});
|
||||
|
||||
// Copies a string snippet to the clipboard with robust OSC-52 support.
|
||||
export const copyToClipboard = async (text: string): Promise<void> => {
|
||||
export const copyToClipboard = async (
|
||||
text: string,
|
||||
settings?: Settings,
|
||||
): Promise<void> => {
|
||||
if (!text) return;
|
||||
|
||||
const tty = await pickTty();
|
||||
|
||||
if (shouldUseOsc52(tty)) {
|
||||
if (shouldUseOsc52(tty, settings)) {
|
||||
const osc = buildOsc52(text);
|
||||
const payload = inTmux()
|
||||
? wrapForTmux(osc)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { GeminiAgent } from './zedIntegration.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
ApprovalMode,
|
||||
AuthType,
|
||||
type Config,
|
||||
CoreToolCallStatus,
|
||||
@@ -62,6 +63,8 @@ describe('GeminiAgent Session Resume', () => {
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
@@ -149,7 +152,28 @@ describe('GeminiAgent Session Resume', () => {
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(response).toEqual({});
|
||||
expect(response).toEqual({
|
||||
modes: {
|
||||
availableModes: [
|
||||
{
|
||||
id: ApprovalMode.DEFAULT,
|
||||
name: 'Default',
|
||||
description: 'Prompts for approval',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.AUTO_EDIT,
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.YOLO,
|
||||
name: 'YOLO',
|
||||
description: 'Auto-approves all tools',
|
||||
},
|
||||
],
|
||||
currentModeId: ApprovalMode.DEFAULT,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify resumeChat received the correct arguments
|
||||
expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith(
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core/src/policy/types.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
@@ -119,6 +120,8 @@ describe('GeminiAgent', () => {
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
@@ -185,6 +188,59 @@ describe('GeminiAgent', () => {
|
||||
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return modes without plan mode when plan is disabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue('default');
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(response.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
|
||||
{
|
||||
id: 'autoEdit',
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
|
||||
],
|
||||
currentModeId: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return modes with plan mode when plan is enabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan');
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(response.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
|
||||
{
|
||||
id: 'autoEdit',
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
|
||||
{ id: 'plan', name: 'Plan', description: 'Read-only mode' },
|
||||
],
|
||||
currentModeId: 'plan',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail session creation if Gemini API key is missing', async () => {
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
@@ -306,6 +362,32 @@ describe('GeminiAgent', () => {
|
||||
expect(session.prompt).toHaveBeenCalled();
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should delegate setMode to session', async () => {
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
const session = (
|
||||
agent as unknown as { sessions: Map<string, Session> }
|
||||
).sessions.get('test-session-id');
|
||||
if (!session) throw new Error('Session not found');
|
||||
session.setMode = vi.fn().mockReturnValue({});
|
||||
|
||||
const result = await agent.setSessionMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId: 'plan',
|
||||
});
|
||||
|
||||
expect(session.setMode).toHaveBeenCalledWith('plan');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error when setting mode on non-existent session', async () => {
|
||||
await expect(
|
||||
agent.setSessionMode({
|
||||
sessionId: 'unknown',
|
||||
modeId: 'plan',
|
||||
}),
|
||||
).rejects.toThrow('Session not found: unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session', () => {
|
||||
@@ -352,6 +434,8 @@ describe('Session', () => {
|
||||
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
setApprovalMode: vi.fn(),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Mocked<Config>;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
@@ -822,4 +906,17 @@ describe('Session', () => {
|
||||
].value;
|
||||
expect(mockInstance.build).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set mode on config', () => {
|
||||
session.setMode(ApprovalMode.AUTO_EDIT);
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid mode', () => {
|
||||
expect(() => session.setMode('invalid-mode')).toThrow(
|
||||
'Invalid or unavailable mode: invalid-mode',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Kind,
|
||||
partListUnionToString,
|
||||
LlmRole,
|
||||
ApprovalMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -225,6 +226,10 @@ export class GeminiAgent {
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,7 +281,12 @@ export class GeminiAgent {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.streamHistory(sessionData.messages);
|
||||
|
||||
return {};
|
||||
return {
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async initializeSessionConfig(
|
||||
@@ -377,6 +387,16 @@ export class GeminiAgent {
|
||||
}
|
||||
return session.prompt(params);
|
||||
}
|
||||
|
||||
async setSessionMode(
|
||||
params: acp.SetSessionModeRequest,
|
||||
): Promise<acp.SetSessionModeResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.setMode(params.modeId);
|
||||
}
|
||||
}
|
||||
|
||||
export class Session {
|
||||
@@ -398,6 +418,17 @@ export class Session {
|
||||
this.pendingPrompt = null;
|
||||
}
|
||||
|
||||
setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse {
|
||||
const availableModes = buildAvailableModes(this.config.isPlanEnabled());
|
||||
const mode = availableModes.find((m) => m.id === modeId);
|
||||
if (!mode) {
|
||||
throw new Error(`Invalid or unavailable mode: ${modeId}`);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
this.config.setApprovalMode(mode.id as ApprovalMode);
|
||||
return {};
|
||||
}
|
||||
|
||||
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
|
||||
for (const msg of messages) {
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
@@ -1273,3 +1304,33 @@ function toAcpToolKind(kind: Kind): acp.ToolKind {
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
|
||||
const modes: acp.SessionMode[] = [
|
||||
{
|
||||
id: ApprovalMode.DEFAULT,
|
||||
name: 'Default',
|
||||
description: 'Prompts for approval',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.AUTO_EDIT,
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.YOLO,
|
||||
name: 'YOLO',
|
||||
description: 'Auto-approves all tools',
|
||||
},
|
||||
];
|
||||
|
||||
if (isPlanEnabled) {
|
||||
modes.push({
|
||||
id: ApprovalMode.PLAN,
|
||||
name: 'Plan',
|
||||
description: 'Read-only mode',
|
||||
});
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function extractMessageText(message: Message | undefined): string {
|
||||
/**
|
||||
* Extracts text from a single Part.
|
||||
*/
|
||||
export function extractPartText(part: Part): string {
|
||||
function extractPartText(part: Part): string {
|
||||
if (isTextPart(part)) {
|
||||
return part.text;
|
||||
}
|
||||
|
||||
@@ -720,6 +720,13 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
chat.setHistory(newHistory);
|
||||
this.hasFailedCompressionAttempt = false;
|
||||
}
|
||||
} else if (info.compressionStatus === CompressionStatus.CONTENT_TRUNCATED) {
|
||||
if (newHistory) {
|
||||
chat.setHistory(newHistory);
|
||||
// Do NOT reset hasFailedCompressionAttempt.
|
||||
// We only truncated content because summarization previously failed.
|
||||
// We want to keep avoiding expensive summarization calls.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
type: 'info',
|
||||
title: `Call Remote Agent: ${this.definition.displayName ?? this.definition.name}`,
|
||||
prompt: `Calling remote agent: "${this.params.query}"`,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
await this.publishPolicyUpdate(outcome);
|
||||
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
|
||||
// Policy updates are now handled centrally by the scheduler
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@ import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type {
|
||||
DeclarativeTool,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
} from '../tools/tools.js';
|
||||
import type { ToolRegistry } from 'src/tools/tool-registry.js';
|
||||
|
||||
vi.mock('./subagent-tool-wrapper.js');
|
||||
|
||||
@@ -274,3 +276,85 @@ describe('SubAgentInvocation', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SubagentTool Read-Only logic', () => {
|
||||
let mockConfig: Config;
|
||||
let mockMessageBus: MessageBus;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
mockMessageBus = createMockMessageBus();
|
||||
});
|
||||
|
||||
it('should be false for remote agents', () => {
|
||||
const tool = new SubagentTool(
|
||||
testRemoteDefinition,
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
);
|
||||
expect(tool.isReadOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true for local agent with only read-only tools', () => {
|
||||
const readOnlyTool = {
|
||||
name: 'read',
|
||||
isReadOnly: true,
|
||||
} as unknown as DeclarativeTool<object, ToolResult>;
|
||||
const registry = {
|
||||
getTool: (name: string) => (name === 'read' ? readOnlyTool : undefined),
|
||||
};
|
||||
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(
|
||||
registry as unknown as ToolRegistry,
|
||||
);
|
||||
|
||||
const defWithTools: LocalAgentDefinition = {
|
||||
...testDefinition,
|
||||
toolConfig: { tools: ['read'] },
|
||||
};
|
||||
const tool = new SubagentTool(defWithTools, mockConfig, mockMessageBus);
|
||||
expect(tool.isReadOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false for local agent with at least one non-read-only tool', () => {
|
||||
const readOnlyTool = {
|
||||
name: 'read',
|
||||
isReadOnly: true,
|
||||
} as unknown as DeclarativeTool<object, ToolResult>;
|
||||
const mutatorTool = {
|
||||
name: 'write',
|
||||
isReadOnly: false,
|
||||
} as unknown as DeclarativeTool<object, ToolResult>;
|
||||
const registry = {
|
||||
getTool: (name: string) => {
|
||||
if (name === 'read') return readOnlyTool;
|
||||
if (name === 'write') return mutatorTool;
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(
|
||||
registry as unknown as ToolRegistry,
|
||||
);
|
||||
|
||||
const defWithTools: LocalAgentDefinition = {
|
||||
...testDefinition,
|
||||
toolConfig: { tools: ['read', 'write'] },
|
||||
};
|
||||
const tool = new SubagentTool(defWithTools, mockConfig, mockMessageBus);
|
||||
expect(tool.isReadOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true for local agent with no tools', () => {
|
||||
const registry = { getTool: () => undefined };
|
||||
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(
|
||||
registry as unknown as ToolRegistry,
|
||||
);
|
||||
|
||||
const defNoTools: LocalAgentDefinition = {
|
||||
...testDefinition,
|
||||
toolConfig: { tools: [] },
|
||||
};
|
||||
const tool = new SubagentTool(defNoTools, mockConfig, mockMessageBus);
|
||||
expect(tool.isReadOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ToolResult,
|
||||
BaseToolInvocation,
|
||||
type ToolCallConfirmationDetails,
|
||||
isTool,
|
||||
} from '../tools/tools.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -48,6 +49,53 @@ export class SubagentTool extends BaseDeclarativeTool<AgentInputs, ToolResult> {
|
||||
);
|
||||
}
|
||||
|
||||
private _memoizedIsReadOnly: boolean | undefined;
|
||||
|
||||
override get isReadOnly(): boolean {
|
||||
if (this._memoizedIsReadOnly !== undefined) {
|
||||
return this._memoizedIsReadOnly;
|
||||
}
|
||||
// No try-catch here. If getToolRegistry() throws, we let it throw.
|
||||
// This is an invariant: you can't check read-only status if the system isn't initialized.
|
||||
this._memoizedIsReadOnly = SubagentTool.checkIsReadOnly(
|
||||
this.definition,
|
||||
this.config,
|
||||
);
|
||||
return this._memoizedIsReadOnly;
|
||||
}
|
||||
|
||||
private static checkIsReadOnly(
|
||||
definition: AgentDefinition,
|
||||
config: Config,
|
||||
): boolean {
|
||||
if (definition.kind === 'remote') {
|
||||
return false;
|
||||
}
|
||||
const tools = definition.toolConfig?.tools ?? [];
|
||||
const registry = config.getToolRegistry();
|
||||
|
||||
if (!registry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
if (typeof tool === 'string') {
|
||||
const resolvedTool = registry.getTool(tool);
|
||||
if (!resolvedTool || !resolvedTool.isReadOnly) {
|
||||
return false;
|
||||
}
|
||||
} else if (isTool(tool)) {
|
||||
if (!tool.isReadOnly) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// FunctionDeclaration - we don't know, so assume NOT read-only
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: AgentInputs,
|
||||
messageBus: MessageBus,
|
||||
|
||||
@@ -737,6 +737,42 @@ describe('Server Config (config.ts)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Plan Settings', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'should pass custom plan directory to storage',
|
||||
planSettings: { directory: 'custom-plans' },
|
||||
expected: 'custom-plans',
|
||||
},
|
||||
{
|
||||
name: 'should call setCustomPlansDir with undefined if directory is not provided',
|
||||
planSettings: {},
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: 'should call setCustomPlansDir with undefined if planSettings is not provided',
|
||||
planSettings: undefined,
|
||||
expected: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ name, planSettings, expected }) => {
|
||||
it(`${name}`, () => {
|
||||
const setCustomPlansDirSpy = vi.spyOn(
|
||||
Storage.prototype,
|
||||
'setCustomPlansDir',
|
||||
);
|
||||
new Config({
|
||||
...baseParams,
|
||||
planSettings,
|
||||
});
|
||||
|
||||
expect(setCustomPlansDirSpy).toHaveBeenCalledWith(expected);
|
||||
setCustomPlansDirSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Telemetry Settings', () => {
|
||||
it('should return default telemetry target if not provided', () => {
|
||||
const params: ConfigParameters = {
|
||||
@@ -1356,7 +1392,22 @@ describe('setApprovalMode with folder trust', () => {
|
||||
expect(updateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update system instruction when switching between non-Plan modes', () => {
|
||||
it('should update system instruction when entering YOLO mode', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
|
||||
vi.spyOn(config, 'getToolRegistry').mockReturnValue({
|
||||
getTool: vi.fn().mockReturnValue(undefined),
|
||||
unregisterTool: vi.fn(),
|
||||
registerTool: vi.fn(),
|
||||
} as Partial<ToolRegistry> as ToolRegistry);
|
||||
const updateSpy = vi.spyOn(config, 'updateSystemInstructionIfInitialized');
|
||||
|
||||
config.setApprovalMode(ApprovalMode.YOLO);
|
||||
|
||||
expect(updateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update system instruction when switching between non-Plan/non-YOLO modes', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
|
||||
const updateSpy = vi.spyOn(config, 'updateSystemInstructionIfInitialized');
|
||||
@@ -2501,7 +2552,7 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getProjectTempPlansDir();
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, {
|
||||
recursive: true,
|
||||
});
|
||||
@@ -2518,7 +2569,7 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getProjectTempPlansDir();
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
|
||||
recursive: true,
|
||||
});
|
||||
@@ -2613,6 +2664,27 @@ describe('syncPlanModeTools', () => {
|
||||
expect(registeredTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT register EnterPlanModeTool when in YOLO mode, even if plan is enabled', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
plan: true,
|
||||
});
|
||||
const registry = new ToolRegistry(config, config.getMessageBus());
|
||||
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
|
||||
|
||||
const registerSpy = vi.spyOn(registry, 'registerTool');
|
||||
vi.spyOn(registry, 'getTool').mockReturnValue(undefined);
|
||||
|
||||
config.syncPlanModeTools();
|
||||
|
||||
const { EnterPlanModeTool } = await import('../tools/enter-plan-mode.js');
|
||||
const registeredTool = registerSpy.mock.calls.find(
|
||||
(call) => call[0] instanceof EnterPlanModeTool,
|
||||
);
|
||||
expect(registeredTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call geminiClient.setTools if initialized', async () => {
|
||||
const config = new Config(baseParams);
|
||||
const registry = new ToolRegistry(config, config.getMessageBus());
|
||||
|
||||
@@ -126,8 +126,11 @@ import {
|
||||
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import { UserHintService } from './userHintService.js';
|
||||
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
|
||||
import { loadPoliciesFromToml } from '../policy/toml-loader.js';
|
||||
|
||||
export interface AccessibilitySettings {
|
||||
/** @deprecated Use ui.loadingPhrases instead. */
|
||||
enableLoadingPhrases?: boolean;
|
||||
screenReader?: boolean;
|
||||
}
|
||||
@@ -140,6 +143,10 @@ export interface SummarizeToolOutputSettings {
|
||||
tokenBudget?: number;
|
||||
}
|
||||
|
||||
export interface PlanSettings {
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
export interface TelemetrySettings {
|
||||
enabled?: boolean;
|
||||
target?: TelemetryTarget;
|
||||
@@ -375,6 +382,13 @@ export interface McpEnablementCallbacks {
|
||||
isFileEnabled: (serverId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PolicyUpdateConfirmationRequest {
|
||||
scope: string;
|
||||
identifier: string;
|
||||
policyDir: string;
|
||||
newHash: string;
|
||||
}
|
||||
|
||||
export interface ConfigParameters {
|
||||
sessionId: string;
|
||||
clientVersion?: string;
|
||||
@@ -455,6 +469,7 @@ export interface ConfigParameters {
|
||||
eventEmitter?: EventEmitter;
|
||||
useWriteTodos?: boolean;
|
||||
policyEngineConfig?: PolicyEngineConfig;
|
||||
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
|
||||
output?: OutputSettings;
|
||||
disableModelRouterForAuth?: AuthType[];
|
||||
continueOnFailedApiCall?: boolean;
|
||||
@@ -484,6 +499,7 @@ export interface ConfigParameters {
|
||||
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
|
||||
disableLLMCorrection?: boolean;
|
||||
plan?: boolean;
|
||||
planSettings?: PlanSettings;
|
||||
modelSteering?: boolean;
|
||||
onModelChange?: (model: string) => void;
|
||||
mcpEnabled?: boolean;
|
||||
@@ -633,6 +649,9 @@ export class Config {
|
||||
private readonly useWriteTodos: boolean;
|
||||
private readonly messageBus: MessageBus;
|
||||
private readonly policyEngine: PolicyEngine;
|
||||
private policyUpdateConfirmationRequest:
|
||||
| PolicyUpdateConfirmationRequest
|
||||
| undefined;
|
||||
private readonly outputSettings: OutputSettings;
|
||||
private readonly continueOnFailedApiCall: boolean;
|
||||
private readonly retryFetchErrors: boolean;
|
||||
@@ -838,6 +857,7 @@ export class Config {
|
||||
this.extensionManagement = params.extensionManagement ?? true;
|
||||
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
|
||||
this.storage = new Storage(this.targetDir, this.sessionId);
|
||||
this.storage.setCustomPlansDir(params.planSettings?.directory);
|
||||
|
||||
this.fakeResponses = params.fakeResponses;
|
||||
this.recordResponses = params.recordResponses;
|
||||
@@ -849,6 +869,8 @@ export class Config {
|
||||
approvalMode:
|
||||
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
|
||||
});
|
||||
this.policyUpdateConfirmationRequest =
|
||||
params.policyUpdateConfirmationRequest;
|
||||
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
|
||||
this.acknowledgedAgentsService = new AcknowledgedAgentsService();
|
||||
this.skillManager = new SkillManager();
|
||||
@@ -952,7 +974,7 @@ export class Config {
|
||||
|
||||
// Add plans directory to workspace context for plan file storage
|
||||
if (this.planEnabled) {
|
||||
const plansDir = this.storage.getProjectTempPlansDir();
|
||||
const plansDir = this.storage.getPlansDir();
|
||||
await fs.promises.mkdir(plansDir, { recursive: true });
|
||||
this.workspaceContext.addDirectory(plansDir);
|
||||
}
|
||||
@@ -1718,6 +1740,41 @@ export class Config {
|
||||
return this.policyEngine.getApprovalMode();
|
||||
}
|
||||
|
||||
getPolicyUpdateConfirmationRequest():
|
||||
| PolicyUpdateConfirmationRequest
|
||||
| undefined {
|
||||
return this.policyUpdateConfirmationRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot-loads workspace policies from the specified directory into the active policy engine.
|
||||
* This allows applying newly accepted policies without requiring an application restart.
|
||||
*
|
||||
* @param policyDir The directory containing the workspace policy TOML files.
|
||||
*/
|
||||
async loadWorkspacePolicies(policyDir: string): Promise<void> {
|
||||
const { rules, checkers } = await loadPoliciesFromToml(
|
||||
[policyDir],
|
||||
() => WORKSPACE_POLICY_TIER,
|
||||
);
|
||||
|
||||
// Clear existing workspace policies to prevent duplicates/stale rules
|
||||
this.policyEngine.removeRulesByTier(WORKSPACE_POLICY_TIER);
|
||||
this.policyEngine.removeCheckersByTier(WORKSPACE_POLICY_TIER);
|
||||
|
||||
for (const rule of rules) {
|
||||
this.policyEngine.addRule(rule);
|
||||
}
|
||||
|
||||
for (const checker of checkers) {
|
||||
this.policyEngine.addChecker(checker);
|
||||
}
|
||||
|
||||
this.policyUpdateConfirmationRequest = undefined;
|
||||
|
||||
debugLogger.debug(`Workspace policies loaded from: ${policyDir}`);
|
||||
}
|
||||
|
||||
setApprovalMode(mode: ApprovalMode): void {
|
||||
if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) {
|
||||
throw new Error(
|
||||
@@ -1740,7 +1797,11 @@ export class Config {
|
||||
const isPlanModeTransition =
|
||||
currentMode !== mode &&
|
||||
(currentMode === ApprovalMode.PLAN || mode === ApprovalMode.PLAN);
|
||||
if (isPlanModeTransition) {
|
||||
const isYoloModeTransition =
|
||||
currentMode !== mode &&
|
||||
(currentMode === ApprovalMode.YOLO || mode === ApprovalMode.YOLO);
|
||||
|
||||
if (isPlanModeTransition || isYoloModeTransition) {
|
||||
this.syncPlanModeTools();
|
||||
this.updateSystemInstructionIfInitialized();
|
||||
}
|
||||
@@ -1750,8 +1811,13 @@ export class Config {
|
||||
* Synchronizes enter/exit plan mode tools based on current mode.
|
||||
*/
|
||||
syncPlanModeTools(): void {
|
||||
const isPlanMode = this.getApprovalMode() === ApprovalMode.PLAN;
|
||||
const registry = this.getToolRegistry();
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
const approvalMode = this.getApprovalMode();
|
||||
const isPlanMode = approvalMode === ApprovalMode.PLAN;
|
||||
const isYoloMode = approvalMode === ApprovalMode.YOLO;
|
||||
|
||||
if (isPlanMode) {
|
||||
if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
|
||||
@@ -1764,7 +1830,7 @@ export class Config {
|
||||
if (registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.unregisterTool(EXIT_PLAN_MODE_TOOL_NAME);
|
||||
}
|
||||
if (this.planEnabled) {
|
||||
if (this.planEnabled && !isYoloMode) {
|
||||
if (!registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.registerTool(new EnterPlanModeTool(this, this.messageBus));
|
||||
}
|
||||
|
||||
@@ -12,12 +12,14 @@ vi.unmock('./storageMigration.js');
|
||||
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return {
|
||||
...actual,
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: vi.fn(actual.realpathSync),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -61,12 +63,11 @@ describe('Storage – initialize', () => {
|
||||
).toHaveBeenCalledWith(projectRoot);
|
||||
|
||||
// Verify migration calls
|
||||
const shortId = 'project-slug';
|
||||
// We can't easily get the hash here without repeating logic, but we can verify it's called twice
|
||||
expect(StorageMigration.migrateDirectory).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify identifier is set by checking a path
|
||||
expect(storage.getProjectTempDir()).toContain(shortId);
|
||||
expect(storage.getProjectTempDir()).toContain(PROJECT_SLUG);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +106,12 @@ describe('Storage – additional helpers', () => {
|
||||
const projectRoot = '/tmp/project';
|
||||
const storage = new Storage(projectRoot);
|
||||
|
||||
beforeEach(() => {
|
||||
ProjectRegistry.prototype.getShortId = vi
|
||||
.fn()
|
||||
.mockReturnValue(PROJECT_SLUG);
|
||||
});
|
||||
|
||||
it('getWorkspaceSettingsPath returns project/.gemini/settings.json', () => {
|
||||
const expected = path.join(projectRoot, GEMINI_DIR, 'settings.json');
|
||||
expect(storage.getWorkspaceSettingsPath()).toBe(expected);
|
||||
@@ -172,6 +179,181 @@ describe('Storage – additional helpers', () => {
|
||||
const expected = path.join(tempDir, sessionId, 'plans');
|
||||
expect(storageWithSession.getProjectTempPlansDir()).toBe(expected);
|
||||
});
|
||||
|
||||
describe('Session and JSON Loading', () => {
|
||||
beforeEach(async () => {
|
||||
await storage.initialize();
|
||||
});
|
||||
|
||||
it('listProjectChatFiles returns sorted sessions from chats directory', async () => {
|
||||
const readdirSpy = vi
|
||||
.spyOn(fs.promises, 'readdir')
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
.mockResolvedValue([
|
||||
'session-1.json',
|
||||
'session-2.json',
|
||||
'not-a-session.txt',
|
||||
] as any);
|
||||
|
||||
const statSpy = vi
|
||||
.spyOn(fs.promises, 'stat')
|
||||
.mockImplementation(async (p: any) => {
|
||||
if (p.toString().endsWith('session-1.json')) {
|
||||
return {
|
||||
mtime: new Date('2026-02-01'),
|
||||
mtimeMs: 1000,
|
||||
} as any;
|
||||
}
|
||||
return {
|
||||
mtime: new Date('2026-02-02'),
|
||||
mtimeMs: 2000,
|
||||
} as any;
|
||||
});
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
const sessions = await storage.listProjectChatFiles();
|
||||
|
||||
expect(readdirSpy).toHaveBeenCalledWith(expect.stringContaining('chats'));
|
||||
expect(sessions).toHaveLength(2);
|
||||
// Sorted by mtime desc
|
||||
expect(sessions[0].filePath).toBe(path.join('chats', 'session-2.json'));
|
||||
expect(sessions[1].filePath).toBe(path.join('chats', 'session-1.json'));
|
||||
expect(sessions[0].lastUpdated).toBe(
|
||||
new Date('2026-02-02').toISOString(),
|
||||
);
|
||||
|
||||
readdirSpy.mockRestore();
|
||||
statSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('loadProjectTempFile loads and parses JSON from relative path', async () => {
|
||||
const readFileSpy = vi
|
||||
.spyOn(fs.promises, 'readFile')
|
||||
.mockResolvedValue(JSON.stringify({ hello: 'world' }));
|
||||
|
||||
const result = await storage.loadProjectTempFile<{ hello: string }>(
|
||||
'some/file.json',
|
||||
);
|
||||
|
||||
expect(readFileSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.join(PROJECT_SLUG, 'some/file.json')),
|
||||
'utf8',
|
||||
);
|
||||
expect(result).toEqual({ hello: 'world' });
|
||||
|
||||
readFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('loadProjectTempFile returns null if file does not exist', async () => {
|
||||
const error = new Error('File not found');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code = 'ENOENT';
|
||||
const readFileSpy = vi
|
||||
.spyOn(fs.promises, 'readFile')
|
||||
.mockRejectedValue(error);
|
||||
|
||||
const result = await storage.loadProjectTempFile('missing.json');
|
||||
|
||||
expect(result).toBeNull();
|
||||
|
||||
readFileSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlansDir', () => {
|
||||
interface TestCase {
|
||||
name: string;
|
||||
customDir: string | undefined;
|
||||
expected: string | (() => string);
|
||||
expectedError?: string;
|
||||
setup?: () => () => void;
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
{
|
||||
name: 'custom relative path',
|
||||
customDir: '.my-plans',
|
||||
expected: path.resolve(projectRoot, '.my-plans'),
|
||||
},
|
||||
{
|
||||
name: 'custom absolute path outside throws',
|
||||
customDir: '/absolute/path/to/plans',
|
||||
expected: '',
|
||||
expectedError:
|
||||
"Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '/tmp/project'.",
|
||||
},
|
||||
{
|
||||
name: 'absolute path that happens to be inside project root',
|
||||
customDir: path.join(projectRoot, 'internal-plans'),
|
||||
expected: path.join(projectRoot, 'internal-plans'),
|
||||
},
|
||||
{
|
||||
name: 'relative path that stays within project root',
|
||||
customDir: 'subdir/../plans',
|
||||
expected: path.resolve(projectRoot, 'plans'),
|
||||
},
|
||||
{
|
||||
name: 'dot path',
|
||||
customDir: '.',
|
||||
expected: projectRoot,
|
||||
},
|
||||
{
|
||||
name: 'default behavior when customDir is undefined',
|
||||
customDir: undefined,
|
||||
expected: () => storage.getProjectTempPlansDir(),
|
||||
},
|
||||
{
|
||||
name: 'escaping relative path throws',
|
||||
customDir: '../escaped-plans',
|
||||
expected: '',
|
||||
expectedError:
|
||||
"Custom plans directory '../escaped-plans' resolves to '/tmp/escaped-plans', which is outside the project root '/tmp/project'.",
|
||||
},
|
||||
{
|
||||
name: 'hidden directory starting with ..',
|
||||
customDir: '..plans',
|
||||
expected: path.resolve(projectRoot, '..plans'),
|
||||
},
|
||||
{
|
||||
name: 'security escape via symbolic link throws',
|
||||
customDir: 'symlink-to-outside',
|
||||
setup: () => {
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
|
||||
if (p.toString().includes('symlink-to-outside')) {
|
||||
return '/outside/project/root';
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
return () => vi.mocked(fs.realpathSync).mockRestore();
|
||||
},
|
||||
expected: '',
|
||||
expectedError:
|
||||
"Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'.",
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
|
||||
it(`should handle ${name}`, async () => {
|
||||
const cleanup = setup?.();
|
||||
try {
|
||||
if (name.includes('default behavior')) {
|
||||
await storage.initialize();
|
||||
}
|
||||
|
||||
storage.setCustomPlansDir(customDir);
|
||||
if (expectedError) {
|
||||
expect(() => storage.getPlansDir()).toThrow(expectedError);
|
||||
} else {
|
||||
const expectedValue =
|
||||
typeof expected === 'function' ? expected() : expected;
|
||||
expect(storage.getPlansDir()).toBe(expectedValue);
|
||||
}
|
||||
} finally {
|
||||
cleanup?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage - System Paths', () => {
|
||||
|
||||
@@ -8,11 +8,16 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import { GEMINI_DIR, homedir } from '../utils/paths.js';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
GOOGLE_ACCOUNTS_FILENAME,
|
||||
isSubpath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { ProjectRegistry } from './projectRegistry.js';
|
||||
import { StorageMigration } from './storageMigration.js';
|
||||
|
||||
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
||||
export const OAUTH_FILE = 'oauth_creds.json';
|
||||
const TMP_DIR_NAME = 'tmp';
|
||||
const BIN_DIR_NAME = 'bin';
|
||||
@@ -23,12 +28,17 @@ export class Storage {
|
||||
private readonly sessionId: string | undefined;
|
||||
private projectIdentifier: string | undefined;
|
||||
private initPromise: Promise<void> | undefined;
|
||||
private customPlansDir: string | undefined;
|
||||
|
||||
constructor(targetDir: string, sessionId?: string) {
|
||||
this.targetDir = targetDir;
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
setCustomPlansDir(dir: string | undefined): void {
|
||||
this.customPlansDir = dir;
|
||||
}
|
||||
|
||||
static getGlobalGeminiDir(): string {
|
||||
const homeDir = homedir();
|
||||
if (!homeDir) {
|
||||
@@ -93,6 +103,10 @@ export class Storage {
|
||||
);
|
||||
}
|
||||
|
||||
static getPolicyIntegrityStoragePath(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'policy_integrity.json');
|
||||
}
|
||||
|
||||
private static getSystemConfigDir(): string {
|
||||
if (os.platform() === 'darwin') {
|
||||
return '/Library/Application Support/GeminiCli';
|
||||
@@ -136,6 +150,10 @@ export class Storage {
|
||||
return path.join(tempDir, identifier);
|
||||
}
|
||||
|
||||
getWorkspacePoliciesDir(): string {
|
||||
return path.join(this.getGeminiDir(), 'policies');
|
||||
}
|
||||
|
||||
ensureProjectTempDirExists(): void {
|
||||
fs.mkdirSync(this.getProjectTempDir(), { recursive: true });
|
||||
}
|
||||
@@ -250,6 +268,26 @@ export class Storage {
|
||||
return path.join(this.getProjectTempDir(), 'plans');
|
||||
}
|
||||
|
||||
getPlansDir(): string {
|
||||
if (this.customPlansDir) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.getProjectRoot(),
|
||||
this.customPlansDir,
|
||||
);
|
||||
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
|
||||
const realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
if (!isSubpath(realProjectRoot, realResolvedPath)) {
|
||||
throw new Error(
|
||||
`Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
return this.getProjectTempPlansDir();
|
||||
}
|
||||
|
||||
getProjectTempTasksDir(): string {
|
||||
if (this.sessionId) {
|
||||
return path.join(this.getProjectTempDir(), this.sessionId, 'tasks');
|
||||
@@ -257,6 +295,63 @@ export class Storage {
|
||||
return path.join(this.getProjectTempDir(), 'tasks');
|
||||
}
|
||||
|
||||
async listProjectChatFiles(): Promise<
|
||||
Array<{ filePath: string; lastUpdated: string }>
|
||||
> {
|
||||
const chatsDir = path.join(this.getProjectTempDir(), 'chats');
|
||||
try {
|
||||
const files = await fs.promises.readdir(chatsDir);
|
||||
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
||||
|
||||
const sessions = await Promise.all(
|
||||
jsonFiles.map(async (file) => {
|
||||
const absolutePath = path.join(chatsDir, file);
|
||||
const stats = await fs.promises.stat(absolutePath);
|
||||
return {
|
||||
filePath: path.join('chats', file),
|
||||
lastUpdated: stats.mtime.toISOString(),
|
||||
mtimeMs: stats.mtimeMs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return sessions
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
.map(({ filePath, lastUpdated }) => ({ filePath, lastUpdated }));
|
||||
} catch (e) {
|
||||
// If directory doesn't exist, return empty
|
||||
if (
|
||||
e instanceof Error &&
|
||||
'code' in e &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(e as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async loadProjectTempFile<T>(filePath: string): Promise<T | null> {
|
||||
const absolutePath = path.join(this.getProjectTempDir(), filePath);
|
||||
try {
|
||||
const content = await fs.promises.readFile(absolutePath, 'utf8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return JSON.parse(content) as T;
|
||||
} catch (e) {
|
||||
// If file doesn't exist, return null
|
||||
if (
|
||||
e instanceof Error &&
|
||||
'code' in e &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(e as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getExtensionsDir(): string {
|
||||
return path.join(this.getGeminiDir(), 'extensions');
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user