Compare commits

...

31 Commits

Author SHA1 Message Date
Christian Gunderman ef38202dbd feat(core): centralize line truncation and apply to all tools 2026-03-02 21:02:57 -08:00
Christian Gunderman 9850f01894 feat(core): centralize line truncation and apply to all tools 2026-03-02 18:48:56 -08:00
Aswin Ashok 0d69f9f7fa Build binary (#18933)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-03-03 01:02:19 +00:00
Christian Gunderman 46231a1755 ci(evals): only run evals in CI if prompts or tools changed (#20898)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-03 00:29:31 +00:00
Sandy Tao 2e7722d6a3 fix(core): restrict "System: Please continue" invalid stream retry to Gemini 2 models (#20897) 2026-03-02 23:21:13 +00:00
Yuna Seol 69e15a50d1 fix(core): skip telemetry logging for AbortError exceptions (#19477)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-03-02 23:14:31 +00:00
Christian Gunderman 25f59a0099 Add some dos and don'ts to behavioral evals README. (#20629) 2026-03-02 23:14:00 +00:00
Adib234 01927a36d1 feat(plan): support annotating plans with feedback for iteration (#20876) 2026-03-02 23:03:59 +00:00
Shreya Keshive 06ddfa5c4c feat(admin): enable 30 day default retention for chat history & remove warning (#20853) 2026-03-02 22:44:49 +00:00
Christian Gunderman 3f7ef816f1 fix(core): increase default headers timeout to 5 minutes (#20890) 2026-03-02 22:36:58 +00:00
Jerop Kipruto d05ba11a31 refactor(core): replace manual syncPlanModeTools with declarative policy rules (#20596) 2026-03-02 22:30:50 +00:00
Hamdanbinhashim e43b1cff58 docs: fix broken markdown links in main README.md (#20300) 2026-03-02 21:51:52 +00:00
Allen Hutchison bb6d1a2775 feat(core): add tool name validation in TOML policy files (#19281)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-02 21:47:21 +00:00
Nayana Parameswarappa dd9ccc9807 Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider (#20121) 2026-03-02 21:37:44 +00:00
Pyush Sinha 8133d63ac6 refactor(cli): fully remove React anti patterns, improve type safety and fix UX oversights in SettingsDialog.tsx (#18963)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-02 21:30:58 +00:00
Sandy Tao 18d0375a7f feat(core): support authenticated A2A agent card discovery (#20622)
Co-authored-by: Adam Weidman <adamfweidman@google.com>
Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com>
2026-03-02 21:29:31 +00:00
Keith Guerin 31ca57ec94 feat: redesign header to be compact with ASCII icon (#18713)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-02 21:12:17 +00:00
Abhi b7a8f0d1f9 fix(core): ensure subagents use qualified MCP tool names (#20801) 2026-03-02 21:12:13 +00:00
Abdul Tawab 1502e5cbc3 style(cli) : Dialog pattern for /hooks Command (#17930) 2026-03-02 21:12:05 +00:00
Christian Gunderman 7ca3a33f8b Subagent activity UX. (#17570) 2026-03-02 21:04:31 +00:00
Sandy Tao ce5a2d0760 feat(core): truncate large MCP tool output (#19365) 2026-03-02 21:01:49 +00:00
Sam Roberts aa321b3d8c Update CODEOWNERS for README.md reviewers (#20860) 2026-03-02 20:54:05 +00:00
David Pierce 3a7a6e1540 Add install as an option when extension is selected. (#20358) 2026-03-02 20:41:16 +00:00
Tommaso Sciortino 66530e44c8 document node limitation for shift+tab (#20877) 2026-03-02 20:31:52 +00:00
Christian Gunderman b034dcd412 Do not block CI on evals (#20870) 2026-03-02 20:31:02 +00:00
Aishanee Shah 659301ff83 feat(core): centralize read_file limits and update gemini-3 description (#20619) 2026-03-02 20:11:58 +00:00
Sandy Tao 446a4316c4 feat(core): implement HTTP authentication support for A2A remote agents (#20510)
Co-authored-by: Adam Weidman <adamfweidman@google.com>
2026-03-02 19:59:48 +00:00
Tommaso Sciortino 48412a068e Add /unassign support (#20864)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-02 19:54:26 +00:00
Adib234 2e1efaebe4 fix(plan): deflake plan mode integration tests (#20477) 2026-03-02 19:51:44 +00:00
Sandy Tao 7c9fceba7f fix(core): reduce LLM-based loop detection false positives (#20701) 2026-03-02 19:08:15 +00:00
Adam Weidman 740efa2ac2 Merge User and Agent Card Descriptions #20849 (#20850) 2026-03-02 17:59:29 +00:00
162 changed files with 7746 additions and 3654 deletions
+1
View File
@@ -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
+8
View File
@@ -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'
-1
View File
@@ -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
+41 -1
View File
@@ -25,7 +25,7 @@ jobs:
if: |-
github.repository == 'google-gemini/gemini-cli' &&
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '/assign')
(contains(github.event.comment.body, '/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.`
});
}
+160
View File
@@ -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
View File
@@ -61,4 +61,4 @@ gemini-debug.log
.genkit
.gemini-clipboard/
.eslintcache
evals/logs/
evals/logs/
+7 -4
View File
@@ -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.
+11 -9
View File
@@ -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
+3 -2
View File
@@ -109,8 +109,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).
+2 -2
View File
@@ -32,8 +32,8 @@ they appear in the UI.
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `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
+2 -7
View File
@@ -159,12 +159,12 @@ their corresponding top-level category object in your `settings.json` file.
- **`general.sessionRetention.enabled`** (boolean):
- **Description:** Enable automatic session cleanup
- **Default:** `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):
+10
View File
@@ -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.
+3
View File
@@ -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: {
+44 -1
View File
@@ -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
+3 -2
View File
@@ -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(
+3 -1
View File
@@ -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(
+87 -34
View File
@@ -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,
);
+6 -1
View File
@@ -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');
+79 -5
View File
@@ -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
View File
@@ -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",
+5 -2
View File
@@ -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(''))
+44 -5
View File
@@ -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());
+3 -1
View File
@@ -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;
}
-3
View File
@@ -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 {
+2 -12
View File
@@ -339,7 +339,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 +348,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 +372,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.',
},
+1 -1
View File
@@ -243,7 +243,7 @@ export async function startInteractiveUI(
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<VimModeProvider>
<AppContainer
config={config}
startupWarnings={startupWarnings}
+6 -2
View File
@@ -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
+3 -30
View File
@@ -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');
});
});
+18 -11
View File
@@ -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();
+74 -19
View File
@@ -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>
@@ -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) => {
@@ -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';
@@ -217,9 +216,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 &quot;Keep chat history&quot;.
</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();
+120 -366
View File
@@ -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;
+14 -18
View File
@@ -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();
});
});
+14 -18
View File
@@ -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();
});
+24 -21
View File
@@ -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>
);
};
@@ -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
"
`;
@@ -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. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -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
"
`;
@@ -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
@@ -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>
);
};
@@ -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,
+1
View File
@@ -48,6 +48,7 @@ export function mapToDisplay(
const baseDisplayProperties = {
callId: call.request.callId,
parentCallId: call.request.parentCallId,
name: displayName,
description,
renderOutputAsMarkdown,
@@ -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 };
}
+2 -15
View File
@@ -98,6 +98,7 @@ export interface ToolCallEvent {
export interface IndividualToolCallDisplay {
callId: string;
parentCallId?: string;
name: string;
description: string;
resultDisplay: ToolResultDisplay | undefined;
@@ -348,18 +349,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 +377,7 @@ export type HistoryItemWithoutId =
| HistoryItemMcpStatus
| HistoryItemChatList
| HistoryItemThinking
| HistoryItemHint
| HistoryItemHooksList;
| HistoryItemHint;
export type HistoryItem = HistoryItemWithoutId & { id: number };
@@ -413,7 +401,6 @@ export enum MessageType {
AGENTS_LIST = 'agents_list',
MCP_STATUS = 'mcp_status',
CHAT_LIST = 'chat_list',
HOOKS_LIST = 'hooks_list',
HINT = 'hint',
}
@@ -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

@@ -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

@@ -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

@@ -2,14 +2,10 @@
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a pending search dialog (google_web_search) 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ google_web_search │
@@ -20,14 +16,10 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a shell tool 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ run_shell_command │
@@ -38,14 +30,10 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for an empty slice following a search tool 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ google_web_search │
@@ -11,7 +11,7 @@ import {
getScopeItems,
getScopeMessageForSetting,
} from './dialogScopeUtils.js';
import { settingExistsInScope } from './settingsUtils.js';
import { isInSettingsScope } from './settingsUtils.js';
vi.mock('../config/settings', () => ({
SettingScope: {
@@ -24,7 +24,7 @@ vi.mock('../config/settings', () => ({
}));
vi.mock('./settingsUtils', () => ({
settingExistsInScope: vi.fn(),
isInSettingsScope: vi.fn(),
}));
describe('dialogScopeUtils', () => {
@@ -53,7 +53,7 @@ describe('dialogScopeUtils', () => {
});
it('should return empty string if not modified in other scopes', () => {
vi.mocked(settingExistsInScope).mockReturnValue(false);
vi.mocked(isInSettingsScope).mockReturnValue(false);
const message = getScopeMessageForSetting(
'key',
SettingScope.User,
@@ -63,7 +63,7 @@ describe('dialogScopeUtils', () => {
});
it('should return message indicating modification in other scopes', () => {
vi.mocked(settingExistsInScope).mockReturnValue(true);
vi.mocked(isInSettingsScope).mockReturnValue(true);
const message = getScopeMessageForSetting(
'key',
@@ -88,7 +88,7 @@ describe('dialogScopeUtils', () => {
return { settings: {} };
});
vi.mocked(settingExistsInScope).mockImplementation(
vi.mocked(isInSettingsScope).mockImplementation(
(_key, settings: unknown) => {
if (settings === workspaceSettings) return true;
if (settings === systemSettings) return false;
+7 -8
View File
@@ -4,12 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
LoadableSettingScope,
LoadedSettings,
} from '../config/settings.js';
import type { LoadableSettingScope, Settings } from '../config/settings.js';
import { isLoadableSettingScope, SettingScope } from '../config/settings.js';
import { settingExistsInScope } from './settingsUtils.js';
import { isInSettingsScope } from './settingsUtils.js';
/**
* Shared scope labels for dialog components that need to display setting scopes
@@ -43,7 +40,9 @@ export function getScopeItems(): Array<{
export function getScopeMessageForSetting(
settingKey: string,
selectedScope: LoadableSettingScope,
settings: LoadedSettings,
settings: {
forScope: (scope: LoadableSettingScope) => { settings: Settings };
},
): string {
const otherScopes = Object.values(SettingScope)
.filter(isLoadableSettingScope)
@@ -51,7 +50,7 @@ export function getScopeMessageForSetting(
const modifiedInOtherScopes = otherScopes.filter((scope) => {
const scopeSettings = settings.forScope(scope).settings;
return settingExistsInScope(settingKey, scopeSettings);
return isInSettingsScope(settingKey, scopeSettings);
});
if (modifiedInOtherScopes.length === 0) {
@@ -60,7 +59,7 @@ export function getScopeMessageForSetting(
const modifiedScopesStr = modifiedInOtherScopes.join(', ');
const currentScopeSettings = settings.forScope(selectedScope).settings;
const existsInCurrentScope = settingExistsInScope(
const existsInCurrentScope = isInSettingsScope(
settingKey,
currentScopeSettings,
);
+38 -467
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
// Schema utilities
getSettingsByCategory,
@@ -22,18 +22,10 @@ import {
getDialogSettingsByCategory,
getDialogSettingsByType,
getDialogSettingKeys,
// Business logic utilities
getSettingValue,
isSettingModified,
// Business logic utilities,
TEST_ONLY,
settingExistsInScope,
setPendingSettingValue,
hasRestartRequiredSettings,
getRestartRequiredFromModified,
isInSettingsScope,
getDisplayValue,
isDefaultValue,
isValueInherited,
getEffectiveDisplayValue,
} from './settingsUtils.js';
import {
getSettingsSchema,
@@ -255,41 +247,15 @@ describe('SettingsUtils', () => {
describe('getEffectiveValue', () => {
it('should return value from settings when set', () => {
const settings = makeMockSettings({ ui: { requiresRestart: true } });
const mergedSettings = makeMockSettings({
ui: { requiresRestart: false },
});
const value = getEffectiveValue(
'ui.requiresRestart',
settings,
mergedSettings,
);
expect(value).toBe(true);
});
it('should return value from merged settings when not set in current scope', () => {
const settings = makeMockSettings({});
const mergedSettings = makeMockSettings({
ui: { requiresRestart: true },
});
const value = getEffectiveValue(
'ui.requiresRestart',
settings,
mergedSettings,
);
const value = getEffectiveValue('ui.requiresRestart', settings);
expect(value).toBe(true);
});
it('should return default value when not set anywhere', () => {
const settings = makeMockSettings({});
const mergedSettings = makeMockSettings({});
const value = getEffectiveValue(
'ui.requiresRestart',
settings,
mergedSettings,
);
const value = getEffectiveValue('ui.requiresRestart', settings);
expect(value).toBe(false); // default value
});
@@ -297,27 +263,18 @@ describe('SettingsUtils', () => {
const settings = makeMockSettings({
ui: { accessibility: { enableLoadingPhrases: false } },
});
const mergedSettings = makeMockSettings({
ui: { accessibility: { enableLoadingPhrases: true } },
});
const value = getEffectiveValue(
'ui.accessibility.enableLoadingPhrases',
settings,
mergedSettings,
);
expect(value).toBe(false);
});
it('should return undefined for invalid settings', () => {
const settings = makeMockSettings({});
const mergedSettings = makeMockSettings({});
const value = getEffectiveValue(
'invalidSetting',
settings,
mergedSettings,
);
const value = getEffectiveValue('invalidSetting', settings);
expect(value).toBeUndefined();
});
});
@@ -483,7 +440,9 @@ describe('SettingsUtils', () => {
expect(dialogKeys.length).toBeGreaterThan(0);
});
it('should handle nested settings display correctly', () => {
const nestedDialogKey = 'context.fileFiltering.respectGitIgnore';
function mockNestedDialogSchema() {
vi.mocked(getSettingsSchema).mockReturnValue({
context: {
type: 'object',
@@ -517,128 +476,27 @@ describe('SettingsUtils', () => {
},
},
} as unknown as SettingsSchemaType);
}
// Test the specific issue with fileFiltering.respectGitIgnore
const key = 'context.fileFiltering.respectGitIgnore';
const initialSettings = makeMockSettings({});
const pendingSettings = makeMockSettings({});
it('should include nested file filtering setting in dialog keys', () => {
mockNestedDialogSchema();
// Set the nested setting to true
const updatedPendingSettings = setPendingSettingValue(
key,
true,
pendingSettings,
);
// Check if the setting exists in pending settings
const existsInPending = settingExistsInScope(
key,
updatedPendingSettings,
);
expect(existsInPending).toBe(true);
// Get the value from pending settings
const valueFromPending = getSettingValue(
key,
updatedPendingSettings,
{},
);
expect(valueFromPending).toBe(true);
// Test getDisplayValue should show the pending change
const displayValue = getDisplayValue(
key,
initialSettings,
{},
new Set(),
updatedPendingSettings,
);
expect(displayValue).toBe('true'); // Should show true (no * since value matches default)
// Test that modified settings also show the * indicator
const modifiedSettings = new Set([key]);
const displayValueWithModified = getDisplayValue(
key,
initialSettings,
{},
modifiedSettings,
{},
);
expect(displayValueWithModified).toBe('true*'); // Should show true* because it's in modified settings and default is true
const dialogKeys = getDialogSettingKeys();
expect(dialogKeys).toContain(nestedDialogKey);
});
});
});
describe('Business Logic Utilities', () => {
describe('getSettingValue', () => {
it('should return value from settings when set', () => {
const settings = makeMockSettings({ ui: { requiresRestart: true } });
const mergedSettings = makeMockSettings({
ui: { requiresRestart: false },
});
const value = getSettingValue(
'ui.requiresRestart',
settings,
mergedSettings,
);
expect(value).toBe(true);
});
it('should return value from merged settings when not set in current scope', () => {
const settings = makeMockSettings({});
const mergedSettings = makeMockSettings({
ui: { requiresRestart: true },
});
const value = getSettingValue(
'ui.requiresRestart',
settings,
mergedSettings,
);
expect(value).toBe(true);
});
it('should return default value for invalid setting', () => {
const settings = makeMockSettings({});
const mergedSettings = makeMockSettings({});
const value = getSettingValue(
'invalidSetting',
settings,
mergedSettings,
);
expect(value).toBe(false); // Default fallback
});
});
describe('isSettingModified', () => {
it('should return true when value differs from default', () => {
expect(isSettingModified('ui.requiresRestart', true)).toBe(true);
expect(
isSettingModified('ui.accessibility.enableLoadingPhrases', false),
).toBe(true);
});
it('should return false when value matches default', () => {
expect(isSettingModified('ui.requiresRestart', false)).toBe(false);
expect(
isSettingModified('ui.accessibility.enableLoadingPhrases', true),
).toBe(false);
});
});
describe('settingExistsInScope', () => {
describe('isInSettingsScope', () => {
it('should return true for top-level settings that exist', () => {
const settings = makeMockSettings({ ui: { requiresRestart: true } });
expect(settingExistsInScope('ui.requiresRestart', settings)).toBe(true);
expect(isInSettingsScope('ui.requiresRestart', settings)).toBe(true);
});
it('should return false for top-level settings that do not exist', () => {
const settings = makeMockSettings({});
expect(settingExistsInScope('ui.requiresRestart', settings)).toBe(
false,
);
expect(isInSettingsScope('ui.requiresRestart', settings)).toBe(false);
});
it('should return true for nested settings that exist', () => {
@@ -646,121 +504,25 @@ describe('SettingsUtils', () => {
ui: { accessibility: { enableLoadingPhrases: true } },
});
expect(
settingExistsInScope(
'ui.accessibility.enableLoadingPhrases',
settings,
),
isInSettingsScope('ui.accessibility.enableLoadingPhrases', settings),
).toBe(true);
});
it('should return false for nested settings that do not exist', () => {
const settings = makeMockSettings({});
expect(
settingExistsInScope(
'ui.accessibility.enableLoadingPhrases',
settings,
),
isInSettingsScope('ui.accessibility.enableLoadingPhrases', settings),
).toBe(false);
});
it('should return false when parent exists but child does not', () => {
const settings = makeMockSettings({ ui: { accessibility: {} } });
expect(
settingExistsInScope(
'ui.accessibility.enableLoadingPhrases',
settings,
),
isInSettingsScope('ui.accessibility.enableLoadingPhrases', settings),
).toBe(false);
});
});
describe('setPendingSettingValue', () => {
it('should set top-level setting value', () => {
const pendingSettings = makeMockSettings({});
const result = setPendingSettingValue(
'ui.hideWindowTitle',
true,
pendingSettings,
);
expect(result.ui?.hideWindowTitle).toBe(true);
});
it('should set nested setting value', () => {
const pendingSettings = makeMockSettings({});
const result = setPendingSettingValue(
'ui.accessibility.enableLoadingPhrases',
true,
pendingSettings,
);
expect(result.ui?.accessibility?.enableLoadingPhrases).toBe(true);
});
it('should preserve existing nested settings', () => {
const pendingSettings = makeMockSettings({
ui: { accessibility: { enableLoadingPhrases: false } },
});
const result = setPendingSettingValue(
'ui.accessibility.enableLoadingPhrases',
true,
pendingSettings,
);
expect(result.ui?.accessibility?.enableLoadingPhrases).toBe(true);
});
it('should not mutate original settings', () => {
const pendingSettings = makeMockSettings({});
setPendingSettingValue('ui.requiresRestart', true, pendingSettings);
expect(pendingSettings).toEqual({});
});
});
describe('hasRestartRequiredSettings', () => {
it('should return true when modified settings require restart', () => {
const modifiedSettings = new Set<string>([
'advanced.autoConfigureMemory',
'ui.requiresRestart',
]);
expect(hasRestartRequiredSettings(modifiedSettings)).toBe(true);
});
it('should return false when no modified settings require restart', () => {
const modifiedSettings = new Set<string>(['test']);
expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false);
});
it('should return false for empty set', () => {
const modifiedSettings = new Set<string>();
expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false);
});
});
describe('getRestartRequiredFromModified', () => {
it('should return only settings that require restart', () => {
const modifiedSettings = new Set<string>([
'ui.requiresRestart',
'test',
]);
const result = getRestartRequiredFromModified(modifiedSettings);
expect(result).toContain('ui.requiresRestart');
expect(result).not.toContain('test');
});
it('should return empty array when no settings require restart', () => {
const modifiedSettings = new Set<string>([
'requiresRestart',
'hideTips',
]);
const result = getRestartRequiredFromModified(modifiedSettings);
expect(result).toEqual([]);
});
});
describe('getDisplayValue', () => {
describe('enum behavior', () => {
enum StringEnum {
@@ -830,14 +592,8 @@ describe('SettingsUtils', () => {
const mergedSettings = makeMockSettings({
ui: { theme: NumberEnum.THREE },
});
const modifiedSettings = new Set<string>();
const result = getDisplayValue(
'ui.theme',
settings,
mergedSettings,
modifiedSettings,
);
const result = getDisplayValue('ui.theme', settings, mergedSettings);
expect(result).toBe('Three*');
});
@@ -867,13 +623,11 @@ describe('SettingsUtils', () => {
},
},
} as unknown as SettingsSchemaType);
const modifiedSettings = new Set<string>();
const result = getDisplayValue(
'ui.theme',
makeMockSettings({}),
makeMockSettings({}),
modifiedSettings,
);
expect(result).toBe('Three');
});
@@ -886,14 +640,8 @@ describe('SettingsUtils', () => {
const mergedSettings = makeMockSettings({
ui: { theme: StringEnum.BAR },
});
const modifiedSettings = new Set<string>();
const result = getDisplayValue(
'ui.theme',
settings,
mergedSettings,
modifiedSettings,
);
const result = getDisplayValue('ui.theme', settings, mergedSettings);
expect(result).toBe('Bar*');
});
@@ -907,14 +655,8 @@ describe('SettingsUtils', () => {
} as unknown as SettingsSchemaType);
const settings = makeMockSettings({ ui: { theme: 'xyz' } });
const mergedSettings = makeMockSettings({ ui: { theme: 'xyz' } });
const modifiedSettings = new Set<string>();
const result = getDisplayValue(
'ui.theme',
settings,
mergedSettings,
modifiedSettings,
);
const result = getDisplayValue('ui.theme', settings, mergedSettings);
expect(result).toBe('xyz*');
});
@@ -926,242 +668,71 @@ describe('SettingsUtils', () => {
},
},
} as unknown as SettingsSchemaType);
const modifiedSettings = new Set<string>();
const result = getDisplayValue(
'ui.theme',
makeMockSettings({}),
makeMockSettings({}),
modifiedSettings,
);
expect(result).toBe('Bar');
});
});
it('should show value without * when setting matches default', () => {
const settings = makeMockSettings({
ui: { requiresRestart: false },
}); // false matches default, so no *
it('should show value with * when setting exists in scope', () => {
const settings = makeMockSettings({ ui: { requiresRestart: true } });
const mergedSettings = makeMockSettings({
ui: { requiresRestart: false },
ui: { requiresRestart: true },
});
const modifiedSettings = new Set<string>();
const result = getDisplayValue(
'ui.requiresRestart',
settings,
mergedSettings,
modifiedSettings,
);
expect(result).toBe('false*');
expect(result).toBe('true*');
});
it('should show default value when setting is not in scope', () => {
it('should not show * when key is not in scope', () => {
const settings = makeMockSettings({}); // no setting in scope
const mergedSettings = makeMockSettings({
ui: { requiresRestart: false },
});
const modifiedSettings = new Set<string>();
const result = getDisplayValue(
'ui.requiresRestart',
settings,
mergedSettings,
modifiedSettings,
);
expect(result).toBe('false'); // shows default value
});
it('should show value with * when changed from default', () => {
const settings = makeMockSettings({ ui: { requiresRestart: true } }); // true is different from default (false)
const mergedSettings = makeMockSettings({
ui: { requiresRestart: true },
});
const modifiedSettings = new Set<string>();
const result = getDisplayValue(
'ui.requiresRestart',
settings,
mergedSettings,
modifiedSettings,
);
expect(result).toBe('true*');
});
it('should show default value without * when setting does not exist in scope', () => {
const settings = makeMockSettings({}); // setting doesn't exist in scope, show default
const mergedSettings = makeMockSettings({
ui: { requiresRestart: false },
});
const modifiedSettings = new Set<string>();
const result = getDisplayValue(
'ui.requiresRestart',
settings,
mergedSettings,
modifiedSettings,
);
expect(result).toBe('false'); // default value (false) without *
});
it('should show value with * when user changes from default', () => {
const settings = makeMockSettings({}); // setting doesn't exist in scope originally
const mergedSettings = makeMockSettings({
ui: { requiresRestart: false },
});
const modifiedSettings = new Set<string>(['ui.requiresRestart']);
const pendingSettings = makeMockSettings({
ui: { requiresRestart: true },
}); // user changed to true
const result = getDisplayValue(
'ui.requiresRestart',
settings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
expect(result).toBe('true*'); // changed from default (false) to true
});
});
describe('isDefaultValue', () => {
it('should return true when setting does not exist in scope', () => {
const settings = makeMockSettings({}); // setting doesn't exist
const result = isDefaultValue('ui.requiresRestart', settings);
expect(result).toBe(true);
});
it('should return false when setting exists in scope', () => {
const settings = makeMockSettings({ ui: { requiresRestart: true } }); // setting exists
const result = isDefaultValue('ui.requiresRestart', settings);
expect(result).toBe(false);
});
it('should return true when nested setting does not exist in scope', () => {
const settings = makeMockSettings({}); // nested setting doesn't exist
const result = isDefaultValue(
'ui.accessibility.enableLoadingPhrases',
settings,
);
expect(result).toBe(true);
});
it('should return false when nested setting exists in scope', () => {
it('should show value with * when setting exists in scope, even when it matches default', () => {
const settings = makeMockSettings({
ui: { accessibility: { enableLoadingPhrases: true } },
}); // nested setting exists
const result = isDefaultValue(
'ui.accessibility.enableLoadingPhrases',
settings,
);
expect(result).toBe(false);
});
});
describe('isValueInherited', () => {
it('should return false for top-level settings that exist in scope', () => {
const settings = makeMockSettings({ ui: { requiresRestart: true } });
const mergedSettings = makeMockSettings({
ui: { requiresRestart: true },
});
const result = isValueInherited(
'ui.requiresRestart',
settings,
mergedSettings,
);
expect(result).toBe(false);
});
it('should return true for top-level settings that do not exist in scope', () => {
const settings = makeMockSettings({});
const mergedSettings = makeMockSettings({
ui: { requiresRestart: true },
});
const result = isValueInherited(
'ui.requiresRestart',
settings,
mergedSettings,
);
expect(result).toBe(true);
});
it('should return false for nested settings that exist in scope', () => {
const settings = makeMockSettings({
ui: { accessibility: { enableLoadingPhrases: true } },
});
const mergedSettings = makeMockSettings({
ui: { accessibility: { enableLoadingPhrases: true } },
});
const result = isValueInherited(
'ui.accessibility.enableLoadingPhrases',
settings,
mergedSettings,
);
expect(result).toBe(false);
});
it('should return true for nested settings that do not exist in scope', () => {
const settings = makeMockSettings({});
const mergedSettings = makeMockSettings({
ui: { accessibility: { enableLoadingPhrases: true } },
});
const result = isValueInherited(
'ui.accessibility.enableLoadingPhrases',
settings,
mergedSettings,
);
expect(result).toBe(true);
});
});
describe('getEffectiveDisplayValue', () => {
it('should return value from settings when available', () => {
const settings = makeMockSettings({ ui: { requiresRestart: true } });
ui: { requiresRestart: false },
}); // false matches default, but key is explicitly set in scope
const mergedSettings = makeMockSettings({
ui: { requiresRestart: false },
});
const result = getEffectiveDisplayValue(
const result = getDisplayValue(
'ui.requiresRestart',
settings,
mergedSettings,
);
expect(result).toBe(true);
expect(result).toBe('false*');
});
it('should return value from merged settings when not in scope', () => {
const settings = makeMockSettings({});
it('should show schema default (not inherited merged value) when key is not in scope', () => {
const settings = makeMockSettings({}); // no setting in current scope
const mergedSettings = makeMockSettings({
ui: { requiresRestart: true },
});
}); // inherited merged value differs from schema default (false)
const result = getEffectiveDisplayValue(
const result = getDisplayValue(
'ui.requiresRestart',
settings,
mergedSettings,
);
expect(result).toBe(true);
});
it('should return default value for undefined values', () => {
const settings = makeMockSettings({});
const mergedSettings = makeMockSettings({});
const result = getEffectiveDisplayValue(
'ui.requiresRestart',
settings,
mergedSettings,
);
expect(result).toBe(false); // Default value
expect(result).toBe('false');
});
});
});
+152 -280
View File
@@ -4,11 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Settings,
LoadedSettings,
LoadableSettingScope,
} from '../config/settings.js';
import type { Settings } from '../config/settings.js';
import type {
SettingDefinition,
SettingsSchema,
@@ -52,9 +48,6 @@ function clearFlattenedSchema() {
_FLATTENED_SCHEMA = undefined;
}
/**
* Get all settings grouped by category
*/
export function getSettingsByCategory(): Record<
string,
Array<SettingDefinition & { key: string }>
@@ -75,25 +68,16 @@ export function getSettingsByCategory(): Record<
return categories;
}
/**
* Get a setting definition by key
*/
export function getSettingDefinition(
key: string,
): (SettingDefinition & { key: string }) | undefined {
return getFlattenedSchema()[key];
}
/**
* Check if a setting requires restart
*/
export function requiresRestart(key: string): boolean {
return getFlattenedSchema()[key]?.requiresRestart ?? false;
}
/**
* Get the default value for a setting
*/
export function getDefaultValue(key: string): SettingsValue {
return getFlattenedSchema()[key]?.default;
}
@@ -120,9 +104,6 @@ export function getEffectiveDefaultValue(
return getDefaultValue(key);
}
/**
* Get all setting keys that require restart
*/
export function getRestartRequiredSettings(): string[] {
return Object.values(getFlattenedSchema())
.filter((definition) => definition.requiresRestart)
@@ -130,35 +111,55 @@ export function getRestartRequiredSettings(): string[] {
}
/**
* Recursively gets a value from a nested object using a key path array.
* Get restart-required setting keys that are also visible in the dialog.
* Non-dialog restart keys (e.g. parent container objects like mcpServers, tools)
* are excluded because users cannot change them through the dialog.
*/
export function getNestedValue(
obj: Record<string, unknown>,
path: string[],
): unknown {
const [first, ...rest] = path;
if (!first || !(first in obj)) {
return undefined;
}
const value = obj[first];
if (rest.length === 0) {
return value;
}
if (value && typeof value === 'object' && value !== null) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return getNestedValue(value as Record<string, unknown>, rest);
}
return undefined;
export function getDialogRestartRequiredSettings(): string[] {
return Object.values(getFlattenedSchema())
.filter(
(definition) =>
definition.requiresRestart && definition.showInDialog !== false,
)
.map((definition) => definition.key);
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isSettingsValue(value: unknown): value is SettingsValue {
if (value === undefined) return true;
if (value === null) return false;
const type = typeof value;
return (
type === 'string' ||
type === 'number' ||
type === 'boolean' ||
type === 'object'
);
}
/**
* Get the effective value for a setting, considering inheritance from higher scopes
* Always returns a value (never undefined) - falls back to default if not set anywhere
* Gets a value from a nested object using a key path array iteratively.
*/
export function getNestedValue(obj: unknown, path: string[]): unknown {
let current = obj;
for (const key of path) {
if (!isRecord(current) || !(key in current)) {
return undefined;
}
current = current[key];
}
return current;
}
/**
* Get the effective value for a setting falling back to the default value
*/
export function getEffectiveValue(
key: string,
settings: Settings,
mergedSettings: Settings,
): SettingsValue {
const definition = getSettingDefinition(key);
if (!definition) {
@@ -168,33 +169,19 @@ export function getEffectiveValue(
const path = key.split('.');
// Check the current scope's settings first
let value = getNestedValue(settings as Record<string, unknown>, path);
if (value !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return value as SettingsValue;
}
// Check the merged settings for an inherited value
value = getNestedValue(mergedSettings as Record<string, unknown>, path);
if (value !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return value as SettingsValue;
const value = getNestedValue(settings, path);
if (value !== undefined && isSettingsValue(value)) {
return value;
}
// Return default value if no value is set anywhere
return definition.default;
}
/**
* Get all setting keys from the schema
*/
export function getAllSettingKeys(): string[] {
return Object.keys(getFlattenedSchema());
}
/**
* Get settings by type
*/
export function getSettingsByType(
type: SettingsType,
): Array<SettingDefinition & { key: string }> {
@@ -203,9 +190,6 @@ export function getSettingsByType(
);
}
/**
* Get settings that require restart
*/
export function getSettingsRequiringRestart(): Array<
SettingDefinition & {
key: string;
@@ -223,22 +207,22 @@ export function isValidSettingKey(key: string): boolean {
return key in getFlattenedSchema();
}
/**
* Get the category for a setting
*/
export function getSettingCategory(key: string): string | undefined {
return getFlattenedSchema()[key]?.category;
}
/**
* Check if a setting should be shown in the settings dialog
*/
export function shouldShowInDialog(key: string): boolean {
return getFlattenedSchema()[key]?.showInDialog ?? true; // Default to true for backward compatibility
}
export function getDialogSettingKeys(): string[] {
return Object.values(getFlattenedSchema())
.filter((definition) => definition.showInDialog !== false)
.map((definition) => definition.key);
}
/**
* Get all settings that should be shown in the dialog, grouped by category
* Get all settings that should be shown in the dialog, grouped by category like "Advanced", "General", etc.
*/
export function getDialogSettingsByCategory(): Record<
string,
@@ -262,9 +246,6 @@ export function getDialogSettingsByCategory(): Record<
return categories;
}
/**
* Get settings by type that should be shown in the dialog
*/
export function getDialogSettingsByType(
type: SettingsType,
): Array<SettingDefinition & { key: string }> {
@@ -274,197 +255,30 @@ export function getDialogSettingsByType(
);
}
/**
* Get all setting keys that should be shown in the dialog
*/
export function getDialogSettingKeys(): string[] {
return Object.values(getFlattenedSchema())
.filter((definition) => definition.showInDialog !== false)
.map((definition) => definition.key);
}
// ============================================================================
// BUSINESS LOGIC UTILITIES (Higher-level utilities for setting operations)
// ============================================================================
/**
* Get the current value for a setting in a specific scope
* Always returns a value (never undefined) - falls back to default if not set anywhere
*/
export function getSettingValue(
key: string,
settings: Settings,
mergedSettings: Settings,
): boolean {
const definition = getSettingDefinition(key);
if (!definition) {
return false; // Default fallback for invalid settings
}
const value = getEffectiveValue(key, settings, mergedSettings);
// Ensure we return a boolean value, converting from the more general type
if (typeof value === 'boolean') {
return value;
}
return false; // Final fallback
}
/**
* Check if a setting value is modified from its default
*/
export function isSettingModified(key: string, value: boolean): boolean {
const defaultValue = getDefaultValue(key);
// Handle type comparison properly
if (typeof defaultValue === 'boolean') {
return value !== defaultValue;
}
// If default is not a boolean, consider it modified if value is true
return value === true;
}
/**
* Check if a setting exists in the original settings file for a scope
*/
export function settingExistsInScope(
export function isInSettingsScope(
key: string,
scopeSettings: Settings,
): boolean {
const path = key.split('.');
const value = getNestedValue(scopeSettings as Record<string, unknown>, path);
const value = getNestedValue(scopeSettings, path);
return value !== undefined;
}
/**
* Recursively sets a value in a nested object using a key path array.
*/
function setNestedValue(
obj: Record<string, unknown>,
path: string[],
value: unknown,
): Record<string, unknown> {
const [first, ...rest] = path;
if (!first) {
return obj;
}
if (rest.length === 0) {
obj[first] = value;
return obj;
}
if (!obj[first] || typeof obj[first] !== 'object') {
obj[first] = {};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
setNestedValue(obj[first] as Record<string, unknown>, rest, value);
return obj;
}
/**
* Set a setting value in the pending settings
*/
export function setPendingSettingValue(
key: string,
value: boolean,
pendingSettings: Settings,
): Settings {
const path = key.split('.');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const newSettings = JSON.parse(JSON.stringify(pendingSettings));
setNestedValue(newSettings, path, value);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return newSettings;
}
/**
* Generic setter: Set a setting value (boolean, number, string, etc.) in the pending settings
*/
export function setPendingSettingValueAny(
key: string,
value: SettingsValue,
pendingSettings: Settings,
): Settings {
const path = key.split('.');
const newSettings = structuredClone(pendingSettings);
setNestedValue(newSettings, path, value);
return newSettings;
}
/**
* Check if any modified settings require a restart
*/
export function hasRestartRequiredSettings(
modifiedSettings: Set<string>,
): boolean {
return Array.from(modifiedSettings).some((key) => requiresRestart(key));
}
/**
* Get the restart required settings from a set of modified settings
*/
export function getRestartRequiredFromModified(
modifiedSettings: Set<string>,
): string[] {
return Array.from(modifiedSettings).filter((key) => requiresRestart(key));
}
/**
* Save modified settings to the appropriate scope
*/
export function saveModifiedSettings(
modifiedSettings: Set<string>,
pendingSettings: Settings,
loadedSettings: LoadedSettings,
scope: LoadableSettingScope,
): void {
modifiedSettings.forEach((settingKey) => {
const path = settingKey.split('.');
const value = getNestedValue(
pendingSettings as Record<string, unknown>,
path,
);
if (value === undefined) {
return;
}
const existsInOriginalFile = settingExistsInScope(
settingKey,
loadedSettings.forScope(scope).settings,
);
const isDefaultValue = value === getDefaultValue(settingKey);
if (existsInOriginalFile || !isDefaultValue) {
loadedSettings.setValue(scope, settingKey, value);
}
});
}
/**
* Get the display value for a setting, showing current scope value with default change indicator
* Appends a star (*) to settings that exist in the scope
*/
export function getDisplayValue(
key: string,
settings: Settings,
scopeSettings: Settings,
_mergedSettings: Settings,
modifiedSettings: Set<string>,
pendingSettings?: Settings,
): string {
// Prioritize pending changes if user has modified this setting
const definition = getSettingDefinition(key);
const existsInScope = isInSettingsScope(key, scopeSettings);
let value: SettingsValue;
if (pendingSettings && settingExistsInScope(key, pendingSettings)) {
// Show the value from the pending (unsaved) edits when it exists
value = getEffectiveValue(key, pendingSettings, {});
} else if (settingExistsInScope(key, settings)) {
// Show the value defined at the current scope if present
value = getEffectiveValue(key, settings, {});
if (existsInScope) {
value = getEffectiveValue(key, scopeSettings);
} else {
// Fall back to the schema default when the key is unset in this scope
value = getDefaultValue(key);
}
@@ -475,50 +289,108 @@ export function getDisplayValue(
valueString = option?.label ?? `${value}`;
}
// Check if value is different from default OR if it's in modified settings OR if there are pending changes
const defaultValue = getDefaultValue(key);
const isChangedFromDefault = value !== defaultValue;
const isInModifiedSettings = modifiedSettings.has(key);
// Mark as modified if setting exists in current scope OR is in modified settings
if (settingExistsInScope(key, settings) || isInModifiedSettings) {
return `${valueString}*`; // * indicates setting is set in current scope
}
if (isChangedFromDefault || isInModifiedSettings) {
return `${valueString}*`; // * indicates changed from default value
if (existsInScope) {
return `${valueString}*`;
}
return valueString;
}
/**
* Check if a setting doesn't exist in current scope (should be greyed out)
*/
export function isDefaultValue(key: string, settings: Settings): boolean {
return !settingExistsInScope(key, settings);
/**Utilities for parsing Settings that can be inline edited by the user typing out values */
function tryParseJsonStringArray(input: string): string[] | null {
try {
const parsed: unknown = JSON.parse(input);
if (
Array.isArray(parsed) &&
parsed.every((item): item is string => typeof item === 'string')
) {
return parsed;
}
return null;
} catch {
return null;
}
}
/**
* Check if a setting value is inherited (not set at current scope)
*/
export function isValueInherited(
key: string,
settings: Settings,
_mergedSettings: Settings,
): boolean {
return !settingExistsInScope(key, settings);
function tryParseJsonObject(input: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(input);
if (isRecord(parsed) && !Array.isArray(parsed)) {
return parsed;
}
return null;
} catch {
return null;
}
}
/**
* Get the effective value for display, considering inheritance
* Always returns a boolean value (never undefined)
*/
export function getEffectiveDisplayValue(
key: string,
settings: Settings,
mergedSettings: Settings,
): boolean {
return getSettingValue(key, settings, mergedSettings);
function parseStringArrayValue(input: string): string[] {
const trimmed = input.trim();
if (trimmed === '') return [];
return (
tryParseJsonStringArray(trimmed) ??
input
.split(',')
.map((p) => p.trim())
.filter((p) => p.length > 0)
);
}
function parseObjectValue(input: string): Record<string, unknown> | null {
const trimmed = input.trim();
if (trimmed === '') {
return null;
}
return tryParseJsonObject(trimmed);
}
export function parseEditedValue(
type: SettingsType,
newValue: string,
): SettingsValue | null {
if (type === 'number') {
if (newValue.trim() === '') {
return null;
}
const numParsed = Number(newValue.trim());
if (Number.isNaN(numParsed)) {
return null;
}
return numParsed;
}
if (type === 'array') {
return parseStringArrayValue(newValue);
}
if (type === 'object') {
return parseObjectValue(newValue);
}
return newValue;
}
export function getEditValue(
type: SettingsType,
rawValue: SettingsValue,
): string | undefined {
if (rawValue === undefined) {
return undefined;
}
if (type === 'array' && Array.isArray(rawValue)) {
return rawValue.join(', ');
}
if (type === 'object' && rawValue !== null && typeof rawValue === 'object') {
return JSON.stringify(rawValue);
}
return undefined;
}
export const TEST_ONLY = { clearFlattenedSchema };
@@ -439,6 +439,54 @@ auth:
});
});
it('should parse remote agent with Digest via raw value', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: digest-agent
agent_card_url: https://example.com/card
auth:
type: http
scheme: Digest
value: username="admin", response="abc123"
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'digest-agent',
auth: {
type: 'http',
scheme: 'Digest',
value: 'username="admin", response="abc123"',
},
});
});
it('should parse remote agent with generic raw auth value', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: raw-agent
agent_card_url: https://example.com/card
auth:
type: http
scheme: CustomScheme
value: raw-token-value
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'raw-agent',
auth: {
type: 'http',
scheme: 'CustomScheme',
value: 'raw-token-value',
},
});
});
it('should throw error for Bearer auth without token', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
+19 -5
View File
@@ -50,10 +50,11 @@ interface FrontmatterAuthConfig {
key?: string;
name?: string;
// HTTP
scheme?: 'Bearer' | 'Basic';
scheme?: string;
token?: string;
username?: string;
password?: string;
value?: string;
}
interface FrontmatterRemoteAgentDefinition
@@ -139,16 +140,21 @@ const apiKeyAuthSchema = z.object({
const httpAuthSchema = z.object({
...baseAuthFields,
type: z.literal('http'),
scheme: z.enum(['Bearer', 'Basic']),
scheme: z.string().min(1),
token: z.string().min(1).optional(),
username: z.string().min(1).optional(),
password: z.string().min(1).optional(),
value: z.string().min(1).optional(),
});
const authConfigSchema = z
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchema])
.superRefine((data, ctx) => {
if (data.type === 'http') {
if (data.value) {
// Raw mode - only scheme and value are needed
return;
}
if (data.scheme === 'Bearer' && !data.token) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -348,6 +354,14 @@ function convertFrontmatterAuthToConfig(
'Internal error: HTTP scheme missing after validation.',
);
}
if (frontmatter.value) {
return {
...base,
type: 'http',
scheme: frontmatter.scheme,
value: frontmatter.value,
};
}
switch (frontmatter.scheme) {
case 'Bearer':
if (!frontmatter.token) {
@@ -375,8 +389,8 @@ function convertFrontmatterAuthToConfig(
password: frontmatter.password,
};
default: {
const exhaustive: never = frontmatter.scheme;
throw new Error(`Unknown HTTP scheme: ${exhaustive}`);
// Other IANA schemes without a value should not reach here after validation
throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`);
}
}
}
@@ -417,7 +431,7 @@ export function markdownToAgentDefinition(
return {
kind: 'remote',
name: markdown.name,
description: markdown.description || '(Loading description...)',
description: markdown.description || '',
displayName: markdown.display_name,
agentCardUrl: markdown.agent_card_url,
auth: markdown.auth
@@ -11,6 +11,7 @@ import type {
AuthValidationResult,
} from './types.js';
import { ApiKeyAuthProvider } from './api-key-provider.js';
import { HttpAuthProvider } from './http-provider.js';
export interface CreateAuthProviderOptions {
/** Required for OAuth/OIDC token storage. */
@@ -50,9 +51,11 @@ export class A2AAuthProviderFactory {
return provider;
}
case 'http':
// TODO: Implement
throw new Error('http auth provider not yet implemented');
case 'http': {
const provider = new HttpAuthProvider(authConfig);
await provider.initialize();
return provider;
}
case 'oauth2':
// TODO: Implement
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpAuthProvider } from './http-provider.js';
describe('HttpAuthProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Bearer Authentication', () => {
it('should provide Bearer token header', async () => {
const config = {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'test-token',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();
const headers = await provider.headers();
expect(headers).toEqual({ Authorization: 'Bearer test-token' });
});
it('should resolve token from environment variable', async () => {
process.env['TEST_TOKEN'] = 'env-token';
const config = {
type: 'http' as const,
scheme: 'Bearer' as const,
token: '$TEST_TOKEN',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();
const headers = await provider.headers();
expect(headers).toEqual({ Authorization: 'Bearer env-token' });
delete process.env['TEST_TOKEN'];
});
});
describe('Basic Authentication', () => {
it('should provide Basic auth header', async () => {
const config = {
type: 'http' as const,
scheme: 'Basic' as const,
username: 'user',
password: 'password',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();
const headers = await provider.headers();
const expected = Buffer.from('user:password').toString('base64');
expect(headers).toEqual({ Authorization: `Basic ${expected}` });
});
});
describe('Generic/Raw Authentication', () => {
it('should provide custom scheme with raw value', async () => {
const config = {
type: 'http' as const,
scheme: 'CustomScheme',
value: 'raw-value-here',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();
const headers = await provider.headers();
expect(headers).toEqual({ Authorization: 'CustomScheme raw-value-here' });
});
it('should support Digest via raw value', async () => {
const config = {
type: 'http' as const,
scheme: 'Digest',
value: 'username="foo", response="bar"',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();
const headers = await provider.headers();
expect(headers).toEqual({
Authorization: 'Digest username="foo", response="bar"',
});
});
});
describe('Retry logic', () => {
it('should re-initialize on 401 for Bearer', async () => {
const config = {
type: 'http' as const,
scheme: 'Bearer' as const,
token: '$DYNAMIC_TOKEN',
};
process.env['DYNAMIC_TOKEN'] = 'first';
const provider = new HttpAuthProvider(config);
await provider.initialize();
process.env['DYNAMIC_TOKEN'] = 'second';
const mockResponse = { status: 401 } as Response;
const retryHeaders = await provider.shouldRetryWithHeaders(
{},
mockResponse,
);
expect(retryHeaders).toEqual({ Authorization: 'Bearer second' });
delete process.env['DYNAMIC_TOKEN'];
});
it('should stop after max retries', async () => {
const config = {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'token',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();
const mockResponse = { status: 401 } as Response;
// MAX_AUTH_RETRIES is 2
await provider.shouldRetryWithHeaders({}, mockResponse);
await provider.shouldRetryWithHeaders({}, mockResponse);
const third = await provider.shouldRetryWithHeaders({}, mockResponse);
expect(third).toBeUndefined();
});
});
});
@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { HttpHeaders } from '@a2a-js/sdk/client';
import { BaseA2AAuthProvider } from './base-provider.js';
import type { HttpAuthConfig } from './types.js';
import { resolveAuthValue } from './value-resolver.js';
import { debugLogger } from '../../utils/debugLogger.js';
/**
* Authentication provider for HTTP authentication schemes.
* Supports Bearer, Basic, and any IANA-registered scheme via raw value.
*/
export class HttpAuthProvider extends BaseA2AAuthProvider {
readonly type = 'http' as const;
private resolvedToken?: string;
private resolvedUsername?: string;
private resolvedPassword?: string;
private resolvedValue?: string;
constructor(private readonly config: HttpAuthConfig) {
super();
}
override async initialize(): Promise<void> {
const config = this.config;
if ('token' in config) {
this.resolvedToken = await resolveAuthValue(config.token);
} else if ('username' in config) {
this.resolvedUsername = await resolveAuthValue(config.username);
this.resolvedPassword = await resolveAuthValue(config.password);
} else {
// Generic raw value for any other IANA-registered scheme
this.resolvedValue = await resolveAuthValue(config.value);
}
debugLogger.debug(
`[HttpAuthProvider] Initialized with scheme: ${this.config.scheme}`,
);
}
override async headers(): Promise<HttpHeaders> {
const config = this.config;
if ('token' in config) {
if (!this.resolvedToken)
throw new Error('HttpAuthProvider not initialized');
return { Authorization: `Bearer ${this.resolvedToken}` };
}
if ('username' in config) {
if (!this.resolvedUsername || !this.resolvedPassword) {
throw new Error('HttpAuthProvider not initialized');
}
const credentials = Buffer.from(
`${this.resolvedUsername}:${this.resolvedPassword}`,
).toString('base64');
return { Authorization: `Basic ${credentials}` };
}
// Generic raw value for any other IANA-registered scheme
if (!this.resolvedValue)
throw new Error('HttpAuthProvider not initialized');
return { Authorization: `${config.scheme} ${this.resolvedValue}` };
}
/**
* Re-resolves credentials on auth failure (e.g. rotated tokens via $ENV or !command).
* Respects MAX_AUTH_RETRIES from the base class to prevent infinite loops.
*/
override async shouldRetryWithHeaders(
req: RequestInit,
res: Response,
): Promise<HttpHeaders | undefined> {
if (res.status === 401 || res.status === 403) {
if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) {
return undefined;
}
debugLogger.debug(
'[HttpAuthProvider] Re-resolving values after auth failure',
);
await this.initialize();
}
return super.shouldRetryWithHeaders(req, res);
}
}
@@ -60,6 +60,12 @@ export type HttpAuthConfig = BaseAuthConfig & {
/** For Basic. Supports $ENV_VAR, !command, or literal. */
password: string;
}
| {
/** Any IANA-registered scheme (e.g., "Digest", "HOBA", "Custom"). */
scheme: string;
/** Raw value to be sent as "Authorization: <scheme> <value>". Supports $ENV_VAR, !command, or literal. */
value: string;
}
);
/** Client config corresponding to OAuth2SecurityScheme. */
@@ -16,8 +16,11 @@
import type { Config } from '../../config/config.js';
import { LocalAgentExecutor } from '../local-executor.js';
import type { AnsiOutput } from '../../utils/terminalSerializer.js';
import { BaseToolInvocation, type ToolResult } from '../../tools/tools.js';
import {
BaseToolInvocation,
type ToolResult,
type ToolLiveOutput,
} from '../../tools/tools.js';
import { ToolErrorType } from '../../tools/tool-error.js';
import type { AgentInputs, SubagentActivityEvent } from '../types.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
@@ -82,7 +85,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
*/
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
let browserManager;
+22 -15
View File
@@ -501,7 +501,7 @@ describe('LocalAgentExecutor', () => {
expect(agentRegistry.getTool(subAgentName)).toBeUndefined();
});
it('should enforce qualified names for MCP tools in agent definitions', async () => {
it('should automatically qualify MCP tools in agent definitions', async () => {
const serverName = 'mcp-server';
const toolName = 'mcp-tool';
const qualifiedName = `${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}`;
@@ -530,7 +530,7 @@ describe('LocalAgentExecutor', () => {
return undefined;
});
// 1. Qualified name works and registers the tool (using short name per status quo)
// 1. Qualified name works and registers the tool (using qualified name)
const definition = createTestDefinition([qualifiedName]);
const executor = await LocalAgentExecutor.create(
definition,
@@ -539,14 +539,18 @@ describe('LocalAgentExecutor', () => {
);
const agentRegistry = executor['toolRegistry'];
// Registry shortening logic means it's registered as 'mcp-tool' internally
expect(agentRegistry.getTool(toolName)).toBeDefined();
// It should be registered as the qualified name
expect(agentRegistry.getTool(qualifiedName)).toBeDefined();
// 2. Unqualified name for MCP tool THROWS
const badDefinition = createTestDefinition([toolName]);
await expect(
LocalAgentExecutor.create(badDefinition, mockConfig, onActivity),
).rejects.toThrow(/must be requested with its server prefix/);
// 2. Unqualified name for MCP tool now also works (and gets upgraded to qualified)
const definition2 = createTestDefinition([toolName]);
const executor2 = await LocalAgentExecutor.create(
definition2,
mockConfig,
onActivity,
);
const agentRegistry2 = executor2['toolRegistry'];
expect(agentRegistry2.getTool(qualifiedName)).toBeDefined();
getToolSpy.mockRestore();
});
@@ -711,25 +715,28 @@ describe('LocalAgentExecutor', () => {
expect.arrayContaining([
expect.objectContaining({
type: 'THOUGHT_CHUNK',
data: { text: 'T1: Listing' },
data: expect.objectContaining({ text: 'T1: Listing' }),
}),
expect.objectContaining({
type: 'TOOL_CALL_END',
data: { name: LS_TOOL_NAME, output: 'file1.txt' },
data: expect.objectContaining({
name: LS_TOOL_NAME,
output: 'file1.txt',
}),
}),
expect.objectContaining({
type: 'TOOL_CALL_START',
data: {
data: expect.objectContaining({
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'Found file1.txt' },
},
}),
}),
expect.objectContaining({
type: 'TOOL_CALL_END',
data: {
data: expect.objectContaining({
name: TASK_COMPLETE_TOOL_NAME,
output: expect.stringContaining('Output submitted'),
},
}),
}),
]),
);
+39 -14
View File
@@ -16,10 +16,7 @@ import type {
Schema,
} from '@google/genai';
import { ToolRegistry } from '../tools/tool-registry.js';
import {
DiscoveredMCPTool,
MCP_QUALIFIED_NAME_SEPARATOR,
} from '../tools/mcp-tool.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { CompressionStatus } from '../core/turn.js';
import { type ToolCallRequestInfo } from '../scheduler/types.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
@@ -142,15 +139,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
// registry and register it with the agent's isolated registry.
const tool = parentToolRegistry.getTool(toolName);
if (tool) {
if (
tool instanceof DiscoveredMCPTool &&
!toolName.includes(MCP_QUALIFIED_NAME_SEPARATOR)
) {
throw new Error(
`MCP tool '${toolName}' must be requested with its server prefix (e.g., '${tool.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}') in agent '${definition.name}'.`,
);
if (tool instanceof DiscoveredMCPTool) {
// Subagents MUST use fully qualified names for MCP tools to ensure
// unambiguous tool calls and to comply with policy requirements.
// We automatically "upgrade" any MCP tool to its qualified version.
agentToolRegistry.registerTool(tool.asFullyQualifiedTool());
} else {
agentToolRegistry.registerTool(tool);
}
agentToolRegistry.registerTool(tool);
}
};
@@ -269,13 +265,22 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
};
}
const { nextMessage, submittedOutput, taskCompleted } =
const { nextMessage, submittedOutput, taskCompleted, aborted } =
await this.processFunctionCalls(
functionCalls,
combinedSignal,
promptId,
onWaitingForConfirmation,
);
if (aborted) {
return {
status: 'stop',
terminateReason: AgentTerminateMode.ABORTED,
finalResult: null,
};
}
if (taskCompleted) {
const finalResult = submittedOutput ?? 'Task completed successfully.';
return {
@@ -857,6 +862,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
nextMessage: Content;
submittedOutput: string | null;
taskCompleted: boolean;
aborted: boolean;
}> {
const allowedToolNames = new Set(this.toolRegistry.getAllToolNames());
// Always allow the completion tool
@@ -864,6 +870,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
let submittedOutput: string | null = null;
let taskCompleted = false;
let aborted = false;
// We'll separate complete_task from other tools
const toolRequests: ToolCallRequestInfo[] = [];
@@ -878,8 +885,24 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const toolName = functionCall.name as string;
let displayName = toolName;
let description: string | undefined = undefined;
try {
const tool = this.toolRegistry.getTool(toolName);
if (tool) {
displayName = tool.displayName ?? toolName;
const invocation = tool.build(args);
description = invocation.getDescription();
}
} catch {
// Ignore errors during formatting for activity emission
}
this.emitActivity('TOOL_CALL_START', {
name: toolName,
displayName,
description,
args,
});
@@ -1077,8 +1100,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.emitActivity('ERROR', {
context: 'tool_call',
name: toolName,
error: 'Tool call was cancelled.',
error: 'Request cancelled.',
});
aborted = true;
}
// Add result to syncResults to preserve order later
@@ -1111,6 +1135,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
nextMessage: { role: 'user', parts: toolResponseParts },
submittedOutput,
taskCompleted,
aborted,
};
}
@@ -4,17 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
} from 'vitest';
import type {
LocalAgentDefinition,
SubagentActivityEvent,
AgentInputs,
SubagentProgress,
} from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { LocalAgentExecutor } from './local-executor.js';
import { AgentTerminateMode } from './types.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { ToolErrorType } from '../tools/tool-error.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { type z } from 'zod';
@@ -29,6 +37,7 @@ let mockConfig: Config;
const testDefinition: LocalAgentDefinition<z.ZodUnknown> = {
kind: 'local',
name: 'MockAgent',
displayName: 'Mock Agent',
description: 'A mock agent.',
inputConfig: {
inputSchema: {
@@ -70,6 +79,10 @@ describe('LocalSubagentInvocation', () => {
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should pass the messageBus to the parent constructor', () => {
const params = { task: 'Analyze data' };
const invocation = new LocalSubagentInvocation(
@@ -173,7 +186,12 @@ describe('LocalSubagentInvocation', () => {
mockConfig,
expect.any(Function),
);
expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n');
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
agentName: 'MockAgent',
}),
);
expect(mockExecutorInstance.run).toHaveBeenCalledWith(params, signal);
@@ -211,13 +229,17 @@ describe('LocalSubagentInvocation', () => {
await invocation.execute(signal, updateOutput);
expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n');
expect(updateOutput).toHaveBeenCalledWith('🤖💭 Analyzing...');
expect(updateOutput).toHaveBeenCalledWith('🤖💭 Still thinking.');
expect(updateOutput).toHaveBeenCalledTimes(3); // Initial message + 2 thoughts
expect(updateOutput).toHaveBeenCalledTimes(3); // Initial + 2 updates
const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress;
expect(lastCall.recentActivity).toContainEqual(
expect.objectContaining({
type: 'thought',
content: 'Analyzing... Still thinking.',
}),
);
});
it('should NOT stream other activities (e.g., TOOL_CALL_START, ERROR)', async () => {
it('should stream other activities (e.g., TOOL_CALL_START, ERROR)', async () => {
mockExecutorInstance.run.mockImplementation(async () => {
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
@@ -226,7 +248,7 @@ describe('LocalSubagentInvocation', () => {
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'ls' },
data: { name: 'ls', args: {} },
} as SubagentActivityEvent);
onActivity({
isSubagentActivityEvent: true,
@@ -240,9 +262,15 @@ describe('LocalSubagentInvocation', () => {
await invocation.execute(signal, updateOutput);
// Should only contain the initial "Subagent starting..." message
expect(updateOutput).toHaveBeenCalledTimes(1);
expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n');
expect(updateOutput).toHaveBeenCalledTimes(3);
const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress;
expect(lastCall.recentActivity).toContainEqual(
expect.objectContaining({
type: 'thought',
content: 'Error: Failed',
status: 'error',
}),
);
});
it('should run successfully without an updateOutput callback', async () => {
@@ -272,16 +300,19 @@ describe('LocalSubagentInvocation', () => {
const result = await invocation.execute(signal, updateOutput);
expect(result.error).toEqual({
message: error.message,
type: ToolErrorType.EXECUTION_FAILED,
});
expect(result.returnDisplay).toBe(
`Subagent Failed: MockAgent\nError: ${error.message}`,
);
expect(result.error).toBeUndefined();
expect(result.llmContent).toBe(
`Subagent 'MockAgent' failed. Error: ${error.message}`,
);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.recentActivity).toContainEqual(
expect.objectContaining({
type: 'thought',
content: `Error: ${error.message}`,
status: 'error',
}),
);
});
it('should handle executor creation failure', async () => {
@@ -291,19 +322,21 @@ describe('LocalSubagentInvocation', () => {
const result = await invocation.execute(signal, updateOutput);
expect(mockExecutorInstance.run).not.toHaveBeenCalled();
expect(result.error).toEqual({
message: creationError.message,
type: ToolErrorType.EXECUTION_FAILED,
});
expect(result.returnDisplay).toContain(`Error: ${creationError.message}`);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain(creationError.message);
const display = result.returnDisplay as SubagentProgress;
expect(display.recentActivity).toContainEqual(
expect.objectContaining({
content: `Error: ${creationError.message}`,
status: 'error',
}),
);
});
/**
* This test verifies that the AbortSignal is correctly propagated and
* that a rejection from the executor due to abortion is handled gracefully.
*/
it('should handle abortion signal during execution', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
mockExecutorInstance.run.mockRejectedValue(abortError);
const controller = new AbortController();
@@ -312,14 +345,24 @@ describe('LocalSubagentInvocation', () => {
updateOutput,
);
controller.abort();
const result = await executePromise;
await expect(executePromise).rejects.toThrow('Aborted');
expect(mockExecutorInstance.run).toHaveBeenCalledWith(
params,
controller.signal,
);
expect(result.error?.message).toBe('Aborted');
expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED);
});
it('should throw an error and bubble cancellation when execution returns ABORTED', async () => {
const mockOutput = {
result: 'Cancelled by user',
terminate_reason: AgentTerminateMode.ABORTED,
};
mockExecutorInstance.run.mockResolvedValue(mockOutput);
await expect(invocation.execute(signal, updateOutput)).rejects.toThrow(
'Operation cancelled by user',
);
});
});
});
+197 -19
View File
@@ -6,18 +6,25 @@
import type { Config } from '../config/config.js';
import { LocalAgentExecutor } from './local-executor.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { BaseToolInvocation, type ToolResult } from '../tools/tools.js';
import { ToolErrorType } from '../tools/tool-error.js';
import type {
LocalAgentDefinition,
AgentInputs,
SubagentActivityEvent,
import {
BaseToolInvocation,
type ToolResult,
type ToolLiveOutput,
} from '../tools/tools.js';
import {
type LocalAgentDefinition,
type AgentInputs,
type SubagentActivityEvent,
type SubagentProgress,
type SubagentActivityItem,
AgentTerminateMode,
} from './types.js';
import { randomUUID } from 'node:crypto';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
const INPUT_PREVIEW_MAX_LENGTH = 50;
const DESCRIPTION_MAX_LENGTH = 200;
const MAX_RECENT_ACTIVITY = 3;
/**
* Represents a validated, executable instance of a subagent tool.
@@ -81,11 +88,20 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
*/
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
let recentActivity: SubagentActivityItem[] = [];
try {
if (updateOutput) {
updateOutput('Subagent starting...\n');
// Send initial state
const initialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [],
state: 'running',
};
updateOutput(initialProgress);
}
// Create an activity callback to bridge the executor's events to the
@@ -93,11 +109,114 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
const onActivity = (activity: SubagentActivityEvent): void => {
if (!updateOutput) return;
if (
activity.type === 'THOUGHT_CHUNK' &&
typeof activity.data['text'] === 'string'
) {
updateOutput(`🤖💭 ${activity.data['text']}`);
let updated = false;
switch (activity.type) {
case 'THOUGHT_CHUNK': {
const text = String(activity.data['text']);
const lastItem = recentActivity[recentActivity.length - 1];
if (
lastItem &&
lastItem.type === 'thought' &&
lastItem.status === 'running'
) {
lastItem.content += text;
} else {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: text,
status: 'running',
});
}
updated = true;
break;
}
case 'TOOL_CALL_START': {
const name = String(activity.data['name']);
const displayName = activity.data['displayName']
? String(activity.data['displayName'])
: undefined;
const description = activity.data['description']
? String(activity.data['description'])
: undefined;
const args = JSON.stringify(activity.data['args']);
recentActivity.push({
id: randomUUID(),
type: 'tool_call',
content: name,
displayName,
description,
args,
status: 'running',
});
updated = true;
break;
}
case 'TOOL_CALL_END': {
const name = String(activity.data['name']);
// Find the last running tool call with this name
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === name &&
recentActivity[i].status === 'running'
) {
recentActivity[i].status = 'completed';
updated = true;
break;
}
}
break;
}
case 'ERROR': {
const error = String(activity.data['error']);
const isCancellation = error === 'Request cancelled.';
const toolName = activity.data['name']
? String(activity.data['name'])
: undefined;
if (toolName && isCancellation) {
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === toolName &&
recentActivity[i].status === 'running'
) {
recentActivity[i].status = 'cancelled';
updated = true;
break;
}
}
}
recentActivity.push({
id: randomUUID(),
type: 'thought', // Treat errors as thoughts for now, or add an error type
content: isCancellation ? error : `Error: ${error}`,
status: isCancellation ? 'cancelled' : 'error',
});
updated = true;
break;
}
default:
break;
}
if (updated) {
// Keep only the last N items
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity], // Copy to avoid mutation issues
state: 'running',
};
updateOutput(progress);
}
};
@@ -109,6 +228,23 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
const output = await executor.run(this.params, signal);
if (output.terminate_reason === AgentTerminateMode.ABORTED) {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: 'cancelled',
};
if (updateOutput) {
updateOutput(progress);
}
const cancelError = new Error('Operation cancelled by user');
cancelError.name = 'AbortError';
throw cancelError;
}
const resultContent = `Subagent '${this.definition.name}' finished.
Termination Reason: ${output.terminate_reason}
Result:
@@ -131,13 +267,55 @@ ${output.result}
const errorMessage =
error instanceof Error ? error.message : String(error);
const isAbort =
(error instanceof Error && error.name === 'AbortError') ||
errorMessage.includes('Aborted');
// Mark any running items as error/cancelled
for (const item of recentActivity) {
if (item.status === 'running') {
item.status = isAbort ? 'cancelled' : 'error';
}
}
// Ensure the error is reflected in the recent activity for display
// But only if it's NOT an abort, or if we want to show "Cancelled" as a thought
if (!isAbort) {
const lastActivity = recentActivity[recentActivity.length - 1];
if (!lastActivity || lastActivity.status !== 'error') {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: `Error: ${errorMessage}`,
status: 'error',
});
// Maintain size limit
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
}
}
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: isAbort ? 'cancelled' : 'error',
};
if (updateOutput) {
updateOutput(progress);
}
if (isAbort) {
throw error;
}
return {
llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`,
returnDisplay: `Subagent Failed: ${this.definition.name}\nError: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.EXECUTION_FAILED,
},
returnDisplay: progress,
// We omit the 'error' property so that the UI renders our rich returnDisplay
// instead of the raw error message. The llmContent still informs the agent of the failure.
};
}
}
+291
View File
@@ -30,6 +30,8 @@ import type { ToolRegistry } from '../tools/tool-registry.js';
import { ThinkingLevel } from '@google/genai';
import type { AcknowledgedAgentsService } from './acknowledgedAgents.js';
import { PolicyDecision } from '../policy/types.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import type { A2AAuthProvider } from './auth-provider/types.js';
vi.mock('./agentLoader.js', () => ({
loadAgentsFromDirectory: vi
@@ -43,6 +45,12 @@ vi.mock('./a2a-client-manager.js', () => ({
},
}));
vi.mock('./auth-provider/factory.js', () => ({
A2AAuthProviderFactory: {
create: vi.fn(),
},
}));
function makeMockedConfig(params?: Partial<ConfigParameters>): Config {
const config = makeFakeConfig(params);
vi.spyOn(config, 'getToolRegistry').mockReturnValue({
@@ -546,6 +554,90 @@ describe('AgentRegistry', () => {
expect(registry.getDefinition('RemoteAgent')).toEqual(remoteAgent);
});
it('should register a remote agent with authentication configuration', async () => {
const mockAuth = {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'secret-token',
};
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'RemoteAgentWithAuth',
description: 'A remote agent',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
auth: mockAuth,
};
const mockHandler = {
type: 'http' as const,
headers: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer secret-token' }),
shouldRetryWithHeaders: vi.fn(),
} as unknown as A2AAuthProvider;
vi.mocked(A2AAuthProviderFactory.create).mockResolvedValue(mockHandler);
const loadAgentSpy = vi
.fn()
.mockResolvedValue({ name: 'RemoteAgentWithAuth' });
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: loadAgentSpy,
clearCache: vi.fn(),
} as unknown as A2AClientManager);
await registry.testRegisterAgent(remoteAgent);
expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith({
authConfig: mockAuth,
agentName: 'RemoteAgentWithAuth',
});
expect(loadAgentSpy).toHaveBeenCalledWith(
'RemoteAgentWithAuth',
'https://example.com/card',
mockHandler,
);
expect(registry.getDefinition('RemoteAgentWithAuth')).toEqual(
remoteAgent,
);
});
it('should not register remote agent when auth provider factory returns undefined', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'RemoteAgentBadAuth',
description: 'A remote agent',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
auth: {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'secret-token',
},
};
vi.mocked(A2AAuthProviderFactory.create).mockResolvedValue(undefined);
const loadAgentSpy = vi.fn();
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: loadAgentSpy,
clearCache: vi.fn(),
} as unknown as A2AClientManager);
const warnSpy = vi
.spyOn(debugLogger, 'warn')
.mockImplementation(() => {});
await registry.testRegisterAgent(remoteAgent);
expect(loadAgentSpy).not.toHaveBeenCalled();
expect(registry.getDefinition('RemoteAgentBadAuth')).toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Error loading A2A agent'),
expect.any(Error),
);
warnSpy.mockRestore();
});
it('should log remote agent registration in debug mode', async () => {
const debugConfig = makeMockedConfig({ debugMode: true });
const debugRegistry = new TestableAgentRegistry(debugConfig);
@@ -572,6 +664,205 @@ describe('AgentRegistry', () => {
);
});
it('should surface an error if remote agent registration fails', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'FailingRemoteAgent',
description: 'A remote agent',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
const error = new Error('401 Unauthorized');
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockRejectedValue(error),
} as unknown as A2AClientManager);
const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
await registry.testRegisterAgent(remoteAgent);
expect(feedbackSpy).toHaveBeenCalledWith(
'error',
`Error loading A2A agent "FailingRemoteAgent": 401 Unauthorized`,
);
});
it('should merge user and agent description and skills when registering a remote agent', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'RemoteAgentWithDescription',
description: 'User-provided description',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
const mockAgentCard = {
name: 'RemoteAgentWithDescription',
description: 'Card-provided description',
skills: [
{ name: 'Skill1', description: 'Desc1' },
{ name: 'Skill2', description: 'Desc2' },
],
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockResolvedValue(mockAgentCard),
clearCache: vi.fn(),
} as unknown as A2AClientManager);
await registry.testRegisterAgent(remoteAgent);
const registered = registry.getDefinition('RemoteAgentWithDescription');
expect(registered?.description).toBe(
'User Description: User-provided description\nAgent Description: Card-provided description\nSkills:\nSkill1: Desc1\nSkill2: Desc2',
);
});
it('should include skills when agent description is empty', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'RemoteAgentWithSkillsOnly',
description: 'User-provided description',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
const mockAgentCard = {
name: 'RemoteAgentWithSkillsOnly',
description: '',
skills: [{ name: 'Skill1', description: 'Desc1' }],
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockResolvedValue(mockAgentCard),
clearCache: vi.fn(),
} as unknown as A2AClientManager);
await registry.testRegisterAgent(remoteAgent);
const registered = registry.getDefinition('RemoteAgentWithSkillsOnly');
expect(registered?.description).toBe(
'User Description: User-provided description\nSkills:\nSkill1: Desc1',
);
});
it('should handle empty user or agent descriptions and no skills during merging', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'RemoteAgentWithEmptyAgentDescription',
description: 'User-provided description',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
const mockAgentCard = {
name: 'RemoteAgentWithEmptyAgentDescription',
description: '', // Empty agent description
skills: [],
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockResolvedValue(mockAgentCard),
clearCache: vi.fn(),
} as unknown as A2AClientManager);
await registry.testRegisterAgent(remoteAgent);
const registered = registry.getDefinition(
'RemoteAgentWithEmptyAgentDescription',
);
// Should only contain user description
expect(registered?.description).toBe(
'User Description: User-provided description',
);
});
it('should not accumulate descriptions on repeated registration', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'RemoteAgentAccumulationTest',
description: 'User-provided description',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
const mockAgentCard = {
name: 'RemoteAgentAccumulationTest',
description: 'Card-provided description',
skills: [{ name: 'Skill1', description: 'Desc1' }],
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockResolvedValue(mockAgentCard),
clearCache: vi.fn(),
} as unknown as A2AClientManager);
// Register first time
await registry.testRegisterAgent(remoteAgent);
let registered = registry.getDefinition('RemoteAgentAccumulationTest');
const firstDescription = registered?.description;
expect(firstDescription).toBe(
'User Description: User-provided description\nAgent Description: Card-provided description\nSkills:\nSkill1: Desc1',
);
// Register second time with the SAME object
await registry.testRegisterAgent(remoteAgent);
registered = registry.getDefinition('RemoteAgentAccumulationTest');
expect(registered?.description).toBe(firstDescription);
});
it('should allow registering a remote agent with an empty initial description', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'EmptyDescAgent',
description: '', // Empty initial description
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockResolvedValue({
name: 'EmptyDescAgent',
description: 'Loaded from card',
}),
clearCache: vi.fn(),
} as unknown as A2AClientManager);
await registry.testRegisterAgent(remoteAgent);
const registered = registry.getDefinition('EmptyDescAgent');
expect(registered?.description).toBe(
'Agent Description: Loaded from card',
);
});
it('should provide fallback for skill descriptions if missing in the card', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'SkillFallbackAgent',
description: 'User description',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockResolvedValue({
name: 'SkillFallbackAgent',
description: 'Card description',
skills: [{ name: 'SkillNoDesc' }], // Missing description
}),
clearCache: vi.fn(),
} as unknown as A2AClientManager);
await registry.testRegisterAgent(remoteAgent);
const registered = registry.getDefinition('SkillFallbackAgent');
expect(registered?.description).toContain(
'SkillNoDesc: No description provided',
);
});
it('should handle special characters in agent names', async () => {
const specialAgent = {
...MOCK_AGENT_V1,
+92 -16
View File
@@ -14,7 +14,8 @@ import { CliHelpAgent } from './cli-help-agent.js';
import { GeneralistAgent } from './generalist-agent.js';
import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js';
import { A2AClientManager } from './a2a-client-manager.js';
import { ADCHandler } from './remote-invocation.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { type z } from 'zod';
import { debugLogger } from '../utils/debugLogger.js';
import { isAutoModel } from '../config/models.js';
@@ -118,7 +119,20 @@ export class AgentRegistry {
coreEvents.emitFeedback('error', `Agent loading error: ${error.message}`);
}
await Promise.allSettled(
userAgents.agents.map((agent) => this.registerAgent(agent)),
userAgents.agents.map(async (agent) => {
try {
await this.registerAgent(agent);
} catch (e) {
debugLogger.warn(
`[AgentRegistry] Error registering user agent "${agent.name}":`,
e,
);
coreEvents.emitFeedback(
'error',
`Error registering user agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
);
}
}),
);
// Load project-level agents: .gemini/agents/ (relative to Project Root)
@@ -173,7 +187,20 @@ export class AgentRegistry {
}
await Promise.allSettled(
agentsToRegister.map((agent) => this.registerAgent(agent)),
agentsToRegister.map(async (agent) => {
try {
await this.registerAgent(agent);
} catch (e) {
debugLogger.warn(
`[AgentRegistry] Error registering project agent "${agent.name}":`,
e,
);
coreEvents.emitFeedback(
'error',
`Error registering project agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
);
}
}),
);
} else {
coreEvents.emitFeedback(
@@ -186,7 +213,20 @@ export class AgentRegistry {
for (const extension of this.config.getExtensions()) {
if (extension.isActive && extension.agents) {
await Promise.allSettled(
extension.agents.map((agent) => this.registerAgent(agent)),
extension.agents.map(async (agent) => {
try {
await this.registerAgent(agent);
} catch (e) {
debugLogger.warn(
`[AgentRegistry] Error registering extension agent "${agent.name}":`,
e,
);
coreEvents.emitFeedback(
'error',
`Error registering extension agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
);
}
}),
);
}
}
@@ -335,9 +375,10 @@ export class AgentRegistry {
}
// Basic validation
if (!definition.name || !definition.description) {
// Remote agents can have an empty description initially as it will be populated from the AgentCard
if (!definition.name) {
debugLogger.warn(
`[AgentRegistry] Skipping invalid agent definition. Missing name or description.`,
`[AgentRegistry] Skipping invalid agent definition. Missing name.`,
);
return;
}
@@ -360,24 +401,60 @@ export class AgentRegistry {
debugLogger.log(`[AgentRegistry] Overriding agent '${definition.name}'`);
}
const remoteDef = definition;
// Capture the original description from the first registration
if (remoteDef.originalDescription === undefined) {
remoteDef.originalDescription = remoteDef.description;
}
// Log remote A2A agent registration for visibility.
try {
const clientManager = A2AClientManager.getInstance();
// Use ADCHandler to ensure we can load agents hosted on secure platforms (e.g. Vertex AI)
const authHandler = new ADCHandler();
let authHandler: AuthenticationHandler | undefined;
if (definition.auth) {
const provider = await A2AAuthProviderFactory.create({
authConfig: definition.auth,
agentName: definition.name,
});
if (!provider) {
throw new Error(
`Failed to create auth provider for agent '${definition.name}'`,
);
}
authHandler = provider;
}
const agentCard = await clientManager.loadAgent(
definition.name,
definition.agentCardUrl,
remoteDef.name,
remoteDef.agentCardUrl,
authHandler,
);
const userDescription = remoteDef.originalDescription;
const agentDescription = agentCard.description;
const descriptions: string[] = [];
if (userDescription?.trim()) {
descriptions.push(`User Description: ${userDescription.trim()}`);
}
if (agentDescription?.trim()) {
descriptions.push(`Agent Description: ${agentDescription.trim()}`);
}
if (agentCard.skills && agentCard.skills.length > 0) {
definition.description = agentCard.skills
const skillsList = agentCard.skills
.map(
(skill: { name: string; description: string }) =>
`${skill.name}: ${skill.description}`,
`${skill.name}: ${skill.description || 'No description provided'}`,
)
.join('\n');
descriptions.push(`Skills:\n${skillsList}`);
}
if (descriptions.length > 0) {
definition.description = descriptions.join('\n');
}
if (this.config.getDebugMode()) {
debugLogger.log(
`[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl}`,
@@ -386,10 +463,9 @@ export class AgentRegistry {
this.agents.set(definition.name, definition);
this.addAgentPolicy(definition);
} catch (e) {
debugLogger.warn(
`[AgentRegistry] Error loading A2A agent "${definition.name}":`,
e,
);
const errorMessage = `Error loading A2A agent "${definition.name}": ${e instanceof Error ? e.message : String(e)}`;
debugLogger.warn(`[AgentRegistry] ${errorMessage}`, e);
coreEvents.emitFeedback('error', errorMessage);
}
}
@@ -20,14 +20,22 @@ import {
} from './a2a-client-manager.js';
import type { RemoteAgentDefinition } from './types.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import type { A2AAuthProvider } from './auth-provider/types.js';
// Mock A2AClientManager
vi.mock('./a2a-client-manager.js', () => {
const A2AClientManager = {
vi.mock('./a2a-client-manager.js', () => ({
A2AClientManager: {
getInstance: vi.fn(),
};
return { A2AClientManager };
});
},
}));
// Mock A2AAuthProviderFactory
vi.mock('./auth-provider/factory.js', () => ({
A2AAuthProviderFactory: {
create: vi.fn(),
},
}));
describe('RemoteAgentInvocation', () => {
const mockDefinition: RemoteAgentDefinition = {
@@ -118,7 +126,7 @@ describe('RemoteAgentInvocation', () => {
});
describe('Execution Logic', () => {
it('should lazy load the agent with ADCHandler if not present', async () => {
it('should lazy load the agent without auth handler when no auth configured', async () => {
mockClientManager.getClient.mockReturnValue(undefined);
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
@@ -143,10 +151,80 @@ describe('RemoteAgentInvocation', () => {
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'test-agent',
'http://test-agent/card',
expect.objectContaining({
headers: expect.any(Function),
shouldRetryWithHeaders: expect.any(Function),
}),
undefined,
);
});
it('should use A2AAuthProviderFactory when auth is present in definition', async () => {
const mockAuth = {
type: 'http' as const,
scheme: 'Basic' as const,
username: 'admin',
password: 'password',
};
const authDefinition: RemoteAgentDefinition = {
...mockDefinition,
auth: mockAuth,
};
const mockHandler = {
type: 'http' as const,
headers: vi.fn().mockResolvedValue({ Authorization: 'Basic dGVzdA==' }),
shouldRetryWithHeaders: vi.fn(),
} as unknown as A2AAuthProvider;
(A2AAuthProviderFactory.create as Mock).mockResolvedValue(mockHandler);
mockClientManager.getClient.mockReturnValue(undefined);
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
yield {
kind: 'message',
messageId: 'msg-1',
role: 'agent',
parts: [{ kind: 'text', text: 'Hello' }],
};
},
);
const invocation = new RemoteAgentInvocation(
authDefinition,
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute(new AbortController().signal);
expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith({
authConfig: mockAuth,
agentName: 'test-agent',
});
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'test-agent',
'http://test-agent/card',
mockHandler,
);
});
it('should return error when auth provider factory returns undefined for configured auth', async () => {
const authDefinition: RemoteAgentDefinition = {
...mockDefinition,
auth: {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'secret-token',
},
};
(A2AAuthProviderFactory.create as Mock).mockResolvedValue(undefined);
mockClientManager.getClient.mockReturnValue(undefined);
const invocation = new RemoteAgentInvocation(
authDefinition,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.message).toContain(
"Failed to create auth provider for agent 'test-agent'",
);
});
+26 -2
View File
@@ -24,6 +24,7 @@ import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { debugLogger } from '../utils/debugLogger.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import type { SendMessageResult } from './a2a-client-manager.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
/**
* Authentication handler implementation using Google Application Default Credentials (ADC).
@@ -79,7 +80,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
// TODO: See if we can reuse the singleton from AppContainer or similar, but for now use getInstance directly
// as per the current pattern in the codebase.
private readonly clientManager = A2AClientManager.getInstance();
private readonly authHandler = new ADCHandler();
private authHandler: AuthenticationHandler | undefined;
constructor(
private readonly definition: RemoteAgentDefinition,
@@ -107,6 +108,27 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
return `Calling remote agent ${this.definition.displayName ?? this.definition.name}`;
}
private async getAuthHandler(): Promise<AuthenticationHandler | undefined> {
if (this.authHandler) {
return this.authHandler;
}
if (this.definition.auth) {
const provider = await A2AAuthProviderFactory.create({
authConfig: this.definition.auth,
agentName: this.definition.name,
});
if (!provider) {
throw new Error(
`Failed to create auth provider for agent '${this.definition.name}'`,
);
}
this.authHandler = provider;
}
return this.authHandler;
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
@@ -138,11 +160,13 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
this.taskId = priorState.taskId;
}
const authHandler = await this.getAuthHandler();
if (!this.clientManager.getClient(this.definition.name)) {
await this.clientManager.loadAgent(
this.definition.name,
this.definition.agentCardUrl,
this.authHandler,
authHandler,
);
}
@@ -120,6 +120,16 @@ describe('SubAgentInvocation', () => {
);
});
it('should return the correct description', () => {
const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus);
const params = {};
// @ts-expect-error - accessing protected method for testing
const invocation = tool.createInvocation(params, mockMessageBus);
expect(invocation.getDescription()).toBe(
"Delegating to agent 'LocalAgent'",
);
});
it('should delegate shouldConfirmExecute to the inner sub-invocation (remote)', async () => {
const tool = new SubagentTool(
testRemoteDefinition,
+2 -2
View File
@@ -12,8 +12,8 @@ import {
BaseToolInvocation,
type ToolCallConfirmationDetails,
isTool,
type ToolLiveOutput,
} from '../tools/tools.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { AgentDefinition, AgentInputs } from './types.js';
@@ -155,7 +155,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
const validationError = SchemaValidator.validate(
this.definition.inputConfig.inputSchema,
+28
View File
@@ -71,6 +71,32 @@ export interface SubagentActivityEvent {
data: Record<string, unknown>;
}
export interface SubagentActivityItem {
id: string;
type: 'thought' | 'tool_call';
content: string;
displayName?: string;
description?: string;
args?: string;
status: 'running' | 'completed' | 'error' | 'cancelled';
}
export interface SubagentProgress {
isSubagentProgress: true;
agentName: string;
recentActivity: SubagentActivityItem[];
state?: 'running' | 'completed' | 'error' | 'cancelled';
}
export function isSubagentProgress(obj: unknown): obj is SubagentProgress {
return (
typeof obj === 'object' &&
obj !== null &&
'isSubagentProgress' in obj &&
obj.isSubagentProgress === true
);
}
/**
* The base definition for an agent.
* @template TOutput The specific Zod schema for the agent's final output object.
@@ -119,6 +145,8 @@ export interface RemoteAgentDefinition<
> extends BaseAgentDefinition<TOutput> {
kind: 'remote';
agentCardUrl: string;
/** The user-provided description, before any remote card merging. */
originalDescription?: string;
/**
* Optional authentication configuration for the remote agent.
* If not specified, the agent will try to use defaults based on the AgentCard's
+8 -64
View File
@@ -18,9 +18,7 @@ import type {
ModelSelectionConfig,
GenerateContentResponsePromptFeedback,
GenerateContentResponseUsageMetadata,
Part,
SafetySetting,
PartUnion,
SpeechConfigUnion,
ThinkingConfig,
ToolListUnion,
@@ -28,6 +26,11 @@ import type {
} from '@google/genai';
import { GenerateContentResponse } from '@google/genai';
import { debugLogger } from '../utils/debugLogger.js';
import {
isPart,
toParts,
toPartWithThoughtAsText,
} from '../utils/partUtils.js';
import type { Credits } from './types.js';
export interface CAGenerateContentRequest {
@@ -193,22 +196,12 @@ function maybeToContent(content?: ContentUnion): Content | undefined {
return toContent(content);
}
function isPart(c: ContentUnion): c is PartUnion {
return (
typeof c === 'object' &&
c !== null &&
!Array.isArray(c) &&
!('parts' in c) &&
!('role' in c)
);
}
function toContent(content: ContentUnion): Content {
if (Array.isArray(content)) {
// it's a PartsUnion[]
return {
role: 'user',
parts: toParts(content),
parts: toParts(content).map(toPartWithThoughtAsText),
};
}
if (typeof content === 'string') {
@@ -222,65 +215,16 @@ function toContent(content: ContentUnion): Content {
// it's a Content - process parts to handle thought filtering
return {
...content,
parts: content.parts
? toParts(content.parts.filter((p) => p != null))
: [],
parts: toParts(content.parts).map(toPartWithThoughtAsText),
};
}
// it's a Part
return {
role: 'user',
parts: [toPart(content)],
parts: [toPartWithThoughtAsText(content)],
};
}
export function toParts(parts: PartUnion[]): Part[] {
return parts.map(toPart);
}
function toPart(part: PartUnion): Part {
if (typeof part === 'string') {
// it's a string
return { text: part };
}
// Handle thought parts for CountToken API compatibility
// The CountToken API expects parts to have certain required "oneof" fields initialized,
// but thought parts don't conform to this schema and cause API failures
if ('thought' in part && part.thought) {
const thoughtText = `[Thought: ${part.thought}]`;
const newPart = { ...part };
delete (newPart as Record<string, unknown>)['thought'];
const hasApiContent =
'functionCall' in newPart ||
'functionResponse' in newPart ||
'inlineData' in newPart ||
'fileData' in newPart;
if (hasApiContent) {
// It's a functionCall or other non-text part. Just strip the thought.
return newPart;
}
// If no other valid API content, this must be a text part.
// Combine existing text (if any) with the thought, preserving other properties.
const text = (newPart as { text?: unknown }).text;
const existingText = text ? String(text) : '';
const combinedText = existingText
? `${existingText}\n${thoughtText}`
: thoughtText;
return {
...newPart,
text: combinedText,
};
}
return part;
}
function toVertexGenerationConfig(
config?: GenerateContentConfig,
): VertexGenerationConfig | undefined {
+22 -127
View File
@@ -223,8 +223,6 @@ import type {
ModelConfigService,
ModelConfigServiceConfig,
} from '../services/modelConfigService.js';
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js';
vi.mock('../core/baseLlmClient.js');
@@ -1204,6 +1202,28 @@ describe('Server Config (config.ts)', () => {
expect(SubAgentToolMock).not.toHaveBeenCalled();
});
it('should register EnterPlanModeTool and ExitPlanModeTool when plan is enabled', async () => {
const params: ConfigParameters = {
...baseParams,
plan: true,
};
const config = new Config(params);
await config.initialize();
const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerTool: Mock } };
}
).ToolRegistry.prototype.registerTool;
const registeredTools = registerToolMock.mock.calls.map(
(call) => call[0].constructor.name,
);
expect(registeredTools).toContain('EnterPlanModeTool');
expect(registeredTools).toContain('ExitPlanModeTool');
});
describe('with minified tool class names', () => {
beforeEach(() => {
Object.defineProperty(
@@ -2961,131 +2981,6 @@ describe('Plans Directory Initialization', () => {
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
recursive: true,
});
const context = config.getWorkspaceContext();
expect(context.getDirectories()).not.toContain(plansDir);
});
});
describe('syncPlanModeTools', () => {
const baseParams: ConfigParameters = {
sessionId: 'test-session',
targetDir: '.',
debugMode: false,
model: 'test-model',
cwd: '.',
};
it('should register ExitPlanModeTool and unregister EnterPlanModeTool when in PLAN mode', async () => {
const config = new Config({
...baseParams,
approvalMode: ApprovalMode.PLAN,
});
const registry = new ToolRegistry(config, config.getMessageBus());
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
const registerSpy = vi.spyOn(registry, 'registerTool');
const unregisterSpy = vi.spyOn(registry, 'unregisterTool');
const getToolSpy = vi.spyOn(registry, 'getTool');
getToolSpy.mockImplementation((name) => {
if (name === 'enter_plan_mode')
return new EnterPlanModeTool(config, config.getMessageBus());
return undefined;
});
config.syncPlanModeTools();
expect(unregisterSpy).toHaveBeenCalledWith('enter_plan_mode');
expect(registerSpy).toHaveBeenCalledWith(expect.anything());
const registeredTool = registerSpy.mock.calls[0][0];
const { ExitPlanModeTool } = await import('../tools/exit-plan-mode.js');
expect(registeredTool).toBeInstanceOf(ExitPlanModeTool);
});
it('should register EnterPlanModeTool and unregister ExitPlanModeTool when NOT in PLAN mode and experimental.plan is enabled', async () => {
const config = new Config({
...baseParams,
approvalMode: ApprovalMode.DEFAULT,
plan: true,
});
const registry = new ToolRegistry(config, config.getMessageBus());
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
const registerSpy = vi.spyOn(registry, 'registerTool');
const unregisterSpy = vi.spyOn(registry, 'unregisterTool');
const getToolSpy = vi.spyOn(registry, 'getTool');
getToolSpy.mockImplementation((name) => {
if (name === 'exit_plan_mode')
return new ExitPlanModeTool(config, config.getMessageBus());
return undefined;
});
config.syncPlanModeTools();
expect(unregisterSpy).toHaveBeenCalledWith('exit_plan_mode');
expect(registerSpy).toHaveBeenCalledWith(expect.anything());
const registeredTool = registerSpy.mock.calls[0][0];
const { EnterPlanModeTool } = await import('../tools/enter-plan-mode.js');
expect(registeredTool).toBeInstanceOf(EnterPlanModeTool);
});
it('should NOT register EnterPlanModeTool when experimental.plan is disabled', async () => {
const config = new Config({
...baseParams,
approvalMode: ApprovalMode.DEFAULT,
plan: false,
});
const registry = new ToolRegistry(config, config.getMessageBus());
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
const registerSpy = vi.spyOn(registry, 'registerTool');
vi.spyOn(registry, 'getTool').mockReturnValue(undefined);
config.syncPlanModeTools();
const { EnterPlanModeTool } = await import('../tools/enter-plan-mode.js');
const registeredTool = registerSpy.mock.calls.find(
(call) => call[0] instanceof EnterPlanModeTool,
);
expect(registeredTool).toBeUndefined();
});
it('should NOT register EnterPlanModeTool when in YOLO mode, even if plan is enabled', async () => {
const config = new Config({
...baseParams,
approvalMode: ApprovalMode.YOLO,
plan: true,
});
const registry = new ToolRegistry(config, config.getMessageBus());
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
const registerSpy = vi.spyOn(registry, 'registerTool');
vi.spyOn(registry, 'getTool').mockReturnValue(undefined);
config.syncPlanModeTools();
const { EnterPlanModeTool } = await import('../tools/enter-plan-mode.js');
const registeredTool = registerSpy.mock.calls.find(
(call) => call[0] instanceof EnterPlanModeTool,
);
expect(registeredTool).toBeUndefined();
});
it('should call geminiClient.setTools if initialized', async () => {
const config = new Config(baseParams);
const registry = new ToolRegistry(config, config.getMessageBus());
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
const client = config.getGeminiClient();
vi.spyOn(client, 'isInitialized').mockReturnValue(true);
const setToolsSpy = vi
.spyOn(client, 'setTools')
.mockResolvedValue(undefined);
config.syncPlanModeTools();
expect(setToolsSpy).toHaveBeenCalled();
});
});

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