Compare commits

..

5 Commits

454 changed files with 5647 additions and 15899 deletions
-4
View File
@@ -45,10 +45,6 @@ Write precisely to ensure your instructions are unambiguous.
specific verbs.
- **Examples:** Use meaningful names in examples; avoid placeholders like
"foo" or "bar."
- **Quota and limit terminology:** For any content involving resource capacity
or using the word "quota" or "limit", strictly adhere to the guidelines in
the `quota-limit-style-guide.md` resource file. Generally, Use "quota" for the
administrative bucket and "limit" for the numerical ceiling.
### Formatting and syntax
Apply consistent formatting to make documentation visually organized and
@@ -1,61 +0,0 @@
# Style Guide: Quota vs. Limit
This guide defines the usage of "quota," "limit," and related terms in
user-facing interfaces.
## TL;DR
- **`quota`**: The administrative "bucket." Use for settings, billing, and
requesting increases. (e.g., "Adjust your storage **quota**.")
- **`limit`**: The real-time numerical "ceiling." Use for error messages when a
user is blocked. (e.g., "You've reached your request **limit**.")
- **When blocked, combine them:** Explain the **limit** that was hit and the
**quota** that is the remedy. (e.g., "You've reached the request **limit** for
your developer **quota**.")
- **Related terms:** Use `usage` for consumption tracking, `restriction` for
fixed rules, and `reset` for when a limit refreshes.
---
## Detailed Guidelines
### Definitions
- **Quota is the "what":** It identifies the category of resource being managed
(e.g., storage quota, GPU quota, request/prompt quota).
- **Limit is the "how much":** It defines the numerical boundary.
Use **quota** when referring to the administrative concept or the request for
more. Use **limit** when discussing the specific point of exhaustion.
### When to use "quota"
Use this term for **account management, billing, and settings.** It describes
the entitlement the user has purchased or been assigned.
**Examples:**
- **Navigation label:** Quota and usage
- **Contextual help:** Your **usage quota** is managed by your organization. To
request an increase, contact your administrator.
### When to use "limit"
Use this term for **real-time feedback, notifications, and error messages.** It
identifies the specific wall the user just hit.
**Examples:**
- **Error message:** Youve reached the 50-request-per-minute **limit**.
- **Inline warning:** Input exceeds the 32k token **limit**.
### How to use both together
When a user is blocked, combine both terms to explain the **event** (limit) and
the **remedy** (quota).
**Example:**
- **Heading:** Daily usage limit reached
- **Body:** You've reached the maximum daily capacity for your developer quota.
To continue working today, upgrade your quota.
-1
View File
@@ -14,4 +14,3 @@
# Docs have a dedicated approver group in addition to maintainers
/docs/ @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs
/README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs
-8
View File
@@ -290,7 +290,6 @@ jobs:
with:
ref: '${{ needs.parse_run_context.outputs.sha }}'
repository: '${{ needs.parse_run_context.outputs.repository }}'
fetch-depth: 0
- name: 'Set up Node.js 20.x'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
@@ -303,14 +302,7 @@ jobs:
- name: 'Build project'
run: 'npm run build'
- name: 'Check if evals should run'
id: 'check_evals'
run: |
SHOULD_RUN=$(node scripts/changed_prompt.js)
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
- name: 'Run Evals (Required to pass)'
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
run: 'npm run test:always_passing_evals'
+1
View File
@@ -117,6 +117,7 @@ jobs:
name: 'Slow E2E - Win'
runs-on: 'gemini-cli-windows-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -23,10 +23,6 @@ jobs:
steps:
- name: 'Generate GitHub App Token'
id: 'generate_token'
env:
APP_ID: '${{ secrets.APP_ID }}'
if: |-
${{ env.APP_ID != '' }}
uses: 'actions/create-github-app-token@v2'
with:
app-id: '${{ secrets.APP_ID }}'
@@ -37,7 +33,7 @@ jobs:
env:
DRY_RUN: '${{ inputs.dry_run }}'
with:
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
github-token: '${{ steps.generate_token.outputs.token }}'
script: |
const dryRun = process.env.DRY_RUN === 'true';
const thirtyDaysAgo = new Date();
+1 -41
View File
@@ -25,7 +25,7 @@ jobs:
if: |-
github.repository == 'google-gemini/gemini-cli' &&
github.event_name == 'issue_comment' &&
(contains(github.event.comment.body, '/assign') || contains(github.event.comment.body, '/unassign'))
contains(github.event.comment.body, '/assign')
runs-on: 'ubuntu-latest'
steps:
- name: 'Generate GitHub App Token'
@@ -38,7 +38,6 @@ jobs:
permission-issues: 'write'
- name: 'Assign issue to user'
if: "contains(github.event.comment.body, '/assign')"
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
@@ -109,42 +108,3 @@ jobs:
issue_number: issueNumber,
body: `👋 @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).`
});
- name: 'Unassign issue from user'
if: "contains(github.event.comment.body, '/unassign')"
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |
const issueNumber = context.issue.number;
const commenter = context.actor;
const owner = context.repo.owner;
const repo = context.repo.repo;
const commentBody = context.payload.comment.body.trim();
if (commentBody !== '/unassign') {
return;
}
const issue = await github.rest.issues.get({
owner: owner,
repo: repo,
issue_number: issueNumber,
});
const isAssigned = issue.data.assignees.some(assignee => assignee.login === commenter);
if (isAssigned) {
await github.rest.issues.removeAssignees({
owner: owner,
repo: repo,
issue_number: issueNumber,
assignees: [commenter]
});
await github.rest.issues.createComment({
owner: owner,
repo: repo,
issue_number: issueNumber,
body: `👋 @${commenter}, you have been unassigned from this issue.`
});
}
-160
View File
@@ -1,160 +0,0 @@
name: 'Test Build Binary'
on:
workflow_dispatch:
permissions:
contents: 'read'
defaults:
run:
shell: 'bash'
jobs:
build-node-binary:
name: 'Build Binary (${{ matrix.os }})'
runs-on: '${{ matrix.os }}'
strategy:
fail-fast: false
matrix:
include:
- os: 'ubuntu-latest'
platform_name: 'linux-x64'
arch: 'x64'
- os: 'windows-latest'
platform_name: 'win32-x64'
arch: 'x64'
- os: 'macos-latest' # Apple Silicon (ARM64)
platform_name: 'darwin-arm64'
arch: 'arm64'
- os: 'macos-latest' # Intel (x64) running on ARM via Rosetta
platform_name: 'darwin-x64'
arch: 'x64'
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
- name: 'Optimize Windows Performance'
if: "matrix.os == 'windows-latest'"
run: |
Set-MpPreference -DisableRealtimeMonitoring $true
Stop-Service -Name "wsearch" -Force -ErrorAction SilentlyContinue
Set-Service -Name "wsearch" -StartupType Disabled
Stop-Service -Name "SysMain" -Force -ErrorAction SilentlyContinue
Set-Service -Name "SysMain" -StartupType Disabled
shell: 'powershell'
- name: 'Set up Node.js'
uses: 'actions/setup-node@v4'
with:
node-version-file: '.nvmrc'
architecture: '${{ matrix.arch }}'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Check Secrets'
id: 'check_secrets'
run: |
echo "has_win_cert=${{ secrets.WINDOWS_PFX_BASE64 != '' }}" >> "$GITHUB_OUTPUT"
echo "has_mac_cert=${{ secrets.MACOS_CERT_P12_BASE64 != '' }}" >> "$GITHUB_OUTPUT"
- name: 'Setup Windows SDK (Windows)'
if: "matrix.os == 'windows-latest'"
uses: 'microsoft/setup-msbuild@v2'
- name: 'Add Signtool to Path (Windows)'
if: "matrix.os == 'windows-latest'"
run: |
$signtoolPath = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty DirectoryName
echo "Found signtool at: $signtoolPath"
echo "$signtoolPath" >> $env:GITHUB_PATH
shell: 'pwsh'
- name: 'Setup macOS Keychain'
if: "startsWith(matrix.os, 'macos') && steps.check_secrets.outputs.has_mac_cert == 'true' && github.event_name != 'pull_request'"
env:
BUILD_CERTIFICATE_BASE64: '${{ secrets.MACOS_CERT_P12_BASE64 }}'
P12_PASSWORD: '${{ secrets.MACOS_CERT_PASSWORD }}'
KEYCHAIN_PASSWORD: 'temp-password'
run: |
# Create the P12 file
echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > certificate.p12
# Create a temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import the certificate
security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign
# Allow codesign to access it
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Set Identity for build script
echo "APPLE_IDENTITY=${{ secrets.MACOS_CERT_IDENTITY }}" >> "$GITHUB_ENV"
- name: 'Setup Windows Certificate'
if: "matrix.os == 'windows-latest' && steps.check_secrets.outputs.has_win_cert == 'true' && github.event_name != 'pull_request'"
env:
PFX_BASE64: '${{ secrets.WINDOWS_PFX_BASE64 }}'
PFX_PASSWORD: '${{ secrets.WINDOWS_PFX_PASSWORD }}'
run: |
$pfx_cert_byte = [System.Convert]::FromBase64String("$env:PFX_BASE64")
$certPath = Join-Path (Get-Location) "cert.pfx"
[IO.File]::WriteAllBytes($certPath, $pfx_cert_byte)
echo "WINDOWS_PFX_FILE=$certPath" >> $env:GITHUB_ENV
echo "WINDOWS_PFX_PASSWORD=$env:PFX_PASSWORD" >> $env:GITHUB_ENV
shell: 'pwsh'
- name: 'Build Binary'
run: 'npm run build:binary'
- name: 'Build Core Package'
run: 'npm run build -w @google/gemini-cli-core'
- name: 'Verify Output Exists'
run: |
if [ -f "dist/${{ matrix.platform_name }}/gemini" ]; then
echo "Binary found at dist/${{ matrix.platform_name }}/gemini"
elif [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then
echo "Binary found at dist/${{ matrix.platform_name }}/gemini.exe"
else
echo "Error: Binary not found in dist/${{ matrix.platform_name }}/"
ls -R dist/
exit 1
fi
- name: 'Smoke Test Binary'
run: |
echo "Running binary smoke test..."
if [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then
"./dist/${{ matrix.platform_name }}/gemini.exe" --version
else
"./dist/${{ matrix.platform_name }}/gemini" --version
fi
- name: 'Run Integration Tests'
if: "github.event_name != 'pull_request'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
run: |
echo "Running integration tests with binary..."
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
BINARY_PATH="$(cygpath -m "$(pwd)/dist/${{ matrix.platform_name }}/gemini.exe")"
else
BINARY_PATH="$(pwd)/dist/${{ matrix.platform_name }}/gemini"
fi
echo "Using binary at $BINARY_PATH"
export INTEGRATION_TEST_GEMINI_BINARY_PATH="$BINARY_PATH"
npm run test:integration:sandbox:none -- --testTimeout=600000
- name: 'Upload Artifact'
uses: 'actions/upload-artifact@v4'
with:
name: 'gemini-cli-${{ matrix.platform_name }}'
path: 'dist/${{ matrix.platform_name }}/'
retention-days: 5
+1 -1
View File
@@ -61,4 +61,4 @@ gemini-debug.log
.genkit
.gemini-clipboard/
.eslintcache
evals/logs/
evals/logs/
+4 -7
View File
@@ -75,14 +75,11 @@ Replace `<PR_NUMBER>` with your pull request number. Authors are encouraged to
run this on their own PRs for self-review, and reviewers should use it to
augment their manual review process.
### Self-assigning and unassigning issues
### Self assigning issues
To assign an issue to yourself, simply add a comment with the text `/assign`. To
unassign yourself from an issue, add a comment with the text `/unassign`.
The comment must contain only that text and nothing else. These commands will
assign or unassign the issue as requested, provided the conditions are met
(e.g., an issue must be unassigned to be assigned).
To assign an issue to yourself, simply add a comment with the text `/assign`.
The comment must contain only that text and nothing else. This command will
assign the issue to you, provided it is not already assigned.
Please note that you can have a maximum of 3 issues assigned to you at any given
time.
+9 -11
View File
@@ -282,14 +282,14 @@ gemini
quickly.
- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed
auth configuration.
- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and
- [**Configuration Guide**](./docs/get-started/configuration.md) - Settings and
customization.
- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) -
Productivity tips.
- [**Keyboard Shortcuts**](./docs/cli/keyboard-shortcuts.md) - Productivity
tips.
### Core Features
- [**Commands Reference**](./docs/reference/commands.md) - All slash commands
- [**Commands Reference**](./docs/cli/commands.md) - All slash commands
(`/help`, `/chat`, etc).
- [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own
reusable commands.
@@ -323,16 +323,15 @@ gemini
- [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a
corporate environment.
- [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking.
- [**Tools API Development**](./docs/reference/tools-api.md) - Create custom
tools.
- [**Tools API Development**](./docs/core/tools-api.md) - Create custom tools.
- [**Local development**](./docs/local-development.md) - Local development
tooling.
### Troubleshooting & Support
- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common
issues and solutions.
- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions.
- [**Troubleshooting Guide**](./docs/troubleshooting.md) - Common issues and
solutions.
- [**FAQ**](./docs/faq.md) - Frequently asked questions.
- Use `/bug` command to report issues directly from the CLI.
### Using MCP Servers
@@ -378,8 +377,7 @@ for planned features and priorities.
### Uninstall
See the [Uninstall Guide](./docs/resources/uninstall.md) for removal
instructions.
See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
## 📄 Legal
+2 -3
View File
@@ -464,9 +464,8 @@ on GitHub.
page in their default browser directly from the CLI using the `/extension`
explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846)
by [@JayadityaGit](https://github.com/JayadityaGit)).
- **Configurable compression:** Users can modify the context compression
threshold in `/settings` (decimal with percentage display). The default has
been made more proactive
- **Configurable compression:** Users can modify the compression threshold in
`/settings`. The default has been made more proactive
([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by
[@scidomino](https://github.com/scidomino)).
- **API key authentication:** Users can now securely enter and store their
+7 -38
View File
@@ -21,15 +21,13 @@ implementation. It allows you to:
- [Entering Plan Mode](#entering-plan-mode)
- [Planning Workflow](#planning-workflow)
- [Exiting Plan Mode](#exiting-plan-mode)
- [Commands](#commands)
- [Tool Restrictions](#tool-restrictions)
- [Customizing Planning with Skills](#customizing-planning-with-skills)
- [Customizing Policies](#customizing-policies)
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
- [Example: Enable custom subagents in Plan Mode](#example-enable-custom-subagents-in-plan-mode)
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
- [Automatic Model Routing](#automatic-model-routing)
- [Cleanup](#cleanup)
## Enabling Plan Mode
@@ -111,9 +109,8 @@ structure, and consultation level are proportional to the task's complexity:
- **Iterate:** Provide feedback to refine the plan.
- **Refine manually:** Press **Ctrl + X** to open the plan file in your
[preferred external editor]. This allows you to manually refine the plan
steps before approval. If you make any changes and save the file, the CLI
will automatically send the updated plan back to the agent for review and
iteration.
steps before approval. The CLI will automatically refresh and show the
updated plan after you save and close the editor.
For more complex or specialized planning tasks, you can
[customize the planning workflow with skills](#customizing-planning-with-skills).
@@ -127,10 +124,6 @@ To exit Plan Mode, you can:
- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
finalized plan for your approval.
### Commands
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
## Tool Restrictions
Plan Mode enforces strict safety policies to prevent accidental changes.
@@ -139,7 +132,6 @@ These are the only allowed tools:
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
- **Search:** [`grep_search`], [`google_web_search`]
- **Research Subagents:** [`codebase_investigator`], [`cli_help`]
- **Interaction:** [`ask_user`]
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
`postgres_read_schema`) are allowed.
@@ -210,17 +202,16 @@ priority = 100
modes = ["plan"]
```
#### Example: Enable custom subagents in Plan Mode
#### Example: Enable research subagents in Plan Mode
Built-in research [subagents] like [`codebase_investigator`] and [`cli_help`]
are enabled by default in Plan Mode. You can enable additional [custom
subagents] by adding a rule to your policy.
You can enable experimental research [subagents] like `codebase_investigator` to
help gather architecture details during the planning phase.
`~/.gemini/policies/research-subagents.toml`
```toml
[[rule]]
toolName = "my_custom_subagent"
toolName = "codebase_investigator"
decision = "allow"
priority = 100
modes = ["plan"]
@@ -298,24 +289,6 @@ performance. You can disable this automatic switching in your settings:
}
```
## Cleanup
By default, Gemini CLI automatically cleans up old session data, including all
associated plan files and task trackers.
- **Default behavior:** Sessions (and their plans) are retained for **30 days**.
- **Configuration:** You can customize this behavior via the `/settings` command
(search for **Session Retention**) or in your `settings.json` file. See
[session retention] for more details.
Manual deletion also removes all associated artifacts:
- **Command Line:** Use `gemini --delete-session <index|id>`.
- **Session Browser:** Press `/resume`, navigate to a session, and press `x`.
If you use a [custom plans directory](#custom-plan-directory-and-policies),
those files are not automatically deleted and must be managed manually.
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
@@ -326,10 +299,7 @@ those files are not automatically deleted and must be managed manually.
[MCP tools]: /docs/tools/mcp-server.md
[`save_memory`]: /docs/tools/memory.md
[`activate_skill`]: /docs/cli/skills.md
[`codebase_investigator`]: /docs/core/subagents.md#codebase_investigator
[`cli_help`]: /docs/core/subagents.md#cli_help
[subagents]: /docs/core/subagents.md
[custom subagents]: /docs/core/subagents.md#creating-custom-subagents
[policy engine]: /docs/reference/policy-engine.md
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
@@ -340,4 +310,3 @@ those files are not automatically deleted and must be managed manually.
[auto model]: /docs/reference/configuration.md#model-settings
[model routing]: /docs/cli/telemetry.md#model-routing
[preferred external editor]: /docs/reference/configuration.md#general
[session retention]: /docs/cli/session-management.md#session-retention
+7 -16
View File
@@ -121,36 +121,27 @@ session lengths.
### Session retention
By default, Gemini CLI automatically cleans up old session data to prevent your
history from growing indefinitely. When a session is deleted, Gemini CLI also
removes all associated data, including implementation plans, task trackers, tool
outputs, and activity logs.
The default policy is to **retain sessions for 30 days**.
#### Configuration
You can customize these policies using the `/settings` command or by manually
editing your `settings.json` file:
To prevent your history from growing indefinitely, enable automatic cleanup
policies in your settings.
```json
{
"general": {
"sessionRetention": {
"enabled": true,
"maxAge": "30d",
"maxCount": 50
"maxAge": "30d", // Keep sessions for 30 days
"maxCount": 50 // Keep the 50 most recent sessions
}
}
}
```
- **`enabled`**: (boolean) Master switch for session cleanup. Defaults to
`true`.
`false`.
- **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d",
"4w"). Sessions older than this are deleted. Defaults to `"30d"`.
"4w"). Sessions older than this are deleted.
- **`maxCount`**: (number) Maximum number of sessions to retain. The oldest
sessions exceeding this count are deleted. Defaults to undefined (unlimited).
sessions exceeding this count are deleted.
- **`minRetention`**: (string) Minimum retention period (safety limit). Defaults
to `"1d"`. Sessions newer than this period are never deleted by automatic
cleanup.
+10 -10
View File
@@ -32,8 +32,8 @@ they appear in the UI.
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
### Output
@@ -60,7 +60,7 @@ they appear in the UI.
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
@@ -89,13 +89,13 @@ they appear in the UI.
### Model
| UI Label | Setting | Description | Default |
| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
| UI Label | Setting | Description | Default |
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
### Context
+1 -9
View File
@@ -122,10 +122,7 @@ The manifest file defines the extension's behavior and configuration.
}
},
"contextFileName": "GEMINI.md",
"excludeTools": ["run_shell_command"],
"plan": {
"directory": ".gemini/plans"
}
"excludeTools": ["run_shell_command"]
}
```
@@ -160,11 +157,6 @@ The manifest file defines the extension's behavior and configuration.
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
command. Note that this differs from the MCP server `excludeTools`
functionality, which can be listed in the MCP server config.
- `plan`: Planning features configuration.
- `directory`: The directory where planning artifacts are stored. This serves
as a fallback if the user hasn't specified a plan directory in their
settings. If not specified by either the extension or the user, the default
is `~/.gemini/tmp/<project>/<session-id>/plans/`.
When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
-3
View File
@@ -270,9 +270,6 @@ Slash commands provide meta-level control over the CLI itself.
one has been generated.
- **Note:** This feature requires the `experimental.plan` setting to be
enabled in your configuration.
- **Sub-commands:**
- **`copy`**:
- **Description:** Copy the currently approved plan to your clipboard.
### `/policies`
+8 -8
View File
@@ -159,12 +159,12 @@ their corresponding top-level category object in your `settings.json` file.
- **`general.sessionRetention.enabled`** (boolean):
- **Description:** Enable automatic session cleanup
- **Default:** `true`
- **Default:** `false`
- **`general.sessionRetention.maxAge`** (string):
- **Description:** Automatically delete chats older than this time period
(e.g., "30d", "7d", "24h", "1w")
- **Default:** `"30d"`
- **Default:** `undefined`
- **`general.sessionRetention.maxCount`** (number):
- **Description:** Alternative: Maximum number of sessions to keep (most
@@ -175,6 +175,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Minimum retention period (safety limit, defaults to "1d")
- **Default:** `"1d"`
- **`general.sessionRetention.warningAcknowledged`** (boolean):
- **Description:** INTERNAL: Whether the user has acknowledged the session
retention warning
- **Default:** `false`
#### `output`
- **`output.format`** (enum):
@@ -263,7 +268,7 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **`ui.footer.hideContextPercentage`** (boolean):
- **Description:** Hides the context window usage percentage.
- **Description:** Hides the context window remaining percentage.
- **Default:** `true`
- **`ui.hideFooter`** (boolean):
@@ -1014,11 +1019,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.taskTracker`** (boolean):
- **Description:** Enable task tracker tools.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.modelSteering`** (boolean):
- **Description:** Enable model steering (user hints) to guide the model
during tool execution.
-10
View File
@@ -152,13 +152,3 @@ available combinations.
inline when the cursor is over the placeholder.
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
view full content inline. Double-click again to collapse.
## Limitations
- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal):
- `shift+enter` is not supported.
- `shift+tab`
[is not supported](https://github.com/google-gemini/gemini-cli/issues/20314)
on Node 20 and earlier versions of Node 22.
- On macOS's [Terminal](<https://en.wikipedia.org/wiki/Terminal_(macOS)>):
- `shift+enter` is not supported.
+1 -8
View File
@@ -94,14 +94,7 @@
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{
"label": "Hooks",
"collapsed": true,
"items": [
{ "label": "Overview", "slug": "docs/hooks" },
{ "label": "Reference", "slug": "docs/hooks/reference" }
]
},
{ "label": "Hooks", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
-3
View File
@@ -88,9 +88,6 @@ const cliConfig = {
outfile: 'bundle/gemini.js',
define: {
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify(
pkg.config?.sandboxImageUri,
),
},
plugins: createWasmPlugins(),
alias: {
+12 -35
View File
@@ -25,18 +25,6 @@ const __dirname = path.dirname(__filename);
const projectRoot = __dirname;
const currentYear = new Date().getFullYear();
const commonRestrictedSyntaxRules = [
{
selector: 'CallExpression[callee.name="require"]',
message: 'Avoid using require(). Use ES6 imports instead.',
},
{
selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])',
message:
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
},
];
export default tseslint.config(
{
// Global ignores
@@ -132,7 +120,18 @@ export default tseslint.config(
'no-cond-assign': 'error',
'no-debugger': 'error',
'no-duplicate-case': 'error',
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
'no-restricted-syntax': [
'error',
{
selector: 'CallExpression[callee.name="require"]',
message: 'Avoid using require(). Use ES6 imports instead.',
},
{
selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])',
message:
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
},
],
'no-unsafe-finally': 'error',
'no-unused-expressions': 'off', // Disable base rule
'@typescript-eslint/no-unused-expressions': [
@@ -172,28 +171,6 @@ export default tseslint.config(
],
},
},
{
// API Response Optionality enforcement for Code Assist
files: ['packages/core/src/code_assist/**/*.{ts,tsx}'],
rules: {
'no-restricted-syntax': [
'error',
...commonRestrictedSyntaxRules,
{
selector:
'TSInterfaceDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])',
message:
'All fields in API response interfaces (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.',
},
{
selector:
'TSTypeAliasDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])',
message:
'All fields in API response types (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.',
},
],
},
},
{
// Rules that only apply to product code
files: ['packages/*/src/**/*.{ts,tsx}'],
+1 -44
View File
@@ -3,8 +3,7 @@
Behavioral evaluations (evals) are tests designed to validate the agent's
behavior in response to specific prompts. They serve as a critical feedback loop
for changes to system prompts, tool definitions, and other model-steering
mechanisms, and as a tool for assessing feature reliability by model, and
preventing regressions.
mechanisms.
## Why Behavioral Evals?
@@ -31,48 +30,6 @@ CLI's features.
those that are generally reliable but might occasionally vary
(`USUALLY_PASSES`).
## Best Practices
When designing behavioral evals, aim for scenarios that accurately reflect
real-world usage while remaining small and maintainable.
- **Realistic Complexity**: Evals should be complicated enough to be
"realistic." They should operate on actual files and a source directory,
mirroring how a real agent interacts with a workspace. Remember that the agent
may behave differently in a larger codebase, so we want to avoid scenarios
that are too simple to be realistic.
- _Good_: An eval that provides a small, functional React component and asks
the agent to add a specific feature, requiring it to read the file,
understand the context, and write the correct changes.
- _Bad_: An eval that simply asks the agent a trivia question or asks it to
write a generic script without providing any local workspace context.
- **Maintainable Size**: Evals should be small enough to reason about and
maintain. We probably can't check in an entire repo as a test case, though
over time we will want these evals to mature into more and more realistic
scenarios.
- _Good_: A test setup with 2-3 files (e.g., a source file, a config file, and
a test file) that isolates the specific behavior being evaluated.
- _Bad_: A test setup containing dozens of files from a complex framework
where the setup logic itself is prone to breaking.
- **Unambiguous and Reliable Assertions**: Assertions must be clear and specific
to ensure the test passes for the right reason.
- _Good_: Checking that a modified file contains a specific AST node or exact
string, or verifying that a tool was called with with the right parameters.
- _Bad_: Only checking for a tool call, which could happen for an unrelated
reason. Expecting specific LLM output.
- **Fail First**: Have tests that failed before your prompt or tool change. We
want to be sure the test fails before your "fix". It's pretty easy to
accidentally create a passing test that asserts behaviors we get for free. In
general, every eval should be accompanied by prompt change, and most prompt
changes should be accompanied by an eval.
- _Good_: Observing a failure, writing an eval that reliably reproduces the
failure, modifying the prompt/tool, and then verifying the eval passes.
- _Bad_: Writing an eval that passes on the first run and assuming your new
prompt change was responsible.
- **Less is More**: Prefer fewer, more realistic tests that assert the major
paths vs. more tests that are more unit-test like. These are evals, so the
value is in testing how the agent works in a semi-realistic scenario.
## Creating an Evaluation
Evaluations are located in the `evals` directory. Each evaluation is a Vitest
-92
View File
@@ -1,92 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('ask_user', () => {
evalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool to present multiple choice options',
prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
assert: async (rig) => {
const wasToolCalled = await rig.waitForToolCall('ask_user');
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
},
});
evalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool to clarify ambiguous requirements',
files: {
'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
},
prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`,
assert: async (rig) => {
const wasToolCalled = await rig.waitForToolCall('ask_user');
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
},
});
evalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool before performing significant ambiguous rework',
files: {
'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
'packages/core/src/util.ts': '// util\nexport function help() {}',
'packages/core/package.json': JSON.stringify({
name: '@google/gemini-cli-core',
}),
'README.md': '# Gemini CLI',
},
prompt: `Refactor the entire core package to be better.`,
assert: async (rig) => {
const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
true,
);
const wasAskUserCalled = await rig.waitForToolCall('ask_user');
expect(
wasAskUserCalled,
'Expected ask_user tool to be called to clarify the significant rework',
).toBe(true);
},
});
// --- Regression Tests for Recent Fixes ---
// Regression test for issue #20177: Ensure the agent does not use `ask_user` to
// confirm shell commands. Fixed via prompt refinements and tool definition
// updates to clarify that shell command confirmation is handled by the UI.
// See fix: https://github.com/google-gemini/gemini-cli/pull/20504
evalTest('USUALLY_PASSES', {
name: 'Agent does NOT use AskUser to confirm shell commands',
files: {
'package.json': JSON.stringify({
scripts: { build: 'echo building' },
}),
},
prompt: `Run 'npm run build' in the current directory.`,
assert: async (rig) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const wasShellCalled = toolLogs.some(
(log) => log.toolRequest.name === 'run_shell_command',
);
const wasAskUserCalled = toolLogs.some(
(log) => log.toolRequest.name === 'ask_user',
);
expect(
wasShellCalled,
'Expected run_shell_command tool to be called',
).toBe(true);
expect(
wasAskUserCalled,
'ask_user should not be called to confirm shell commands',
).toBe(false);
},
});
});
+2 -3
View File
@@ -165,15 +165,14 @@ describe('Hooks Agent Flow', () => {
// BeforeModel hook to track message counts across LLM calls
const messageCountFile = join(rig.testDir!, 'message-counts.json');
const escapedPath = JSON.stringify(messageCountFile);
const beforeModelScript = `
const fs = require('fs');
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
const messageCount = input.llm_request?.contents?.length || 0;
let counts = [];
try { counts = JSON.parse(fs.readFileSync(${escapedPath}, 'utf-8')); } catch (e) {}
try { counts = JSON.parse(fs.readFileSync(${JSON.stringify(messageCountFile)}, 'utf-8')); } catch (e) {}
counts.push(messageCount);
fs.writeFileSync(${escapedPath}, JSON.stringify(counts));
fs.writeFileSync(${JSON.stringify(messageCountFile)}, JSON.stringify(counts));
console.log(JSON.stringify({ decision: 'allow' }));
`;
const beforeModelScriptPath = rig.createScript(
+1 -3
View File
@@ -81,9 +81,7 @@ describe('JSON output', () => {
const message = (thrown as Error).message;
// Use a regex to find the first complete JSON object in the string
// We expect the JSON to start with a quote (e.g. {"error": ...}) to avoid
// matching random error objects printed to stderr (like ENOENT).
const jsonMatch = message.match(/{\s*"[\s\S]*}/);
const jsonMatch = message.match(/{[\s\S]*}/);
// Fail if no JSON-like text was found
expect(
+32 -85
View File
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js';
import { TestRig, checkModelOutputContent } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
@@ -64,98 +62,50 @@ describe('Plan Mode', () => {
});
});
it('should allow write_file to the plans directory in plan mode', async () => {
const plansDir = '.gemini/tmp/foo/123/plans';
const testName =
'should allow write_file to the plans directory in plan mode';
await rig.setup(testName, {
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
},
general: {
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
it.skip('should allow write_file only in the plans directory in plan mode', async () => {
await rig.setup(
'should allow write_file only in the plans directory in plan mode',
{
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
allowed: ['write_file'],
},
general: { defaultApprovalMode: 'plan' },
},
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const run = await rig.runInteractive({
// We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
// Verify that write_file outside of plan directory fails
await rig.run({
approvalMode: 'plan',
stdin:
'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
});
await run.type('Create a file called plan.md in the plans directory.');
await run.type('\r');
await rig.expectToolCallSuccess(['write_file'], 30000, (args) =>
args.includes('plan.md'),
);
const toolLogs = rig.readToolLogs();
const planWrite = toolLogs.find(
const writeLogs = toolLogs.filter(
(l) => l.toolRequest.name === 'write_file',
);
const planWrite = writeLogs.find(
(l) =>
l.toolRequest.name === 'write_file' &&
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'),
);
expect(planWrite?.toolRequest.success).toBe(true);
});
it('should deny write_file to non-plans directory in plan mode', async () => {
const plansDir = '.gemini/tmp/foo/123/plans';
const testName =
'should deny write_file to non-plans directory in plan mode';
await rig.setup(testName, {
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
},
general: {
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
},
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
const blockedWrite = writeLogs.find((l) =>
l.toolRequest.args.includes('hello.txt'),
);
const run = await rig.runInteractive({
approvalMode: 'plan',
});
await run.type('Create a file called hello.txt in the current directory.');
await run.type('\r');
const toolLogs = rig.readToolLogs();
const writeLog = toolLogs.find(
(l) =>
l.toolRequest.name === 'write_file' &&
l.toolRequest.args.includes('hello.txt'),
);
// In Plan Mode, writes outside the plans directory should be blocked.
// Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail.
if (writeLog) {
expect(writeLog.toolRequest.success).toBe(false);
// Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
if (blockedWrite) {
expect(blockedWrite?.toolRequest.success).toBe(false);
}
expect(planWrite?.toolRequest.success).toBe(true);
});
it('should be able to enter plan mode from default mode', async () => {
@@ -169,12 +119,6 @@ describe('Plan Mode', () => {
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
// Start in default mode and ask to enter plan mode.
await rig.run({
approvalMode: 'default',
@@ -182,7 +126,10 @@ describe('Plan Mode', () => {
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
});
const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode');
const enterPlanCallFound = await rig.waitForToolCall(
'enter_plan_mode',
10000,
);
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
true,
);
+1 -6
View File
@@ -18,7 +18,6 @@ const { shell } = getShellConfiguration();
function getLineCountCommand(): { command: string; tool: string } {
switch (shell) {
case 'powershell':
return { command: `Measure-Object -Line`, tool: 'Measure-Object' };
case 'cmd':
return { command: `find /c /v`, tool: 'find' };
case 'bash':
@@ -239,12 +238,8 @@ describe('run_shell_command', () => {
});
it('should succeed in yolo mode', async () => {
const isWindows = process.platform === 'win32';
await rig.setup('should succeed in yolo mode', {
settings: {
tools: { core: ['run_shell_command'] },
shell: isWindows ? { enableInteractiveShell: false } : undefined,
},
settings: { tools: { core: ['run_shell_command'] } },
});
const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n');
+23 -270
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"workspaces": [
"packages/*"
],
@@ -6298,36 +6298,16 @@
"node": ">= 12"
}
},
"node_modules/clipboard-image": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/clipboard-image/-/clipboard-image-0.1.0.tgz",
"integrity": "sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg==",
"license": "MIT",
"dependencies": {
"run-jxa": "^3.0.0"
},
"bin": {
"clipboard-image": "cli.js"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clipboardy": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.2.1.tgz",
"integrity": "sha512-RWp4E/ivQAzgF4QSWA9sjeW+Bjo+U2SvebkDhNIfO7y65eGdXPUxMTdIKYsn+bxM3ItPHGm3e68Bv3fgQ3mARw==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.0.0.tgz",
"integrity": "sha512-MQfKHaD09eP80Pev4qBxZLbxJK/ONnqfSYAPlCmPh+7BDboYtO/3BmB6HGzxDIT0SlTRc2tzS8lQqfcdLtZ0Kg==",
"license": "MIT",
"dependencies": {
"clipboard-image": "^0.1.0",
"execa": "^9.6.1",
"execa": "^9.6.0",
"is-wayland": "^0.1.0",
"is-wsl": "^3.1.0",
"is64bit": "^2.0.0",
"powershell-utils": "^0.2.0"
"is64bit": "^2.0.0"
},
"engines": {
"node": ">=20"
@@ -6583,9 +6563,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">=18"
},
@@ -6752,33 +6729,6 @@
"node": ">= 8"
}
},
"node_modules/crypto-random-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
"integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
"license": "MIT",
"dependencies": {
"type-fest": "^1.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/crypto-random-string/node_modules/type-fest": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -8469,9 +8419,9 @@
}
},
"node_modules/execa": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
@@ -8589,15 +8539,6 @@
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8849,24 +8790,13 @@
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 0.8"
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/finalhandler/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -11741,21 +11671,6 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/macos-version": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/macos-version/-/macos-version-6.0.0.tgz",
"integrity": "sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -11891,12 +11806,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -13447,18 +13356,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/powershell-utils": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
"integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14341,107 +14238,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/run-jxa/-/run-jxa-3.0.0.tgz",
"integrity": "sha512-4f2CrY7H+sXkKXJn/cE6qRA3z+NMVO7zvlZ/nUV0e62yWftpiLAfw5eV9ZdomzWd2TXWwEIiGjAT57+lWIzzvA==",
"license": "MIT",
"dependencies": {
"execa": "^5.1.1",
"macos-version": "^6.0.0",
"subsume": "^4.0.0",
"type-fest": "^2.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa/node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/run-jxa/node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa/node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/run-jxa/node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/run-jxa/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/run-jxa/node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/run-jxa/node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -15378,34 +15174,6 @@
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"license": "MIT"
},
"node_modules/subsume": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/subsume/-/subsume-4.0.0.tgz",
"integrity": "sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^5.0.0",
"unique-string": "^3.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/subsume/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/superagent": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz",
@@ -16388,21 +16156,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unique-string": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
"integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
"license": "MIT",
"dependencies": {
"crypto-random-string": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universal-user-agent": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
@@ -17303,7 +17056,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -17361,7 +17114,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -17373,7 +17126,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "~5.2.0",
"clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
@@ -17444,7 +17197,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -17709,7 +17462,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17724,7 +17477,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17741,7 +17494,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17758,7 +17511,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+4 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"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.34.0-nightly.20260303.34f0c1538"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -37,12 +37,10 @@
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js",
"build:binary": "node scripts/build_binary.js",
"bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces --if-present && npm run test:sea-launch",
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch",
"test": "npm run test --workspaces --if-present",
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
"test:sea-launch": "vitest run sea/sea-launch.test.js",
"test:always_passing_evals": "vitest run --config evals/vitest.config.ts",
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -5
View File
@@ -27,8 +27,7 @@ import {
type ToolCallConfirmationDetails,
type Config,
type UserTierId,
type ToolLiveOutput,
isSubagentProgress,
type AnsiOutput,
EDIT_TOOL_NAMES,
processRestorableToolCalls,
} from '@google/gemini-cli-core';
@@ -337,13 +336,11 @@ export class Task {
private _schedulerOutputUpdate(
toolCallId: string,
outputChunk: ToolLiveOutput,
outputChunk: string | AnsiOutput,
): void {
let outputAsText: string;
if (typeof outputChunk === 'string') {
outputAsText = outputChunk;
} else if (isSubagentProgress(outputChunk)) {
outputAsText = JSON.stringify(outputChunk);
} else {
outputAsText = outputChunk
.map((line) => line.map((token) => token.text).join(''))
+5 -44
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import express, { type Request } from 'express';
import express from 'express';
import type { AgentCard, Message } from '@a2a-js/sdk';
import {
@@ -13,9 +13,8 @@ import {
InMemoryTaskStore,
DefaultExecutionEventBus,
type AgentExecutionEvent,
UnauthenticatedUser,
} from '@a2a-js/sdk/server';
import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components
import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import type { AgentSettings } from '../types.js';
@@ -56,17 +55,8 @@ const coderAgentCard: AgentCard = {
pushNotifications: false,
stateTransitionHistory: true,
},
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
basicAuth: {
type: 'http',
scheme: 'basic',
},
},
security: [{ bearerAuth: [] }, { basicAuth: [] }],
securitySchemes: undefined,
security: undefined,
defaultInputModes: ['text'],
defaultOutputModes: ['text'],
skills: [
@@ -91,35 +81,6 @@ export function updateCoderAgentCardUrl(port: number) {
coderAgentCard.url = `http://localhost:${port}/`;
}
const customUserBuilder: UserBuilder = async (req: Request) => {
const auth = req.headers['authorization'];
if (auth) {
const scheme = auth.split(' ')[0];
logger.info(
`[customUserBuilder] Received Authorization header with scheme: ${scheme}`,
);
}
if (!auth) return new UnauthenticatedUser();
// 1. Bearer Auth
if (auth.startsWith('Bearer ')) {
const token = auth.substring(7);
if (token === 'valid-token') {
return { userName: 'bearer-user', isAuthenticated: true };
}
}
// 2. Basic Auth
if (auth.startsWith('Basic ')) {
const credentials = Buffer.from(auth.substring(6), 'base64').toString();
if (credentials === 'admin:password') {
return { userName: 'basic-user', isAuthenticated: true };
}
}
return new UnauthenticatedUser();
};
async function handleExecuteCommand(
req: express.Request,
res: express.Response,
@@ -243,7 +204,7 @@ export async function createApp() {
requestStorage.run({ req }, next);
});
const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder);
const appBuilder = new A2AExpressApp(requestHandler);
expressApp = appBuilder.setupRoutes(expressApp, '');
expressApp.use(express.json());
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260303.34f0c1538",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"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.34.0-nightly.20260303.34f0c1538"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -38,7 +38,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "~5.2.0",
"clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
-99
View File
@@ -19,8 +19,6 @@ import {
debugLogger,
ApprovalMode,
type MCPServerConfig,
type GeminiCLIExtension,
Storage,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import {
@@ -3526,101 +3524,4 @@ describe('loadCliConfig mcpEnabled', () => {
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
});
describe('extension plan settings', () => {
beforeEach(() => {
vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue(
'/mock/home/user/.gemini/tmp/test-project',
);
});
it('should use plan directory from active extension when user has not specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: true,
plan: { directory: 'ext-plans-dir' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
});
it('should NOT use plan directory from active extension when user has specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
general: {
plan: { directory: 'user-plans-dir' },
},
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: true,
plan: { directory: 'ext-plans-dir' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('user-plans-dir');
expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir');
});
it('should NOT use plan directory from inactive extension', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: false,
plan: { directory: 'ext-plans-dir-inactive' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).not.toContain(
'ext-plans-dir-inactive',
);
});
it('should use default path if neither user nor extension settings provide a plan directory', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
// No extensions providing plan directory
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
const config = await loadCliConfig(settings, 'test-session', argv);
// Should return the default managed temp directory path
expect(config.storage.getPlansDir()).toBe(
path.join(
'/mock',
'home',
'user',
'.gemini',
'tmp',
'test-project',
'test-session',
'plans',
),
);
});
});
});
+1 -8
View File
@@ -511,10 +511,6 @@ export async function loadCliConfig(
});
await extensionManager.loadExtensions();
const extensionPlanSettings = extensionManager
.getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let memoryContent: string | HierarchicalMemory = '';
@@ -830,11 +826,8 @@ export async function loadCliConfig(
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan?.directory
? settings.general.plan
: (extensionPlanSettings ?? settings.general?.plan),
planSettings: settings.general?.plan,
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -886,7 +886,6 @@ Would you like to attempt to install via "git clone" instead?`,
themes: config.themes,
rules,
checkers,
plan: config.plan,
};
} catch (e) {
debugLogger.error(
-9
View File
@@ -33,15 +33,6 @@ export interface ExtensionConfig {
* These themes will be registered when the extension is activated.
*/
themes?: CustomTheme[];
/**
* Planning features configuration contributed by this extension.
*/
plan?: {
/**
* The directory where planning artifacts are stored.
*/
directory?: string;
};
}
export interface ExtensionUpdateInfo {
@@ -10,15 +10,11 @@
<text x="0" y="53" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> * server2 (remote): https://remote.com </text>
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">This extension will append info to your gemini.md context using my-context.md </text>
<text x="0" y="87" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">This extension will exclude the following core tools: tool1,tool2 </text>
<text x="0" y="121" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-weight="bold">Agent Skills:</text>
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Agent Skills: </text>
<text x="0" y="155" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">This extension will install the following agent skills: </text>
<text x="0" y="189" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> * </text>
<text x="36" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">skill1</text>
<text x="90" y="189" fill="#ffffff" textLength="810" lengthAdjust="spacingAndGlyphs">: desc1 </text>
<text x="0" y="189" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> * skill1: desc1 </text>
<text x="0" y="206" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory) </text>
<text x="0" y="240" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> * </text>
<text x="36" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">skill2</text>
<text x="90" y="240" fill="#ffffff" textLength="810" lengthAdjust="spacingAndGlyphs">: desc2 </text>
<text x="0" y="240" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> * skill2: desc2 </text>
<text x="0" y="257" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory) </text>
<text x="0" y="308" fill="#cdcd00" textLength="891" lengthAdjust="spacingAndGlyphs">The extension you are about to install may have been created by a third-party developer and sourced</text>
<text x="0" y="325" fill="#cdcd00" textLength="882" lengthAdjust="spacingAndGlyphs">from a public repository. Google does not vet, endorse, or guarantee the functionality or security</text>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -5,11 +5,9 @@
<rect width="920" height="343" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Installing extension &quot;test-ext&quot;. </text>
<text x="0" y="36" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-weight="bold">Agent Skills:</text>
<text x="0" y="36" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Agent Skills: </text>
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">This extension will install the following agent skills: </text>
<text x="0" y="104" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> * </text>
<text x="36" y="104" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs" font-weight="bold">locked-skill</text>
<text x="144" y="104" fill="#ffffff" textLength="756" lengthAdjust="spacingAndGlyphs">: A skill in a locked dir </text>
<text x="0" y="104" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> * locked-skill: A skill in a locked dir </text>
<text x="0" y="121" fill="#ffffff" textLength="405" lengthAdjust="spacingAndGlyphs"> (Source: /mock/temp/dir/locked/SKILL.md) </text>
<text x="405" y="121" fill="#cd0000" textLength="342" lengthAdjust="spacingAndGlyphs">⚠️ (Could not count items in directory)</text>
<text x="0" y="172" fill="#cdcd00" textLength="891" lengthAdjust="spacingAndGlyphs">The extension you are about to install may have been created by a third-party developer and sourced</text>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -6,9 +6,7 @@
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Installing agent skill(s) from &quot;https://example.com/repo.git&quot;. </text>
<text x="0" y="36" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">The following agent skill(s) will be installing: </text>
<text x="0" y="70" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> * </text>
<text x="36" y="70" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">skill1</text>
<text x="90" y="70" fill="#ffffff" textLength="810" lengthAdjust="spacingAndGlyphs">: desc1 </text>
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> * skill1: desc1 </text>
<text x="0" y="87" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory) </text>
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Install Destination: /mock/target/dir </text>
<text x="0" y="155" fill="#cdcd00" textLength="882" lengthAdjust="spacingAndGlyphs">Agent skills inject specialized instructions and domain-specific knowledge into the agent&apos;s system</text>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+1 -3
View File
@@ -102,9 +102,7 @@ export async function loadSandboxConfig(
const packageJson = await getPackageJson(__dirname);
const image =
process.env['GEMINI_SANDBOX_IMAGE'] ??
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
packageJson?.config?.sandboxImageUri;
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri;
return command && image ? { command, image } : undefined;
}
+3
View File
@@ -185,6 +185,9 @@ export interface SessionRetentionSettings {
/** Minimum retention period (safety limit, defaults to "1d") */
minRetention?: string;
/** INTERNAL: Whether the user has acknowledged the session retention warning */
warningAcknowledged?: boolean;
}
export interface SettingsError {
+14 -30
View File
@@ -117,10 +117,6 @@ export interface SettingDefinition {
* For map-like objects without explicit `properties`, describes the shape of the values.
*/
additionalProperties?: SettingCollectionDefinition;
/**
* Optional unit to display after the value (e.g. '%').
*/
unit?: string;
/**
* Optional reference identifier for generators that emit a `$ref`.
*/
@@ -343,7 +339,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Session Cleanup',
category: 'General',
requiresRestart: false,
default: true as boolean,
default: false,
description: 'Enable automatic session cleanup',
showInDialog: true,
},
@@ -352,7 +348,7 @@ const SETTINGS_SCHEMA = {
label: 'Keep chat history',
category: 'General',
requiresRestart: false,
default: '30d' as string,
default: undefined as string | undefined,
description:
'Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w")',
showInDialog: true,
@@ -376,6 +372,16 @@ const SETTINGS_SCHEMA = {
description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`,
showInDialog: false,
},
warningAcknowledged: {
type: 'boolean',
label: 'Warning Acknowledged',
category: 'General',
requiresRestart: false,
default: false,
showInDialog: false,
description:
'INTERNAL: Whether the user has acknowledged the session retention warning',
},
},
description: 'Settings for automatic session cleanup.',
},
@@ -599,7 +605,7 @@ const SETTINGS_SCHEMA = {
category: 'UI',
requiresRestart: false,
default: true,
description: 'Hides the context window usage percentage.',
description: 'Hides the context window remaining percentage.',
showInDialog: true,
},
},
@@ -917,14 +923,13 @@ const SETTINGS_SCHEMA = {
},
compressionThreshold: {
type: 'number',
label: 'Context Compression Threshold',
label: 'Compression Threshold',
category: 'Model',
requiresRestart: true,
default: 0.5 as number,
description:
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
showInDialog: true,
unit: '%',
},
disableLoopDetection: {
type: 'boolean',
@@ -1475,18 +1480,6 @@ const SETTINGS_SCHEMA = {
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
showInDialog: true,
},
autoAddToPolicyByDefault: {
type: 'boolean',
label: 'Auto-add to Policy by Default',
category: 'Security',
requiresRestart: false,
default: true,
description: oneLine`
When enabled, the "Allow for all future sessions" option becomes the
default choice for low-risk tools in trusted workspaces.
`,
showInDialog: true,
},
blockGitExtensions: {
type: 'boolean',
label: 'Blocks extensions from Git',
@@ -1818,15 +1811,6 @@ const SETTINGS_SCHEMA = {
description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true,
},
taskTracker: {
type: 'boolean',
label: 'Task Tracker',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable task tracker tools.',
showInDialog: false,
},
modelSteering: {
type: 'boolean',
label: 'Model Steering',
+1 -1
View File
@@ -243,7 +243,7 @@ export async function startInteractiveUI(
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
startupWarnings={startupWarnings}
+3 -6
View File
@@ -528,13 +528,12 @@ export const mockSettings = new LoadedSettings(
// A minimal mock UIState to satisfy the context provider.
// Tests that need specific UIState values should provide their own.
const baseMockUiState = {
history: [],
renderMarkdown: true,
streamingState: StreamingState.Idle,
terminalWidth: 100,
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: 'black' as const,
terminalBackgroundColor: 'black',
cleanUiDetailsVisible: false,
allowPlanMode: true,
activePtyId: undefined,
@@ -553,9 +552,6 @@ const baseMockUiState = {
warningText: '',
},
bannerVisible: false,
nightly: false,
updateInfo: null,
pendingHistoryItems: [],
};
export const mockAppState: AppState = {
@@ -608,6 +604,7 @@ const mockUIActions: UIActions = {
revealCleanUiDetailsTemporarily: vi.fn(),
handleWarning: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
setActivePtyId: vi.fn(),
dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
@@ -756,7 +753,7 @@ export const renderWithProviders = (
<ConfigContext.Provider value={finalConfig}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider>
<VimModeProvider settings={finalSettings}>
<ShellFocusContext.Provider value={shellFocus}>
<SessionStatsProvider>
<StreamingContext.Provider
+3 -26
View File
@@ -89,7 +89,6 @@ export const generateSvgForTerminal = (terminal: Terminal): string => {
break;
}
}
if (contentRows === 0) contentRows = 1; // Minimum 1 row
const width = terminal.cols * charWidth + padding * 2;
@@ -114,9 +113,6 @@ export const generateSvgForTerminal = (terminal: Terminal): string => {
let currentFgHex: string | null = null;
let currentBgHex: string | null = null;
let currentIsBold = false;
let currentIsItalic = false;
let currentIsUnderline = false;
let currentBlockStartCol = -1;
let currentBlockText = '';
let currentBlockNumCells = 0;
@@ -132,20 +128,12 @@ export const generateSvgForTerminal = (terminal: Terminal): string => {
svg += ` <rect x="${xPos}" y="${yPos}" width="${rectWidth}" height="${charHeight}" fill="${currentBgHex}" />
`;
}
if (currentBlockText.trim().length > 0 || currentIsUnderline) {
if (currentBlockText.trim().length > 0) {
const fill = currentFgHex || '#ffffff'; // Default text color
const textWidth = currentBlockNumCells * charWidth;
let extraAttrs = '';
if (currentIsBold) extraAttrs += ' font-weight="bold"';
if (currentIsItalic) extraAttrs += ' font-style="italic"';
if (currentIsUnderline)
extraAttrs += ' text-decoration="underline"';
// Use textLength to ensure the block fits exactly into its designated cells
const textElement = `<text x="${xPos}" y="${yPos + 2}" fill="${fill}" textLength="${textWidth}" lengthAdjust="spacingAndGlyphs"${extraAttrs}>${escapeXml(currentBlockText)}</text>`;
svg += ` ${textElement}\n`;
svg += ` <text x="${xPos}" y="${yPos + 2}" fill="${fill}" textLength="${textWidth}" lengthAdjust="spacingAndGlyphs">${escapeXml(currentBlockText)}</text>
`;
}
}
}
@@ -176,27 +164,17 @@ export const generateSvgForTerminal = (terminal: Terminal): string => {
bgHex = tempFgHex || '#ffffff';
}
const isBold = !!cell.isBold();
const isItalic = !!cell.isItalic();
const isUnderline = !!cell.isUnderline();
let chars = cell.getChars();
if (chars === '') chars = ' '.repeat(cellWidth);
if (
fgHex !== currentFgHex ||
bgHex !== currentBgHex ||
isBold !== currentIsBold ||
isItalic !== currentIsItalic ||
isUnderline !== currentIsUnderline ||
currentBlockStartCol === -1
) {
finalizeBlock(x);
currentFgHex = fgHex;
currentBgHex = bgHex;
currentIsBold = isBold;
currentIsItalic = isItalic;
currentIsUnderline = isUnderline;
currentBlockStartCol = x;
currentBlockText = chars;
currentBlockNumCells = cellWidth;
@@ -207,7 +185,6 @@ export const generateSvgForTerminal = (terminal: Terminal): string => {
}
finalizeBlock(line.length);
}
svg += ` </g>\n</svg>`;
return svg;
};
+130
View File
@@ -2544,6 +2544,136 @@ describe('AppContainer State Management', () => {
});
});
describe('Expansion Persistence', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: ReturnType<typeof render>['stdin'];
const setupExpansionPersistenceTest = async (
HighPriorityChild?: React.FC,
) => {
const getTree = () => (
<SettingsContext.Provider value={mockSettings}>
<KeypressProvider config={mockConfig}>
<OverflowProvider>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
{HighPriorityChild && <HighPriorityChild />}
</OverflowProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
const renderResult = render(getTree());
stdin = renderResult.stdin;
await act(async () => {
vi.advanceTimersByTime(100);
});
rerender = () => renderResult.rerender(getTree());
unmount = () => renderResult.unmount();
};
const writeStdin = async (sequence: string) => {
await act(async () => {
stdin.write(sequence);
// Advance timers to allow escape sequence parsing and broadcasting
vi.advanceTimersByTime(100);
});
rerender();
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should reset expansion when a key is NOT handled by anyone', async () => {
await setupExpansionPersistenceTest();
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// Press a random key that no one handles (hits Low priority fallback)
await writeStdin('x');
// Should be reset to true (collapsed)
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should toggle expansion when Ctrl+O is pressed', async () => {
await setupExpansionPersistenceTest();
// Initial state is collapsed
expect(capturedUIState.constrainHeight).toBe(true);
// Press Ctrl+O to expand (Ctrl+O is sequence \x0f)
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(false);
// Press Ctrl+O again to collapse
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => {
const NavigationHandler = () => {
// use real useKeypress
useKeypress(
(key: Key) => {
if (key.name === 'up' || key.name === 'down') {
return true; // Handle navigation
}
return false;
},
{ isActive: true, priority: true }, // High priority
);
return null;
};
await setupExpansionPersistenceTest(NavigationHandler);
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// 1. Simulate Up arrow (handled by high priority child)
// CSI A is Up arrow
await writeStdin('\u001b[A');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 2. Simulate Down arrow (handled by high priority child)
// CSI B is Down arrow
await writeStdin('\u001b[B');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 3. Sanity check: press an unhandled key
await writeStdin('x');
// Should finally collapse
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
});
describe('Shortcuts Help Visibility', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let mockedUseKeypress: Mock;
+37 -4
View File
@@ -146,6 +146,7 @@ import { requestConsentInteractive } from '../config/extensions/consent.js';
import { useSessionBrowser } from './hooks/useSessionBrowser.js';
import { useSessionResume } from './hooks/useSessionResume.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
@@ -1108,6 +1109,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
backgroundShells,
dismissBackgroundShell,
retryStatus,
setActivePtyId,
} = useGeminiStream(
config.getGeminiClient(),
historyManager.history,
@@ -1547,6 +1549,28 @@ Logging in with Google... Restarting Gemini CLI to continue.
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
const handleAutoEnableRetention = useCallback(() => {
const userSettings = settings.forScope(SettingScope.User).settings;
const currentRetention = userSettings.general?.sessionRetention ?? {};
settings.setValue(SettingScope.User, 'general.sessionRetention', {
...currentRetention,
enabled: true,
maxAge: '30d',
warningAcknowledged: true,
});
}, [settings]);
const {
shouldShowWarning: shouldShowRetentionWarning,
checkComplete: retentionCheckComplete,
sessionsToDeleteCount,
} = useSessionRetentionCheck(
config,
settings.merged,
handleAutoEnableRetention,
);
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
@@ -1873,7 +1897,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(handleGlobalKeypress, {
isActive: true,
priority: KeypressPriority.Low,
});
useKeypress(
() => {
@@ -1989,7 +2016,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const nightly = props.version.includes('nightly');
const dialogsVisible =
shouldShowIdePrompt ||
(shouldShowRetentionWarning && retentionCheckComplete) ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
isPolicyUpdateDialogOpen ||
@@ -2176,7 +2203,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
history: historyManager.history,
historyManager,
isThemeDialogOpen,
shouldShowRetentionWarning:
shouldShowRetentionWarning && retentionCheckComplete,
sessionsToDeleteCount: sessionsToDeleteCount ?? 0,
themeError,
isAuthenticating,
isConfigInitialized,
@@ -2306,7 +2335,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
}),
[
isThemeDialogOpen,
shouldShowRetentionWarning,
retentionCheckComplete,
sessionsToDeleteCount,
themeError,
isAuthenticating,
isConfigInitialized,
@@ -2479,6 +2510,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
setActivePtyId,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
@@ -2571,6 +2603,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
setActivePtyId,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
@@ -2,20 +2,20 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
@@ -47,31 +47,34 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
"Notifications
Footer
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
Composer
"
`;
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
@@ -107,17 +110,20 @@ DialogManager
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
HistoryItemDisplay
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required │
@@ -140,9 +146,6 @@ HistoryItemDisplay
Notifications
Composer
"
+1 -1
View File
@@ -98,7 +98,7 @@ export function ApiAuthDialog({
return (
<Box
borderStyle="round"
borderColor={theme.ui.focus}
borderColor={theme.border.focused}
flexDirection="column"
padding={1}
width="100%"
+2 -2
View File
@@ -193,7 +193,7 @@ export function AuthDialog({
return (
<Box
borderStyle="round"
borderColor={theme.ui.focus}
borderColor={theme.border.focused}
flexDirection="row"
padding={1}
width="100%"
@@ -209,7 +209,7 @@ export function AuthDialog({
return (
<Box
borderStyle="round"
borderColor={theme.ui.focus}
borderColor={theme.border.focused}
flexDirection="row"
padding={1}
width="100%"
@@ -21,10 +21,6 @@ import {
ConfigExtensionDialog,
type ConfigExtensionDialogProps,
} from '../components/ConfigExtensionDialog.js';
import {
ExtensionRegistryView,
type ExtensionRegistryViewProps,
} from '../components/views/ExtensionRegistryView.js';
import { type CommandContext, type SlashCommand } from './types.js';
import {
@@ -43,8 +39,6 @@ import {
} from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js';
import { stat } from 'node:fs/promises';
import { type RegistryExtension } from '../../config/extensionRegistryClient.js';
import { waitFor } from '../../test-utils/async.js';
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
@@ -173,7 +167,6 @@ describe('extensionsCommand', () => {
},
ui: {
dispatchExtensionStateUpdate: mockDispatchExtensionState,
removeComponent: vi.fn(),
},
});
});
@@ -436,61 +429,6 @@ describe('extensionsCommand', () => {
throw new Error('Explore action not found');
}
it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => {
mockContext.services.settings.merged.experimental.extensionRegistry = true;
const result = await exploreAction(mockContext, '');
expect(result).toBeDefined();
if (result?.type !== 'custom_dialog') {
throw new Error('Expected custom_dialog');
}
const component =
result.component as ReactElement<ExtensionRegistryViewProps>;
expect(component.type).toBe(ExtensionRegistryView);
expect(component.props.extensionManager).toBe(mockExtensionLoader);
});
it('should handle onSelect and onClose in ExtensionRegistryView', async () => {
mockContext.services.settings.merged.experimental.extensionRegistry = true;
const result = await exploreAction(mockContext, '');
if (result?.type !== 'custom_dialog') {
throw new Error('Expected custom_dialog');
}
const component =
result.component as ReactElement<ExtensionRegistryViewProps>;
const extension = {
extensionName: 'test-ext',
url: 'https://github.com/test/ext.git',
} as RegistryExtension;
vi.mocked(inferInstallMetadata).mockResolvedValue({
source: extension.url,
type: 'git',
});
mockInstallExtension.mockResolvedValue({ name: extension.url });
// Call onSelect
component.props.onSelect?.(extension);
await waitFor(() => {
expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: extension.url,
type: 'git',
});
});
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1);
// Call onClose
component.props.onClose?.();
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2);
});
it("should add an info message and call 'open' in a non-sandbox environment", async () => {
// Ensure no special environment variables that would affect behavior
vi.stubEnv('NODE_ENV', '');
@@ -280,9 +280,7 @@ async function exploreAction(
type: 'custom_dialog' as const,
component: React.createElement(ExtensionRegistryView, {
onSelect: (extension) => {
debugLogger.log(`Selected extension: ${extension.extensionName}`);
void installAction(context, extension.url);
context.ui.removeComponent();
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
},
onClose: () => context.ui.removeComponent(),
extensionManager,
@@ -7,6 +7,7 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { hooksCommand } from './hooksCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import type { HookRegistryEntry } from '@google/gemini-cli-core';
import { HookType, HookEventName, ConfigSource } from '@google/gemini-cli-core';
import type { CommandContext } from './types.js';
@@ -126,10 +127,13 @@ describe('hooksCommand', () => {
createMockHook('test-hook', HookEventName.BeforeTool, true),
]);
const result = await hooksCommand.action(mockContext, '');
await hooksCommand.action(mockContext, '');
expect(result).toHaveProperty('type', 'custom_dialog');
expect(result).toHaveProperty('component');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HOOKS_LIST,
}),
);
});
});
@@ -157,7 +161,7 @@ describe('hooksCommand', () => {
});
});
it('should return custom_dialog even when hook system is not enabled', async () => {
it('should display panel even when hook system is not enabled', async () => {
mockConfig.getHookSystem.mockReturnValue(null);
const panelCmd = hooksCommand.subCommands!.find(
@@ -167,13 +171,17 @@ describe('hooksCommand', () => {
throw new Error('panel command must have an action');
}
const result = await panelCmd.action(mockContext, '');
await panelCmd.action(mockContext, '');
expect(result).toHaveProperty('type', 'custom_dialog');
expect(result).toHaveProperty('component');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HOOKS_LIST,
hooks: [],
}),
);
});
it('should return custom_dialog when no hooks are configured', async () => {
it('should display panel when no hooks are configured', async () => {
mockHookSystem.getAllHooks.mockReturnValue([]);
(mockContext.services.settings.merged as Record<string, unknown>)[
'hooksConfig'
@@ -186,13 +194,17 @@ describe('hooksCommand', () => {
throw new Error('panel command must have an action');
}
const result = await panelCmd.action(mockContext, '');
await panelCmd.action(mockContext, '');
expect(result).toHaveProperty('type', 'custom_dialog');
expect(result).toHaveProperty('component');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HOOKS_LIST,
hooks: [],
}),
);
});
it('should return custom_dialog when hooks are configured', async () => {
it('should display hooks list when hooks are configured', async () => {
const mockHooks: HookRegistryEntry[] = [
createMockHook('echo-test', HookEventName.BeforeTool, true),
createMockHook('notify', HookEventName.AfterAgent, false),
@@ -210,10 +222,14 @@ describe('hooksCommand', () => {
throw new Error('panel command must have an action');
}
const result = await panelCmd.action(mockContext, '');
await panelCmd.action(mockContext, '');
expect(result).toHaveProperty('type', 'custom_dialog');
expect(result).toHaveProperty('component');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HOOKS_LIST,
hooks: mockHooks,
}),
);
});
});
+11 -18
View File
@@ -4,13 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { createElement } from 'react';
import type {
SlashCommand,
CommandContext,
OpenCustomDialogActionReturn,
} from './types.js';
import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } from './types.js';
import { MessageType, type HistoryItemHooksList } from '../types.js';
import type {
HookRegistryEntry,
MessageActionReturn,
@@ -19,14 +15,13 @@ import { getErrorMessage } from '@google/gemini-cli-core';
import { SettingScope, isLoadableSettingScope } from '../../config/settings.js';
import { enableHook, disableHook } from '../../utils/hookSettings.js';
import { renderHookActionFeedback } from '../../utils/hookUtils.js';
import { HooksDialog } from '../components/HooksDialog.js';
/**
* Display a formatted list of hooks with their status in a dialog
* Display a formatted list of hooks with their status
*/
function panelAction(
async function panelAction(
context: CommandContext,
): MessageActionReturn | OpenCustomDialogActionReturn {
): Promise<void | MessageActionReturn> {
const { config } = context.services;
if (!config) {
return {
@@ -39,13 +34,12 @@ function panelAction(
const hookSystem = config.getHookSystem();
const allHooks = hookSystem?.getAllHooks() || [];
return {
type: 'custom_dialog',
component: createElement(HooksDialog, {
hooks: allHooks,
onClose: () => context.ui.removeComponent(),
}),
const hooksListItem: HistoryItemHooksList = {
type: MessageType.HOOKS_LIST,
hooks: allHooks,
};
context.ui.addItem(hooksListItem);
}
/**
@@ -349,7 +343,6 @@ const panelCommand: SlashCommand = {
altNames: ['list', 'show'],
description: 'Display all registered hooks with their status',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: panelAction,
};
@@ -400,5 +393,5 @@ export const hooksCommand: SlashCommand = {
enableAllCommand,
disableAllCommand,
],
action: (context: CommandContext) => panelCommand.action!(context, ''),
action: async (context: CommandContext) => panelCommand.action!(context, ''),
};
@@ -14,9 +14,7 @@ import {
coreEvents,
processSingleFileContent,
type ProcessedFileReadResult,
readFileWithEncoding,
} from '@google/gemini-cli-core';
import { copyToClipboard } from '../utils/commandUtils.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -27,7 +25,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
emitFeedback: vi.fn(),
},
processSingleFileContent: vi.fn(),
readFileWithEncoding: vi.fn(),
partToString: vi.fn((val) => val),
};
});
@@ -38,14 +35,9 @@ vi.mock('node:path', async (importOriginal) => {
...actual,
default: { ...actual },
join: vi.fn((...args) => args.join('/')),
basename: vi.fn((p) => p.split('/').pop()),
};
});
vi.mock('../utils/commandUtils.js', () => ({
copyToClipboard: vi.fn(),
}));
describe('planCommand', () => {
let mockContext: CommandContext;
@@ -123,46 +115,4 @@ describe('planCommand', () => {
text: '# Approved Plan Content',
});
});
describe('copy subcommand', () => {
it('should copy the approved plan to clipboard', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
mockContext.services.config!.getApprovedPlanPath,
).mockReturnValue(mockPlanPath);
vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content');
const copySubCommand = planCommand.subCommands?.find(
(sc) => sc.name === 'copy',
);
if (!copySubCommand?.action) throw new Error('Copy action missing');
await copySubCommand.action(mockContext, '');
expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath);
expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Plan copied to clipboard (approved-plan.md).',
);
});
it('should warn if no approved plan is found', async () => {
vi.mocked(
mockContext.services.config!.getApprovedPlanPath,
).mockReturnValue(undefined);
const copySubCommand = planCommand.subCommands?.find(
(sc) => sc.name === 'copy',
);
if (!copySubCommand?.action) throw new Error('Copy action missing');
await copySubCommand.action(mockContext, '');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
'No approved plan found to copy.',
);
});
});
});
+2 -43
View File
@@ -4,54 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CommandContext,
CommandKind,
type SlashCommand,
} from './types.js';
import { CommandKind, type SlashCommand } from './types.js';
import {
ApprovalMode,
coreEvents,
debugLogger,
processSingleFileContent,
partToString,
readFileWithEncoding,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import * as path from 'node:path';
import { copyToClipboard } from '../utils/commandUtils.js';
async function copyAction(context: CommandContext) {
const config = context.services.config;
if (!config) {
debugLogger.debug('Plan copy command: config is not available in context');
return;
}
const planPath = config.getApprovedPlanPath();
if (!planPath) {
coreEvents.emitFeedback('warning', 'No approved plan found to copy.');
return;
}
try {
const content = await readFileWithEncoding(planPath);
await copyToClipboard(content);
coreEvents.emitFeedback(
'info',
`Plan copied to clipboard (${path.basename(planPath)}).`,
);
} catch (error) {
coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error);
}
}
export const planCommand: SlashCommand = {
name: 'plan',
description: 'Switch to Plan Mode and view current plan',
kind: CommandKind.BUILT_IN,
autoExecute: false,
autoExecute: true,
action: async (context) => {
const config = context.services.config;
if (!config) {
@@ -94,13 +62,4 @@ export const planCommand: SlashCommand = {
);
}
},
subCommands: [
{
name: 'copy',
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: copyAction,
},
],
};
@@ -9,15 +9,12 @@ import { policiesCommand } from './policiesCommand.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as fs from 'node:fs/promises';
import {
type Config,
PolicyDecision,
ApprovalMode,
} from '@google/gemini-cli-core';
vi.mock('node:fs/promises');
describe('policiesCommand', () => {
let mockContext: ReturnType<typeof createMockCommandContext>;
@@ -29,9 +26,8 @@ describe('policiesCommand', () => {
expect(policiesCommand.name).toBe('policies');
expect(policiesCommand.description).toBe('Manage policies');
expect(policiesCommand.kind).toBe(CommandKind.BUILT_IN);
expect(policiesCommand.subCommands).toHaveLength(2);
expect(policiesCommand.subCommands).toHaveLength(1);
expect(policiesCommand.subCommands![0].name).toBe('list');
expect(policiesCommand.subCommands![1].name).toBe('undo');
});
describe('list subcommand', () => {
@@ -164,63 +160,4 @@ describe('policiesCommand', () => {
expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]');
});
});
describe('undo subcommand', () => {
it('should show error if config is missing', async () => {
mockContext.services.config = null;
const undoCommand = policiesCommand.subCommands![1];
await undoCommand.action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Error: Config not available.',
}),
expect.any(Number),
);
});
it('should show message if no backups found', async () => {
const mockStorage = {
getAutoSavedPolicyPath: vi.fn().mockReturnValue('user.toml'),
getWorkspaceAutoSavedPolicyPath: vi.fn().mockReturnValue('ws.toml'),
};
mockContext.services.config = {
storage: mockStorage,
} as unknown as Config;
vi.mocked(fs.access).mockRejectedValue(new Error('no backup'));
const undoCommand = policiesCommand.subCommands![1];
await undoCommand.action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.WARNING,
text: 'No policy backups found to restore.',
}),
expect.any(Number),
);
});
it('should restore backups if found', async () => {
const mockStorage = {
getAutoSavedPolicyPath: vi.fn().mockReturnValue('user.toml'),
getWorkspaceAutoSavedPolicyPath: vi.fn().mockReturnValue('ws.toml'),
};
mockContext.services.config = {
storage: mockStorage,
} as unknown as Config;
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.copyFile).mockResolvedValue(undefined);
const undoCommand = policiesCommand.subCommands![1];
await undoCommand.action!(mockContext, '');
expect(fs.copyFile).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Successfully restored'),
}),
expect.any(Number),
);
});
});
});
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } from '../types.js';
@@ -112,66 +111,10 @@ const listPoliciesCommand: SlashCommand = {
},
};
const undoPoliciesCommand: SlashCommand = {
name: 'undo',
description: 'Undo the last auto-saved policy update',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const { config } = context.services;
if (!config) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Error: Config not available.',
},
Date.now(),
);
return;
}
const storage = config.storage;
const paths = [
storage.getAutoSavedPolicyPath(),
storage.getWorkspaceAutoSavedPolicyPath(),
];
let restoredCount = 0;
for (const p of paths) {
const bak = `${p}.bak`;
try {
await fs.access(bak);
await fs.copyFile(bak, p);
restoredCount++;
} catch {
// No backup or failed to restore
}
}
if (restoredCount > 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Successfully restored ${restoredCount} policy file(s) from backup. Please restart the CLI to apply changes.`,
},
Date.now(),
);
} else {
context.ui.addItem(
{
type: MessageType.WARNING,
text: 'No policy backups found to restore.',
},
Date.now(),
);
}
},
};
export const policiesCommand: SlashCommand = {
name: 'policies',
description: 'Manage policies',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [listPoliciesCommand, undoPoliciesCommand],
subCommands: [listPoliciesCommand],
};
@@ -19,7 +19,6 @@ import {
BaseSettingsDialog,
type SettingsDialogItem,
} from './shared/BaseSettingsDialog.js';
import { getNestedValue, isRecord } from '../../utils/settingsUtils.js';
/**
* Configuration field definition for agent settings
@@ -112,12 +111,32 @@ interface AgentConfigDialogProps {
onSave?: () => void;
}
/**
* Get a nested value from an object using a path array
*/
function getNestedValue(
obj: Record<string, unknown> | undefined,
path: string[],
): unknown {
if (!obj) return undefined;
let current: unknown = obj;
for (const key of path) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = (current as Record<string, unknown>)[key];
}
return current;
}
/**
* Set a nested value in an object using a path array, creating intermediate objects as needed
*/
function setNestedValue(obj: unknown, path: string[], value: unknown): unknown {
if (!isRecord(obj)) return obj;
function setNestedValue(
obj: Record<string, unknown>,
path: string[],
value: unknown,
): Record<string, unknown> {
const result = { ...obj };
let current = result;
@@ -125,17 +144,12 @@ function setNestedValue(obj: unknown, path: string[], value: unknown): unknown {
const key = path[i];
if (current[key] === undefined || current[key] === null) {
current[key] = {};
} else if (isRecord(current[key])) {
current[key] = { ...current[key] };
}
const next = current[key];
if (isRecord(next)) {
current = next;
} else {
// Cannot traverse further through non-objects
return result;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current[key] = { ...(current[key] as Record<string, unknown>) };
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = current[key] as Record<string, unknown>;
}
const finalKey = path[path.length - 1];
@@ -253,7 +267,11 @@ export function AgentConfigDialog({
const items: SettingsDialogItem[] = useMemo(
() =>
AGENT_CONFIG_FIELDS.map((field) => {
const currentValue = getNestedValue(pendingOverride, field.path);
const currentValue = getNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
);
const defaultValue = getFieldDefaultFromDefinition(field, definition);
const effectiveValue =
currentValue !== undefined ? currentValue : defaultValue;
@@ -306,18 +324,23 @@ export function AgentConfigDialog({
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
if (!field || field.type !== 'boolean') return;
const currentValue = getNestedValue(pendingOverride, field.path);
const currentValue = getNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
);
const defaultValue = getFieldDefaultFromDefinition(field, definition);
const effectiveValue =
currentValue !== undefined ? currentValue : defaultValue;
const newValue = !effectiveValue;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const newOverride = setNestedValue(
pendingOverride,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
newValue,
) as AgentOverride;
setPendingOverride(newOverride);
setModifiedFields((prev) => new Set(prev).add(key));
@@ -352,9 +375,9 @@ export function AgentConfigDialog({
}
// Update pending override locally
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const newOverride = setNestedValue(
pendingOverride,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
parsed,
) as AgentOverride;
@@ -375,9 +398,9 @@ export function AgentConfigDialog({
if (!field) return;
// Remove the override (set to undefined)
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const newOverride = setNestedValue(
pendingOverride,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
undefined,
) as AgentOverride;
@@ -213,12 +213,6 @@ describe('<AppHeader />', () => {
it('should NOT render Tips when tipsShown is 10 or more', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: '',
warningText: '',
},
};
persistentStateMock.setData({ tipsShown: 10 });
@@ -226,7 +220,6 @@ describe('<AppHeader />', () => {
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
+19 -74
View File
@@ -1,113 +1,58 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { UserIdentity } from './UserIdentity.js';
import { Box } from 'ink';
import { Header } from './Header.js';
import { Tips } from './Tips.js';
import { UserIdentity } from './UserIdentity.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Banner } from './Banner.js';
import { useBanner } from '../hooks/useBanner.js';
import { useTips } from '../hooks/useTips.js';
import { theme } from '../semantic-colors.js';
import { ThemedGradient } from './ThemedGradient.js';
import { CliSpinner } from './CliSpinner.js';
interface AppHeaderProps {
version: string;
showDetails?: boolean;
}
const ICON = `▝▜▄
`;
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState();
const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
const showHeader = !(
settings.merged.ui.hideBanner || config.getScreenReader()
);
if (!showDetails) {
return (
<Box flexDirection="column">
{showHeader && (
<Box
flexDirection="row"
marginTop={1}
marginBottom={1}
paddingLeft={2}
>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
</Box>
</Box>
</Box>
)}
<Header version={version} nightly={false} />
</Box>
);
}
return (
<Box flexDirection="column">
{showHeader && (
<Box flexDirection="row" marginTop={1} marginBottom={1} paddingLeft={2}>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</Box>
</Box>
{!(settings.merged.ui.hideBanner || config.getScreenReader()) && (
<>
<Header version={version} nightly={nightly} />
{bannerVisible && bannerText && (
<Banner
width={terminalWidth}
bannerText={bannerText}
isWarning={bannerData.warningText !== ''}
/>
)}
</>
)}
{bannerVisible && bannerText && (
<Banner
width={terminalWidth}
bannerText={bannerText}
isWarning={bannerData.warningText !== ''}
/>
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
showTips && <Tips config={config} />}
</Box>
@@ -427,7 +427,7 @@ export const BackgroundShellDisplay = ({
height="100%"
width="100%"
borderStyle="single"
borderColor={isFocused ? theme.ui.focus : undefined}
borderColor={isFocused ? theme.border.focused : undefined}
>
<Box
flexDirection="row"
@@ -438,7 +438,7 @@ export const BackgroundShellDisplay = ({
borderRight={false}
borderTop={false}
paddingX={1}
borderColor={isFocused ? theme.ui.focus : undefined}
borderColor={isFocused ? theme.border.focused : undefined}
>
<Box flexDirection="row">
{renderTabs()}
@@ -1,118 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { ColorsDisplay } from './ColorsDisplay.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { themeManager } from '../themes/theme-manager.js';
import type { Theme, ColorsTheme } from '../themes/theme.js';
import type { SemanticColors } from '../themes/semantic-tokens.js';
describe('ColorsDisplay', () => {
beforeEach(() => {
vi.spyOn(themeManager, 'getSemanticColors').mockReturnValue({
text: {
primary: '#ffffff',
secondary: '#cccccc',
link: '#0000ff',
accent: '#ff00ff',
response: '#ffffff',
},
background: {
primary: '#000000',
message: '#111111',
input: '#222222',
focus: '#333333',
diff: {
added: '#003300',
removed: '#330000',
},
},
border: {
default: '#555555',
},
ui: {
comment: '#666666',
symbol: '#cccccc',
active: '#0000ff',
dark: '#333333',
focus: '#0000ff',
gradient: undefined,
},
status: {
error: '#ff0000',
success: '#00ff00',
warning: '#ffff00',
},
});
vi.spyOn(themeManager, 'getActiveTheme').mockReturnValue({
name: 'Test Theme',
type: 'dark',
colors: {} as unknown as ColorsTheme,
semanticColors: {
text: {
primary: '#ffffff',
secondary: '#cccccc',
link: '#0000ff',
accent: '#ff00ff',
response: '#ffffff',
},
background: {
primary: '#000000',
message: '#111111',
input: '#222222',
diff: {
added: '#003300',
removed: '#330000',
},
},
border: {
default: '#555555',
},
ui: {
comment: '#666666',
symbol: '#cccccc',
active: '#0000ff',
dark: '#333333',
focus: '#0000ff',
gradient: undefined,
},
status: {
error: '#ff0000',
success: '#00ff00',
warning: '#ffff00',
},
} as unknown as SemanticColors,
} as unknown as Theme);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders correctly', async () => {
const mockTheme = themeManager.getActiveTheme();
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ColorsDisplay activeTheme={mockTheme} />,
);
await waitUntilReady();
const output = lastFrame();
// Check for title and description
expect(output).toContain('How do colors get applied?');
expect(output).toContain('Hex:');
// Check for some color names and values expect(output).toContain('text.primary');
expect(output).toContain('#ffffff');
expect(output).toContain('background.diff.added');
expect(output).toContain('#003300');
expect(output).toContain('border.default');
expect(output).toContain('#555555');
unmount();
});
});
@@ -1,277 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { theme } from '../semantic-colors.js';
import type { Theme } from '../themes/theme.js';
interface StandardColorRow {
type: 'standard';
name: string;
value: string;
}
interface GradientColorRow {
type: 'gradient';
name: string;
value: string[];
}
interface BackgroundColorRow {
type: 'background';
name: string;
value: string;
}
type ColorRow = StandardColorRow | GradientColorRow | BackgroundColorRow;
const VALUE_COLUMN_WIDTH = 10;
const COLOR_DESCRIPTIONS: Record<string, string> = {
'text.primary': 'Primary text color (uses terminal default if blank)',
'text.secondary': 'Secondary/dimmed text color',
'text.link': 'Hyperlink and highlighting color',
'text.accent': 'Accent color for emphasis',
'text.response':
'Color for model response text (uses terminal default if blank)',
'background.primary': 'Main terminal background color',
'background.message': 'Subtle background for message blocks',
'background.input': 'Background for the input prompt',
'background.focus': 'Background highlight for selected/focused items',
'background.diff.added': 'Background for added lines in diffs',
'background.diff.removed': 'Background for removed lines in diffs',
'border.default': 'Standard border color',
'ui.comment': 'Color for code comments and metadata',
'ui.symbol': 'Color for technical symbols and UI icons',
'ui.active': 'Border color for active or running elements',
'ui.dark': 'Deeply dimmed color for subtle UI elements',
'ui.focus':
'Color for focused elements (e.g. selected menu items, focused borders)',
'status.error': 'Color for error messages and critical status',
'status.success': 'Color for success messages and positive status',
'status.warning': 'Color for warnings and cautionary status',
};
interface ColorsDisplayProps {
activeTheme: Theme;
}
/**
* Determines a contrasting text color (black or white) based on the background color's luminance.
*/
function getContrastingTextColor(hex: string): string {
if (!hex || !hex.startsWith('#') || hex.length < 7) {
// Fallback for invalid hex codes or named colors
return theme.text.primary;
}
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
// Using YIQ formula to determine luminance
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 128 ? '#000000' : '#FFFFFF';
}
export const ColorsDisplay: React.FC<ColorsDisplayProps> = ({
activeTheme,
}) => {
const semanticColors = activeTheme.semanticColors;
const backgroundRows: BackgroundColorRow[] = [];
const standardRows: StandardColorRow[] = [];
let gradientRow: GradientColorRow | null = null;
if (semanticColors.ui.gradient && semanticColors.ui.gradient.length > 0) {
gradientRow = {
type: 'gradient',
name: 'ui.gradient',
value: semanticColors.ui.gradient,
};
}
/**
* Recursively flattens the semanticColors object.
*/
const flattenColors = (obj: object, path: string = '') => {
for (const [key, value] of Object.entries(obj)) {
if (value === undefined || value === null) continue;
const newPath = path ? `${path}.${key}` : key;
if (key === 'gradient' && Array.isArray(value)) {
// Gradient handled separately
continue;
}
if (typeof value === 'object' && !Array.isArray(value)) {
flattenColors(value, newPath);
} else if (typeof value === 'string') {
if (newPath.startsWith('background.')) {
backgroundRows.push({
type: 'background',
name: newPath,
value,
});
} else {
standardRows.push({
type: 'standard',
name: newPath,
value,
});
}
}
}
};
flattenColors(semanticColors);
// Final order: Backgrounds first, then Standards, then Gradient
const allRows: ColorRow[] = [
...backgroundRows,
...standardRows,
...(gradientRow ? [gradientRow] : []),
];
return (
<Box
flexDirection="column"
paddingX={1}
paddingY={0}
borderStyle="round"
borderColor={theme.border.default}
>
<Box marginBottom={1} flexDirection="column">
<Text bold color={theme.text.accent}>
DEVELOPER TOOLS (Not visible to users)
</Text>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.primary}>
<Text bold>How do colors get applied?</Text>
</Text>
<Box marginLeft={2} flexDirection="column">
<Text color={theme.text.primary}>
<Text bold>Hex:</Text> Rendered exactly by modern terminals. Not
overridden by app themes.
</Text>
<Text color={theme.text.primary}>
<Text bold>Blank:</Text> Uses your terminal&apos;s default
foreground/background.
</Text>
<Text color={theme.text.primary}>
<Text bold>Compatibility:</Text> On older terminals, hex is
approximated to the nearest ANSI color.
</Text>
<Text color={theme.text.primary}>
<Text bold>ANSI Names:</Text> &apos;red&apos;,
&apos;green&apos;, etc. are mapped to your terminal app&apos;s
palette.
</Text>
</Box>
</Box>
</Box>
{/* Header */}
<Box flexDirection="row" marginBottom={0} paddingX={1}>
<Box width={VALUE_COLUMN_WIDTH}>
<Text bold color={theme.text.link} dimColor>
Value
</Text>
</Box>
<Box flexGrow={1}>
<Text bold color={theme.text.link} dimColor>
Name
</Text>
</Box>
</Box>
{/* All Rows */}
<Box flexDirection="column">
{allRows.map((row) => {
if (row.type === 'standard') return renderStandardRow(row);
if (row.type === 'gradient') return renderGradientRow(row);
if (row.type === 'background') return renderBackgroundRow(row);
return null;
})}
</Box>
</Box>
);
};
function renderStandardRow({ name, value }: StandardColorRow) {
const isHex = value.startsWith('#');
const displayColor = isHex ? value : theme.text.primary;
const description = COLOR_DESCRIPTIONS[name] || '';
return (
<Box key={name} flexDirection="row" paddingX={1}>
<Box width={VALUE_COLUMN_WIDTH}>
<Text color={displayColor}>{value || '(blank)'}</Text>
</Box>
<Box flexGrow={1} flexDirection="row">
<Box width="30%">
<Text color={displayColor}>{name}</Text>
</Box>
<Box flexGrow={1} paddingLeft={1}>
<Text color={theme.text.secondary}>{description}</Text>
</Box>
</Box>
</Box>
);
}
function renderGradientRow({ name, value }: GradientColorRow) {
const description = COLOR_DESCRIPTIONS[name] || '';
return (
<Box key={name} flexDirection="row" paddingX={1}>
<Box width={VALUE_COLUMN_WIDTH} flexDirection="column">
{value.map((c, i) => (
<Text key={i} color={c}>
{c}
</Text>
))}
</Box>
<Box flexGrow={1} flexDirection="row">
<Box width="30%">
<Gradient colors={value}>
<Text>{name}</Text>
</Gradient>
</Box>
<Box flexGrow={1} paddingLeft={1}>
<Text color={theme.text.secondary}>{description}</Text>
</Box>
</Box>
</Box>
);
}
function renderBackgroundRow({ name, value }: BackgroundColorRow) {
const description = COLOR_DESCRIPTIONS[name] || '';
return (
<Box key={name} flexDirection="row" paddingX={1}>
<Box
width={VALUE_COLUMN_WIDTH}
backgroundColor={value}
justifyContent="center"
paddingX={1}
>
<Text color={getContrastingTextColor(value)} bold wrap="truncate">
{value || 'default'}
</Text>
</Box>
<Box flexGrow={1} flexDirection="row" paddingLeft={1}>
<Box width="30%">
<Text color={theme.text.primary}>{name}</Text>
</Box>
<Box flexGrow={1} paddingLeft={1}>
<Text color={theme.text.secondary}>{description}</Text>
</Box>
</Box>
</Box>
);
}
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { render } from '../../test-utils/render.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { describe, it, expect, vi } from 'vitest';
@@ -17,9 +17,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
vi.mock('../../config/settings.js', () => ({
DEFAULT_MODEL_CONFIGS: {},
LoadedSettings: class {
constructor() {
// this.merged = {};
}
},
}));
describe('ContextUsageDisplay', () => {
it('renders correct percentage used', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
it('renders correct percentage left', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ContextUsageDisplay
promptTokenCount={5000}
model="gemini-pro"
@@ -28,56 +37,27 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('50% context used');
expect(output).toContain('50% context left');
unmount();
});
it('renders correctly when usage is 0%', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={0}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% context used');
unmount();
});
it('renders abbreviated label when terminal width is small', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
it('renders short label when terminal width is small', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ContextUsageDisplay
promptTokenCount={2000}
model="gemini-pro"
terminalWidth={80}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('20%');
expect(output).not.toContain('context used');
expect(output).toContain('80%');
expect(output).not.toContain('context left');
unmount();
});
it('renders 80% correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={8000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80% context used');
unmount();
});
it('renders 100% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
it('renders 0% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ContextUsageDisplay
promptTokenCount={10000}
model="gemini-pro"
@@ -86,7 +66,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('100% context used');
expect(output).toContain('0% context left');
unmount();
});
});
@@ -7,11 +7,6 @@
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { getContextUsagePercentage } from '../utils/contextUsage.js';
import { useSettings } from '../contexts/SettingsContext.js';
import {
MIN_TERMINAL_WIDTH_FOR_FULL_LABEL,
DEFAULT_COMPRESSION_THRESHOLD,
} from '../constants.js';
export const ContextUsageDisplay = ({
promptTokenCount,
@@ -19,30 +14,17 @@ export const ContextUsageDisplay = ({
terminalWidth,
}: {
promptTokenCount: number;
model: string | undefined;
model: string;
terminalWidth: number;
}) => {
const settings = useSettings();
const percentage = getContextUsagePercentage(promptTokenCount, model);
const percentageUsed = (percentage * 100).toFixed(0);
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
const threshold =
settings.merged.model?.compressionThreshold ??
DEFAULT_COMPRESSION_THRESHOLD;
let textColor = theme.text.secondary;
if (percentage >= 1.0) {
textColor = theme.status.error;
} else if (percentage >= threshold) {
textColor = theme.status.warning;
}
const label =
terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used';
const label = terminalWidth < 100 ? '%' : '% context left';
return (
<Text color={textColor}>
{percentageUsed}
<Text color={theme.text.secondary}>
{percentageLeft}
{label}
</Text>
);
@@ -76,7 +76,7 @@ describe('DetailedMessagesDisplay', () => {
unmount();
});
it('shows the F12 hint even in low error verbosity mode', async () => {
it('hides the F12 hint in low error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
@@ -95,7 +95,7 @@ describe('DetailedMessagesDisplay', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('(F12 to close)');
expect(lastFrame()).not.toContain('(F12 to close)');
unmount();
});
@@ -13,6 +13,8 @@ import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[];
@@ -27,6 +29,10 @@ export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ messages, maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const config = useConfig();
const settings = useSettings();
const showHotkeyHint =
settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode();
const borderAndPadding = 3;
@@ -65,7 +71,10 @@ export const DetailedMessagesDisplay: React.FC<
>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
Debug Console{' '}
{showHotkeyHint && (
<Text color={theme.text.secondary}>(F12 to close)</Text>
)}
</Text>
</Box>
<Box height={maxHeight} width={width - borderAndPadding}>
@@ -37,6 +37,9 @@ import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { NewAgentsNotification } from './NewAgentsNotification.js';
import { AgentConfigDialog } from './AgentConfigDialog.js';
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
interface DialogManagerProps {
@@ -59,8 +62,56 @@ export const DialogManager = ({
terminalHeight,
staticExtraHeight,
terminalWidth: uiTerminalWidth,
shouldShowRetentionWarning,
sessionsToDeleteCount,
} = uiState;
const handleKeep120Days = useCallback(() => {
settings.setValue(
SettingScope.User,
'general.sessionRetention.warningAcknowledged',
true,
);
settings.setValue(
SettingScope.User,
'general.sessionRetention.enabled',
true,
);
settings.setValue(
SettingScope.User,
'general.sessionRetention.maxAge',
'120d',
);
}, [settings]);
const handleKeep30Days = useCallback(() => {
settings.setValue(
SettingScope.User,
'general.sessionRetention.warningAcknowledged',
true,
);
settings.setValue(
SettingScope.User,
'general.sessionRetention.enabled',
true,
);
settings.setValue(
SettingScope.User,
'general.sessionRetention.maxAge',
'30d',
);
}, [settings]);
if (shouldShowRetentionWarning && sessionsToDeleteCount !== undefined) {
return (
<SessionRetentionWarningDialog
onKeep120Days={handleKeep120Days}
onKeep30Days={handleKeep30Days}
sessionsToDeleteCount={sessionsToDeleteCount ?? 0}
/>
);
}
if (uiState.adminSettingsChanged) {
return <AdminSettingsChangedDialog />;
}
@@ -230,12 +281,14 @@ export const DialogManager = ({
return (
<Box flexDirection="column">
<SettingsDialog
settings={settings}
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={async () => {
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}}
availableTerminalHeight={terminalHeight - staticExtraHeight}
config={config}
/>
</Box>
);
@@ -11,6 +11,7 @@ import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { openFileInEditor } from '../utils/editorUtils.js';
import {
ApprovalMode,
validatePlanContent,
@@ -40,6 +41,10 @@ vi.mock('node:fs', async (importOriginal) => {
...actual,
existsSync: vi.fn(),
realpathSync: vi.fn((p) => p),
promises: {
...actual.promises,
readFile: vi.fn(),
},
};
});
@@ -541,7 +546,7 @@ Implement a comprehensive authentication system with multiple providers.
expect(onFeedback).not.toHaveBeenCalled();
});
it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => {
it('opens plan in external editor when Ctrl+X is pressed', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
@@ -552,16 +557,27 @@ Implement a comprehensive authentication system with multiple providers.
expect(lastFrame()).toContain('Add user authentication');
});
// Reset the mock to track the second call during refresh
vi.mocked(processSingleFileContent).mockClear();
// Press Ctrl+X
await act(async () => {
writeKey(stdin, '\x18'); // Ctrl+X
});
await waitFor(() => {
expect(onFeedback).toHaveBeenCalledWith(
'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.',
expect(openFileInEditor).toHaveBeenCalledWith(
mockPlanFullPath,
expect.anything(),
expect.anything(),
undefined,
);
});
// Verify that content is refreshed (processSingleFileContent called again)
await waitFor(() => {
expect(processSingleFileContent).toHaveBeenCalled();
});
});
},
);
@@ -156,15 +156,11 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
const handleOpenEditor = useCallback(async () => {
try {
await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor());
onFeedback(
'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.',
);
refresh();
} catch (err) {
debugLogger.error('Failed to open plan in editor:', err);
}
}, [planPath, stdin, setRawMode, getPreferredEditor, refresh, onFeedback]);
}, [planPath, stdin, setRawMode, getPreferredEditor, refresh]);
useKeypress(
(key) => {
+6 -50
View File
@@ -15,19 +15,6 @@ import {
} from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
let mockIsDevelopment = false;
vi.mock('../../utils/installationInfo.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('../../utils/installationInfo.js')>();
return {
...original,
get isDevelopment() {
return mockIsDevelopment;
},
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -174,7 +161,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context used/);
expect(lastFrame()).toMatch(/\d+% context left/);
unmount();
});
@@ -229,7 +216,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('used');
expect(lastFrame()).not.toContain('Usage remaining');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -262,7 +249,7 @@ describe('<Footer />', () => {
unmount();
});
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
it('displays the model name and abbreviated context percentage', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
@@ -280,7 +267,6 @@ describe('<Footer />', () => {
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+%/);
expect(lastFrame()).not.toContain('context used');
unmount();
});
@@ -478,7 +464,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% context used/);
expect(lastFrame()).not.toMatch(/\d+% context left/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
@@ -498,7 +484,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context used/);
expect(lastFrame()).toMatch(/\d+% context left/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
@@ -523,15 +509,7 @@ describe('<Footer />', () => {
});
describe('error summary visibility', () => {
beforeEach(() => {
mockIsDevelopment = false;
});
afterEach(() => {
mockIsDevelopment = false;
});
it('hides error summary in low verbosity mode out of dev mode', async () => {
it('hides error summary in low verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
@@ -552,28 +530,6 @@ describe('<Footer />', () => {
unmount();
});
it('shows error summary in low verbosity mode in dev mode', async () => {
mockIsDevelopment = true;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
});
it('shows error summary in full verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
+1 -3
View File
@@ -62,9 +62,7 @@ export const Footer: React.FC = () => {
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails &&
errorCount > 0 &&
(isFullErrorVerbosity || debugMode || isDevelopment);
!showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
const hideCWD = settings.merged.ui.footer.hideCWD;
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
@@ -22,13 +22,8 @@ vi.mock('../semantic-colors.js', async (importOriginal) => {
...original,
theme: {
...original.theme,
background: {
...original.theme.background,
focus: '#004000',
},
ui: {
...original.theme.ui,
focus: '#00ff00',
gradient: [], // Empty array to potentially trigger the crash
},
},
@@ -98,18 +98,16 @@ describe('<Header />', () => {
primary: '',
message: '',
input: '',
focus: '',
diff: { added: '', removed: '' },
},
border: {
default: '',
focused: '',
},
ui: {
comment: '',
symbol: '',
active: '',
dark: '',
focus: '',
gradient: undefined,
},
status: {
@@ -32,6 +32,7 @@ import { SkillsList } from './views/SkillsList.js';
import { AgentsStatus } from './views/AgentsStatus.js';
import { McpStatus } from './views/McpStatus.js';
import { ChatList } from './views/ChatList.js';
import { HooksList } from './views/HooksList.js';
import { ModelMessage } from './messages/ModelMessage.js';
import { ThinkingMessage } from './messages/ThinkingMessage.js';
import { HintMessage } from './messages/HintMessage.js';
@@ -99,7 +100,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'info' && (
<InfoMessage
text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}
@@ -217,6 +217,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'chat_list' && (
<ChatList chats={itemForDisplay.chats} />
)}
{itemForDisplay.type === 'hooks_list' && (
<HooksList hooks={itemForDisplay.hooks} />
)}
</Box>
);
};
@@ -1,248 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { act } from 'react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { HooksDialog, type HookEntry } from './HooksDialog.js';
describe('HooksDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockHook = (
name: string,
eventName: string,
enabled: boolean,
options?: Partial<HookEntry>,
): HookEntry => ({
config: {
name,
command: `run-${name}`,
type: 'command',
description: `Test hook: ${name}`,
...options?.config,
},
source: options?.source ?? '/mock/path/GEMINI.md',
eventName,
enabled,
...options,
});
describe('snapshots', () => {
it('renders empty hooks dialog', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={[]} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders single hook with security warning, source, and tips', async () => {
const hooks = [createMockHook('test-hook', 'before-tool', true)];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders hooks grouped by event name with enabled and disabled status', async () => {
const hooks = [
createMockHook('hook1', 'before-tool', true),
createMockHook('hook2', 'before-tool', false),
createMockHook('hook3', 'after-agent', true),
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders hook with all metadata (matcher, sequential, timeout)', async () => {
const hooks = [
createMockHook('my-hook', 'before-tool', true, {
matcher: 'shell_exec',
sequential: true,
config: {
name: 'my-hook',
type: 'command',
description: 'A hook with all metadata fields',
timeout: 30,
},
}),
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders hook using command as name when name is not provided', async () => {
const hooks: HookEntry[] = [
{
config: {
command: 'echo hello',
type: 'command',
},
source: '/mock/path',
eventName: 'before-tool',
enabled: true,
},
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
describe('keyboard interaction', () => {
it('should call onClose when escape key is pressed', async () => {
const onClose = vi.fn();
const { waitUntilReady, stdin, unmount } = renderWithProviders(
<HooksDialog hooks={[]} onClose={onClose} />,
);
await waitUntilReady();
act(() => {
stdin.write('\u001b[27u');
});
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
});
describe('scrolling behavior', () => {
const createManyHooks = (count: number): HookEntry[] =>
Array.from({ length: count }, (_, i) =>
createMockHook(`hook-${i + 1}`, `event-${(i % 3) + 1}`, i % 2 === 0),
);
it('should not show scroll indicators when hooks fit within maxVisibleHooks', async () => {
const hooks = [
createMockHook('hook1', 'before-tool', true),
createMockHook('hook2', 'after-tool', false),
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={10} />,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('▲');
expect(lastFrame()).not.toContain('▼');
unmount();
});
it('should show scroll down indicator when there are more hooks than maxVisibleHooks', async () => {
const hooks = createManyHooks(15);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('▼');
unmount();
});
it('should scroll down when down arrow is pressed', async () => {
const hooks = createManyHooks(15);
const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Initially should not show up indicator
expect(lastFrame()).not.toContain('▲');
act(() => {
stdin.write('\u001b[B');
});
await waitUntilReady();
// Should now show up indicator after scrolling down
expect(lastFrame()).toContain('▲');
unmount();
});
it('should scroll up when up arrow is pressed after scrolling down', async () => {
const hooks = createManyHooks(15);
const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Scroll down twice
act(() => {
stdin.write('\u001b[B');
stdin.write('\u001b[B');
});
await waitUntilReady();
expect(lastFrame()).toContain('▲');
// Scroll up once
act(() => {
stdin.write('\u001b[A');
});
await waitUntilReady();
// Should still show up indicator (scrolled down once)
expect(lastFrame()).toContain('▲');
unmount();
});
it('should not scroll beyond the end', async () => {
const hooks = createManyHooks(10);
const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Scroll down many times past the end
act(() => {
for (let i = 0; i < 20; i++) {
stdin.write('\u001b[B');
}
});
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('▲');
// At the end, down indicator should be hidden
expect(frame).not.toContain('▼');
unmount();
});
it('should not scroll above the beginning', async () => {
const hooks = createManyHooks(10);
const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Try to scroll up when already at top
act(() => {
stdin.write('\u001b[A');
});
await waitUntilReady();
expect(lastFrame()).not.toContain('▲');
expect(lastFrame()).toContain('▼');
unmount();
});
});
});
@@ -1,247 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
/**
* Hook entry type matching HookRegistryEntry from core
*/
export interface HookEntry {
config: {
command?: string;
type: string;
name?: string;
description?: string;
timeout?: number;
};
source: string;
eventName: string;
matcher?: string;
sequential?: boolean;
enabled: boolean;
}
interface HooksDialogProps {
hooks: readonly HookEntry[];
onClose: () => void;
/** Maximum number of hooks to display at once before scrolling. Default: 8 */
maxVisibleHooks?: number;
}
/** Maximum hooks to show at once before scrolling is needed */
const DEFAULT_MAX_VISIBLE_HOOKS = 8;
/**
* Dialog component for displaying hooks in a styled box.
* Replaces inline chat history display with a modal-style dialog.
* Supports scrolling with up/down arrow keys when there are many hooks.
*/
export const HooksDialog: React.FC<HooksDialogProps> = ({
hooks,
onClose,
maxVisibleHooks = DEFAULT_MAX_VISIBLE_HOOKS,
}) => {
const [scrollOffset, setScrollOffset] = useState(0);
// Flatten hooks with their event names for easier scrolling
const flattenedHooks = useMemo(() => {
const result: Array<{
type: 'header' | 'hook';
eventName: string;
hook?: HookEntry;
}> = [];
// Group hooks by event name
const hooksByEvent = hooks.reduce(
(acc, hook) => {
if (!acc[hook.eventName]) {
acc[hook.eventName] = [];
}
acc[hook.eventName].push(hook);
return acc;
},
{} as Record<string, HookEntry[]>,
);
// Flatten into displayable items
Object.entries(hooksByEvent).forEach(([eventName, eventHooks]) => {
result.push({ type: 'header', eventName });
eventHooks.forEach((hook) => {
result.push({ type: 'hook', eventName, hook });
});
});
return result;
}, [hooks]);
const totalItems = flattenedHooks.length;
const needsScrolling = totalItems > maxVisibleHooks;
const maxScrollOffset = Math.max(0, totalItems - maxVisibleHooks);
// Handle keyboard navigation
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onClose();
return true;
}
// Scroll navigation
if (needsScrolling) {
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
setScrollOffset((prev) => Math.max(0, prev - 1));
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
setScrollOffset((prev) => Math.min(maxScrollOffset, prev + 1));
return true;
}
}
return false;
},
{ isActive: true },
);
// Get visible items based on scroll offset
const visibleItems = needsScrolling
? flattenedHooks.slice(scrollOffset, scrollOffset + maxVisibleHooks)
: flattenedHooks;
const showScrollUp = needsScrolling && scrollOffset > 0;
const showScrollDown = needsScrolling && scrollOffset < maxScrollOffset;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
marginY={1}
width="100%"
>
{hooks.length === 0 ? (
<>
<Text color={theme.text.primary}>No hooks configured.</Text>
</>
) : (
<>
{/* Security Warning */}
<Box marginBottom={1} flexDirection="column">
<Text color={theme.status.warning} bold underline>
Security Warning:
</Text>
<Text color={theme.status.warning} wrap="wrap">
Hooks can execute arbitrary commands on your system. Only use
hooks from sources you trust. Review hook scripts carefully.
</Text>
</Box>
{/* Learn more link */}
<Box marginBottom={1}>
<Text wrap="wrap">
Learn more:{' '}
<Text color={theme.text.link}>
https://geminicli.com/docs/hooks
</Text>
</Text>
</Box>
{/* Configured Hooks heading */}
<Box marginBottom={1}>
<Text bold color={theme.text.accent}>
Configured Hooks
</Text>
</Box>
{/* Scroll up indicator */}
{showScrollUp && (
<Box paddingLeft={2} minWidth={0}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
{/* Visible hooks */}
<Box flexDirection="column" paddingLeft={2}>
{visibleItems.map((item, index) => {
if (item.type === 'header') {
return (
<Box
key={`header-${item.eventName}-${index}`}
marginBottom={1}
>
<Text bold color={theme.text.link}>
{item.eventName}
</Text>
</Box>
);
}
const hook = item.hook!;
const hookName =
hook.config.name || hook.config.command || 'unknown';
const hookKey = `${item.eventName}:${hook.source}:${hook.config.name ?? ''}:${hook.config.command ?? ''}`;
const statusColor = hook.enabled
? theme.status.success
: theme.text.secondary;
const statusText = hook.enabled ? 'enabled' : 'disabled';
return (
<Box key={hookKey} flexDirection="column" marginBottom={1}>
<Box flexDirection="row">
<Text color={theme.text.accent} bold>
{hookName}
</Text>
<Text color={statusColor}>{` [${statusText}]`}</Text>
</Box>
<Box paddingLeft={2} flexDirection="column">
{hook.config.description && (
<Text color={theme.text.primary} italic wrap="wrap">
{hook.config.description}
</Text>
)}
<Text color={theme.text.secondary} wrap="wrap">
Source: {hook.source}
{hook.config.name &&
hook.config.command &&
` | Command: ${hook.config.command}`}
{hook.matcher && ` | Matcher: ${hook.matcher}`}
{hook.sequential && ` | Sequential`}
{hook.config.timeout &&
` | Timeout: ${hook.config.timeout}s`}
</Text>
</Box>
</Box>
);
})}
</Box>
{/* Scroll down indicator */}
{showScrollDown && (
<Box paddingLeft={2} minWidth={0}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
{/* Tips */}
<Box marginTop={1}>
<Text color={theme.text.secondary} wrap="wrap">
Tip: Use <Text bold>/hooks enable {'<hook-name>'}</Text> or{' '}
<Text bold>/hooks disable {'<hook-name>'}</Text> to toggle
individual hooks. Use <Text bold>/hooks enable-all</Text> or{' '}
<Text bold>/hooks disable-all</Text> to toggle all hooks at once.
</Text>
</Box>
</>
)}
</Box>
);
};
@@ -1427,7 +1427,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const borderColor =
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.ui.focus)
? (statusColor ?? theme.border.focused)
: theme.border.default;
return (
@@ -79,18 +79,10 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/>
</Box>
{primaryText && (
<Box flexShrink={1}>
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
<Text color={theme.ui.active} italic>
{' '}
(press tab to focus)
</Text>
)}
</Box>
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
)}
{cancelAndTimerContent && (
<>
@@ -121,18 +113,10 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/>
</Box>
{primaryText && (
<Box flexShrink={1}>
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
<Text color={theme.ui.active} italic>
{' '}
(press tab to focus)
</Text>
)}
</Box>
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
)}
{!isNarrow && cancelAndTimerContent && (
<>
@@ -53,7 +53,7 @@ export const LogoutConfirmationDialog: React.FC<
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.ui.focus}
borderColor={theme.border.focused}
padding={1}
flexGrow={1}
marginLeft={1}
@@ -203,7 +203,7 @@ describe('getToolGroupBorderAppearance', () => {
});
});
it('returns active border for executing shell commands', () => {
it('returns symbol border for executing shell commands', () => {
const item = {
type: 'tool_group' as const,
tools: [
@@ -219,37 +219,7 @@ describe('getToolGroupBorderAppearance', () => {
],
id: 1,
};
// While executing shell commands, it's dim false, border active
const result = getToolGroupBorderAppearance(
item,
activeShellPtyId,
false,
[],
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.active,
borderDimColor: true,
});
});
it('returns focus border for focused executing shell commands', () => {
const item = {
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: SHELL_COMMAND_NAME,
description: '',
status: CoreToolCallStatus.Executing,
ptyId: activeShellPtyId,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
};
// When focused, it's dim false, border focus
// While executing shell commands, it's dim false, border symbol
const result = getToolGroupBorderAppearance(
item,
activeShellPtyId,
@@ -258,12 +228,12 @@ describe('getToolGroupBorderAppearance', () => {
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.focus,
borderColor: theme.ui.symbol,
borderDimColor: false,
});
});
it('returns active border and dims color for background executing shell command when another shell is active', () => {
it('returns symbol border and dims color for background executing shell command when another shell is active', () => {
const item = {
type: 'tool_group' as const,
tools: [
@@ -287,7 +257,7 @@ describe('getToolGroupBorderAppearance', () => {
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.active,
borderColor: theme.ui.symbol,
borderDimColor: true,
});
});
@@ -305,7 +275,7 @@ describe('getToolGroupBorderAppearance', () => {
);
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
// so it counts as pending shell.
expect(result.borderColor).toEqual(theme.ui.focus);
expect(result.borderColor).toEqual(theme.ui.symbol);
// It shouldn't be dim because there are no tools to say it isEmbeddedShellFocused = false
expect(result.borderDimColor).toBe(false);
});
@@ -34,7 +34,6 @@ export const MainContent = () => {
const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
const confirmingToolCallId = confirmingTool?.tool.callId;
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
@@ -42,7 +41,7 @@ export const MainContent = () => {
if (showConfirmationQueue) {
scrollableListRef.current?.scrollToEnd();
}
}, [showConfirmationQueue, confirmingToolCallId]);
}, [showConfirmationQueue, confirmingTool]);
const {
pendingHistoryItems,
@@ -7,7 +7,6 @@
import type React from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -437,7 +436,7 @@ const SessionItem = ({
if (isDisabled) {
return Colors.Gray;
}
return isActive ? theme.ui.focus : c;
return isActive ? Colors.AccentPurple : c;
};
const prefix = isActive ? ' ' : ' ';
@@ -484,10 +483,7 @@ const SessionItem = ({
));
return (
<Box
flexDirection="row"
backgroundColor={isActive ? theme.background.focus : undefined}
>
<Box flexDirection="row">
<Text color={textColor()} dimColor={isDisabled}>
{prefix}
</Text>
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
import { waitFor } from '../../test-utils/async.js';
import { act } from 'react';
// Helper to write to stdin
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
act(() => {
stdin.write(key);
});
};
describe('SessionRetentionWarningDialog', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('renders correctly with warning message and session count', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<SessionRetentionWarningDialog
onKeep120Days={vi.fn()}
onKeep30Days={vi.fn()}
sessionsToDeleteCount={42}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Keep chat history');
expect(lastFrame()).toContain(
'introducing a limit on how long chat sessions are stored',
);
expect(lastFrame()).toContain('Keep for 30 days (Recommended)');
expect(lastFrame()).toContain('42 sessions will be deleted');
expect(lastFrame()).toContain('Keep for 120 days');
expect(lastFrame()).toContain('No sessions will be deleted at this time');
});
it('handles pluralization correctly for 1 session', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<SessionRetentionWarningDialog
onKeep120Days={vi.fn()}
onKeep30Days={vi.fn()}
sessionsToDeleteCount={1}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('1 session will be deleted');
});
it('defaults to "Keep for 120 days" when there are sessions to delete', async () => {
const onKeep120Days = vi.fn();
const onKeep30Days = vi.fn();
const { stdin, waitUntilReady } = renderWithProviders(
<SessionRetentionWarningDialog
onKeep120Days={onKeep120Days}
onKeep30Days={onKeep30Days}
sessionsToDeleteCount={10}
/>,
);
await waitUntilReady();
// Initial selection should be "Keep for 120 days" (index 1) because count > 0
// Pressing Enter immediately should select it.
writeKey(stdin, '\r');
await waitFor(() => {
expect(onKeep120Days).toHaveBeenCalled();
expect(onKeep30Days).not.toHaveBeenCalled();
});
});
it('calls onKeep30Days when "Keep for 30 days" is explicitly selected (from 120 days default)', async () => {
const onKeep120Days = vi.fn();
const onKeep30Days = vi.fn();
const { stdin, waitUntilReady } = renderWithProviders(
<SessionRetentionWarningDialog
onKeep120Days={onKeep120Days}
onKeep30Days={onKeep30Days}
sessionsToDeleteCount={10}
/>,
);
await waitUntilReady();
// Default is index 1 (120 days). Move UP to index 0 (30 days).
writeKey(stdin, '\x1b[A'); // Up arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(onKeep30Days).toHaveBeenCalled();
expect(onKeep120Days).not.toHaveBeenCalled();
});
});
it('should match snapshot', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<SessionRetentionWarningDialog
onKeep120Days={vi.fn()}
onKeep30Days={vi.fn()}
sessionsToDeleteCount={123}
/>,
);
await waitUntilReady();
// Initial render
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
interface SessionRetentionWarningDialogProps {
onKeep120Days: () => void;
onKeep30Days: () => void;
sessionsToDeleteCount: number;
}
export const SessionRetentionWarningDialog = ({
onKeep120Days,
onKeep30Days,
sessionsToDeleteCount,
}: SessionRetentionWarningDialogProps) => {
const options: Array<RadioSelectItem<() => void>> = [
{
label: 'Keep for 30 days (Recommended)',
value: onKeep30Days,
key: '30days',
sublabel: `${sessionsToDeleteCount} session${
sessionsToDeleteCount === 1 ? '' : 's'
} will be deleted`,
},
{
label: 'Keep for 120 days',
value: onKeep120Days,
key: '120days',
sublabel: 'No sessions will be deleted at this time',
},
];
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
width="100%"
padding={1}
>
<Box marginBottom={1} justifyContent="center" width="100%">
<Text bold>Keep chat history</Text>
</Box>
<Box flexDirection="column" gap={1} marginBottom={1}>
<Text>
To keep your workspace clean, we are introducing a limit on how long
chat sessions are stored. Please choose a retention period for your
existing chats:
</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={options}
onSelect={(action) => action()}
initialIndex={1}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Set a custom limit <Text color={theme.text.primary}>/settings</Text>{' '}
and change &quot;Keep chat history&quot;.
</Text>
</Box>
</Box>
);
};
@@ -5,23 +5,11 @@
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
getShellConfiguration,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getShellConfiguration: vi.fn(),
};
});
import { ToolCallDecision } from '@google/gemini-cli-core';
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
@@ -31,16 +19,12 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
};
});
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = async (
metrics: SessionMetrics,
sessionId = 'test-session',
) => {
const renderWithMockedStats = async (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId,
sessionId: 'test-session',
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
@@ -62,38 +46,8 @@ const renderWithMockedStats = async (
};
describe('<SessionSummaryDisplay />', () => {
const emptyMetrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
beforeEach(() => {
getShellConfigurationMock.mockReturnValue({
executable: 'bash',
argsPrefix: ['-c'],
shell: 'bash',
});
});
it('renders the summary display with a title', async () => {
const metrics: SessionMetrics = {
...emptyMetrics,
models: {
'gemini-2.5-pro': {
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 },
@@ -109,6 +63,19 @@ describe('<SessionSummaryDisplay />', () => {
roles: {},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 42,
totalLinesRemoved: 15,
@@ -122,70 +89,4 @@ describe('<SessionSummaryDisplay />', () => {
expect(output).toMatchSnapshot();
unmount();
});
describe('Session ID escaping', () => {
it('renders a standard UUID-formatted session ID in the footer (bash)', async () => {
const uuidSessionId = '1234-abcd-5678-efgh';
const { lastFrame, unmount } = await renderWithMockedStats(
emptyMetrics,
uuidSessionId,
);
const output = lastFrame();
// Standard UUID characters should not be escaped/quoted by default for bash.
expect(output).toContain('gemini --resume 1234-abcd-5678-efgh');
unmount();
});
it('sanitizes a malicious session ID in the footer (bash)', async () => {
const maliciousSessionId = "'; rm -rf / #";
const { lastFrame, unmount } = await renderWithMockedStats(
emptyMetrics,
maliciousSessionId,
);
const output = lastFrame();
// escapeShellArg (using shell-quote for bash) will wrap special characters in double quotes.
expect(output).toContain('gemini --resume "\'; rm -rf / #"');
unmount();
});
it('renders a standard UUID-formatted session ID in the footer (powershell)', async () => {
getShellConfigurationMock.mockReturnValue({
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell',
});
const uuidSessionId = '1234-abcd-5678-efgh';
const { lastFrame, unmount } = await renderWithMockedStats(
emptyMetrics,
uuidSessionId,
);
const output = lastFrame();
// PowerShell wraps strings in single quotes
expect(output).toContain("gemini --resume '1234-abcd-5678-efgh'");
unmount();
});
it('sanitizes a malicious session ID in the footer (powershell)', async () => {
getShellConfigurationMock.mockReturnValue({
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell',
});
const maliciousSessionId = "'; rm -rf / #";
const { lastFrame, unmount } = await renderWithMockedStats(
emptyMetrics,
maliciousSessionId,
);
const output = lastFrame();
// PowerShell wraps in single quotes and escapes internal single quotes by doubling them
expect(output).toContain("gemini --resume '''; rm -rf / #'");
unmount();
});
});
});
@@ -6,8 +6,6 @@
import type React from 'react';
import { StatsDisplay } from './StatsDisplay.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
interface SessionSummaryDisplayProps {
duration: string;
@@ -15,16 +13,10 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => {
const { stats } = useSessionStats();
const { shell } = getShellConfiguration();
const footer = `To resume this session: gemini --resume ${escapeShellArg(stats.sessionId, shell)}`;
return (
<StatsDisplay
title="Agent powering down. Goodbye!"
duration={duration}
footer={footer}
/>
);
};
}) => (
<StatsDisplay
title="Agent powering down. Goodbye!"
duration={duration}
footer="Tip: Resume a previous session using gemini --resume or /resume"
/>
);
@@ -14,6 +14,7 @@
* - Focus section switching between settings and scope selector
* - Scope selection and settings persistence across scopes
* - Restart-required vs immediate settings behavior
* - VimModeContext integration
* - Complex user interaction workflows
* - Error handling and edge cases
* - Display values for inherited and overridden settings
@@ -24,12 +25,12 @@ import { render } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { SettingScope } from '../../config/settings.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
import { TEST_ONLY } from '../../utils/settingsUtils.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js';
import {
getSettingsSchema,
type SettingDefinition,
@@ -37,6 +38,10 @@ import {
} from '../../config/settingsSchema.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn().mockResolvedValue(undefined);
const mockSetVimMode = vi.fn();
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => ({
terminalWidth: 100, // Fixed width for consistent snapshots
@@ -63,6 +68,27 @@ vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
};
});
vi.mock('../contexts/VimModeContext.js', async () => {
const actual = await vi.importActual('../contexts/VimModeContext.js');
return {
...actual,
useVimMode: () => ({
vimEnabled: false,
vimMode: 'INSERT' as const,
toggleVimEnabled: mockToggleVimEnabled,
setVimMode: mockSetVimMode,
}),
};
});
vi.mock('../../utils/settingsUtils.js', async () => {
const actual = await vi.importActual('../../utils/settingsUtils.js');
return {
...actual,
saveModifiedSettings: vi.fn(),
};
});
// Shared test schemas
enum StringEnum {
FOO = 'foo',
@@ -105,62 +131,6 @@ const ENUM_FAKE_SCHEMA: SettingsSchemaType = {
},
} as unknown as SettingsSchemaType;
const ARRAY_FAKE_SCHEMA: SettingsSchemaType = {
context: {
type: 'object',
label: 'Context',
category: 'Context',
requiresRestart: false,
default: {},
description: 'Context settings.',
showInDialog: false,
properties: {
fileFiltering: {
type: 'object',
label: 'File Filtering',
category: 'Context',
requiresRestart: false,
default: {},
description: 'File filtering settings.',
showInDialog: false,
properties: {
customIgnoreFilePaths: {
type: 'array',
label: 'Custom Ignore File Paths',
category: 'Context',
requiresRestart: false,
default: [] as string[],
description: 'Additional ignore file paths.',
showInDialog: true,
items: { type: 'string' },
},
},
},
},
},
security: {
type: 'object',
label: 'Security',
category: 'Security',
requiresRestart: false,
default: {},
description: 'Security settings.',
showInDialog: false,
properties: {
allowedExtensions: {
type: 'array',
label: 'Extension Source Regex Allowlist',
category: 'Security',
requiresRestart: false,
default: [] as string[],
description: 'Allowed extension source regex patterns.',
showInDialog: true,
items: { type: 'string' },
},
},
},
} as unknown as SettingsSchemaType;
const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = {
tools: {
type: 'object',
@@ -215,7 +185,7 @@ const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = {
// Helper function to render SettingsDialog with standard wrapper
const renderDialog = (
settings: ReturnType<typeof createMockSettings>,
settings: LoadedSettings,
onSelect: ReturnType<typeof vi.fn>,
options?: {
onRestartRequest?: ReturnType<typeof vi.fn>;
@@ -223,15 +193,14 @@ const renderDialog = (
},
) =>
render(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog
onSelect={onSelect}
onRestartRequest={options?.onRestartRequest}
availableTerminalHeight={options?.availableTerminalHeight}
/>
</KeypressProvider>
</SettingsContext.Provider>,
<KeypressProvider>
<SettingsDialog
settings={settings}
onSelect={onSelect}
onRestartRequest={options?.onRestartRequest}
availableTerminalHeight={options?.availableTerminalHeight}
/>
</KeypressProvider>,
);
describe('SettingsDialog', () => {
@@ -241,6 +210,7 @@ describe('SettingsDialog', () => {
terminalCapabilityManager,
'isKittyProtocolEnabled',
).mockReturnValue(true);
mockToggleVimEnabled.mockRejectedValue(undefined);
});
afterEach(() => {
@@ -424,8 +394,9 @@ describe('SettingsDialog', () => {
describe('Settings Toggling', () => {
it('should toggle setting with Enter key', async () => {
vi.mocked(saveModifiedSettings).mockClear();
const settings = createMockSettings();
const setValueSpy = vi.spyOn(settings, 'setValue');
const onSelect = vi.fn();
const { stdin, unmount, lastFrame, waitUntilReady } = renderDialog(
@@ -443,16 +414,29 @@ describe('SettingsDialog', () => {
await act(async () => {
stdin.write(TerminalKeys.ENTER as string);
});
await waitUntilReady();
// Wait for setValue to be called
// Wait for the setting change to be processed
await waitFor(() => {
expect(setValueSpy).toHaveBeenCalled();
expect(
vi.mocked(saveModifiedSettings).mock.calls.length,
).toBeGreaterThan(0);
});
expect(setValueSpy).toHaveBeenCalledWith(
// Wait for the mock to be called
await waitFor(() => {
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
});
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['general.vimMode']),
expect.objectContaining({
general: expect.objectContaining({
vimMode: true,
}),
}),
expect.any(LoadedSettings),
SettingScope.User,
'general.vimMode',
true,
);
unmount();
@@ -471,13 +455,13 @@ describe('SettingsDialog', () => {
expectedValue: StringEnum.FOO,
},
])('$name', async ({ initialValue, expectedValue }) => {
vi.mocked(saveModifiedSettings).mockClear();
vi.mocked(getSettingsSchema).mockReturnValue(ENUM_FAKE_SCHEMA);
const settings = createMockSettings();
if (initialValue !== undefined) {
settings.setValue(SettingScope.User, 'ui.theme', initialValue);
}
const setValueSpy = vi.spyOn(settings, 'setValue');
const onSelect = vi.fn();
@@ -498,13 +482,20 @@ describe('SettingsDialog', () => {
await waitUntilReady();
await waitFor(() => {
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'ui.theme',
expectedValue,
);
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
});
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['ui.theme']),
expect.objectContaining({
ui: expect.objectContaining({
theme: expectedValue,
}),
}),
expect.any(LoadedSettings),
SettingScope.User,
);
unmount();
});
});
@@ -701,6 +692,30 @@ describe('SettingsDialog', () => {
});
});
describe('Error Handling', () => {
it('should handle vim mode toggle errors gracefully', async () => {
mockToggleVimEnabled.mockRejectedValue(new Error('Toggle failed'));
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount, waitUntilReady } = renderDialog(
settings,
onSelect,
);
await waitUntilReady();
// Try to toggle a setting (this might trigger vim mode toggle)
await act(async () => {
stdin.write(TerminalKeys.ENTER as string); // Enter
});
await waitUntilReady();
// Should not crash
unmount();
});
});
describe('Complex State Management', () => {
it('should track modified settings correctly', async () => {
const settings = createMockSettings();
@@ -752,6 +767,31 @@ describe('SettingsDialog', () => {
});
});
describe('VimMode Integration', () => {
it('should sync with VimModeContext when vim mode is toggled', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount, waitUntilReady } = render(
<VimModeProvider settings={settings}>
<KeypressProvider>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>
</VimModeProvider>,
);
await waitUntilReady();
// Navigate to and toggle vim mode setting
// This would require knowing the exact position of vim mode setting
await act(async () => {
stdin.write(TerminalKeys.ENTER as string); // Enter
});
await waitUntilReady();
unmount();
});
});
describe('Specific Settings Behavior', () => {
it('should show correct display values for settings with different states', async () => {
const settings = createMockSettings({
@@ -821,7 +861,7 @@ describe('SettingsDialog', () => {
// Should not show restart prompt initially
await waitFor(() => {
expect(lastFrame()).not.toContain(
'Changes that require a restart have been modified',
'To see changes, Gemini CLI must be restarted',
);
});
@@ -917,41 +957,63 @@ describe('SettingsDialog', () => {
pager: 'less',
},
},
])('should $name', async ({ toggleCount, shellSettings }) => {
vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA);
])(
'should $name',
async ({ toggleCount, shellSettings, expectedSiblings }) => {
vi.mocked(saveModifiedSettings).mockClear();
const settings = createMockSettings({
tools: {
shell: shellSettings,
},
});
const setValueSpy = vi.spyOn(settings, 'setValue');
vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA);
const onSelect = vi.fn();
const { stdin, unmount } = renderDialog(settings, onSelect);
for (let i = 0; i < toggleCount; i++) {
act(() => {
stdin.write(TerminalKeys.ENTER as string);
const settings = createMockSettings({
tools: {
shell: shellSettings,
},
});
}
await waitFor(() => {
expect(setValueSpy).toHaveBeenCalled();
});
const onSelect = vi.fn();
// With the store pattern, setValue is called atomically per key.
// Sibling preservation is handled by LoadedSettings internally.
const calls = setValueSpy.mock.calls;
expect(calls.length).toBeGreaterThan(0);
calls.forEach((call) => {
// Each call should target only 'tools.shell.showColor'
expect(call[1]).toBe('tools.shell.showColor');
});
const { stdin, unmount, waitUntilReady } = renderDialog(
settings,
onSelect,
);
await waitUntilReady();
unmount();
});
for (let i = 0; i < toggleCount; i++) {
await act(async () => {
stdin.write(TerminalKeys.ENTER as string);
});
await waitUntilReady();
}
await waitFor(() => {
expect(
vi.mocked(saveModifiedSettings).mock.calls.length,
).toBeGreaterThan(0);
});
const calls = vi.mocked(saveModifiedSettings).mock.calls;
calls.forEach((call) => {
const [modifiedKeys, pendingSettings] = call;
if (modifiedKeys.has('tools.shell.showColor')) {
const shellSettings = pendingSettings.tools?.shell as
| Record<string, unknown>
| undefined;
Object.entries(expectedSiblings).forEach(([key, value]) => {
expect(shellSettings?.[key]).toBe(value);
expect(modifiedKeys.has(`tools.shell.${key}`)).toBe(false);
});
expect(modifiedKeys.size).toBe(1);
}
});
expect(calls.length).toBeGreaterThan(0);
unmount();
},
);
});
describe('Keyboard Shortcuts Edge Cases', () => {
@@ -1257,7 +1319,7 @@ describe('SettingsDialog', () => {
await waitFor(() => {
expect(lastFrame()).toContain(
'Changes that require a restart have been modified',
'To see changes, Gemini CLI must be restarted',
);
});
@@ -1304,7 +1366,7 @@ describe('SettingsDialog', () => {
await waitFor(() => {
expect(lastFrame()).toContain(
'Changes that require a restart have been modified',
'To see changes, Gemini CLI must be restarted',
);
});
@@ -1323,11 +1385,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount, rerender, waitUntilReady } = render(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog onSelect={onSelect} />
</KeypressProvider>
</SettingsContext.Provider>,
<KeypressProvider>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
await waitUntilReady();
@@ -1364,13 +1424,14 @@ describe('SettingsDialog', () => {
path: '',
},
});
rerender(
<SettingsContext.Provider value={settings}>
await act(async () => {
rerender(
<KeypressProvider>
<SettingsDialog onSelect={onSelect} />
</KeypressProvider>
</SettingsContext.Provider>,
);
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
});
await waitUntilReady();
// Press Escape to exit
await act(async () => {
@@ -1386,74 +1447,6 @@ describe('SettingsDialog', () => {
});
});
describe('Array Settings Editing', () => {
const typeInput = async (
stdin: { write: (data: string) => void },
input: string,
) => {
for (const ch of input) {
await act(async () => {
stdin.write(ch);
});
}
};
it('should parse comma-separated input as string arrays', async () => {
vi.mocked(getSettingsSchema).mockReturnValue(ARRAY_FAKE_SCHEMA);
const settings = createMockSettings();
const setValueSpy = vi.spyOn(settings, 'setValue');
const { stdin, unmount } = renderDialog(settings, vi.fn());
await act(async () => {
stdin.write(TerminalKeys.ENTER as string); // Start editing first array setting
});
await typeInput(stdin, 'first/path, second/path,third/path');
await act(async () => {
stdin.write(TerminalKeys.ENTER as string); // Commit
});
await waitFor(() => {
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'context.fileFiltering.customIgnoreFilePaths',
['first/path', 'second/path', 'third/path'],
);
});
unmount();
});
it('should parse JSON array input for allowedExtensions', async () => {
vi.mocked(getSettingsSchema).mockReturnValue(ARRAY_FAKE_SCHEMA);
const settings = createMockSettings();
const setValueSpy = vi.spyOn(settings, 'setValue');
const { stdin, unmount } = renderDialog(settings, vi.fn());
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW as string); // Move to second array setting
});
await act(async () => {
stdin.write(TerminalKeys.ENTER as string); // Start editing
});
await typeInput(stdin, '["^github\\\\.com/.*$", "^gitlab\\\\.com/.*$"]');
await act(async () => {
stdin.write(TerminalKeys.ENTER as string); // Commit
});
await waitFor(() => {
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'security.allowedExtensions',
['^github\\.com/.*$', '^gitlab\\.com/.*$'],
);
});
unmount();
});
});
describe('Search Functionality', () => {
it('should display text entered in search', async () => {
const settings = createMockSettings();
+359 -113
View File
@@ -5,35 +5,40 @@
*/
import type React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Text } from 'ink';
import { AsyncFzf } from 'fzf';
import type { Key } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import type { LoadableSettingScope, Settings } from '../../config/settings.js';
import type {
LoadableSettingScope,
LoadedSettings,
Settings,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import {
getDialogSettingKeys,
setPendingSettingValue,
getDisplayValue,
hasRestartRequiredSettings,
saveModifiedSettings,
getSettingDefinition,
getDialogRestartRequiredSettings,
isDefaultValue,
requiresRestart,
getRestartRequiredFromModified,
getEffectiveDefaultValue,
setPendingSettingValueAny,
getEffectiveValue,
isInSettingsScope,
getEditValue,
parseEditedValue,
} from '../../utils/settingsUtils.js';
import {
useSettingsStore,
type SettingsState,
} from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
import {
type SettingsType,
type SettingsValue,
TOGGLE_TYPES,
} from '../../config/settingsSchema.js';
import { debugLogger } from '@google/gemini-cli-core';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
import {
@@ -50,56 +55,31 @@ interface FzfResult {
}
interface SettingsDialogProps {
settings: LoadedSettings;
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
onRestartRequest?: () => void;
availableTerminalHeight?: number;
config?: Config;
}
const MAX_ITEMS_TO_SHOW = 8;
// Create a snapshot of the initial per-scope state of Restart Required Settings
// This creates a nested map of the form
// restartRequiredSetting -> Map { scopeName -> value }
function getActiveRestartRequiredSettings(
settings: SettingsState,
): Map<string, Map<string, string>> {
const snapshot = new Map<string, Map<string, string>>();
const scopes: Array<[string, Settings]> = [
['User', settings.user.settings],
['Workspace', settings.workspace.settings],
['System', settings.system.settings],
];
for (const key of getDialogRestartRequiredSettings()) {
const scopeMap = new Map<string, string>();
for (const [scopeName, scopeSettings] of scopes) {
// Raw per-scope value (undefined if not in file)
const value = isInSettingsScope(key, scopeSettings)
? getEffectiveValue(key, scopeSettings)
: undefined;
scopeMap.set(scopeName, JSON.stringify(value));
}
snapshot.set(key, scopeMap);
}
return snapshot;
}
export function SettingsDialog({
settings,
onSelect,
onRestartRequest,
availableTerminalHeight,
config,
}: SettingsDialogProps): React.JSX.Element {
// Reactive settings from store (re-renders on any settings change)
const { settings, setSetting } = useSettingsStore();
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
// Scope selector state (User by default)
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
SettingScope.User,
);
// Snapshot restart-required values at mount time for diff tracking
const [activeRestartRequiredSettings] = useState(() =>
getActiveRestartRequiredSettings(settings),
);
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
// Search state
const [searchQuery, setSearchQuery] = useState('');
@@ -156,34 +136,52 @@ export function SettingsDialog({
};
}, [searchQuery, fzfInstance, searchMap]);
// Track whether a restart is required to apply the changes in the Settings json file
// This does not care for inheritance
// It checks whether a proposed change from this UI to a settings.json file requires a restart to take effect in the app
const pendingRestartRequiredSettings = useMemo(() => {
const changed = new Set<string>();
const scopes: Array<[string, Settings]> = [
['User', settings.user.settings],
['Workspace', settings.workspace.settings],
['System', settings.system.settings],
];
// Local pending settings state for the selected scope
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
// Deep clone to avoid mutation
structuredClone(settings.forScope(selectedScope).settings),
);
// Iterate through the nested map snapshot in activeRestartRequiredSettings, diff with current settings
for (const [key, initialScopeMap] of activeRestartRequiredSettings) {
for (const [scopeName, scopeSettings] of scopes) {
const currentValue = isInSettingsScope(key, scopeSettings)
? getEffectiveValue(key, scopeSettings)
: undefined;
const initialJson = initialScopeMap.get(scopeName);
if (JSON.stringify(currentValue) !== initialJson) {
changed.add(key);
break; // one scope changed is enough
}
// Track which settings have been modified by the user
const [modifiedSettings, setModifiedSettings] = useState<Set<string>>(
new Set(),
);
// Preserve pending changes across scope switches
type PendingValue = boolean | number | string;
const [globalPendingChanges, setGlobalPendingChanges] = useState<
Map<string, PendingValue>
>(new Map());
// Track restart-required settings across scope changes
const [_restartRequiredSettings, setRestartRequiredSettings] = useState<
Set<string>
>(new Set());
useEffect(() => {
// Base settings for selected scope
let updated = structuredClone(settings.forScope(selectedScope).settings);
// Overlay globally pending (unsaved) changes so user sees their modifications in any scope
const newModified = new Set<string>();
const newRestartRequired = new Set<string>();
for (const [key, value] of globalPendingChanges.entries()) {
const def = getSettingDefinition(key);
if (def?.type === 'boolean' && typeof value === 'boolean') {
updated = setPendingSettingValue(key, value, updated);
} else if (
(def?.type === 'number' && typeof value === 'number') ||
(def?.type === 'string' && typeof value === 'string')
) {
updated = setPendingSettingValueAny(key, value, updated);
}
newModified.add(key);
if (requiresRestart(key)) newRestartRequired.add(key);
}
return changed;
}, [settings, activeRestartRequiredSettings]);
const showRestartPrompt = pendingRestartRequiredSettings.size > 0;
setPendingSettings(updated);
setModifiedSettings(newModified);
setRestartRequiredSettings(newRestartRequired);
setShowRestartPrompt(newRestartRequired.size > 0);
}, [selectedScope, settings, globalPendingChanges]);
// Calculate max width for the left column (Label/Description) to keep values aligned or close
const maxLabelOrDescriptionWidth = useMemo(() => {
@@ -224,10 +222,16 @@ export function SettingsDialog({
return settingKeys.map((key) => {
const definition = getSettingDefinition(key);
const type: SettingsType = definition?.type ?? 'string';
const type = definition?.type ?? 'string';
// Get the display value (with * indicator if modified)
const displayValue = getDisplayValue(key, scopeSettings, mergedSettings);
const displayValue = getDisplayValue(
key,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
// Get the scope message (e.g., "(Modified in Workspace)")
const scopeMessage = getScopeMessageForSetting(
@@ -236,28 +240,28 @@ export function SettingsDialog({
settings,
);
// Grey out values that defer to defaults
const isGreyedOut = !isInSettingsScope(key, scopeSettings);
// Check if the value is at default (grey it out)
const isGreyedOut = isDefaultValue(key, scopeSettings);
// Some settings can be edited by an inline editor
const rawValue = getEffectiveValue(key, scopeSettings);
// The inline editor needs a string but non primitive settings like Arrays and Objects exist
const editValue = getEditValue(type, rawValue);
// Get raw value for edit mode initialization
const rawValue = getEffectiveValue(key, pendingSettings, {});
return {
key,
label: definition?.label || key,
description: definition?.description,
type,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
type: type as 'boolean' | 'number' | 'string' | 'enum',
displayValue,
isGreyedOut,
scopeMessage,
rawValue,
editValue,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawValue: rawValue as string | number | boolean | undefined,
};
});
}, [settingKeys, selectedScope, settings]);
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
// Scope selection handler
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
setSelectedScope(scope);
}, []);
@@ -269,21 +273,17 @@ export function SettingsDialog({
if (!TOGGLE_TYPES.has(definition?.type)) {
return;
}
const scopeSettings = settings.forScope(selectedScope).settings;
const currentValue = getEffectiveValue(key, scopeSettings);
const currentValue = getEffectiveValue(key, pendingSettings, {});
let newValue: SettingsValue;
if (definition?.type === 'boolean') {
if (typeof currentValue !== 'boolean') {
return;
}
newValue = !currentValue;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
newValue = !(currentValue as boolean);
setPendingSettings((prev) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
setPendingSettingValue(key, newValue as boolean, prev),
);
} else if (definition?.type === 'enum' && definition.options) {
const options = definition.options;
if (options.length === 0) {
return;
}
const currentIndex = options?.findIndex(
(opt) => opt.value === currentValue,
);
@@ -292,58 +292,303 @@ export function SettingsDialog({
} else {
newValue = options[0].value; // loop back to start.
}
} else {
return;
setPendingSettings((prev) =>
setPendingSettingValueAny(key, newValue, prev),
);
}
debugLogger.log(
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
newValue,
);
setSetting(selectedScope, key, newValue);
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const currentScopeSettings = settings.forScope(selectedScope).settings;
const immediateSettingsObject = setPendingSettingValueAny(
key,
newValue,
currentScopeSettings,
);
debugLogger.log(
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
newValue,
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Special handling for vim mode to sync with VimModeContext
if (key === 'general.vimMode' && newValue !== vimEnabled) {
// Call toggleVimEnabled to sync the VimModeContext local state
toggleVimEnabled().catch((error) => {
coreEvents.emitFeedback(
'error',
'Failed to toggle vim mode:',
error,
);
});
}
// Remove from modifiedSettings since it's now saved
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Also remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Remove from global pending changes if present
setGlobalPendingChanges((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
return next;
});
} else {
// For restart-required settings, track as modified
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
debugLogger.log(
`[DEBUG SettingsDialog] Modified settings:`,
Array.from(updated),
'Needs restart:',
needsRestart,
);
if (needsRestart) {
setShowRestartPrompt(true);
setRestartRequiredSettings((prevRestart) =>
new Set(prevRestart).add(key),
);
}
return updated;
});
// Record pending change globally
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
next.set(key, newValue as PendingValue);
return next;
});
}
},
[settings, selectedScope, setSetting],
[pendingSettings, settings, selectedScope, vimEnabled, toggleVimEnabled],
);
// For inline editor
// Edit commit handler
const handleEditCommit = useCallback(
(key: string, newValue: string, _item: SettingsDialogItem) => {
const definition = getSettingDefinition(key);
const type: SettingsType = definition?.type ?? 'string';
const parsed = parseEditedValue(type, newValue);
const type = definition?.type;
if (parsed === null) {
if (newValue.trim() === '' && type === 'number') {
// Nothing entered for a number; cancel edit
return;
}
setSetting(selectedScope, key, parsed);
let parsed: string | number;
if (type === 'number') {
const numParsed = Number(newValue.trim());
if (Number.isNaN(numParsed)) {
// Invalid number; cancel edit
return;
}
parsed = numParsed;
} else {
// For strings, use the buffer as is.
parsed = newValue;
}
// Update pending
setPendingSettings((prev) =>
setPendingSettingValueAny(key, parsed, prev),
);
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const currentScopeSettings = settings.forScope(selectedScope).settings;
const immediateSettingsObject = setPendingSettingValueAny(
key,
parsed,
currentScopeSettings,
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Remove from modified sets if present
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Remove from global pending since it's immediately saved
setGlobalPendingChanges((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
return next;
});
} else {
// Mark as modified and needing restart
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
if (needsRestart) {
setShowRestartPrompt(true);
setRestartRequiredSettings((prevRestart) =>
new Set(prevRestart).add(key),
);
}
return updated;
});
// Record pending change globally for persistence across scopes
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
next.set(key, parsed as PendingValue);
return next;
});
}
},
[selectedScope, setSetting],
[settings, selectedScope],
);
// Clear/reset handler - removes the value from settings.json so it falls back to default
const handleItemClear = useCallback(
(key: string, _item: SettingsDialogItem) => {
setSetting(selectedScope, key, undefined);
const defaultValue = getEffectiveDefaultValue(key, config);
// Update local pending state to show the default value
if (typeof defaultValue === 'boolean') {
setPendingSettings((prev) =>
setPendingSettingValue(key, defaultValue, prev),
);
} else if (
typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
) {
setPendingSettings((prev) =>
setPendingSettingValueAny(key, defaultValue, prev),
);
}
// Clear the value from settings.json (set to undefined to remove the key)
if (!requiresRestart(key)) {
settings.setValue(selectedScope, key, undefined);
// Special handling for vim mode
if (key === 'general.vimMode') {
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
if (booleanDefaultValue !== vimEnabled) {
toggleVimEnabled().catch((error) => {
coreEvents.emitFeedback(
'error',
'Failed to toggle vim mode:',
error,
);
});
}
}
}
// Remove from modified sets
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
setGlobalPendingChanges((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
return next;
});
// Update restart prompt
setShowRestartPrompt((_prev) => {
const remaining = getRestartRequiredFromModified(modifiedSettings);
return remaining.filter((k) => k !== key).length > 0;
});
},
[selectedScope, setSetting],
[
config,
settings,
selectedScope,
vimEnabled,
toggleVimEnabled,
modifiedSettings,
],
);
const saveRestartRequiredSettings = useCallback(() => {
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
// Remove saved keys from global pending changes
setGlobalPendingChanges((prev) => {
if (prev.size === 0) return prev;
const next = new Map(prev);
for (const key of restartRequiredSet) {
next.delete(key);
}
return next;
});
}
}, [modifiedSettings, pendingSettings, settings, selectedScope]);
// Close handler
const handleClose = useCallback(() => {
// Save any restart-required settings before closing
saveRestartRequiredSettings();
onSelect(undefined, selectedScope as SettingScope);
}, [onSelect, selectedScope]);
}, [saveRestartRequiredSettings, onSelect, selectedScope]);
// Custom key handler for restart key
const handleKeyPress = useCallback(
(key: Key, _currentItem: SettingsDialogItem | undefined): boolean => {
// 'r' key for restart
if (showRestartPrompt && key.sequence === 'r') {
saveRestartRequiredSettings();
setShowRestartPrompt(false);
setModifiedSettings(new Set());
setRestartRequiredSettings(new Set());
if (onRestartRequest) onRestartRequest();
return true;
}
return false;
},
[showRestartPrompt, onRestartRequest],
[showRestartPrompt, onRestartRequest, saveRestartRequiredSettings],
);
// Calculate effective max items and scope visibility based on terminal height
@@ -428,10 +673,11 @@ export function SettingsDialog({
showRestartPrompt,
]);
// Footer content for restart prompt
const footerContent = showRestartPrompt ? (
<Text color={theme.status.warning}>
Changes that require a restart have been modified. Press r to exit and
apply changes now.
To see changes, Gemini CLI must be restarted. Press r to exit and apply
changes now.
</Text>
) : null;
@@ -89,12 +89,11 @@ const renderStatusDisplay = async (
};
describe('StatusDisplay', () => {
beforeEach(() => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
});
const originalEnv = process.env;
afterEach(() => {
vi.unstubAllEnvs();
process.env = { ...originalEnv };
delete process.env['GEMINI_SYSTEM_MD'];
vi.restoreAllMocks();
});
@@ -113,7 +112,7 @@ describe('StatusDisplay', () => {
});
it('renders system md indicator if env var is set', async () => {
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
process.env['GEMINI_SYSTEM_MD'] = 'true';
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -26,7 +26,6 @@ export const StickyHeader: React.FC<StickyHeaderProps> = ({
containerRef,
}) => (
<Box
ref={containerRef}
sticky
minHeight={1}
flexShrink={0}
@@ -58,6 +57,7 @@ export const StickyHeader: React.FC<StickyHeaderProps> = ({
}
>
<Box
ref={containerRef}
borderStyle="round"
width={width}
borderColor={borderColor}
@@ -84,7 +84,7 @@ export function SuggestionsDisplay({
const originalIndex = startIndex + index;
const isActive = originalIndex === activeIndex;
const isExpanded = originalIndex === expandedIndex;
const textColor = isActive ? theme.ui.focus : theme.text.secondary;
const textColor = isActive ? theme.text.accent : theme.text.secondary;
const isLong = suggestion.value.length >= MAX_WIDTH;
const labelElement = (
<ExpandableText
@@ -97,11 +97,7 @@ export function SuggestionsDisplay({
);
return (
<Box
key={`${suggestion.value}-${originalIndex}`}
flexDirection="row"
backgroundColor={isActive ? theme.background.focus : undefined}
>
<Box key={`${suggestion.value}-${originalIndex}`} flexDirection="row">
<Box
{...(mode === 'slash'
? { width: commandColumnWidth, flexShrink: 0 as const }
@@ -8,22 +8,6 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ThemeDialog } from './ThemeDialog.js';
const { mockIsDevelopment } = vi.hoisted(() => ({
mockIsDevelopment: { value: false },
}));
vi.mock('../../utils/installationInfo.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../utils/installationInfo.js')>();
return {
...actual,
get isDevelopment() {
return mockIsDevelopment.value;
},
};
});
import { createMockSettings } from '../../test-utils/settings.js';
import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js';
import { act } from 'react';
@@ -46,21 +30,17 @@ describe('ThemeDialog Snapshots', () => {
vi.restoreAllMocks();
});
it.each([true, false])(
'should render correctly in theme selection mode (isDevelopment: %s)',
async (isDev) => {
mockIsDevelopment.value = isDev;
const settings = createMockSettings();
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog {...baseProps} settings={settings} />,
{ settings },
);
await waitUntilReady();
it('should render correctly in theme selection mode', async () => {
const settings = createMockSettings();
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog {...baseProps} settings={settings} />,
{ settings },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render correctly in scope selector mode', async () => {
const settings = createMockSettings();
+37 -39
View File
@@ -23,8 +23,6 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { ScopeSelector } from './shared/ScopeSelector.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { ColorsDisplay } from './ColorsDisplay.js';
import { isDevelopment } from '../../utils/installationInfo.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
@@ -247,11 +245,6 @@ export function ThemeDialog({
// The code block is slightly longer than the diff, so give it more space.
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
const previewTheme =
themeManager.getTheme(highlightedThemeName || DEFAULT_THEME.name) ||
DEFAULT_THEME;
return (
<Box
borderStyle="round"
@@ -335,48 +328,53 @@ export function ThemeDialog({
<Text bold color={theme.text.primary}>
Preview
</Text>
<Box
borderStyle="single"
borderColor={theme.border.default}
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
flexDirection="column"
>
{colorizeCode({
code: `# function
{/* Get the Theme object for the highlighted theme, fall back to default if not found */}
{(() => {
const previewTheme =
themeManager.getTheme(
highlightedThemeName || DEFAULT_THEME.name,
) || DEFAULT_THEME;
return (
<Box
borderStyle="single"
borderColor={theme.border.default}
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
flexDirection="column"
>
{colorizeCode({
code: `# function
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a`,
language: 'python',
availableHeight:
isAlternateBuffer === false ? codeBlockHeight : undefined,
maxWidth: colorizeCodeWidth,
settings,
})}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/util.py
language: 'python',
availableHeight:
isAlternateBuffer === false ? codeBlockHeight : undefined,
maxWidth: colorizeCodeWidth,
settings,
})}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/util.py
+++ b/util.py
@@ -1,2 +1,2 @@
- print("Hello, " + name)
+ print(f"Hello, {name}!")
`}
availableTerminalHeight={
isAlternateBuffer === false ? diffHeight : undefined
}
terminalWidth={colorizeCodeWidth}
theme={previewTheme}
/>
</Box>
{isDevelopment && (
<Box marginTop={1}>
<ColorsDisplay activeTheme={previewTheme} />
</Box>
)}
availableTerminalHeight={
isAlternateBuffer === false ? diffHeight : undefined
}
terminalWidth={colorizeCodeWidth}
theme={previewTheme}
/>
</Box>
);
})()}
</Box>
</Box>
) : (
@@ -13,10 +13,6 @@ vi.mock('../semantic-colors.js', () => ({
theme: {
ui: {
gradient: ['red', 'blue'],
focus: 'green',
},
background: {
focus: 'darkgreen',
},
text: {
accent: 'cyan',
+18 -14
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -11,18 +11,22 @@ import type { Config } from '@google/gemini-cli-core';
describe('Tips', () => {
it.each([
{ fileCount: 0, description: 'renders all tips including GEMINI.md tip' },
{ fileCount: 5, description: 'renders fewer tips when GEMINI.md exists' },
])('$description', async ({ fileCount }) => {
const config = {
getGeminiMdFileCount: vi.fn().mockReturnValue(fileCount),
} as unknown as Config;
[0, '3. Create GEMINI.md files'],
[5, '3. /help for more information'],
])(
'renders correct tips when file count is %i',
async (count, expectedText) => {
const config = {
getGeminiMdFileCount: vi.fn().mockReturnValue(count),
} as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = render(
<Tips config={config} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
const { lastFrame, waitUntilReady, unmount } = render(
<Tips config={config} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(expectedText);
unmount();
},
);
});
+18 -14
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -15,26 +15,30 @@ interface TipsProps {
export const Tips: React.FC<TipsProps> = ({ config }) => {
const geminiMdFileCount = config.getGeminiMdFileCount();
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column">
<Text color={theme.text.primary}>Tips for getting started:</Text>
<Text color={theme.text.primary}>
1. Ask questions, edit files, or run commands.
</Text>
<Text color={theme.text.primary}>
2. Be specific for the best results.
</Text>
{geminiMdFileCount === 0 && (
<Text color={theme.text.primary}>
1. Create <Text bold>GEMINI.md</Text> files to customize your
interactions
3. Create{' '}
<Text bold color={theme.text.accent}>
GEMINI.md
</Text>{' '}
files to customize your interactions with Gemini.
</Text>
)}
<Text color={theme.text.primary}>
{geminiMdFileCount === 0 ? '2.' : '1.'}{' '}
<Text color={theme.text.secondary}>/help</Text> for more information
</Text>
<Text color={theme.text.primary}>
{geminiMdFileCount === 0 ? '3.' : '2.'} Ask coding questions, edit code
or run commands
</Text>
<Text color={theme.text.primary}>
{geminiMdFileCount === 0 ? '4.' : '3.'} Be specific for the best results
{geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
<Text bold color={theme.text.accent}>
/help
</Text>{' '}
for more information.
</Text>
</Box>
);

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