mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 20:51:00 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5765905f64 | |||
| 5a36725f22 | |||
| 9d028db351 | |||
| 30e2820dc2 | |||
| ae04632a59 |
@@ -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:** You’ve 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.
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -61,4 +61,4 @@ gemini-debug.log
|
||||
.genkit
|
||||
.gemini-clipboard/
|
||||
.eslintcache
|
||||
evals/logs/
|
||||
evals/logs/
|
||||
+4
-7
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Generated
+23
-270
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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(''))
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+3
-7
@@ -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 |
+2
-4
@@ -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 "test-ext". </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 |
+1
-3
@@ -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 "https://example.com/repo.git". </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's system</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -243,7 +243,7 @@ export async function startInteractiveUI(
|
||||
<ScrollProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
"
|
||||
|
||||
@@ -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%"
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'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> 'red',
|
||||
'green', etc. are mapped to your terminal app'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) => {
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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 "Keep chat history".
|
||||
</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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user