mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-29 03:00:58 -07:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa69351f2b | |||
| 7099d38282 | |||
| 4be08a2261 | |||
| fe332bbef7 | |||
| 50af050623 | |||
| e5207eb67f | |||
| f15bcaf499 | |||
| 5f2f60bed6 | |||
| c332d1e636 | |||
| aa158e18d3 | |||
| fca29b0bd8 | |||
| 1e2afbb514 | |||
| 208291f391 | |||
| 8303edbb54 | |||
| 0d69f9f7fa | |||
| 46231a1755 | |||
| 2e7722d6a3 | |||
| 69e15a50d1 | |||
| 25f59a0099 | |||
| 01927a36d1 | |||
| 06ddfa5c4c | |||
| 3f7ef816f1 | |||
| d05ba11a31 | |||
| e43b1cff58 | |||
| bb6d1a2775 | |||
| dd9ccc9807 | |||
| 8133d63ac6 | |||
| 18d0375a7f | |||
| 31ca57ec94 | |||
| b7a8f0d1f9 | |||
| 1502e5cbc3 | |||
| 7ca3a33f8b | |||
| ce5a2d0760 | |||
| aa321b3d8c | |||
| 3a7a6e1540 | |||
| 66530e44c8 | |||
| b034dcd412 | |||
| 659301ff83 | |||
| 446a4316c4 | |||
| 48412a068e | |||
| 2e1efaebe4 | |||
| 7c9fceba7f | |||
| 740efa2ac2 |
@@ -14,3 +14,4 @@
|
||||
|
||||
# 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,6 +290,7 @@ 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
|
||||
@@ -302,7 +303,14 @@ 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,7 +117,6 @@ 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,6 +23,10 @@ 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 }}'
|
||||
@@ -33,7 +37,7 @@ jobs:
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_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, '/assign') || contains(github.event.comment.body, '/unassign'))
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
@@ -38,6 +38,7 @@ 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 }}'
|
||||
@@ -108,3 +109,42 @@ 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.`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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/
|
||||
|
||||
+7
-4
@@ -75,11 +75,14 @@ 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 issues
|
||||
### Self-assigning and unassigning issues
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
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/get-started/configuration.md) - Settings and
|
||||
- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and
|
||||
customization.
|
||||
- [**Keyboard Shortcuts**](./docs/cli/keyboard-shortcuts.md) - Productivity
|
||||
tips.
|
||||
- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) -
|
||||
Productivity tips.
|
||||
|
||||
### Core Features
|
||||
|
||||
- [**Commands Reference**](./docs/cli/commands.md) - All slash commands
|
||||
- [**Commands Reference**](./docs/reference/commands.md) - All slash commands
|
||||
(`/help`, `/chat`, etc).
|
||||
- [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own
|
||||
reusable commands.
|
||||
@@ -323,15 +323,16 @@ 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/core/tools-api.md) - Create custom tools.
|
||||
- [**Tools API Development**](./docs/reference/tools-api.md) - Create custom
|
||||
tools.
|
||||
- [**Local development**](./docs/local-development.md) - Local development
|
||||
tooling.
|
||||
|
||||
### Troubleshooting & Support
|
||||
|
||||
- [**Troubleshooting Guide**](./docs/troubleshooting.md) - Common issues and
|
||||
solutions.
|
||||
- [**FAQ**](./docs/faq.md) - Frequently asked questions.
|
||||
- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common
|
||||
issues and solutions.
|
||||
- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions.
|
||||
- Use `/bug` command to report issues directly from the CLI.
|
||||
|
||||
### Using MCP Servers
|
||||
@@ -377,7 +378,8 @@ for planned features and priorities.
|
||||
|
||||
### Uninstall
|
||||
|
||||
See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
|
||||
See the [Uninstall Guide](./docs/resources/uninstall.md) for removal
|
||||
instructions.
|
||||
|
||||
## 📄 Legal
|
||||
|
||||
|
||||
@@ -464,8 +464,9 @@ 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 compression threshold in
|
||||
`/settings`. The default has been made more proactive
|
||||
- **Configurable compression:** Users can modify the context compression
|
||||
threshold in `/settings` (decimal with percentage display). 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
|
||||
|
||||
+33
-7
@@ -25,9 +25,10 @@ implementation. It allows you to:
|
||||
- [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 research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
|
||||
- [Example: Enable custom subagents in Plan Mode](#example-enable-custom-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
|
||||
|
||||
@@ -109,8 +110,9 @@ 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. The CLI will automatically refresh and show the
|
||||
updated plan after you save and close the editor.
|
||||
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.
|
||||
|
||||
For more complex or specialized planning tasks, you can
|
||||
[customize the planning workflow with skills](#customizing-planning-with-skills).
|
||||
@@ -132,6 +134,7 @@ 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.
|
||||
@@ -202,16 +205,17 @@ priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Enable research subagents in Plan Mode
|
||||
#### Example: Enable custom subagents in Plan Mode
|
||||
|
||||
You can enable experimental research [subagents] like `codebase_investigator` to
|
||||
help gather architecture details during the planning phase.
|
||||
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.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
toolName = "my_custom_subagent"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
@@ -289,6 +293,24 @@ 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
|
||||
@@ -299,7 +321,10 @@ performance. You can disable this automatic switching in your settings:
|
||||
[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
|
||||
@@ -310,3 +335,4 @@ performance. You can disable this automatic switching in your settings:
|
||||
[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,27 +121,36 @@ session lengths.
|
||||
|
||||
### Session retention
|
||||
|
||||
To prevent your history from growing indefinitely, enable automatic cleanup
|
||||
policies in your settings.
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"sessionRetention": {
|
||||
"enabled": true,
|
||||
"maxAge": "30d", // Keep sessions for 30 days
|
||||
"maxCount": 50 // Keep the 50 most recent sessions
|
||||
"maxAge": "30d",
|
||||
"maxCount": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`enabled`**: (boolean) Master switch for session cleanup. Defaults to
|
||||
`false`.
|
||||
`true`.
|
||||
- **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d",
|
||||
"4w"). Sessions older than this are deleted.
|
||||
"4w"). Sessions older than this are deleted. Defaults to `"30d"`.
|
||||
- **`maxCount`**: (number) Maximum number of sessions to retain. The oldest
|
||||
sessions exceeding this count are deleted.
|
||||
sessions exceeding this count are deleted. Defaults to undefined (unlimited).
|
||||
- **`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 | `false` |
|
||||
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
|
||||
| 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"` |
|
||||
|
||||
### 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 remaining percentage. | `true` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
### Context
|
||||
|
||||
|
||||
@@ -122,7 +122,10 @@ The manifest file defines the extension's behavior and configuration.
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
"excludeTools": ["run_shell_command"],
|
||||
"plan": {
|
||||
"directory": ".gemini/plans"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -157,6 +160,11 @@ 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
|
||||
|
||||
@@ -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:** `false`
|
||||
- **Default:** `true`
|
||||
|
||||
- **`general.sessionRetention.maxAge`** (string):
|
||||
- **Description:** Automatically delete chats older than this time period
|
||||
(e.g., "30d", "7d", "24h", "1w")
|
||||
- **Default:** `undefined`
|
||||
- **Default:** `"30d"`
|
||||
|
||||
- **`general.sessionRetention.maxCount`** (number):
|
||||
- **Description:** Alternative: Maximum number of sessions to keep (most
|
||||
@@ -175,11 +175,6 @@ 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):
|
||||
@@ -268,7 +263,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.footer.hideContextPercentage`** (boolean):
|
||||
- **Description:** Hides the context window remaining percentage.
|
||||
- **Description:** Hides the context window usage percentage.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.hideFooter`** (boolean):
|
||||
|
||||
@@ -152,3 +152,13 @@ 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.
|
||||
|
||||
@@ -88,6 +88,9 @@ 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: {
|
||||
|
||||
+35
-12
@@ -25,6 +25,18 @@ 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
|
||||
@@ -120,18 +132,7 @@ export default tseslint.config(
|
||||
'no-cond-assign': 'error',
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-case': 'error',
|
||||
'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-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
|
||||
'no-unsafe-finally': 'error',
|
||||
'no-unused-expressions': 'off', // Disable base rule
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
@@ -171,6 +172,28 @@ 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}'],
|
||||
|
||||
+44
-1
@@ -3,7 +3,8 @@
|
||||
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.
|
||||
mechanisms, and as a tool for assessing feature reliability by model, and
|
||||
preventing regressions.
|
||||
|
||||
## Why Behavioral Evals?
|
||||
|
||||
@@ -30,6 +31,48 @@ 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
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @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,14 +165,15 @@ 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(${JSON.stringify(messageCountFile)}, 'utf-8')); } catch (e) {}
|
||||
try { counts = JSON.parse(fs.readFileSync(${escapedPath}, 'utf-8')); } catch (e) {}
|
||||
counts.push(messageCount);
|
||||
fs.writeFileSync(${JSON.stringify(messageCountFile)}, JSON.stringify(counts));
|
||||
fs.writeFileSync(${escapedPath}, JSON.stringify(counts));
|
||||
console.log(JSON.stringify({ decision: 'allow' }));
|
||||
`;
|
||||
const beforeModelScriptPath = rig.createScript(
|
||||
|
||||
@@ -81,7 +81,9 @@ describe('JSON output', () => {
|
||||
const message = (thrown as Error).message;
|
||||
|
||||
// Use a regex to find the first complete JSON object in the string
|
||||
const jsonMatch = message.match(/{[\s\S]*}/);
|
||||
// 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]*}/);
|
||||
|
||||
// Fail if no JSON-like text was found
|
||||
expect(
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* 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 } from './test-helper.js';
|
||||
import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js';
|
||||
|
||||
describe('Plan Mode', () => {
|
||||
let rig: TestRig;
|
||||
@@ -62,50 +64,98 @@ describe('Plan Mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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'],
|
||||
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,
|
||||
},
|
||||
general: { defaultApprovalMode: 'plan' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 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',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeLogs = toolLogs.filter(
|
||||
(l) => l.toolRequest.name === 'write_file',
|
||||
// Disable the interactive terminal setup prompt in tests
|
||||
writeFileSync(
|
||||
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
|
||||
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
|
||||
);
|
||||
|
||||
const planWrite = writeLogs.find(
|
||||
const run = await rig.runInteractive({
|
||||
approvalMode: 'plan',
|
||||
});
|
||||
|
||||
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(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'write_file' &&
|
||||
l.toolRequest.args.includes('plans') &&
|
||||
l.toolRequest.args.includes('plan.md'),
|
||||
);
|
||||
expect(planWrite?.toolRequest.success).toBe(true);
|
||||
});
|
||||
|
||||
const blockedWrite = writeLogs.find((l) =>
|
||||
l.toolRequest.args.includes('hello.txt'),
|
||||
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),
|
||||
);
|
||||
|
||||
// Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
|
||||
if (blockedWrite) {
|
||||
expect(blockedWrite?.toolRequest.success).toBe(false);
|
||||
}
|
||||
const run = await rig.runInteractive({
|
||||
approvalMode: 'plan',
|
||||
});
|
||||
|
||||
expect(planWrite?.toolRequest.success).toBe(true);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
it('should be able to enter plan mode from default mode', async () => {
|
||||
@@ -119,6 +169,12 @@ 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',
|
||||
@@ -126,10 +182,7 @@ 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',
|
||||
10000,
|
||||
);
|
||||
const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ 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':
|
||||
@@ -238,8 +239,12 @@ 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'] } },
|
||||
settings: {
|
||||
tools: { core: ['run_shell_command'] },
|
||||
shell: isWindows ? { enableInteractiveShell: false } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n');
|
||||
|
||||
Generated
+79
-5
@@ -5464,6 +5464,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
|
||||
@@ -6563,6 +6570,10 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -8539,6 +8550,36 @@
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -8790,11 +8831,34 @@
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"peer": true,
|
||||
"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",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
@@ -16222,6 +16286,16 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
|
||||
+4
-2
@@ -37,10 +37,12 @@
|
||||
"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",
|
||||
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
|
||||
"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: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",
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
type ToolCallConfirmationDetails,
|
||||
type Config,
|
||||
type UserTierId,
|
||||
type AnsiOutput,
|
||||
type ToolLiveOutput,
|
||||
isSubagentProgress,
|
||||
EDIT_TOOL_NAMES,
|
||||
processRestorableToolCalls,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -336,11 +337,13 @@ export class Task {
|
||||
|
||||
private _schedulerOutputUpdate(
|
||||
toolCallId: string,
|
||||
outputChunk: string | AnsiOutput,
|
||||
outputChunk: ToolLiveOutput,
|
||||
): 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 from 'express';
|
||||
import express, { type Request } from 'express';
|
||||
|
||||
import type { AgentCard, Message } from '@a2a-js/sdk';
|
||||
import {
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
InMemoryTaskStore,
|
||||
DefaultExecutionEventBus,
|
||||
type AgentExecutionEvent,
|
||||
UnauthenticatedUser,
|
||||
} from '@a2a-js/sdk/server';
|
||||
import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components
|
||||
import { A2AExpressApp, type UserBuilder } 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';
|
||||
@@ -55,8 +56,17 @@ const coderAgentCard: AgentCard = {
|
||||
pushNotifications: false,
|
||||
stateTransitionHistory: true,
|
||||
},
|
||||
securitySchemes: undefined,
|
||||
security: undefined,
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
},
|
||||
basicAuth: {
|
||||
type: 'http',
|
||||
scheme: 'basic',
|
||||
},
|
||||
},
|
||||
security: [{ bearerAuth: [] }, { basicAuth: [] }],
|
||||
defaultInputModes: ['text'],
|
||||
defaultOutputModes: ['text'],
|
||||
skills: [
|
||||
@@ -81,6 +91,35 @@ 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,
|
||||
@@ -204,7 +243,7 @@ export async function createApp() {
|
||||
requestStorage.run({ req }, next);
|
||||
});
|
||||
|
||||
const appBuilder = new A2AExpressApp(requestHandler);
|
||||
const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder);
|
||||
expressApp = appBuilder.setupRoutes(expressApp, '');
|
||||
expressApp.use(express.json());
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
debugLogger,
|
||||
ApprovalMode,
|
||||
type MCPServerConfig,
|
||||
type GeminiCLIExtension,
|
||||
Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import {
|
||||
@@ -3524,4 +3526,101 @@ 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,6 +511,10 @@ 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 = '';
|
||||
@@ -827,7 +831,9 @@ export async function loadCliConfig(
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -886,6 +886,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
themes: config.themes,
|
||||
rules,
|
||||
checkers,
|
||||
plan: config.plan,
|
||||
};
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
|
||||
@@ -33,6 +33,15 @@ 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 {
|
||||
|
||||
@@ -102,7 +102,9 @@ export async function loadSandboxConfig(
|
||||
|
||||
const packageJson = await getPackageJson(__dirname);
|
||||
const image =
|
||||
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri;
|
||||
process.env['GEMINI_SANDBOX_IMAGE'] ??
|
||||
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
|
||||
packageJson?.config?.sandboxImageUri;
|
||||
|
||||
return command && image ? { command, image } : undefined;
|
||||
}
|
||||
|
||||
@@ -185,9 +185,6 @@ 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,6 +117,10 @@ 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`.
|
||||
*/
|
||||
@@ -339,7 +343,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Session Cleanup',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
default: true as boolean,
|
||||
description: 'Enable automatic session cleanup',
|
||||
showInDialog: true,
|
||||
},
|
||||
@@ -348,7 +352,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Keep chat history',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
default: '30d' as string,
|
||||
description:
|
||||
'Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w")',
|
||||
showInDialog: true,
|
||||
@@ -372,16 +376,6 @@ 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.',
|
||||
},
|
||||
@@ -605,7 +599,7 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Hides the context window remaining percentage.',
|
||||
description: 'Hides the context window usage percentage.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
@@ -923,13 +917,14 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
compressionThreshold: {
|
||||
type: 'number',
|
||||
label: 'Compression Threshold',
|
||||
label: 'Context 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',
|
||||
|
||||
@@ -243,7 +243,7 @@ export async function startInteractiveUI(
|
||||
<ScrollProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<VimModeProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
|
||||
@@ -528,12 +528,13 @@ 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',
|
||||
terminalBackgroundColor: 'black' as const,
|
||||
cleanUiDetailsVisible: false,
|
||||
allowPlanMode: true,
|
||||
activePtyId: undefined,
|
||||
@@ -552,6 +553,9 @@ const baseMockUiState = {
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: false,
|
||||
nightly: false,
|
||||
updateInfo: null,
|
||||
pendingHistoryItems: [],
|
||||
};
|
||||
|
||||
export const mockAppState: AppState = {
|
||||
@@ -752,7 +756,7 @@ export const renderWithProviders = (
|
||||
<ConfigContext.Provider value={finalConfig}>
|
||||
<SettingsContext.Provider value={finalSettings}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider settings={finalSettings}>
|
||||
<VimModeProvider>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<SessionStatsProvider>
|
||||
<StreamingContext.Provider
|
||||
|
||||
@@ -146,7 +146,6 @@ 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';
|
||||
@@ -1548,28 +1547,6 @@ 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(() => {
|
||||
@@ -2015,7 +1992,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const dialogsVisible =
|
||||
(shouldShowRetentionWarning && retentionCheckComplete) ||
|
||||
shouldShowIdePrompt ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
isPolicyUpdateDialogOpen ||
|
||||
@@ -2202,9 +2179,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
history: historyManager.history,
|
||||
historyManager,
|
||||
isThemeDialogOpen,
|
||||
shouldShowRetentionWarning:
|
||||
shouldShowRetentionWarning && retentionCheckComplete,
|
||||
sessionsToDeleteCount: sessionsToDeleteCount ?? 0,
|
||||
|
||||
themeError,
|
||||
isAuthenticating,
|
||||
isConfigInitialized,
|
||||
@@ -2334,9 +2309,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
shouldShowRetentionWarning,
|
||||
retentionCheckComplete,
|
||||
sessionsToDeleteCount,
|
||||
|
||||
themeError,
|
||||
isAuthenticating,
|
||||
isConfigInitialized,
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
|
||||
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -47,34 +47,31 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
|
||||
"Notifications
|
||||
Footer
|
||||
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
Composer
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -110,20 +107,17 @@ 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. 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.
|
||||
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
|
||||
HistoryItemDisplay
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required │
|
||||
@@ -146,6 +140,9 @@ HistoryItemDisplay
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
Composer
|
||||
"
|
||||
|
||||
@@ -21,6 +21,10 @@ 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 {
|
||||
@@ -39,6 +43,8 @@ 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 =
|
||||
@@ -167,6 +173,7 @@ describe('extensionsCommand', () => {
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: mockDispatchExtensionState,
|
||||
removeComponent: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -429,6 +436,61 @@ 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,7 +280,9 @@ async function exploreAction(
|
||||
type: 'custom_dialog' as const,
|
||||
component: React.createElement(ExtensionRegistryView, {
|
||||
onSelect: (extension) => {
|
||||
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
|
||||
debugLogger.log(`Selected extension: ${extension.extensionName}`);
|
||||
void installAction(context, extension.url);
|
||||
context.ui.removeComponent();
|
||||
},
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
extensionManager,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
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';
|
||||
@@ -127,13 +126,10 @@ describe('hooksCommand', () => {
|
||||
createMockHook('test-hook', HookEventName.BeforeTool, true),
|
||||
]);
|
||||
|
||||
await hooksCommand.action(mockContext, '');
|
||||
const result = await hooksCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.HOOKS_LIST,
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
expect(result).toHaveProperty('component');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,7 +157,7 @@ describe('hooksCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should display panel even when hook system is not enabled', async () => {
|
||||
it('should return custom_dialog even when hook system is not enabled', async () => {
|
||||
mockConfig.getHookSystem.mockReturnValue(null);
|
||||
|
||||
const panelCmd = hooksCommand.subCommands!.find(
|
||||
@@ -171,17 +167,13 @@ describe('hooksCommand', () => {
|
||||
throw new Error('panel command must have an action');
|
||||
}
|
||||
|
||||
await panelCmd.action(mockContext, '');
|
||||
const result = await panelCmd.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.HOOKS_LIST,
|
||||
hooks: [],
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
expect(result).toHaveProperty('component');
|
||||
});
|
||||
|
||||
it('should display panel when no hooks are configured', async () => {
|
||||
it('should return custom_dialog when no hooks are configured', async () => {
|
||||
mockHookSystem.getAllHooks.mockReturnValue([]);
|
||||
(mockContext.services.settings.merged as Record<string, unknown>)[
|
||||
'hooksConfig'
|
||||
@@ -194,17 +186,13 @@ describe('hooksCommand', () => {
|
||||
throw new Error('panel command must have an action');
|
||||
}
|
||||
|
||||
await panelCmd.action(mockContext, '');
|
||||
const result = await panelCmd.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.HOOKS_LIST,
|
||||
hooks: [],
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
expect(result).toHaveProperty('component');
|
||||
});
|
||||
|
||||
it('should display hooks list when hooks are configured', async () => {
|
||||
it('should return custom_dialog when hooks are configured', async () => {
|
||||
const mockHooks: HookRegistryEntry[] = [
|
||||
createMockHook('echo-test', HookEventName.BeforeTool, true),
|
||||
createMockHook('notify', HookEventName.AfterAgent, false),
|
||||
@@ -222,14 +210,10 @@ describe('hooksCommand', () => {
|
||||
throw new Error('panel command must have an action');
|
||||
}
|
||||
|
||||
await panelCmd.action(mockContext, '');
|
||||
const result = await panelCmd.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.HOOKS_LIST,
|
||||
hooks: mockHooks,
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
expect(result).toHaveProperty('component');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SlashCommand, CommandContext } from './types.js';
|
||||
import { createElement } from 'react';
|
||||
import type {
|
||||
SlashCommand,
|
||||
CommandContext,
|
||||
OpenCustomDialogActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType, type HistoryItemHooksList } from '../types.js';
|
||||
import type {
|
||||
HookRegistryEntry,
|
||||
MessageActionReturn,
|
||||
@@ -15,13 +19,14 @@ 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
|
||||
* Display a formatted list of hooks with their status in a dialog
|
||||
*/
|
||||
async function panelAction(
|
||||
function panelAction(
|
||||
context: CommandContext,
|
||||
): Promise<void | MessageActionReturn> {
|
||||
): MessageActionReturn | OpenCustomDialogActionReturn {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
@@ -34,12 +39,13 @@ async function panelAction(
|
||||
const hookSystem = config.getHookSystem();
|
||||
const allHooks = hookSystem?.getAllHooks() || [];
|
||||
|
||||
const hooksListItem: HistoryItemHooksList = {
|
||||
type: MessageType.HOOKS_LIST,
|
||||
hooks: allHooks,
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: createElement(HooksDialog, {
|
||||
hooks: allHooks,
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
}),
|
||||
};
|
||||
|
||||
context.ui.addItem(hooksListItem);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,6 +349,7 @@ const panelCommand: SlashCommand = {
|
||||
altNames: ['list', 'show'],
|
||||
description: 'Display all registered hooks with their status',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: panelAction,
|
||||
};
|
||||
|
||||
@@ -393,5 +400,5 @@ export const hooksCommand: SlashCommand = {
|
||||
enableAllCommand,
|
||||
disableAllCommand,
|
||||
],
|
||||
action: async (context: CommandContext) => panelCommand.action!(context, ''),
|
||||
action: (context: CommandContext) => panelCommand.action!(context, ''),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
BaseSettingsDialog,
|
||||
type SettingsDialogItem,
|
||||
} from './shared/BaseSettingsDialog.js';
|
||||
import { getNestedValue, isRecord } from '../../utils/settingsUtils.js';
|
||||
|
||||
/**
|
||||
* Configuration field definition for agent settings
|
||||
@@ -111,32 +112,12 @@ 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: Record<string, unknown>,
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
function setNestedValue(obj: unknown, path: string[], value: unknown): unknown {
|
||||
if (!isRecord(obj)) return obj;
|
||||
|
||||
const result = { ...obj };
|
||||
let current = result;
|
||||
|
||||
@@ -144,12 +125,17 @@ function setNestedValue(
|
||||
const key = path[i];
|
||||
if (current[key] === undefined || current[key] === null) {
|
||||
current[key] = {};
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current[key] = { ...(current[key] as Record<string, unknown>) };
|
||||
} 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 = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const finalKey = path[path.length - 1];
|
||||
@@ -267,11 +253,7 @@ export function AgentConfigDialog({
|
||||
const items: SettingsDialogItem[] = useMemo(
|
||||
() =>
|
||||
AGENT_CONFIG_FIELDS.map((field) => {
|
||||
const currentValue = getNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
const currentValue = getNestedValue(pendingOverride, field.path);
|
||||
const defaultValue = getFieldDefaultFromDefinition(field, definition);
|
||||
const effectiveValue =
|
||||
currentValue !== undefined ? currentValue : defaultValue;
|
||||
@@ -324,23 +306,18 @@ export function AgentConfigDialog({
|
||||
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
|
||||
if (!field || field.type !== 'boolean') return;
|
||||
|
||||
const currentValue = getNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
const currentValue = getNestedValue(pendingOverride, 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(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
pendingOverride,
|
||||
field.path,
|
||||
newValue,
|
||||
) as AgentOverride;
|
||||
|
||||
setPendingOverride(newOverride);
|
||||
setModifiedFields((prev) => new Set(prev).add(key));
|
||||
|
||||
@@ -375,9 +352,9 @@ export function AgentConfigDialog({
|
||||
}
|
||||
|
||||
// Update pending override locally
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
pendingOverride,
|
||||
field.path,
|
||||
parsed,
|
||||
) as AgentOverride;
|
||||
@@ -398,9 +375,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(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
pendingOverride,
|
||||
field.path,
|
||||
undefined,
|
||||
) as AgentOverride;
|
||||
|
||||
@@ -213,6 +213,12 @@ 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 });
|
||||
|
||||
@@ -220,6 +226,7 @@ describe('<AppHeader />', () => {
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -1,58 +1,113 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { Header } from './Header.js';
|
||||
import { Tips } from './Tips.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { UserIdentity } from './UserIdentity.js';
|
||||
import { Tips } from './Tips.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 { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
|
||||
const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState();
|
||||
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
const { showTips } = useTips();
|
||||
|
||||
const showHeader = !(
|
||||
settings.merged.ui.hideBanner || config.getScreenReader()
|
||||
);
|
||||
|
||||
if (!showDetails) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Header version={version} nightly={false} />
|
||||
{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>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{!(settings.merged.ui.hideBanner || config.getScreenReader()) && (
|
||||
<>
|
||||
<Header version={version} nightly={nightly} />
|
||||
{bannerVisible && bannerText && (
|
||||
<Banner
|
||||
width={terminalWidth}
|
||||
bannerText={bannerText}
|
||||
isWarning={bannerData.warningText !== ''}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{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.showUserIdentity !== false && (
|
||||
<UserIdentity config={config} />
|
||||
|
||||
{bannerVisible && bannerText && (
|
||||
<Banner
|
||||
width={terminalWidth}
|
||||
bannerText={bannerText}
|
||||
isWarning={bannerData.warningText !== ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
|
||||
showTips && <Tips config={config} />}
|
||||
</Box>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
@@ -17,18 +17,9 @@ 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 left', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
it('renders correct percentage used', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={5000}
|
||||
model="gemini-pro"
|
||||
@@ -37,27 +28,56 @@ describe('ContextUsageDisplay', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('50% context left');
|
||||
expect(output).toContain('50% context used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders short label when terminal width is small', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
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(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={2000}
|
||||
model="gemini-pro"
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('80%');
|
||||
expect(output).not.toContain('context left');
|
||||
expect(output).toContain('20%');
|
||||
expect(output).not.toContain('context used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders 0% when full', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
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(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={10000}
|
||||
model="gemini-pro"
|
||||
@@ -66,7 +86,7 @@ describe('ContextUsageDisplay', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('0% context left');
|
||||
expect(output).toContain('100% context used');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
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,
|
||||
@@ -14,17 +19,30 @@ export const ContextUsageDisplay = ({
|
||||
terminalWidth,
|
||||
}: {
|
||||
promptTokenCount: number;
|
||||
model: string;
|
||||
model: string | undefined;
|
||||
terminalWidth: number;
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const percentage = getContextUsagePercentage(promptTokenCount, model);
|
||||
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
|
||||
const percentageUsed = (percentage * 100).toFixed(0);
|
||||
|
||||
const label = terminalWidth < 100 ? '%' : '% context left';
|
||||
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';
|
||||
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
{percentageLeft}
|
||||
<Text color={textColor}>
|
||||
{percentageUsed}
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('DetailedMessagesDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides the F12 hint in low error verbosity mode', async () => {
|
||||
it('shows the F12 hint even 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()).not.toContain('(F12 to close)');
|
||||
expect(lastFrame()).toContain('(F12 to close)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface DetailedMessagesDisplayProps {
|
||||
messages: ConsoleMessageItem[];
|
||||
@@ -29,10 +27,6 @@ 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;
|
||||
|
||||
@@ -71,10 +65,7 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Debug Console{' '}
|
||||
{showHotkeyHint && (
|
||||
<Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
)}
|
||||
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={maxHeight} width={width - borderAndPadding}>
|
||||
|
||||
@@ -37,9 +37,6 @@ 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 {
|
||||
@@ -62,56 +59,8 @@ 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 />;
|
||||
}
|
||||
@@ -281,14 +230,12 @@ 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,7 +11,6 @@ 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,
|
||||
@@ -41,10 +40,6 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
realpathSync: vi.fn((p) => p),
|
||||
promises: {
|
||||
...actual.promises,
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -546,7 +541,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
expect(onFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens plan in external editor when Ctrl+X is pressed', async () => {
|
||||
it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
@@ -557,27 +552,16 @@ 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(openFileInEditor).toHaveBeenCalledWith(
|
||||
mockPlanFullPath,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
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.',
|
||||
);
|
||||
});
|
||||
|
||||
// Verify that content is refreshed (processSingleFileContent called again)
|
||||
await waitFor(() => {
|
||||
expect(processSingleFileContent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -156,11 +156,15 @@ 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]);
|
||||
}, [planPath, stdin, setRawMode, getPreferredEditor, refresh, onFeedback]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
|
||||
@@ -15,6 +15,19 @@ 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')>();
|
||||
@@ -161,7 +174,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).toMatch(/\d+% context used/);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -216,7 +229,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('Usage remaining');
|
||||
expect(lastFrame()).not.toContain('used');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -249,7 +262,7 @@ describe('<Footer />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('displays the model name and abbreviated context percentage', async () => {
|
||||
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
@@ -267,6 +280,7 @@ describe('<Footer />', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+%/);
|
||||
expect(lastFrame()).not.toContain('context used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -464,7 +478,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).not.toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).not.toMatch(/\d+% context used/);
|
||||
unmount();
|
||||
});
|
||||
it('shows the context percentage when hideContextPercentage is false', async () => {
|
||||
@@ -484,7 +498,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).toMatch(/\d+% context used/);
|
||||
unmount();
|
||||
});
|
||||
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
|
||||
@@ -509,7 +523,15 @@ describe('<Footer />', () => {
|
||||
});
|
||||
|
||||
describe('error summary visibility', () => {
|
||||
it('hides error summary in low verbosity mode', async () => {
|
||||
beforeEach(() => {
|
||||
mockIsDevelopment = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockIsDevelopment = false;
|
||||
});
|
||||
|
||||
it('hides error summary in low verbosity mode out of dev mode', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
@@ -530,6 +552,28 @@ 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,7 +62,9 @@ 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);
|
||||
!showErrorDetails &&
|
||||
errorCount > 0 &&
|
||||
(isFullErrorVerbosity || debugMode || isDevelopment);
|
||||
const hideCWD = settings.merged.ui.footer.hideCWD;
|
||||
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
|
||||
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
|
||||
|
||||
@@ -32,7 +32,6 @@ 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';
|
||||
@@ -100,6 +99,7 @@ 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,9 +217,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'chat_list' && (
|
||||
<ChatList chats={itemForDisplay.chats} />
|
||||
)}
|
||||
{itemForDisplay.type === 'hooks_list' && (
|
||||
<HooksList hooks={itemForDisplay.hooks} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +34,7 @@ export const MainContent = () => {
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showConfirmationQueue = confirmingTool !== null;
|
||||
const confirmingToolCallId = confirmingTool?.tool.callId;
|
||||
|
||||
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
|
||||
|
||||
@@ -41,7 +42,7 @@ export const MainContent = () => {
|
||||
if (showConfirmationQueue) {
|
||||
scrollableListRef.current?.scrollToEnd();
|
||||
}
|
||||
}, [showConfirmationQueue, confirmingTool]);
|
||||
}, [showConfirmationQueue, confirmingToolCallId]);
|
||||
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
};
|
||||
@@ -14,7 +14,6 @@
|
||||
* - 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
|
||||
@@ -25,12 +24,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 { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { 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 { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js';
|
||||
import { TEST_ONLY } from '../../utils/settingsUtils.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import {
|
||||
getSettingsSchema,
|
||||
type SettingDefinition,
|
||||
@@ -38,10 +37,6 @@ 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
|
||||
@@ -68,27 +63,6 @@ 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',
|
||||
@@ -131,6 +105,62 @@ 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',
|
||||
@@ -185,7 +215,7 @@ const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = {
|
||||
|
||||
// Helper function to render SettingsDialog with standard wrapper
|
||||
const renderDialog = (
|
||||
settings: LoadedSettings,
|
||||
settings: ReturnType<typeof createMockSettings>,
|
||||
onSelect: ReturnType<typeof vi.fn>,
|
||||
options?: {
|
||||
onRestartRequest?: ReturnType<typeof vi.fn>;
|
||||
@@ -193,14 +223,15 @@ const renderDialog = (
|
||||
},
|
||||
) =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={onSelect}
|
||||
onRestartRequest={options?.onRestartRequest}
|
||||
availableTerminalHeight={options?.availableTerminalHeight}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog
|
||||
onSelect={onSelect}
|
||||
onRestartRequest={options?.onRestartRequest}
|
||||
availableTerminalHeight={options?.availableTerminalHeight}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
describe('SettingsDialog', () => {
|
||||
@@ -210,7 +241,6 @@ describe('SettingsDialog', () => {
|
||||
terminalCapabilityManager,
|
||||
'isKittyProtocolEnabled',
|
||||
).mockReturnValue(true);
|
||||
mockToggleVimEnabled.mockRejectedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -394,9 +424,8 @@ 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(
|
||||
@@ -414,29 +443,16 @@ describe('SettingsDialog', () => {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Wait for the setting change to be processed
|
||||
// Wait for setValue to be called
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
vi.mocked(saveModifiedSettings).mock.calls.length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(setValueSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 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),
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'general.vimMode',
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -455,13 +471,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();
|
||||
|
||||
@@ -482,20 +498,13 @@ describe('SettingsDialog', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui.theme',
|
||||
expectedValue,
|
||||
);
|
||||
});
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['ui.theme']),
|
||||
expect.objectContaining({
|
||||
ui: expect.objectContaining({
|
||||
theme: expectedValue,
|
||||
}),
|
||||
}),
|
||||
expect.any(LoadedSettings),
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -692,30 +701,6 @@ 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();
|
||||
@@ -767,31 +752,6 @@ 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({
|
||||
@@ -861,7 +821,7 @@ describe('SettingsDialog', () => {
|
||||
// Should not show restart prompt initially
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'Changes that require a restart have been modified',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -957,63 +917,41 @@ describe('SettingsDialog', () => {
|
||||
pager: 'less',
|
||||
},
|
||||
},
|
||||
])(
|
||||
'should $name',
|
||||
async ({ toggleCount, shellSettings, expectedSiblings }) => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
])('should $name', async ({ toggleCount, shellSettings }) => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA);
|
||||
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA);
|
||||
const settings = createMockSettings({
|
||||
tools: {
|
||||
shell: shellSettings,
|
||||
},
|
||||
});
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
|
||||
const settings = createMockSettings({
|
||||
tools: {
|
||||
shell: shellSettings,
|
||||
},
|
||||
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 onSelect = vi.fn();
|
||||
await waitFor(() => {
|
||||
expect(setValueSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const { stdin, unmount, waitUntilReady } = renderDialog(
|
||||
settings,
|
||||
onSelect,
|
||||
);
|
||||
await waitUntilReady();
|
||||
// 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');
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Shortcuts Edge Cases', () => {
|
||||
@@ -1319,7 +1257,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'Changes that require a restart have been modified',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1366,7 +1304,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'Changes that require a restart have been modified',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1385,9 +1323,11 @@ describe('SettingsDialog', () => {
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount, rerender, waitUntilReady } = render(
|
||||
<KeypressProvider>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -1424,14 +1364,13 @@ describe('SettingsDialog', () => {
|
||||
path: '',
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
rerender(
|
||||
rerender(
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
<SettingsDialog onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
// Press Escape to exit
|
||||
await act(async () => {
|
||||
@@ -1447,6 +1386,74 @@ 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,40 +5,35 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } 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,
|
||||
LoadedSettings,
|
||||
Settings,
|
||||
} from '../../config/settings.js';
|
||||
import type { LoadableSettingScope, Settings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||
import {
|
||||
getDialogSettingKeys,
|
||||
setPendingSettingValue,
|
||||
getDisplayValue,
|
||||
hasRestartRequiredSettings,
|
||||
saveModifiedSettings,
|
||||
getSettingDefinition,
|
||||
isDefaultValue,
|
||||
requiresRestart,
|
||||
getRestartRequiredFromModified,
|
||||
getEffectiveDefaultValue,
|
||||
setPendingSettingValueAny,
|
||||
getDialogRestartRequiredSettings,
|
||||
getEffectiveValue,
|
||||
isInSettingsScope,
|
||||
getEditValue,
|
||||
parseEditedValue,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import {
|
||||
useSettingsStore,
|
||||
type SettingsState,
|
||||
} from '../contexts/SettingsContext.js';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import {
|
||||
type SettingsType,
|
||||
type SettingsValue,
|
||||
TOGGLE_TYPES,
|
||||
} from '../../config/settingsSchema.js';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
|
||||
import {
|
||||
@@ -55,31 +50,56 @@ 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 {
|
||||
// Get vim mode context to sync vim mode changes
|
||||
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
||||
// Reactive settings from store (re-renders on any settings change)
|
||||
const { settings, setSetting } = useSettingsStore();
|
||||
|
||||
// Scope selector state (User by default)
|
||||
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||
// Snapshot restart-required values at mount time for diff tracking
|
||||
const [activeRestartRequiredSettings] = useState(() =>
|
||||
getActiveRestartRequiredSettings(settings),
|
||||
);
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -136,52 +156,34 @@ export function SettingsDialog({
|
||||
};
|
||||
}, [searchQuery, fzfInstance, searchMap]);
|
||||
|
||||
// Local pending settings state for the selected scope
|
||||
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
||||
// Deep clone to avoid mutation
|
||||
structuredClone(settings.forScope(selectedScope).settings),
|
||||
);
|
||||
// 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],
|
||||
];
|
||||
|
||||
// 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);
|
||||
// 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
|
||||
}
|
||||
}
|
||||
newModified.add(key);
|
||||
if (requiresRestart(key)) newRestartRequired.add(key);
|
||||
}
|
||||
setPendingSettings(updated);
|
||||
setModifiedSettings(newModified);
|
||||
setRestartRequiredSettings(newRestartRequired);
|
||||
setShowRestartPrompt(newRestartRequired.size > 0);
|
||||
}, [selectedScope, settings, globalPendingChanges]);
|
||||
return changed;
|
||||
}, [settings, activeRestartRequiredSettings]);
|
||||
|
||||
const showRestartPrompt = pendingRestartRequiredSettings.size > 0;
|
||||
|
||||
// Calculate max width for the left column (Label/Description) to keep values aligned or close
|
||||
const maxLabelOrDescriptionWidth = useMemo(() => {
|
||||
@@ -222,16 +224,10 @@ export function SettingsDialog({
|
||||
|
||||
return settingKeys.map((key) => {
|
||||
const definition = getSettingDefinition(key);
|
||||
const type = definition?.type ?? 'string';
|
||||
const type: SettingsType = definition?.type ?? 'string';
|
||||
|
||||
// Get the display value (with * indicator if modified)
|
||||
const displayValue = getDisplayValue(
|
||||
key,
|
||||
scopeSettings,
|
||||
mergedSettings,
|
||||
modifiedSettings,
|
||||
pendingSettings,
|
||||
);
|
||||
const displayValue = getDisplayValue(key, scopeSettings, mergedSettings);
|
||||
|
||||
// Get the scope message (e.g., "(Modified in Workspace)")
|
||||
const scopeMessage = getScopeMessageForSetting(
|
||||
@@ -240,28 +236,28 @@ export function SettingsDialog({
|
||||
settings,
|
||||
);
|
||||
|
||||
// Check if the value is at default (grey it out)
|
||||
const isGreyedOut = isDefaultValue(key, scopeSettings);
|
||||
// Grey out values that defer to defaults
|
||||
const isGreyedOut = !isInSettingsScope(key, scopeSettings);
|
||||
|
||||
// Get raw value for edit mode initialization
|
||||
const rawValue = getEffectiveValue(key, pendingSettings, {});
|
||||
// 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);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: definition?.label || key,
|
||||
description: definition?.description,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
type: type as 'boolean' | 'number' | 'string' | 'enum',
|
||||
type,
|
||||
displayValue,
|
||||
isGreyedOut,
|
||||
scopeMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
rawValue: rawValue as string | number | boolean | undefined,
|
||||
rawValue,
|
||||
editValue,
|
||||
};
|
||||
});
|
||||
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
|
||||
}, [settingKeys, selectedScope, settings]);
|
||||
|
||||
// Scope selection handler
|
||||
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
}, []);
|
||||
@@ -273,17 +269,21 @@ export function SettingsDialog({
|
||||
if (!TOGGLE_TYPES.has(definition?.type)) {
|
||||
return;
|
||||
}
|
||||
const currentValue = getEffectiveValue(key, pendingSettings, {});
|
||||
|
||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||
const currentValue = getEffectiveValue(key, scopeSettings);
|
||||
let newValue: SettingsValue;
|
||||
|
||||
if (definition?.type === 'boolean') {
|
||||
// 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),
|
||||
);
|
||||
if (typeof currentValue !== 'boolean') {
|
||||
return;
|
||||
}
|
||||
newValue = !currentValue;
|
||||
} 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,303 +292,58 @@ export function SettingsDialog({
|
||||
} else {
|
||||
newValue = options[0].value; // loop back to start.
|
||||
}
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValueAny(key, newValue, prev),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
},
|
||||
[pendingSettings, settings, selectedScope, vimEnabled, toggleVimEnabled],
|
||||
);
|
||||
|
||||
// Edit commit handler
|
||||
const handleEditCommit = useCallback(
|
||||
(key: string, newValue: string, _item: SettingsDialogItem) => {
|
||||
const definition = getSettingDefinition(key);
|
||||
const type = definition?.type;
|
||||
|
||||
if (newValue.trim() === '' && type === 'number') {
|
||||
// Nothing entered for a number; cancel edit
|
||||
return;
|
||||
}
|
||||
|
||||
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),
|
||||
debugLogger.log(
|
||||
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
|
||||
newValue,
|
||||
);
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
setSetting(selectedScope, key, newValue);
|
||||
},
|
||||
[settings, selectedScope],
|
||||
[settings, selectedScope, setSetting],
|
||||
);
|
||||
|
||||
// For inline editor
|
||||
const handleEditCommit = useCallback(
|
||||
(key: string, newValue: string, _item: SettingsDialogItem) => {
|
||||
const definition = getSettingDefinition(key);
|
||||
const type: SettingsType = definition?.type ?? 'string';
|
||||
const parsed = parseEditedValue(type, newValue);
|
||||
|
||||
if (parsed === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSetting(selectedScope, key, parsed);
|
||||
},
|
||||
[selectedScope, setSetting],
|
||||
);
|
||||
|
||||
// Clear/reset handler - removes the value from settings.json so it falls back to default
|
||||
const handleItemClear = useCallback(
|
||||
(key: string, _item: SettingsDialogItem) => {
|
||||
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;
|
||||
});
|
||||
setSetting(selectedScope, key, undefined);
|
||||
},
|
||||
[
|
||||
config,
|
||||
settings,
|
||||
selectedScope,
|
||||
vimEnabled,
|
||||
toggleVimEnabled,
|
||||
modifiedSettings,
|
||||
],
|
||||
[selectedScope, setSetting],
|
||||
);
|
||||
|
||||
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);
|
||||
}, [saveRestartRequiredSettings, onSelect, selectedScope]);
|
||||
}, [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, saveRestartRequiredSettings],
|
||||
[showRestartPrompt, onRestartRequest],
|
||||
);
|
||||
|
||||
// Calculate effective max items and scope visibility based on terminal height
|
||||
@@ -673,11 +428,10 @@ export function SettingsDialog({
|
||||
showRestartPrompt,
|
||||
]);
|
||||
|
||||
// Footer content for restart prompt
|
||||
const footerContent = showRestartPrompt ? (
|
||||
<Text color={theme.status.warning}>
|
||||
To see changes, Gemini CLI must be restarted. Press r to exit and apply
|
||||
changes now.
|
||||
Changes that require a restart have been modified. Press r to exit and
|
||||
apply changes now.
|
||||
</Text>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -89,11 +89,12 @@ const renderStatusDisplay = async (
|
||||
};
|
||||
|
||||
describe('StatusDisplay', () => {
|
||||
const originalEnv = process.env;
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('GEMINI_SYSTEM_MD', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env['GEMINI_SYSTEM_MD'];
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -112,7 +113,7 @@ describe('StatusDisplay', () => {
|
||||
});
|
||||
|
||||
it('renders system md indicator if env var is set', async () => {
|
||||
process.env['GEMINI_SYSTEM_MD'] = 'true';
|
||||
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
|
||||
const { lastFrame, unmount } = await renderStatusDisplay();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -11,22 +11,18 @@ import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Tips', () => {
|
||||
it.each([
|
||||
[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;
|
||||
{ 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;
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<Tips config={config} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(expectedText);
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<Tips config={config} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -15,30 +15,26 @@ interface TipsProps {
|
||||
|
||||
export const Tips: React.FC<TipsProps> = ({ config }) => {
|
||||
const geminiMdFileCount = config.getGeminiMdFileCount();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<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}>
|
||||
3. Create{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
GEMINI.md
|
||||
</Text>{' '}
|
||||
files to customize your interactions with Gemini.
|
||||
1. Create <Text bold>GEMINI.md</Text> files to customize your
|
||||
interactions
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}>
|
||||
{geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
/help
|
||||
</Text>{' '}
|
||||
for more information.
|
||||
{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
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -45,12 +45,12 @@ describe('<UserIdentity />', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google: test@example.com');
|
||||
expect(output).toContain('test@example.com');
|
||||
expect(output).toContain('/auth');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render login message without colon if email is missing', async () => {
|
||||
it('should render login message if email is missing', async () => {
|
||||
// Modify the mock for this specific test
|
||||
vi.mocked(UserAccountManager).mockImplementationOnce(
|
||||
() =>
|
||||
@@ -73,12 +73,11 @@ describe('<UserIdentity />', () => {
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google');
|
||||
expect(output).not.toContain('Logged in with Google:');
|
||||
expect(output).toContain('/auth');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render plan name on a separate line if provided', async () => {
|
||||
it('should render plan name and upgrade indicator', async () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
@@ -92,18 +91,10 @@ describe('<UserIdentity />', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google: test@example.com');
|
||||
expect(output).toContain('test@example.com');
|
||||
expect(output).toContain('/auth');
|
||||
expect(output).toContain('Plan: Premium Plan');
|
||||
|
||||
// Check for two lines (or more if wrapped, but here it should be separate)
|
||||
const lines = output?.split('\n').filter((line) => line.trim().length > 0);
|
||||
expect(lines?.some((line) => line.includes('Logged in with Google'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(lines?.some((line) => line.includes('Plan: Premium Plan'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(output).toContain('Premium Plan');
|
||||
expect(output).toContain('/upgrade');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
@@ -20,42 +20,45 @@ interface UserIdentityProps {
|
||||
|
||||
export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const [email, setEmail] = useState<string | undefined>();
|
||||
|
||||
const { email, tierName } = useMemo(() => {
|
||||
if (!authType) {
|
||||
return { email: undefined, tierName: undefined };
|
||||
useEffect(() => {
|
||||
if (authType) {
|
||||
const userAccountManager = new UserAccountManager();
|
||||
setEmail(userAccountManager.getCachedGoogleAccount() ?? undefined);
|
||||
}
|
||||
const userAccountManager = new UserAccountManager();
|
||||
return {
|
||||
email: userAccountManager.getCachedGoogleAccount(),
|
||||
tierName: config.getUserTierName(),
|
||||
};
|
||||
}, [config, authType]);
|
||||
}, [authType]);
|
||||
|
||||
const tierName = useMemo(
|
||||
() => (authType ? config.getUserTierName() : undefined),
|
||||
[config, authType],
|
||||
);
|
||||
|
||||
if (!authType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
{/* User Email /auth */}
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
{authType === AuthType.LOGIN_WITH_GOOGLE ? (
|
||||
<Text>
|
||||
<Text bold>Logged in with Google{email ? ':' : ''}</Text>
|
||||
{email ? ` ${email}` : ''}
|
||||
</Text>
|
||||
<Text>{email ?? 'Logged in with Google'}</Text>
|
||||
) : (
|
||||
`Authenticated with ${authType}`
|
||||
)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> /auth</Text>
|
||||
</Box>
|
||||
{tierName && (
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold>Plan:</Text> {tierName}
|
||||
|
||||
{/* Tier Name /upgrade */}
|
||||
<Box>
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
{tierName ?? 'Gemini Code Assist for individuals'}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.secondary}> /upgrade</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
+54
-72
@@ -2,20 +2,17 @@
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
|
||||
Action Required (was prompted):
|
||||
|
||||
@@ -25,20 +22,17 @@ Action Required (was prompted):
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
@@ -52,39 +46,33 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
@@ -98,39 +86,33 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Hello Gemini
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
|
||||
@@ -2,82 +2,70 @@
|
||||
|
||||
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with default text 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ This is the default banner │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ There are capacity issues │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
Tips for getting started:
|
||||
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.
|
||||
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
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -11,12 +11,12 @@ exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
|
||||
" ...s/to/make/it/long no sandbox /model gemini-pro 100%
|
||||
" ...s/to/make/it/long no sandbox /model gemini-pro 0%
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
|
||||
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left
|
||||
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 0% context used
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`HooksDialog > snapshots > renders empty hooks dialog 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ No hooks configured. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`HooksDialog > snapshots > renders hook using command as name when name is not provided 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Security Warning: │
|
||||
│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
|
||||
│ Review hook scripts carefully. │
|
||||
│ │
|
||||
│ Learn more: https://geminicli.com/docs/hooks │
|
||||
│ │
|
||||
│ Configured Hooks │
|
||||
│ │
|
||||
│ before-tool │
|
||||
│ │
|
||||
│ echo hello [enabled] │
|
||||
│ Source: /mock/path │
|
||||
│ │
|
||||
│ │
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`HooksDialog > snapshots > renders hook with all metadata (matcher, sequential, timeout) 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Security Warning: │
|
||||
│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
|
||||
│ Review hook scripts carefully. │
|
||||
│ │
|
||||
│ Learn more: https://geminicli.com/docs/hooks │
|
||||
│ │
|
||||
│ Configured Hooks │
|
||||
│ │
|
||||
│ before-tool │
|
||||
│ │
|
||||
│ my-hook [enabled] │
|
||||
│ A hook with all metadata fields │
|
||||
│ Source: /mock/path/GEMINI.md | Matcher: shell_exec | Sequential | Timeout: 30s │
|
||||
│ │
|
||||
│ │
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`HooksDialog > snapshots > renders hooks grouped by event name with enabled and disabled status 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Security Warning: │
|
||||
│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
|
||||
│ Review hook scripts carefully. │
|
||||
│ │
|
||||
│ Learn more: https://geminicli.com/docs/hooks │
|
||||
│ │
|
||||
│ Configured Hooks │
|
||||
│ │
|
||||
│ before-tool │
|
||||
│ │
|
||||
│ hook1 [enabled] │
|
||||
│ Test hook: hook1 │
|
||||
│ Source: /mock/path/GEMINI.md | Command: run-hook1 │
|
||||
│ │
|
||||
│ hook2 [disabled] │
|
||||
│ Test hook: hook2 │
|
||||
│ Source: /mock/path/GEMINI.md | Command: run-hook2 │
|
||||
│ │
|
||||
│ after-agent │
|
||||
│ │
|
||||
│ hook3 [enabled] │
|
||||
│ Test hook: hook3 │
|
||||
│ Source: /mock/path/GEMINI.md | Command: run-hook3 │
|
||||
│ │
|
||||
│ │
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`HooksDialog > snapshots > renders single hook with security warning, source, and tips 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Security Warning: │
|
||||
│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
|
||||
│ Review hook scripts carefully. │
|
||||
│ │
|
||||
│ Learn more: https://geminicli.com/docs/hooks │
|
||||
│ │
|
||||
│ Configured Hooks │
|
||||
│ │
|
||||
│ before-tool │
|
||||
│ │
|
||||
│ test-hook [enabled] │
|
||||
│ Test hook: test-hook │
|
||||
│ Source: /mock/path/GEMINI.md | Command: run-test-hook │
|
||||
│ │
|
||||
│ │
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SessionRetentionWarningDialog > should match snapshot 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Keep chat history │
|
||||
│ │
|
||||
│ 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: │
|
||||
│ │
|
||||
│ │
|
||||
│ 1. Keep for 30 days (Recommended) │
|
||||
│ 123 sessions will be deleted │
|
||||
│ ● 2. Keep for 120 days │
|
||||
│ No sessions will be deleted at this time │
|
||||
│ │
|
||||
│ Set a custom limit /settings and change "Keep chat history". │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Tips > 'renders all tips including GEMINI.md …' 1`] = `
|
||||
"
|
||||
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
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Tips > 'renders fewer tips when GEMINI.md exi…' 1`] = `
|
||||
"
|
||||
Tips for getting started:
|
||||
1. /help for more information
|
||||
2. Ask coding questions, edit code or run commands
|
||||
3. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
@@ -11,6 +11,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
|
||||
interface InfoMessageProps {
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
marginBottom?: number;
|
||||
@@ -18,6 +19,7 @@ interface InfoMessageProps {
|
||||
|
||||
export const InfoMessage: React.FC<InfoMessageProps> = ({
|
||||
text,
|
||||
secondaryText,
|
||||
icon,
|
||||
color,
|
||||
marginBottom,
|
||||
@@ -35,6 +37,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
|
||||
{text.split('\n').map((line, index) => (
|
||||
<Text wrap="wrap" key={index}>
|
||||
<RenderInline text={line} defaultColor={color} />
|
||||
{index === text.split('\n').length - 1 && secondaryText && (
|
||||
<Text color={theme.text.secondary}> {secondaryText}</Text>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render, cleanup } from '../../../test-utils/render.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
import type { SubagentProgress } from '@google/gemini-cli-core';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
|
||||
vi.mock('ink-spinner', () => ({
|
||||
default: () => <Text>⠋</Text>,
|
||||
}));
|
||||
|
||||
describe('<SubagentProgressDisplay />', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders correctly with description in args', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: '{"command": "echo hello", "description": "Say hello"}',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly with displayName and description from item', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
displayName: 'RunShellCommand',
|
||||
description: 'Executing echo hello',
|
||||
args: '{"command": "echo hello"}',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly with command fallback', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '2',
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: '{"command": "echo hello"}',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly with file_path', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '3',
|
||||
type: 'tool_call',
|
||||
content: 'write_file',
|
||||
args: '{"file_path": "/tmp/test.txt", "content": "foo"}',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('truncates long args', async () => {
|
||||
const longDesc =
|
||||
'This is a very long description that should definitely be truncated because it exceeds the limit of sixty characters.';
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '4',
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: JSON.stringify({ description: longDesc }),
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders thought bubbles correctly', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '5',
|
||||
type: 'thought',
|
||||
content: 'Thinking about life',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders cancelled state correctly', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [],
|
||||
state: 'cancelled',
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders "Request cancelled." with the info icon', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '6',
|
||||
type: 'thought',
|
||||
content: 'Request cancelled.',
|
||||
status: 'error',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import Spinner from 'ink-spinner';
|
||||
import type {
|
||||
SubagentProgress,
|
||||
SubagentActivityItem,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { TOOL_STATUS } from '../../constants.js';
|
||||
import { STATUS_INDICATOR_WIDTH } from './ToolShared.js';
|
||||
|
||||
export interface SubagentProgressDisplayProps {
|
||||
progress: SubagentProgress;
|
||||
}
|
||||
|
||||
const formatToolArgs = (args?: string): string => {
|
||||
if (!args) return '';
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(args);
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return args;
|
||||
}
|
||||
|
||||
if (
|
||||
'description' in parsed &&
|
||||
typeof parsed.description === 'string' &&
|
||||
parsed.description
|
||||
) {
|
||||
return parsed.description;
|
||||
}
|
||||
if ('command' in parsed && typeof parsed.command === 'string')
|
||||
return parsed.command;
|
||||
if ('file_path' in parsed && typeof parsed.file_path === 'string')
|
||||
return parsed.file_path;
|
||||
if ('dir_path' in parsed && typeof parsed.dir_path === 'string')
|
||||
return parsed.dir_path;
|
||||
if ('query' in parsed && typeof parsed.query === 'string')
|
||||
return parsed.query;
|
||||
if ('url' in parsed && typeof parsed.url === 'string') return parsed.url;
|
||||
if ('target' in parsed && typeof parsed.target === 'string')
|
||||
return parsed.target;
|
||||
|
||||
return args;
|
||||
} catch {
|
||||
return args;
|
||||
}
|
||||
};
|
||||
|
||||
export const SubagentProgressDisplay: React.FC<
|
||||
SubagentProgressDisplayProps
|
||||
> = ({ progress }) => {
|
||||
let headerText: string | undefined;
|
||||
let headerColor = theme.text.secondary;
|
||||
|
||||
if (progress.state === 'cancelled') {
|
||||
headerText = `Subagent ${progress.agentName} was cancelled.`;
|
||||
headerColor = theme.status.warning;
|
||||
} else if (progress.state === 'error') {
|
||||
headerText = `Subagent ${progress.agentName} failed.`;
|
||||
headerColor = theme.status.error;
|
||||
} else if (progress.state === 'completed') {
|
||||
headerText = `Subagent ${progress.agentName} completed.`;
|
||||
headerColor = theme.status.success;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={0}>
|
||||
{headerText && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={headerColor} italic>
|
||||
{headerText}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="column" marginLeft={0} gap={0}>
|
||||
{progress.recentActivity.map((item: SubagentActivityItem) => {
|
||||
if (item.type === 'thought') {
|
||||
const isCancellation = item.content === 'Request cancelled.';
|
||||
const icon = isCancellation ? 'ℹ ' : '💭';
|
||||
const color = isCancellation
|
||||
? theme.status.warning
|
||||
: theme.text.secondary;
|
||||
|
||||
return (
|
||||
<Box key={item.id} flexDirection="row">
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>
|
||||
<Text color={color}>{icon}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={color}>{item.content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else if (item.type === 'tool_call') {
|
||||
const statusSymbol =
|
||||
item.status === 'running' ? (
|
||||
<Spinner type="dots" />
|
||||
) : item.status === 'completed' ? (
|
||||
<Text color={theme.status.success}>{TOOL_STATUS.SUCCESS}</Text>
|
||||
) : item.status === 'cancelled' ? (
|
||||
<Text color={theme.status.warning} bold>
|
||||
{TOOL_STATUS.CANCELED}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>{TOOL_STATUS.ERROR}</Text>
|
||||
);
|
||||
|
||||
const formattedArgs = item.description || formatToolArgs(item.args);
|
||||
const displayArgs =
|
||||
formattedArgs.length > 60
|
||||
? formattedArgs.slice(0, 60) + '...'
|
||||
: formattedArgs;
|
||||
|
||||
return (
|
||||
<Box key={item.id} flexDirection="row">
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusSymbol}</Box>
|
||||
<Box flexDirection="row" flexGrow={1} flexWrap="wrap">
|
||||
<Text
|
||||
bold
|
||||
color={theme.text.primary}
|
||||
strikethrough={item.status === 'cancelled'}
|
||||
>
|
||||
{item.displayName || item.content}
|
||||
</Text>
|
||||
{displayArgs && (
|
||||
<Box marginLeft={1}>
|
||||
<Text
|
||||
color={theme.text.secondary}
|
||||
wrap="truncate"
|
||||
strikethrough={item.status === 'cancelled'}
|
||||
>
|
||||
{displayArgs}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -75,6 +75,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
status: t.status,
|
||||
approvalMode: t.approvalMode,
|
||||
hasResultDisplay: !!t.resultDisplay,
|
||||
parentCallId: t.parentCallId,
|
||||
});
|
||||
}),
|
||||
[allToolCalls, isLowErrorVerbosity],
|
||||
|
||||
@@ -11,7 +11,11 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
isSubagentProgress,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
@@ -20,6 +24,7 @@ import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
|
||||
// Large threshold to ensure we don't cause performance issues for very large
|
||||
// outputs that will get truncated further MaxSizedBox anyway.
|
||||
@@ -167,6 +172,8 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
{formattedJSON}
|
||||
</Text>
|
||||
);
|
||||
} else if (isSubagentProgress(truncatedResultDisplay)) {
|
||||
content = <SubagentProgressDisplay progress={truncatedResultDisplay} />;
|
||||
} else if (
|
||||
typeof truncatedResultDisplay === 'string' &&
|
||||
renderOutputAsMarkdown
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders "Request cancelled." with the info icon 1`] = `
|
||||
"ℹ Request cancelled.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders cancelled state correctly 1`] = `
|
||||
"Subagent TestAgent was cancelled.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with command fallback 1`] = `
|
||||
"⠋ run_shell_command echo hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with description in args 1`] = `
|
||||
"⠋ run_shell_command Say hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with displayName and description from item 1`] = `
|
||||
"⠋ RunShellCommand Executing echo hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with file_path 1`] = `
|
||||
"✓ write_file /tmp/test.txt
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders thought bubbles correctly 1`] = `
|
||||
"💭 Thinking about life
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > truncates long args 1`] = `
|
||||
"⠋ run_shell_command This is a very long description that should definitely be tr...
|
||||
"
|
||||
`;
|
||||
@@ -531,6 +531,37 @@ describe('BaseSettingsDialog', () => {
|
||||
});
|
||||
|
||||
describe('edit mode', () => {
|
||||
it('should prioritize editValue over rawValue stringification', async () => {
|
||||
const objectItem: SettingsDialogItem = {
|
||||
key: 'object-setting',
|
||||
label: 'Object Setting',
|
||||
description: 'A complex object setting',
|
||||
displayValue: '{"foo":"bar"}',
|
||||
type: 'object',
|
||||
rawValue: { foo: 'bar' },
|
||||
editValue: '{"foo":"bar"}',
|
||||
};
|
||||
const { stdin } = await renderDialog({
|
||||
items: [objectItem],
|
||||
});
|
||||
|
||||
// Enter edit mode and immediately commit
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||||
'object-setting',
|
||||
'{"foo":"bar"}',
|
||||
expect.objectContaining({ type: 'object' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit edit on Enter', async () => {
|
||||
const items = createMockItems(4);
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
|
||||
@@ -9,6 +9,10 @@ import { Box, Text } from 'ink';
|
||||
import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { LoadableSettingScope } from '../../../config/settings.js';
|
||||
import type {
|
||||
SettingsType,
|
||||
SettingsValue,
|
||||
} from '../../../config/settingsSchema.js';
|
||||
import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
|
||||
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
||||
import { TextInput } from './TextInput.js';
|
||||
@@ -33,7 +37,7 @@ export interface SettingsDialogItem {
|
||||
/** Optional description below label */
|
||||
description?: string;
|
||||
/** Item type for determining interaction behavior */
|
||||
type: 'boolean' | 'number' | 'string' | 'enum';
|
||||
type: SettingsType;
|
||||
/** Pre-formatted display value (with * if modified) */
|
||||
displayValue: string;
|
||||
/** Grey out value (at default) */
|
||||
@@ -41,7 +45,9 @@ export interface SettingsDialogItem {
|
||||
/** Scope message e.g., "(Modified in Workspace)" */
|
||||
scopeMessage?: string;
|
||||
/** Raw value for edit mode initialization */
|
||||
rawValue?: string | number | boolean;
|
||||
rawValue?: SettingsValue;
|
||||
/** Optional pre-formatted edit buffer value for complex types */
|
||||
editValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,9 +387,11 @@ export function BaseSettingsDialog({
|
||||
if (currentItem.type === 'boolean' || currentItem.type === 'enum') {
|
||||
onItemToggle(currentItem.key, currentItem);
|
||||
} else {
|
||||
// Start editing for string/number
|
||||
// Start editing for string/number/array/object
|
||||
const rawVal = currentItem.rawValue;
|
||||
const initialValue = rawVal !== undefined ? String(rawVal) : '';
|
||||
const initialValue =
|
||||
currentItem.editValue ??
|
||||
(rawVal !== undefined ? String(rawVal) : '');
|
||||
startEditing(currentItem.key, initialValue);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
|
||||
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface ExtensionRegistryViewProps {
|
||||
export interface ExtensionRegistryViewProps {
|
||||
onSelect?: (extension: RegistryExtension) => void;
|
||||
onClose?: () => void;
|
||||
extensionManager: ExtensionManager;
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface HooksListProps {
|
||||
hooks: ReadonlyArray<{
|
||||
config: {
|
||||
command?: string;
|
||||
type: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
source: string;
|
||||
eventName: string;
|
||||
matcher?: string;
|
||||
sequential?: boolean;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const HooksList: React.FC<HooksListProps> = ({ hooks }) => {
|
||||
if (hooks.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>No hooks configured.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Group hooks by event name for better organization
|
||||
const hooksByEvent = hooks.reduce(
|
||||
(acc, hook) => {
|
||||
if (!acc[hook.eventName]) {
|
||||
acc[hook.eventName] = [];
|
||||
}
|
||||
acc[hook.eventName].push(hook);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Array<(typeof hooks)[number]>>,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.status.warning} bold underline>
|
||||
⚠️ Security Warning:
|
||||
</Text>
|
||||
<Text color={theme.status.warning}>
|
||||
Hooks can execute arbitrary commands on your system. Only use hooks
|
||||
from sources you trust. Review hook scripts carefully.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
Learn more:{' '}
|
||||
<Text color={theme.text.link}>https://geminicli.com/docs/hooks</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text bold>Configured Hooks:</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
|
||||
{Object.entries(hooksByEvent).map(([eventName, eventHooks]) => (
|
||||
<Box key={eventName} flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.accent} bold>
|
||||
{eventName}:
|
||||
</Text>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{eventHooks.map((hook, index) => {
|
||||
const hookName =
|
||||
hook.config.name || hook.config.command || 'unknown';
|
||||
const statusColor = hook.enabled
|
||||
? theme.status.success
|
||||
: theme.text.secondary;
|
||||
const statusText = hook.enabled ? 'enabled' : 'disabled';
|
||||
|
||||
return (
|
||||
<Box key={`${eventName}-${index}`} flexDirection="column">
|
||||
<Box>
|
||||
<Text>
|
||||
<Text color={theme.text.accent}>{hookName}</Text>
|
||||
<Text color={statusColor}>{` [${statusText}]`}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
{hook.config.description && (
|
||||
<Text italic>{hook.config.description}</Text>
|
||||
)}
|
||||
<Text dimColor>
|
||||
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>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -48,3 +48,9 @@ export const ACTIVE_SHELL_MAX_LINES = 15;
|
||||
|
||||
// Max lines to preserve in history for completed shell commands
|
||||
export const COMPLETED_SHELL_MAX_LINES = 15;
|
||||
|
||||
/** Minimum terminal width required to show the full context used label */
|
||||
export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
|
||||
|
||||
/** Default context usage fraction at which to trigger compression */
|
||||
export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
|
||||
|
||||
@@ -30,7 +30,7 @@ export const INFORMATIVE_TIPS = [
|
||||
'Choose a specific Gemini model for conversations (/settings)…',
|
||||
'Limit the number of turns in your session history (/settings)…',
|
||||
'Automatically summarize large tool outputs to save tokens (settings.json)…',
|
||||
'Control when chat history gets compressed based on token usage (settings.json)…',
|
||||
'Control when chat history gets compressed based on context compression threshold (settings.json)…',
|
||||
'Define custom context file names, like CONTEXT.md (settings.json)…',
|
||||
'Set max directories to scan for context files (/settings)…',
|
||||
'Expand your workspace with additional directories (/directory)…',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
SettingsFile,
|
||||
} from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { checkExhaustive } from '@google/gemini-cli-core';
|
||||
|
||||
export const SettingsContext = React.createContext<LoadedSettings | undefined>(
|
||||
undefined,
|
||||
@@ -66,7 +67,7 @@ export const useSettingsStore = (): SettingsStoreValue => {
|
||||
case SettingScope.SystemDefaults:
|
||||
return snapshot.systemDefaults;
|
||||
default:
|
||||
throw new Error(`Invalid scope: ${scope}`);
|
||||
checkExhaustive(scope);
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -107,8 +107,6 @@ export interface UIState {
|
||||
history: HistoryItem[];
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
isThemeDialogOpen: boolean;
|
||||
shouldShowRetentionWarning: boolean;
|
||||
sessionsToDeleteCount: number;
|
||||
themeError: string | null;
|
||||
isAuthenticating: boolean;
|
||||
isConfigInitialized: boolean;
|
||||
|
||||
@@ -4,15 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { useSettingsStore } from './SettingsContext.js';
|
||||
|
||||
export type VimMode = 'NORMAL' | 'INSERT';
|
||||
|
||||
@@ -27,35 +21,22 @@ const VimModeContext = createContext<VimModeContextType | undefined>(undefined);
|
||||
|
||||
export const VimModeProvider = ({
|
||||
children,
|
||||
settings,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
settings: LoadedSettings;
|
||||
}) => {
|
||||
const initialVimEnabled = settings.merged.general.vimMode;
|
||||
const [vimEnabled, setVimEnabled] = useState(initialVimEnabled);
|
||||
const { settings, setSetting } = useSettingsStore();
|
||||
const vimEnabled = settings.merged.general.vimMode;
|
||||
const [vimMode, setVimMode] = useState<VimMode>('INSERT');
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize vimEnabled from settings on mount
|
||||
const enabled = settings.merged.general.vimMode;
|
||||
setVimEnabled(enabled);
|
||||
// When vim mode is enabled, start in INSERT mode
|
||||
if (enabled) {
|
||||
setVimMode('INSERT');
|
||||
}
|
||||
}, [settings.merged.general.vimMode]);
|
||||
|
||||
const toggleVimEnabled = useCallback(async () => {
|
||||
const newValue = !vimEnabled;
|
||||
setVimEnabled(newValue);
|
||||
// When enabling vim mode, start in INSERT mode
|
||||
if (newValue) {
|
||||
setVimMode('INSERT');
|
||||
}
|
||||
settings.setValue(SettingScope.User, 'general.vimMode', newValue);
|
||||
setSetting(SettingScope.User, 'general.vimMode', newValue);
|
||||
return newValue;
|
||||
}, [vimEnabled, settings]);
|
||||
}, [vimEnabled, setSetting]);
|
||||
|
||||
const value = {
|
||||
vimEnabled,
|
||||
|
||||
@@ -48,6 +48,7 @@ export function mapToDisplay(
|
||||
|
||||
const baseDisplayProperties = {
|
||||
callId: call.request.callId,
|
||||
parentCallId: call.request.parentCallId,
|
||||
name: displayName,
|
||||
description,
|
||||
renderOutputAsMarkdown,
|
||||
|
||||
@@ -50,6 +50,7 @@ import { MessageType, StreamingState } from '../types.js';
|
||||
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
// --- MOCKS ---
|
||||
const mockSendMessageStream = vi
|
||||
@@ -2300,14 +2301,14 @@ describe('useGeminiStream', () => {
|
||||
requestTokens: 20,
|
||||
remainingTokens: 80,
|
||||
expectedMessage:
|
||||
'Sending this message (20 tokens) might exceed the remaining context window limit (80 tokens).',
|
||||
'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
|
||||
},
|
||||
{
|
||||
name: 'with suggestion when remaining tokens are < 75% of limit',
|
||||
requestTokens: 30,
|
||||
remainingTokens: 70,
|
||||
expectedMessage:
|
||||
'Sending this message (30 tokens) might exceed the remaining context window limit (70 tokens). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
|
||||
'Sending this message (30 tokens) might exceed the context window limit (70 tokens left). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
|
||||
},
|
||||
])(
|
||||
'should add message $name',
|
||||
@@ -2388,6 +2389,43 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should add informational messages when ChatCompressed event is received', async () => {
|
||||
vi.mocked(tokenLimit).mockReturnValue(10000);
|
||||
// Setup mock to return a stream with ChatCompressed event
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.ChatCompressed,
|
||||
value: {
|
||||
originalTokenCount: 1000,
|
||||
newTokenCount: 500,
|
||||
compressionStatus: 'compressed',
|
||||
},
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHookWithDefaults();
|
||||
|
||||
// Submit a query
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Test compression');
|
||||
});
|
||||
|
||||
// Check that the succinct info message was added
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Context compressed from 10% to 5%.',
|
||||
secondaryText: 'Change threshold in /settings.',
|
||||
color: theme.status.warning,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
reason: 'STOP',
|
||||
|
||||
@@ -108,9 +108,9 @@ enum StreamProcessingStatus {
|
||||
}
|
||||
|
||||
const SUPPRESSED_TOOL_ERRORS_NOTE =
|
||||
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
|
||||
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for details.';
|
||||
const LOW_VERBOSITY_FAILURE_NOTE =
|
||||
'This request failed. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
|
||||
'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
|
||||
|
||||
function isShellToolData(data: unknown): data is ShellToolData {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
@@ -1065,16 +1065,27 @@ export const useGeminiStream = (
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
return addItem({
|
||||
type: 'info',
|
||||
text:
|
||||
`IMPORTANT: This conversation exceeded the compress threshold. ` +
|
||||
`A compressed context will be sent for future messages (compressed from: ` +
|
||||
`${eventValue?.originalTokenCount ?? 'unknown'} to ` +
|
||||
`${eventValue?.newTokenCount ?? 'unknown'} tokens).`,
|
||||
});
|
||||
|
||||
const limit = tokenLimit(config.getModel());
|
||||
const originalPercentage = Math.round(
|
||||
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
|
||||
);
|
||||
const newPercentage = Math.round(
|
||||
((eventValue?.newTokenCount ?? 0) / limit) * 100,
|
||||
);
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
|
||||
secondaryText: `Change threshold in /settings.`,
|
||||
color: theme.status.warning,
|
||||
marginBottom: 1,
|
||||
} as HistoryItemInfo,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
},
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
|
||||
);
|
||||
|
||||
const handleMaxSessionTurnsEvent = useCallback(
|
||||
@@ -1094,12 +1105,12 @@ export const useGeminiStream = (
|
||||
|
||||
const limit = tokenLimit(config.getModel());
|
||||
|
||||
const isLessThan75Percent =
|
||||
const isMoreThan25PercentUsed =
|
||||
limit > 0 && remainingTokenCount < limit * 0.75;
|
||||
|
||||
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the remaining context window limit (${remainingTokenCount} tokens).`;
|
||||
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
|
||||
|
||||
if (isLessThan75Percent) {
|
||||
if (isMoreThan25PercentUsed) {
|
||||
text +=
|
||||
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('useLoadingIndicator', () => {
|
||||
shouldShowFocusHint?: boolean;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
mode?: LoadingPhrasesMode;
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
errorVerbosity: 'low' | 'full';
|
||||
}) {
|
||||
hookResult = useLoadingIndicator({
|
||||
streamingState,
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface UseLoadingIndicatorProps {
|
||||
retryStatus: RetryAttemptPayload | null;
|
||||
loadingPhrasesMode?: LoadingPhrasesMode;
|
||||
customWittyPhrases?: string[];
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
errorVerbosity: 'low' | 'full';
|
||||
}
|
||||
|
||||
export const useLoadingIndicator = ({
|
||||
@@ -31,7 +31,7 @@ export const useLoadingIndicator = ({
|
||||
retryStatus,
|
||||
loadingPhrasesMode,
|
||||
customWittyPhrases,
|
||||
errorVerbosity = 'full',
|
||||
errorVerbosity,
|
||||
}: UseLoadingIndicatorProps) => {
|
||||
const [timerResetKey, setTimerResetKey] = useState(0);
|
||||
const isTimerActive = streamingState === StreamingState.Responding;
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useSessionRetentionCheck } from './useSessionRetentionCheck.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import type { Settings } from '../../config/settingsSchema.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
|
||||
// Mock utils
|
||||
const mockGetAllSessionFiles = vi.fn();
|
||||
const mockIdentifySessionsToDelete = vi.fn();
|
||||
|
||||
vi.mock('../../utils/sessionUtils.js', () => ({
|
||||
getAllSessionFiles: () => mockGetAllSessionFiles(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/sessionCleanup.js', () => ({
|
||||
identifySessionsToDelete: () => mockIdentifySessionsToDelete(),
|
||||
DEFAULT_MIN_RETENTION: '30d',
|
||||
}));
|
||||
|
||||
describe('useSessionRetentionCheck', () => {
|
||||
const mockConfig = {
|
||||
storage: {
|
||||
getProjectTempDir: () => '/mock/project/temp/dir',
|
||||
},
|
||||
getSessionId: () => 'mock-session-id',
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should show warning if enabled is true but maxAge is undefined', async () => {
|
||||
const settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: true,
|
||||
maxAge: undefined,
|
||||
warningAcknowledged: false,
|
||||
},
|
||||
},
|
||||
} as unknown as Settings;
|
||||
|
||||
mockGetAllSessionFiles.mockResolvedValue(['session1.json']);
|
||||
mockIdentifySessionsToDelete.mockResolvedValue(['session1.json']);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSessionRetentionCheck(mockConfig, settings),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.checkComplete).toBe(true);
|
||||
expect(result.current.shouldShowWarning).toBe(true);
|
||||
expect(mockGetAllSessionFiles).toHaveBeenCalled();
|
||||
expect(mockIdentifySessionsToDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show warning if warningAcknowledged is true', async () => {
|
||||
const settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
warningAcknowledged: true,
|
||||
},
|
||||
},
|
||||
} as unknown as Settings;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSessionRetentionCheck(mockConfig, settings),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.checkComplete).toBe(true);
|
||||
expect(result.current.shouldShowWarning).toBe(false);
|
||||
expect(mockGetAllSessionFiles).not.toHaveBeenCalled();
|
||||
expect(mockIdentifySessionsToDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show warning if retention is already enabled', async () => {
|
||||
const settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: true,
|
||||
maxAge: '30d', // Explicitly enabled with non-default
|
||||
},
|
||||
},
|
||||
} as unknown as Settings;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSessionRetentionCheck(mockConfig, settings),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.checkComplete).toBe(true);
|
||||
expect(result.current.shouldShowWarning).toBe(false);
|
||||
expect(mockGetAllSessionFiles).not.toHaveBeenCalled();
|
||||
expect(mockIdentifySessionsToDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show warning if sessions to delete exist', async () => {
|
||||
const settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: false,
|
||||
warningAcknowledged: false,
|
||||
},
|
||||
},
|
||||
} as unknown as Settings;
|
||||
|
||||
mockGetAllSessionFiles.mockResolvedValue([
|
||||
'session1.json',
|
||||
'session2.json',
|
||||
]);
|
||||
mockIdentifySessionsToDelete.mockResolvedValue(['session1.json']); // 1 session to delete
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSessionRetentionCheck(mockConfig, settings),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.checkComplete).toBe(true);
|
||||
expect(result.current.shouldShowWarning).toBe(true);
|
||||
expect(result.current.sessionsToDeleteCount).toBe(1);
|
||||
expect(mockGetAllSessionFiles).toHaveBeenCalled();
|
||||
expect(mockIdentifySessionsToDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onAutoEnable if no sessions to delete and currently disabled', async () => {
|
||||
const settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: false,
|
||||
warningAcknowledged: false,
|
||||
},
|
||||
},
|
||||
} as unknown as Settings;
|
||||
|
||||
mockGetAllSessionFiles.mockResolvedValue(['session1.json']);
|
||||
mockIdentifySessionsToDelete.mockResolvedValue([]); // 0 sessions to delete
|
||||
|
||||
const onAutoEnable = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSessionRetentionCheck(mockConfig, settings, onAutoEnable),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.checkComplete).toBe(true);
|
||||
expect(result.current.shouldShowWarning).toBe(false);
|
||||
expect(onAutoEnable).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show warning if no sessions to delete', async () => {
|
||||
const settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: false,
|
||||
warningAcknowledged: false,
|
||||
},
|
||||
},
|
||||
} as unknown as Settings;
|
||||
|
||||
mockGetAllSessionFiles.mockResolvedValue([
|
||||
'session1.json',
|
||||
'session2.json',
|
||||
]);
|
||||
mockIdentifySessionsToDelete.mockResolvedValue([]); // 0 sessions to delete
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSessionRetentionCheck(mockConfig, settings),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.checkComplete).toBe(true);
|
||||
expect(result.current.shouldShowWarning).toBe(false);
|
||||
expect(result.current.sessionsToDeleteCount).toBe(0);
|
||||
expect(mockGetAllSessionFiles).toHaveBeenCalled();
|
||||
expect(mockIdentifySessionsToDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully (assume no warning)', async () => {
|
||||
const settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: false,
|
||||
warningAcknowledged: false,
|
||||
},
|
||||
},
|
||||
} as unknown as Settings;
|
||||
|
||||
mockGetAllSessionFiles.mockRejectedValue(new Error('FS Error'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSessionRetentionCheck(mockConfig, settings),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.checkComplete).toBe(true);
|
||||
expect(result.current.shouldShowWarning).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { type Settings } from '../../config/settings.js';
|
||||
import { getAllSessionFiles } from '../../utils/sessionUtils.js';
|
||||
import { identifySessionsToDelete } from '../../utils/sessionCleanup.js';
|
||||
import path from 'node:path';
|
||||
|
||||
export function useSessionRetentionCheck(
|
||||
config: Config,
|
||||
settings: Settings,
|
||||
onAutoEnable?: () => void,
|
||||
) {
|
||||
const [shouldShowWarning, setShouldShowWarning] = useState(false);
|
||||
const [sessionsToDeleteCount, setSessionsToDeleteCount] = useState(0);
|
||||
const [checkComplete, setCheckComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If warning already acknowledged or retention already enabled, skip check
|
||||
if (
|
||||
settings.general?.sessionRetention?.warningAcknowledged ||
|
||||
(settings.general?.sessionRetention?.enabled &&
|
||||
settings.general?.sessionRetention?.maxAge !== undefined)
|
||||
) {
|
||||
setShouldShowWarning(false);
|
||||
setCheckComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const checkSessions = async () => {
|
||||
try {
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
const allFiles = await getAllSessionFiles(
|
||||
chatsDir,
|
||||
config.getSessionId(),
|
||||
);
|
||||
|
||||
// Calculate how many sessions would be deleted if we applied a 30-day retention
|
||||
const sessionsToDelete = await identifySessionsToDelete(allFiles, {
|
||||
enabled: true,
|
||||
maxAge: '30d',
|
||||
});
|
||||
|
||||
if (sessionsToDelete.length > 0) {
|
||||
setSessionsToDeleteCount(sessionsToDelete.length);
|
||||
setShouldShowWarning(true);
|
||||
} else {
|
||||
setShouldShowWarning(false);
|
||||
// If no sessions to delete, safe to auto-enable retention
|
||||
onAutoEnable?.();
|
||||
}
|
||||
} catch {
|
||||
// If we can't check sessions, default to not showing the warning to be safe
|
||||
setShouldShowWarning(false);
|
||||
} finally {
|
||||
setCheckComplete(true);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
checkSessions();
|
||||
}, [config, settings.general?.sessionRetention, onAutoEnable]);
|
||||
|
||||
return { shouldShowWarning, checkComplete, sessionsToDeleteCount };
|
||||
}
|
||||
@@ -48,59 +48,43 @@ export interface ColorsTheme {
|
||||
|
||||
export const lightTheme: ColorsTheme = {
|
||||
type: 'light',
|
||||
Background: '#FAFAFA',
|
||||
Foreground: '',
|
||||
LightBlue: '#89BDCD',
|
||||
AccentBlue: '#3B82F6',
|
||||
AccentPurple: '#8B5CF6',
|
||||
AccentCyan: '#06B6D4',
|
||||
AccentGreen: '#3CA84B',
|
||||
AccentYellow: '#D5A40A',
|
||||
AccentRed: '#DD4C4C',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FFCCCC',
|
||||
Comment: '#008000',
|
||||
Gray: '#97a0b0',
|
||||
DarkGray: interpolateColor('#FAFAFA', '#97a0b0', DEFAULT_BORDER_OPACITY),
|
||||
InputBackground: interpolateColor(
|
||||
'#FAFAFA',
|
||||
'#97a0b0',
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
MessageBackground: interpolateColor(
|
||||
'#FAFAFA',
|
||||
'#97a0b0',
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
Foreground: '#262626', // Google Gray 900
|
||||
Background: '#FFFFFF', // White
|
||||
LightBlue: '#0087ff', // Google Blue 600
|
||||
AccentBlue: '#005fdf', // Google Blue 800
|
||||
AccentPurple: '#8700ff', // Google Purple 600
|
||||
AccentCyan: '#005faf', // Google Cyan 900
|
||||
AccentGreen: '#008700', // Google Green 700
|
||||
AccentYellow: '#b06000', // Google Orange 900
|
||||
AccentRed: '#df0000', // Google Red 600
|
||||
DiffAdded: '#d7ffd7', // Google Green 100
|
||||
DiffRemoved: '#ffd7d7', // Google Red 100
|
||||
Comment: '#008700', // Google Green 700
|
||||
Gray: '#626262', // Google Gray 700
|
||||
DarkGray: '#444444', // Google Gray 800
|
||||
InputBackground: '#eeeeee', // Google Gray 100
|
||||
MessageBackground: '#eeeeee', // Google Gray 100
|
||||
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
|
||||
};
|
||||
|
||||
export const darkTheme: ColorsTheme = {
|
||||
type: 'dark',
|
||||
Background: '#1E1E2E',
|
||||
Foreground: '',
|
||||
LightBlue: '#ADD8E6',
|
||||
AccentBlue: '#89B4FA',
|
||||
AccentPurple: '#CBA6F7',
|
||||
AccentCyan: '#89DCEB',
|
||||
AccentGreen: '#A6E3A1',
|
||||
AccentYellow: '#F9E2AF',
|
||||
AccentRed: '#F38BA8',
|
||||
DiffAdded: '#28350B',
|
||||
DiffRemoved: '#430000',
|
||||
Comment: '#6C7086',
|
||||
Gray: '#6C7086',
|
||||
DarkGray: interpolateColor('#1E1E2E', '#6C7086', DEFAULT_BORDER_OPACITY),
|
||||
InputBackground: interpolateColor(
|
||||
'#1E1E2E',
|
||||
'#6C7086',
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
MessageBackground: interpolateColor(
|
||||
'#1E1E2E',
|
||||
'#6C7086',
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
Background: '#000000', // Black
|
||||
Foreground: '#F8F9FA', // Google Gray 50
|
||||
LightBlue: '#AECBFA', // Google Blue 200
|
||||
AccentBlue: '#8AB4F8', // Google Blue 300
|
||||
AccentPurple: '#C58AF9', // Google Purple 300
|
||||
AccentCyan: '#78D9EC', // Google Cyan 300
|
||||
AccentGreen: '#81C995', // Google Green 300
|
||||
AccentYellow: '#FDD663', // Google Yellow 300
|
||||
AccentRed: '#F28B82', // Google Red 300
|
||||
DiffAdded: '#0D652D', // Google Green 900
|
||||
DiffRemoved: '#A50E0E', // Google Red 900
|
||||
Comment: '#DADCE0', // Google Gray 300
|
||||
Gray: '#DADCE0', // Google Gray 300
|
||||
DarkGray: '#80868B', // Google Gray 600
|
||||
InputBackground: '#202124', // Google Gray 900
|
||||
MessageBackground: '#202124', // Google Gray 900
|
||||
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
|
||||
};
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface ToolCallEvent {
|
||||
|
||||
export interface IndividualToolCallDisplay {
|
||||
callId: string;
|
||||
parentCallId?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
@@ -150,6 +151,7 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
|
||||
export type HistoryItemInfo = HistoryItemBase & {
|
||||
type: 'info';
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
marginBottom?: number;
|
||||
@@ -348,18 +350,6 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
|
||||
showSchema: boolean;
|
||||
};
|
||||
|
||||
export type HistoryItemHooksList = HistoryItemBase & {
|
||||
type: 'hooks_list';
|
||||
hooks: Array<{
|
||||
config: { command?: string; type: string; timeout?: number };
|
||||
source: string;
|
||||
eventName: string;
|
||||
matcher?: string;
|
||||
sequential?: boolean;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
|
||||
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
|
||||
// 'tools' in historyItem.
|
||||
@@ -388,8 +378,7 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemMcpStatus
|
||||
| HistoryItemChatList
|
||||
| HistoryItemThinking
|
||||
| HistoryItemHint
|
||||
| HistoryItemHooksList;
|
||||
| HistoryItemHint;
|
||||
|
||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||
|
||||
@@ -413,7 +402,6 @@ export enum MessageType {
|
||||
AGENTS_LIST = 'agents_list',
|
||||
MCP_STATUS = 'mcp_status',
|
||||
CHAT_LIST = 'chat_list',
|
||||
HOOKS_LIST = 'hooks_list',
|
||||
HINT = 'hint',
|
||||
}
|
||||
|
||||
|
||||
+25
-117
@@ -1,123 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="275" fill="#000000" />
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="19" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="19" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="19" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="19" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="19" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="144" y="19" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="19" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="19" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="19" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="19" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="19" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="36" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="9" y="36" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="18" y="36" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="27" y="36" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="36" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="36" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="36" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="36" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="36" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="36" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="144" y="36" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="153" y="36" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="36" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="36" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="36" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="36" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="198" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="53" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="27" y="53" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="36" y="53" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="45" y="53" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="53" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="63" y="53" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="99" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="53" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="53" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="53" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="53" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="189" y="53" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="36" y="70" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="45" y="70" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="54" y="70" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="63" y="70" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="72" y="70" fill="#7382d4" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="81" y="70" fill="#797fd2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="90" y="70" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="70" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="70" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="87" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="87" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="63" y="87" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="72" y="87" fill="#7382d4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="90" y="87" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="87" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="87" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="87" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="87" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="87" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="87" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="87" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="198" y="87" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="104" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="104" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="104" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="104" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="90" y="104" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="104" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="104" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="104" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="104" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="104" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="104" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="104" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="104" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="104" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="9" y="121" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="121" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="121" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="121" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="121" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="121" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="117" y="121" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="121" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="121" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="144" y="121" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="121" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="121" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="121" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="121" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="121" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="138" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="9" y="138" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="18" y="138" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="138" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="117" y="138" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="126" y="138" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="135" y="138" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="144" y="138" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="153" y="138" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="138" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="138" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="138" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="0" y="172" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="189" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="189" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> ⊷ google_web_search </text>
|
||||
<text x="855" y="189" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="206" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="206" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="223" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="223" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> Searching... </text>
|
||||
<text x="855" y="223" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="240" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="63" y="19" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#6c7086" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="121" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> ⊷ google_web_search </text>
|
||||
<text x="855" y="121" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="155" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> Searching... </text>
|
||||
<text x="855" y="155" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.3 KiB |
+25
-117
@@ -1,123 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="275" fill="#000000" />
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="19" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="19" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="19" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="19" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="19" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="144" y="19" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="19" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="19" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="19" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="19" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="19" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="36" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="9" y="36" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="18" y="36" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="27" y="36" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="36" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="36" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="36" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="36" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="36" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="36" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="144" y="36" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="153" y="36" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="36" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="36" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="36" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="36" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="198" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="53" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="27" y="53" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="36" y="53" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="45" y="53" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="53" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="63" y="53" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="99" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="53" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="53" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="53" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="53" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="189" y="53" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="36" y="70" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="45" y="70" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="54" y="70" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="63" y="70" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="72" y="70" fill="#7382d4" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="81" y="70" fill="#797fd2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="90" y="70" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="70" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="70" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="87" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="87" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="63" y="87" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="72" y="87" fill="#7382d4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="90" y="87" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="87" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="87" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="87" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="87" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="87" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="87" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="87" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="198" y="87" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="104" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="104" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="104" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="104" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="90" y="104" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="104" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="104" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="104" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="104" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="104" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="104" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="104" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="104" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="104" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="9" y="121" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="121" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="121" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="121" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="121" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="121" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="117" y="121" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="121" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="121" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="144" y="121" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="121" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="121" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="121" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="121" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="121" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="138" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="9" y="138" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="18" y="138" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="138" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="117" y="138" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="126" y="138" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="135" y="138" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="144" y="138" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="153" y="138" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="138" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="138" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="138" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="0" y="172" fill="#6c7086" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="189" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="189" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> ⊷ run_shell_command </text>
|
||||
<text x="855" y="189" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="206" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="206" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="223" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="223" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> Running command... </text>
|
||||
<text x="855" y="223" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="240" fill="#6c7086" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="63" y="19" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#6c7086" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#6c7086" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="121" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> ⊷ run_shell_command </text>
|
||||
<text x="855" y="121" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="155" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> Running command... </text>
|
||||
<text x="855" y="155" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#6c7086" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.3 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user