mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 13:22:35 -07:00
Merge remote-tracking branch 'origin/main' into feature/flicker-reduction-rework
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
name: 'Bug Report'
|
||||
description: 'Report a bug to help us improve Gemini CLI'
|
||||
labels:
|
||||
- 'status/need-triage'
|
||||
body:
|
||||
- type: 'markdown'
|
||||
attributes:
|
||||
|
||||
@@ -68,7 +68,7 @@ process_pr_optimized() {
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ No linked issue found for PR #${PR_NUMBER}"
|
||||
if [[ ",${CURRENT_LABELS}," != ",status/need-issue,"* ]]; then
|
||||
if [[ ",${CURRENT_LABELS}," != *",status/need-issue,"* ]]; then
|
||||
echo " ➕ Adding status/need-issue label"
|
||||
LABELS_TO_ADD="status/need-issue"
|
||||
fi
|
||||
@@ -82,7 +82,7 @@ process_pr_optimized() {
|
||||
else
|
||||
echo " 🔗 Found linked issue #${ISSUE_NUMBER}"
|
||||
|
||||
if [[ ",${CURRENT_LABELS}," == ",status/need-issue,"* ]]; then
|
||||
if [[ ",${CURRENT_LABELS}," == *",status/need-issue,"* ]]; then
|
||||
echo " ➖ Removing status/need-issue label"
|
||||
LABELS_TO_REMOVE="status/need-issue"
|
||||
fi
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
enforce-label:
|
||||
# Run this job only when restricted labels are changed
|
||||
if: |-
|
||||
${{ (github.event.label.name == 'help wanted' || github.event.label.name == 'status/need-triage') &&
|
||||
${{ (github.event.label.name == 'help wanted' || github.event.label.name == 'status/need-triage' || github.event.label.name == '🔒 maintainer only') &&
|
||||
(github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli') }}
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
@@ -39,28 +39,34 @@ jobs:
|
||||
const labelName = context.payload.label.name;
|
||||
|
||||
// Skip if the change was made by a bot to avoid infinite loops
|
||||
if (username === 'github-actions[bot]') {
|
||||
if (username === 'github-actions[bot]' || username === 'gemini-cli[bot]' || username.endsWith('[bot]')) {
|
||||
core.info('Change made by a bot. Skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// This will succeed with a 204 status if the user is a member,
|
||||
// and fail with a 404 error if they are not.
|
||||
await github.rest.teams.getMembershipForUserInOrg ({
|
||||
org,
|
||||
team_slug,
|
||||
// Check repository permission level directly.
|
||||
// This is more robust than team membership as it doesn't require Org-level read permissions
|
||||
// and correctly handles Repo Admins/Writers who might not be in the specific team.
|
||||
const { data: { permission } } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
username,
|
||||
});
|
||||
core.info(`${username} is a member of the ${team_slug} team. No action needed.`);
|
||||
} catch (error) {
|
||||
// If the error is not 404, rethrow it to fail the action
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
|
||||
if (permission === 'admin' || permission === 'write') {
|
||||
core.info(`${username} has '${permission}' permission. Allowed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`${username} is not a member. Reverting '${action}' action for '${labelName}' label.`);
|
||||
core.info(`${username} has '${permission}' permission (needs 'write' or 'admin'). Reverting '${action}' action for '${labelName}' label.`);
|
||||
} catch (error) {
|
||||
core.error(`Failed to check permissions for ${username}: ${error.message}`);
|
||||
// Fall through to revert logic if we can't verify permissions (fail safe)
|
||||
}
|
||||
|
||||
// If we are here, the user is NOT authorized.
|
||||
if (true) { // wrapping block to preserve variable scope if needed
|
||||
if (action === 'labeled') {
|
||||
// 1. Remove the label if added by a non-maintainer
|
||||
await github.rest.issues.removeLabel ({
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
name: 'Label Workstream Rollup'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: ['opened', 'edited', 'reopened']
|
||||
schedule:
|
||||
- cron: '0 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Check for Parent Workstream and Apply Label'
|
||||
uses: 'actions/github-script@v7'
|
||||
with:
|
||||
script: |
|
||||
const labelToAdd = 'workstream-rollup';
|
||||
|
||||
// Allow-list of parent issue URLs
|
||||
const allowedParentUrls = [
|
||||
'https://github.com/google-gemini/gemini-cli/issues/15374',
|
||||
'https://github.com/google-gemini/gemini-cli/issues/15456',
|
||||
'https://github.com/google-gemini/gemini-cli/issues/15324'
|
||||
];
|
||||
|
||||
async function getIssueParent(owner, repo, number) {
|
||||
const query = `
|
||||
query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
issue(number:$number) {
|
||||
parent {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
try {
|
||||
const result = await github.graphql(query, { owner, repo, number });
|
||||
return result.repository.issue.parent ? result.repository.issue.parent.url : null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch parent for #${number}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which issues to process
|
||||
let issuesToProcess = [];
|
||||
|
||||
if (context.eventName === 'issues') {
|
||||
// Context payload for 'issues' event already has the issue object
|
||||
issuesToProcess.push({
|
||||
number: context.payload.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
} else {
|
||||
// For schedule/dispatch, fetch open issues (lightweight list)
|
||||
console.log(`Running for event: ${context.eventName}. Fetching open issues...`);
|
||||
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open'
|
||||
});
|
||||
issuesToProcess = openIssues.map(i => ({
|
||||
number: i.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(`Processing ${issuesToProcess.length} issue(s)...`);
|
||||
|
||||
for (const issue of issuesToProcess) {
|
||||
const parentUrl = await getIssueParent(issue.owner, issue.repo, issue.number);
|
||||
|
||||
if (parentUrl && allowedParentUrls.includes(parentUrl)) {
|
||||
console.log(`SUCCESS: Issue #${issue.number} is a direct child of ${parentUrl}. Adding label.`);
|
||||
await github.rest.issues.addLabels({
|
||||
owner: issue.owner,
|
||||
repo: issue.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [labelToAdd]
|
||||
});
|
||||
} else {
|
||||
// logging only for single execution to avoid spam
|
||||
if (context.eventName === 'issues') {
|
||||
console.log(`Issue #${issue.number} parent is ${parentUrl || 'None'}. No action.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,17 @@ project. While you can run the individual steps (`build`, `test`, `typecheck`,
|
||||
`lint`) separately, it is highly recommended to use `npm run preflight` to
|
||||
ensure a comprehensive validation.
|
||||
|
||||
## Running Tests in Workspaces\*\*: To run a specific test file within a
|
||||
|
||||
workspace, use the command:
|
||||
`npm test -w <workspace-name> -- <path/to/test-file.test.ts>`. **CRITICAL**: The
|
||||
`<path/to/test-file.test.ts>` MUST be relative to the workspace directory root,
|
||||
NOT the project root.
|
||||
|
||||
- _Example (Core package)_:
|
||||
`npm test -w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`
|
||||
- _Common workspaces_: `@google/gemini-cli`, `@google/gemini-cli-core`.
|
||||
|
||||
## Writing Tests
|
||||
|
||||
This project uses **Vitest** as its primary testing framework. When writing
|
||||
|
||||
@@ -1230,7 +1230,7 @@ https://github.com/google-gemini/gemini-cli/compare/v0.19.4...v0.20.0
|
||||
https://github.com/google-gemini/gemini-cli/pull/12863
|
||||
- feat(hooks): Hook Telemetry Infrastructure by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9082
|
||||
- fix: (some minor improvements to configs and getPackageJson return behaviour)
|
||||
- fix: (some minor improvements to configs and getPackageJson return behavior)
|
||||
by @grMLEqomlkkU5Eeinz4brIrOVCUCkJuN in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12510
|
||||
- feat(hooks): Hook Event Handling by @Edilmo in
|
||||
@@ -1364,7 +1364,7 @@ https://github.com/google-gemini/gemini-cli/compare/v0.18.4...v0.19.0
|
||||
https://github.com/google-gemini/gemini-cli/pull/12863
|
||||
- feat(hooks): Hook Telemetry Infrastructure by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9082
|
||||
- fix: (some minor improvements to configs and getPackageJson return behaviour)
|
||||
- fix: (some minor improvements to configs and getPackageJson return behavior)
|
||||
by @grMLEqomlkkU5Eeinz4brIrOVCUCkJuN in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12510
|
||||
- feat(hooks): Hook Event Handling by @Edilmo in
|
||||
|
||||
@@ -204,6 +204,23 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
modify them as desired. Changes to some settings are applied immediately,
|
||||
while others require a restart.
|
||||
|
||||
- [**`/skills`**](./skills.md)
|
||||
- **Description:** (Experimental) Manage Agent Skills, which provide on-demand
|
||||
expertise and specialized workflows.
|
||||
- **Sub-commands:**
|
||||
- **`list`**:
|
||||
- **Description:** List all discovered skills and their current status
|
||||
(enabled/disabled).
|
||||
- **`enable`**:
|
||||
- **Description:** Enable a specific skill by name.
|
||||
- **Usage:** `/skills enable <name>`
|
||||
- **`disable`**:
|
||||
- **Description:** Disable a specific skill by name.
|
||||
- **Usage:** `/skills disable <name>`
|
||||
- **`reload`**:
|
||||
- **Description:** Refresh the list of discovered skills from all tiers
|
||||
(workspace, user, and extensions).
|
||||
|
||||
- **`/stats`**
|
||||
- **Description:** Display detailed statistics for the current Gemini CLI
|
||||
session, including token usage, cached token savings (when available), and
|
||||
|
||||
@@ -50,7 +50,7 @@ Your command definition files must be written in the TOML format and use the
|
||||
## Handling arguments
|
||||
|
||||
Custom commands support two powerful methods for handling arguments. The CLI
|
||||
automatically chooses the correct method based on the content of your command\'s
|
||||
automatically chooses the correct method based on the content of your command's
|
||||
`prompt`.
|
||||
|
||||
### 1. Context-aware injection with `{{args}}`
|
||||
@@ -96,13 +96,13 @@ Search Results:
|
||||
"""
|
||||
```
|
||||
|
||||
When you run `/grep-code It\'s complicated`:
|
||||
When you run `/grep-code It's complicated`:
|
||||
|
||||
1. The CLI sees `{{args}}` used both outside and inside `!{...}`.
|
||||
2. Outside: The first `{{args}}` is replaced raw with `It\'s complicated`.
|
||||
2. Outside: The first `{{args}}` is replaced raw with `It's complicated`.
|
||||
3. Inside: The second `{{args}}` is replaced with the escaped version (e.g., on
|
||||
Linux: `"It\'s complicated"`).
|
||||
4. The command executed is `grep -r "It\'s complicated" .`.
|
||||
4. The command executed is `grep -r "It's complicated" .`.
|
||||
5. The CLI prompts you to confirm this exact, secure command before execution.
|
||||
6. The final prompt is sent.
|
||||
|
||||
@@ -129,13 +129,13 @@ format and behavior.
|
||||
# In: <project>/.gemini/commands/changelog.toml
|
||||
# Invoked via: /changelog 1.2.0 added "Support for default argument parsing."
|
||||
|
||||
description = "Adds a new entry to the project\'s CHANGELOG.md file."
|
||||
description = "Adds a new entry to the project's CHANGELOG.md file."
|
||||
prompt = """
|
||||
# Task: Update Changelog
|
||||
|
||||
You are an expert maintainer of this software project. A user has invoked a command to add a new entry to the changelog.
|
||||
|
||||
**The user\'s raw command is appended below your instructions.**
|
||||
**The user's raw command is appended below your instructions.**
|
||||
|
||||
Your task is to parse the `<version>`, `<change_type>`, and `<message>` from their input and use the `write_file` tool to correctly update the `CHANGELOG.md` file.
|
||||
|
||||
@@ -147,7 +147,7 @@ The command follows this format: `/changelog <version> <type> <message>`
|
||||
1. Read the `CHANGELOG.md` file.
|
||||
2. Find the section for the specified `<version>`.
|
||||
3. Add the `<message>` under the correct `<type>` heading.
|
||||
4. If the version or type section doesn\'t exist, create it.
|
||||
4. If the version or type section doesn't exist, create it.
|
||||
5. Adhere strictly to the "Keep a Changelog" format.
|
||||
"""
|
||||
```
|
||||
@@ -241,7 +241,7 @@ operate on specific files.
|
||||
**Example (`review.toml`):**
|
||||
|
||||
This command injects the content of a _fixed_ best practices file
|
||||
(`docs/best-practices.md`) and uses the user\'s arguments to provide context for
|
||||
(`docs/best-practices.md`) and uses the user's arguments to provide context for
|
||||
the review.
|
||||
|
||||
```toml
|
||||
@@ -293,7 +293,7 @@ practice.
|
||||
description = "Asks the model to refactor the current context into a pure function."
|
||||
|
||||
prompt = """
|
||||
Please analyze the code I\'ve provided in the current context.
|
||||
Please analyze the code I've provided in the current context.
|
||||
Refactor it into a pure function.
|
||||
|
||||
Your response should include:
|
||||
|
||||
+4
-2
@@ -25,10 +25,12 @@ overview of Gemini CLI, see the [main documentation page](../index.md).
|
||||
|
||||
- **[Checkpointing](./checkpointing.md):** Automatically save and restore
|
||||
snapshots of your session and files.
|
||||
- **[Enterprise configuration](./enterprise.md):** Deploying and manage Gemini
|
||||
CLI in an enterprise environment.
|
||||
- **[Enterprise configuration](./enterprise.md):** Deploy and manage Gemini CLI
|
||||
in an enterprise environment.
|
||||
- **[Sandboxing](./sandbox.md):** Isolate tool execution in a secure,
|
||||
containerized environment.
|
||||
- **[Agent Skills](./skills.md):** (Experimental) Extend the CLI with
|
||||
specialized expertise and procedural workflows.
|
||||
- **[Telemetry](./telemetry.md):** Configure observability to monitor usage and
|
||||
performance.
|
||||
- **[Token caching](./token-caching.md):** Optimize API costs by caching tokens.
|
||||
|
||||
@@ -8,10 +8,12 @@ available combinations.
|
||||
|
||||
#### Basic Controls
|
||||
|
||||
| Action | Keys |
|
||||
| -------------------------------------------- | ------- |
|
||||
| Confirm the current selection or choice. | `Enter` |
|
||||
| Dismiss dialogs or cancel the current focus. | `Esc` |
|
||||
| Action | Keys |
|
||||
| --------------------------------------------------------------- | ---------- |
|
||||
| Confirm the current selection or choice. | `Enter` |
|
||||
| Dismiss dialogs or cancel the current focus. | `Esc` |
|
||||
| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` |
|
||||
| Exit the CLI when the input buffer is empty. | `Ctrl + D` |
|
||||
|
||||
#### Cursor Movement
|
||||
|
||||
@@ -40,12 +42,6 @@ available combinations.
|
||||
| Undo the most recent text edit. | `Ctrl + Z (no Shift)` |
|
||||
| Redo the most recent undone text edit. | `Ctrl + Shift + Z` |
|
||||
|
||||
#### Screen Control
|
||||
|
||||
| Action | Keys |
|
||||
| -------------------------------------------- | ---------- |
|
||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
||||
|
||||
#### Scrolling
|
||||
|
||||
| Action | Keys |
|
||||
@@ -88,39 +84,28 @@ available combinations.
|
||||
|
||||
#### Text Input
|
||||
|
||||
| Action | Keys |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd)` |
|
||||
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
|
||||
|
||||
#### External Tools
|
||||
|
||||
| Action | Keys |
|
||||
| ---------------------------------------------- | ------------------------- |
|
||||
| Open the current prompt in an external editor. | `Ctrl + X` |
|
||||
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V` |
|
||||
| Action | Keys |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd)` |
|
||||
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
|
||||
| Open the current prompt in an external editor. | `Ctrl + X` |
|
||||
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V` |
|
||||
|
||||
#### App Controls
|
||||
|
||||
| Action | Keys |
|
||||
| ----------------------------------------------------------------- | ---------------- |
|
||||
| Toggle detailed error information. | `F12` |
|
||||
| Toggle the full TODO list. | `Ctrl + T` |
|
||||
| Show IDE context details. | `Ctrl + G` |
|
||||
| Toggle Markdown rendering. | `Cmd + M` |
|
||||
| Toggle copy mode when the terminal is using the alternate buffer. | `Ctrl + S` |
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Toggle Auto Edit (auto-accept edits) mode. | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines. | `Ctrl + S` |
|
||||
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
|
||||
| Focus the Gemini input from the shell input. | `Tab` |
|
||||
|
||||
#### Session Control
|
||||
|
||||
| Action | Keys |
|
||||
| -------------------------------------------- | ---------- |
|
||||
| Cancel the current request or quit the CLI. | `Ctrl + C` |
|
||||
| Exit the CLI when the input buffer is empty. | `Ctrl + D` |
|
||||
| Action | Keys |
|
||||
| ------------------------------------------------------------------------------------------------ | ---------------- |
|
||||
| Toggle detailed error information. | `F12` |
|
||||
| Toggle the full TODO list. | `Ctrl + T` |
|
||||
| Show IDE context details. | `Ctrl + G` |
|
||||
| Toggle Markdown rendering. | `Cmd + M` |
|
||||
| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Toggle Auto Edit (auto-accept edits) mode. | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + S` |
|
||||
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
|
||||
| Focus the Gemini input from the shell input. | `Tab` |
|
||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
||||
|
||||
<!-- KEYBINDINGS-AUTOGEN:END -->
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ health and automatically routes requests to available models based on defined
|
||||
policies.
|
||||
|
||||
1. **Model failure:** If the currently selected model fails (e.g., due to quota
|
||||
or server errors), the CLI will iniate the fallback process.
|
||||
or server errors), the CLI will initiate the fallback process.
|
||||
|
||||
2. **User consent:** Depending on the failure and the model's policy, the CLI
|
||||
may prompt you to switch to a fallback model (by default always prompts
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ Before using sandboxing, you need to install and set up the Gemini CLI:
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
To verify the installation
|
||||
To verify the installation:
|
||||
|
||||
```bash
|
||||
gemini --version
|
||||
|
||||
@@ -224,11 +224,11 @@ visualize your telemetry.
|
||||
This dashboard can be found under **Google Cloud Monitoring Dashboard
|
||||
Templates** as "**Gemini CLI Monitoring**".
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
To learn more, check out this blog post:
|
||||
[Instant insights: Gemini CLI’s new pre-configured monitoring dashboards](https://cloud.google.com/blog/topics/developers-practitioners/instant-insights-gemini-clis-new-pre-configured-monitoring-dashboards/).
|
||||
|
||||
@@ -222,9 +222,45 @@ need this for extensions built to expose commands and prompts.
|
||||
Restart the CLI again. The model will now have the context from your `GEMINI.md`
|
||||
file in every session where the extension is active.
|
||||
|
||||
## Step 6: Releasing your extension
|
||||
## (Optional) Step 6: Add an Agent Skill
|
||||
|
||||
Once you are happy with your extension, you can share it with others. The two
|
||||
_Note: This is an experimental feature enabled via `experimental.skills`._
|
||||
|
||||
[Agent Skills](../cli/skills.md) let you bundle specialized expertise and
|
||||
procedural workflows. Unlike `GEMINI.md`, which provides persistent context,
|
||||
skills are activated only when needed, saving context tokens.
|
||||
|
||||
1. Create a `skills` directory and a subdirectory for your skill:
|
||||
|
||||
```bash
|
||||
mkdir -p skills/security-audit
|
||||
```
|
||||
|
||||
2. Create a `skills/security-audit/SKILL.md` file:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: security-audit
|
||||
description:
|
||||
Expertise in auditing code for security vulnerabilities. Use when the user
|
||||
asks to "check for security issues" or "audit" their changes.
|
||||
---
|
||||
|
||||
# Security Auditor
|
||||
|
||||
You are an expert security researcher. When auditing code:
|
||||
|
||||
1. Look for common vulnerabilities (OWASP Top 10).
|
||||
2. Check for hardcoded secrets or API keys.
|
||||
3. Suggest remediation steps for any findings.
|
||||
```
|
||||
|
||||
Skills bundled with your extension are automatically discovered and can be
|
||||
activated by the model during a session when it identifies a relevant task.
|
||||
|
||||
## Step 7: Release your extension
|
||||
|
||||
Once you're happy with your extension, you can share it with others. The two
|
||||
primary ways of releasing extensions are via a Git repository or through GitHub
|
||||
Releases. Using a public Git repository is the simplest method.
|
||||
|
||||
@@ -239,6 +275,7 @@ You've successfully created a Gemini CLI extension! You learned how to:
|
||||
- Add custom tools with an MCP server.
|
||||
- Create convenient custom commands.
|
||||
- Provide persistent context to the model.
|
||||
- Bundle specialized Agent Skills.
|
||||
- Link your extension for local development.
|
||||
|
||||
From here, you can explore more advanced features and build powerful new
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
_This documentation is up-to-date with the v0.4.0 release._
|
||||
|
||||
Gemini CLI extensions package prompts, MCP servers, and custom commands into a
|
||||
familiar and user-friendly format. With extensions, you can expand the
|
||||
capabilities of Gemini CLI and share those capabilities with others. They are
|
||||
designed to be easily installable and shareable.
|
||||
Gemini CLI extensions package prompts, MCP servers, Agent Skills, and custom
|
||||
commands into a familiar and user-friendly format. With extensions, you can
|
||||
expand the capabilities of Gemini CLI and share those capabilities with others.
|
||||
They're designed to be easily installable and shareable.
|
||||
|
||||
To see examples of extensions, you can browse a gallery of
|
||||
[Gemini CLI extensions](https://geminicli.com/extensions/browse/).
|
||||
@@ -263,6 +263,40 @@ Would provide these commands:
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
||||
|
||||
### Agent Skills
|
||||
|
||||
_Note: This is an experimental feature enabled via `experimental.skills`._
|
||||
|
||||
Extensions can bundle [Agent Skills](../cli/skills.md) to provide on-demand
|
||||
expertise and specialized workflows. To include skills in your extension, place
|
||||
them in a `skills/` subdirectory within the extension directory. Each skill must
|
||||
follow the [Agent Skills structure](../cli/skills.md#folder-structure),
|
||||
including a `SKILL.md` file.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension named `security-toolkit` with the following structure:
|
||||
|
||||
```
|
||||
.gemini/extensions/security-toolkit/
|
||||
├── gemini-extension.json
|
||||
└── skills/
|
||||
├── audit/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/
|
||||
│ └── scan.py
|
||||
└── hardening/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
Upon installation, these skills will be discovered by Gemini CLI and can be
|
||||
activated during a session when the model identifies a task matching their
|
||||
descriptions.
|
||||
|
||||
Extension skills have the lowest precedence and will be overridden by user or
|
||||
workspace skills of the same name. They can be viewed and managed (enabled or
|
||||
disabled) using the [`/skills` command](../cli/skills.md#managing-skills).
|
||||
|
||||
### Hooks
|
||||
|
||||
Extensions can provide [hooks](../hooks/index.md) to intercept and customize
|
||||
|
||||
@@ -1346,6 +1346,10 @@ for that specific session.
|
||||
- `auto_edit`: Automatically approve edit tools (replace, write_file) while
|
||||
prompting for others
|
||||
- `yolo`: Automatically approve all tool calls (equivalent to `--yolo`)
|
||||
- `plan`: Read-only mode for tool calls (requires experimental planning to
|
||||
be enabled).
|
||||
> **Note:** This mode is currently under development and not yet fully
|
||||
> functional.
|
||||
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
|
||||
`--yolo` for the new unified approach.
|
||||
- Example: `gemini --approval-mode auto_edit`
|
||||
|
||||
@@ -56,6 +56,8 @@ This documentation is organized into the following sections:
|
||||
commands with `/model`.
|
||||
- **[Sandbox](./cli/sandbox.md):** Isolate tool execution in a secure,
|
||||
containerized environment.
|
||||
- **[Agent Skills](./cli/skills.md):** (Experimental) Extend the CLI with
|
||||
specialized expertise and procedural workflows.
|
||||
- **[Settings](./cli/settings.md):** Configure various aspects of the CLI's
|
||||
behavior and appearance with `/settings`.
|
||||
- **[Telemetry](./cli/telemetry.md):** Overview of telemetry in the CLI.
|
||||
|
||||
@@ -10,7 +10,7 @@ debug your code by instrumenting interesting events like model calls, tool
|
||||
scheduler, tool calls, etc.
|
||||
|
||||
Dev traces are verbose and are specifically meant for understanding agent
|
||||
behaviour and debugging issues. They are disabled by default.
|
||||
behavior and debugging issues. They are disabled by default.
|
||||
|
||||
To enable dev traces, set the `GEMINI_DEV_TRACING=true` environment variable
|
||||
when running Gemini CLI.
|
||||
|
||||
@@ -91,5 +91,8 @@ Additionally, these tools incorporate:
|
||||
|
||||
- **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the
|
||||
Gemini model and your local environment or other services like APIs.
|
||||
- **[Agent Skills](../cli/skills.md)**: (Experimental) On-demand expertise
|
||||
packages that are activated via the `activate_skill` tool to provide
|
||||
specialized guidance and resources.
|
||||
- **[Sandboxing](../cli/sandbox.md)**: Sandboxing isolates the model and its
|
||||
changes from your environment to reduce potential risk.
|
||||
|
||||
+12
-1
@@ -28,6 +28,16 @@ topics on:
|
||||
- **Organizational Users:** Contact your Google Cloud administrator to be
|
||||
added to your organization's Gemini Code Assist subscription.
|
||||
|
||||
- **Error:
|
||||
`Failed to login. Message: Your current account is not eligible... because it is not currently available in your location.`**
|
||||
- **Cause:** Gemini CLI does not currently support your location. For a full
|
||||
list of supported locations, see the following pages:
|
||||
- Gemini Code Assist for individuals:
|
||||
[Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
|
||||
- Google AI Pro and Ultra where Gemini Code Assist (and Gemini CLI) is also
|
||||
available:
|
||||
[Available locations](https://developers.google.com/gemini-code-assist/resources/locations-pro-ultra)
|
||||
|
||||
- **Error: `Failed to login. Message: Request contains an invalid argument`**
|
||||
- **Cause:** Users with Google Workspace accounts or Google Cloud accounts
|
||||
associated with their Gmail accounts may not be able to activate the free
|
||||
@@ -137,7 +147,8 @@ This is especially useful for scripting and automation.
|
||||
|
||||
- **Core debugging:**
|
||||
- Check the server console output for error messages or stack traces.
|
||||
- Increase log verbosity if configurable.
|
||||
- Increase log verbosity if configurable. For example, set the `DEBUG_MODE`
|
||||
environment variable to `true` or `1`.
|
||||
- Use Node.js debugging tools (e.g., `node --inspect`) if you need to step
|
||||
through server-side code.
|
||||
|
||||
|
||||
@@ -88,6 +88,13 @@ describe('my_feature', () => {
|
||||
|
||||
## Running Evaluations
|
||||
|
||||
First, build the bundled Gemini CLI. You must do this after every code change.
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run bundle
|
||||
```
|
||||
|
||||
### Always Passing Evals
|
||||
|
||||
To run the evaluations that are expected to always pass (CI safe):
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
const AGENT_DEFINITION = `---
|
||||
name: docs-agent
|
||||
description: An agent with expertise in updating documentation.
|
||||
tools:
|
||||
- read_file
|
||||
- write_file
|
||||
---
|
||||
|
||||
You are the docs agent. Update the documentation.
|
||||
`;
|
||||
|
||||
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;';
|
||||
|
||||
describe('subagent eval test cases', () => {
|
||||
/**
|
||||
* Checks whether the outer agent reliably utilizes an expert subagent to
|
||||
* accomplish a task when one is available.
|
||||
*
|
||||
* Note that the test is intentionally crafted to avoid the word "document"
|
||||
* or "docs". We want to see the outer agent make the connection even when
|
||||
* the prompt indirectly implies need of expertise.
|
||||
*
|
||||
* This tests the system prompt's subagent specific clauses.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should delegate to user provided agent with relevant expertise',
|
||||
params: {
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: 'Please update README.md with a description of this library.',
|
||||
files: {
|
||||
'.gemini/agents/test-agent.md': AGENT_DEFINITION,
|
||||
'index.ts': INDEX_TS,
|
||||
'README.md': 'TODO: update the README.',
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
await rig.expectToolCallSuccess(
|
||||
['delegate_to_agent'],
|
||||
undefined,
|
||||
(args) => {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed.agent_name === 'docs-agent';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
+31
-2
@@ -6,7 +6,10 @@
|
||||
|
||||
import { it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { TestRig } from '@google/gemini-cli-test-utils';
|
||||
import { createUnauthorizedToolError } from '@google/gemini-cli-core';
|
||||
|
||||
export * from '@google/gemini-cli-test-utils';
|
||||
|
||||
@@ -32,8 +35,33 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
const fn = async () => {
|
||||
const rig = new TestRig();
|
||||
try {
|
||||
await rig.setup(evalCase.name, evalCase.params);
|
||||
rig.setup(evalCase.name, evalCase.params);
|
||||
|
||||
if (evalCase.files) {
|
||||
for (const [filePath, content] of Object.entries(evalCase.files)) {
|
||||
const fullPath = path.join(rig.testDir!, filePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
|
||||
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
|
||||
execSync('git init', execOptions);
|
||||
execSync('git config user.email "test@example.com"', execOptions);
|
||||
execSync('git config user.name "Test User"', execOptions);
|
||||
execSync('git add .', execOptions);
|
||||
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
|
||||
}
|
||||
|
||||
const result = await rig.run({ args: evalCase.prompt });
|
||||
|
||||
const unauthorizedErrorPrefix =
|
||||
createUnauthorizedToolError('').split("'")[0];
|
||||
if (result.includes(unauthorizedErrorPrefix)) {
|
||||
throw new Error(
|
||||
'Test failed due to unauthorized tool call in output: ' + result,
|
||||
);
|
||||
}
|
||||
|
||||
await evalCase.assert(rig, result);
|
||||
} finally {
|
||||
await logToFile(
|
||||
@@ -44,7 +72,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
}
|
||||
};
|
||||
|
||||
if (policy === 'USUALLY_PASSES' && !process.env.RUN_EVALS) {
|
||||
if (policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) {
|
||||
it.skip(evalCase.name, fn);
|
||||
} else {
|
||||
it(evalCase.name, fn);
|
||||
@@ -55,6 +83,7 @@ export interface EvalCase {
|
||||
name: string;
|
||||
params?: Record<string, any>;
|
||||
prompt: string;
|
||||
files?: Record<string, string>;
|
||||
assert: (rig: TestRig, result: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ rpc.send({
|
||||
});
|
||||
`;
|
||||
|
||||
describe('simple-mcp-server', () => {
|
||||
describe.skip('simple-mcp-server', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
Generated
+34
-22
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.26.0-nightly.20260114.bb6c57414",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.26.0-nightly.20260114.bb6c57414",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2474,6 +2474,7 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2654,6 +2655,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2687,6 +2689,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
||||
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -3055,6 +3058,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
||||
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -3088,6 +3092,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
||||
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1"
|
||||
@@ -3140,6 +3145,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
||||
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1",
|
||||
@@ -4352,6 +4358,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4629,6 +4636,7 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5633,6 +5641,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6077,8 +6086,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
@@ -7362,7 +7370,6 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
@@ -8682,6 +8689,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -9284,7 +9292,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -9294,7 +9301,6 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -9304,7 +9310,6 @@
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -9558,7 +9563,6 @@
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
@@ -9577,7 +9581,6 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -9586,15 +9589,13 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -10877,6 +10878,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
|
||||
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -14061,8 +14063,7 @@
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "3.0.0",
|
||||
@@ -14639,6 +14640,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14649,6 +14651,7 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16908,6 +16911,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17131,7 +17135,8 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -17139,6 +17144,7 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -17322,6 +17328,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -17484,7 +17491,6 @@
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
@@ -17539,6 +17545,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17652,6 +17659,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17664,6 +17672,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -18368,6 +18377,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -18383,7 +18393,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.26.0-nightly.20260114.bb6c57414",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.7",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -18693,7 +18703,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.26.0-nightly.20260114.bb6c57414",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -18704,6 +18714,7 @@
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"clipboardy": "^5.0.0",
|
||||
"color-convert": "^2.0.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^7.0.0",
|
||||
@@ -18796,7 +18807,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.26.0-nightly.20260114.bb6c57414",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.7",
|
||||
@@ -18933,6 +18944,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18955,7 +18967,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.26.0-nightly.20260114.bb6c57414",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18972,7 +18984,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.26.0-nightly.20260114.bb6c57414",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.26.0-nightly.20260114.bb6c57414",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260114.bb6c57414"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.26.0-nightly.20260114.bb6c57414",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
TaskStatusUpdateEvent,
|
||||
SendStreamingMessageSuccessResponse,
|
||||
} from '@a2a-js/sdk';
|
||||
import type express from 'express';
|
||||
import express from 'express';
|
||||
import type { Server } from 'node:http';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { createApp } from './app.js';
|
||||
import { createApp, main } from './app.js';
|
||||
import { commandRegistry } from '../commands/command-registry.js';
|
||||
import {
|
||||
assertUniqueFinalEventIsLast,
|
||||
@@ -1176,4 +1176,43 @@ describe('E2E Tests', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('main', () => {
|
||||
it('should listen on localhost only', async () => {
|
||||
const listenSpy = vi
|
||||
.spyOn(express.application, 'listen')
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
// Trigger the callback passed to listen
|
||||
const callback = args.find(
|
||||
(arg): arg is () => void => typeof arg === 'function',
|
||||
);
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
|
||||
return {
|
||||
address: () => ({ port: 1234 }),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
} as unknown as Server;
|
||||
});
|
||||
|
||||
// Avoid process.exit if possible, or mock it if main might fail
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
await main();
|
||||
|
||||
expect(listenSpy).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
'localhost',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
listenSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,9 +326,9 @@ export async function createApp() {
|
||||
export async function main() {
|
||||
try {
|
||||
const expressApp = await createApp();
|
||||
const port = process.env['CODER_AGENT_PORT'] || 0;
|
||||
const port = Number(process.env['CODER_AGENT_PORT'] || 0);
|
||||
|
||||
const server = expressApp.listen(port, () => {
|
||||
const server = expressApp.listen(port, 'localhost', () => {
|
||||
const address = server.address();
|
||||
let actualPort;
|
||||
if (process.env['CODER_AGENT_PORT']) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.26.0-nightly.20260114.bb6c57414",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260114.bb6c57414"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -37,6 +37,7 @@
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"clipboardy": "^5.0.0",
|
||||
"color-convert": "^2.0.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^7.0.0",
|
||||
|
||||
@@ -230,8 +230,7 @@ export async function handleMigrateFromClaude() {
|
||||
const settings = loadSettings(workingDir);
|
||||
|
||||
// Merge migrated hooks with existing hooks
|
||||
const existingHooks =
|
||||
(settings.merged.hooks as Record<string, unknown>) || {};
|
||||
const existingHooks = settings.merged.hooks as Record<string, unknown>;
|
||||
const mergedHooks = { ...existingHooks, ...migratedHooks };
|
||||
|
||||
// Update settings (setValue automatically saves)
|
||||
|
||||
@@ -6,15 +6,20 @@
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
||||
import { listMcpServers } from './list.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { loadSettings, mergeSettings } from '../../config/settings.js';
|
||||
import { createTransport, debugLogger } from '@google/gemini-cli-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ExtensionStorage } from '../../config/extensions/storage.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
loadSettings: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../config/extensions/storage.js', () => ({
|
||||
ExtensionStorage: {
|
||||
getUserExtensionsDir: vi.fn(),
|
||||
@@ -32,11 +37,16 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
CONNECTING: 'CONNECTING',
|
||||
DISCONNECTED: 'DISCONNECTED',
|
||||
},
|
||||
Storage: vi.fn().mockImplementation((_cwd: string) => ({
|
||||
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
|
||||
getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json',
|
||||
getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash',
|
||||
})),
|
||||
Storage: Object.assign(
|
||||
vi.fn().mockImplementation((_cwd: string) => ({
|
||||
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
|
||||
getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json',
|
||||
getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash',
|
||||
})),
|
||||
{
|
||||
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
|
||||
},
|
||||
),
|
||||
GEMINI_DIR: '.gemini',
|
||||
getErrorMessage: (e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
@@ -96,7 +106,10 @@ describe('mcp list command', () => {
|
||||
});
|
||||
|
||||
it('should display message when no servers configured', async () => {
|
||||
mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } });
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: { ...defaultMergedSettings, mcpServers: {} },
|
||||
});
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
@@ -104,8 +117,10 @@ describe('mcp list command', () => {
|
||||
});
|
||||
|
||||
it('should display different server types with connected status', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
|
||||
'sse-server': { url: 'https://example.com/sse' },
|
||||
@@ -138,8 +153,10 @@ describe('mcp list command', () => {
|
||||
});
|
||||
|
||||
it('should display disconnected status when connection fails', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
@@ -158,9 +175,13 @@ describe('mcp list command', () => {
|
||||
});
|
||||
|
||||
it('should merge extension servers with config servers', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
mcpServers: { 'config-server': { command: '/config/server' } },
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'config-server': { command: '/config/server' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ async function getMcpServersFromConfig(): Promise<
|
||||
requestSetting: promptForSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const mcpServers = { ...(settings.merged.mcpServers || {}) };
|
||||
const mcpServers = { ...settings.merged.mcpServers };
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
|
||||
if (mcpServers[key]) {
|
||||
@@ -63,8 +63,7 @@ async function testMCPConnection(
|
||||
const sanitizationConfig = {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables:
|
||||
settings.merged.advanced?.excludedEnvVars || [],
|
||||
blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
|
||||
};
|
||||
|
||||
let transport;
|
||||
|
||||
@@ -22,8 +22,9 @@ import {
|
||||
Config,
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settingsSchema.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
export const server = setupServer();
|
||||
@@ -212,7 +213,7 @@ describe('Configuration Integration Tests', () => {
|
||||
const originalArgv = process.argv;
|
||||
try {
|
||||
process.argv = argv;
|
||||
const parsedArgs = await parseArguments({} as Settings);
|
||||
const parsedArgs = await parseArguments(createTestMergedSettings());
|
||||
expect(parsedArgs.approvalMode).toBe(expected.approvalMode);
|
||||
expect(parsedArgs.prompt).toBe(expected.prompt);
|
||||
expect(parsedArgs.yolo).toBe(expected.yolo);
|
||||
@@ -235,7 +236,9 @@ describe('Configuration Integration Tests', () => {
|
||||
const originalArgv = process.argv;
|
||||
try {
|
||||
process.argv = argv;
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow();
|
||||
await expect(
|
||||
parseArguments(createTestMergedSettings()),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,8 +38,12 @@ import {
|
||||
type OutputFormat,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import { saveModelChange, loadSettings } from './settings.js';
|
||||
import {
|
||||
type Settings,
|
||||
type MergedSettings,
|
||||
saveModelChange,
|
||||
loadSettings,
|
||||
} from './settings.js';
|
||||
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { resolvePath } from '../utils/resolvePath.js';
|
||||
@@ -54,7 +58,6 @@ import { requestConsentNonInteractive } from './extensions/consent.js';
|
||||
import { promptForSetting } from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import { getEnableHooks, getEnableHooksUI } from './settingsSchema.js';
|
||||
|
||||
export interface CliArgs {
|
||||
query: string | undefined;
|
||||
@@ -82,7 +85,9 @@ export interface CliArgs {
|
||||
recordResponses: string | undefined;
|
||||
}
|
||||
|
||||
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
export async function parseArguments(
|
||||
settings: MergedSettings,
|
||||
): Promise<CliArgs> {
|
||||
const rawArgv = hideBin(process.argv);
|
||||
const yargsInstance = yargs(rawArgv)
|
||||
.locale('en')
|
||||
@@ -138,9 +143,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
.option('approval-mode', {
|
||||
type: 'string',
|
||||
nargs: 1,
|
||||
choices: ['default', 'auto_edit', 'yolo'],
|
||||
choices: ['default', 'auto_edit', 'yolo', 'plan'],
|
||||
description:
|
||||
'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)',
|
||||
'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode)',
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
type: 'boolean',
|
||||
@@ -280,16 +285,16 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
return true;
|
||||
});
|
||||
|
||||
if (settings?.experimental?.extensionManagement ?? true) {
|
||||
if (settings.experimental.extensionManagement) {
|
||||
yargsInstance.command(extensionsCommand);
|
||||
}
|
||||
|
||||
if (settings?.experimental?.skills ?? false) {
|
||||
if (settings.experimental.skills) {
|
||||
yargsInstance.command(skillsCommand);
|
||||
}
|
||||
|
||||
// Register hooks command if hooks are enabled
|
||||
if (getEnableHooksUI(settings)) {
|
||||
if (settings.tools.enableHooks) {
|
||||
yargsInstance.command(hooksCommand);
|
||||
}
|
||||
|
||||
@@ -392,7 +397,7 @@ export interface LoadCliConfigOptions {
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
settings: Settings,
|
||||
settings: MergedSettings,
|
||||
sessionId: string,
|
||||
argv: CliArgs,
|
||||
options: LoadCliConfigOptions = {},
|
||||
@@ -487,12 +492,20 @@ export async function loadCliConfig(
|
||||
case 'auto_edit':
|
||||
approvalMode = ApprovalMode.AUTO_EDIT;
|
||||
break;
|
||||
case 'plan':
|
||||
if (!(settings.experimental?.plan ?? false)) {
|
||||
throw new Error(
|
||||
'Approval mode "plan" is only available when experimental.plan is enabled.',
|
||||
);
|
||||
}
|
||||
approvalMode = ApprovalMode.PLAN;
|
||||
break;
|
||||
case 'default':
|
||||
approvalMode = ApprovalMode.DEFAULT;
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, default`,
|
||||
`Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, plan, default`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -573,6 +586,11 @@ export async function loadCliConfig(
|
||||
);
|
||||
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
// In plan non-interactive mode, all tools that require approval are excluded.
|
||||
// TODO(#16625): Replace this default exclusion logic with specific rules for plan mode.
|
||||
extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter));
|
||||
break;
|
||||
case ApprovalMode.DEFAULT:
|
||||
// In default non-interactive mode, all tools that require approval are excluded.
|
||||
extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter));
|
||||
@@ -590,10 +608,7 @@ export async function loadCliConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const excludeTools = mergeExcludeTools(
|
||||
settings,
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
);
|
||||
const excludeTools = mergeExcludeTools(settings, extraExcludes);
|
||||
|
||||
// Create a settings object that includes CLI overrides for policy generation
|
||||
const effectiveSettings: Settings = {
|
||||
@@ -742,15 +757,17 @@ export async function loadCliConfig(
|
||||
disableLLMCorrection: settings.tools?.disableLLMCorrection,
|
||||
modelConfigServiceConfig: settings.modelConfigs,
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks: getEnableHooks(settings),
|
||||
enableHooksUI: getEnableHooksUI(settings),
|
||||
enableHooks:
|
||||
(settings.tools?.enableHooks ?? true) &&
|
||||
(settings.hooks?.enabled ?? false),
|
||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
||||
hooks: settings.hooks || {},
|
||||
projectHooks: projectHooks || {},
|
||||
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
|
||||
onReload: async () => {
|
||||
const refreshedSettings = loadSettings(cwd);
|
||||
return {
|
||||
disabledSkills: refreshedSettings.merged.skills?.disabled,
|
||||
disabledSkills: refreshedSettings.merged.skills.disabled,
|
||||
agents: refreshedSettings.merged.agents,
|
||||
};
|
||||
},
|
||||
@@ -758,12 +775,12 @@ export async function loadCliConfig(
|
||||
}
|
||||
|
||||
function mergeExcludeTools(
|
||||
settings: Settings,
|
||||
extraExcludes?: string[] | undefined,
|
||||
settings: MergedSettings,
|
||||
extraExcludes: string[] = [],
|
||||
): string[] {
|
||||
const allExcludeTools = new Set([
|
||||
...(settings.tools?.exclude || []),
|
||||
...(extraExcludes || []),
|
||||
...(settings.tools.exclude || []),
|
||||
...extraExcludes,
|
||||
]);
|
||||
return [...allExcludeTools];
|
||||
return Array.from(allExcludeTools);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { type Settings } from './settings.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
|
||||
@@ -52,10 +52,9 @@ describe('ExtensionManager agents loading', () => {
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
settings: {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
trustedFolders: [tempDir],
|
||||
} as unknown as Settings,
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn(),
|
||||
workspaceDir: tempDir,
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import {
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
@@ -105,14 +105,10 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
workspaceDir: tempWorkspace,
|
||||
requestConsent: async () => true,
|
||||
requestSetting: async () => '',
|
||||
settings: {
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
},
|
||||
experimental: {
|
||||
extensionConfig: true,
|
||||
},
|
||||
} as Settings,
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
}),
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
@@ -147,14 +143,10 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
workspaceDir: tempWorkspace,
|
||||
requestConsent: async () => true,
|
||||
requestSetting: async () => '',
|
||||
settings: {
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
},
|
||||
experimental: {
|
||||
extensionConfig: true,
|
||||
},
|
||||
} as Settings,
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
}),
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
@@ -187,14 +179,10 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
workspaceDir: tempWorkspace,
|
||||
requestConsent: async () => true,
|
||||
requestSetting: async () => '',
|
||||
settings: {
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
},
|
||||
experimental: {
|
||||
extensionConfig: true,
|
||||
},
|
||||
} as Settings,
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
}),
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import { type Settings } from './settings.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
|
||||
@@ -58,10 +58,9 @@ describe('ExtensionManager skills validation', () => {
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
settings: {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
trustedFolders: [tempDir],
|
||||
} as unknown as Settings,
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn(),
|
||||
workspaceDir: tempDir,
|
||||
@@ -134,10 +133,9 @@ describe('ExtensionManager skills validation', () => {
|
||||
|
||||
// 3. Create a fresh ExtensionManager to force loading from disk
|
||||
const newExtensionManager = new ExtensionManager({
|
||||
settings: {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
trustedFolders: [tempDir],
|
||||
} as unknown as Settings,
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn(),
|
||||
workspaceDir: tempDir,
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as path from 'node:path';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import chalk from 'chalk';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import { type Settings, SettingScope } from './settings.js';
|
||||
import { type MergedSettings, SettingScope } from './settings.js';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
|
||||
import {
|
||||
@@ -68,11 +68,10 @@ import {
|
||||
ExtensionSettingScope,
|
||||
} from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
import { getEnableHooks } from './settingsSchema.js';
|
||||
|
||||
interface ExtensionManagerParams {
|
||||
enabledExtensionOverrides?: string[];
|
||||
settings: Settings;
|
||||
settings: MergedSettings;
|
||||
requestConsent: (consent: string) => Promise<boolean>;
|
||||
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
|
||||
workspaceDir: string;
|
||||
@@ -86,7 +85,7 @@ interface ExtensionManagerParams {
|
||||
*/
|
||||
export class ExtensionManager extends ExtensionLoader {
|
||||
private extensionEnablementManager: ExtensionEnablementManager;
|
||||
private settings: Settings;
|
||||
private settings: MergedSettings;
|
||||
private requestConsent: (consent: string) => Promise<boolean>;
|
||||
private requestSetting:
|
||||
| ((setting: ExtensionSetting) => Promise<string>)
|
||||
@@ -143,7 +142,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
if (
|
||||
(installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release') &&
|
||||
this.settings.security?.blockGitExtensions
|
||||
this.settings.security.blockGitExtensions
|
||||
) {
|
||||
throw new Error(
|
||||
'Installing extensions from remote sources is disallowed by your current settings.',
|
||||
@@ -287,10 +286,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
if (
|
||||
this.requestSetting &&
|
||||
(this.settings.experimental?.extensionConfig ?? false)
|
||||
) {
|
||||
if (this.requestSetting && this.settings.experimental.extensionConfig) {
|
||||
if (isUpdate) {
|
||||
await maybePromptForSettings(
|
||||
newExtensionConfig,
|
||||
@@ -308,14 +304,13 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
}
|
||||
|
||||
const missingSettings =
|
||||
(this.settings.experimental?.extensionConfig ?? false)
|
||||
? await getMissingSettings(
|
||||
newExtensionConfig,
|
||||
extensionId,
|
||||
this.workspaceDir,
|
||||
)
|
||||
: [];
|
||||
const missingSettings = this.settings.experimental.extensionConfig
|
||||
? await getMissingSettings(
|
||||
newExtensionConfig,
|
||||
extensionId,
|
||||
this.workspaceDir,
|
||||
)
|
||||
: [];
|
||||
if (missingSettings.length > 0) {
|
||||
const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings
|
||||
.map((s) => s.name)
|
||||
@@ -478,7 +473,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
throw new Error('Extensions already loaded, only load extensions once.');
|
||||
}
|
||||
|
||||
if (this.settings.admin?.extensions?.enabled === false) {
|
||||
if (this.settings.admin.extensions.enabled === false) {
|
||||
this.loadedExtensions = [];
|
||||
return this.loadedExtensions;
|
||||
}
|
||||
@@ -511,7 +506,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
if (
|
||||
(installMetadata?.type === 'git' ||
|
||||
installMetadata?.type === 'github-release') &&
|
||||
this.settings.security?.blockGitExtensions
|
||||
this.settings.security.blockGitExtensions
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -535,7 +530,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
let userSettings: Record<string, string> = {};
|
||||
let workspaceSettings: Record<string, string> = {};
|
||||
|
||||
if (this.settings.experimental?.extensionConfig ?? false) {
|
||||
if (this.settings.experimental.extensionConfig) {
|
||||
userSettings = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
@@ -553,10 +548,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
config = resolveEnvVarsInObject(config, customEnv);
|
||||
|
||||
const resolvedSettings: ResolvedExtensionSetting[] = [];
|
||||
if (
|
||||
config.settings &&
|
||||
(this.settings.experimental?.extensionConfig ?? false)
|
||||
) {
|
||||
if (config.settings && this.settings.experimental.extensionConfig) {
|
||||
for (const setting of config.settings) {
|
||||
const value = customEnv[setting.envVar];
|
||||
let scope: 'user' | 'workspace' | undefined;
|
||||
@@ -600,7 +592,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
|
||||
if (config.mcpServers) {
|
||||
if (this.settings.admin?.mcp?.enabled === false) {
|
||||
if (this.settings.admin.mcp.enabled === false) {
|
||||
config.mcpServers = undefined;
|
||||
} else {
|
||||
config.mcpServers = Object.fromEntries(
|
||||
@@ -619,7 +611,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
if (getEnableHooks(this.settings)) {
|
||||
if (this.settings.tools.enableHooks && this.settings.hooks.enabled) {
|
||||
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
|
||||
extensionPath: effectiveExtensionPath,
|
||||
workspacePath: this.workspaceDir,
|
||||
|
||||
@@ -26,7 +26,11 @@ import {
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadSettings, SettingScope } from './settings.js';
|
||||
import {
|
||||
loadSettings,
|
||||
createTestMergedSettings,
|
||||
SettingScope,
|
||||
} from './settings.js';
|
||||
import {
|
||||
isWorkspaceTrusted,
|
||||
resetTrustedFoldersForTesting,
|
||||
@@ -201,7 +205,7 @@ describe('extension tests', () => {
|
||||
});
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
(settings.experimental ??= {}).extensionConfig = true;
|
||||
settings.experimental.extensionConfig = true;
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
@@ -628,11 +632,9 @@ describe('extension tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const blockGitExtensionsSetting = {
|
||||
security: {
|
||||
blockGitExtensions: true,
|
||||
},
|
||||
};
|
||||
const blockGitExtensionsSetting = createTestMergedSettings({
|
||||
security: { blockGitExtensions: true },
|
||||
});
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
@@ -652,7 +654,6 @@ describe('extension tests', () => {
|
||||
version: '1.0.0',
|
||||
});
|
||||
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
|
||||
(loadedSettings.admin ??= {}).extensions ??= {};
|
||||
loadedSettings.admin.extensions.enabled = false;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
@@ -676,7 +677,6 @@ describe('extension tests', () => {
|
||||
},
|
||||
});
|
||||
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
|
||||
(loadedSettings.admin ??= {}).mcp ??= {};
|
||||
loadedSettings.admin.mcp.enabled = false;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
@@ -701,7 +701,6 @@ describe('extension tests', () => {
|
||||
},
|
||||
});
|
||||
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
|
||||
(loadedSettings.admin ??= {}).mcp ??= {};
|
||||
loadedSettings.admin.mcp.enabled = true;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
@@ -837,7 +836,6 @@ describe('extension tests', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
settings.hooks.enabled = true;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
@@ -873,7 +871,6 @@ describe('extension tests', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
settings.hooks.enabled = false;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
@@ -1098,11 +1095,9 @@ describe('extension tests', () => {
|
||||
|
||||
it('should not install a github extension if blockGitExtensions is set', async () => {
|
||||
const gitUrl = 'https://somehost.com/somerepo.git';
|
||||
const blockGitExtensionsSetting = {
|
||||
security: {
|
||||
blockGitExtensions: true,
|
||||
},
|
||||
};
|
||||
const blockGitExtensionsSetting = createTestMergedSettings({
|
||||
security: { blockGitExtensions: true },
|
||||
});
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
|
||||
@@ -11,7 +11,6 @@ import * as fs from 'node:fs';
|
||||
import { getMissingSettings } from './extensionSettings.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
import type { Settings } from '../settings.js';
|
||||
import {
|
||||
KeychainTokenStorage,
|
||||
debugLogger,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
||||
import { ExtensionManager } from '../extension-manager.js';
|
||||
import { createTestMergedSettings } from '../settings.js';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -247,12 +247,10 @@ describe('extensionUpdates', () => {
|
||||
const manager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
|
||||
settings: {
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
},
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
} as unknown as Settings,
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null, // Simulate non-interactive
|
||||
});
|
||||
|
||||
@@ -8,87 +8,79 @@
|
||||
* Command enum for all available keyboard shortcuts
|
||||
*/
|
||||
export enum Command {
|
||||
// Basic bindings
|
||||
RETURN = 'return',
|
||||
ESCAPE = 'escape',
|
||||
// Basic Controls
|
||||
RETURN = 'basic.confirm',
|
||||
ESCAPE = 'basic.cancel',
|
||||
QUIT = 'basic.quit',
|
||||
EXIT = 'basic.exit',
|
||||
|
||||
// Cursor movement
|
||||
HOME = 'home',
|
||||
END = 'end',
|
||||
// Cursor Movement
|
||||
HOME = 'cursor.home',
|
||||
END = 'cursor.end',
|
||||
MOVE_UP = 'cursor.up',
|
||||
MOVE_DOWN = 'cursor.down',
|
||||
MOVE_LEFT = 'cursor.left',
|
||||
MOVE_RIGHT = 'cursor.right',
|
||||
MOVE_WORD_LEFT = 'cursor.wordLeft',
|
||||
MOVE_WORD_RIGHT = 'cursor.wordRight',
|
||||
|
||||
// Text deletion
|
||||
KILL_LINE_RIGHT = 'killLineRight',
|
||||
KILL_LINE_LEFT = 'killLineLeft',
|
||||
CLEAR_INPUT = 'clearInput',
|
||||
DELETE_WORD_BACKWARD = 'deleteWordBackward',
|
||||
|
||||
// Screen control
|
||||
CLEAR_SCREEN = 'clearScreen',
|
||||
// Editing
|
||||
KILL_LINE_RIGHT = 'edit.deleteRightAll',
|
||||
KILL_LINE_LEFT = 'edit.deleteLeftAll',
|
||||
CLEAR_INPUT = 'edit.clear',
|
||||
DELETE_WORD_BACKWARD = 'edit.deleteWordLeft',
|
||||
DELETE_WORD_FORWARD = 'edit.deleteWordRight',
|
||||
DELETE_CHAR_LEFT = 'edit.deleteLeft',
|
||||
DELETE_CHAR_RIGHT = 'edit.deleteRight',
|
||||
UNDO = 'edit.undo',
|
||||
REDO = 'edit.redo',
|
||||
|
||||
// Scrolling
|
||||
SCROLL_UP = 'scrollUp',
|
||||
SCROLL_DOWN = 'scrollDown',
|
||||
SCROLL_HOME = 'scrollHome',
|
||||
SCROLL_END = 'scrollEnd',
|
||||
PAGE_UP = 'pageUp',
|
||||
PAGE_DOWN = 'pageDown',
|
||||
SCROLL_UP = 'scroll.up',
|
||||
SCROLL_DOWN = 'scroll.down',
|
||||
SCROLL_HOME = 'scroll.home',
|
||||
SCROLL_END = 'scroll.end',
|
||||
PAGE_UP = 'scroll.pageUp',
|
||||
PAGE_DOWN = 'scroll.pageDown',
|
||||
|
||||
// History navigation
|
||||
HISTORY_UP = 'historyUp',
|
||||
HISTORY_DOWN = 'historyDown',
|
||||
NAVIGATION_UP = 'navigationUp',
|
||||
NAVIGATION_DOWN = 'navigationDown',
|
||||
// History & Search
|
||||
HISTORY_UP = 'history.previous',
|
||||
HISTORY_DOWN = 'history.next',
|
||||
REVERSE_SEARCH = 'history.search.start',
|
||||
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
|
||||
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
|
||||
|
||||
// Dialog navigation
|
||||
DIALOG_NAVIGATION_UP = 'dialogNavigationUp',
|
||||
DIALOG_NAVIGATION_DOWN = 'dialogNavigationDown',
|
||||
// Navigation
|
||||
NAVIGATION_UP = 'nav.up',
|
||||
NAVIGATION_DOWN = 'nav.down',
|
||||
DIALOG_NAVIGATION_UP = 'nav.dialog.up',
|
||||
DIALOG_NAVIGATION_DOWN = 'nav.dialog.down',
|
||||
|
||||
// Auto-completion
|
||||
ACCEPT_SUGGESTION = 'acceptSuggestion',
|
||||
COMPLETION_UP = 'completionUp',
|
||||
COMPLETION_DOWN = 'completionDown',
|
||||
// Suggestions & Completions
|
||||
ACCEPT_SUGGESTION = 'suggest.accept',
|
||||
COMPLETION_UP = 'suggest.focusPrevious',
|
||||
COMPLETION_DOWN = 'suggest.focusNext',
|
||||
EXPAND_SUGGESTION = 'suggest.expand',
|
||||
COLLAPSE_SUGGESTION = 'suggest.collapse',
|
||||
|
||||
// Text input
|
||||
SUBMIT = 'submit',
|
||||
NEWLINE = 'newline',
|
||||
// Text Input
|
||||
SUBMIT = 'input.submit',
|
||||
NEWLINE = 'input.newline',
|
||||
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
||||
PASTE_CLIPBOARD = 'input.paste',
|
||||
|
||||
// External tools
|
||||
OPEN_EXTERNAL_EDITOR = 'openExternalEditor',
|
||||
PASTE_CLIPBOARD = 'pasteClipboard',
|
||||
|
||||
// App level bindings
|
||||
SHOW_ERROR_DETAILS = 'showErrorDetails',
|
||||
SHOW_FULL_TODOS = 'showFullTodos',
|
||||
SHOW_IDE_CONTEXT_DETAIL = 'showIDEContextDetail',
|
||||
TOGGLE_MARKDOWN = 'toggleMarkdown',
|
||||
TOGGLE_COPY_MODE = 'toggleCopyMode',
|
||||
TOGGLE_YOLO = 'toggleYolo',
|
||||
TOGGLE_AUTO_EDIT = 'toggleAutoEdit',
|
||||
UNDO = 'undo',
|
||||
REDO = 'redo',
|
||||
MOVE_UP = 'moveUp',
|
||||
MOVE_DOWN = 'moveDown',
|
||||
MOVE_LEFT = 'moveLeft',
|
||||
MOVE_RIGHT = 'moveRight',
|
||||
MOVE_WORD_LEFT = 'moveWordLeft',
|
||||
MOVE_WORD_RIGHT = 'moveWordRight',
|
||||
DELETE_CHAR_LEFT = 'deleteCharLeft',
|
||||
DELETE_CHAR_RIGHT = 'deleteCharRight',
|
||||
DELETE_WORD_FORWARD = 'deleteWordForward',
|
||||
QUIT = 'quit',
|
||||
EXIT = 'exit',
|
||||
SHOW_MORE_LINES = 'showMoreLines',
|
||||
|
||||
// Shell commands
|
||||
REVERSE_SEARCH = 'reverseSearch',
|
||||
SUBMIT_REVERSE_SEARCH = 'submitReverseSearch',
|
||||
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch',
|
||||
FOCUS_SHELL_INPUT = 'focusShellInput',
|
||||
UNFOCUS_SHELL_INPUT = 'unfocusShellInput',
|
||||
|
||||
// Suggestion expansion
|
||||
EXPAND_SUGGESTION = 'expandSuggestion',
|
||||
COLLAPSE_SUGGESTION = 'collapseSuggestion',
|
||||
// App Controls
|
||||
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
|
||||
SHOW_FULL_TODOS = 'app.showFullTodos',
|
||||
SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail',
|
||||
TOGGLE_MARKDOWN = 'app.toggleMarkdown',
|
||||
TOGGLE_COPY_MODE = 'app.toggleCopyMode',
|
||||
TOGGLE_YOLO = 'app.toggleYolo',
|
||||
TOGGLE_AUTO_EDIT = 'app.toggleAutoEdit',
|
||||
SHOW_MORE_LINES = 'app.showMoreLines',
|
||||
FOCUS_SHELL_INPUT = 'app.focusShellInput',
|
||||
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
|
||||
CLEAR_SCREEN = 'app.clearScreen',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,24 +109,17 @@ export type KeyBindingConfig = {
|
||||
* Matches the original hard-coded logic exactly
|
||||
*/
|
||||
export const defaultKeyBindings: KeyBindingConfig = {
|
||||
// Basic bindings
|
||||
// Basic Controls
|
||||
[Command.RETURN]: [{ key: 'return' }],
|
||||
[Command.ESCAPE]: [{ key: 'escape' }],
|
||||
[Command.QUIT]: [{ key: 'c', ctrl: true }],
|
||||
[Command.EXIT]: [{ key: 'd', ctrl: true }],
|
||||
|
||||
// Cursor movement
|
||||
// Cursor Movement
|
||||
[Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
|
||||
[Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
|
||||
|
||||
// Text deletion
|
||||
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
|
||||
[Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
|
||||
[Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
|
||||
// Added command (meta/alt/option) for mac compatibility
|
||||
[Command.DELETE_WORD_BACKWARD]: [
|
||||
{ key: 'backspace', ctrl: true },
|
||||
{ key: 'backspace', command: true },
|
||||
{ key: 'w', ctrl: true },
|
||||
],
|
||||
[Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }],
|
||||
[Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }],
|
||||
[Command.MOVE_LEFT]: [
|
||||
{ key: 'left', ctrl: false, command: false },
|
||||
{ key: 'b', ctrl: true },
|
||||
@@ -143,8 +128,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'right', ctrl: false, command: false },
|
||||
{ key: 'f', ctrl: true },
|
||||
],
|
||||
[Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }],
|
||||
[Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }],
|
||||
[Command.MOVE_WORD_LEFT]: [
|
||||
{ key: 'left', ctrl: true },
|
||||
{ key: 'left', command: true },
|
||||
@@ -155,15 +138,25 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'right', command: true },
|
||||
{ key: 'f', command: true },
|
||||
],
|
||||
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
|
||||
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
|
||||
|
||||
// Editing
|
||||
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
|
||||
[Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
|
||||
[Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
|
||||
// Added command (meta/alt/option) for mac compatibility
|
||||
[Command.DELETE_WORD_BACKWARD]: [
|
||||
{ key: 'backspace', ctrl: true },
|
||||
{ key: 'backspace', command: true },
|
||||
{ key: 'w', ctrl: true },
|
||||
],
|
||||
[Command.DELETE_WORD_FORWARD]: [
|
||||
{ key: 'delete', ctrl: true },
|
||||
{ key: 'delete', command: true },
|
||||
],
|
||||
|
||||
// Screen control
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
|
||||
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
|
||||
[Command.UNDO]: [{ key: 'z', ctrl: true, shift: false }],
|
||||
[Command.REDO]: [{ key: 'z', ctrl: true, shift: true }],
|
||||
|
||||
// Scrolling
|
||||
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
|
||||
@@ -173,13 +166,17 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.PAGE_UP]: [{ key: 'pageup' }],
|
||||
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
|
||||
|
||||
// History navigation
|
||||
// History & Search
|
||||
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
|
||||
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
|
||||
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
||||
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
||||
|
||||
// Navigation
|
||||
[Command.NAVIGATION_UP]: [{ key: 'up', shift: false }],
|
||||
[Command.NAVIGATION_DOWN]: [{ key: 'down', shift: false }],
|
||||
|
||||
// Dialog navigation
|
||||
// Navigation shortcuts appropriate for dialogs where we do not need to accept
|
||||
// text input.
|
||||
[Command.DIALOG_NAVIGATION_UP]: [
|
||||
@@ -191,7 +188,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'j', shift: false },
|
||||
],
|
||||
|
||||
// Auto-completion
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
|
||||
// Completion navigation (arrow or Ctrl+P/N)
|
||||
[Command.COMPLETION_UP]: [
|
||||
@@ -202,8 +199,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'down', shift: false },
|
||||
{ key: 'n', ctrl: true, shift: false },
|
||||
],
|
||||
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
|
||||
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
|
||||
|
||||
// Text input
|
||||
// Text Input
|
||||
// Must also exclude shift to allow shift+enter for newline
|
||||
[Command.SUBMIT]: [
|
||||
{
|
||||
@@ -221,15 +220,13 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'return', shift: true },
|
||||
{ key: 'j', ctrl: true },
|
||||
],
|
||||
|
||||
// External tools
|
||||
[Command.OPEN_EXTERNAL_EDITOR]: [{ key: 'x', ctrl: true }],
|
||||
[Command.PASTE_CLIPBOARD]: [
|
||||
{ key: 'v', ctrl: true },
|
||||
{ key: 'v', command: true },
|
||||
],
|
||||
|
||||
// App level bindings
|
||||
// App Controls
|
||||
[Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }],
|
||||
[Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }],
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }],
|
||||
@@ -237,22 +234,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
|
||||
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
|
||||
[Command.TOGGLE_AUTO_EDIT]: [{ key: 'tab', shift: true }],
|
||||
[Command.UNDO]: [{ key: 'z', ctrl: true, shift: false }],
|
||||
[Command.REDO]: [{ key: 'z', ctrl: true, shift: true }],
|
||||
[Command.QUIT]: [{ key: 'c', ctrl: true }],
|
||||
[Command.EXIT]: [{ key: 'd', ctrl: true }],
|
||||
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
|
||||
|
||||
// Shell commands
|
||||
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
||||
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
|
||||
// Suggestion expansion
|
||||
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
|
||||
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
};
|
||||
|
||||
interface CommandCategory {
|
||||
@@ -266,7 +251,7 @@ interface CommandCategory {
|
||||
export const commandCategories: readonly CommandCategory[] = [
|
||||
{
|
||||
title: 'Basic Controls',
|
||||
commands: [Command.RETURN, Command.ESCAPE],
|
||||
commands: [Command.RETURN, Command.ESCAPE, Command.QUIT, Command.EXIT],
|
||||
},
|
||||
{
|
||||
title: 'Cursor Movement',
|
||||
@@ -295,10 +280,6 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.REDO,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Screen Control',
|
||||
commands: [Command.CLEAR_SCREEN],
|
||||
},
|
||||
{
|
||||
title: 'Scrolling',
|
||||
commands: [
|
||||
@@ -341,11 +322,12 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
},
|
||||
{
|
||||
title: 'Text Input',
|
||||
commands: [Command.SUBMIT, Command.NEWLINE],
|
||||
},
|
||||
{
|
||||
title: 'External Tools',
|
||||
commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD],
|
||||
commands: [
|
||||
Command.SUBMIT,
|
||||
Command.NEWLINE,
|
||||
Command.OPEN_EXTERNAL_EDITOR,
|
||||
Command.PASTE_CLIPBOARD,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'App Controls',
|
||||
@@ -360,28 +342,33 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.SHOW_MORE_LINES,
|
||||
Command.FOCUS_SHELL_INPUT,
|
||||
Command.UNFOCUS_SHELL_INPUT,
|
||||
Command.CLEAR_SCREEN,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Session Control',
|
||||
commands: [Command.QUIT, Command.EXIT],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Human-readable descriptions for each command, used in docs/tooling.
|
||||
*/
|
||||
export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
// Basic Controls
|
||||
[Command.RETURN]: 'Confirm the current selection or choice.',
|
||||
[Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.',
|
||||
[Command.QUIT]:
|
||||
'Cancel the current request or quit the CLI when input is empty.',
|
||||
[Command.EXIT]: 'Exit the CLI when the input buffer is empty.',
|
||||
|
||||
// Cursor Movement
|
||||
[Command.HOME]: 'Move the cursor to the start of the line.',
|
||||
[Command.END]: 'Move the cursor to the end of the line.',
|
||||
[Command.MOVE_LEFT]: 'Move the cursor one character to the left.',
|
||||
[Command.MOVE_RIGHT]: 'Move the cursor one character to the right.',
|
||||
[Command.MOVE_UP]: 'Move the cursor up one line.',
|
||||
[Command.MOVE_DOWN]: 'Move the cursor down one line.',
|
||||
[Command.MOVE_LEFT]: 'Move the cursor one character to the left.',
|
||||
[Command.MOVE_RIGHT]: 'Move the cursor one character to the right.',
|
||||
[Command.MOVE_WORD_LEFT]: 'Move the cursor one word to the left.',
|
||||
[Command.MOVE_WORD_RIGHT]: 'Move the cursor one word to the right.',
|
||||
|
||||
// Editing
|
||||
[Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.',
|
||||
[Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.',
|
||||
[Command.CLEAR_INPUT]: 'Clear all text in the input field.',
|
||||
@@ -391,45 +378,54 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.DELETE_CHAR_RIGHT]: 'Delete the character to the right.',
|
||||
[Command.UNDO]: 'Undo the most recent text edit.',
|
||||
[Command.REDO]: 'Redo the most recent undone text edit.',
|
||||
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
||||
|
||||
// Scrolling
|
||||
[Command.SCROLL_UP]: 'Scroll content up.',
|
||||
[Command.SCROLL_DOWN]: 'Scroll content down.',
|
||||
[Command.SCROLL_HOME]: 'Scroll to the top.',
|
||||
[Command.SCROLL_END]: 'Scroll to the bottom.',
|
||||
[Command.PAGE_UP]: 'Scroll up by one page.',
|
||||
[Command.PAGE_DOWN]: 'Scroll down by one page.',
|
||||
|
||||
// History & Search
|
||||
[Command.HISTORY_UP]: 'Show the previous entry in history.',
|
||||
[Command.HISTORY_DOWN]: 'Show the next entry in history.',
|
||||
[Command.REVERSE_SEARCH]: 'Start reverse search through history.',
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
|
||||
'Accept a suggestion while reverse searching.',
|
||||
|
||||
// Navigation
|
||||
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
|
||||
[Command.NAVIGATION_DOWN]: 'Move selection down in lists.',
|
||||
[Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.',
|
||||
[Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.',
|
||||
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.',
|
||||
[Command.COMPLETION_UP]: 'Move to the previous completion option.',
|
||||
[Command.COMPLETION_DOWN]: 'Move to the next completion option.',
|
||||
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
|
||||
[Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.',
|
||||
|
||||
// Text Input
|
||||
[Command.SUBMIT]: 'Submit the current prompt.',
|
||||
[Command.NEWLINE]: 'Insert a newline without submitting.',
|
||||
[Command.OPEN_EXTERNAL_EDITOR]:
|
||||
'Open the current prompt in an external editor.',
|
||||
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
|
||||
|
||||
// App Controls
|
||||
[Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.',
|
||||
[Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.',
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
|
||||
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
|
||||
[Command.TOGGLE_COPY_MODE]:
|
||||
'Toggle copy mode when the terminal is using the alternate buffer.',
|
||||
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
|
||||
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
|
||||
[Command.TOGGLE_AUTO_EDIT]: 'Toggle Auto Edit (auto-accept edits) mode.',
|
||||
[Command.QUIT]: 'Cancel the current request or quit the CLI.',
|
||||
[Command.EXIT]: 'Exit the CLI when the input buffer is empty.',
|
||||
[Command.SHOW_MORE_LINES]:
|
||||
'Expand a height-constrained response to show additional lines.',
|
||||
[Command.REVERSE_SEARCH]: 'Start reverse search through history.',
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
|
||||
'Accept a suggestion while reverse searching.',
|
||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
|
||||
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
|
||||
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
|
||||
[Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.',
|
||||
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
||||
};
|
||||
|
||||
@@ -287,6 +287,43 @@ describe('Policy Engine Integration Tests', () => {
|
||||
).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should handle Plan mode correctly', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Read and search tools should be allowed
|
||||
expect(
|
||||
(await engine.check({ name: 'read_file' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
expect(
|
||||
(await engine.check({ name: 'google_web_search' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
expect(
|
||||
(await engine.check({ name: 'list_directory' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Other tools should be denied via catch all
|
||||
expect(
|
||||
(await engine.check({ name: 'replace' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
expect(
|
||||
(await engine.check({ name: 'write_file' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
expect(
|
||||
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Unknown tools should be denied via catch-all
|
||||
expect(
|
||||
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should verify priority ordering works correctly in practice', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
|
||||
@@ -24,12 +24,24 @@ import { DefaultDark } from '../ui/themes/default.js';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import {
|
||||
type Settings,
|
||||
type MergedSettings,
|
||||
type MemoryImportFormat,
|
||||
type MergeStrategy,
|
||||
type SettingsSchema,
|
||||
type SettingDefinition,
|
||||
getSettingsSchema,
|
||||
} from './settingsSchema.js';
|
||||
|
||||
export {
|
||||
type Settings,
|
||||
type MergedSettings,
|
||||
type MemoryImportFormat,
|
||||
type MergeStrategy,
|
||||
type SettingsSchema,
|
||||
type SettingDefinition,
|
||||
getSettingsSchema,
|
||||
};
|
||||
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { customDeepMerge } from '../utils/deepMerge.js';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
@@ -59,8 +71,6 @@ function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
return current?.mergeStrategy;
|
||||
}
|
||||
|
||||
export type { Settings, MemoryImportFormat };
|
||||
|
||||
export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
||||
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
@@ -201,10 +211,7 @@ export function getDefaultsFromSchema(
|
||||
for (const key in schema) {
|
||||
const definition = schema[key];
|
||||
if (definition.properties) {
|
||||
const childDefaults = getDefaultsFromSchema(definition.properties);
|
||||
if (Object.keys(childDefaults).length > 0) {
|
||||
defaults[key] = childDefaults;
|
||||
}
|
||||
defaults[key] = getDefaultsFromSchema(definition.properties);
|
||||
} else if (definition.default !== undefined) {
|
||||
defaults[key] = definition.default;
|
||||
}
|
||||
@@ -212,13 +219,13 @@ export function getDefaultsFromSchema(
|
||||
return defaults as Settings;
|
||||
}
|
||||
|
||||
function mergeSettings(
|
||||
export function mergeSettings(
|
||||
system: Settings,
|
||||
systemDefaults: Settings,
|
||||
user: Settings,
|
||||
workspace: Settings,
|
||||
isTrusted: boolean,
|
||||
): Settings {
|
||||
): MergedSettings {
|
||||
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
|
||||
const schemaDefaults = getDefaultsFromSchema();
|
||||
|
||||
@@ -236,7 +243,24 @@ function mergeSettings(
|
||||
user,
|
||||
safeWorkspace,
|
||||
system,
|
||||
) as Settings;
|
||||
) as MergedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fully populated MergedSettings object for testing purposes.
|
||||
* It merges the provided overrides with the default settings from the schema.
|
||||
*
|
||||
* @param overrides Partial settings to override the defaults.
|
||||
* @returns A complete MergedSettings object.
|
||||
*/
|
||||
export function createTestMergedSettings(
|
||||
overrides: Partial<Settings> = {},
|
||||
): MergedSettings {
|
||||
return customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
getDefaultsFromSchema(),
|
||||
overrides,
|
||||
) as MergedSettings;
|
||||
}
|
||||
|
||||
export class LoadedSettings {
|
||||
@@ -264,14 +288,14 @@ export class LoadedSettings {
|
||||
readonly isTrusted: boolean;
|
||||
readonly errors: SettingsError[];
|
||||
|
||||
private _merged: Settings;
|
||||
private _merged: MergedSettings;
|
||||
private _remoteAdminSettings: Partial<Settings> | undefined;
|
||||
|
||||
get merged(): Settings {
|
||||
get merged(): MergedSettings {
|
||||
return this._merged;
|
||||
}
|
||||
|
||||
private computeMergedSettings(): Settings {
|
||||
private computeMergedSettings(): MergedSettings {
|
||||
const merged = mergeSettings(
|
||||
this.system.settings,
|
||||
this.systemDefaults.settings,
|
||||
@@ -293,7 +317,7 @@ export class LoadedSettings {
|
||||
(path: string[]) => getMergeStrategyForPath(['admin', ...path]),
|
||||
adminDefaults,
|
||||
this._remoteAdminSettings?.admin ?? {},
|
||||
) as Settings['admin'];
|
||||
) as MergedSettings['admin'];
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
BugCommandSettings,
|
||||
TelemetrySettings,
|
||||
AuthType,
|
||||
AgentOverride,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
@@ -799,7 +800,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Agent Overrides',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
default: {} as Record<string, AgentOverride>,
|
||||
description:
|
||||
'Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.',
|
||||
showInDialog: false,
|
||||
@@ -2092,6 +2093,10 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
},
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to enable the agent.',
|
||||
},
|
||||
disabled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to disable the agent.',
|
||||
@@ -2262,12 +2267,17 @@ type InferSettings<T extends SettingsSchema> = {
|
||||
: T[K]['default'];
|
||||
};
|
||||
|
||||
type InferMergedSettings<T extends SettingsSchema> = {
|
||||
-readonly [K in keyof T]-?: T[K] extends { properties: SettingsSchema }
|
||||
? InferMergedSettings<T[K]['properties']>
|
||||
: T[K]['type'] extends 'enum'
|
||||
? T[K]['options'] extends readonly SettingEnumOption[]
|
||||
? T[K]['options'][number]['value']
|
||||
: T[K]['default']
|
||||
: T[K]['default'] extends boolean
|
||||
? boolean
|
||||
: T[K]['default'];
|
||||
};
|
||||
|
||||
export type Settings = InferSettings<SettingsSchemaType>;
|
||||
|
||||
export function getEnableHooksUI(settings: Settings): boolean {
|
||||
return settings.tools?.enableHooks ?? true;
|
||||
}
|
||||
|
||||
export function getEnableHooks(settings: Settings): boolean {
|
||||
return getEnableHooksUI(settings) && (settings.hooks?.enabled ?? false);
|
||||
}
|
||||
export type MergedSettings = InferMergedSettings<SettingsSchemaType>;
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('initializer', () => {
|
||||
});
|
||||
|
||||
it('should handle undefined auth type', async () => {
|
||||
mockSettings.merged.security!.auth!.selectedType = undefined;
|
||||
mockSettings.merged.security.auth.selectedType = undefined;
|
||||
const result = await initializeApp(
|
||||
mockConfig as unknown as Config,
|
||||
mockSettings,
|
||||
|
||||
@@ -39,13 +39,13 @@ export async function initializeApp(
|
||||
const authHandle = startupProfiler.start('authenticate');
|
||||
const authError = await performInitialAuth(
|
||||
config,
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
authHandle?.end();
|
||||
const themeError = validateTheme(settings);
|
||||
|
||||
const shouldOpenAuthDialog =
|
||||
settings.merged.security?.auth?.selectedType === undefined || !!authError;
|
||||
settings.merged.security.auth.selectedType === undefined || !!authError;
|
||||
|
||||
logCliConfiguration(
|
||||
config,
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('theme', () => {
|
||||
});
|
||||
|
||||
it('should return null if theme is undefined', () => {
|
||||
mockSettings.merged.ui!.theme = undefined;
|
||||
mockSettings.merged.ui.theme = undefined;
|
||||
const result = validateTheme(mockSettings);
|
||||
expect(result).toBeNull();
|
||||
expect(themeManager.findThemeByName).not.toHaveBeenCalled();
|
||||
|
||||
@@ -13,7 +13,7 @@ import { type LoadedSettings } from '../config/settings.js';
|
||||
* @returns An error message if the theme is not found, otherwise null.
|
||||
*/
|
||||
export function validateTheme(settings: LoadedSettings): string | null {
|
||||
const effectiveTheme = settings.merged.ui?.theme;
|
||||
const effectiveTheme = settings.merged.ui.theme;
|
||||
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
|
||||
return `Theme "${effectiveTheme}" not found.`;
|
||||
}
|
||||
|
||||
+127
-108
@@ -23,8 +23,30 @@ import {
|
||||
import os from 'node:os';
|
||||
import v8 from 'node:v8';
|
||||
import { type CliArgs } from './config/config.js';
|
||||
import { type LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
type LoadedSettings,
|
||||
type Settings,
|
||||
createTestMergedSettings,
|
||||
} from './config/settings.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
|
||||
function createMockSettings(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): LoadedSettings {
|
||||
const merged = createTestMergedSettings(
|
||||
(overrides['merged'] as Partial<Settings>) || {},
|
||||
);
|
||||
|
||||
return {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
...overrides,
|
||||
merged,
|
||||
} as unknown as LoadedSettings;
|
||||
}
|
||||
import {
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
@@ -108,26 +130,19 @@ class MockProcessExitError extends Error {
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./config/settings.js', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
}),
|
||||
migrateDeprecatedSettings: vi.fn(),
|
||||
SettingScope: {
|
||||
User: 'user',
|
||||
Workspace: 'workspace',
|
||||
System: 'system',
|
||||
SystemDefaults: 'system-defaults',
|
||||
},
|
||||
}));
|
||||
vi.mock('./config/settings.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn().mockImplementation(() => ({
|
||||
merged: actual.getDefaultsFromSchema(),
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
})),
|
||||
saveModelChange: vi.fn(),
|
||||
getDefaultsFromSchema: actual.getDefaultsFromSchema,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({
|
||||
terminalCapabilityManager: {
|
||||
@@ -443,17 +458,15 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
errors: [],
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
} as never);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
model: undefined,
|
||||
sandbox: undefined,
|
||||
@@ -505,17 +518,18 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
@@ -594,17 +608,18 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockConfig = {
|
||||
isInteractive: () => false,
|
||||
@@ -665,17 +680,18 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: { theme: 'non-existent-theme' },
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: { theme: 'non-existent-theme' },
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
@@ -753,13 +769,14 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
@@ -839,13 +856,14 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
@@ -918,13 +936,14 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
@@ -1034,10 +1053,11 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({} as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { security: { auth: {} }, ui: {} },
|
||||
errors: [],
|
||||
} as never);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: { security: { auth: {} }, ui: {} },
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: true,
|
||||
} as unknown as CliArgs);
|
||||
@@ -1066,14 +1086,13 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
security: { auth: { selectedType: 'google', useExternal: false } },
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
} as never);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: {
|
||||
security: { auth: { selectedType: 'google', useExternal: false } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
|
||||
vi.mock('./config/auth.js', () => ({
|
||||
validateAuthMethod: vi.fn().mockReturnValue(null),
|
||||
@@ -1131,11 +1150,11 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { security: { auth: {} }, ui: {} },
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
} as never);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: { security: { auth: {} }, ui: {} },
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
resume: 'invalid-session',
|
||||
} as unknown as CliArgs);
|
||||
@@ -1200,11 +1219,11 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
},
|
||||
getRemoteAdminSettings: () => undefined,
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: { security: { auth: {} }, ui: {} },
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
} as never);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: { security: { auth: {} }, ui: {} },
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true, // Simulate TTY so it doesn't try to read stdin
|
||||
|
||||
+18
-19
@@ -213,12 +213,12 @@ export async function startInteractiveUI(
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider
|
||||
config={config}
|
||||
debugKeystrokeLogging={settings.merged.general?.debugKeystrokeLogging}
|
||||
debugKeystrokeLogging={settings.merged.general.debugKeystrokeLogging}
|
||||
>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general?.debugKeystrokeLogging
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<ScrollProvider>
|
||||
@@ -263,8 +263,7 @@ export async function startInteractiveUI(
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
incrementalRendering:
|
||||
settings.merged.ui?.incrementalRendering !== false &&
|
||||
useAlternateBuffer,
|
||||
settings.merged.ui.incrementalRendering !== false && useAlternateBuffer,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -336,13 +335,13 @@ export async function main() {
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
dns.setDefaultResultOrder(
|
||||
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
|
||||
validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder),
|
||||
);
|
||||
|
||||
// Set a default auth type if one isn't set or is set to a legacy type
|
||||
if (
|
||||
!settings.merged.security?.auth?.selectedType ||
|
||||
settings.merged.security?.auth?.selectedType === AuthType.LEGACY_CLOUD_SHELL
|
||||
!settings.merged.security.auth.selectedType ||
|
||||
settings.merged.security.auth.selectedType === AuthType.LEGACY_CLOUD_SHELL
|
||||
) {
|
||||
if (
|
||||
process.env['CLOUD_SHELL'] === 'true' ||
|
||||
@@ -364,8 +363,8 @@ export async function main() {
|
||||
// the sandbox because the sandbox will interfere with the Oauth2 web
|
||||
// redirect.
|
||||
if (
|
||||
settings.merged.security?.auth?.selectedType &&
|
||||
!settings.merged.security?.auth?.useExternal
|
||||
settings.merged.security.auth.selectedType &&
|
||||
!settings.merged.security.auth.useExternal
|
||||
) {
|
||||
try {
|
||||
if (partialConfig.isInteractive()) {
|
||||
@@ -381,8 +380,8 @@ export async function main() {
|
||||
);
|
||||
} else {
|
||||
const authType = await validateNonInteractiveAuth(
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
settings.merged.security.auth.selectedType,
|
||||
settings.merged.security.auth.useExternal,
|
||||
partialConfig,
|
||||
settings,
|
||||
);
|
||||
@@ -403,7 +402,7 @@ export async function main() {
|
||||
|
||||
// hop into sandbox if we are outside and sandboxing is enabled
|
||||
if (!process.env['SANDBOX']) {
|
||||
const memoryArgs = settings.merged.advanced?.autoConfigureMemory
|
||||
const memoryArgs = settings.merged.advanced.autoConfigureMemory
|
||||
? getNodeMemoryArgs(isDebugMode)
|
||||
: [];
|
||||
const sandboxConfig = await loadSandboxConfig(settings.merged, argv);
|
||||
@@ -506,7 +505,7 @@ export async function main() {
|
||||
// Handle --list-sessions flag
|
||||
if (config.getListSessions()) {
|
||||
// Attempt auth for summary generation (gracefully skips if not configured)
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
const authType = settings.merged.security.auth.selectedType;
|
||||
if (authType) {
|
||||
try {
|
||||
await config.refreshAuth(authType);
|
||||
@@ -566,7 +565,7 @@ export async function main() {
|
||||
initAppHandle?.end();
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.selectedType ===
|
||||
settings.merged.security.auth.selectedType ===
|
||||
AuthType.LOGIN_WITH_GOOGLE &&
|
||||
config.isBrowserLaunchSuppressed()
|
||||
) {
|
||||
@@ -678,8 +677,8 @@ export async function main() {
|
||||
);
|
||||
|
||||
const authType = await validateNonInteractiveAuth(
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
settings.merged.security.auth.selectedType,
|
||||
settings.merged.security.auth.useExternal,
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
@@ -705,14 +704,14 @@ export async function main() {
|
||||
}
|
||||
|
||||
function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
if (!settings.merged.ui?.hideWindowTitle) {
|
||||
if (!settings.merged.ui.hideWindowTitle) {
|
||||
// Initial state before React loop starts
|
||||
const windowTitle = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
folderName: title,
|
||||
showThoughts: !!settings.merged.ui?.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true,
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
});
|
||||
writeToStdout(`\x1b]0;${windowTitle}\x07`);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { CommandContext } from '../ui/commands/types.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { mergeSettings } from '../config/settings.js';
|
||||
import type { GitService } from '@google/gemini-cli-core';
|
||||
import type { SessionStatsState } from '../ui/contexts/SessionContext.js';
|
||||
|
||||
@@ -27,6 +28,8 @@ type DeepPartial<T> = T extends object
|
||||
export const createMockCommandContext = (
|
||||
overrides: DeepPartial<CommandContext> = {},
|
||||
): CommandContext => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
|
||||
const defaultMocks: CommandContext = {
|
||||
invocation: {
|
||||
raw: '',
|
||||
@@ -35,7 +38,11 @@ export const createMockCommandContext = (
|
||||
},
|
||||
services: {
|
||||
config: null,
|
||||
settings: { merged: {} } as LoadedSettings,
|
||||
settings: {
|
||||
merged: defaultMergedSettings,
|
||||
setValue: vi.fn(),
|
||||
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
||||
} as unknown as LoadedSettings,
|
||||
git: undefined as GitService | undefined,
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('App', () => {
|
||||
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
|
||||
} as UIState;
|
||||
|
||||
mockLoadedSettings.merged.ui = { useAlternateBuffer: true };
|
||||
mockLoadedSettings.merged.ui.useAlternateBuffer = true;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, quittingUIState);
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('App', () => {
|
||||
expect(lastFrame()).toContain('Quitting...');
|
||||
|
||||
// Reset settings
|
||||
mockLoadedSettings.merged.ui = { useAlternateBuffer: false };
|
||||
mockLoadedSettings.merged.ui.useAlternateBuffer = false;
|
||||
});
|
||||
|
||||
it('should render dialog manager when dialogs are visible', () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { type LoadedSettings, mergeSettings } from '../config/settings.js';
|
||||
import type { InitializationResult } from '../core/initializer.js';
|
||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
@@ -380,14 +380,17 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
|
||||
// Mock LoadedSettings
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockSettings = {
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
hideBanner: false,
|
||||
hideFooter: false,
|
||||
hideTips: false,
|
||||
showMemoryUsage: false,
|
||||
theme: 'default',
|
||||
ui: {
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: false,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
@@ -507,8 +510,10 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
describe('Settings Integration', () => {
|
||||
it('handles settings with all display options disabled', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const settingsAllHidden = {
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
hideBanner: true,
|
||||
hideFooter: true,
|
||||
hideTips: true,
|
||||
@@ -526,8 +531,10 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('handles settings with memory usage enabled', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const settingsWithMemory = {
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
hideBanner: false,
|
||||
hideFooter: false,
|
||||
hideTips: false,
|
||||
@@ -574,7 +581,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
it('handles undefined settings gracefully', async () => {
|
||||
const undefinedSettings = {
|
||||
merged: {},
|
||||
merged: mergeSettings({}, {}, {}, {}, true),
|
||||
} as LoadedSettings;
|
||||
|
||||
let unmount: () => void;
|
||||
@@ -991,12 +998,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
it('should update terminal title with Working… when showStatusInTitle is false', () => {
|
||||
// Arrange: Set up mock settings with showStatusInTitle disabled
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const mockSettingsWithShowStatusFalse = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
...defaultMergedSettings,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: false,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
@@ -1073,12 +1081,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
it('should not update terminal title when hideWindowTitle is true', () => {
|
||||
// Arrange: Set up mock settings with hideWindowTitle enabled
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const mockSettingsWithHideTitleTrue = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
...defaultMergedSettings,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: true,
|
||||
},
|
||||
@@ -1101,12 +1110,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
it('should update terminal title with thought subject when in active state', () => {
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
...defaultMergedSettings,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
@@ -1143,12 +1153,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
it('should update terminal title with default text when in Idle state and no thought subject', () => {
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
...defaultMergedSettings,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
@@ -1184,12 +1195,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => {
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
...defaultMergedSettings,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
@@ -1392,12 +1404,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
it('should pad title to exactly 80 characters', () => {
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
...defaultMergedSettings,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
@@ -1435,12 +1448,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
it('should use correct ANSI escape code format', () => {
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
...defaultMergedSettings,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
@@ -1802,12 +1816,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
const setupCopyModeTest = async (isAlternateMode = false) => {
|
||||
// Update settings for this test run
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const testSettings = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
...defaultMergedSettings,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
...defaultMergedSettings.ui,
|
||||
useAlternateBuffer: isAlternateMode,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -392,8 +392,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}, []);
|
||||
|
||||
const getPreferredEditor = useCallback(
|
||||
() => settings.merged.general?.preferredEditor as EditorType,
|
||||
[settings.merged.general?.preferredEditor],
|
||||
() => settings.merged.general.preferredEditor as EditorType,
|
||||
[settings.merged.general.preferredEditor],
|
||||
);
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
@@ -443,7 +443,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!(settings.merged.ui?.hideBanner || config.getScreenReader()) &&
|
||||
!(settings.merged.ui.hideBanner || config.getScreenReader()) &&
|
||||
bannerVisible &&
|
||||
bannerText
|
||||
) {
|
||||
@@ -603,17 +603,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
// Check for enforced auth type mismatch
|
||||
useEffect(() => {
|
||||
if (
|
||||
settings.merged.security?.auth?.enforcedType &&
|
||||
settings.merged.security?.auth.selectedType &&
|
||||
settings.merged.security?.auth.enforcedType !==
|
||||
settings.merged.security?.auth.selectedType
|
||||
settings.merged.security.auth.enforcedType &&
|
||||
settings.merged.security.auth.selectedType &&
|
||||
settings.merged.security.auth.enforcedType !==
|
||||
settings.merged.security.auth.selectedType
|
||||
) {
|
||||
onAuthError(
|
||||
`Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`,
|
||||
`Authentication is enforced to be ${settings.merged.security.auth.enforcedType}, but you are currently using ${settings.merged.security.auth.selectedType}.`,
|
||||
);
|
||||
} else if (
|
||||
settings.merged.security?.auth?.selectedType &&
|
||||
!settings.merged.security?.auth?.useExternal
|
||||
settings.merged.security.auth.selectedType &&
|
||||
!settings.merged.security.auth.useExternal
|
||||
) {
|
||||
// We skip validation for Gemini API key here because it might be stored
|
||||
// in the keychain, which we can't check synchronously.
|
||||
@@ -630,9 +630,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}
|
||||
}, [
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.enforcedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
settings.merged.security.auth.selectedType,
|
||||
settings.merged.security.auth.enforcedType,
|
||||
settings.merged.security.auth.useExternal,
|
||||
onAuthError,
|
||||
]);
|
||||
|
||||
@@ -951,8 +951,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING),
|
||||
1,
|
||||
),
|
||||
pager: settings.merged.tools?.shell?.pager,
|
||||
showColor: settings.merged.tools?.shell?.showColor,
|
||||
pager: settings.merged.tools.shell.pager,
|
||||
showColor: settings.merged.tools.shell.showColor,
|
||||
sanitizationConfig: config.sanitizationConfig,
|
||||
});
|
||||
|
||||
@@ -960,13 +960,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
// Context file names computation
|
||||
const contextFileNames = useMemo(() => {
|
||||
const fromSettings = settings.merged.context?.fileName;
|
||||
const fromSettings = settings.merged.context.fileName;
|
||||
return fromSettings
|
||||
? Array.isArray(fromSettings)
|
||||
? fromSettings
|
||||
: [fromSettings]
|
||||
: getAllGeminiMdFilenames();
|
||||
}, [settings.merged.context?.fileName]);
|
||||
}, [settings.merged.context.fileName]);
|
||||
// Initial prompt handling
|
||||
const initialPrompt = useMemo(() => config.getQuestion(), [config]);
|
||||
const initialPromptSubmitted = useRef(false);
|
||||
@@ -1040,7 +1040,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const shouldShowIdePrompt = Boolean(
|
||||
currentIDE &&
|
||||
!config.getIdeMode() &&
|
||||
!settings.merged.ide?.hasSeenNudge &&
|
||||
!settings.merged.ide.hasSeenNudge &&
|
||||
!idePromptAnswered,
|
||||
);
|
||||
|
||||
@@ -1221,7 +1221,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
|
||||
streamingState,
|
||||
settings.merged.ui?.customWittyPhrases,
|
||||
settings.merged.ui.customWittyPhrases,
|
||||
!!activePtyId && !embeddedShellFocused,
|
||||
lastOutputTime,
|
||||
retryStatus,
|
||||
@@ -1237,7 +1237,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
|
||||
// Debug log keystrokes if enabled
|
||||
if (settings.merged.general?.debugKeystrokeLogging) {
|
||||
if (settings.merged.general.debugKeystrokeLogging) {
|
||||
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
|
||||
}
|
||||
|
||||
@@ -1337,7 +1337,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
cancelOngoingRequest,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
settings.merged.general?.debugKeystrokeLogging,
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
refreshStatic,
|
||||
setCopyModeEnabled,
|
||||
copyModeEnabled,
|
||||
@@ -1351,7 +1351,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
// Update terminal title with Gemini CLI status and thoughts
|
||||
useEffect(() => {
|
||||
// Respect hideWindowTitle settings
|
||||
if (settings.merged.ui?.hideWindowTitle) return;
|
||||
if (settings.merged.ui.hideWindowTitle) return;
|
||||
|
||||
const paddedTitle = computeTerminalTitle({
|
||||
streamingState,
|
||||
@@ -1361,8 +1361,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!!confirmationRequest ||
|
||||
showShellActionRequired,
|
||||
folderName: basename(config.getTargetDir()),
|
||||
showThoughts: !!settings.merged.ui?.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true,
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
});
|
||||
|
||||
// Only update the title if it's different from the last value we set
|
||||
@@ -1377,9 +1377,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
showShellActionRequired,
|
||||
settings.merged.ui?.showStatusInTitle,
|
||||
settings.merged.ui?.dynamicWindowTitle,
|
||||
settings.merged.ui?.hideWindowTitle,
|
||||
settings.merged.ui.showStatusInTitle,
|
||||
settings.merged.ui.dynamicWindowTitle,
|
||||
settings.merged.ui.hideWindowTitle,
|
||||
config,
|
||||
stdout,
|
||||
]);
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('filters auth types when enforcedType is set', () => {
|
||||
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
|
||||
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -160,7 +160,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('sets initial index to 0 when enforcedType is set', () => {
|
||||
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
|
||||
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(initialIndex).toBe(0);
|
||||
@@ -170,7 +170,7 @@ describe('AuthDialog', () => {
|
||||
it.each([
|
||||
{
|
||||
setup: () => {
|
||||
props.settings.merged.security!.auth!.selectedType =
|
||||
props.settings.merged.security.auth.selectedType =
|
||||
AuthType.USE_VERTEX_AI;
|
||||
},
|
||||
expected: AuthType.USE_VERTEX_AI,
|
||||
@@ -290,7 +290,7 @@ describe('AuthDialog', () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
|
||||
// Simulate that the user has already authenticated once
|
||||
props.settings.merged.security!.auth!.selectedType =
|
||||
props.settings.merged.security.auth.selectedType =
|
||||
AuthType.LOGIN_WITH_GOOGLE;
|
||||
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
@@ -349,7 +349,7 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
desc: 'calls onAuthError on escape if no auth method is set',
|
||||
setup: () => {
|
||||
props.settings.merged.security!.auth!.selectedType = undefined;
|
||||
props.settings.merged.security.auth.selectedType = undefined;
|
||||
},
|
||||
expectations: (p: typeof props) => {
|
||||
expect(p.onAuthError).toHaveBeenCalledWith(
|
||||
@@ -360,7 +360,7 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set',
|
||||
setup: () => {
|
||||
props.settings.merged.security!.auth!.selectedType =
|
||||
props.settings.merged.security.auth.selectedType =
|
||||
AuthType.USE_GEMINI;
|
||||
},
|
||||
expectations: (p: typeof props) => {
|
||||
@@ -392,7 +392,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('renders correctly with enforced auth type', () => {
|
||||
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
|
||||
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
||||
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -78,9 +78,9 @@ export function AuthDialog({
|
||||
},
|
||||
];
|
||||
|
||||
if (settings.merged.security?.auth?.enforcedType) {
|
||||
if (settings.merged.security.auth.enforcedType) {
|
||||
items = items.filter(
|
||||
(item) => item.value === settings.merged.security?.auth?.enforcedType,
|
||||
(item) => item.value === settings.merged.security.auth.enforcedType,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function AuthDialog({
|
||||
}
|
||||
|
||||
let initialAuthIndex = items.findIndex((item) => {
|
||||
if (settings.merged.security?.auth?.selectedType) {
|
||||
if (settings.merged.security.auth.selectedType) {
|
||||
return item.value === settings.merged.security.auth.selectedType;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function AuthDialog({
|
||||
|
||||
return item.value === AuthType.LOGIN_WITH_GOOGLE;
|
||||
});
|
||||
if (settings.merged.security?.auth?.enforcedType) {
|
||||
if (settings.merged.security.auth.enforcedType) {
|
||||
initialAuthIndex = 0;
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export function AuthDialog({
|
||||
if (authError) {
|
||||
return;
|
||||
}
|
||||
if (settings.merged.security?.auth?.selectedType === undefined) {
|
||||
if (settings.merged.security.auth.selectedType === undefined) {
|
||||
// Prevent exiting if no auth method is set
|
||||
onAuthError(
|
||||
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
|
||||
|
||||
@@ -20,11 +20,11 @@ export function validateAuthMethodWithSettings(
|
||||
authType: AuthType,
|
||||
settings: LoadedSettings,
|
||||
): string | null {
|
||||
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
||||
const enforcedType = settings.merged.security.auth.enforcedType;
|
||||
if (enforcedType && enforcedType !== authType) {
|
||||
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
|
||||
}
|
||||
if (settings.merged.security?.auth?.useExternal) {
|
||||
if (settings.merged.security.auth.useExternal) {
|
||||
return null;
|
||||
}
|
||||
// If using Gemini API key, we don't validate it here as we might need to prompt for it.
|
||||
@@ -80,7 +80,7 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
const authType = settings.merged.security.auth.selectedType;
|
||||
if (!authType) {
|
||||
if (process.env['GEMINI_API_KEY']) {
|
||||
onAuthError(
|
||||
|
||||
@@ -33,7 +33,7 @@ export const aboutCommand: SlashCommand = {
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
context.services.settings.merged.security.auth.selectedType || '';
|
||||
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { agentsCommand } from './agentsCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { Config, AgentOverride } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { enableAgent, disableAgent } from '../../utils/agentSettings.js';
|
||||
@@ -148,12 +148,9 @@ describe('agentsCommand', () => {
|
||||
reload: reloadSpy,
|
||||
});
|
||||
// Add agent to disabled overrides so validation passes
|
||||
(
|
||||
mockContext.services.settings.merged.agents!.overrides as Record<
|
||||
string,
|
||||
AgentOverride
|
||||
>
|
||||
)['test-agent'] = { disabled: true };
|
||||
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
vi.mocked(enableAgent).mockReturnValue({
|
||||
status: 'success',
|
||||
@@ -266,12 +263,9 @@ describe('agentsCommand', () => {
|
||||
|
||||
it('should show info message if agent is already disabled', async () => {
|
||||
mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]);
|
||||
(
|
||||
mockContext.services.settings.merged.agents!.overrides as Record<
|
||||
string,
|
||||
AgentOverride
|
||||
>
|
||||
)['test-agent'] = { disabled: true };
|
||||
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
const disableCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'disable',
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType, type HistoryItemAgentsList } from '../types.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { AgentOverride } from '@google/gemini-cli-core';
|
||||
import { disableAgent, enableAgent } from '../../utils/agentSettings.js';
|
||||
import { renderAgentActionFeedback } from '../../utils/agentUtils.js';
|
||||
|
||||
@@ -84,10 +83,7 @@ async function enableAction(
|
||||
}
|
||||
|
||||
const allAgents = agentRegistry.getAllAgentNames();
|
||||
const overrides = (settings.merged.agents?.overrides ?? {}) as Record<
|
||||
string,
|
||||
AgentOverride
|
||||
>;
|
||||
const overrides = settings.merged.agents.overrides;
|
||||
const disabledAgents = Object.keys(overrides).filter(
|
||||
(name) => overrides[name]?.disabled === true,
|
||||
);
|
||||
@@ -157,10 +153,7 @@ async function disableAction(
|
||||
}
|
||||
|
||||
const allAgents = agentRegistry.getAllAgentNames();
|
||||
const overrides = (settings.merged.agents?.overrides ?? {}) as Record<
|
||||
string,
|
||||
AgentOverride
|
||||
>;
|
||||
const overrides = settings.merged.agents.overrides;
|
||||
const disabledAgents = Object.keys(overrides).filter(
|
||||
(name) => overrides[name]?.disabled === true,
|
||||
);
|
||||
@@ -211,10 +204,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) {
|
||||
const { config, settings } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const overrides = (settings.merged.agents?.overrides ?? {}) as Record<
|
||||
string,
|
||||
AgentOverride
|
||||
>;
|
||||
const overrides = settings.merged.agents.overrides;
|
||||
const disabledAgents = Object.entries(overrides)
|
||||
.filter(([_, override]) => override?.disabled === true)
|
||||
.map(([name]) => name);
|
||||
|
||||
@@ -271,9 +271,10 @@ describe('hooksCommand', () => {
|
||||
|
||||
it('should enable a hook and update settings', async () => {
|
||||
// Update the context's settings with disabled hooks
|
||||
mockContext.services.settings.merged.hooks = {
|
||||
disabled: ['test-hook', 'other-hook'],
|
||||
};
|
||||
mockContext.services.settings.merged.hooks.disabled = [
|
||||
'test-hook',
|
||||
'other-hook',
|
||||
];
|
||||
|
||||
const enableCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'enable',
|
||||
@@ -401,9 +402,7 @@ describe('hooksCommand', () => {
|
||||
});
|
||||
|
||||
it('should disable a hook and update settings', async () => {
|
||||
mockContext.services.settings.merged.hooks = {
|
||||
disabled: [],
|
||||
};
|
||||
mockContext.services.settings.merged.hooks.disabled = [];
|
||||
|
||||
const disableCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'disable',
|
||||
@@ -432,9 +431,7 @@ describe('hooksCommand', () => {
|
||||
|
||||
it('should return info when hook is already disabled', async () => {
|
||||
// Update the context's settings with the hook already disabled
|
||||
mockContext.services.settings.merged.hooks = {
|
||||
disabled: ['test-hook'],
|
||||
};
|
||||
mockContext.services.settings.merged.hooks.disabled = ['test-hook'];
|
||||
|
||||
const disableCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'disable',
|
||||
@@ -455,9 +452,7 @@ describe('hooksCommand', () => {
|
||||
});
|
||||
|
||||
it('should handle error when disabling hook fails', async () => {
|
||||
mockContext.services.settings.merged.hooks = {
|
||||
disabled: [],
|
||||
};
|
||||
mockContext.services.settings.merged.hooks.disabled = [];
|
||||
mockSettings.setValue.mockImplementationOnce(() => {
|
||||
throw new Error('Failed to save settings');
|
||||
});
|
||||
|
||||
@@ -76,8 +76,7 @@ async function enableAction(
|
||||
|
||||
// Get current disabled hooks from settings
|
||||
const settings = context.services.settings;
|
||||
const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]);
|
||||
|
||||
const disabledHooks = settings.merged.hooks.disabled;
|
||||
// Remove from disabled list if present
|
||||
const newDisabledHooks = disabledHooks.filter(
|
||||
(name: string) => name !== hookName,
|
||||
@@ -143,8 +142,7 @@ async function disableAction(
|
||||
|
||||
// Get current disabled hooks from settings
|
||||
const settings = context.services.settings;
|
||||
const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]);
|
||||
|
||||
const disabledHooks = settings.merged.hooks.disabled;
|
||||
// Add to disabled list if not already present
|
||||
if (!disabledHooks.includes(hookName)) {
|
||||
const newDisabledHooks = [...disabledHooks, hookName];
|
||||
|
||||
@@ -72,6 +72,7 @@ describe('policiesCommand', () => {
|
||||
{
|
||||
decision: PolicyDecision.ALLOW,
|
||||
argsPattern: /safe/,
|
||||
source: 'test.toml',
|
||||
},
|
||||
{
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
@@ -101,7 +102,9 @@ describe('policiesCommand', () => {
|
||||
expect(content).toContain(
|
||||
'1. **DENY** tool: `dangerousTool` [Priority: 10]',
|
||||
);
|
||||
expect(content).toContain('2. **ALLOW** all tools (args match: `safe`)');
|
||||
expect(content).toContain(
|
||||
'2. **ALLOW** all tools (args match: `safe`) [Source: `test.toml`]',
|
||||
);
|
||||
expect(content).toContain('3. **ASK_USER** all tools');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,9 @@ const listPoliciesCommand: SlashCommand = {
|
||||
if (rule.priority !== undefined) {
|
||||
content += ` [Priority: ${rule.priority}]`;
|
||||
}
|
||||
if (rule.source) {
|
||||
content += ` [Source: \`${rule.source}\`]`;
|
||||
}
|
||||
content += '\n';
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
|
||||
{!(settings.merged.ui.hideBanner || config.getScreenReader()) && (
|
||||
<>
|
||||
<Header version={version} nightly={nightly} />
|
||||
{bannerVisible && bannerText && (
|
||||
@@ -38,7 +38,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) && (
|
||||
<Tips config={config} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
}));
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { mergeSettings } from '../../config/settings.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./LoadingIndicator.js', () => ({
|
||||
@@ -163,13 +164,20 @@ const createMockConfig = (overrides = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockSettings = (merged = {}) => ({
|
||||
merged: {
|
||||
hideFooter: false,
|
||||
showMemoryUsage: false,
|
||||
...merged,
|
||||
},
|
||||
});
|
||||
const createMockSettings = (merged = {}) => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
return {
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
ui: {
|
||||
...defaultMergedSettings.ui,
|
||||
hideFooter: false,
|
||||
showMemoryUsage: false,
|
||||
...merged,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const renderComposer = (
|
||||
|
||||
@@ -82,9 +82,7 @@ export const Composer = () => {
|
||||
<Box
|
||||
marginTop={1}
|
||||
justifyContent={
|
||||
settings.merged.ui?.hideContextSummary
|
||||
? 'flex-start'
|
||||
: 'space-between'
|
||||
settings.merged.ui.hideContextSummary ? 'flex-start' : 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
@@ -153,7 +151,7 @@ export const Composer = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!settings.merged.ui?.hideFooter && !isScreenReaderEnabled && <Footer />}
|
||||
{!settings.merged.ui.hideFooter && !isScreenReaderEnabled && <Footer />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -124,12 +124,12 @@ export function EditorSettingsDialog({
|
||||
|
||||
let mergedEditorName = 'None';
|
||||
if (
|
||||
settings.merged.general?.preferredEditor &&
|
||||
isEditorAvailable(settings.merged.general?.preferredEditor)
|
||||
settings.merged.general.preferredEditor &&
|
||||
isEditorAvailable(settings.merged.general.preferredEditor)
|
||||
) {
|
||||
mergedEditorName =
|
||||
EDITOR_DISPLAY_NAMES[
|
||||
settings.merged.general?.preferredEditor as EditorType
|
||||
settings.merged.general.preferredEditor as EditorType
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,11 @@ export const Footer: React.FC = () => {
|
||||
};
|
||||
|
||||
const showMemoryUsage =
|
||||
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false;
|
||||
const hideCWD = settings.merged.ui?.footer?.hideCWD;
|
||||
const hideSandboxStatus = settings.merged.ui?.footer?.hideSandboxStatus;
|
||||
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo;
|
||||
const hideContextPercentage =
|
||||
settings.merged.ui?.footer?.hideContextPercentage;
|
||||
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
|
||||
const hideCWD = settings.merged.ui.footer.hideCWD;
|
||||
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
|
||||
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
|
||||
const hideContextPercentage = settings.merged.ui.footer.hideContextPercentage;
|
||||
|
||||
const pathLength = Math.max(20, Math.floor(mainAreaWidth * 0.25));
|
||||
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
|
||||
|
||||
@@ -52,14 +52,11 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
|
||||
}
|
||||
|
||||
if (
|
||||
uiState.activeHooks.length > 0 &&
|
||||
(settings.merged.hooks?.notifications ?? true)
|
||||
) {
|
||||
if (uiState.activeHooks.length > 0 && settings.merged.hooks.notifications) {
|
||||
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
|
||||
}
|
||||
|
||||
if (!settings.merged.ui?.hideContextSummary && !hideContextSummary) {
|
||||
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
|
||||
return (
|
||||
<ContextSummaryDisplay
|
||||
ideContext={uiState.ideContextState}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function ThemeDialog({
|
||||
const [highlightedThemeName, setHighlightedThemeName] = useState<string>(
|
||||
() => {
|
||||
// If a theme is already set, use it.
|
||||
if (settings.merged.ui?.theme) {
|
||||
if (settings.merged.ui.theme) {
|
||||
return settings.merged.ui.theme;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function ThemeDialog({
|
||||
const customThemes =
|
||||
selectedScope === SettingScope.User
|
||||
? settings.user.settings.ui?.customThemes || {}
|
||||
: settings.merged.ui?.customThemes || {};
|
||||
: settings.merged.ui.customThemes;
|
||||
const builtInThemes = themeManager
|
||||
.getAvailableThemes()
|
||||
.filter((theme) => theme.type !== 'custom');
|
||||
|
||||
@@ -42,7 +42,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
const settings = useSettings();
|
||||
const allowPermanentApproval =
|
||||
settings.merged.security?.enablePermanentToolApproval ?? false;
|
||||
settings.merged.security.enablePermanentToolApproval;
|
||||
|
||||
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
|
||||
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../../test-utils/render.js';
|
||||
import { useTextBuffer } from './text-buffer.js';
|
||||
import { parseInputForHighlighting } from '../../utils/highlight.js';
|
||||
|
||||
describe('text-buffer performance', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should handle pasting large amounts of text efficiently', () => {
|
||||
const viewport = { width: 80, height: 24 };
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
viewport,
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
|
||||
const lines = 5000;
|
||||
const largeText = Array.from(
|
||||
{ length: lines },
|
||||
(_, i) =>
|
||||
`Line ${i}: some sample text with many @path/to/image${i}.png and maybe some more @path/to/another/image.png references to trigger regex. This line is much longer than the previous one to test wrapping.`,
|
||||
).join('\n');
|
||||
|
||||
const start = Date.now();
|
||||
act(() => {
|
||||
result.current.insert(largeText, { paste: true });
|
||||
});
|
||||
const end = Date.now();
|
||||
|
||||
const duration = end - start;
|
||||
expect(duration).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('should handle character-by-character insertion in a large buffer efficiently', () => {
|
||||
const lines = 5000;
|
||||
const initialText = Array.from(
|
||||
{ length: lines },
|
||||
(_, i) => `Line ${i}: some sample text with @path/to/image.png`,
|
||||
).join('\n');
|
||||
const viewport = { width: 80, height: 24 };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
initialText,
|
||||
viewport,
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
|
||||
const start = Date.now();
|
||||
const charsToInsert = 100;
|
||||
for (let i = 0; i < charsToInsert; i++) {
|
||||
act(() => {
|
||||
result.current.insert('a');
|
||||
});
|
||||
}
|
||||
const end = Date.now();
|
||||
|
||||
const duration = end - start;
|
||||
expect(duration).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('should highlight many lines efficiently', () => {
|
||||
const lines = 5000;
|
||||
const sampleLines = Array.from(
|
||||
{ length: lines },
|
||||
(_, i) =>
|
||||
`Line ${i}: some sample text with @path/to/image${i}.png /command and more @file.txt`,
|
||||
);
|
||||
|
||||
const start = Date.now();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// Simulate 100 renders
|
||||
for (const line of sampleLines.slice(0, 20)) {
|
||||
// 20 visible lines
|
||||
parseInputForHighlighting(line, 1, []);
|
||||
}
|
||||
}
|
||||
const end = Date.now();
|
||||
|
||||
const duration = end - start;
|
||||
expect(duration).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../../test-utils/render.js';
|
||||
import {
|
||||
renderHook,
|
||||
renderHookWithProviders,
|
||||
} from '../../../test-utils/render.js';
|
||||
import type {
|
||||
Viewport,
|
||||
TextBuffer,
|
||||
@@ -55,6 +58,10 @@ const initialState: TextBufferState = {
|
||||
};
|
||||
|
||||
describe('textBufferReducer', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return the initial state if state is undefined', () => {
|
||||
const action = { type: 'unknown_action' } as unknown as TextBufferAction;
|
||||
const state = textBufferReducer(initialState, action);
|
||||
@@ -381,6 +388,10 @@ describe('useTextBuffer', () => {
|
||||
viewport = { width: 10, height: 3 }; // Default viewport for tests
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with empty text and cursor at (0,0) by default', () => {
|
||||
const { result } = renderHook(() =>
|
||||
@@ -2371,6 +2382,10 @@ describe('Unicode helper functions', () => {
|
||||
});
|
||||
|
||||
describe('Transformation Utilities', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getTransformedImagePath', () => {
|
||||
it('should transform a simple image path', () => {
|
||||
expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]');
|
||||
@@ -2547,4 +2562,125 @@ describe('Transformation Utilities', () => {
|
||||
expect(result.transformedToLogMap).toEqual([0]); // Just the trailing position
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout Caching and Invalidation', () => {
|
||||
it.each([
|
||||
{
|
||||
desc: 'via setText',
|
||||
actFn: (result: { current: TextBuffer }) =>
|
||||
result.current.setText('changed line'),
|
||||
expected: 'changed line',
|
||||
},
|
||||
{
|
||||
desc: 'via replaceRange',
|
||||
actFn: (result: { current: TextBuffer }) =>
|
||||
result.current.replaceRange(0, 0, 0, 13, 'changed line'),
|
||||
expected: 'changed line',
|
||||
},
|
||||
])(
|
||||
'should invalidate cache when line content changes $desc',
|
||||
({ actFn, expected }) => {
|
||||
const viewport = { width: 80, height: 24 };
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useTextBuffer({
|
||||
initialText: 'original line',
|
||||
viewport,
|
||||
isValidPath: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
const originalLayout = result.current.visualLayout;
|
||||
|
||||
act(() => {
|
||||
actFn(result);
|
||||
});
|
||||
|
||||
expect(result.current.visualLayout).not.toBe(originalLayout);
|
||||
expect(result.current.allVisualLines[0]).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should invalidate cache when viewport width changes', () => {
|
||||
const viewport = { width: 80, height: 24 };
|
||||
const { result, rerender } = renderHookWithProviders(
|
||||
({ vp }) =>
|
||||
useTextBuffer({
|
||||
initialText:
|
||||
'a very long line that will wrap when the viewport is small',
|
||||
viewport: vp,
|
||||
isValidPath: () => true,
|
||||
}),
|
||||
{ initialProps: { vp: viewport } },
|
||||
);
|
||||
|
||||
const originalLayout = result.current.visualLayout;
|
||||
|
||||
// Shrink viewport to force wrapping change
|
||||
rerender({ vp: { width: 10, height: 24 } });
|
||||
|
||||
expect(result.current.visualLayout).not.toBe(originalLayout);
|
||||
expect(result.current.allVisualLines.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('should correctly handle cursor expansion/collapse in cached layout', () => {
|
||||
const viewport = { width: 80, height: 24 };
|
||||
const text = 'Check @image.png here';
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useTextBuffer({
|
||||
initialText: text,
|
||||
viewport,
|
||||
isValidPath: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Cursor at start (collapsed)
|
||||
act(() => {
|
||||
result.current.moveToOffset(0);
|
||||
});
|
||||
expect(result.current.allVisualLines[0]).toContain('[Image image.png]');
|
||||
|
||||
// Move cursor onto the @path (expanded)
|
||||
act(() => {
|
||||
result.current.moveToOffset(7); // onto @
|
||||
});
|
||||
expect(result.current.allVisualLines[0]).toContain('@image.png');
|
||||
expect(result.current.allVisualLines[0]).not.toContain(
|
||||
'[Image image.png]',
|
||||
);
|
||||
|
||||
// Move cursor away (collapsed again)
|
||||
act(() => {
|
||||
result.current.moveToOffset(0);
|
||||
});
|
||||
expect(result.current.allVisualLines[0]).toContain('[Image image.png]');
|
||||
});
|
||||
|
||||
it('should reuse cache for unchanged lines during editing', () => {
|
||||
const viewport = { width: 80, height: 24 };
|
||||
const initialText = 'line 1\nline 2\nline 3';
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useTextBuffer({
|
||||
initialText,
|
||||
viewport,
|
||||
isValidPath: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
const layout1 = result.current.visualLayout;
|
||||
|
||||
// Edit line 1
|
||||
act(() => {
|
||||
result.current.moveToOffset(0);
|
||||
result.current.insert('X');
|
||||
});
|
||||
|
||||
const layout2 = result.current.visualLayout;
|
||||
expect(layout2).not.toBe(layout1);
|
||||
|
||||
// Verify that visual lines for line 2 and 3 (indices 1 and 2 in visualLines)
|
||||
// are identical in content if not in object reference (the arrays are rebuilt, but contents are cached)
|
||||
expect(result.current.allVisualLines[1]).toBe('line 2');
|
||||
expect(result.current.allVisualLines[2]).toBe('line 3');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type EditorType,
|
||||
getEditorCommand,
|
||||
isGuiEditor,
|
||||
LruCache,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
toCodePoints,
|
||||
@@ -31,6 +32,7 @@ import type { Key } from '../../contexts/KeypressContext.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import type { VimAction } from './vim-buffer-actions.js';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
|
||||
|
||||
export type Direction =
|
||||
| 'left'
|
||||
@@ -726,9 +728,18 @@ export function getTransformedImagePath(filePath: string): string {
|
||||
return `[Image ${truncatedBase}${extension}]`;
|
||||
}
|
||||
|
||||
const transformationsCache = new LruCache<string, Transformation[]>(
|
||||
LRU_BUFFER_PERF_CACHE_LIMIT,
|
||||
);
|
||||
|
||||
export function calculateTransformationsForLine(
|
||||
line: string,
|
||||
): Transformation[] {
|
||||
const cached = transformationsCache.get(line);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const transformations: Transformation[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
@@ -748,6 +759,8 @@ export function calculateTransformationsForLine(
|
||||
});
|
||||
}
|
||||
|
||||
transformationsCache.set(line, transformations);
|
||||
|
||||
return transformations;
|
||||
}
|
||||
|
||||
@@ -845,6 +858,7 @@ export function calculateTransformedLine(
|
||||
|
||||
return { transformedLine, transformedToLogMap };
|
||||
}
|
||||
|
||||
export interface VisualLayout {
|
||||
visualLines: string[];
|
||||
// For each logical line, an array of [visualLineIndex, startColInLogical]
|
||||
@@ -858,6 +872,33 @@ export interface VisualLayout {
|
||||
visualToTransformedMap: number[];
|
||||
}
|
||||
|
||||
// Caches for layout calculation
|
||||
interface LineLayoutResult {
|
||||
visualLines: string[];
|
||||
logicalToVisualMap: Array<[number, number]>;
|
||||
visualToLogicalMap: Array<[number, number]>;
|
||||
transformedToLogMap: number[];
|
||||
visualToTransformedMap: number[];
|
||||
}
|
||||
|
||||
const lineLayoutCache = new LruCache<string, LineLayoutResult>(
|
||||
LRU_BUFFER_PERF_CACHE_LIMIT,
|
||||
);
|
||||
|
||||
function getLineLayoutCacheKey(
|
||||
line: string,
|
||||
viewportWidth: number,
|
||||
isCursorOnLine: boolean,
|
||||
cursorCol: number,
|
||||
): string {
|
||||
// Most lines (99.9% in a large buffer) are not cursor lines.
|
||||
// We use a simpler key for them to reduce string allocation overhead.
|
||||
if (!isCursorOnLine) {
|
||||
return `${viewportWidth}:N:${line}`;
|
||||
}
|
||||
return `${viewportWidth}:C:${cursorCol}:${line}`;
|
||||
}
|
||||
|
||||
// Calculates the visual wrapping of lines and the mapping between logical and visual coordinates.
|
||||
// This is an expensive operation and should be memoized.
|
||||
function calculateLayout(
|
||||
@@ -873,6 +914,34 @@ function calculateLayout(
|
||||
|
||||
logicalLines.forEach((logLine, logIndex) => {
|
||||
logicalToVisualMap[logIndex] = [];
|
||||
|
||||
const isCursorOnLine = logIndex === logicalCursor[0];
|
||||
const cacheKey = getLineLayoutCacheKey(
|
||||
logLine,
|
||||
viewportWidth,
|
||||
isCursorOnLine,
|
||||
logicalCursor[1],
|
||||
);
|
||||
const cached = lineLayoutCache.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const visualLineOffset = visualLines.length;
|
||||
visualLines.push(...cached.visualLines);
|
||||
cached.logicalToVisualMap.forEach(([relVisualIdx, logCol]) => {
|
||||
logicalToVisualMap[logIndex].push([
|
||||
visualLineOffset + relVisualIdx,
|
||||
logCol,
|
||||
]);
|
||||
});
|
||||
cached.visualToLogicalMap.forEach(([, logCol]) => {
|
||||
visualToLogicalMap.push([logIndex, logCol]);
|
||||
});
|
||||
transformedToLogicalMaps[logIndex] = cached.transformedToLogMap;
|
||||
visualToTransformedMap.push(...cached.visualToTransformedMap);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in cache, calculate
|
||||
const transformations = calculateTransformationsForLine(logLine);
|
||||
const { transformedLine, transformedToLogMap } = calculateTransformedLine(
|
||||
logLine,
|
||||
@@ -880,13 +949,18 @@ function calculateLayout(
|
||||
logicalCursor,
|
||||
transformations,
|
||||
);
|
||||
transformedToLogicalMaps[logIndex] = transformedToLogMap;
|
||||
|
||||
const lineVisualLines: string[] = [];
|
||||
const lineLogicalToVisualMap: Array<[number, number]> = [];
|
||||
const lineVisualToLogicalMap: Array<[number, number]> = [];
|
||||
const lineVisualToTransformedMap: number[] = [];
|
||||
|
||||
if (transformedLine.length === 0) {
|
||||
// Handle empty logical line
|
||||
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
|
||||
visualToLogicalMap.push([logIndex, 0]);
|
||||
visualToTransformedMap.push(0);
|
||||
visualLines.push('');
|
||||
lineLogicalToVisualMap.push([0, 0]);
|
||||
lineVisualToLogicalMap.push([logIndex, 0]);
|
||||
lineVisualToTransformedMap.push(0);
|
||||
lineVisualLines.push('');
|
||||
} else {
|
||||
// Non-empty logical line
|
||||
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
|
||||
@@ -929,15 +1003,6 @@ function calculateLayout(
|
||||
// Single character is wider than viewport, take it anyway
|
||||
currentChunk = char;
|
||||
numCodePointsInChunk = 1;
|
||||
} else if (
|
||||
numCodePointsInChunk === 0 &&
|
||||
charVisualWidth <= viewportWidth
|
||||
) {
|
||||
// This case should ideally be caught by the next iteration if the char fits.
|
||||
// If it doesn't fit (because currentChunkVisualWidth was already > 0 from a previous char that filled the line),
|
||||
// then numCodePointsInChunk would not be 0.
|
||||
// This branch means the current char *itself* doesn't fit an empty line, which is handled by the above.
|
||||
// If we are here, it means the loop should break and the current chunk (which is empty) is finalized.
|
||||
}
|
||||
}
|
||||
break; // Break from inner loop to finalize this chunk
|
||||
@@ -955,55 +1020,57 @@ function calculateLayout(
|
||||
}
|
||||
}
|
||||
|
||||
// If the inner loop completed without breaking (i.e., remaining text fits)
|
||||
// or if the loop broke but numCodePointsInChunk is still 0 (e.g. first char too wide for empty line)
|
||||
if (
|
||||
numCodePointsInChunk === 0 &&
|
||||
currentPosInLogLine < codePointsInLogLine.length
|
||||
) {
|
||||
// This can happen if the very first character considered for a new visual line is wider than the viewport.
|
||||
// In this case, we take that single character.
|
||||
const firstChar = codePointsInLogLine[currentPosInLogLine];
|
||||
currentChunk = firstChar;
|
||||
numCodePointsInChunk = 1; // Ensure we advance
|
||||
}
|
||||
|
||||
// If after everything, numCodePointsInChunk is still 0 but we haven't processed the whole logical line,
|
||||
// it implies an issue, like viewportWidth being 0 or less. Avoid infinite loop.
|
||||
if (
|
||||
numCodePointsInChunk === 0 &&
|
||||
currentPosInLogLine < codePointsInLogLine.length
|
||||
) {
|
||||
// Force advance by one character to prevent infinite loop if something went wrong
|
||||
currentChunk = codePointsInLogLine[currentPosInLogLine];
|
||||
numCodePointsInChunk = 1;
|
||||
}
|
||||
|
||||
const logicalStartCol = transformedToLogMap[currentPosInLogLine] ?? 0;
|
||||
logicalToVisualMap[logIndex].push([
|
||||
visualLines.length,
|
||||
logicalStartCol,
|
||||
]);
|
||||
visualToLogicalMap.push([logIndex, logicalStartCol]);
|
||||
visualToTransformedMap.push(currentPosInLogLine);
|
||||
visualLines.push(currentChunk);
|
||||
lineLogicalToVisualMap.push([lineVisualLines.length, logicalStartCol]);
|
||||
lineVisualToLogicalMap.push([logIndex, logicalStartCol]);
|
||||
lineVisualToTransformedMap.push(currentPosInLogLine);
|
||||
lineVisualLines.push(currentChunk);
|
||||
|
||||
const logicalStartOfThisChunk = currentPosInLogLine;
|
||||
currentPosInLogLine += numCodePointsInChunk;
|
||||
|
||||
// If the chunk processed did not consume the entire logical line,
|
||||
// and the character immediately following the chunk is a space,
|
||||
// advance past this space as it acted as a delimiter for word wrapping.
|
||||
if (
|
||||
logicalStartOfThisChunk + numCodePointsInChunk <
|
||||
codePointsInLogLine.length &&
|
||||
currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe
|
||||
currentPosInLogLine < codePointsInLogLine.length &&
|
||||
codePointsInLogLine[currentPosInLogLine] === ' '
|
||||
) {
|
||||
currentPosInLogLine++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result for this line
|
||||
lineLayoutCache.set(cacheKey, {
|
||||
visualLines: lineVisualLines,
|
||||
logicalToVisualMap: lineLogicalToVisualMap,
|
||||
visualToLogicalMap: lineVisualToLogicalMap,
|
||||
transformedToLogMap,
|
||||
visualToTransformedMap: lineVisualToTransformedMap,
|
||||
});
|
||||
|
||||
const visualLineOffset = visualLines.length;
|
||||
visualLines.push(...lineVisualLines);
|
||||
lineLogicalToVisualMap.forEach(([relVisualIdx, logCol]) => {
|
||||
logicalToVisualMap[logIndex].push([
|
||||
visualLineOffset + relVisualIdx,
|
||||
logCol,
|
||||
]);
|
||||
});
|
||||
lineVisualToLogicalMap.forEach(([, logCol]) => {
|
||||
visualToLogicalMap.push([logIndex, logCol]);
|
||||
});
|
||||
transformedToLogicalMaps[logIndex] = transformedToLogMap;
|
||||
visualToTransformedMap.push(...lineVisualToTransformedMap);
|
||||
});
|
||||
|
||||
// If the entire logical text was empty, ensure there's one empty visual line.
|
||||
@@ -2378,6 +2445,7 @@ export function useTextBuffer({
|
||||
transformedToLogicalMaps,
|
||||
visualToTransformedMap,
|
||||
transformationsByLine,
|
||||
visualLayout,
|
||||
setText,
|
||||
insert,
|
||||
newline,
|
||||
@@ -2447,6 +2515,7 @@ export function useTextBuffer({
|
||||
transformedToLogicalMaps,
|
||||
visualToTransformedMap,
|
||||
transformationsByLine,
|
||||
visualLayout,
|
||||
setText,
|
||||
insert,
|
||||
newline,
|
||||
@@ -2540,6 +2609,7 @@ export interface TextBuffer {
|
||||
visualToTransformedMap: number[];
|
||||
/** Cached transformations per logical line */
|
||||
transformationsByLine: Transformation[][];
|
||||
visualLayout: VisualLayout;
|
||||
|
||||
// Actions
|
||||
|
||||
|
||||
@@ -32,3 +32,4 @@ export const MAX_MCP_RESOURCES_TO_SHOW = 10;
|
||||
export const WARNING_PROMPT_DURATION_MS = 1000;
|
||||
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
||||
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
|
||||
export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000;
|
||||
|
||||
@@ -32,7 +32,7 @@ export const VimModeProvider = ({
|
||||
children: React.ReactNode;
|
||||
settings: LoadedSettings;
|
||||
}) => {
|
||||
const initialVimEnabled = settings.merged.general?.vimMode ?? false;
|
||||
const initialVimEnabled = settings.merged.general.vimMode;
|
||||
const [vimEnabled, setVimEnabled] = useState(initialVimEnabled);
|
||||
const [vimMode, setVimMode] = useState<VimMode>(
|
||||
initialVimEnabled ? 'NORMAL' : 'INSERT',
|
||||
@@ -40,13 +40,13 @@ export const VimModeProvider = ({
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize vimEnabled from settings on mount
|
||||
const enabled = settings.merged.general?.vimMode ?? false;
|
||||
const enabled = settings.merged.general.vimMode;
|
||||
setVimEnabled(enabled);
|
||||
// When vim mode is enabled, always start in NORMAL mode
|
||||
if (enabled) {
|
||||
setVimMode('NORMAL');
|
||||
}
|
||||
}, [settings.merged.general?.vimMode]);
|
||||
}, [settings.merged.general.vimMode]);
|
||||
|
||||
const toggleVimEnabled = useCallback(async () => {
|
||||
const newValue = !vimEnabled;
|
||||
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
ToolConfirmationOutcome,
|
||||
Storage,
|
||||
IdeClient,
|
||||
addMCPStatusChangeListener,
|
||||
removeMCPStatusChangeListener,
|
||||
MCPDiscoveryState,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import type {
|
||||
@@ -269,6 +272,10 @@ export const useSlashCommandProcessor = (
|
||||
ideClient.addStatusChangeListener(listener);
|
||||
})();
|
||||
|
||||
// Listen for MCP server status changes (e.g. connection, discovery completion)
|
||||
// to reload slash commands (since they may include MCP prompts).
|
||||
addMCPStatusChangeListener(listener);
|
||||
|
||||
// TODO: Ideally this would happen more directly inside the ExtensionLoader,
|
||||
// but the CommandService today is not conducive to that since it isn't a
|
||||
// long lived service but instead gets fully re-created based on reload
|
||||
@@ -289,6 +296,7 @@ export const useSlashCommandProcessor = (
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
ideClient.removeStatusChangeListener(listener);
|
||||
})();
|
||||
removeMCPStatusChangeListener(listener);
|
||||
appEvents.off('extensionsStarting', extensionEventListener);
|
||||
appEvents.off('extensionsStopping', extensionEventListener);
|
||||
};
|
||||
@@ -572,9 +580,16 @@ export const useSlashCommandProcessor = (
|
||||
}
|
||||
}
|
||||
|
||||
const isMcpLoading =
|
||||
config?.getMcpClientManager()?.getDiscoveryState() ===
|
||||
MCPDiscoveryState.IN_PROGRESS;
|
||||
const errorMessage = isMcpLoading
|
||||
? `Unknown command: ${trimmed}. Command might have been from an MCP server but MCP servers are not done loading.`
|
||||
: `Unknown command: ${trimmed}`;
|
||||
|
||||
addMessage({
|
||||
type: MessageType.ERROR,
|
||||
content: `Unknown command: ${trimmed}`,
|
||||
content: errorMessage,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean =>
|
||||
settings.merged.ui?.useAlternateBuffer === true;
|
||||
settings.merged.ui.useAlternateBuffer === true;
|
||||
|
||||
export const useAlternateBuffer = (): boolean => {
|
||||
const settings = useSettings();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useReducer, useRef } from 'react';
|
||||
import { setTimeout as setTimeoutPromise } from 'node:timers/promises';
|
||||
import type { Config, FileSearch } from '@google/gemini-cli-core';
|
||||
import { FileSearchFactory, escapePath } from '@google/gemini-cli-core';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
@@ -12,6 +13,8 @@ import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
|
||||
const DEFAULT_SEARCH_TIMEOUT_MS = 5000;
|
||||
|
||||
export enum AtCompletionStatus {
|
||||
IDLE = 'idle',
|
||||
INITIALIZING = 'initializing',
|
||||
@@ -257,6 +260,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
|
||||
config?.getEnableRecursiveFileSearch() ?? true,
|
||||
disableFuzzySearch:
|
||||
config?.getFileFilteringDisableFuzzySearch() ?? false,
|
||||
maxFiles: config?.getFileFilteringOptions()?.maxFileCount,
|
||||
});
|
||||
await searcher.initialize();
|
||||
fileSearch.current = searcher;
|
||||
@@ -285,6 +289,22 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
}, 200);
|
||||
|
||||
const timeoutMs =
|
||||
config?.getFileFilteringOptions()?.searchTimeout ??
|
||||
DEFAULT_SEARCH_TIMEOUT_MS;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
(async () => {
|
||||
try {
|
||||
await setTimeoutPromise(timeoutMs, undefined, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
const results = await fileSearch.current.search(state.pattern, {
|
||||
signal: controller.signal,
|
||||
@@ -332,6 +352,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
|
||||
if (!(error instanceof Error && error.name === 'AbortError')) {
|
||||
dispatch({ type: 'ERROR' });
|
||||
}
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useFolderTrust = (
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const startupMessageSent = useRef(false);
|
||||
|
||||
const folderTrust = settings.merged.security?.folderTrust?.enabled;
|
||||
const folderTrust = settings.merged.security.folderTrust.enabled;
|
||||
|
||||
useEffect(() => {
|
||||
const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
MCPDiscoveryState,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -178,6 +179,11 @@ describe('useGeminiStream', () => {
|
||||
return clientInstance;
|
||||
});
|
||||
|
||||
const mockMcpClientManager = {
|
||||
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED),
|
||||
getMcpServerCount: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
|
||||
const contentGeneratorConfig = {
|
||||
model: 'test-model',
|
||||
apiKey: 'test-key',
|
||||
@@ -211,6 +217,7 @@ describe('useGeminiStream', () => {
|
||||
getProjectRoot: vi.fn(() => '/test/dir'),
|
||||
getCheckpointingEnabled: vi.fn(() => false),
|
||||
getGeminiClient: mockGetGeminiClient,
|
||||
getMcpClientManager: () => mockMcpClientManager as any,
|
||||
getApprovalMode: () => ApprovalMode.DEFAULT,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
@@ -254,6 +261,7 @@ describe('useGeminiStream', () => {
|
||||
.mockClear()
|
||||
.mockReturnValue((async function* () {})());
|
||||
handleAtCommandSpy = vi.spyOn(atCommandProcessor, 'handleAtCommand');
|
||||
vi.spyOn(coreEvents, 'emitFeedback');
|
||||
});
|
||||
|
||||
const mockLoadedSettings: LoadedSettings = {
|
||||
@@ -1954,6 +1962,73 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Discovery State', () => {
|
||||
it('should block non-slash command queries when discovery is in progress and servers exist', async () => {
|
||||
const mockMcpClientManager = {
|
||||
getDiscoveryState: vi
|
||||
.fn()
|
||||
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
|
||||
getMcpServerCount: vi.fn().mockReturnValue(1),
|
||||
};
|
||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
||||
|
||||
const { result } = renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('test query');
|
||||
});
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Waiting for MCP servers to initialize... Slash commands are still available.',
|
||||
);
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT block queries when discovery is NOT_STARTED but there are no servers', async () => {
|
||||
const mockMcpClientManager = {
|
||||
getDiscoveryState: vi
|
||||
.fn()
|
||||
.mockReturnValue(MCPDiscoveryState.NOT_STARTED),
|
||||
getMcpServerCount: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
||||
|
||||
const { result } = renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('test query');
|
||||
});
|
||||
|
||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Waiting for MCP servers to initialize... Slash commands are still available.',
|
||||
);
|
||||
expect(mockSendMessageStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT block slash commands even when discovery is in progress', async () => {
|
||||
const mockMcpClientManager = {
|
||||
getDiscoveryState: vi
|
||||
.fn()
|
||||
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
|
||||
getMcpServerCount: vi.fn().mockReturnValue(1),
|
||||
};
|
||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
||||
|
||||
const { result } = renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/help');
|
||||
});
|
||||
|
||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Waiting for MCP servers to initialize... Slash commands are still available.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleFinishedEvent', () => {
|
||||
it('should add info message for MAX_TOKENS finish reason', async () => {
|
||||
// Setup mock to return a stream with MAX_TOKENS finish reason
|
||||
@@ -3015,4 +3090,68 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Server Initialization', () => {
|
||||
it('should allow slash commands to run while MCP servers are initializing', async () => {
|
||||
const mockMcpClientManager = {
|
||||
getDiscoveryState: vi
|
||||
.fn()
|
||||
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
|
||||
getMcpServerCount: vi.fn().mockReturnValue(1),
|
||||
};
|
||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
||||
|
||||
const { result } = renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/help');
|
||||
});
|
||||
|
||||
// Slash command should be handled, and no Gemini call should be made.
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/help');
|
||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block normal prompts and provide feedback while MCP servers are initializing', async () => {
|
||||
const mockMcpClientManager = {
|
||||
getDiscoveryState: vi
|
||||
.fn()
|
||||
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
|
||||
getMcpServerCount: vi.fn().mockReturnValue(1),
|
||||
};
|
||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
||||
const { result } = renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('a normal prompt');
|
||||
});
|
||||
|
||||
// No slash command, no Gemini call, but feedback should be emitted.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Waiting for MCP servers to initialize... Slash commands are still available.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow normal prompts to run when MCP servers are finished initializing', async () => {
|
||||
const mockMcpClientManager = {
|
||||
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED),
|
||||
getMcpServerCount: vi.fn().mockReturnValue(1),
|
||||
};
|
||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
||||
|
||||
const { result } = renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('a normal prompt');
|
||||
});
|
||||
|
||||
// Prompt should be sent to Gemini.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
expect(mockSendMessageStream).toHaveBeenCalled();
|
||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
ToolErrorType,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
MCPDiscoveryState,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -83,7 +84,7 @@ enum StreamProcessingStatus {
|
||||
}
|
||||
|
||||
function showCitations(settings: LoadedSettings): boolean {
|
||||
const enabled = settings?.merged?.ui?.showCitations;
|
||||
const enabled = settings.merged.ui.showCitations;
|
||||
if (enabled !== undefined) {
|
||||
return enabled;
|
||||
}
|
||||
@@ -459,7 +460,7 @@ export const useGeminiStream = (
|
||||
isClientInitiated: true,
|
||||
prompt_id,
|
||||
};
|
||||
scheduleToolCalls([toolCallRequest], abortSignal);
|
||||
await scheduleToolCalls([toolCallRequest], abortSignal);
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
}
|
||||
case 'submit_prompt': {
|
||||
@@ -782,7 +783,7 @@ export const useGeminiStream = (
|
||||
|
||||
const handleChatModelEvent = useCallback(
|
||||
(eventValue: string, userMessageTimestamp: number) => {
|
||||
if (!settings?.merged?.ui?.showModelInfoInChat) {
|
||||
if (!settings.merged.ui.showModelInfoInChat) {
|
||||
return;
|
||||
}
|
||||
if (pendingHistoryItemRef.current) {
|
||||
@@ -922,7 +923,7 @@ export const useGeminiStream = (
|
||||
}
|
||||
}
|
||||
if (toolCallRequests.length > 0) {
|
||||
scheduleToolCalls(toolCallRequests, signal);
|
||||
await scheduleToolCalls(toolCallRequests, signal);
|
||||
}
|
||||
return StreamProcessingStatus.Completed;
|
||||
},
|
||||
@@ -951,6 +952,26 @@ export const useGeminiStream = (
|
||||
{ name: 'submitQuery' },
|
||||
async ({ metadata: spanMetadata }) => {
|
||||
spanMetadata.input = query;
|
||||
|
||||
const discoveryState = config
|
||||
.getMcpClientManager()
|
||||
?.getDiscoveryState();
|
||||
const mcpServerCount =
|
||||
config.getMcpClientManager()?.getMcpServerCount() ?? 0;
|
||||
if (
|
||||
!options?.isContinuation &&
|
||||
typeof query === 'string' &&
|
||||
!isSlashCommand(query.trim()) &&
|
||||
mcpServerCount > 0 &&
|
||||
discoveryState !== MCPDiscoveryState.COMPLETED
|
||||
) {
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
'Waiting for MCP servers to initialize... Slash commands are still available.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const queryId = `${Date.now()}-${Math.random()}`;
|
||||
activeQueryIdRef.current = queryId;
|
||||
if (
|
||||
|
||||
@@ -85,7 +85,7 @@ export const usePermissionsModifyTrust = (
|
||||
);
|
||||
const [needsRestart, setNeedsRestart] = useState(false);
|
||||
|
||||
const isFolderTrustEnabled = !!settings.merged.security?.folderTrust?.enabled;
|
||||
const isFolderTrustEnabled = !!settings.merged.security.folderTrust.enabled;
|
||||
|
||||
const updateTrustLevel = useCallback(
|
||||
(trustLevel: TrustLevel) => {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { ToolCallStatus } from '../types.js';
|
||||
export type ScheduleFn = (
|
||||
request: ToolCallRequestInfo | ToolCallRequestInfo[],
|
||||
signal: AbortSignal,
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
export type MarkToolsAsSubmittedFn = (callIds: string[]) => void;
|
||||
|
||||
export type TrackedScheduledToolCall = ScheduledToolCall & {
|
||||
@@ -181,7 +181,7 @@ export function useReactToolScheduler(
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
setToolCallsForDisplay([]);
|
||||
void scheduler.schedule(request, signal);
|
||||
return scheduler.schedule(request, signal);
|
||||
},
|
||||
[scheduler, setToolCallsForDisplay],
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function createShowMemoryAction(
|
||||
|
||||
const currentMemory = config.getUserMemory();
|
||||
const fileCount = config.getGeminiMdFileCount();
|
||||
const contextFileName = settings.merged.context?.fileName;
|
||||
const contextFileName = settings.merged.context.fileName;
|
||||
const contextFileNames = Array.isArray(contextFileName)
|
||||
? contextFileName
|
||||
: [contextFileName];
|
||||
|
||||
@@ -67,7 +67,7 @@ export const useThemeCommand = (
|
||||
|
||||
const closeThemeDialog = useCallback(() => {
|
||||
// Re-apply the saved theme to revert any preview changes from highlighting
|
||||
applyTheme(loadedSettings.merged.ui?.theme);
|
||||
applyTheme(loadedSettings.merged.ui.theme);
|
||||
setIsThemeDialogOpen(false);
|
||||
}, [applyTheme, loadedSettings]);
|
||||
|
||||
@@ -88,10 +88,10 @@ export const useThemeCommand = (
|
||||
return;
|
||||
}
|
||||
loadedSettings.setValue(scope, 'ui.theme', themeName); // Update the merged settings
|
||||
if (loadedSettings.merged.ui?.customThemes) {
|
||||
themeManager.loadCustomThemes(loadedSettings.merged.ui?.customThemes);
|
||||
if (loadedSettings.merged.ui.customThemes) {
|
||||
themeManager.loadCustomThemes(loadedSettings.merged.ui.customThemes);
|
||||
}
|
||||
applyTheme(loadedSettings.merged.ui?.theme); // Apply the current theme
|
||||
applyTheme(loadedSettings.merged.ui.theme); // Apply the current theme
|
||||
setThemeError(null);
|
||||
} finally {
|
||||
setIsThemeDialogOpen(false); // Close the dialog
|
||||
|
||||
@@ -172,8 +172,8 @@ describe('useReactToolScheduler in YOLO Mode', () => {
|
||||
args: { data: 'any data' },
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
schedule(request, new AbortController().signal);
|
||||
await act(async () => {
|
||||
await schedule(request, new AbortController().signal);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -229,11 +229,11 @@ describe('useReactToolScheduler', () => {
|
||||
schedule: (
|
||||
req: ToolCallRequestInfo | ToolCallRequestInfo[],
|
||||
signal: AbortSignal,
|
||||
) => void,
|
||||
) => Promise<void>,
|
||||
request: ToolCallRequestInfo | ToolCallRequestInfo[],
|
||||
) => {
|
||||
act(() => {
|
||||
schedule(request, new AbortController().signal);
|
||||
await act(async () => {
|
||||
await schedule(request, new AbortController().signal);
|
||||
});
|
||||
|
||||
await advanceAndSettle();
|
||||
@@ -322,10 +322,13 @@ describe('useReactToolScheduler', () => {
|
||||
|
||||
it('should clear previous tool calls when scheduling new ones', async () => {
|
||||
mockToolRegistry.getTool.mockReturnValue(mockTool);
|
||||
(mockTool.execute as Mock).mockResolvedValue({
|
||||
llmContent: 'Tool output',
|
||||
returnDisplay: 'Formatted tool output',
|
||||
} as ToolResult);
|
||||
(mockTool.execute as Mock).mockImplementation(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return {
|
||||
llmContent: 'Tool output',
|
||||
returnDisplay: 'Formatted tool output',
|
||||
};
|
||||
});
|
||||
|
||||
const { result } = renderScheduler();
|
||||
const schedule = result.current[1];
|
||||
@@ -346,10 +349,13 @@ describe('useReactToolScheduler', () => {
|
||||
name: 'mockTool',
|
||||
args: {},
|
||||
} as any;
|
||||
act(() => {
|
||||
schedule(newRequest, new AbortController().signal);
|
||||
let schedulePromise: Promise<void>;
|
||||
await act(async () => {
|
||||
schedulePromise = schedule(newRequest, new AbortController().signal);
|
||||
});
|
||||
|
||||
await advanceAndSettle();
|
||||
|
||||
// After scheduling, the old call should be gone,
|
||||
// and the new one should be in the display in its initial state.
|
||||
expect(result.current[0].length).toBe(1);
|
||||
@@ -358,14 +364,13 @@ describe('useReactToolScheduler', () => {
|
||||
|
||||
// Let the new call finish.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await schedulePromise;
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -388,16 +393,14 @@ describe('useReactToolScheduler', () => {
|
||||
args: {},
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
schedule(request, new AbortController().signal);
|
||||
});
|
||||
let schedulePromise: Promise<void>;
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}); // validation
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0); // Process scheduling
|
||||
schedulePromise = schedule(request, new AbortController().signal);
|
||||
});
|
||||
|
||||
await advanceAndSettle(); // validation
|
||||
await advanceAndSettle(); // Process scheduling
|
||||
|
||||
// At this point, the tool is 'executing' and waiting on the promise.
|
||||
expect(result.current[0][0].status).toBe('executing');
|
||||
|
||||
@@ -406,9 +409,7 @@ describe('useReactToolScheduler', () => {
|
||||
cancelAllToolCalls(cancelController.signal);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await advanceAndSettle();
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
@@ -421,6 +422,11 @@ describe('useReactToolScheduler', () => {
|
||||
await act(async () => {
|
||||
resolveExecute({ llmContent: 'output', returnDisplay: 'display' });
|
||||
});
|
||||
|
||||
// Now await the schedule promise
|
||||
await act(async () => {
|
||||
await schedulePromise;
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -520,8 +526,9 @@ describe('useReactToolScheduler', () => {
|
||||
args: { data: 'sensitive' },
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
schedule(request, new AbortController().signal);
|
||||
let schedulePromise: Promise<void>;
|
||||
await act(async () => {
|
||||
schedulePromise = schedule(request, new AbortController().signal);
|
||||
});
|
||||
await advanceAndSettle();
|
||||
|
||||
@@ -535,8 +542,11 @@ describe('useReactToolScheduler', () => {
|
||||
});
|
||||
|
||||
await advanceAndSettle();
|
||||
await advanceAndSettle();
|
||||
await advanceAndSettle();
|
||||
|
||||
// Now await the schedule promise as it should complete
|
||||
await act(async () => {
|
||||
await schedulePromise;
|
||||
});
|
||||
|
||||
expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
@@ -567,8 +577,9 @@ describe('useReactToolScheduler', () => {
|
||||
args: {},
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
schedule(request, new AbortController().signal);
|
||||
let schedulePromise: Promise<void>;
|
||||
await act(async () => {
|
||||
schedulePromise = schedule(request, new AbortController().signal);
|
||||
});
|
||||
await advanceAndSettle();
|
||||
|
||||
@@ -580,8 +591,13 @@ describe('useReactToolScheduler', () => {
|
||||
await act(async () => {
|
||||
await capturedOnConfirmForTest?.(ToolConfirmationOutcome.Cancel);
|
||||
});
|
||||
|
||||
await advanceAndSettle();
|
||||
await advanceAndSettle();
|
||||
|
||||
// Now await the schedule promise
|
||||
await act(async () => {
|
||||
await schedulePromise;
|
||||
});
|
||||
|
||||
expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
@@ -628,8 +644,12 @@ describe('useReactToolScheduler', () => {
|
||||
args: {},
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
result.current[1](request, new AbortController().signal);
|
||||
let schedulePromise: Promise<void>;
|
||||
await act(async () => {
|
||||
schedulePromise = result.current[1](
|
||||
request,
|
||||
new AbortController().signal,
|
||||
);
|
||||
});
|
||||
await advanceAndSettle();
|
||||
|
||||
@@ -653,7 +673,11 @@ describe('useReactToolScheduler', () => {
|
||||
} as ToolResult);
|
||||
});
|
||||
await advanceAndSettle();
|
||||
await advanceAndSettle();
|
||||
|
||||
// Now await schedule
|
||||
await act(async () => {
|
||||
await schedulePromise;
|
||||
});
|
||||
|
||||
const completedCalls = onComplete.mock.calls[0][0] as ToolCall[];
|
||||
expect(completedCalls[0].status).toBe('success');
|
||||
@@ -699,8 +723,8 @@ describe('useReactToolScheduler', () => {
|
||||
{ callId: 'multi2', name: 'tool2', args: { p: 2 } } as any,
|
||||
];
|
||||
|
||||
act(() => {
|
||||
schedule(requests, new AbortController().signal);
|
||||
await act(async () => {
|
||||
await schedule(requests, new AbortController().signal);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -791,24 +815,30 @@ describe('useReactToolScheduler', () => {
|
||||
args: {},
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
schedule(request1, new AbortController().signal);
|
||||
let schedulePromise1: Promise<void>;
|
||||
let schedulePromise2: Promise<void>;
|
||||
|
||||
await act(async () => {
|
||||
schedulePromise1 = schedule(request1, new AbortController().signal);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
schedule(request2, new AbortController().signal);
|
||||
await act(async () => {
|
||||
schedulePromise2 = schedule(request2, new AbortController().signal);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for first to complete
|
||||
await act(async () => {
|
||||
await schedulePromise1;
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
status: 'success',
|
||||
@@ -816,13 +846,17 @@ describe('useReactToolScheduler', () => {
|
||||
response: expect.objectContaining({ resultDisplay: 'done display' }),
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for second to complete
|
||||
await act(async () => {
|
||||
await schedulePromise2;
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
status: 'success',
|
||||
|
||||
@@ -149,7 +149,7 @@ export function colorizeCode({
|
||||
const activeTheme = theme || themeManager.getActiveTheme();
|
||||
const showLineNumbers = hideLineNumbers
|
||||
? false
|
||||
: (settings?.merged.ui?.showLineNumbers ?? true);
|
||||
: settings.merged.ui.showLineNumbers;
|
||||
|
||||
const useMaxSizedBox = !isAlternateBufferEnabled(settings);
|
||||
try {
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('child_process');
|
||||
// fs (for /dev/tty)
|
||||
const mockFs = vi.hoisted(() => ({
|
||||
createWriteStream: vi.fn(),
|
||||
constants: { W_OK: 2 },
|
||||
}));
|
||||
vi.mock('node:fs', () => ({
|
||||
default: mockFs,
|
||||
@@ -63,10 +64,12 @@ const makeWritable = (opts?: { isTTY?: boolean; writeReturn?: boolean }) => {
|
||||
once: EventEmitter.prototype.once,
|
||||
on: EventEmitter.prototype.on,
|
||||
off: EventEmitter.prototype.off,
|
||||
removeAllListeners: EventEmitter.prototype.removeAllListeners,
|
||||
}) as unknown as EventEmitter & {
|
||||
write: Mock;
|
||||
end: Mock;
|
||||
isTTY?: boolean;
|
||||
removeAllListeners: Mock;
|
||||
};
|
||||
return stream;
|
||||
};
|
||||
@@ -125,9 +128,11 @@ describe('commandUtils', () => {
|
||||
// Setup clipboardy mock
|
||||
mockClipboardyWrite = clipboardy.write as Mock;
|
||||
|
||||
// default: no /dev/tty available
|
||||
// default: /dev/tty creation succeeds and emits 'open'
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
setTimeout(() => tty.emit('open'), 0);
|
||||
return tty;
|
||||
});
|
||||
|
||||
// default: stdio are not TTY for tests unless explicitly set
|
||||
@@ -224,7 +229,10 @@ describe('commandUtils', () => {
|
||||
it('writes OSC-52 to /dev/tty when in SSH', async () => {
|
||||
const testText = 'abc';
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
setTimeout(() => tty.emit('open'), 0);
|
||||
return tty;
|
||||
});
|
||||
|
||||
process.env['SSH_CONNECTION'] = '1';
|
||||
|
||||
@@ -242,7 +250,10 @@ describe('commandUtils', () => {
|
||||
it('wraps OSC-52 for tmux when in SSH', async () => {
|
||||
const testText = 'tmux-copy';
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
setTimeout(() => tty.emit('open'), 0);
|
||||
return tty;
|
||||
});
|
||||
|
||||
process.env['SSH_CONNECTION'] = '1';
|
||||
process.env['TMUX'] = '1';
|
||||
@@ -262,7 +273,10 @@ describe('commandUtils', () => {
|
||||
// ensure payload > chunk size (240) so there are multiple chunks
|
||||
const testText = 'x'.repeat(1200);
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
setTimeout(() => tty.emit('open'), 0);
|
||||
return tty;
|
||||
});
|
||||
|
||||
process.env['SSH_CONNECTION'] = '1';
|
||||
process.env['STY'] = 'screen-session';
|
||||
@@ -290,6 +304,13 @@ describe('commandUtils', () => {
|
||||
|
||||
process.env['SSH_TTY'] = '/dev/pts/1';
|
||||
|
||||
// Simulate /dev/tty access failure
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
setTimeout(() => tty.emit('error', new Error('EACCES')), 0);
|
||||
return tty;
|
||||
});
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
const b64 = Buffer.from(testText, 'utf8').toString('base64');
|
||||
@@ -303,7 +324,11 @@ describe('commandUtils', () => {
|
||||
const testText = 'no-tty';
|
||||
mockClipboardyWrite.mockResolvedValue(undefined);
|
||||
|
||||
// /dev/tty throws; stderr/stdout are non-TTY by default
|
||||
// /dev/tty throws or errors
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
process.env['SSH_CLIENT'] = 'client';
|
||||
|
||||
await copyToClipboard(testText);
|
||||
@@ -313,7 +338,10 @@ describe('commandUtils', () => {
|
||||
|
||||
it('resolves on drain when backpressure occurs', async () => {
|
||||
const tty = makeWritable({ isTTY: true, writeReturn: false });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
setTimeout(() => tty.emit('open'), 0);
|
||||
return tty;
|
||||
});
|
||||
process.env['SSH_CONNECTION'] = '1';
|
||||
|
||||
const p = copyToClipboard('drain-test');
|
||||
@@ -325,7 +353,10 @@ describe('commandUtils', () => {
|
||||
|
||||
it('propagates errors from OSC-52 write path', async () => {
|
||||
const tty = makeWritable({ isTTY: true, writeReturn: false });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
setTimeout(() => tty.emit('open'), 0);
|
||||
return tty;
|
||||
});
|
||||
process.env['SSH_CONNECTION'] = '1';
|
||||
|
||||
const p = copyToClipboard('err-test');
|
||||
@@ -349,7 +380,10 @@ describe('commandUtils', () => {
|
||||
|
||||
it('uses clipboardy when not in eligible env even if /dev/tty exists', async () => {
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
setTimeout(() => tty.emit('open'), 0);
|
||||
return tty;
|
||||
});
|
||||
const text = 'local-terminal';
|
||||
mockClipboardyWrite.mockResolvedValue(undefined);
|
||||
|
||||
@@ -360,9 +394,31 @@ describe('commandUtils', () => {
|
||||
expect(tty.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back if /dev/tty emits error (e.g. sandbox)', async () => {
|
||||
const testText = 'access-denied-fallback';
|
||||
process.env['SSH_CONNECTION'] = '1'; // normally would trigger OSC52 on TTY
|
||||
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
const stream = makeWritable({ isTTY: true });
|
||||
// Emit error instead of open
|
||||
setTimeout(() => stream.emit('error', new Error('EACCES')), 0);
|
||||
return stream;
|
||||
});
|
||||
|
||||
// Fallback to clipboardy since stdio isn't configured as TTY in this test (default from beforeEach)
|
||||
mockClipboardyWrite.mockResolvedValue(undefined);
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
expect(mockFs.createWriteStream).toHaveBeenCalled();
|
||||
expect(mockClipboardyWrite).toHaveBeenCalledWith(testText);
|
||||
});
|
||||
it('uses clipboardy in tmux when not in SSH/WSL', async () => {
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
setTimeout(() => tty.emit('open'), 0);
|
||||
return tty;
|
||||
});
|
||||
const text = 'tmux-local';
|
||||
mockClipboardyWrite.mockResolvedValue(undefined);
|
||||
|
||||
@@ -375,6 +431,24 @@ describe('commandUtils', () => {
|
||||
expect(tty.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back if /dev/tty hangs (timeout)', async () => {
|
||||
const testText = 'timeout-fallback';
|
||||
process.env['SSH_CONNECTION'] = '1';
|
||||
|
||||
mockFs.createWriteStream.mockImplementation(() =>
|
||||
// Stream that never emits open or error
|
||||
makeWritable({ isTTY: true }),
|
||||
);
|
||||
|
||||
mockClipboardyWrite.mockResolvedValue(undefined);
|
||||
|
||||
// Should complete even though stream hangs
|
||||
await copyToClipboard(testText);
|
||||
|
||||
expect(mockFs.createWriteStream).toHaveBeenCalled();
|
||||
expect(mockClipboardyWrite).toHaveBeenCalledWith(testText);
|
||||
});
|
||||
|
||||
it('skips /dev/tty on Windows and uses stderr fallback for OSC-52', async () => {
|
||||
mockProcess.platform = 'win32';
|
||||
const stderrStream = makeWritable({ isTTY: true });
|
||||
|
||||
@@ -65,20 +65,50 @@ const SCREEN_DCS_CHUNK_SIZE = 240;
|
||||
|
||||
type TtyTarget = { stream: Writable; closeAfter: boolean } | null;
|
||||
|
||||
const pickTty = (): TtyTarget => {
|
||||
// /dev/tty is only available on Unix-like systems (Linux, macOS, BSD, etc.)
|
||||
if (process.platform !== 'win32') {
|
||||
// Prefer the controlling TTY to avoid interleaving escape sequences with piped stdout.
|
||||
try {
|
||||
const devTty = fs.createWriteStream('/dev/tty');
|
||||
// Prevent unhandled 'error' events from crashing the process.
|
||||
devTty.on('error', () => {});
|
||||
return { stream: devTty, closeAfter: true };
|
||||
} catch {
|
||||
// fall through - /dev/tty not accessible
|
||||
}
|
||||
}
|
||||
const pickTty = (): Promise<TtyTarget> =>
|
||||
new Promise((resolve) => {
|
||||
// /dev/tty is only available on Unix-like systems (Linux, macOS, BSD, etc.)
|
||||
if (process.platform !== 'win32') {
|
||||
// Prefer the controlling TTY to avoid interleaving escape sequences with piped stdout.
|
||||
try {
|
||||
const devTty = fs.createWriteStream('/dev/tty');
|
||||
|
||||
// Safety timeout: if /dev/tty doesn't respond quickly, fallback to avoid hanging.
|
||||
const timeout = setTimeout(() => {
|
||||
// Remove listeners to prevent them from firing after timeout.
|
||||
devTty.removeAllListeners('open');
|
||||
devTty.removeAllListeners('error');
|
||||
devTty.destroy();
|
||||
resolve(getStdioTty());
|
||||
}, 100);
|
||||
|
||||
// If we can't open it (e.g. sandbox), we'll get an error.
|
||||
// We wait for 'open' to confirm it's usable, or 'error' to fallback.
|
||||
// If it opens, we resolve with the stream.
|
||||
devTty.once('open', () => {
|
||||
clearTimeout(timeout);
|
||||
devTty.removeAllListeners('error');
|
||||
// Prevent future unhandled 'error' events from crashing the process
|
||||
devTty.on('error', () => {});
|
||||
resolve({ stream: devTty, closeAfter: true });
|
||||
});
|
||||
|
||||
// If it errors immediately (or quickly), we fallback.
|
||||
devTty.once('error', () => {
|
||||
clearTimeout(timeout);
|
||||
devTty.removeAllListeners('open');
|
||||
resolve(getStdioTty());
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// fall through - synchronous failure
|
||||
}
|
||||
}
|
||||
|
||||
resolve(getStdioTty());
|
||||
});
|
||||
|
||||
const getStdioTty = (): TtyTarget => {
|
||||
if (process.stderr?.isTTY)
|
||||
return { stream: process.stderr, closeAfter: false };
|
||||
if (process.stdout?.isTTY)
|
||||
@@ -172,7 +202,7 @@ const writeAll = (stream: Writable, data: string): Promise<void> =>
|
||||
export const copyToClipboard = async (text: string): Promise<void> => {
|
||||
if (!text) return;
|
||||
|
||||
const tty = pickTty();
|
||||
const tty = await pickTty();
|
||||
|
||||
if (shouldUseOsc52(tty)) {
|
||||
const osc = buildOsc52(text);
|
||||
|
||||
@@ -212,13 +212,13 @@ describe('parseInputForHighlighting with Transformations', () => {
|
||||
});
|
||||
|
||||
it('should handle empty transformations array', () => {
|
||||
const line = 'Check out @test.png';
|
||||
const line = 'Check out @test_no_transform.png';
|
||||
const result = parseInputForHighlighting(line, 0, [], 0);
|
||||
|
||||
// Should fall back to default highlighting
|
||||
expect(result).toEqual([
|
||||
{ text: 'Check out ', type: 'default' },
|
||||
{ text: '@test.png', type: 'file' },
|
||||
{ text: '@test_no_transform.png', type: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { LruCache } from '@google/gemini-cli-core';
|
||||
import type { Transformation } from '../components/shared/text-buffer.js';
|
||||
import { cpLen, cpSlice } from './textUtils.js';
|
||||
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js';
|
||||
|
||||
export type HighlightToken = {
|
||||
text: string;
|
||||
@@ -18,12 +20,30 @@ export type HighlightToken = {
|
||||
// semicolon, common punctuation, and brackets.
|
||||
const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[^,\s;!?()[\]{}])+)/g;
|
||||
|
||||
const highlightCache = new LruCache<string, readonly HighlightToken[]>(
|
||||
LRU_BUFFER_PERF_CACHE_LIMIT,
|
||||
);
|
||||
|
||||
export function parseInputForHighlighting(
|
||||
text: string,
|
||||
index: number,
|
||||
transformations: Transformation[] = [],
|
||||
cursorCol?: number,
|
||||
): readonly HighlightToken[] {
|
||||
let isCursorInsideTransform = false;
|
||||
if (cursorCol !== undefined) {
|
||||
for (const transform of transformations) {
|
||||
if (cursorCol >= transform.logStart && cursorCol <= transform.logEnd) {
|
||||
isCursorInsideTransform = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = `${index === 0 ? 'F' : 'N'}:${isCursorInsideTransform ? cursorCol : 'NC'}:${text}`;
|
||||
const cached = highlightCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
HIGHLIGHT_REGEX.lastIndex = 0;
|
||||
|
||||
if (!text) {
|
||||
@@ -79,7 +99,7 @@ export function parseInputForHighlighting(
|
||||
tokens.push(...parseUntransformedInput(textBeforeTransformation));
|
||||
|
||||
const isCursorInside =
|
||||
typeof cursorCol === 'number' &&
|
||||
cursorCol !== undefined &&
|
||||
cursorCol >= transformation.logStart &&
|
||||
cursorCol <= transformation.logEnd;
|
||||
const transformationText = isCursorInside
|
||||
@@ -92,6 +112,9 @@ export function parseInputForHighlighting(
|
||||
|
||||
const textAfterFinalTransformation = cpSlice(text, column);
|
||||
tokens.push(...parseUntransformedInput(textAfterFinalTransformation));
|
||||
|
||||
highlightCache.set(cacheKey, tokens);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import stripAnsi from 'strip-ansi';
|
||||
import ansiRegex from 'ansi-regex';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
import stringWidth from 'string-width';
|
||||
import { LruCache } from '@google/gemini-cli-core';
|
||||
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Calculates the maximum width of a multi-line ASCII art string.
|
||||
@@ -28,9 +30,11 @@ export const getAsciiArtWidth = (asciiArt: string): number => {
|
||||
* code units so that surrogate‑pair emoji count as one "column".)
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
// Cache for code points to reduce GC pressure
|
||||
const codePointsCache = new Map<string, string[]>();
|
||||
// Cache for code points
|
||||
const MAX_STRING_LENGTH_TO_CACHE = 1000;
|
||||
const codePointsCache = new LruCache<string, string[]>(
|
||||
LRU_BUFFER_PERF_CACHE_LIMIT,
|
||||
);
|
||||
|
||||
export function toCodePoints(str: string): string[] {
|
||||
// ASCII fast path - check if all chars are ASCII (0-127)
|
||||
@@ -48,14 +52,14 @@ export function toCodePoints(str: string): string[] {
|
||||
// Cache short strings
|
||||
if (str.length <= MAX_STRING_LENGTH_TO_CACHE) {
|
||||
const cached = codePointsCache.get(str);
|
||||
if (cached) {
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const result = Array.from(str);
|
||||
|
||||
// Cache result (unlimited like Ink)
|
||||
// Cache result
|
||||
if (str.length <= MAX_STRING_LENGTH_TO_CACHE) {
|
||||
codePointsCache.set(str, result);
|
||||
}
|
||||
@@ -119,21 +123,26 @@ export function stripUnsafeCharacters(str: string): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
// String width caching for performance optimization
|
||||
const stringWidthCache = new Map<string, number>();
|
||||
const stringWidthCache = new LruCache<string, number>(
|
||||
LRU_BUFFER_PERF_CACHE_LIMIT,
|
||||
);
|
||||
|
||||
/**
|
||||
* Cached version of stringWidth function for better performance
|
||||
* Follows Ink's approach with unlimited cache (no eviction)
|
||||
*/
|
||||
export const getCachedStringWidth = (str: string): number => {
|
||||
// ASCII printable chars have width 1
|
||||
if (/^[\x20-\x7E]*$/.test(str)) {
|
||||
return str.length;
|
||||
// ASCII printable chars (32-126) have width 1.
|
||||
// This is a very frequent path, so we use a fast numeric check.
|
||||
if (str.length === 1) {
|
||||
const code = str.charCodeAt(0);
|
||||
if (code >= 0x20 && code <= 0x7e) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (stringWidthCache.has(str)) {
|
||||
return stringWidthCache.get(str)!;
|
||||
const cached = stringWidthCache.get(str);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let width: number;
|
||||
|
||||
@@ -27,7 +27,7 @@ export const calculateMainAreaWidth = (
|
||||
terminalWidth: number,
|
||||
settings: LoadedSettings,
|
||||
): number => {
|
||||
if (settings.merged.ui?.useFullWidth) {
|
||||
if (settings.merged.ui.useFullWidth) {
|
||||
if (isAlternateBufferEnabled(settings)) {
|
||||
return terminalWidth - 1;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('checkForUpdates', () => {
|
||||
});
|
||||
|
||||
it('should return null if disableUpdateNag is true', async () => {
|
||||
mockSettings.merged.general!.disableUpdateNag = true;
|
||||
mockSettings.merged.general.disableUpdateNag = true;
|
||||
const result = await checkForUpdates(mockSettings);
|
||||
expect(result).toBeNull();
|
||||
expect(getPackageJson).not.toHaveBeenCalled();
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function checkForUpdates(
|
||||
settings: LoadedSettings,
|
||||
): Promise<UpdateObject | null> {
|
||||
try {
|
||||
if (settings.merged.general?.disableUpdateNag) {
|
||||
if (settings.merged.general.disableUpdateNag) {
|
||||
return null;
|
||||
}
|
||||
// Skip update check when running from source (development mode)
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type LoadedSettings,
|
||||
} from '../config/settings.js';
|
||||
import type { ModifiedScope } from './skillSettings.js';
|
||||
import type { AgentOverride } from '@google/gemini-cli-core';
|
||||
|
||||
export type AgentActionStatus = 'success' | 'no-op' | 'error';
|
||||
|
||||
@@ -44,8 +43,8 @@ export function enableAgent(
|
||||
for (const scope of writableScopes) {
|
||||
if (isLoadableSettingScope(scope)) {
|
||||
const scopePath = settings.forScope(scope).path;
|
||||
const agentOverrides = settings.forScope(scope).settings.agents
|
||||
?.overrides as Record<string, AgentOverride> | undefined;
|
||||
const agentOverrides =
|
||||
settings.forScope(scope).settings.agents?.overrides;
|
||||
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
|
||||
|
||||
if (isDisabled) {
|
||||
@@ -105,9 +104,7 @@ export function disableAgent(
|
||||
}
|
||||
|
||||
const scopePath = settings.forScope(scope).path;
|
||||
const agentOverrides = settings.forScope(scope).settings.agents?.overrides as
|
||||
| Record<string, AgentOverride>
|
||||
| undefined;
|
||||
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
|
||||
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
|
||||
|
||||
if (isDisabled) {
|
||||
@@ -128,8 +125,8 @@ export function disableAgent(
|
||||
const alreadyDisabledInOther: ModifiedScope[] = [];
|
||||
|
||||
if (isLoadableSettingScope(otherScope)) {
|
||||
const otherOverrides = settings.forScope(otherScope).settings.agents
|
||||
?.overrides as Record<string, AgentOverride> | undefined;
|
||||
const otherOverrides =
|
||||
settings.forScope(otherScope).settings.agents?.overrides;
|
||||
if (otherOverrides?.[agentName]?.disabled === true) {
|
||||
alreadyDisabledInOther.push({
|
||||
scope: otherScope,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user