mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 17:21:01 -07:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef38202dbd | |||
| 9850f01894 | |||
| 0d69f9f7fa | |||
| 46231a1755 | |||
| 2e7722d6a3 | |||
| 69e15a50d1 | |||
| 25f59a0099 | |||
| 01927a36d1 | |||
| 06ddfa5c4c | |||
| 3f7ef816f1 | |||
| d05ba11a31 | |||
| e43b1cff58 | |||
| bb6d1a2775 | |||
| dd9ccc9807 | |||
| 8133d63ac6 | |||
| 18d0375a7f | |||
| 31ca57ec94 | |||
| b7a8f0d1f9 | |||
| 1502e5cbc3 | |||
| 7ca3a33f8b | |||
| ce5a2d0760 | |||
| aa321b3d8c | |||
| 3a7a6e1540 | |||
| 66530e44c8 | |||
| b034dcd412 | |||
| 659301ff83 | |||
| 446a4316c4 | |||
| 48412a068e | |||
| 2e1efaebe4 | |||
| 7c9fceba7f | |||
| 740efa2ac2 |
@@ -14,3 +14,4 @@
|
||||
|
||||
# Docs have a dedicated approver group in addition to maintainers
|
||||
/docs/ @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs
|
||||
/README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs
|
||||
@@ -290,6 +290,7 @@ jobs:
|
||||
with:
|
||||
ref: '${{ needs.parse_run_context.outputs.sha }}'
|
||||
repository: '${{ needs.parse_run_context.outputs.repository }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node.js 20.x'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
@@ -302,7 +303,14 @@ jobs:
|
||||
- name: 'Build project'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Check if evals should run'
|
||||
id: 'check_evals'
|
||||
run: |
|
||||
SHOULD_RUN=$(node scripts/changed_prompt.js)
|
||||
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Run Evals (Required to pass)'
|
||||
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
run: 'npm run test:always_passing_evals'
|
||||
|
||||
@@ -117,7 +117,6 @@ jobs:
|
||||
name: 'Slow E2E - Win'
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
if: |-
|
||||
github.repository == 'google-gemini/gemini-cli' &&
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '/assign')
|
||||
(contains(github.event.comment.body, '/assign') || contains(github.event.comment.body, '/unassign'))
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
@@ -38,6 +38,7 @@ jobs:
|
||||
permission-issues: 'write'
|
||||
|
||||
- name: 'Assign issue to user'
|
||||
if: "contains(github.event.comment.body, '/assign')"
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
@@ -108,3 +109,42 @@ jobs:
|
||||
issue_number: issueNumber,
|
||||
body: `👋 @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).`
|
||||
});
|
||||
|
||||
- name: 'Unassign issue from user'
|
||||
if: "contains(github.event.comment.body, '/unassign')"
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |
|
||||
const issueNumber = context.issue.number;
|
||||
const commenter = context.actor;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const commentBody = context.payload.comment.body.trim();
|
||||
|
||||
if (commentBody !== '/unassign') {
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = await github.rest.issues.get({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
|
||||
const isAssigned = issue.data.assignees.some(assignee => assignee.login === commenter);
|
||||
|
||||
if (isAssigned) {
|
||||
await github.rest.issues.removeAssignees({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
issue_number: issueNumber,
|
||||
assignees: [commenter]
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
issue_number: issueNumber,
|
||||
body: `👋 @${commenter}, you have been unassigned from this issue.`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
name: 'Test Build Binary'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
build-node-binary:
|
||||
name: 'Build Binary (${{ matrix.os }})'
|
||||
runs-on: '${{ matrix.os }}'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: 'ubuntu-latest'
|
||||
platform_name: 'linux-x64'
|
||||
arch: 'x64'
|
||||
- os: 'windows-latest'
|
||||
platform_name: 'win32-x64'
|
||||
arch: 'x64'
|
||||
- os: 'macos-latest' # Apple Silicon (ARM64)
|
||||
platform_name: 'darwin-arm64'
|
||||
arch: 'arm64'
|
||||
- os: 'macos-latest' # Intel (x64) running on ARM via Rosetta
|
||||
platform_name: 'darwin-x64'
|
||||
arch: 'x64'
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@v4'
|
||||
|
||||
- name: 'Optimize Windows Performance'
|
||||
if: "matrix.os == 'windows-latest'"
|
||||
run: |
|
||||
Set-MpPreference -DisableRealtimeMonitoring $true
|
||||
Stop-Service -Name "wsearch" -Force -ErrorAction SilentlyContinue
|
||||
Set-Service -Name "wsearch" -StartupType Disabled
|
||||
Stop-Service -Name "SysMain" -Force -ErrorAction SilentlyContinue
|
||||
Set-Service -Name "SysMain" -StartupType Disabled
|
||||
shell: 'powershell'
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@v4'
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
architecture: '${{ matrix.arch }}'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Check Secrets'
|
||||
id: 'check_secrets'
|
||||
run: |
|
||||
echo "has_win_cert=${{ secrets.WINDOWS_PFX_BASE64 != '' }}" >> "$GITHUB_OUTPUT"
|
||||
echo "has_mac_cert=${{ secrets.MACOS_CERT_P12_BASE64 != '' }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Setup Windows SDK (Windows)'
|
||||
if: "matrix.os == 'windows-latest'"
|
||||
uses: 'microsoft/setup-msbuild@v2'
|
||||
|
||||
- name: 'Add Signtool to Path (Windows)'
|
||||
if: "matrix.os == 'windows-latest'"
|
||||
run: |
|
||||
$signtoolPath = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty DirectoryName
|
||||
echo "Found signtool at: $signtoolPath"
|
||||
echo "$signtoolPath" >> $env:GITHUB_PATH
|
||||
shell: 'pwsh'
|
||||
|
||||
- name: 'Setup macOS Keychain'
|
||||
if: "startsWith(matrix.os, 'macos') && steps.check_secrets.outputs.has_mac_cert == 'true' && github.event_name != 'pull_request'"
|
||||
env:
|
||||
BUILD_CERTIFICATE_BASE64: '${{ secrets.MACOS_CERT_P12_BASE64 }}'
|
||||
P12_PASSWORD: '${{ secrets.MACOS_CERT_PASSWORD }}'
|
||||
KEYCHAIN_PASSWORD: 'temp-password'
|
||||
run: |
|
||||
# Create the P12 file
|
||||
echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > certificate.p12
|
||||
|
||||
# Create a temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Import the certificate
|
||||
security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign
|
||||
|
||||
# Allow codesign to access it
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Set Identity for build script
|
||||
echo "APPLE_IDENTITY=${{ secrets.MACOS_CERT_IDENTITY }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: 'Setup Windows Certificate'
|
||||
if: "matrix.os == 'windows-latest' && steps.check_secrets.outputs.has_win_cert == 'true' && github.event_name != 'pull_request'"
|
||||
env:
|
||||
PFX_BASE64: '${{ secrets.WINDOWS_PFX_BASE64 }}'
|
||||
PFX_PASSWORD: '${{ secrets.WINDOWS_PFX_PASSWORD }}'
|
||||
run: |
|
||||
$pfx_cert_byte = [System.Convert]::FromBase64String("$env:PFX_BASE64")
|
||||
$certPath = Join-Path (Get-Location) "cert.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $pfx_cert_byte)
|
||||
echo "WINDOWS_PFX_FILE=$certPath" >> $env:GITHUB_ENV
|
||||
echo "WINDOWS_PFX_PASSWORD=$env:PFX_PASSWORD" >> $env:GITHUB_ENV
|
||||
shell: 'pwsh'
|
||||
|
||||
- name: 'Build Binary'
|
||||
run: 'npm run build:binary'
|
||||
|
||||
- name: 'Build Core Package'
|
||||
run: 'npm run build -w @google/gemini-cli-core'
|
||||
|
||||
- name: 'Verify Output Exists'
|
||||
run: |
|
||||
if [ -f "dist/${{ matrix.platform_name }}/gemini" ]; then
|
||||
echo "Binary found at dist/${{ matrix.platform_name }}/gemini"
|
||||
elif [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then
|
||||
echo "Binary found at dist/${{ matrix.platform_name }}/gemini.exe"
|
||||
else
|
||||
echo "Error: Binary not found in dist/${{ matrix.platform_name }}/"
|
||||
ls -R dist/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Smoke Test Binary'
|
||||
run: |
|
||||
echo "Running binary smoke test..."
|
||||
if [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then
|
||||
"./dist/${{ matrix.platform_name }}/gemini.exe" --version
|
||||
else
|
||||
"./dist/${{ matrix.platform_name }}/gemini" --version
|
||||
fi
|
||||
|
||||
- name: 'Run Integration Tests'
|
||||
if: "github.event_name != 'pull_request'"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
run: |
|
||||
echo "Running integration tests with binary..."
|
||||
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
|
||||
BINARY_PATH="$(cygpath -m "$(pwd)/dist/${{ matrix.platform_name }}/gemini.exe")"
|
||||
else
|
||||
BINARY_PATH="$(pwd)/dist/${{ matrix.platform_name }}/gemini"
|
||||
fi
|
||||
echo "Using binary at $BINARY_PATH"
|
||||
export INTEGRATION_TEST_GEMINI_BINARY_PATH="$BINARY_PATH"
|
||||
npm run test:integration:sandbox:none -- --testTimeout=600000
|
||||
|
||||
- name: 'Upload Artifact'
|
||||
uses: 'actions/upload-artifact@v4'
|
||||
with:
|
||||
name: 'gemini-cli-${{ matrix.platform_name }}'
|
||||
path: 'dist/${{ matrix.platform_name }}/'
|
||||
retention-days: 5
|
||||
+1
-1
@@ -61,4 +61,4 @@ gemini-debug.log
|
||||
.genkit
|
||||
.gemini-clipboard/
|
||||
.eslintcache
|
||||
evals/logs/
|
||||
evals/logs/
|
||||
|
||||
+7
-4
@@ -75,11 +75,14 @@ Replace `<PR_NUMBER>` with your pull request number. Authors are encouraged to
|
||||
run this on their own PRs for self-review, and reviewers should use it to
|
||||
augment their manual review process.
|
||||
|
||||
### Self assigning issues
|
||||
### Self-assigning and unassigning issues
|
||||
|
||||
To assign an issue to yourself, simply add a comment with the text `/assign`.
|
||||
The comment must contain only that text and nothing else. This command will
|
||||
assign the issue to you, provided it is not already assigned.
|
||||
To assign an issue to yourself, simply add a comment with the text `/assign`. To
|
||||
unassign yourself from an issue, add a comment with the text `/unassign`.
|
||||
|
||||
The comment must contain only that text and nothing else. These commands will
|
||||
assign or unassign the issue as requested, provided the conditions are met
|
||||
(e.g., an issue must be unassigned to be assigned).
|
||||
|
||||
Please note that you can have a maximum of 3 issues assigned to you at any given
|
||||
time.
|
||||
|
||||
@@ -282,14 +282,14 @@ gemini
|
||||
quickly.
|
||||
- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed
|
||||
auth configuration.
|
||||
- [**Configuration Guide**](./docs/get-started/configuration.md) - Settings and
|
||||
- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and
|
||||
customization.
|
||||
- [**Keyboard Shortcuts**](./docs/cli/keyboard-shortcuts.md) - Productivity
|
||||
tips.
|
||||
- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) -
|
||||
Productivity tips.
|
||||
|
||||
### Core Features
|
||||
|
||||
- [**Commands Reference**](./docs/cli/commands.md) - All slash commands
|
||||
- [**Commands Reference**](./docs/reference/commands.md) - All slash commands
|
||||
(`/help`, `/chat`, etc).
|
||||
- [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own
|
||||
reusable commands.
|
||||
@@ -323,15 +323,16 @@ gemini
|
||||
- [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a
|
||||
corporate environment.
|
||||
- [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking.
|
||||
- [**Tools API Development**](./docs/core/tools-api.md) - Create custom tools.
|
||||
- [**Tools API Development**](./docs/reference/tools-api.md) - Create custom
|
||||
tools.
|
||||
- [**Local development**](./docs/local-development.md) - Local development
|
||||
tooling.
|
||||
|
||||
### Troubleshooting & Support
|
||||
|
||||
- [**Troubleshooting Guide**](./docs/troubleshooting.md) - Common issues and
|
||||
solutions.
|
||||
- [**FAQ**](./docs/faq.md) - Frequently asked questions.
|
||||
- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common
|
||||
issues and solutions.
|
||||
- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions.
|
||||
- Use `/bug` command to report issues directly from the CLI.
|
||||
|
||||
### Using MCP Servers
|
||||
@@ -377,7 +378,8 @@ for planned features and priorities.
|
||||
|
||||
### Uninstall
|
||||
|
||||
See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
|
||||
See the [Uninstall Guide](./docs/resources/uninstall.md) for removal
|
||||
instructions.
|
||||
|
||||
## 📄 Legal
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -152,3 +152,13 @@ available combinations.
|
||||
inline when the cursor is over the placeholder.
|
||||
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
|
||||
view full content inline. Double-click again to collapse.
|
||||
|
||||
## Limitations
|
||||
|
||||
- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal):
|
||||
- `shift+enter` is not supported.
|
||||
- `shift+tab`
|
||||
[is not supported](https://github.com/google-gemini/gemini-cli/issues/20314)
|
||||
on Node 20 and earlier versions of Node 22.
|
||||
- On macOS's [Terminal](<https://en.wikipedia.org/wiki/Terminal_(macOS)>):
|
||||
- `shift+enter` is not supported.
|
||||
|
||||
@@ -88,6 +88,9 @@ const cliConfig = {
|
||||
outfile: 'bundle/gemini.js',
|
||||
define: {
|
||||
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
|
||||
'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify(
|
||||
pkg.config?.sandboxImageUri,
|
||||
),
|
||||
},
|
||||
plugins: createWasmPlugins(),
|
||||
alias: {
|
||||
|
||||
+44
-1
@@ -3,7 +3,8 @@
|
||||
Behavioral evaluations (evals) are tests designed to validate the agent's
|
||||
behavior in response to specific prompts. They serve as a critical feedback loop
|
||||
for changes to system prompts, tool definitions, and other model-steering
|
||||
mechanisms.
|
||||
mechanisms, and as a tool for assessing feature reliability by model, and
|
||||
preventing regressions.
|
||||
|
||||
## Why Behavioral Evals?
|
||||
|
||||
@@ -30,6 +31,48 @@ CLI's features.
|
||||
those that are generally reliable but might occasionally vary
|
||||
(`USUALLY_PASSES`).
|
||||
|
||||
## Best Practices
|
||||
|
||||
When designing behavioral evals, aim for scenarios that accurately reflect
|
||||
real-world usage while remaining small and maintainable.
|
||||
|
||||
- **Realistic Complexity**: Evals should be complicated enough to be
|
||||
"realistic." They should operate on actual files and a source directory,
|
||||
mirroring how a real agent interacts with a workspace. Remember that the agent
|
||||
may behave differently in a larger codebase, so we want to avoid scenarios
|
||||
that are too simple to be realistic.
|
||||
- _Good_: An eval that provides a small, functional React component and asks
|
||||
the agent to add a specific feature, requiring it to read the file,
|
||||
understand the context, and write the correct changes.
|
||||
- _Bad_: An eval that simply asks the agent a trivia question or asks it to
|
||||
write a generic script without providing any local workspace context.
|
||||
- **Maintainable Size**: Evals should be small enough to reason about and
|
||||
maintain. We probably can't check in an entire repo as a test case, though
|
||||
over time we will want these evals to mature into more and more realistic
|
||||
scenarios.
|
||||
- _Good_: A test setup with 2-3 files (e.g., a source file, a config file, and
|
||||
a test file) that isolates the specific behavior being evaluated.
|
||||
- _Bad_: A test setup containing dozens of files from a complex framework
|
||||
where the setup logic itself is prone to breaking.
|
||||
- **Unambiguous and Reliable Assertions**: Assertions must be clear and specific
|
||||
to ensure the test passes for the right reason.
|
||||
- _Good_: Checking that a modified file contains a specific AST node or exact
|
||||
string, or verifying that a tool was called with with the right parameters.
|
||||
- _Bad_: Only checking for a tool call, which could happen for an unrelated
|
||||
reason. Expecting specific LLM output.
|
||||
- **Fail First**: Have tests that failed before your prompt or tool change. We
|
||||
want to be sure the test fails before your "fix". It's pretty easy to
|
||||
accidentally create a passing test that asserts behaviors we get for free. In
|
||||
general, every eval should be accompanied by prompt change, and most prompt
|
||||
changes should be accompanied by an eval.
|
||||
- _Good_: Observing a failure, writing an eval that reliably reproduces the
|
||||
failure, modifying the prompt/tool, and then verifying the eval passes.
|
||||
- _Bad_: Writing an eval that passes on the first run and assuming your new
|
||||
prompt change was responsible.
|
||||
- **Less is More**: Prefer fewer, more realistic tests that assert the major
|
||||
paths vs. more tests that are more unit-test like. These are evals, so the
|
||||
value is in testing how the agent works in a semi-realistic scenario.
|
||||
|
||||
## Creating an Evaluation
|
||||
|
||||
Evaluations are located in the `evals` directory. Each evaluation is a Vitest
|
||||
|
||||
@@ -165,14 +165,15 @@ describe('Hooks Agent Flow', () => {
|
||||
|
||||
// BeforeModel hook to track message counts across LLM calls
|
||||
const messageCountFile = join(rig.testDir!, 'message-counts.json');
|
||||
const escapedPath = JSON.stringify(messageCountFile);
|
||||
const beforeModelScript = `
|
||||
const fs = require('fs');
|
||||
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
|
||||
const messageCount = input.llm_request?.contents?.length || 0;
|
||||
let counts = [];
|
||||
try { counts = JSON.parse(fs.readFileSync(${JSON.stringify(messageCountFile)}, 'utf-8')); } catch (e) {}
|
||||
try { counts = JSON.parse(fs.readFileSync(${escapedPath}, 'utf-8')); } catch (e) {}
|
||||
counts.push(messageCount);
|
||||
fs.writeFileSync(${JSON.stringify(messageCountFile)}, JSON.stringify(counts));
|
||||
fs.writeFileSync(${escapedPath}, JSON.stringify(counts));
|
||||
console.log(JSON.stringify({ decision: 'allow' }));
|
||||
`;
|
||||
const beforeModelScriptPath = rig.createScript(
|
||||
|
||||
@@ -81,7 +81,9 @@ describe('JSON output', () => {
|
||||
const message = (thrown as Error).message;
|
||||
|
||||
// Use a regex to find the first complete JSON object in the string
|
||||
const jsonMatch = message.match(/{[\s\S]*}/);
|
||||
// We expect the JSON to start with a quote (e.g. {"error": ...}) to avoid
|
||||
// matching random error objects printed to stderr (like ENOENT).
|
||||
const jsonMatch = message.match(/{\s*"[\s\S]*}/);
|
||||
|
||||
// Fail if no JSON-like text was found
|
||||
expect(
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, checkModelOutputContent } from './test-helper.js';
|
||||
import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js';
|
||||
|
||||
describe('Plan Mode', () => {
|
||||
let rig: TestRig;
|
||||
@@ -62,50 +64,98 @@ describe('Plan Mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.skip('should allow write_file only in the plans directory in plan mode', async () => {
|
||||
await rig.setup(
|
||||
'should allow write_file only in the plans directory in plan mode',
|
||||
{
|
||||
settings: {
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: ['write_file', 'read_file', 'list_directory'],
|
||||
allowed: ['write_file'],
|
||||
it('should allow write_file to the plans directory in plan mode', async () => {
|
||||
const plansDir = '.gemini/tmp/foo/123/plans';
|
||||
const testName =
|
||||
'should allow write_file to the plans directory in plan mode';
|
||||
|
||||
await rig.setup(testName, {
|
||||
settings: {
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: ['write_file', 'read_file', 'list_directory'],
|
||||
},
|
||||
general: {
|
||||
defaultApprovalMode: 'plan',
|
||||
plan: {
|
||||
directory: plansDir,
|
||||
},
|
||||
general: { defaultApprovalMode: 'plan' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
|
||||
// Verify that write_file outside of plan directory fails
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
stdin:
|
||||
'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeLogs = toolLogs.filter(
|
||||
(l) => l.toolRequest.name === 'write_file',
|
||||
// Disable the interactive terminal setup prompt in tests
|
||||
writeFileSync(
|
||||
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
|
||||
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
|
||||
);
|
||||
|
||||
const planWrite = writeLogs.find(
|
||||
const run = await rig.runInteractive({
|
||||
approvalMode: 'plan',
|
||||
});
|
||||
|
||||
await run.type('Create a file called plan.md in the plans directory.');
|
||||
await run.type('\r');
|
||||
|
||||
await rig.expectToolCallSuccess(['write_file'], 30000, (args) =>
|
||||
args.includes('plan.md'),
|
||||
);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const planWrite = toolLogs.find(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'write_file' &&
|
||||
l.toolRequest.args.includes('plans') &&
|
||||
l.toolRequest.args.includes('plan.md'),
|
||||
);
|
||||
expect(planWrite?.toolRequest.success).toBe(true);
|
||||
});
|
||||
|
||||
const blockedWrite = writeLogs.find((l) =>
|
||||
l.toolRequest.args.includes('hello.txt'),
|
||||
it('should deny write_file to non-plans directory in plan mode', async () => {
|
||||
const plansDir = '.gemini/tmp/foo/123/plans';
|
||||
const testName =
|
||||
'should deny write_file to non-plans directory in plan mode';
|
||||
|
||||
await rig.setup(testName, {
|
||||
settings: {
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: ['write_file', 'read_file', 'list_directory'],
|
||||
},
|
||||
general: {
|
||||
defaultApprovalMode: 'plan',
|
||||
plan: {
|
||||
directory: plansDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Disable the interactive terminal setup prompt in tests
|
||||
writeFileSync(
|
||||
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
|
||||
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
|
||||
);
|
||||
|
||||
// Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
|
||||
if (blockedWrite) {
|
||||
expect(blockedWrite?.toolRequest.success).toBe(false);
|
||||
}
|
||||
const run = await rig.runInteractive({
|
||||
approvalMode: 'plan',
|
||||
});
|
||||
|
||||
expect(planWrite?.toolRequest.success).toBe(true);
|
||||
await run.type('Create a file called hello.txt in the current directory.');
|
||||
await run.type('\r');
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeLog = toolLogs.find(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'write_file' &&
|
||||
l.toolRequest.args.includes('hello.txt'),
|
||||
);
|
||||
|
||||
// In Plan Mode, writes outside the plans directory should be blocked.
|
||||
// Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail.
|
||||
if (writeLog) {
|
||||
expect(writeLog.toolRequest.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should be able to enter plan mode from default mode', async () => {
|
||||
@@ -119,6 +169,12 @@ describe('Plan Mode', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Disable the interactive terminal setup prompt in tests
|
||||
writeFileSync(
|
||||
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
|
||||
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
|
||||
);
|
||||
|
||||
// Start in default mode and ask to enter plan mode.
|
||||
await rig.run({
|
||||
approvalMode: 'default',
|
||||
@@ -126,10 +182,7 @@ describe('Plan Mode', () => {
|
||||
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
|
||||
});
|
||||
|
||||
const enterPlanCallFound = await rig.waitForToolCall(
|
||||
'enter_plan_mode',
|
||||
10000,
|
||||
);
|
||||
const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ const { shell } = getShellConfiguration();
|
||||
function getLineCountCommand(): { command: string; tool: string } {
|
||||
switch (shell) {
|
||||
case 'powershell':
|
||||
return { command: `Measure-Object -Line`, tool: 'Measure-Object' };
|
||||
case 'cmd':
|
||||
return { command: `find /c /v`, tool: 'find' };
|
||||
case 'bash':
|
||||
@@ -238,8 +239,12 @@ describe('run_shell_command', () => {
|
||||
});
|
||||
|
||||
it('should succeed in yolo mode', async () => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
await rig.setup('should succeed in yolo mode', {
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
settings: {
|
||||
tools: { core: ['run_shell_command'] },
|
||||
shell: isWindows ? { enableInteractiveShell: false } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n');
|
||||
|
||||
Generated
+79
-5
@@ -5464,6 +5464,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
|
||||
@@ -6563,6 +6570,10 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -8539,6 +8550,36 @@
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -8790,11 +8831,34 @@
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
@@ -16222,6 +16286,16 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
|
||||
+4
-2
@@ -37,10 +37,12 @@
|
||||
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
|
||||
"build:packages": "npm run build --workspaces",
|
||||
"build:sandbox": "node scripts/build_sandbox.js",
|
||||
"build:binary": "node scripts/build_binary.js",
|
||||
"bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
|
||||
"test": "npm run test --workspaces --if-present && npm run test:sea-launch",
|
||||
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch",
|
||||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||
"test:sea-launch": "vitest run sea/sea-launch.test.js",
|
||||
"test:always_passing_evals": "vitest run --config evals/vitest.config.ts",
|
||||
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
|
||||
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
type ToolCallConfirmationDetails,
|
||||
type Config,
|
||||
type UserTierId,
|
||||
type AnsiOutput,
|
||||
type ToolLiveOutput,
|
||||
isSubagentProgress,
|
||||
EDIT_TOOL_NAMES,
|
||||
processRestorableToolCalls,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -336,11 +337,13 @@ export class Task {
|
||||
|
||||
private _schedulerOutputUpdate(
|
||||
toolCallId: string,
|
||||
outputChunk: string | AnsiOutput,
|
||||
outputChunk: ToolLiveOutput,
|
||||
): void {
|
||||
let outputAsText: string;
|
||||
if (typeof outputChunk === 'string') {
|
||||
outputAsText = outputChunk;
|
||||
} else if (isSubagentProgress(outputChunk)) {
|
||||
outputAsText = JSON.stringify(outputChunk);
|
||||
} else {
|
||||
outputAsText = outputChunk
|
||||
.map((line) => line.map((token) => token.text).join(''))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import express, { type Request } from 'express';
|
||||
|
||||
import type { AgentCard, Message } from '@a2a-js/sdk';
|
||||
import {
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
InMemoryTaskStore,
|
||||
DefaultExecutionEventBus,
|
||||
type AgentExecutionEvent,
|
||||
UnauthenticatedUser,
|
||||
} from '@a2a-js/sdk/server';
|
||||
import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components
|
||||
import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { AgentSettings } from '../types.js';
|
||||
@@ -55,8 +56,17 @@ const coderAgentCard: AgentCard = {
|
||||
pushNotifications: false,
|
||||
stateTransitionHistory: true,
|
||||
},
|
||||
securitySchemes: undefined,
|
||||
security: undefined,
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
},
|
||||
basicAuth: {
|
||||
type: 'http',
|
||||
scheme: 'basic',
|
||||
},
|
||||
},
|
||||
security: [{ bearerAuth: [] }, { basicAuth: [] }],
|
||||
defaultInputModes: ['text'],
|
||||
defaultOutputModes: ['text'],
|
||||
skills: [
|
||||
@@ -81,6 +91,35 @@ export function updateCoderAgentCardUrl(port: number) {
|
||||
coderAgentCard.url = `http://localhost:${port}/`;
|
||||
}
|
||||
|
||||
const customUserBuilder: UserBuilder = async (req: Request) => {
|
||||
const auth = req.headers['authorization'];
|
||||
if (auth) {
|
||||
const scheme = auth.split(' ')[0];
|
||||
logger.info(
|
||||
`[customUserBuilder] Received Authorization header with scheme: ${scheme}`,
|
||||
);
|
||||
}
|
||||
if (!auth) return new UnauthenticatedUser();
|
||||
|
||||
// 1. Bearer Auth
|
||||
if (auth.startsWith('Bearer ')) {
|
||||
const token = auth.substring(7);
|
||||
if (token === 'valid-token') {
|
||||
return { userName: 'bearer-user', isAuthenticated: true };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Basic Auth
|
||||
if (auth.startsWith('Basic ')) {
|
||||
const credentials = Buffer.from(auth.substring(6), 'base64').toString();
|
||||
if (credentials === 'admin:password') {
|
||||
return { userName: 'basic-user', isAuthenticated: true };
|
||||
}
|
||||
}
|
||||
|
||||
return new UnauthenticatedUser();
|
||||
};
|
||||
|
||||
async function handleExecuteCommand(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
@@ -204,7 +243,7 @@ export async function createApp() {
|
||||
requestStorage.run({ req }, next);
|
||||
});
|
||||
|
||||
const appBuilder = new A2AExpressApp(requestHandler);
|
||||
const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder);
|
||||
expressApp = appBuilder.setupRoutes(expressApp, '');
|
||||
expressApp.use(express.json());
|
||||
|
||||
|
||||
@@ -102,7 +102,9 @@ export async function loadSandboxConfig(
|
||||
|
||||
const packageJson = await getPackageJson(__dirname);
|
||||
const image =
|
||||
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri;
|
||||
process.env['GEMINI_SANDBOX_IMAGE'] ??
|
||||
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
|
||||
packageJson?.config?.sandboxImageUri;
|
||||
|
||||
return command && image ? { command, image } : undefined;
|
||||
}
|
||||
|
||||
@@ -185,9 +185,6 @@ export interface SessionRetentionSettings {
|
||||
|
||||
/** Minimum retention period (safety limit, defaults to "1d") */
|
||||
minRetention?: string;
|
||||
|
||||
/** INTERNAL: Whether the user has acknowledged the session retention warning */
|
||||
warningAcknowledged?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
|
||||
@@ -243,7 +243,7 @@ export async function startInteractiveUI(
|
||||
<ScrollProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<VimModeProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
|
||||
@@ -528,12 +528,13 @@ export const mockSettings = new LoadedSettings(
|
||||
// A minimal mock UIState to satisfy the context provider.
|
||||
// Tests that need specific UIState values should provide their own.
|
||||
const baseMockUiState = {
|
||||
history: [],
|
||||
renderMarkdown: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
terminalWidth: 100,
|
||||
terminalHeight: 40,
|
||||
currentModel: 'gemini-pro',
|
||||
terminalBackgroundColor: 'black',
|
||||
terminalBackgroundColor: 'black' as const,
|
||||
cleanUiDetailsVisible: false,
|
||||
allowPlanMode: true,
|
||||
activePtyId: undefined,
|
||||
@@ -552,6 +553,9 @@ const baseMockUiState = {
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: false,
|
||||
nightly: false,
|
||||
updateInfo: null,
|
||||
pendingHistoryItems: [],
|
||||
};
|
||||
|
||||
export const mockAppState: AppState = {
|
||||
@@ -752,7 +756,7 @@ export const renderWithProviders = (
|
||||
<ConfigContext.Provider value={finalConfig}>
|
||||
<SettingsContext.Provider value={finalSettings}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider settings={finalSettings}>
|
||||
<VimModeProvider>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<SessionStatsProvider>
|
||||
<StreamingContext.Provider
|
||||
|
||||
@@ -146,7 +146,6 @@ import { requestConsentInteractive } from '../config/extensions/consent.js';
|
||||
import { useSessionBrowser } from './hooks/useSessionBrowser.js';
|
||||
import { useSessionResume } from './hooks/useSessionResume.js';
|
||||
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
|
||||
import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js';
|
||||
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
|
||||
import { useSettings } from './contexts/SettingsContext.js';
|
||||
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
|
||||
@@ -1548,28 +1547,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
|
||||
|
||||
const handleAutoEnableRetention = useCallback(() => {
|
||||
const userSettings = settings.forScope(SettingScope.User).settings;
|
||||
const currentRetention = userSettings.general?.sessionRetention ?? {};
|
||||
|
||||
settings.setValue(SettingScope.User, 'general.sessionRetention', {
|
||||
...currentRetention,
|
||||
enabled: true,
|
||||
maxAge: '30d',
|
||||
warningAcknowledged: true,
|
||||
});
|
||||
}, [settings]);
|
||||
|
||||
const {
|
||||
shouldShowWarning: shouldShowRetentionWarning,
|
||||
checkComplete: retentionCheckComplete,
|
||||
sessionsToDeleteCount,
|
||||
} = useSessionRetentionCheck(
|
||||
config,
|
||||
settings.merged,
|
||||
handleAutoEnableRetention,
|
||||
);
|
||||
|
||||
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2015,7 +1992,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const dialogsVisible =
|
||||
(shouldShowRetentionWarning && retentionCheckComplete) ||
|
||||
shouldShowIdePrompt ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
isPolicyUpdateDialogOpen ||
|
||||
@@ -2202,9 +2179,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
history: historyManager.history,
|
||||
historyManager,
|
||||
isThemeDialogOpen,
|
||||
shouldShowRetentionWarning:
|
||||
shouldShowRetentionWarning && retentionCheckComplete,
|
||||
sessionsToDeleteCount: sessionsToDeleteCount ?? 0,
|
||||
|
||||
themeError,
|
||||
isAuthenticating,
|
||||
isConfigInitialized,
|
||||
@@ -2334,9 +2309,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
shouldShowRetentionWarning,
|
||||
retentionCheckComplete,
|
||||
sessionsToDeleteCount,
|
||||
|
||||
themeError,
|
||||
isAuthenticating,
|
||||
isConfigInitialized,
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
|
||||
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -47,34 +47,31 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
|
||||
"Notifications
|
||||
Footer
|
||||
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
Composer
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -110,20 +107,17 @@ DialogManager
|
||||
|
||||
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
HistoryItemDisplay
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required │
|
||||
@@ -146,6 +140,9 @@ HistoryItemDisplay
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
Composer
|
||||
"
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
ConfigExtensionDialog,
|
||||
type ConfigExtensionDialogProps,
|
||||
} from '../components/ConfigExtensionDialog.js';
|
||||
import {
|
||||
ExtensionRegistryView,
|
||||
type ExtensionRegistryViewProps,
|
||||
} from '../components/views/ExtensionRegistryView.js';
|
||||
import { type CommandContext, type SlashCommand } from './types.js';
|
||||
|
||||
import {
|
||||
@@ -39,6 +43,8 @@ import {
|
||||
} from '../../config/extension-manager.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { type RegistryExtension } from '../../config/extensionRegistryClient.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
|
||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -167,6 +173,7 @@ describe('extensionsCommand', () => {
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: mockDispatchExtensionState,
|
||||
removeComponent: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -429,6 +436,61 @@ describe('extensionsCommand', () => {
|
||||
throw new Error('Explore action not found');
|
||||
}
|
||||
|
||||
it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => {
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry = true;
|
||||
|
||||
const result = await exploreAction(mockContext, '');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
|
||||
const component =
|
||||
result.component as ReactElement<ExtensionRegistryViewProps>;
|
||||
expect(component.type).toBe(ExtensionRegistryView);
|
||||
expect(component.props.extensionManager).toBe(mockExtensionLoader);
|
||||
});
|
||||
|
||||
it('should handle onSelect and onClose in ExtensionRegistryView', async () => {
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry = true;
|
||||
|
||||
const result = await exploreAction(mockContext, '');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
|
||||
const component =
|
||||
result.component as ReactElement<ExtensionRegistryViewProps>;
|
||||
|
||||
const extension = {
|
||||
extensionName: 'test-ext',
|
||||
url: 'https://github.com/test/ext.git',
|
||||
} as RegistryExtension;
|
||||
|
||||
vi.mocked(inferInstallMetadata).mockResolvedValue({
|
||||
source: extension.url,
|
||||
type: 'git',
|
||||
});
|
||||
mockInstallExtension.mockResolvedValue({ name: extension.url });
|
||||
|
||||
// Call onSelect
|
||||
component.props.onSelect?.(extension);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||
source: extension.url,
|
||||
type: 'git',
|
||||
});
|
||||
});
|
||||
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Call onClose
|
||||
component.props.onClose?.();
|
||||
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should add an info message and call 'open' in a non-sandbox environment", async () => {
|
||||
// Ensure no special environment variables that would affect behavior
|
||||
vi.stubEnv('NODE_ENV', '');
|
||||
|
||||
@@ -280,7 +280,9 @@ async function exploreAction(
|
||||
type: 'custom_dialog' as const,
|
||||
component: React.createElement(ExtensionRegistryView, {
|
||||
onSelect: (extension) => {
|
||||
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
|
||||
debugLogger.log(`Selected extension: ${extension.extensionName}`);
|
||||
void installAction(context, extension.url);
|
||||
context.ui.removeComponent();
|
||||
},
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
extensionManager,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { hooksCommand } from './hooksCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { HookRegistryEntry } from '@google/gemini-cli-core';
|
||||
import { HookType, HookEventName, ConfigSource } from '@google/gemini-cli-core';
|
||||
import type { CommandContext } from './types.js';
|
||||
@@ -127,13 +126,10 @@ describe('hooksCommand', () => {
|
||||
createMockHook('test-hook', HookEventName.BeforeTool, true),
|
||||
]);
|
||||
|
||||
await hooksCommand.action(mockContext, '');
|
||||
const result = await hooksCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.HOOKS_LIST,
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
expect(result).toHaveProperty('component');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,7 +157,7 @@ describe('hooksCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should display panel even when hook system is not enabled', async () => {
|
||||
it('should return custom_dialog even when hook system is not enabled', async () => {
|
||||
mockConfig.getHookSystem.mockReturnValue(null);
|
||||
|
||||
const panelCmd = hooksCommand.subCommands!.find(
|
||||
@@ -171,17 +167,13 @@ describe('hooksCommand', () => {
|
||||
throw new Error('panel command must have an action');
|
||||
}
|
||||
|
||||
await panelCmd.action(mockContext, '');
|
||||
const result = await panelCmd.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.HOOKS_LIST,
|
||||
hooks: [],
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
expect(result).toHaveProperty('component');
|
||||
});
|
||||
|
||||
it('should display panel when no hooks are configured', async () => {
|
||||
it('should return custom_dialog when no hooks are configured', async () => {
|
||||
mockHookSystem.getAllHooks.mockReturnValue([]);
|
||||
(mockContext.services.settings.merged as Record<string, unknown>)[
|
||||
'hooksConfig'
|
||||
@@ -194,17 +186,13 @@ describe('hooksCommand', () => {
|
||||
throw new Error('panel command must have an action');
|
||||
}
|
||||
|
||||
await panelCmd.action(mockContext, '');
|
||||
const result = await panelCmd.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.HOOKS_LIST,
|
||||
hooks: [],
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
expect(result).toHaveProperty('component');
|
||||
});
|
||||
|
||||
it('should display hooks list when hooks are configured', async () => {
|
||||
it('should return custom_dialog when hooks are configured', async () => {
|
||||
const mockHooks: HookRegistryEntry[] = [
|
||||
createMockHook('echo-test', HookEventName.BeforeTool, true),
|
||||
createMockHook('notify', HookEventName.AfterAgent, false),
|
||||
@@ -222,14 +210,10 @@ describe('hooksCommand', () => {
|
||||
throw new Error('panel command must have an action');
|
||||
}
|
||||
|
||||
await panelCmd.action(mockContext, '');
|
||||
const result = await panelCmd.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.HOOKS_LIST,
|
||||
hooks: mockHooks,
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
expect(result).toHaveProperty('component');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SlashCommand, CommandContext } from './types.js';
|
||||
import { createElement } from 'react';
|
||||
import type {
|
||||
SlashCommand,
|
||||
CommandContext,
|
||||
OpenCustomDialogActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType, type HistoryItemHooksList } from '../types.js';
|
||||
import type {
|
||||
HookRegistryEntry,
|
||||
MessageActionReturn,
|
||||
@@ -15,13 +19,14 @@ import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { SettingScope, isLoadableSettingScope } from '../../config/settings.js';
|
||||
import { enableHook, disableHook } from '../../utils/hookSettings.js';
|
||||
import { renderHookActionFeedback } from '../../utils/hookUtils.js';
|
||||
import { HooksDialog } from '../components/HooksDialog.js';
|
||||
|
||||
/**
|
||||
* Display a formatted list of hooks with their status
|
||||
* Display a formatted list of hooks with their status in a dialog
|
||||
*/
|
||||
async function panelAction(
|
||||
function panelAction(
|
||||
context: CommandContext,
|
||||
): Promise<void | MessageActionReturn> {
|
||||
): MessageActionReturn | OpenCustomDialogActionReturn {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
@@ -34,12 +39,13 @@ async function panelAction(
|
||||
const hookSystem = config.getHookSystem();
|
||||
const allHooks = hookSystem?.getAllHooks() || [];
|
||||
|
||||
const hooksListItem: HistoryItemHooksList = {
|
||||
type: MessageType.HOOKS_LIST,
|
||||
hooks: allHooks,
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: createElement(HooksDialog, {
|
||||
hooks: allHooks,
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
}),
|
||||
};
|
||||
|
||||
context.ui.addItem(hooksListItem);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,6 +349,7 @@ const panelCommand: SlashCommand = {
|
||||
altNames: ['list', 'show'],
|
||||
description: 'Display all registered hooks with their status',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: panelAction,
|
||||
};
|
||||
|
||||
@@ -393,5 +400,5 @@ export const hooksCommand: SlashCommand = {
|
||||
enableAllCommand,
|
||||
disableAllCommand,
|
||||
],
|
||||
action: async (context: CommandContext) => panelCommand.action!(context, ''),
|
||||
action: (context: CommandContext) => panelCommand.action!(context, ''),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
BaseSettingsDialog,
|
||||
type SettingsDialogItem,
|
||||
} from './shared/BaseSettingsDialog.js';
|
||||
import { getNestedValue, isRecord } from '../../utils/settingsUtils.js';
|
||||
|
||||
/**
|
||||
* Configuration field definition for agent settings
|
||||
@@ -111,32 +112,12 @@ interface AgentConfigDialogProps {
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using a path array
|
||||
*/
|
||||
function getNestedValue(
|
||||
obj: Record<string, unknown> | undefined,
|
||||
path: string[],
|
||||
): unknown {
|
||||
if (!obj) return undefined;
|
||||
let current: unknown = obj;
|
||||
for (const key of path) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== 'object') return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a nested value in an object using a path array, creating intermediate objects as needed
|
||||
*/
|
||||
function setNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
function setNestedValue(obj: unknown, path: string[], value: unknown): unknown {
|
||||
if (!isRecord(obj)) return obj;
|
||||
|
||||
const result = { ...obj };
|
||||
let current = result;
|
||||
|
||||
@@ -144,12 +125,17 @@ function setNestedValue(
|
||||
const key = path[i];
|
||||
if (current[key] === undefined || current[key] === null) {
|
||||
current[key] = {};
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current[key] = { ...(current[key] as Record<string, unknown>) };
|
||||
} else if (isRecord(current[key])) {
|
||||
current[key] = { ...current[key] };
|
||||
}
|
||||
|
||||
const next = current[key];
|
||||
if (isRecord(next)) {
|
||||
current = next;
|
||||
} else {
|
||||
// Cannot traverse further through non-objects
|
||||
return result;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const finalKey = path[path.length - 1];
|
||||
@@ -267,11 +253,7 @@ export function AgentConfigDialog({
|
||||
const items: SettingsDialogItem[] = useMemo(
|
||||
() =>
|
||||
AGENT_CONFIG_FIELDS.map((field) => {
|
||||
const currentValue = getNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
const currentValue = getNestedValue(pendingOverride, field.path);
|
||||
const defaultValue = getFieldDefaultFromDefinition(field, definition);
|
||||
const effectiveValue =
|
||||
currentValue !== undefined ? currentValue : defaultValue;
|
||||
@@ -324,23 +306,18 @@ export function AgentConfigDialog({
|
||||
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
|
||||
if (!field || field.type !== 'boolean') return;
|
||||
|
||||
const currentValue = getNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
const currentValue = getNestedValue(pendingOverride, field.path);
|
||||
const defaultValue = getFieldDefaultFromDefinition(field, definition);
|
||||
const effectiveValue =
|
||||
currentValue !== undefined ? currentValue : defaultValue;
|
||||
const newValue = !effectiveValue;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
pendingOverride,
|
||||
field.path,
|
||||
newValue,
|
||||
) as AgentOverride;
|
||||
|
||||
setPendingOverride(newOverride);
|
||||
setModifiedFields((prev) => new Set(prev).add(key));
|
||||
|
||||
@@ -375,9 +352,9 @@ export function AgentConfigDialog({
|
||||
}
|
||||
|
||||
// Update pending override locally
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
pendingOverride,
|
||||
field.path,
|
||||
parsed,
|
||||
) as AgentOverride;
|
||||
@@ -398,9 +375,9 @@ export function AgentConfigDialog({
|
||||
if (!field) return;
|
||||
|
||||
// Remove the override (set to undefined)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
pendingOverride,
|
||||
field.path,
|
||||
undefined,
|
||||
) as AgentOverride;
|
||||
|
||||
@@ -213,6 +213,12 @@ describe('<AppHeader />', () => {
|
||||
|
||||
it('should NOT render Tips when tipsShown is 10 or more', async () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
persistentStateMock.setData({ tipsShown: 10 });
|
||||
|
||||
@@ -220,6 +226,7 @@ describe('<AppHeader />', () => {
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -1,58 +1,113 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { Header } from './Header.js';
|
||||
import { Tips } from './Tips.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { UserIdentity } from './UserIdentity.js';
|
||||
import { Tips } from './Tips.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { useBanner } from '../hooks/useBanner.js';
|
||||
import { useTips } from '../hooks/useTips.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
const ICON = `▝▜▄
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀ `;
|
||||
|
||||
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
|
||||
const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState();
|
||||
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
const { showTips } = useTips();
|
||||
|
||||
const showHeader = !(
|
||||
settings.merged.ui.hideBanner || config.getScreenReader()
|
||||
);
|
||||
|
||||
if (!showDetails) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Header version={version} nightly={false} />
|
||||
{showHeader && (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
paddingLeft={2}
|
||||
>
|
||||
<Box flexShrink={0}>
|
||||
<ThemedGradient>{ICON}</ThemedGradient>
|
||||
</Box>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{!(settings.merged.ui.hideBanner || config.getScreenReader()) && (
|
||||
<>
|
||||
<Header version={version} nightly={nightly} />
|
||||
{bannerVisible && bannerText && (
|
||||
<Banner
|
||||
width={terminalWidth}
|
||||
bannerText={bannerText}
|
||||
isWarning={bannerData.warningText !== ''}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{showHeader && (
|
||||
<Box flexDirection="row" marginTop={1} marginBottom={1} paddingLeft={2}>
|
||||
<Box flexShrink={0}>
|
||||
<ThemedGradient>{ICON}</ThemedGradient>
|
||||
</Box>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{/* Line 1: Gemini CLI vVersion [Updating] */}
|
||||
<Box>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
{updateInfo && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
<CliSpinner /> Updating
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Line 2: Blank */}
|
||||
<Box height={1} />
|
||||
|
||||
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
|
||||
{settings.merged.ui.showUserIdentity !== false && (
|
||||
<UserIdentity config={config} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{settings.merged.ui.showUserIdentity !== false && (
|
||||
<UserIdentity config={config} />
|
||||
|
||||
{bannerVisible && bannerText && (
|
||||
<Banner
|
||||
width={terminalWidth}
|
||||
bannerText={bannerText}
|
||||
isWarning={bannerData.warningText !== ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
|
||||
showTips && <Tips config={config} />}
|
||||
</Box>
|
||||
|
||||
@@ -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 "Keep chat history".
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -14,7 +14,6 @@
|
||||
* - Focus section switching between settings and scope selector
|
||||
* - Scope selection and settings persistence across scopes
|
||||
* - Restart-required vs immediate settings behavior
|
||||
* - VimModeContext integration
|
||||
* - Complex user interaction workflows
|
||||
* - Error handling and edge cases
|
||||
* - Display values for inherited and overridden settings
|
||||
@@ -25,12 +24,12 @@ import { render } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { act } from 'react';
|
||||
import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js';
|
||||
import { TEST_ONLY } from '../../utils/settingsUtils.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import {
|
||||
getSettingsSchema,
|
||||
type SettingDefinition,
|
||||
@@ -38,10 +37,6 @@ import {
|
||||
} from '../../config/settingsSchema.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
|
||||
// Mock the VimModeContext
|
||||
const mockToggleVimEnabled = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSetVimMode = vi.fn();
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: () => ({
|
||||
terminalWidth: 100, // Fixed width for consistent snapshots
|
||||
@@ -68,27 +63,6 @@ vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/VimModeContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/VimModeContext.js');
|
||||
return {
|
||||
...actual,
|
||||
useVimMode: () => ({
|
||||
vimEnabled: false,
|
||||
vimMode: 'INSERT' as const,
|
||||
toggleVimEnabled: mockToggleVimEnabled,
|
||||
setVimMode: mockSetVimMode,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/settingsUtils.js', async () => {
|
||||
const actual = await vi.importActual('../../utils/settingsUtils.js');
|
||||
return {
|
||||
...actual,
|
||||
saveModifiedSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Shared test schemas
|
||||
enum StringEnum {
|
||||
FOO = 'foo',
|
||||
@@ -131,6 +105,62 @@ const ENUM_FAKE_SCHEMA: SettingsSchemaType = {
|
||||
},
|
||||
} as unknown as SettingsSchemaType;
|
||||
|
||||
const ARRAY_FAKE_SCHEMA: SettingsSchemaType = {
|
||||
context: {
|
||||
type: 'object',
|
||||
label: 'Context',
|
||||
category: 'Context',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Context settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
fileFiltering: {
|
||||
type: 'object',
|
||||
label: 'File Filtering',
|
||||
category: 'Context',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'File filtering settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
customIgnoreFilePaths: {
|
||||
type: 'array',
|
||||
label: 'Custom Ignore File Paths',
|
||||
category: 'Context',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description: 'Additional ignore file paths.',
|
||||
showInDialog: true,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
type: 'object',
|
||||
label: 'Security',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Security settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
allowedExtensions: {
|
||||
type: 'array',
|
||||
label: 'Extension Source Regex Allowlist',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description: 'Allowed extension source regex patterns.',
|
||||
showInDialog: true,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as SettingsSchemaType;
|
||||
|
||||
const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = {
|
||||
tools: {
|
||||
type: 'object',
|
||||
@@ -185,7 +215,7 @@ const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = {
|
||||
|
||||
// Helper function to render SettingsDialog with standard wrapper
|
||||
const renderDialog = (
|
||||
settings: LoadedSettings,
|
||||
settings: ReturnType<typeof createMockSettings>,
|
||||
onSelect: ReturnType<typeof vi.fn>,
|
||||
options?: {
|
||||
onRestartRequest?: ReturnType<typeof vi.fn>;
|
||||
@@ -193,14 +223,15 @@ const renderDialog = (
|
||||
},
|
||||
) =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={onSelect}
|
||||
onRestartRequest={options?.onRestartRequest}
|
||||
availableTerminalHeight={options?.availableTerminalHeight}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog
|
||||
onSelect={onSelect}
|
||||
onRestartRequest={options?.onRestartRequest}
|
||||
availableTerminalHeight={options?.availableTerminalHeight}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
describe('SettingsDialog', () => {
|
||||
@@ -210,7 +241,6 @@ describe('SettingsDialog', () => {
|
||||
terminalCapabilityManager,
|
||||
'isKittyProtocolEnabled',
|
||||
).mockReturnValue(true);
|
||||
mockToggleVimEnabled.mockRejectedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -394,9 +424,8 @@ describe('SettingsDialog', () => {
|
||||
|
||||
describe('Settings Toggling', () => {
|
||||
it('should toggle setting with Enter key', async () => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
|
||||
const settings = createMockSettings();
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount, lastFrame, waitUntilReady } = renderDialog(
|
||||
@@ -414,29 +443,16 @@ describe('SettingsDialog', () => {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Wait for the setting change to be processed
|
||||
// Wait for setValue to be called
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
vi.mocked(saveModifiedSettings).mock.calls.length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(setValueSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for the mock to be called
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['general.vimMode']),
|
||||
expect.objectContaining({
|
||||
general: expect.objectContaining({
|
||||
vimMode: true,
|
||||
}),
|
||||
}),
|
||||
expect.any(LoadedSettings),
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'general.vimMode',
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -455,13 +471,13 @@ describe('SettingsDialog', () => {
|
||||
expectedValue: StringEnum.FOO,
|
||||
},
|
||||
])('$name', async ({ initialValue, expectedValue }) => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(ENUM_FAKE_SCHEMA);
|
||||
|
||||
const settings = createMockSettings();
|
||||
if (initialValue !== undefined) {
|
||||
settings.setValue(SettingScope.User, 'ui.theme', initialValue);
|
||||
}
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
|
||||
const onSelect = vi.fn();
|
||||
|
||||
@@ -482,20 +498,13 @@ describe('SettingsDialog', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui.theme',
|
||||
expectedValue,
|
||||
);
|
||||
});
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['ui.theme']),
|
||||
expect.objectContaining({
|
||||
ui: expect.objectContaining({
|
||||
theme: expectedValue,
|
||||
}),
|
||||
}),
|
||||
expect.any(LoadedSettings),
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -692,30 +701,6 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle vim mode toggle errors gracefully', async () => {
|
||||
mockToggleVimEnabled.mockRejectedValue(new Error('Toggle failed'));
|
||||
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount, waitUntilReady } = renderDialog(
|
||||
settings,
|
||||
onSelect,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Try to toggle a setting (this might trigger vim mode toggle)
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Should not crash
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex State Management', () => {
|
||||
it('should track modified settings correctly', async () => {
|
||||
const settings = createMockSettings();
|
||||
@@ -767,31 +752,6 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('VimMode Integration', () => {
|
||||
it('should sync with VimModeContext when vim mode is toggled', async () => {
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount, waitUntilReady } = render(
|
||||
<VimModeProvider settings={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
</VimModeProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Navigate to and toggle vim mode setting
|
||||
// This would require knowing the exact position of vim mode setting
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Specific Settings Behavior', () => {
|
||||
it('should show correct display values for settings with different states', async () => {
|
||||
const settings = createMockSettings({
|
||||
@@ -861,7 +821,7 @@ describe('SettingsDialog', () => {
|
||||
// Should not show restart prompt initially
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'Changes that require a restart have been modified',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -957,63 +917,41 @@ describe('SettingsDialog', () => {
|
||||
pager: 'less',
|
||||
},
|
||||
},
|
||||
])(
|
||||
'should $name',
|
||||
async ({ toggleCount, shellSettings, expectedSiblings }) => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
])('should $name', async ({ toggleCount, shellSettings }) => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA);
|
||||
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA);
|
||||
const settings = createMockSettings({
|
||||
tools: {
|
||||
shell: shellSettings,
|
||||
},
|
||||
});
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
|
||||
const settings = createMockSettings({
|
||||
tools: {
|
||||
shell: shellSettings,
|
||||
},
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = renderDialog(settings, onSelect);
|
||||
|
||||
for (let i = 0; i < toggleCount; i++) {
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
}
|
||||
|
||||
const onSelect = vi.fn();
|
||||
await waitFor(() => {
|
||||
expect(setValueSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const { stdin, unmount, waitUntilReady } = renderDialog(
|
||||
settings,
|
||||
onSelect,
|
||||
);
|
||||
await waitUntilReady();
|
||||
// With the store pattern, setValue is called atomically per key.
|
||||
// Sibling preservation is handled by LoadedSettings internally.
|
||||
const calls = setValueSpy.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
calls.forEach((call) => {
|
||||
// Each call should target only 'tools.shell.showColor'
|
||||
expect(call[1]).toBe('tools.shell.showColor');
|
||||
});
|
||||
|
||||
for (let i = 0; i < toggleCount; i++) {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
await waitUntilReady();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
vi.mocked(saveModifiedSettings).mock.calls.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const calls = vi.mocked(saveModifiedSettings).mock.calls;
|
||||
calls.forEach((call) => {
|
||||
const [modifiedKeys, pendingSettings] = call;
|
||||
|
||||
if (modifiedKeys.has('tools.shell.showColor')) {
|
||||
const shellSettings = pendingSettings.tools?.shell as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
Object.entries(expectedSiblings).forEach(([key, value]) => {
|
||||
expect(shellSettings?.[key]).toBe(value);
|
||||
expect(modifiedKeys.has(`tools.shell.${key}`)).toBe(false);
|
||||
});
|
||||
|
||||
expect(modifiedKeys.size).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Shortcuts Edge Cases', () => {
|
||||
@@ -1319,7 +1257,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'Changes that require a restart have been modified',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1366,7 +1304,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'Changes that require a restart have been modified',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1385,9 +1323,11 @@ describe('SettingsDialog', () => {
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount, rerender, waitUntilReady } = render(
|
||||
<KeypressProvider>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -1424,14 +1364,13 @@ describe('SettingsDialog', () => {
|
||||
path: '',
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
rerender(
|
||||
rerender(
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
<SettingsDialog onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
// Press Escape to exit
|
||||
await act(async () => {
|
||||
@@ -1447,6 +1386,74 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array Settings Editing', () => {
|
||||
const typeInput = async (
|
||||
stdin: { write: (data: string) => void },
|
||||
input: string,
|
||||
) => {
|
||||
for (const ch of input) {
|
||||
await act(async () => {
|
||||
stdin.write(ch);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
it('should parse comma-separated input as string arrays', async () => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(ARRAY_FAKE_SCHEMA);
|
||||
const settings = createMockSettings();
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
|
||||
const { stdin, unmount } = renderDialog(settings, vi.fn());
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Start editing first array setting
|
||||
});
|
||||
await typeInput(stdin, 'first/path, second/path,third/path');
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Commit
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'context.fileFiltering.customIgnoreFilePaths',
|
||||
['first/path', 'second/path', 'third/path'],
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should parse JSON array input for allowedExtensions', async () => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(ARRAY_FAKE_SCHEMA);
|
||||
const settings = createMockSettings();
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
|
||||
const { stdin, unmount } = renderDialog(settings, vi.fn());
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string); // Move to second array setting
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Start editing
|
||||
});
|
||||
await typeInput(stdin, '["^github\\\\.com/.*$", "^gitlab\\\\.com/.*$"]');
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Commit
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.allowedExtensions',
|
||||
['^github\\.com/.*$', '^gitlab\\.com/.*$'],
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should display text entered in search', async () => {
|
||||
const settings = createMockSettings();
|
||||
|
||||
@@ -5,40 +5,35 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type {
|
||||
LoadableSettingScope,
|
||||
LoadedSettings,
|
||||
Settings,
|
||||
} from '../../config/settings.js';
|
||||
import type { LoadableSettingScope, Settings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||
import {
|
||||
getDialogSettingKeys,
|
||||
setPendingSettingValue,
|
||||
getDisplayValue,
|
||||
hasRestartRequiredSettings,
|
||||
saveModifiedSettings,
|
||||
getSettingDefinition,
|
||||
isDefaultValue,
|
||||
requiresRestart,
|
||||
getRestartRequiredFromModified,
|
||||
getEffectiveDefaultValue,
|
||||
setPendingSettingValueAny,
|
||||
getDialogRestartRequiredSettings,
|
||||
getEffectiveValue,
|
||||
isInSettingsScope,
|
||||
getEditValue,
|
||||
parseEditedValue,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import {
|
||||
useSettingsStore,
|
||||
type SettingsState,
|
||||
} from '../contexts/SettingsContext.js';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import {
|
||||
type SettingsType,
|
||||
type SettingsValue,
|
||||
TOGGLE_TYPES,
|
||||
} from '../../config/settingsSchema.js';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
|
||||
import {
|
||||
@@ -55,31 +50,56 @@ interface FzfResult {
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
settings: LoadedSettings;
|
||||
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
|
||||
onRestartRequest?: () => void;
|
||||
availableTerminalHeight?: number;
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
const MAX_ITEMS_TO_SHOW = 8;
|
||||
|
||||
// Create a snapshot of the initial per-scope state of Restart Required Settings
|
||||
// This creates a nested map of the form
|
||||
// restartRequiredSetting -> Map { scopeName -> value }
|
||||
function getActiveRestartRequiredSettings(
|
||||
settings: SettingsState,
|
||||
): Map<string, Map<string, string>> {
|
||||
const snapshot = new Map<string, Map<string, string>>();
|
||||
const scopes: Array<[string, Settings]> = [
|
||||
['User', settings.user.settings],
|
||||
['Workspace', settings.workspace.settings],
|
||||
['System', settings.system.settings],
|
||||
];
|
||||
|
||||
for (const key of getDialogRestartRequiredSettings()) {
|
||||
const scopeMap = new Map<string, string>();
|
||||
for (const [scopeName, scopeSettings] of scopes) {
|
||||
// Raw per-scope value (undefined if not in file)
|
||||
const value = isInSettingsScope(key, scopeSettings)
|
||||
? getEffectiveValue(key, scopeSettings)
|
||||
: undefined;
|
||||
scopeMap.set(scopeName, JSON.stringify(value));
|
||||
}
|
||||
snapshot.set(key, scopeMap);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function SettingsDialog({
|
||||
settings,
|
||||
onSelect,
|
||||
onRestartRequest,
|
||||
availableTerminalHeight,
|
||||
config,
|
||||
}: SettingsDialogProps): React.JSX.Element {
|
||||
// Get vim mode context to sync vim mode changes
|
||||
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
||||
// Reactive settings from store (re-renders on any settings change)
|
||||
const { settings, setSetting } = useSettingsStore();
|
||||
|
||||
// Scope selector state (User by default)
|
||||
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||
// Snapshot restart-required values at mount time for diff tracking
|
||||
const [activeRestartRequiredSettings] = useState(() =>
|
||||
getActiveRestartRequiredSettings(settings),
|
||||
);
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -136,52 +156,34 @@ export function SettingsDialog({
|
||||
};
|
||||
}, [searchQuery, fzfInstance, searchMap]);
|
||||
|
||||
// Local pending settings state for the selected scope
|
||||
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
||||
// Deep clone to avoid mutation
|
||||
structuredClone(settings.forScope(selectedScope).settings),
|
||||
);
|
||||
// Track whether a restart is required to apply the changes in the Settings json file
|
||||
// This does not care for inheritance
|
||||
// It checks whether a proposed change from this UI to a settings.json file requires a restart to take effect in the app
|
||||
const pendingRestartRequiredSettings = useMemo(() => {
|
||||
const changed = new Set<string>();
|
||||
const scopes: Array<[string, Settings]> = [
|
||||
['User', settings.user.settings],
|
||||
['Workspace', settings.workspace.settings],
|
||||
['System', settings.system.settings],
|
||||
];
|
||||
|
||||
// Track which settings have been modified by the user
|
||||
const [modifiedSettings, setModifiedSettings] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Preserve pending changes across scope switches
|
||||
type PendingValue = boolean | number | string;
|
||||
const [globalPendingChanges, setGlobalPendingChanges] = useState<
|
||||
Map<string, PendingValue>
|
||||
>(new Map());
|
||||
|
||||
// Track restart-required settings across scope changes
|
||||
const [_restartRequiredSettings, setRestartRequiredSettings] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// Base settings for selected scope
|
||||
let updated = structuredClone(settings.forScope(selectedScope).settings);
|
||||
// Overlay globally pending (unsaved) changes so user sees their modifications in any scope
|
||||
const newModified = new Set<string>();
|
||||
const newRestartRequired = new Set<string>();
|
||||
for (const [key, value] of globalPendingChanges.entries()) {
|
||||
const def = getSettingDefinition(key);
|
||||
if (def?.type === 'boolean' && typeof value === 'boolean') {
|
||||
updated = setPendingSettingValue(key, value, updated);
|
||||
} else if (
|
||||
(def?.type === 'number' && typeof value === 'number') ||
|
||||
(def?.type === 'string' && typeof value === 'string')
|
||||
) {
|
||||
updated = setPendingSettingValueAny(key, value, updated);
|
||||
// Iterate through the nested map snapshot in activeRestartRequiredSettings, diff with current settings
|
||||
for (const [key, initialScopeMap] of activeRestartRequiredSettings) {
|
||||
for (const [scopeName, scopeSettings] of scopes) {
|
||||
const currentValue = isInSettingsScope(key, scopeSettings)
|
||||
? getEffectiveValue(key, scopeSettings)
|
||||
: undefined;
|
||||
const initialJson = initialScopeMap.get(scopeName);
|
||||
if (JSON.stringify(currentValue) !== initialJson) {
|
||||
changed.add(key);
|
||||
break; // one scope changed is enough
|
||||
}
|
||||
}
|
||||
newModified.add(key);
|
||||
if (requiresRestart(key)) newRestartRequired.add(key);
|
||||
}
|
||||
setPendingSettings(updated);
|
||||
setModifiedSettings(newModified);
|
||||
setRestartRequiredSettings(newRestartRequired);
|
||||
setShowRestartPrompt(newRestartRequired.size > 0);
|
||||
}, [selectedScope, settings, globalPendingChanges]);
|
||||
return changed;
|
||||
}, [settings, activeRestartRequiredSettings]);
|
||||
|
||||
const showRestartPrompt = pendingRestartRequiredSettings.size > 0;
|
||||
|
||||
// Calculate max width for the left column (Label/Description) to keep values aligned or close
|
||||
const maxLabelOrDescriptionWidth = useMemo(() => {
|
||||
@@ -222,16 +224,10 @@ export function SettingsDialog({
|
||||
|
||||
return settingKeys.map((key) => {
|
||||
const definition = getSettingDefinition(key);
|
||||
const type = definition?.type ?? 'string';
|
||||
const type: SettingsType = definition?.type ?? 'string';
|
||||
|
||||
// Get the display value (with * indicator if modified)
|
||||
const displayValue = getDisplayValue(
|
||||
key,
|
||||
scopeSettings,
|
||||
mergedSettings,
|
||||
modifiedSettings,
|
||||
pendingSettings,
|
||||
);
|
||||
const displayValue = getDisplayValue(key, scopeSettings, mergedSettings);
|
||||
|
||||
// Get the scope message (e.g., "(Modified in Workspace)")
|
||||
const scopeMessage = getScopeMessageForSetting(
|
||||
@@ -240,28 +236,28 @@ export function SettingsDialog({
|
||||
settings,
|
||||
);
|
||||
|
||||
// Check if the value is at default (grey it out)
|
||||
const isGreyedOut = isDefaultValue(key, scopeSettings);
|
||||
// Grey out values that defer to defaults
|
||||
const isGreyedOut = !isInSettingsScope(key, scopeSettings);
|
||||
|
||||
// Get raw value for edit mode initialization
|
||||
const rawValue = getEffectiveValue(key, pendingSettings, {});
|
||||
// Some settings can be edited by an inline editor
|
||||
const rawValue = getEffectiveValue(key, scopeSettings);
|
||||
// The inline editor needs a string but non primitive settings like Arrays and Objects exist
|
||||
const editValue = getEditValue(type, rawValue);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: definition?.label || key,
|
||||
description: definition?.description,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
type: type as 'boolean' | 'number' | 'string' | 'enum',
|
||||
type,
|
||||
displayValue,
|
||||
isGreyedOut,
|
||||
scopeMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
rawValue: rawValue as string | number | boolean | undefined,
|
||||
rawValue,
|
||||
editValue,
|
||||
};
|
||||
});
|
||||
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
|
||||
}, [settingKeys, selectedScope, settings]);
|
||||
|
||||
// Scope selection handler
|
||||
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
}, []);
|
||||
@@ -273,17 +269,21 @@ export function SettingsDialog({
|
||||
if (!TOGGLE_TYPES.has(definition?.type)) {
|
||||
return;
|
||||
}
|
||||
const currentValue = getEffectiveValue(key, pendingSettings, {});
|
||||
|
||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||
const currentValue = getEffectiveValue(key, scopeSettings);
|
||||
let newValue: SettingsValue;
|
||||
|
||||
if (definition?.type === 'boolean') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
newValue = !(currentValue as boolean);
|
||||
setPendingSettings((prev) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
setPendingSettingValue(key, newValue as boolean, prev),
|
||||
);
|
||||
if (typeof currentValue !== 'boolean') {
|
||||
return;
|
||||
}
|
||||
newValue = !currentValue;
|
||||
} else if (definition?.type === 'enum' && definition.options) {
|
||||
const options = definition.options;
|
||||
if (options.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentIndex = options?.findIndex(
|
||||
(opt) => opt.value === currentValue,
|
||||
);
|
||||
@@ -292,303 +292,58 @@ export function SettingsDialog({
|
||||
} else {
|
||||
newValue = options[0].value; // loop back to start.
|
||||
}
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValueAny(key, newValue, prev),
|
||||
);
|
||||
}
|
||||
|
||||
if (!requiresRestart(key)) {
|
||||
const immediateSettings = new Set([key]);
|
||||
const currentScopeSettings = settings.forScope(selectedScope).settings;
|
||||
const immediateSettingsObject = setPendingSettingValueAny(
|
||||
key,
|
||||
newValue,
|
||||
currentScopeSettings,
|
||||
);
|
||||
debugLogger.log(
|
||||
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
|
||||
newValue,
|
||||
);
|
||||
saveModifiedSettings(
|
||||
immediateSettings,
|
||||
immediateSettingsObject,
|
||||
settings,
|
||||
selectedScope,
|
||||
);
|
||||
|
||||
// Special handling for vim mode to sync with VimModeContext
|
||||
if (key === 'general.vimMode' && newValue !== vimEnabled) {
|
||||
// Call toggleVimEnabled to sync the VimModeContext local state
|
||||
toggleVimEnabled().catch((error) => {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'Failed to toggle vim mode:',
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from modifiedSettings since it's now saved
|
||||
setModifiedSettings((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.delete(key);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Also remove from restart-required settings if it was there
|
||||
setRestartRequiredSettings((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.delete(key);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Remove from global pending changes if present
|
||||
setGlobalPendingChanges((prev) => {
|
||||
if (!prev.has(key)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// For restart-required settings, track as modified
|
||||
setModifiedSettings((prev) => {
|
||||
const updated = new Set(prev).add(key);
|
||||
const needsRestart = hasRestartRequiredSettings(updated);
|
||||
debugLogger.log(
|
||||
`[DEBUG SettingsDialog] Modified settings:`,
|
||||
Array.from(updated),
|
||||
'Needs restart:',
|
||||
needsRestart,
|
||||
);
|
||||
if (needsRestart) {
|
||||
setShowRestartPrompt(true);
|
||||
setRestartRequiredSettings((prevRestart) =>
|
||||
new Set(prevRestart).add(key),
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Record pending change globally
|
||||
setGlobalPendingChanges((prev) => {
|
||||
const next = new Map(prev);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
next.set(key, newValue as PendingValue);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[pendingSettings, settings, selectedScope, vimEnabled, toggleVimEnabled],
|
||||
);
|
||||
|
||||
// Edit commit handler
|
||||
const handleEditCommit = useCallback(
|
||||
(key: string, newValue: string, _item: SettingsDialogItem) => {
|
||||
const definition = getSettingDefinition(key);
|
||||
const type = definition?.type;
|
||||
|
||||
if (newValue.trim() === '' && type === 'number') {
|
||||
// Nothing entered for a number; cancel edit
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: string | number;
|
||||
if (type === 'number') {
|
||||
const numParsed = Number(newValue.trim());
|
||||
if (Number.isNaN(numParsed)) {
|
||||
// Invalid number; cancel edit
|
||||
return;
|
||||
}
|
||||
parsed = numParsed;
|
||||
} else {
|
||||
// For strings, use the buffer as is.
|
||||
parsed = newValue;
|
||||
}
|
||||
|
||||
// Update pending
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValueAny(key, parsed, prev),
|
||||
debugLogger.log(
|
||||
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
|
||||
newValue,
|
||||
);
|
||||
|
||||
if (!requiresRestart(key)) {
|
||||
const immediateSettings = new Set([key]);
|
||||
const currentScopeSettings = settings.forScope(selectedScope).settings;
|
||||
const immediateSettingsObject = setPendingSettingValueAny(
|
||||
key,
|
||||
parsed,
|
||||
currentScopeSettings,
|
||||
);
|
||||
saveModifiedSettings(
|
||||
immediateSettings,
|
||||
immediateSettingsObject,
|
||||
settings,
|
||||
selectedScope,
|
||||
);
|
||||
|
||||
// Remove from modified sets if present
|
||||
setModifiedSettings((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.delete(key);
|
||||
return updated;
|
||||
});
|
||||
setRestartRequiredSettings((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.delete(key);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Remove from global pending since it's immediately saved
|
||||
setGlobalPendingChanges((prev) => {
|
||||
if (!prev.has(key)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// Mark as modified and needing restart
|
||||
setModifiedSettings((prev) => {
|
||||
const updated = new Set(prev).add(key);
|
||||
const needsRestart = hasRestartRequiredSettings(updated);
|
||||
if (needsRestart) {
|
||||
setShowRestartPrompt(true);
|
||||
setRestartRequiredSettings((prevRestart) =>
|
||||
new Set(prevRestart).add(key),
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Record pending change globally for persistence across scopes
|
||||
setGlobalPendingChanges((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, parsed as PendingValue);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setSetting(selectedScope, key, newValue);
|
||||
},
|
||||
[settings, selectedScope],
|
||||
[settings, selectedScope, setSetting],
|
||||
);
|
||||
|
||||
// For inline editor
|
||||
const handleEditCommit = useCallback(
|
||||
(key: string, newValue: string, _item: SettingsDialogItem) => {
|
||||
const definition = getSettingDefinition(key);
|
||||
const type: SettingsType = definition?.type ?? 'string';
|
||||
const parsed = parseEditedValue(type, newValue);
|
||||
|
||||
if (parsed === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSetting(selectedScope, key, parsed);
|
||||
},
|
||||
[selectedScope, setSetting],
|
||||
);
|
||||
|
||||
// Clear/reset handler - removes the value from settings.json so it falls back to default
|
||||
const handleItemClear = useCallback(
|
||||
(key: string, _item: SettingsDialogItem) => {
|
||||
const defaultValue = getEffectiveDefaultValue(key, config);
|
||||
|
||||
// Update local pending state to show the default value
|
||||
if (typeof defaultValue === 'boolean') {
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValue(key, defaultValue, prev),
|
||||
);
|
||||
} else if (
|
||||
typeof defaultValue === 'number' ||
|
||||
typeof defaultValue === 'string'
|
||||
) {
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValueAny(key, defaultValue, prev),
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the value from settings.json (set to undefined to remove the key)
|
||||
if (!requiresRestart(key)) {
|
||||
settings.setValue(selectedScope, key, undefined);
|
||||
|
||||
// Special handling for vim mode
|
||||
if (key === 'general.vimMode') {
|
||||
const booleanDefaultValue =
|
||||
typeof defaultValue === 'boolean' ? defaultValue : false;
|
||||
if (booleanDefaultValue !== vimEnabled) {
|
||||
toggleVimEnabled().catch((error) => {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'Failed to toggle vim mode:',
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from modified sets
|
||||
setModifiedSettings((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.delete(key);
|
||||
return updated;
|
||||
});
|
||||
setRestartRequiredSettings((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.delete(key);
|
||||
return updated;
|
||||
});
|
||||
setGlobalPendingChanges((prev) => {
|
||||
if (!prev.has(key)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Update restart prompt
|
||||
setShowRestartPrompt((_prev) => {
|
||||
const remaining = getRestartRequiredFromModified(modifiedSettings);
|
||||
return remaining.filter((k) => k !== key).length > 0;
|
||||
});
|
||||
setSetting(selectedScope, key, undefined);
|
||||
},
|
||||
[
|
||||
config,
|
||||
settings,
|
||||
selectedScope,
|
||||
vimEnabled,
|
||||
toggleVimEnabled,
|
||||
modifiedSettings,
|
||||
],
|
||||
[selectedScope, setSetting],
|
||||
);
|
||||
|
||||
const saveRestartRequiredSettings = useCallback(() => {
|
||||
const restartRequiredSettings =
|
||||
getRestartRequiredFromModified(modifiedSettings);
|
||||
const restartRequiredSet = new Set(restartRequiredSettings);
|
||||
|
||||
if (restartRequiredSet.size > 0) {
|
||||
saveModifiedSettings(
|
||||
restartRequiredSet,
|
||||
pendingSettings,
|
||||
settings,
|
||||
selectedScope,
|
||||
);
|
||||
|
||||
// Remove saved keys from global pending changes
|
||||
setGlobalPendingChanges((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Map(prev);
|
||||
for (const key of restartRequiredSet) {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [modifiedSettings, pendingSettings, settings, selectedScope]);
|
||||
|
||||
// Close handler
|
||||
const handleClose = useCallback(() => {
|
||||
// Save any restart-required settings before closing
|
||||
saveRestartRequiredSettings();
|
||||
onSelect(undefined, selectedScope as SettingScope);
|
||||
}, [saveRestartRequiredSettings, onSelect, selectedScope]);
|
||||
}, [onSelect, selectedScope]);
|
||||
|
||||
// Custom key handler for restart key
|
||||
const handleKeyPress = useCallback(
|
||||
(key: Key, _currentItem: SettingsDialogItem | undefined): boolean => {
|
||||
// 'r' key for restart
|
||||
if (showRestartPrompt && key.sequence === 'r') {
|
||||
saveRestartRequiredSettings();
|
||||
setShowRestartPrompt(false);
|
||||
setModifiedSettings(new Set());
|
||||
setRestartRequiredSettings(new Set());
|
||||
if (onRestartRequest) onRestartRequest();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[showRestartPrompt, onRestartRequest, saveRestartRequiredSettings],
|
||||
[showRestartPrompt, onRestartRequest],
|
||||
);
|
||||
|
||||
// Calculate effective max items and scope visibility based on terminal height
|
||||
@@ -673,11 +428,10 @@ export function SettingsDialog({
|
||||
showRestartPrompt,
|
||||
]);
|
||||
|
||||
// Footer content for restart prompt
|
||||
const footerContent = showRestartPrompt ? (
|
||||
<Text color={theme.status.warning}>
|
||||
To see changes, Gemini CLI must be restarted. Press r to exit and apply
|
||||
changes now.
|
||||
Changes that require a restart have been modified. Press r to exit and
|
||||
apply changes now.
|
||||
</Text>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -11,22 +11,18 @@ import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Tips', () => {
|
||||
it.each([
|
||||
[0, '3. Create GEMINI.md files'],
|
||||
[5, '3. /help for more information'],
|
||||
])(
|
||||
'renders correct tips when file count is %i',
|
||||
async (count, expectedText) => {
|
||||
const config = {
|
||||
getGeminiMdFileCount: vi.fn().mockReturnValue(count),
|
||||
} as unknown as Config;
|
||||
{ fileCount: 0, description: 'renders all tips including GEMINI.md tip' },
|
||||
{ fileCount: 5, description: 'renders fewer tips when GEMINI.md exists' },
|
||||
])('$description', async ({ fileCount }) => {
|
||||
const config = {
|
||||
getGeminiMdFileCount: vi.fn().mockReturnValue(fileCount),
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<Tips config={config} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(expectedText);
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<Tips config={config} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -15,30 +15,26 @@ interface TipsProps {
|
||||
|
||||
export const Tips: React.FC<TipsProps> = ({ config }) => {
|
||||
const geminiMdFileCount = config.getGeminiMdFileCount();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>Tips for getting started:</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
1. Ask questions, edit files, or run commands.
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
2. Be specific for the best results.
|
||||
</Text>
|
||||
{geminiMdFileCount === 0 && (
|
||||
<Text color={theme.text.primary}>
|
||||
3. Create{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
GEMINI.md
|
||||
</Text>{' '}
|
||||
files to customize your interactions with Gemini.
|
||||
1. Create <Text bold>GEMINI.md</Text> files to customize your
|
||||
interactions
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}>
|
||||
{geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
/help
|
||||
</Text>{' '}
|
||||
for more information.
|
||||
{geminiMdFileCount === 0 ? '2.' : '1.'}{' '}
|
||||
<Text color={theme.text.secondary}>/help</Text> for more information
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{geminiMdFileCount === 0 ? '3.' : '2.'} Ask coding questions, edit code
|
||||
or run commands
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{geminiMdFileCount === 0 ? '4.' : '3.'} Be specific for the best results
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -45,12 +45,12 @@ describe('<UserIdentity />', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google: test@example.com');
|
||||
expect(output).toContain('test@example.com');
|
||||
expect(output).toContain('/auth');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render login message without colon if email is missing', async () => {
|
||||
it('should render login message if email is missing', async () => {
|
||||
// Modify the mock for this specific test
|
||||
vi.mocked(UserAccountManager).mockImplementationOnce(
|
||||
() =>
|
||||
@@ -73,12 +73,11 @@ describe('<UserIdentity />', () => {
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google');
|
||||
expect(output).not.toContain('Logged in with Google:');
|
||||
expect(output).toContain('/auth');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render plan name on a separate line if provided', async () => {
|
||||
it('should render plan name and upgrade indicator', async () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
@@ -92,18 +91,10 @@ describe('<UserIdentity />', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google: test@example.com');
|
||||
expect(output).toContain('test@example.com');
|
||||
expect(output).toContain('/auth');
|
||||
expect(output).toContain('Plan: Premium Plan');
|
||||
|
||||
// Check for two lines (or more if wrapped, but here it should be separate)
|
||||
const lines = output?.split('\n').filter((line) => line.trim().length > 0);
|
||||
expect(lines?.some((line) => line.includes('Logged in with Google'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(lines?.some((line) => line.includes('Plan: Premium Plan'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(output).toContain('Premium Plan');
|
||||
expect(output).toContain('/upgrade');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
@@ -20,42 +20,45 @@ interface UserIdentityProps {
|
||||
|
||||
export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const [email, setEmail] = useState<string | undefined>();
|
||||
|
||||
const { email, tierName } = useMemo(() => {
|
||||
if (!authType) {
|
||||
return { email: undefined, tierName: undefined };
|
||||
useEffect(() => {
|
||||
if (authType) {
|
||||
const userAccountManager = new UserAccountManager();
|
||||
setEmail(userAccountManager.getCachedGoogleAccount() ?? undefined);
|
||||
}
|
||||
const userAccountManager = new UserAccountManager();
|
||||
return {
|
||||
email: userAccountManager.getCachedGoogleAccount(),
|
||||
tierName: config.getUserTierName(),
|
||||
};
|
||||
}, [config, authType]);
|
||||
}, [authType]);
|
||||
|
||||
const tierName = useMemo(
|
||||
() => (authType ? config.getUserTierName() : undefined),
|
||||
[config, authType],
|
||||
);
|
||||
|
||||
if (!authType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
{/* User Email /auth */}
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
{authType === AuthType.LOGIN_WITH_GOOGLE ? (
|
||||
<Text>
|
||||
<Text bold>Logged in with Google{email ? ':' : ''}</Text>
|
||||
{email ? ` ${email}` : ''}
|
||||
</Text>
|
||||
<Text>{email ?? 'Logged in with Google'}</Text>
|
||||
) : (
|
||||
`Authenticated with ${authType}`
|
||||
)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> /auth</Text>
|
||||
</Box>
|
||||
{tierName && (
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold>Plan:</Text> {tierName}
|
||||
|
||||
{/* Tier Name /upgrade */}
|
||||
<Box>
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
{tierName ?? 'Gemini Code Assist for individuals'}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.secondary}> /upgrade</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
+54
-72
@@ -2,20 +2,17 @@
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
Action Required (was prompted):
|
||||
|
||||
@@ -25,20 +22,17 @@ Action Required (was prompted):
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
@@ -52,39 +46,33 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
@@ -98,39 +86,33 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Hello Gemini
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
|
||||
@@ -2,82 +2,70 @@
|
||||
|
||||
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with default text 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ This is the default banner │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ There are capacity issues │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`HooksDialog > snapshots > renders empty hooks dialog 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ No hooks configured. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`HooksDialog > snapshots > renders hook using command as name when name is not provided 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Security Warning: │
|
||||
│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
|
||||
│ Review hook scripts carefully. │
|
||||
│ │
|
||||
│ Learn more: https://geminicli.com/docs/hooks │
|
||||
│ │
|
||||
│ Configured Hooks │
|
||||
│ │
|
||||
│ before-tool │
|
||||
│ │
|
||||
│ echo hello [enabled] │
|
||||
│ Source: /mock/path │
|
||||
│ │
|
||||
│ │
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`HooksDialog > snapshots > renders hook with all metadata (matcher, sequential, timeout) 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Security Warning: │
|
||||
│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
|
||||
│ Review hook scripts carefully. │
|
||||
│ │
|
||||
│ Learn more: https://geminicli.com/docs/hooks │
|
||||
│ │
|
||||
│ Configured Hooks │
|
||||
│ │
|
||||
│ before-tool │
|
||||
│ │
|
||||
│ my-hook [enabled] │
|
||||
│ A hook with all metadata fields │
|
||||
│ Source: /mock/path/GEMINI.md | Matcher: shell_exec | Sequential | Timeout: 30s │
|
||||
│ │
|
||||
│ │
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`HooksDialog > snapshots > renders hooks grouped by event name with enabled and disabled status 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Security Warning: │
|
||||
│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
|
||||
│ Review hook scripts carefully. │
|
||||
│ │
|
||||
│ Learn more: https://geminicli.com/docs/hooks │
|
||||
│ │
|
||||
│ Configured Hooks │
|
||||
│ │
|
||||
│ before-tool │
|
||||
│ │
|
||||
│ hook1 [enabled] │
|
||||
│ Test hook: hook1 │
|
||||
│ Source: /mock/path/GEMINI.md | Command: run-hook1 │
|
||||
│ │
|
||||
│ hook2 [disabled] │
|
||||
│ Test hook: hook2 │
|
||||
│ Source: /mock/path/GEMINI.md | Command: run-hook2 │
|
||||
│ │
|
||||
│ after-agent │
|
||||
│ │
|
||||
│ hook3 [enabled] │
|
||||
│ Test hook: hook3 │
|
||||
│ Source: /mock/path/GEMINI.md | Command: run-hook3 │
|
||||
│ │
|
||||
│ │
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`HooksDialog > snapshots > renders single hook with security warning, source, and tips 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Security Warning: │
|
||||
│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
|
||||
│ Review hook scripts carefully. │
|
||||
│ │
|
||||
│ Learn more: https://geminicli.com/docs/hooks │
|
||||
│ │
|
||||
│ Configured Hooks │
|
||||
│ │
|
||||
│ before-tool │
|
||||
│ │
|
||||
│ test-hook [enabled] │
|
||||
│ Test hook: test-hook │
|
||||
│ Source: /mock/path/GEMINI.md | Command: run-test-hook │
|
||||
│ │
|
||||
│ │
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SessionRetentionWarningDialog > should match snapshot 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Keep chat history │
|
||||
│ │
|
||||
│ To keep your workspace clean, we are introducing a limit on how long chat sessions are stored. │
|
||||
│ Please choose a retention period for your existing chats: │
|
||||
│ │
|
||||
│ │
|
||||
│ 1. Keep for 30 days (Recommended) │
|
||||
│ 123 sessions will be deleted │
|
||||
│ ● 2. Keep for 120 days │
|
||||
│ No sessions will be deleted at this time │
|
||||
│ │
|
||||
│ Set a custom limit /settings and change "Keep chat history". │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Tips > 'renders all tips including GEMINI.md …' 1`] = `
|
||||
"
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Tips > 'renders fewer tips when GEMINI.md exi…' 1`] = `
|
||||
"
|
||||
Tips for getting started:
|
||||
1. /help for more information
|
||||
2. Ask coding questions, edit code or run commands
|
||||
3. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render, cleanup } from '../../../test-utils/render.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
import type { SubagentProgress } from '@google/gemini-cli-core';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
|
||||
vi.mock('ink-spinner', () => ({
|
||||
default: () => <Text>⠋</Text>,
|
||||
}));
|
||||
|
||||
describe('<SubagentProgressDisplay />', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders correctly with description in args', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: '{"command": "echo hello", "description": "Say hello"}',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly with displayName and description from item', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
displayName: 'RunShellCommand',
|
||||
description: 'Executing echo hello',
|
||||
args: '{"command": "echo hello"}',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly with command fallback', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '2',
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: '{"command": "echo hello"}',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly with file_path', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '3',
|
||||
type: 'tool_call',
|
||||
content: 'write_file',
|
||||
args: '{"file_path": "/tmp/test.txt", "content": "foo"}',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('truncates long args', async () => {
|
||||
const longDesc =
|
||||
'This is a very long description that should definitely be truncated because it exceeds the limit of sixty characters.';
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '4',
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: JSON.stringify({ description: longDesc }),
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders thought bubbles correctly', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '5',
|
||||
type: 'thought',
|
||||
content: 'Thinking about life',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders cancelled state correctly', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [],
|
||||
state: 'cancelled',
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders "Request cancelled." with the info icon', async () => {
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '6',
|
||||
type: 'thought',
|
||||
content: 'Request cancelled.',
|
||||
status: 'error',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import Spinner from 'ink-spinner';
|
||||
import type {
|
||||
SubagentProgress,
|
||||
SubagentActivityItem,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { TOOL_STATUS } from '../../constants.js';
|
||||
import { STATUS_INDICATOR_WIDTH } from './ToolShared.js';
|
||||
|
||||
export interface SubagentProgressDisplayProps {
|
||||
progress: SubagentProgress;
|
||||
}
|
||||
|
||||
const formatToolArgs = (args?: string): string => {
|
||||
if (!args) return '';
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(args);
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return args;
|
||||
}
|
||||
|
||||
if (
|
||||
'description' in parsed &&
|
||||
typeof parsed.description === 'string' &&
|
||||
parsed.description
|
||||
) {
|
||||
return parsed.description;
|
||||
}
|
||||
if ('command' in parsed && typeof parsed.command === 'string')
|
||||
return parsed.command;
|
||||
if ('file_path' in parsed && typeof parsed.file_path === 'string')
|
||||
return parsed.file_path;
|
||||
if ('dir_path' in parsed && typeof parsed.dir_path === 'string')
|
||||
return parsed.dir_path;
|
||||
if ('query' in parsed && typeof parsed.query === 'string')
|
||||
return parsed.query;
|
||||
if ('url' in parsed && typeof parsed.url === 'string') return parsed.url;
|
||||
if ('target' in parsed && typeof parsed.target === 'string')
|
||||
return parsed.target;
|
||||
|
||||
return args;
|
||||
} catch {
|
||||
return args;
|
||||
}
|
||||
};
|
||||
|
||||
export const SubagentProgressDisplay: React.FC<
|
||||
SubagentProgressDisplayProps
|
||||
> = ({ progress }) => {
|
||||
let headerText: string | undefined;
|
||||
let headerColor = theme.text.secondary;
|
||||
|
||||
if (progress.state === 'cancelled') {
|
||||
headerText = `Subagent ${progress.agentName} was cancelled.`;
|
||||
headerColor = theme.status.warning;
|
||||
} else if (progress.state === 'error') {
|
||||
headerText = `Subagent ${progress.agentName} failed.`;
|
||||
headerColor = theme.status.error;
|
||||
} else if (progress.state === 'completed') {
|
||||
headerText = `Subagent ${progress.agentName} completed.`;
|
||||
headerColor = theme.status.success;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={0}>
|
||||
{headerText && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={headerColor} italic>
|
||||
{headerText}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="column" marginLeft={0} gap={0}>
|
||||
{progress.recentActivity.map((item: SubagentActivityItem) => {
|
||||
if (item.type === 'thought') {
|
||||
const isCancellation = item.content === 'Request cancelled.';
|
||||
const icon = isCancellation ? 'ℹ ' : '💭';
|
||||
const color = isCancellation
|
||||
? theme.status.warning
|
||||
: theme.text.secondary;
|
||||
|
||||
return (
|
||||
<Box key={item.id} flexDirection="row">
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>
|
||||
<Text color={color}>{icon}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={color}>{item.content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else if (item.type === 'tool_call') {
|
||||
const statusSymbol =
|
||||
item.status === 'running' ? (
|
||||
<Spinner type="dots" />
|
||||
) : item.status === 'completed' ? (
|
||||
<Text color={theme.status.success}>{TOOL_STATUS.SUCCESS}</Text>
|
||||
) : item.status === 'cancelled' ? (
|
||||
<Text color={theme.status.warning} bold>
|
||||
{TOOL_STATUS.CANCELED}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>{TOOL_STATUS.ERROR}</Text>
|
||||
);
|
||||
|
||||
const formattedArgs = item.description || formatToolArgs(item.args);
|
||||
const displayArgs =
|
||||
formattedArgs.length > 60
|
||||
? formattedArgs.slice(0, 60) + '...'
|
||||
: formattedArgs;
|
||||
|
||||
return (
|
||||
<Box key={item.id} flexDirection="row">
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusSymbol}</Box>
|
||||
<Box flexDirection="row" flexGrow={1} flexWrap="wrap">
|
||||
<Text
|
||||
bold
|
||||
color={theme.text.primary}
|
||||
strikethrough={item.status === 'cancelled'}
|
||||
>
|
||||
{item.displayName || item.content}
|
||||
</Text>
|
||||
{displayArgs && (
|
||||
<Box marginLeft={1}>
|
||||
<Text
|
||||
color={theme.text.secondary}
|
||||
wrap="truncate"
|
||||
strikethrough={item.status === 'cancelled'}
|
||||
>
|
||||
{displayArgs}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -75,6 +75,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
status: t.status,
|
||||
approvalMode: t.approvalMode,
|
||||
hasResultDisplay: !!t.resultDisplay,
|
||||
parentCallId: t.parentCallId,
|
||||
});
|
||||
}),
|
||||
[allToolCalls, isLowErrorVerbosity],
|
||||
|
||||
@@ -11,7 +11,11 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
isSubagentProgress,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
@@ -20,6 +24,7 @@ import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
|
||||
// Large threshold to ensure we don't cause performance issues for very large
|
||||
// outputs that will get truncated further MaxSizedBox anyway.
|
||||
@@ -167,6 +172,8 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
{formattedJSON}
|
||||
</Text>
|
||||
);
|
||||
} else if (isSubagentProgress(truncatedResultDisplay)) {
|
||||
content = <SubagentProgressDisplay progress={truncatedResultDisplay} />;
|
||||
} else if (
|
||||
typeof truncatedResultDisplay === 'string' &&
|
||||
renderOutputAsMarkdown
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders "Request cancelled." with the info icon 1`] = `
|
||||
"ℹ Request cancelled.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders cancelled state correctly 1`] = `
|
||||
"Subagent TestAgent was cancelled.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with command fallback 1`] = `
|
||||
"⠋ run_shell_command echo hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with description in args 1`] = `
|
||||
"⠋ run_shell_command Say hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with displayName and description from item 1`] = `
|
||||
"⠋ RunShellCommand Executing echo hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with file_path 1`] = `
|
||||
"✓ write_file /tmp/test.txt
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders thought bubbles correctly 1`] = `
|
||||
"💭 Thinking about life
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > truncates long args 1`] = `
|
||||
"⠋ run_shell_command This is a very long description that should definitely be tr...
|
||||
"
|
||||
`;
|
||||
@@ -531,6 +531,37 @@ describe('BaseSettingsDialog', () => {
|
||||
});
|
||||
|
||||
describe('edit mode', () => {
|
||||
it('should prioritize editValue over rawValue stringification', async () => {
|
||||
const objectItem: SettingsDialogItem = {
|
||||
key: 'object-setting',
|
||||
label: 'Object Setting',
|
||||
description: 'A complex object setting',
|
||||
displayValue: '{"foo":"bar"}',
|
||||
type: 'object',
|
||||
rawValue: { foo: 'bar' },
|
||||
editValue: '{"foo":"bar"}',
|
||||
};
|
||||
const { stdin } = await renderDialog({
|
||||
items: [objectItem],
|
||||
});
|
||||
|
||||
// Enter edit mode and immediately commit
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||||
'object-setting',
|
||||
'{"foo":"bar"}',
|
||||
expect.objectContaining({ type: 'object' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit edit on Enter', async () => {
|
||||
const items = createMockItems(4);
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
|
||||
@@ -9,6 +9,10 @@ import { Box, Text } from 'ink';
|
||||
import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { LoadableSettingScope } from '../../../config/settings.js';
|
||||
import type {
|
||||
SettingsType,
|
||||
SettingsValue,
|
||||
} from '../../../config/settingsSchema.js';
|
||||
import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
|
||||
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
||||
import { TextInput } from './TextInput.js';
|
||||
@@ -33,7 +37,7 @@ export interface SettingsDialogItem {
|
||||
/** Optional description below label */
|
||||
description?: string;
|
||||
/** Item type for determining interaction behavior */
|
||||
type: 'boolean' | 'number' | 'string' | 'enum';
|
||||
type: SettingsType;
|
||||
/** Pre-formatted display value (with * if modified) */
|
||||
displayValue: string;
|
||||
/** Grey out value (at default) */
|
||||
@@ -41,7 +45,9 @@ export interface SettingsDialogItem {
|
||||
/** Scope message e.g., "(Modified in Workspace)" */
|
||||
scopeMessage?: string;
|
||||
/** Raw value for edit mode initialization */
|
||||
rawValue?: string | number | boolean;
|
||||
rawValue?: SettingsValue;
|
||||
/** Optional pre-formatted edit buffer value for complex types */
|
||||
editValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,9 +387,11 @@ export function BaseSettingsDialog({
|
||||
if (currentItem.type === 'boolean' || currentItem.type === 'enum') {
|
||||
onItemToggle(currentItem.key, currentItem);
|
||||
} else {
|
||||
// Start editing for string/number
|
||||
// Start editing for string/number/array/object
|
||||
const rawVal = currentItem.rawValue;
|
||||
const initialValue = rawVal !== undefined ? String(rawVal) : '';
|
||||
const initialValue =
|
||||
currentItem.editValue ??
|
||||
(rawVal !== undefined ? String(rawVal) : '');
|
||||
startEditing(currentItem.key, initialValue);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
|
||||
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface ExtensionRegistryViewProps {
|
||||
export interface ExtensionRegistryViewProps {
|
||||
onSelect?: (extension: RegistryExtension) => void;
|
||||
onClose?: () => void;
|
||||
extensionManager: ExtensionManager;
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface HooksListProps {
|
||||
hooks: ReadonlyArray<{
|
||||
config: {
|
||||
command?: string;
|
||||
type: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
source: string;
|
||||
eventName: string;
|
||||
matcher?: string;
|
||||
sequential?: boolean;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const HooksList: React.FC<HooksListProps> = ({ hooks }) => {
|
||||
if (hooks.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>No hooks configured.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Group hooks by event name for better organization
|
||||
const hooksByEvent = hooks.reduce(
|
||||
(acc, hook) => {
|
||||
if (!acc[hook.eventName]) {
|
||||
acc[hook.eventName] = [];
|
||||
}
|
||||
acc[hook.eventName].push(hook);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Array<(typeof hooks)[number]>>,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.status.warning} bold underline>
|
||||
⚠️ Security Warning:
|
||||
</Text>
|
||||
<Text color={theme.status.warning}>
|
||||
Hooks can execute arbitrary commands on your system. Only use hooks
|
||||
from sources you trust. Review hook scripts carefully.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
Learn more:{' '}
|
||||
<Text color={theme.text.link}>https://geminicli.com/docs/hooks</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text bold>Configured Hooks:</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
|
||||
{Object.entries(hooksByEvent).map(([eventName, eventHooks]) => (
|
||||
<Box key={eventName} flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.accent} bold>
|
||||
{eventName}:
|
||||
</Text>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{eventHooks.map((hook, index) => {
|
||||
const hookName =
|
||||
hook.config.name || hook.config.command || 'unknown';
|
||||
const statusColor = hook.enabled
|
||||
? theme.status.success
|
||||
: theme.text.secondary;
|
||||
const statusText = hook.enabled ? 'enabled' : 'disabled';
|
||||
|
||||
return (
|
||||
<Box key={`${eventName}-${index}`} flexDirection="column">
|
||||
<Box>
|
||||
<Text>
|
||||
<Text color={theme.text.accent}>{hookName}</Text>
|
||||
<Text color={statusColor}>{` [${statusText}]`}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
{hook.config.description && (
|
||||
<Text italic>{hook.config.description}</Text>
|
||||
)}
|
||||
<Text dimColor>
|
||||
Source: {hook.source}
|
||||
{hook.config.name &&
|
||||
hook.config.command &&
|
||||
` | Command: ${hook.config.command}`}
|
||||
{hook.matcher && ` | Matcher: ${hook.matcher}`}
|
||||
{hook.sequential && ` | Sequential`}
|
||||
{hook.config.timeout &&
|
||||
` | Timeout: ${hook.config.timeout}s`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Tip: Use <Text bold>/hooks enable {'<hook-name>'}</Text> or{' '}
|
||||
<Text bold>/hooks disable {'<hook-name>'}</Text> to toggle individual
|
||||
hooks. Use <Text bold>/hooks enable-all</Text> or{' '}
|
||||
<Text bold>/hooks disable-all</Text> to toggle all hooks at once.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
SettingsFile,
|
||||
} from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { checkExhaustive } from '@google/gemini-cli-core';
|
||||
|
||||
export const SettingsContext = React.createContext<LoadedSettings | undefined>(
|
||||
undefined,
|
||||
@@ -66,7 +67,7 @@ export const useSettingsStore = (): SettingsStoreValue => {
|
||||
case SettingScope.SystemDefaults:
|
||||
return snapshot.systemDefaults;
|
||||
default:
|
||||
throw new Error(`Invalid scope: ${scope}`);
|
||||
checkExhaustive(scope);
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -107,8 +107,6 @@ export interface UIState {
|
||||
history: HistoryItem[];
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
isThemeDialogOpen: boolean;
|
||||
shouldShowRetentionWarning: boolean;
|
||||
sessionsToDeleteCount: number;
|
||||
themeError: string | null;
|
||||
isAuthenticating: boolean;
|
||||
isConfigInitialized: boolean;
|
||||
|
||||
@@ -4,15 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { useSettingsStore } from './SettingsContext.js';
|
||||
|
||||
export type VimMode = 'NORMAL' | 'INSERT';
|
||||
|
||||
@@ -27,35 +21,22 @@ const VimModeContext = createContext<VimModeContextType | undefined>(undefined);
|
||||
|
||||
export const VimModeProvider = ({
|
||||
children,
|
||||
settings,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
settings: LoadedSettings;
|
||||
}) => {
|
||||
const initialVimEnabled = settings.merged.general.vimMode;
|
||||
const [vimEnabled, setVimEnabled] = useState(initialVimEnabled);
|
||||
const { settings, setSetting } = useSettingsStore();
|
||||
const vimEnabled = settings.merged.general.vimMode;
|
||||
const [vimMode, setVimMode] = useState<VimMode>('INSERT');
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize vimEnabled from settings on mount
|
||||
const enabled = settings.merged.general.vimMode;
|
||||
setVimEnabled(enabled);
|
||||
// When vim mode is enabled, start in INSERT mode
|
||||
if (enabled) {
|
||||
setVimMode('INSERT');
|
||||
}
|
||||
}, [settings.merged.general.vimMode]);
|
||||
|
||||
const toggleVimEnabled = useCallback(async () => {
|
||||
const newValue = !vimEnabled;
|
||||
setVimEnabled(newValue);
|
||||
// When enabling vim mode, start in INSERT mode
|
||||
if (newValue) {
|
||||
setVimMode('INSERT');
|
||||
}
|
||||
settings.setValue(SettingScope.User, 'general.vimMode', newValue);
|
||||
setSetting(SettingScope.User, 'general.vimMode', newValue);
|
||||
return newValue;
|
||||
}, [vimEnabled, settings]);
|
||||
}, [vimEnabled, setSetting]);
|
||||
|
||||
const value = {
|
||||
vimEnabled,
|
||||
|
||||
@@ -48,6 +48,7 @@ export function mapToDisplay(
|
||||
|
||||
const baseDisplayProperties = {
|
||||
callId: call.request.callId,
|
||||
parentCallId: call.request.parentCallId,
|
||||
name: displayName,
|
||||
description,
|
||||
renderOutputAsMarkdown,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
+25
-117
@@ -1,123 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="275" fill="#000000" />
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="19" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="19" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="19" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="19" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="19" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="144" y="19" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="19" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="19" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="19" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="19" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="19" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="36" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="9" y="36" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="18" y="36" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="27" y="36" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="36" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="36" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="36" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="36" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="36" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="36" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="144" y="36" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="153" y="36" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="36" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="36" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="36" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="36" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="198" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="53" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="27" y="53" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="36" y="53" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="45" y="53" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="53" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="63" y="53" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="99" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="53" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="53" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="53" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="53" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="189" y="53" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="36" y="70" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="45" y="70" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="54" y="70" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="63" y="70" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="72" y="70" fill="#7382d4" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="81" y="70" fill="#797fd2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="90" y="70" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="70" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="70" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="87" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="87" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="63" y="87" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="72" y="87" fill="#7382d4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="90" y="87" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="87" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="87" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="87" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="87" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="87" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="87" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="87" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="198" y="87" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="104" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="104" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="104" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="104" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="90" y="104" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="104" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="104" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="104" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="104" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="104" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="104" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="104" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="104" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="104" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="9" y="121" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="121" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="121" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="121" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="121" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="121" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="117" y="121" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="121" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="121" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="144" y="121" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="121" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="121" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="121" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="121" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="121" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="138" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="9" y="138" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="18" y="138" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="138" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="117" y="138" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="126" y="138" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="135" y="138" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="144" y="138" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="153" y="138" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="138" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="138" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="138" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="0" y="172" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="189" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="189" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> ⊷ google_web_search </text>
|
||||
<text x="855" y="189" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="206" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="206" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="223" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="223" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> Searching... </text>
|
||||
<text x="855" y="223" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="240" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="63" y="19" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#6c7086" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="121" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> ⊷ google_web_search </text>
|
||||
<text x="855" y="121" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="155" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> Searching... </text>
|
||||
<text x="855" y="155" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.3 KiB |
+25
-117
@@ -1,123 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="275" fill="#000000" />
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="19" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="19" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="19" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="19" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="19" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="144" y="19" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="19" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="19" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="19" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="19" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="19" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="36" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="9" y="36" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="18" y="36" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="27" y="36" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="36" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="36" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="36" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="36" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="36" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="36" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="144" y="36" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="153" y="36" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="36" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="36" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="36" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="36" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="198" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="53" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="27" y="53" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="36" y="53" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="45" y="53" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="53" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="63" y="53" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="99" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="53" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="53" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="53" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="53" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="189" y="53" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="36" y="70" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="45" y="70" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="54" y="70" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="63" y="70" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="72" y="70" fill="#7382d4" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="81" y="70" fill="#797fd2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="90" y="70" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="70" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="70" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="87" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="87" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="63" y="87" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="72" y="87" fill="#7382d4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="90" y="87" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="87" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="87" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="87" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="87" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="87" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="87" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="87" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="198" y="87" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="104" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="104" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="104" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="104" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="90" y="104" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="104" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="104" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="104" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="104" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="104" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="104" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="104" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="104" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="104" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="9" y="121" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="121" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="121" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="121" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="121" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="121" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="117" y="121" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="121" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="121" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="144" y="121" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="121" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="121" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="121" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="121" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="121" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="138" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="9" y="138" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="18" y="138" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="138" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="117" y="138" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="126" y="138" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="135" y="138" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="144" y="138" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="153" y="138" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="138" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="138" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="138" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="0" y="172" fill="#6c7086" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="189" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="189" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> ⊷ run_shell_command </text>
|
||||
<text x="855" y="189" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="206" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="206" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="223" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="223" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> Running command... </text>
|
||||
<text x="855" y="223" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="240" fill="#6c7086" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="63" y="19" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#6c7086" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#6c7086" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="121" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> ⊷ run_shell_command </text>
|
||||
<text x="855" y="121" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="155" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> Running command... </text>
|
||||
<text x="855" y="155" fill="#6c7086" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#6c7086" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.3 KiB |
+25
-117
@@ -1,123 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="275" fill="#000000" />
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="19" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="19" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="19" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="19" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="19" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="144" y="19" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="19" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="19" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="19" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="19" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="19" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="36" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="9" y="36" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="18" y="36" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="27" y="36" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="36" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="36" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="36" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="36" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="36" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="36" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="144" y="36" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="153" y="36" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="36" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="36" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="36" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="36" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="198" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="53" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="27" y="53" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="36" y="53" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="45" y="53" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="53" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="63" y="53" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="99" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="53" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="53" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="53" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="53" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="189" y="53" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="36" y="70" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="45" y="70" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="54" y="70" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="63" y="70" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="72" y="70" fill="#7382d4" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="81" y="70" fill="#797fd2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="90" y="70" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="70" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="70" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="87" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="87" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="63" y="87" fill="#6e84d6" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="72" y="87" fill="#7382d4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="90" y="87" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="87" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="108" y="87" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="87" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="87" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="87" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="87" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="87" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="198" y="87" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="104" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="104" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="45" y="104" fill="#6389da" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="54" y="104" fill="#6887d8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="90" y="104" fill="#7e7dd0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="104" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="104" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="117" y="104" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="104" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="104" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="104" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="104" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="104" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="104" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="9" y="121" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="18" y="121" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="27" y="121" fill="#588ede" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="36" y="121" fill="#5d8cdc" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="99" y="121" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="121" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="117" y="121" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="126" y="121" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="135" y="121" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="144" y="121" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="153" y="121" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="162" y="121" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="171" y="121" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="180" y="121" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="189" y="121" fill="#bd6986" textLength="9" lengthAdjust="spacingAndGlyphs">█</text>
|
||||
<text x="0" y="138" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="9" y="138" fill="#4d93e2" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="18" y="138" fill="#5291e0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="108" y="138" fill="#8a78c7" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="117" y="138" fill="#8f77c0" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="126" y="138" fill="#9575b8" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="135" y="138" fill="#9b73b1" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="144" y="138" fill="#a171aa" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="153" y="138" fill="#a670a3" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="162" y="138" fill="#ac6e9c" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="171" y="138" fill="#b26c95" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="180" y="138" fill="#b86a8d" textLength="9" lengthAdjust="spacingAndGlyphs">░</text>
|
||||
<text x="0" y="172" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="189" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="189" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> ⊷ google_web_search </text>
|
||||
<text x="855" y="189" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="206" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="206" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="223" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="223" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> Searching... </text>
|
||||
<text x="855" y="223" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="240" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="63" y="19" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#6c7086" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="121" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> ⊷ google_web_search </text>
|
||||
<text x="855" y="121" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="155" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs"> Searching... </text>
|
||||
<text x="855" y="155" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#f9e2af" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.3 KiB |
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user