chore: sync gemini-cli v0.1.19

This commit is contained in:
tanzhenxin
2025-08-18 19:55:46 +08:00
244 changed files with 19407 additions and 5030 deletions

View File

@@ -36,6 +36,14 @@ jobs:
- name: Run linter - name: Run linter
run: npm run lint:ci run: npm run lint:ci
- name: Run linter on integration tests
run: npx eslint integration-tests --max-warnings 0
- name: Run formatter on integration tests
run: |
npx prettier --check integration-tests
git diff --exit-code
- name: Build project - name: Build project
run: npm run build run: npm run build

View File

@@ -2,7 +2,33 @@ name: Qwen Automated Issue Triage
on: on:
issues: issues:
types: [opened, reopened] types:
- 'opened'
- 'reopened'
issue_comment:
types:
- 'created'
workflow_dispatch:
inputs:
issue_number:
description: 'issue number to triage'
required: true
type: 'number'
concurrency:
group: '${{ github.workflow }}-${{ github.event.issue.number }}'
cancel-in-progress: true
defaults:
run:
shell: 'bash'
permissions:
contents: 'read'
id-token: 'write'
issues: 'write'
statuses: 'write'
packages: 'read'
jobs: jobs:
triage-issue: triage-issue:
@@ -28,30 +54,39 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
settings_json: | settings_json: |
{ {
"maxSessionTurns": 25,
"coreTools": [ "coreTools": [
"run_shell_command(echo)",
"run_shell_command(gh label list)", "run_shell_command(gh label list)",
"run_shell_command(gh issue edit)", "run_shell_command(gh issue edit)",
"run_shell_command(gh issue list)" "run_shell_command(gh issue list)"
], ],
"sandbox": false "sandbox": false
} }
prompt: | prompt: |-
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue. ## Role
Steps:
You are an issue triage assistant. Analyze the current GitHub issue and apply the most appropriate existing labels. Use the available
tools to gather information; do not ask for information to be provided. Do not remove labels titled help wanted or good first issue.
## Steps
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels. 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Review the issue title, body and any comments provided in the environment variables. 2. Review the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
3. Ignore any existing priorities or tags on the issue. Just report your findings. 3. Ignore any existing priorities or tags on the issue. Just report your findings.
4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case. 4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"` 6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`.
7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label 7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label.
8. If you see that the issue doesnt look like it has sufficient information recommend the status/need-information label 8. If you see that the issue doesnt look like it has sufficient information recommend the status/need-information label.
9. Use Area definitions mentioned below to help you narrow down issues 9. Use Area definitions mentioned below to help you narrow down issues.
Guidelines:
## Guidelines
- Only use labels that already exist in the repository. - Only use labels that already exist in the repository.
- Do not add comments or modify the issue content. - Do not add comments or modify the issue content.
- Triage only the current issue. - Triage only the current issue.
- Apply only one area/ label - Apply only one area/ label.
- Apply only one kind/ label - Apply only one kind/ label.
- Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these. - Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario. - Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
Categorization Guidelines: Categorization Guidelines:
@@ -130,3 +165,163 @@ jobs:
- could also pertain to latency, - could also pertain to latency,
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency. - other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
- Switching models from one to the other unexpectedly. - Switching models from one to the other unexpectedly.
- name: 'Post Issue Triage Failure Comment'
if: |-
${{ failure() && steps.gemini_issue_triage.outcome == 'failure' }}
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
github.rest.issues.createComment({
owner: '${{ github.repository }}'.split('/')[0],
repo: '${{ github.repository }}'.split('/')[1],
issue_number: '${{ github.event.issue.number }}',
body: 'There is a problem with the Gemini CLI issue triaging. Please check the [action logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.'
})
deduplicate-issues:
if: >
github.repository == 'google-gemini/gemini-cli' &&
vars.TRIAGE_DEDUPLICATE_ISSUES != '' &&
(github.event_name == 'issues' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@gemini-cli /deduplicate') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')))
timeout-minutes: 20
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout repository'
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683'
- name: 'Generate GitHub App Token'
id: 'generate_token'
uses: 'actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e'
with:
app-id: '${{ secrets.APP_ID }}'
private-key: '${{ secrets.PRIVATE_KEY }}'
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 'Run Gemini Issue Deduplication'
uses: 'google-github-actions/run-gemini-cli@20351b5ea2b4179431f1ae8918a246a0808f8747'
id: 'gemini_issue_deduplication'
env:
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
ISSUE_TITLE: '${{ github.event.issue.title }}'
ISSUE_BODY: '${{ github.event.issue.body }}'
ISSUE_NUMBER: '${{ github.event.issue.number }}'
REPOSITORY: '${{ github.repository }}'
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}'
gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}'
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}'
use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}'
settings: |-
{
"mcpServers": {
"issue_deduplication": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"--network", "host",
"-e", "GITHUB_TOKEN",
"-e", "GEMINI_API_KEY",
"-e", "DATABASE_TYPE",
"-e", "FIRESTORE_DATABASE_ID",
"-e", "GCP_PROJECT",
"-e", "GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json",
"-v", "${GOOGLE_APPLICATION_CREDENTIALS}:/app/gcp-credentials.json",
"ghcr.io/google-gemini/gemini-cli-issue-triage@sha256:e3de1523f6c83aabb3c54b76d08940a2bf42febcb789dd2da6f95169641f94d3"
],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}",
"GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}",
"DATABASE_TYPE":"firestore",
"GCP_PROJECT": "${FIRESTORE_PROJECT}",
"FIRESTORE_DATABASE_ID": "(default)",
"GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}"
},
"enabled": true,
"timeout": 600000
}
},
"maxSessionTurns": 25,
"coreTools": [
"run_shell_command(echo)",
"run_shell_command(gh issue comment)",
"run_shell_command(gh issue view)",
"run_shell_command(gh issue edit)"
],
"telemetry": {
"enabled": true,
"target": "gcp"
}
}
prompt: |-
## Role
You are an issue de-duplication assistant. Your goal is to find
duplicate issues, label the current issue as a duplicate, and notify
the user by commenting on the current issue, while avoiding
duplicate comments.
## Steps
1. **Find Potential Duplicates:**
- The repository is ${{ github.repository }} and the issue number is ${{ github.event.issue.number }}.
- Use the `duplicates` tool with the `repo` and `issue_number` to find potential duplicates for the current issue. Do not use the `threshold` parameter.
- If no duplicates are found, you are done.
- Print the JSON output from the `duplicates` tool to the logs.
2. **Refine Duplicates List (if necessary):**
- If the `duplicates` tool returns between 1 and 14 results, you must refine the list.
- For each potential duplicate issue, run `gh issue view <issue-number> --json title,body,comments` to fetch its content.
- Also fetch the content of the original issue: `gh issue view "${ISSUE_NUMBER}" --json title,body,comments`.
- Carefully analyze the content (title, body, comments) of the original issue and all potential duplicates.
- It is very important if the comments on either issue mention that they are not duplicates of each other, to treat them as not duplicates.
- Based on your analysis, create a final list containing only the issues you are highly confident are actual duplicates.
- If your final list is empty, you are done.
- Print to the logs if you omitted any potential duplicates based on your analysis.
- If the `duplicates` tool returned 15+ results, use the top 15 matches (based on descending similarity score value) to perform this step.
3. **Format Final Duplicates List:**
Format the final list of duplicates into a markdown string.
The format should be:
"Found possible duplicate issues:\n\n- #${issue_number}\n\nIf you believe this is not a duplicate, please remove the `status/possible-duplicate` label."
Add an HTML comment to the end for identification: `<!-- gemini-cli-deduplication -->`
4. **Check for Existing Comment:**
- Run `gh issue view "${ISSUE_NUMBER}" --json comments` to get all
comments on the issue.
- Look for a comment made by a bot (the author's login often ends in `[bot]`) that contains `<!-- gemini-cli-deduplication -->`.
- If you find such a comment, store its `id` and `body`.
5. **Decide Action:**
- **If an existing comment is found:**
- Compare the new list of duplicate issues with the list from the existing comment's body.
- If they are the same, do nothing.
- If they are different, edit the existing comment. Use
`gh issue comment "${ISSUE_NUMBER}" --edit-comment <comment-id> --body "..."`.
The new body should be the new list of duplicates, but with the header "Found possible duplicate issues (updated):".
- **If no existing comment is found:**
- Create a new comment with the list of duplicates.
- Use `gh issue comment "${ISSUE_NUMBER}" --body "..."`.
6. **Add Duplicate Label:**
- If you created or updated a comment in the previous step, add the `duplicate` label to the current issue.
- Use `gh issue edit "${ISSUE_NUMBER}" --add-label "status/possible-duplicate"`.
## Guidelines
- Only use the `duplicates` and `run_shell_command` tools.
- The `run_shell_command` tool can be used with `gh issue view`, `gh issue comment`, and `gh issue edit`.
- Do not download or read media files like images, videos, or links. The `--json` flag for `gh issue view` will prevent this.
- Do not modify the issue content or status.
- Only comment on and label the current issue.
- Reference all shell variables as "${VAR}" (with quotes and braces).

View File

@@ -3,7 +3,22 @@ name: Qwen Scheduled Issue Triage
on: on:
schedule: schedule:
- cron: '0 * * * *' # Runs every hour - cron: '0 * * * *' # Runs every hour
workflow_dispatch: {} workflow_dispatch:
concurrency:
group: '${{ github.workflow }}'
cancel-in-progress: true
defaults:
run:
shell: 'bash'
permissions:
contents: 'read'
id-token: 'write'
issues: 'write'
statuses: 'write'
packages: 'read'
jobs: jobs:
triage-issues: triage-issues:
@@ -23,16 +38,19 @@ jobs:
echo "🔍 Finding issues without labels..." echo "🔍 Finding issues without labels..."
NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body) NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body)
echo "🏷️ Finding issues that need triage..." echo '🔍 Finding issues without labels...'
NEED_TRIAGE_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue label:\"status/need-triage\"" --json number,title,body) NO_LABEL_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue no:label' --json number,title,body)"
echo "🔄 Merging and deduplicating issues..." echo '🏷️ Finding issues that need triage...'
ISSUES=$(echo "$NO_LABEL_ISSUES" "$NEED_TRIAGE_ISSUES" | jq -c -s 'add | unique_by(.number)') NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue label:"status/needs-triage"' --json number,title,body)"
echo "📝 Setting output for GitHub Actions..." echo '🔄 Merging and deduplicating issues...'
echo "issues_to_triage=$ISSUES" >> "$GITHUB_OUTPUT" ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
echo "✅ Found $(echo "$ISSUES" | jq 'length') issues to triage! 🎯" echo '📝 Setting output for GitHub Actions...'
echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}"
- name: Run Qwen Issue Triage - name: Run Qwen Issue Triage
if: steps.find_issues.outputs.issues_to_triage != '[]' if: steps.find_issues.outputs.issues_to_triage != '[]'
@@ -48,18 +66,25 @@ jobs:
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }} OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
settings_json: | settings_json: |
{ {
"maxSessionTurns": 25,
"coreTools": [ "coreTools": [
"run_shell_command(echo)", "run_shell_command(echo)",
"run_shell_command(gh label list)", "run_shell_command(gh label list)",
"run_shell_command(gh issue edit)", "run_shell_command(gh issue edit)",
"run_shell_command(gh issue list)", "run_shell_command(gh issue view)",
"run_shell_command(gh issue view)" "run_shell_command(gh issue list)"
], ],
"sandbox": false "sandbox": false
} }
prompt: | prompt: |-
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. ## Role
Steps:
You are an issue triage assistant. Analyze issues and apply
appropriate labels. Use the available tools to gather information;
do not ask for information to be provided.
## Steps
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels. 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues) 2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
3. Review the issue title, body and any comments provided in the environment variables. 3. Review the issue title, body and any comments provided in the environment variables.
@@ -84,8 +109,10 @@ jobs:
- After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"` - After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"`
- Execute one `gh issue edit` command per issue, wait for success before proceeding to the next - Execute one `gh issue edit` command per issue, wait for success before proceeding to the next
Process each issue sequentially and confirm each labeling operation before moving to the next issue. Process each issue sequentially and confirm each labeling operation before moving to the next issue.
Guidelines:
- Only use labels that already exist in the repository. ## Guidelines
- Only use labels that already exist in the repository.
- Do not add comments or modify the issue content. - Do not add comments or modify the issue content.
- Do not remove labels titled help wanted or good first issue. - Do not remove labels titled help wanted or good first issue.
- Triage only the current issue. - Triage only the current issue.

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["vitest.explorer", "esbenp.prettier-vscode"]
}

15
.vscode/settings.json vendored
View File

@@ -1,3 +1,16 @@
{ {
"typescript.tsserver.experimental.enableProjectDiagnostics": true "typescript.tsserver.experimental.enableProjectDiagnostics": true,
"editor.tabSize": 2,
"editor.rulers": [80],
"editor.detectIndentation": false,
"editor.insertSpaces": true,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
} }

View File

@@ -99,6 +99,11 @@ Slash commands provide meta-level control over the CLI itself.
- **Usage:** `/restore [tool_call_id]` - **Usage:** `/restore [tool_call_id]`
- **Note:** Only available if the CLI is invoked with the `--checkpointing` option or configured via [settings](./configuration.md). See [Checkpointing documentation](../checkpointing.md) for more details. - **Note:** Only available if the CLI is invoked with the `--checkpointing` option or configured via [settings](./configuration.md). See [Checkpointing documentation](../checkpointing.md) for more details.
- **`/settings`**
- **Description:** Open the settings editor to view and modify Gemini CLI settings.
- **Details:** This command provides a user-friendly interface for changing settings that control the behavior and appearance of Gemini CLI. It is equivalent to manually editing the `.gemini/settings.json` file, but with validation and guidance to prevent errors.
- **Usage:** Simply run `/settings` and the editor will open. You can then browse or search for specific settings, view their current values, and modify them as desired. Changes to some settings are applied immediately, while others require a restart.
- **`/stats`** - **`/stats`**
- **Description:** Display detailed statistics for the current Qwen Code session, including token usage, cached token savings (when available), and session duration. Note: Cached token information is only displayed when cached tokens are being used, which occurs with API key authentication but not with OAuth authentication at this time. - **Description:** Display detailed statistics for the current Qwen Code session, including token usage, cached token savings (when available), and session duration. Note: Cached token information is only displayed when cached tokens are being used, which occurs with API key authentication but not with OAuth authentication at this time.

View File

@@ -272,6 +272,25 @@ In addition to a project settings file, a project's `.gemini` directory can cont
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped. - **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
- **Default:** `undefined` (web search disabled) - **Default:** `undefined` (web search disabled)
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"` - **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
- **`chatCompression`** (object):
- **Description:** Controls the settings for chat history compression, both automatic and
when manually invoked through the /compress command.
- **Properties:**
- **`contextPercentageThreshold`** (number): A value between 0 and 1 that specifies the token threshold for compression as a percentage of the model's total token limit. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit.
- **Example:**
```json
"chatCompression": {
"contextPercentageThreshold": 0.6
}
```
- **`showLineNumbers`** (boolean):
- **Description:** Controls whether line numbers are displayed in code blocks in the CLI output.
- **Default:** `true`
- **Example:**
```json
"showLineNumbers": false
```
### Example `settings.json`: ### Example `settings.json`:

View File

@@ -109,10 +109,10 @@ To check for linting errors, run the following command:
npm run lint npm run lint
``` ```
You can include the `--fix` flag in the command to automatically fix any fixable linting errors: You can include the `:fix` flag in the command to automatically fix any fixable linting errors:
```bash ```bash
npm run lint --fix npm run lint:fix
``` ```
## Directory structure ## Directory structure

View File

@@ -58,7 +58,17 @@ You can export all telemetry data to a file for local inspection.
To enable file export, use the `--telemetry-outfile` flag with a path to your desired output file. This must be run using `--telemetry-target=local`. To enable file export, use the `--telemetry-outfile` flag with a path to your desired output file. This must be run using `--telemetry-target=local`.
```bash ```bash
gemini --telemetry --telemetry-target=local --telemetry-outfile=/path/to/telemetry.log "your prompt" # Set your desired output file path
TELEMETRY_FILE=".gemini/telemetry.log"
# Run Gemini CLI with local telemetry
# NOTE: --telemetry-otlp-endpoint="" is required to override the default
# OTLP exporter and ensure telemetry is written to the local file.
gemini --telemetry \
--telemetry-target=local \
--telemetry-otlp-endpoint="" \
--telemetry-outfile="$TELEMETRY_FILE" \
--prompt "What is OpenTelemetry?"
``` ```
## Running an OTEL Collector ## Running an OTEL Collector
@@ -173,9 +183,10 @@ Logs are timestamped records of specific events. The following events are logged
- `function_args` - `function_args`
- `duration_ms` - `duration_ms`
- `success` (boolean) - `success` (boolean)
- `decision` (string: "accept", "reject", or "modify", if applicable) - `decision` (string: "accept", "reject", "auto_accept", or "modify", if applicable)
- `error` (if applicable) - `error` (if applicable)
- `error_type` (if applicable) - `error_type` (if applicable)
- `metadata` (if applicable, dictionary of string -> any)
- `gemini_cli.api_request`: This event occurs when making a request to Gemini API. - `gemini_cli.api_request`: This event occurs when making a request to Gemini API.
- **Attributes**: - **Attributes**:
@@ -252,3 +263,7 @@ Metrics are numerical measurements of behavior over time. The following metrics
- `lines` (Int, if applicable): Number of lines in the file. - `lines` (Int, if applicable): Number of lines in the file.
- `mimetype` (string, if applicable): Mimetype of the file. - `mimetype` (string, if applicable): Mimetype of the file.
- `extension` (string, if applicable): File extension of the file. - `extension` (string, if applicable): File extension of the file.
- `ai_added_lines` (Int, if applicable): Number of lines added/changed by AI.
- `ai_removed_lines` (Int, if applicable): Number of lines removed/changed by AI.
- `user_added_lines` (Int, if applicable): Number of lines added/changed by user in AI proposed changes.
- `user_removed_lines` (Int, if applicable): Number of lines removed/changed by user in AI proposed changes.

View File

@@ -688,3 +688,114 @@ or, using positional arguments:
``` ```
When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows. When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows.
## Managing MCP Servers with `gemini mcp`
While you can always configure MCP servers by manually editing your `settings.json` file, the Gemini CLI provides a convenient set of commands to manage your server configurations programmatically. These commands streamline the process of adding, listing, and removing MCP servers without needing to directly edit JSON files.
### Adding a Server (`gemini mcp add`)
The `add` command configures a new MCP server in your `settings.json`. Based on the scope (`-s, --scope`), it will be added to either the user config `~/.gemini/settings.json` or the project config `.gemini/settings.json` file.
**Command:**
```bash
gemini mcp add [options] <name> <commandOrUrl> [args...]
```
- `<name>`: A unique name for the server.
- `<commandOrUrl>`: The command to execute (for `stdio`) or the URL (for `http`/`sse`).
- `[args...]`: Optional arguments for a `stdio` command.
**Options (Flags):**
- `-s, --scope`: Configuration scope (user or project). [default: "project"]
- `-t, --transport`: Transport type (stdio, sse, http). [default: "stdio"]
- `-e, --env`: Set environment variables (e.g. -e KEY=value).
- `-H, --header`: Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123").
- `--timeout`: Set connection timeout in milliseconds.
- `--trust`: Trust the server (bypass all tool call confirmation prompts).
- `--description`: Set the description for the server.
- `--include-tools`: A comma-separated list of tools to include.
- `--exclude-tools`: A comma-separated list of tools to exclude.
#### Adding an stdio server
This is the default transport for running local servers.
```bash
# Basic syntax
gemini mcp add <name> <command> [args...]
# Example: Adding a local server
gemini mcp add my-stdio-server -e API_KEY=123 /path/to/server arg1 arg2 arg3
# Example: Adding a local python server
gemini mcp add python-server python server.py --port 8080
```
#### Adding an HTTP server
This transport is for servers that use the streamable HTTP transport.
```bash
# Basic syntax
gemini mcp add --transport http <name> <url>
# Example: Adding an HTTP server
gemini mcp add --transport http http-server https://api.example.com/mcp/
# Example: Adding an HTTP server with an authentication header
gemini mcp add --transport http secure-http https://api.example.com/mcp/ --header "Authorization: Bearer abc123"
```
#### Adding an SSE server
This transport is for servers that use Server-Sent Events (SSE).
```bash
# Basic syntax
gemini mcp add --transport sse <name> <url>
# Example: Adding an SSE server
gemini mcp add --transport sse sse-server https://api.example.com/sse/
# Example: Adding an SSE server with an authentication header
gemini mcp add --transport sse secure-sse https://api.example.com/sse/ --header "Authorization: Bearer abc123"
```
### Listing Servers (`gemini mcp list`)
To view all MCP servers currently configured, use the `list` command. It displays each server's name, configuration details, and connection status.
**Command:**
```bash
gemini mcp list
```
**Example Output:**
```sh
✓ stdio-server: command: python3 server.py (stdio) - Connected
✓ http-server: https://api.example.com/mcp (http) - Connected
✗ sse-server: https://api.example.com/sse (sse) - Disconnected
```
### Removing a Server (`gemini mcp remove`)
To delete a server from your configuration, use the `remove` command with the server's name.
**Command:**
```bash
gemini mcp remove <name>
```
**Example:**
```bash
gemini mcp remove my-server
```
This will find and delete the "my-server" entry from the `mcpServers` object in the appropriate `settings.json` file based on the scope (`-s, --scope`).

View File

@@ -35,6 +35,7 @@ export default tseslint.config(
'packages/vscode-ide-companion/dist/**', 'packages/vscode-ide-companion/dist/**',
'bundle/**', 'bundle/**',
'package/bundle/**', 'package/bundle/**',
'.integration-tests/**',
], ],
}, },
eslint.configs.recommended, eslint.configs.recommended,

View File

@@ -21,8 +21,8 @@ test('should be able to list a directory', async () => {
await rig.poll( await rig.poll(
() => { () => {
// Check if the files exist in the test directory // Check if the files exist in the test directory
const file1Path = join(rig.testDir, 'file1.txt'); const file1Path = join(rig.testDir!, 'file1.txt');
const subdirPath = join(rig.testDir, 'subdir'); const subdirPath = join(rig.testDir!, 'subdir');
return existsSync(file1Path) && existsSync(subdirPath); return existsSync(file1Path) && existsSync(subdirPath);
}, },
1000, // 1 second max wait 1000, // 1 second max wait

View File

@@ -0,0 +1,199 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* This test verifies we can match maximum schema depth errors from Gemini
* and then detect and warn about the potential tools that caused the error.
*/
import { test, describe, before } from 'node:test';
import { strict as assert } from 'node:assert';
import { TestRig } from './test-helper.js';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { writeFileSync } from 'fs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
// Create a minimal MCP server that doesn't require external dependencies
// This implements the MCP protocol directly using Node.js built-ins
const serverScript = `#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const readline = require('readline');
const fs = require('fs');
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
const debugEnabled = process.env.MCP_DEBUG === 'true' || process.env.VERBOSE === 'true';
function debug(msg) {
if (debugEnabled) {
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
}
}
debug('MCP server starting...');
// Simple JSON-RPC implementation for MCP
class SimpleJSONRPC {
constructor() {
this.handlers = new Map();
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
this.rl.on('line', (line) => {
debug(\`Received line: \${line}\`);
try {
const message = JSON.parse(line);
debug(\`Parsed message: \${JSON.stringify(message)}\`);
this.handleMessage(message);
} catch (e) {
debug(\`Parse error: \${e.message}\`);
}
});
}
send(message) {
const msgStr = JSON.stringify(message);
debug(\`Sending message: \${msgStr}\`);
process.stdout.write(msgStr + '\\n');
}
async handleMessage(message) {
if (message.method && this.handlers.has(message.method)) {
try {
const result = await this.handlers.get(message.method)(message.params || {});
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
result
});
}
} catch (error) {
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: error.message
}
});
}
}
} else if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32601,
message: 'Method not found'
}
});
}
}
on(method, handler) {
this.handlers.set(method, handler);
}
}
// Create MCP server
const rpc = new SimpleJSONRPC();
// Handle initialize
rpc.on('initialize', async (params) => {
debug('Handling initialize request');
return {
protocolVersion: '2024-11-05',
capabilities: {
tools: {}
},
serverInfo: {
name: 'cyclic-schema-server',
version: '1.0.0'
}
};
});
// Handle tools/list
rpc.on('tools/list', async () => {
debug('Handling tools/list request');
return {
tools: [{
name: 'tool_with_cyclic_schema',
inputSchema: {
type: 'object',
properties: {
data: {
type: 'array',
items: {
type: 'object',
properties: {
child: { $ref: '#/properties/data/items' },
},
},
},
},
}
}]
};
});
// Send initialization notification
rpc.send({
jsonrpc: '2.0',
method: 'initialized'
});
`;
describe('mcp server with cyclic tool schema is detected', () => {
const rig = new TestRig();
before(async () => {
// Setup test directory with MCP server configuration
await rig.setup('cyclic-schema-mcp-server', {
settings: {
mcpServers: {
'cyclic-schema-server': {
command: 'node',
args: ['mcp-server.cjs'],
},
},
},
});
// Create server script in the test directory
const testServerPath = join(rig.testDir, 'mcp-server.cjs');
writeFileSync(testServerPath, serverScript);
// Make the script executable (though running with 'node' should work anyway)
if (process.platform !== 'win32') {
const { chmodSync } = await import('fs');
chmodSync(testServerPath, 0o755);
}
});
test('should error and suggest disabling the cyclic tool', async () => {
// Just run any command to trigger the schema depth error.
// If this test starts failing, check `isSchemaDepthError` from
// geminiChat.ts to see if it needs to be updated.
// Or, possibly it could mean that gemini has fixed the issue.
const output = await rig.run('hello');
assert.match(
output,
/Skipping tool 'tool_with_cyclic_schema' from MCP server 'cyclic-schema-server' because it has missing types in its parameter schema/,
);
});
});

View File

@@ -52,13 +52,13 @@ async function main() {
const testPatterns = const testPatterns =
args.length > 0 args.length > 0
? args.map((arg) => `integration-tests/${arg}.test.js`) ? args.map((arg) => `integration-tests/${arg}.test.ts`)
: ['integration-tests/*.test.js']; : ['integration-tests/*.test.ts'];
const testFiles = glob.sync(testPatterns, { cwd: rootDir, absolute: true }); const testFiles = glob.sync(testPatterns, { cwd: rootDir, absolute: true });
for (const testFile of testFiles) { for (const testFile of testFiles) {
const testFileName = basename(testFile); const testFileName = basename(testFile);
console.log(`\tFound test file: ${testFileName}`); console.log(` Found test file: ${testFileName}`);
} }
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
@@ -92,7 +92,7 @@ async function main() {
} }
nodeArgs.push(testFile); nodeArgs.push(testFile);
const child = spawn('node', nodeArgs, { const child = spawn('npx', ['tsx', ...nodeArgs], {
stdio: 'pipe', stdio: 'pipe',
env: { env: {
...process.env, ...process.env,

View File

@@ -14,11 +14,8 @@ import { test, describe, before } from 'node:test';
import { strict as assert } from 'node:assert'; import { strict as assert } from 'node:assert';
import { TestRig, validateModelOutput } from './test-helper.js'; import { TestRig, validateModelOutput } from './test-helper.js';
import { join } from 'path'; import { join } from 'path';
import { fileURLToPath } from 'url';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
// Create a minimal MCP server that doesn't require external dependencies // Create a minimal MCP server that doesn't require external dependencies
// This implements the MCP protocol directly using Node.js built-ins // This implements the MCP protocol directly using Node.js built-ins
const serverScript = `#!/usr/bin/env node const serverScript = `#!/usr/bin/env node
@@ -185,7 +182,7 @@ describe('simple-mcp-server', () => {
}); });
// Create server script in the test directory // Create server script in the test directory
const testServerPath = join(rig.testDir, 'mcp-server.cjs'); const testServerPath = join(rig.testDir!, 'mcp-server.cjs');
writeFileSync(testServerPath, serverScript); writeFileSync(testServerPath, serverScript);
// Make the script executable (though running with 'node' should work anyway) // Make the script executable (though running with 'node' should work anyway)

View File

@@ -14,7 +14,7 @@ import { fileExists } from '../scripts/telemetry_utils.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
function sanitizeTestName(name) { function sanitizeTestName(name: string) {
return name return name
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]/g, '-') .replace(/[^a-z0-9]/g, '-')
@@ -22,7 +22,11 @@ function sanitizeTestName(name) {
} }
// Helper to create detailed error messages // Helper to create detailed error messages
export function createToolCallErrorMessage(expectedTools, foundTools, result) { export function createToolCallErrorMessage(
expectedTools: string | string[],
foundTools: string[],
result: string,
) {
const expectedStr = Array.isArray(expectedTools) const expectedStr = Array.isArray(expectedTools)
? expectedTools.join(' or ') ? expectedTools.join(' or ')
: expectedTools; : expectedTools;
@@ -34,7 +38,11 @@ export function createToolCallErrorMessage(expectedTools, foundTools, result) {
} }
// Helper to print debug information when tests fail // Helper to print debug information when tests fail
export function printDebugInfo(rig, result, context = {}) { export function printDebugInfo(
rig: TestRig,
result: string,
context: Record<string, unknown> = {},
) {
console.error('Test failed - Debug info:'); console.error('Test failed - Debug info:');
console.error('Result length:', result.length); console.error('Result length:', result.length);
console.error('Result (first 500 chars):', result.substring(0, 500)); console.error('Result (first 500 chars):', result.substring(0, 500));
@@ -60,8 +68,8 @@ export function printDebugInfo(rig, result, context = {}) {
// Helper to validate model output and warn about unexpected content // Helper to validate model output and warn about unexpected content
export function validateModelOutput( export function validateModelOutput(
result, result: string,
expectedContent = null, expectedContent: string | (string | RegExp)[] | null = null,
testName = '', testName = '',
) { ) {
// First, check if there's any output at all (this should fail the test if missing) // First, check if there's any output at all (this should fail the test if missing)
@@ -102,6 +110,11 @@ export function validateModelOutput(
} }
export class TestRig { export class TestRig {
bundlePath: string;
testDir: string | null;
testName?: string;
_lastRunStdout?: string;
constructor() { constructor() {
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js'); this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
this.testDir = null; this.testDir = null;
@@ -114,10 +127,13 @@ export class TestRig {
return 15000; // 15s locally return 15000; // 15s locally
} }
setup(testName, options = {}) { setup(
testName: string,
options: { settings?: Record<string, unknown> } = {},
) {
this.testName = testName; this.testName = testName;
const sanitizedName = sanitizeTestName(testName); const sanitizedName = sanitizeTestName(testName);
this.testDir = join(env.INTEGRATION_TEST_FILE_DIR, sanitizedName); this.testDir = join(env.INTEGRATION_TEST_FILE_DIR!, sanitizedName);
mkdirSync(this.testDir, { recursive: true }); mkdirSync(this.testDir, { recursive: true });
// Create a settings file to point the CLI to the local collector // Create a settings file to point the CLI to the local collector
@@ -146,36 +162,43 @@ export class TestRig {
); );
} }
createFile(fileName, content) { createFile(fileName: string, content: string) {
const filePath = join(this.testDir, fileName); const filePath = join(this.testDir!, fileName);
writeFileSync(filePath, content); writeFileSync(filePath, content);
return filePath; return filePath;
} }
mkdir(dir) { mkdir(dir: string) {
mkdirSync(join(this.testDir, dir), { recursive: true }); mkdirSync(join(this.testDir!, dir), { recursive: true });
} }
sync() { sync() {
// ensure file system is done before spawning // ensure file system is done before spawning
execSync('sync', { cwd: this.testDir }); execSync('sync', { cwd: this.testDir! });
} }
run(promptOrOptions, ...args) { run(
promptOrOptions: string | { prompt?: string; stdin?: string },
...args: string[]
): Promise<string> {
let command = `node ${this.bundlePath} --yolo`; let command = `node ${this.bundlePath} --yolo`;
const execOptions = { const execOptions: {
cwd: this.testDir, cwd: string;
encoding: 'utf-8';
input?: string;
} = {
cwd: this.testDir!,
encoding: 'utf-8', encoding: 'utf-8',
}; };
if (typeof promptOrOptions === 'string') { if (typeof promptOrOptions === 'string') {
command += ` --prompt "${promptOrOptions}"`; command += ` --prompt ${JSON.stringify(promptOrOptions)}`;
} else if ( } else if (
typeof promptOrOptions === 'object' && typeof promptOrOptions === 'object' &&
promptOrOptions !== null promptOrOptions !== null
) { ) {
if (promptOrOptions.prompt) { if (promptOrOptions.prompt) {
command += ` --prompt "${promptOrOptions.prompt}"`; command += ` --prompt ${JSON.stringify(promptOrOptions.prompt)}`;
} }
if (promptOrOptions.stdin) { if (promptOrOptions.stdin) {
execOptions.input = promptOrOptions.stdin; execOptions.input = promptOrOptions.stdin;
@@ -185,10 +208,10 @@ export class TestRig {
command += ` ${args.join(' ')}`; command += ` ${args.join(' ')}`;
const commandArgs = parse(command); const commandArgs = parse(command);
const node = commandArgs.shift(); const node = commandArgs.shift() as string;
const child = spawn(node, commandArgs, { const child = spawn(node, commandArgs as string[], {
cwd: this.testDir, cwd: this.testDir!,
stdio: 'pipe', stdio: 'pipe',
}); });
@@ -197,26 +220,26 @@ export class TestRig {
// Handle stdin if provided // Handle stdin if provided
if (execOptions.input) { if (execOptions.input) {
child.stdin.write(execOptions.input); child.stdin!.write(execOptions.input);
child.stdin.end(); child.stdin!.end();
} }
child.stdout.on('data', (data) => { child.stdout!.on('data', (data: Buffer) => {
stdout += data; stdout += data;
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
process.stdout.write(data); process.stdout.write(data);
} }
}); });
child.stderr.on('data', (data) => { child.stderr!.on('data', (data: Buffer) => {
stderr += data; stderr += data;
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
process.stderr.write(data); process.stderr.write(data);
} }
}); });
const promise = new Promise((resolve, reject) => { const promise = new Promise<string>((resolve, reject) => {
child.on('close', (code) => { child.on('close', (code: number) => {
if (code === 0) { if (code === 0) {
// Store the raw stdout for Podman telemetry parsing // Store the raw stdout for Podman telemetry parsing
this._lastRunStdout = stdout; this._lastRunStdout = stdout;
@@ -258,6 +281,11 @@ export class TestRig {
result = filteredLines.join('\n'); result = filteredLines.join('\n');
} }
// If we have stderr output, include that also
if (stderr) {
result += `\n\nStdErr:\n${stderr}`;
}
resolve(result); resolve(result);
} else { } else {
reject(new Error(`Process exited with code ${code}:\n${stderr}`)); reject(new Error(`Process exited with code ${code}:\n${stderr}`));
@@ -268,13 +296,13 @@ export class TestRig {
return promise; return promise;
} }
readFile(fileName) { readFile(fileName: string) {
const content = readFileSync(join(this.testDir, fileName), 'utf-8'); const content = readFileSync(join(this.testDir!, fileName), 'utf-8');
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
const testId = `${env.TEST_FILE_NAME.replace( const testId = `${env.TEST_FILE_NAME!.replace(
'.test.js', '.test.js',
'', '',
)}:${this.testName.replace(/ /g, '-')}`; )}:${this.testName!.replace(/ /g, '-')}`;
console.log(`--- FILE: ${testId}/${fileName} ---`); console.log(`--- FILE: ${testId}/${fileName} ---`);
console.log(content); console.log(content);
console.log(`--- END FILE: ${testId}/${fileName} ---`); console.log(`--- END FILE: ${testId}/${fileName} ---`);
@@ -290,7 +318,7 @@ export class TestRig {
} catch (error) { } catch (error) {
// Ignore cleanup errors // Ignore cleanup errors
if (env.VERBOSE === 'true') { if (env.VERBOSE === 'true') {
console.warn('Cleanup warning:', error.message); console.warn('Cleanup warning:', (error as Error).message);
} }
} }
} }
@@ -300,7 +328,7 @@ export class TestRig {
// In sandbox mode, telemetry is written to a relative path in the test directory // In sandbox mode, telemetry is written to a relative path in the test directory
const logFilePath = const logFilePath =
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false' env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
? join(this.testDir, 'telemetry.log') ? join(this.testDir!, 'telemetry.log')
: env.TELEMETRY_LOG_FILE; : env.TELEMETRY_LOG_FILE;
if (!logFilePath) return; if (!logFilePath) return;
@@ -313,7 +341,7 @@ export class TestRig {
const content = readFileSync(logFilePath, 'utf-8'); const content = readFileSync(logFilePath, 'utf-8');
// Check if file has meaningful content (at least one complete JSON object) // Check if file has meaningful content (at least one complete JSON object)
return content.includes('"event.name"'); return content.includes('"event.name"');
} catch (_e) { } catch {
return false; return false;
} }
}, },
@@ -322,7 +350,7 @@ export class TestRig {
); );
} }
async waitForToolCall(toolName, timeout) { async waitForToolCall(toolName: string, timeout?: number) {
// Use environment-specific timeout // Use environment-specific timeout
if (!timeout) { if (!timeout) {
timeout = this.getDefaultTimeout(); timeout = this.getDefaultTimeout();
@@ -341,7 +369,7 @@ export class TestRig {
); );
} }
async waitForAnyToolCall(toolNames, timeout) { async waitForAnyToolCall(toolNames: string[], timeout?: number) {
// Use environment-specific timeout // Use environment-specific timeout
if (!timeout) { if (!timeout) {
timeout = this.getDefaultTimeout(); timeout = this.getDefaultTimeout();
@@ -362,7 +390,11 @@ export class TestRig {
); );
} }
async poll(predicate, timeout, interval) { async poll(
predicate: () => boolean,
timeout: number,
interval: number,
): Promise<boolean> {
const startTime = Date.now(); const startTime = Date.now();
let attempts = 0; let attempts = 0;
while (Date.now() - startTime < timeout) { while (Date.now() - startTime < timeout) {
@@ -384,8 +416,16 @@ export class TestRig {
return false; return false;
} }
_parseToolLogsFromStdout(stdout) { _parseToolLogsFromStdout(stdout: string) {
const logs = []; const logs: {
timestamp: number;
toolRequest: {
name: string;
args: string;
success: boolean;
duration_ms: number;
};
}[] = [];
// The console output from Podman is JavaScript object notation, not JSON // The console output from Podman is JavaScript object notation, not JSON
// Look for tool call events in the output // Look for tool call events in the output
@@ -488,7 +528,7 @@ export class TestRig {
}, },
}); });
} }
} catch (_e) { } catch {
// Not valid JSON // Not valid JSON
} }
currentObject = ''; currentObject = '';
@@ -505,7 +545,7 @@ export class TestRig {
// If not, fall back to parsing from stdout // If not, fall back to parsing from stdout
if (env.GEMINI_SANDBOX === 'podman') { if (env.GEMINI_SANDBOX === 'podman') {
// Try reading from file first // Try reading from file first
const logFilePath = join(this.testDir, 'telemetry.log'); const logFilePath = join(this.testDir!, 'telemetry.log');
if (fileExists(logFilePath)) { if (fileExists(logFilePath)) {
try { try {
@@ -517,7 +557,7 @@ export class TestRig {
// File exists but is empty or doesn't have events, parse from stdout // File exists but is empty or doesn't have events, parse from stdout
return this._parseToolLogsFromStdout(this._lastRunStdout); return this._parseToolLogsFromStdout(this._lastRunStdout);
} }
} catch (_e) { } catch {
// Error reading file, fall back to stdout // Error reading file, fall back to stdout
if (this._lastRunStdout) { if (this._lastRunStdout) {
return this._parseToolLogsFromStdout(this._lastRunStdout); return this._parseToolLogsFromStdout(this._lastRunStdout);
@@ -532,7 +572,7 @@ export class TestRig {
// In sandbox mode, telemetry is written to a relative path in the test directory // In sandbox mode, telemetry is written to a relative path in the test directory
const logFilePath = const logFilePath =
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false' env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
? join(this.testDir, 'telemetry.log') ? join(this.testDir!, 'telemetry.log')
: env.TELEMETRY_LOG_FILE; : env.TELEMETRY_LOG_FILE;
if (!logFilePath) { if (!logFilePath) {
@@ -548,7 +588,7 @@ export class TestRig {
const content = readFileSync(logFilePath, 'utf-8'); const content = readFileSync(logFilePath, 'utf-8');
// Split the content into individual JSON objects // Split the content into individual JSON objects
// They are separated by "}\n{" pattern // They are separated by "}\n{"
const jsonObjects = content const jsonObjects = content
.split(/}\s*\n\s*{/) .split(/}\s*\n\s*{/)
.map((obj, index, array) => { .map((obj, index, array) => {
@@ -559,7 +599,14 @@ export class TestRig {
}) })
.filter((obj) => obj); .filter((obj) => obj);
const logs = []; const logs: {
toolRequest: {
name: string;
args: string;
success: boolean;
duration_ms: number;
};
}[] = [];
for (const jsonStr of jsonObjects) { for (const jsonStr of jsonObjects) {
try { try {
@@ -579,10 +626,13 @@ export class TestRig {
}, },
}); });
} }
} catch (_e) { } catch (e) {
// Skip objects that aren't valid JSON // Skip objects that aren't valid JSON
if (env.VERBOSE === 'true') { if (env.VERBOSE === 'true') {
console.error('Failed to parse telemetry object:', _e.message); console.error(
'Failed to parse telemetry object:',
(e as Error).message,
);
} }
} }
} }

View File

@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowJs": true
},
"include": ["**/*.ts"]
}

View File

@@ -23,10 +23,13 @@ test('should be able to search the web', async () => {
} catch (error) { } catch (error) {
// Network errors can occur in CI environments // Network errors can occur in CI environments
if ( if (
error.message.includes('network') || error instanceof Error &&
error.message.includes('timeout') (error.message.includes('network') || error.message.includes('timeout'))
) { ) {
console.warn('Skipping test due to network error:', error.message); console.warn(
'Skipping test due to network error:',
(error as Error).message,
);
return; // Skip the test return; // Skip the test
} }
throw error; // Re-throw if not a network error throw error; // Re-throw if not a network error

94
package-lock.json generated
View File

@@ -37,9 +37,11 @@
"json": "^11.0.0", "json": "^11.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"memfs": "^4.17.2", "memfs": "^4.17.2",
"mnemonist": "^0.40.3",
"mock-fs": "^5.5.0", "mock-fs": "^5.5.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"react-devtools-core": "^4.28.5", "react-devtools-core": "^4.28.5",
"tsx": "^4.20.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"yargs": "^17.7.2" "yargs": "^17.7.2"
@@ -2515,15 +2517,7 @@
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true, "dev": true
"license": "MIT"
},
"node_modules/@types/vscode": {
"version": "1.102.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.102.0.tgz",
"integrity": "sha512-V9sFXmcXz03FtYTSUsYsu5K0Q9wH9w9V25slddcxrh5JgORD14LpnOA7ov0L9ALi+6HrTjskLJ/tY5zeRF3TFA==",
"dev": true,
"license": "MIT"
}, },
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
@@ -5547,6 +5541,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/fzf": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==",
"license": "BSD-3-Clause"
},
"node_modules/gcp-metadata": { "node_modules/gcp-metadata": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
@@ -5695,6 +5695,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-tsconfig": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
"integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "10.4.5", "version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -7873,6 +7886,15 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/mock-fs": { "node_modules/mock-fs": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz",
@@ -8303,6 +8325,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -9271,6 +9299,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restore-cursor": { "node_modules/restore-cursor": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
@@ -10577,6 +10615,26 @@
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": {
"version": "4.20.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -11704,6 +11762,7 @@
"dependencies": { "dependencies": {
"@google/genai": "1.9.0", "@google/genai": "1.9.0",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.15.1",
"@qwen-code/qwen-code-core": "file:../core", "@qwen-code/qwen-code-core": "file:../core",
"@types/update-notifier": "^6.0.8", "@types/update-notifier": "^6.0.8",
"command-exists": "^1.2.9", "command-exists": "^1.2.9",
@@ -11727,7 +11786,7 @@
"string-width": "^7.1.0", "string-width": "^7.1.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1", "strip-json-comments": "^3.1.1",
"tiktoken": "^1.0.21", "undici": "^7.10.0",
"update-notifier": "^7.3.1", "update-notifier": "^7.3.1",
"yargs": "^17.7.2", "yargs": "^17.7.2",
"zod": "^3.23.8" "zod": "^3.23.8"
@@ -11920,6 +11979,7 @@
"diff": "^7.0.0", "diff": "^7.0.0",
"dotenv": "^17.1.0", "dotenv": "^17.1.0",
"fdir": "^6.4.6", "fdir": "^6.4.6",
"fzf": "^0.5.2",
"glob": "^10.4.5", "glob": "^10.4.5",
"google-auth-library": "^9.11.0", "google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
@@ -11928,6 +11988,7 @@
"jsonrepair": "^3.13.0", "jsonrepair": "^3.13.0",
"marked": "^15.0.12", "marked": "^15.0.12",
"micromatch": "^4.0.8", "micromatch": "^4.0.8",
"mnemonist": "^0.40.3",
"open": "^10.1.2", "open": "^10.1.2",
"openai": "5.11.0", "openai": "5.11.0",
"picomatch": "^4.0.1", "picomatch": "^4.0.1",
@@ -12052,7 +12113,7 @@
}, },
"packages/test-utils": { "packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils", "name": "@qwen-code/qwen-code-test-utils",
"version": "0.1.18", "version": "0.0.7",
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"typescript": "^5.3.3" "typescript": "^5.3.3"
@@ -12075,7 +12136,7 @@
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "20.x", "@types/node": "20.x",
"@types/vscode": "^1.101.0", "@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1", "@typescript-eslint/parser": "^8.31.1",
"esbuild": "^0.25.3", "esbuild": "^0.25.3",
@@ -12085,8 +12146,15 @@
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"engines": { "engines": {
"vscode": "^1.101.0" "vscode": "^1.99.0"
} }
},
"packages/vscode-ide-companion/node_modules/@types/vscode": {
"version": "1.99.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz",
"integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==",
"dev": true,
"license": "MIT"
} }
} }
} }

View File

@@ -28,7 +28,7 @@
"build:packages": "npm run build --workspaces", "build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js --skip-npm-install-build", "build:sandbox": "node scripts/build_sandbox.js --skip-npm-install-build",
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js", "bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces", "test": "npm run test --workspaces --if-present",
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts", "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
"test:e2e": "npm run test:integration:sandbox:none -- --verbose --keep-output", "test:e2e": "npm run test:integration:sandbox:none -- --verbose --keep-output",
@@ -39,7 +39,7 @@
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests", "lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
"lint:fix": "eslint . --fix && eslint integration-tests --fix", "lint:fix": "eslint . --fix && eslint integration-tests --fix",
"lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0", "lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0",
"format": "prettier --write .", "format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present",
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
"prepare": "npm run bundle", "prepare": "npm run bundle",
@@ -83,8 +83,10 @@
"mock-fs": "^5.5.0", "mock-fs": "^5.5.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"react-devtools-core": "^4.28.5", "react-devtools-core": "^4.28.5",
"tsx": "^4.20.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"yargs": "^17.7.2" "yargs": "^17.7.2",
"mnemonist": "^0.40.3"
} }
} }

View File

@@ -31,6 +31,7 @@
"@google/genai": "1.9.0", "@google/genai": "1.9.0",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@qwen-code/qwen-code-core": "file:../core", "@qwen-code/qwen-code-core": "file:../core",
"@modelcontextprotocol/sdk": "^1.15.1",
"@types/update-notifier": "^6.0.8", "@types/update-notifier": "^6.0.8",
"command-exists": "^1.2.9", "command-exists": "^1.2.9",
"diff": "^7.0.0", "diff": "^7.0.0",
@@ -53,7 +54,7 @@
"string-width": "^7.1.0", "string-width": "^7.1.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1", "strip-json-comments": "^3.1.1",
"tiktoken": "^1.0.21", "undici": "^7.10.0",
"update-notifier": "^7.3.1", "update-notifier": "^7.3.1",
"yargs": "^17.7.2", "yargs": "^17.7.2",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@@ -239,65 +239,62 @@ class GeminiAgent implements Agent {
); );
} }
let toolCallId; let toolCallId: number | undefined = undefined;
const confirmationDetails = await tool.shouldConfirmExecute(
args,
abortSignal,
);
if (confirmationDetails) {
let content: acp.ToolCallContent | null = null;
if (confirmationDetails.type === 'edit') {
content = {
type: 'diff',
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
};
}
const result = await this.client.requestToolCallConfirmation({
label: tool.getDescription(args),
icon: tool.icon,
content,
confirmation: toAcpToolCallConfirmation(confirmationDetails),
locations: tool.toolLocations(args),
});
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
switch (result.outcome) {
case 'reject':
return errorResponse(
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
);
case 'cancel':
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case 'allow':
case 'alwaysAllow':
case 'alwaysAllowMcpServer':
case 'alwaysAllowTool':
break;
default: {
const resultOutcome: never = result.outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
toolCallId = result.id;
} else {
const result = await this.client.pushToolCall({
icon: tool.icon,
label: tool.getDescription(args),
locations: tool.toolLocations(args),
});
toolCallId = result.id;
}
try { try {
const toolResult: ToolResult = await tool.execute(args, abortSignal); const invocation = tool.build(args);
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
if (confirmationDetails) {
let content: acp.ToolCallContent | null = null;
if (confirmationDetails.type === 'edit') {
content = {
type: 'diff',
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
};
}
const result = await this.client.requestToolCallConfirmation({
label: invocation.getDescription(),
icon: tool.icon,
content,
confirmation: toAcpToolCallConfirmation(confirmationDetails),
locations: invocation.toolLocations(),
});
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
switch (result.outcome) {
case 'reject':
return errorResponse(
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
);
case 'cancel':
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case 'allow':
case 'alwaysAllow':
case 'alwaysAllowMcpServer':
case 'alwaysAllowTool':
break;
default: {
const resultOutcome: never = result.outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
toolCallId = result.id;
} else {
const result = await this.client.pushToolCall({
icon: tool.icon,
label: invocation.getDescription(),
locations: invocation.toolLocations(),
});
toolCallId = result.id;
}
const toolResult: ToolResult = await invocation.execute(abortSignal);
const toolCallContent = toToolCallContent(toolResult); const toolCallContent = toToolCallContent(toolResult);
await this.client.updateToolCall({ await this.client.updateToolCall({
@@ -320,12 +317,13 @@ class GeminiAgent implements Agent {
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent); return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
} catch (e) { } catch (e) {
const error = e instanceof Error ? e : new Error(String(e)); const error = e instanceof Error ? e : new Error(String(e));
await this.client.updateToolCall({ if (toolCallId) {
toolCallId, await this.client.updateToolCall({
status: 'error', toolCallId,
content: { type: 'markdown', markdown: error.message }, status: 'error',
}); content: { type: 'markdown', markdown: error.message },
});
}
return errorResponse(error); return errorResponse(error);
} }
} }
@@ -408,7 +406,7 @@ class GeminiAgent implements Agent {
`Path ${pathName} not found directly, attempting glob search.`, `Path ${pathName} not found directly, attempting glob search.`,
); );
try { try {
const globResult = await globTool.execute( const globResult = await globTool.buildAndExecute(
{ {
pattern: `**/*${pathName}*`, pattern: `**/*${pathName}*`,
path: this.config.getTargetDir(), path: this.config.getTargetDir(),
@@ -530,12 +528,15 @@ class GeminiAgent implements Agent {
respectGitIgnore, // Use configuration setting respectGitIgnore, // Use configuration setting
}; };
const toolCall = await this.client.pushToolCall({ let toolCallId: number | undefined = undefined;
icon: readManyFilesTool.icon,
label: readManyFilesTool.getDescription(toolArgs),
});
try { try {
const result = await readManyFilesTool.execute(toolArgs, abortSignal); const invocation = readManyFilesTool.build(toolArgs);
const toolCall = await this.client.pushToolCall({
icon: readManyFilesTool.icon,
label: invocation.getDescription(),
});
toolCallId = toolCall.id;
const result = await invocation.execute(abortSignal);
const content = toToolCallContent(result) || { const content = toToolCallContent(result) || {
type: 'markdown', type: 'markdown',
markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`, markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
@@ -578,14 +579,16 @@ class GeminiAgent implements Agent {
return processedQueryParts; return processedQueryParts;
} catch (error: unknown) { } catch (error: unknown) {
await this.client.updateToolCall({ if (toolCallId) {
toolCallId: toolCall.id, await this.client.updateToolCall({
status: 'error', toolCallId,
content: { status: 'error',
type: 'markdown', content: {
markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, type: 'markdown',
}, markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
}); },
});
}
throw error; throw error;
} }
} }

View File

@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { mcpCommand } from './mcp.js';
import { type Argv } from 'yargs';
import yargs from 'yargs';
describe('mcp command', () => {
it('should have correct command definition', () => {
expect(mcpCommand.command).toBe('mcp');
expect(mcpCommand.describe).toBe('Manage MCP servers');
expect(typeof mcpCommand.builder).toBe('function');
expect(typeof mcpCommand.handler).toBe('function');
});
it('should have exactly one option (help flag)', () => {
// Test to ensure that the global 'gemini' flags are not added to the mcp command
const yargsInstance = yargs();
const builtYargs = mcpCommand.builder(yargsInstance);
const options = builtYargs.getOptions();
// Should have exactly 1 option (help flag)
expect(Object.keys(options.key).length).toBe(1);
expect(options.key).toHaveProperty('help');
});
it('should register add, remove, and list subcommands', () => {
const mockYargs = {
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
version: vi.fn().mockReturnThis(),
};
mcpCommand.builder(mockYargs as unknown as Argv);
expect(mockYargs.command).toHaveBeenCalledTimes(3);
// Verify that the specific subcommands are registered
const commandCalls = mockYargs.command.mock.calls;
const commandNames = commandCalls.map((call) => call[0].command);
expect(commandNames).toContain('add <name> <commandOrUrl> [args...]');
expect(commandNames).toContain('remove <name>');
expect(commandNames).toContain('list');
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
1,
'You need at least one command before continuing.',
);
});
});

View File

@@ -0,0 +1,27 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// File for 'gemini mcp' command
import type { CommandModule, Argv } from 'yargs';
import { addCommand } from './mcp/add.js';
import { removeCommand } from './mcp/remove.js';
import { listCommand } from './mcp/list.js';
export const mcpCommand: CommandModule = {
command: 'mcp',
describe: 'Manage MCP servers',
builder: (yargs: Argv) =>
yargs
.command(addCommand)
.command(removeCommand)
.command(listCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
// yargs will automatically show help if no subcommand is provided
// thanks to demandCommand(1) in the builder.
},
};

View File

@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import yargs from 'yargs';
import { addCommand } from './add.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('../../config/settings.js', async () => {
const actual = await vi.importActual('../../config/settings.js');
return {
...actual,
loadSettings: vi.fn(),
};
});
const mockedLoadSettings = loadSettings as vi.Mock;
describe('mcp add command', () => {
let parser: yargs.Argv;
let mockSetValue: vi.Mock;
beforeEach(() => {
vi.resetAllMocks();
const yargsInstance = yargs([]).command(addCommand);
parser = yargsInstance;
mockSetValue = vi.fn();
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: {} }),
setValue: mockSetValue,
});
});
it('should add a stdio server to project settings', async () => {
await parser.parseAsync(
'add my-server /path/to/server arg1 arg2 -e FOO=bar',
);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
{
'my-server': {
command: '/path/to/server',
args: ['arg1', 'arg2'],
env: { FOO: 'bar' },
},
},
);
});
it('should add an sse server to user settings', async () => {
await parser.parseAsync(
'add --transport sse sse-server https://example.com/sse-endpoint --scope user -H "X-API-Key: your-key"',
);
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
'sse-server': {
url: 'https://example.com/sse-endpoint',
headers: { 'X-API-Key': 'your-key' },
},
});
});
it('should add an http server to project settings', async () => {
await parser.parseAsync(
'add --transport http http-server https://example.com/mcp -H "Authorization: Bearer your-token"',
);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
{
'http-server': {
httpUrl: 'https://example.com/mcp',
headers: { Authorization: 'Bearer your-token' },
},
},
);
});
});

View File

@@ -0,0 +1,211 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// File for 'gemini mcp add' command
import type { CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { MCPServerConfig } from '@qwen-code/qwen-code-core';
async function addMcpServer(
name: string,
commandOrUrl: string,
args: Array<string | number> | undefined,
options: {
scope: string;
transport: string;
env: string[] | undefined;
header: string[] | undefined;
timeout?: number;
trust?: boolean;
description?: string;
includeTools?: string[];
excludeTools?: string[];
},
) {
const {
scope,
transport,
env,
header,
timeout,
trust,
description,
includeTools,
excludeTools,
} = options;
const settingsScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const settings = loadSettings(process.cwd());
let newServer: Partial<MCPServerConfig> = {};
const headers = header?.reduce(
(acc, curr) => {
const [key, ...valueParts] = curr.split(':');
const value = valueParts.join(':').trim();
if (key.trim() && value) {
acc[key.trim()] = value;
}
return acc;
},
{} as Record<string, string>,
);
switch (transport) {
case 'sse':
newServer = {
url: commandOrUrl,
headers,
timeout,
trust,
description,
includeTools,
excludeTools,
};
break;
case 'http':
newServer = {
httpUrl: commandOrUrl,
headers,
timeout,
trust,
description,
includeTools,
excludeTools,
};
break;
case 'stdio':
default:
newServer = {
command: commandOrUrl,
args: args?.map(String),
env: env?.reduce(
(acc, curr) => {
const [key, value] = curr.split('=');
if (key && value) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
),
timeout,
trust,
description,
includeTools,
excludeTools,
};
break;
}
const existingSettings = settings.forScope(settingsScope).settings;
const mcpServers = existingSettings.mcpServers || {};
const isExistingServer = !!mcpServers[name];
if (isExistingServer) {
console.log(
`MCP server "${name}" is already configured within ${scope} settings.`,
);
}
mcpServers[name] = newServer as MCPServerConfig;
settings.setValue(settingsScope, 'mcpServers', mcpServers);
if (isExistingServer) {
console.log(`MCP server "${name}" updated in ${scope} settings.`);
} else {
console.log(
`MCP server "${name}" added to ${scope} settings. (${transport})`,
);
}
}
export const addCommand: CommandModule = {
command: 'add <name> <commandOrUrl> [args...]',
describe: 'Add a server',
builder: (yargs) =>
yargs
.usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
.positional('name', {
describe: 'Name of the server',
type: 'string',
demandOption: true,
})
.positional('commandOrUrl', {
describe: 'Command (stdio) or URL (sse, http)',
type: 'string',
demandOption: true,
})
.option('scope', {
alias: 's',
describe: 'Configuration scope (user or project)',
type: 'string',
default: 'project',
choices: ['user', 'project'],
})
.option('transport', {
alias: 't',
describe: 'Transport type (stdio, sse, http)',
type: 'string',
default: 'stdio',
choices: ['stdio', 'sse', 'http'],
})
.option('env', {
alias: 'e',
describe: 'Set environment variables (e.g. -e KEY=value)',
type: 'array',
string: true,
})
.option('header', {
alias: 'H',
describe:
'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")',
type: 'array',
string: true,
})
.option('timeout', {
describe: 'Set connection timeout in milliseconds',
type: 'number',
})
.option('trust', {
describe:
'Trust the server (bypass all tool call confirmation prompts)',
type: 'boolean',
})
.option('description', {
describe: 'Set the description for the server',
type: 'string',
})
.option('include-tools', {
describe: 'A comma-separated list of tools to include',
type: 'array',
string: true,
})
.option('exclude-tools', {
describe: 'A comma-separated list of tools to exclude',
type: 'array',
string: true,
}),
handler: async (argv) => {
await addMcpServer(
argv.name as string,
argv.commandOrUrl as string,
argv.args as Array<string | number>,
{
scope: argv.scope as string,
transport: argv.transport as string,
env: argv.env as string[],
header: argv.header as string[],
timeout: argv.timeout as number | undefined,
trust: argv.trust as boolean | undefined,
description: argv.description as string | undefined,
includeTools: argv.includeTools as string[] | undefined,
excludeTools: argv.excludeTools as string[] | undefined,
},
);
},
};

View File

@@ -0,0 +1,154 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { listMcpServers } from './list.js';
import { loadSettings } from '../../config/settings.js';
import { loadExtensions } from '../../config/extension.js';
import { createTransport } from '@qwen-code/qwen-code-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
vi.mock('../../config/settings.js');
vi.mock('../../config/extension.js');
vi.mock('@qwen-code/qwen-code-core');
vi.mock('@modelcontextprotocol/sdk/client/index.js');
const mockedLoadSettings = loadSettings as vi.Mock;
const mockedLoadExtensions = loadExtensions as vi.Mock;
const mockedCreateTransport = createTransport as vi.Mock;
const MockedClient = Client as vi.Mock;
interface MockClient {
connect: vi.Mock;
ping: vi.Mock;
close: vi.Mock;
}
interface MockTransport {
close: vi.Mock;
}
describe('mcp list command', () => {
let consoleSpy: vi.SpyInstance;
let mockClient: MockClient;
let mockTransport: MockTransport;
beforeEach(() => {
vi.resetAllMocks();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockTransport = { close: vi.fn() };
mockClient = {
connect: vi.fn(),
ping: vi.fn(),
close: vi.fn(),
};
MockedClient.mockImplementation(() => mockClient);
mockedCreateTransport.mockResolvedValue(mockTransport);
mockedLoadExtensions.mockReturnValue([]);
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('should display message when no servers configured', async () => {
mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } });
await listMcpServers();
expect(consoleSpy).toHaveBeenCalledWith('No MCP servers configured.');
});
it('should display different server types with connected status', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
'sse-server': { url: 'https://example.com/sse' },
'http-server': { httpUrl: 'https://example.com/http' },
},
},
});
mockClient.connect.mockResolvedValue(undefined);
mockClient.ping.mockResolvedValue(undefined);
await listMcpServers();
expect(consoleSpy).toHaveBeenCalledWith('Configured MCP servers:\n');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'stdio-server: /path/to/server arg1 (stdio) - Connected',
),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'sse-server: https://example.com/sse (sse) - Connected',
),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'http-server: https://example.com/http (http) - Connected',
),
);
});
it('should display disconnected status when connection fails', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'test-server': { command: '/test/server' },
},
},
});
mockClient.connect.mockRejectedValue(new Error('Connection failed'));
await listMcpServers();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'test-server: /test/server (stdio) - Disconnected',
),
);
});
it('should merge extension servers with config servers', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: { 'config-server': { command: '/config/server' } },
},
});
mockedLoadExtensions.mockReturnValue([
{
config: {
name: 'test-extension',
mcpServers: { 'extension-server': { command: '/ext/server' } },
},
},
]);
mockClient.connect.mockResolvedValue(undefined);
mockClient.ping.mockResolvedValue(undefined);
await listMcpServers();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'config-server: /config/server (stdio) - Connected',
),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'extension-server: /ext/server (stdio) - Connected',
),
);
});
});

View File

@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// File for 'gemini mcp list' command
import type { CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import {
MCPServerConfig,
MCPServerStatus,
createTransport,
} from '@qwen-code/qwen-code-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { loadExtensions } from '../../config/extension.js';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
const COLOR_RED = '\u001b[31m';
const RESET_COLOR = '\u001b[0m';
async function getMcpServersFromConfig(): Promise<
Record<string, MCPServerConfig>
> {
const settings = loadSettings(process.cwd());
const extensions = loadExtensions(process.cwd());
const mcpServers = { ...(settings.merged.mcpServers || {}) };
for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach(
([key, server]) => {
if (mcpServers[key]) {
return;
}
mcpServers[key] = {
...server,
extensionName: extension.config.name,
};
},
);
}
return mcpServers;
}
async function testMCPConnection(
serverName: string,
config: MCPServerConfig,
): Promise<MCPServerStatus> {
const client = new Client({
name: 'mcp-test-client',
version: '0.0.1',
});
let transport;
try {
// Use the same transport creation logic as core
transport = await createTransport(serverName, config, false);
} catch (_error) {
await client.close();
return MCPServerStatus.DISCONNECTED;
}
try {
// Attempt actual MCP connection with short timeout
await client.connect(transport, { timeout: 5000 }); // 5s timeout
// Test basic MCP protocol by pinging the server
await client.ping();
await client.close();
return MCPServerStatus.CONNECTED;
} catch (_error) {
await transport.close();
return MCPServerStatus.DISCONNECTED;
}
}
async function getServerStatus(
serverName: string,
server: MCPServerConfig,
): Promise<MCPServerStatus> {
// Test all server types by attempting actual connection
return await testMCPConnection(serverName, server);
}
export async function listMcpServers(): Promise<void> {
const mcpServers = await getMcpServersFromConfig();
const serverNames = Object.keys(mcpServers);
if (serverNames.length === 0) {
console.log('No MCP servers configured.');
return;
}
console.log('Configured MCP servers:\n');
for (const serverName of serverNames) {
const server = mcpServers[serverName];
const status = await getServerStatus(serverName, server);
let statusIndicator = '';
let statusText = '';
switch (status) {
case MCPServerStatus.CONNECTED:
statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR;
statusText = 'Connected';
break;
case MCPServerStatus.CONNECTING:
statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR;
statusText = 'Connecting';
break;
case MCPServerStatus.DISCONNECTED:
default:
statusIndicator = COLOR_RED + '✗' + RESET_COLOR;
statusText = 'Disconnected';
break;
}
let serverInfo = `${serverName}: `;
if (server.httpUrl) {
serverInfo += `${server.httpUrl} (http)`;
} else if (server.url) {
serverInfo += `${server.url} (sse)`;
} else if (server.command) {
serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`;
}
console.log(`${statusIndicator} ${serverInfo} - ${statusText}`);
}
}
export const listCommand: CommandModule = {
command: 'list',
describe: 'List all configured MCP servers',
handler: async () => {
await listMcpServers();
},
};

View File

@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import yargs from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { removeCommand } from './remove.js';
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('../../config/settings.js', async () => {
const actual = await vi.importActual('../../config/settings.js');
return {
...actual,
loadSettings: vi.fn(),
};
});
const mockedLoadSettings = loadSettings as vi.Mock;
describe('mcp remove command', () => {
let parser: yargs.Argv;
let mockSetValue: vi.Mock;
let mockSettings: Record<string, unknown>;
beforeEach(() => {
vi.resetAllMocks();
const yargsInstance = yargs([]).command(removeCommand);
parser = yargsInstance;
mockSetValue = vi.fn();
mockSettings = {
mcpServers: {
'test-server': {
command: 'echo "hello"',
},
},
};
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: mockSettings }),
setValue: mockSetValue,
});
});
it('should remove a server from project settings', async () => {
await parser.parseAsync('remove test-server');
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
{},
);
});
it('should show a message if server not found', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await parser.parseAsync('remove non-existent-server');
expect(mockSetValue).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(
'Server "non-existent-server" not found in project settings.',
);
});
});

View File

@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// File for 'gemini mcp remove' command
import type { CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
async function removeMcpServer(
name: string,
options: {
scope: string;
},
) {
const { scope } = options;
const settingsScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const settings = loadSettings(process.cwd());
const existingSettings = settings.forScope(settingsScope).settings;
const mcpServers = existingSettings.mcpServers || {};
if (!mcpServers[name]) {
console.log(`Server "${name}" not found in ${scope} settings.`);
return;
}
delete mcpServers[name];
settings.setValue(settingsScope, 'mcpServers', mcpServers);
console.log(`Server "${name}" removed from ${scope} settings.`);
}
export const removeCommand: CommandModule = {
command: 'remove <name>',
describe: 'Remove a server',
builder: (yargs) =>
yargs
.usage('Usage: gemini mcp remove [options] <name>')
.positional('name', {
describe: 'Name of the server',
type: 'string',
demandOption: true,
})
.option('scope', {
alias: 's',
describe: 'Configuration scope (user or project)',
type: 'string',
default: 'project',
choices: ['user', 'project'],
}),
handler: async (argv) => {
await removeMcpServer(argv.name as string, {
scope: argv.scope as string,
});
},
};

View File

@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'os'; import * as os from 'os';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ShellTool, EditTool, WriteFileTool } from '@qwen-code/qwen-code-core';
import { loadCliConfig, parseArguments } from './config.js'; import { loadCliConfig, parseArguments } from './config.js';
import { Settings } from './settings.js'; import { Settings } from './settings.js';
import { Extension } from './extension.js'; import { Extension } from './extension.js';
@@ -635,6 +636,17 @@ describe('loadCliConfig systemPromptMappings', () => {
}); });
describe('mergeExcludeTools', () => { describe('mergeExcludeTools', () => {
const defaultExcludes = [ShellTool.Name, EditTool.Name, WriteFileTool.Name];
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
process.stdin.isTTY = true;
});
afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});
it('should merge excludeTools from settings and extensions', async () => { it('should merge excludeTools from settings and extensions', async () => {
const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
const extensions: Extension[] = [ const extensions: Extension[] = [
@@ -729,7 +741,8 @@ describe('mergeExcludeTools', () => {
expect(config.getExcludeTools()).toHaveLength(4); expect(config.getExcludeTools()).toHaveLength(4);
}); });
it('should return an empty array when no excludeTools are specified', async () => { it('should return an empty array when no excludeTools are specified and it is interactive', async () => {
process.stdin.isTTY = true;
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = []; const extensions: Extension[] = [];
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
@@ -743,6 +756,21 @@ describe('mergeExcludeTools', () => {
expect(config.getExcludeTools()).toEqual([]); expect(config.getExcludeTools()).toEqual([]);
}); });
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
process.stdin.isTTY = false;
const settings: Settings = {};
const extensions: Extension[] = [];
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig(
settings,
extensions,
'test-session',
argv,
);
expect(config.getExcludeTools()).toEqual(defaultExcludes);
});
it('should handle settings with excludeTools but no extensions', async () => { it('should handle settings with excludeTools but no extensions', async () => {
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
const argv = await parseArguments(); const argv = await parseArguments();
@@ -1083,6 +1111,91 @@ describe('loadCliConfig ideModeFeature', () => {
}); });
}); });
describe('loadCliConfig folderTrustFeature', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should be false by default', async () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {};
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrustFeature()).toBe(false);
});
it('should be true when settings.folderTrustFeature is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { folderTrustFeature: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrustFeature()).toBe(true);
});
});
describe('loadCliConfig folderTrust', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should be false if folderTrustFeature is false and folderTrust is false', async () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {
folderTrustFeature: false,
folderTrust: false,
};
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrust()).toBe(false);
});
it('should be false if folderTrustFeature is true and folderTrust is false', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { folderTrustFeature: true, folderTrust: false };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrust()).toBe(false);
});
it('should be false if folderTrustFeature is false and folderTrust is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { folderTrustFeature: false, folderTrust: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrust()).toBe(false);
});
it('should be true when folderTrustFeature is true and folderTrust is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { folderTrustFeature: true, folderTrust: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrust()).toBe(true);
});
});
vi.mock('fs', async () => { vi.mock('fs', async () => {
const actualFs = await vi.importActual<typeof fs>('fs'); const actualFs = await vi.importActual<typeof fs>('fs');
const MOCK_CWD1 = process.cwd(); const MOCK_CWD1 = process.cwd();
@@ -1164,3 +1277,154 @@ describe('loadCliConfig with includeDirectories', () => {
); );
}); });
}); });
describe('loadCliConfig chatCompression', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should pass chatCompression settings to the core config', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {
chatCompression: {
contextPercentageThreshold: 0.5,
},
};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getChatCompression()).toEqual({
contextPercentageThreshold: 0.5,
});
});
it('should have undefined chatCompression if not in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getChatCompression()).toBeUndefined();
});
});
describe('loadCliConfig tool exclusions', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
process.stdin.isTTY = true;
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
process.stdin.isTTY = originalIsTTY;
vi.restoreAllMocks();
});
it('should not exclude interactive tools in interactive mode without YOLO', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
});
it('should not exclude interactive tools in interactive mode with YOLO', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
});
it('should exclude interactive tools in non-interactive mode without YOLO', async () => {
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getExcludeTools()).toContain('run_shell_command');
expect(config.getExcludeTools()).toContain('replace');
expect(config.getExcludeTools()).toContain('write_file');
});
it('should not exclude interactive tools in non-interactive mode with YOLO', async () => {
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
});
});
describe('loadCliConfig interactive', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
process.stdin.isTTY = true;
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
process.stdin.isTTY = originalIsTTY;
vi.restoreAllMocks();
});
it('should be interactive if isTTY and no prompt', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.isInteractive()).toBe(true);
});
it('should be interactive if prompt-interactive is set', async () => {
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '--prompt-interactive', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.isInteractive()).toBe(true);
});
it('should not be interactive if not isTTY and no prompt', async () => {
process.stdin.isTTY = false;
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.isInteractive()).toBe(false);
});
it('should not be interactive if prompt is set', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--prompt', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.isInteractive()).toBe(false);
});
});

View File

@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
import yargs from 'yargs/yargs'; import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers'; import { hideBin } from 'yargs/helpers';
import process from 'node:process'; import process from 'node:process';
import { mcpCommand } from '../commands/mcp.js';
import { import {
Config, Config,
loadServerHierarchicalMemory, loadServerHierarchicalMemory,
@@ -22,6 +23,11 @@ import {
FileDiscoveryService, FileDiscoveryService,
TelemetryTarget, TelemetryTarget,
FileFilteringOptions, FileFilteringOptions,
ShellTool,
EditTool,
WriteFileTool,
MCPServerConfig,
ConfigParameters,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { Settings } from './settings.js'; import { Settings } from './settings.js';
@@ -68,7 +74,6 @@ export interface CliArgs {
openaiBaseUrl: string | undefined; openaiBaseUrl: string | undefined;
proxy: string | undefined; proxy: string | undefined;
includeDirectories: string[] | undefined; includeDirectories: string[] | undefined;
loadMemoryFromIncludeDirectories: boolean | undefined;
tavilyApiKey: string | undefined; tavilyApiKey: string | undefined;
} }
@@ -76,190 +81,196 @@ export async function parseArguments(): Promise<CliArgs> {
const yargsInstance = yargs(hideBin(process.argv)) const yargsInstance = yargs(hideBin(process.argv))
.scriptName('qwen') .scriptName('qwen')
.usage( .usage(
'$0 [options]', 'Usage: qwen [options] [command]\n\nQwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
'Qwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
) )
.option('model', { .command('$0', 'Launch Qwen Code', (yargsInstance) =>
alias: 'm', yargsInstance
type: 'string', .option('model', {
description: `Model`, alias: 'm',
default: process.env.GEMINI_MODEL, type: 'string',
}) description: `Model`,
.option('prompt', { default: process.env.GEMINI_MODEL,
alias: 'p', })
type: 'string', .option('prompt', {
description: 'Prompt. Appended to input on stdin (if any).', alias: 'p',
}) type: 'string',
.option('prompt-interactive', { description: 'Prompt. Appended to input on stdin (if any).',
alias: 'i', })
type: 'string', .option('prompt-interactive', {
description: alias: 'i',
'Execute the provided prompt and continue in interactive mode', type: 'string',
}) description:
.option('sandbox', { 'Execute the provided prompt and continue in interactive mode',
alias: 's', })
type: 'boolean', .option('sandbox', {
description: 'Run in sandbox?', alias: 's',
}) type: 'boolean',
.option('sandbox-image', { description: 'Run in sandbox?',
type: 'string', })
description: 'Sandbox image URI.', .option('sandbox-image', {
}) type: 'string',
.option('debug', { description: 'Sandbox image URI.',
alias: 'd', })
type: 'boolean', .option('debug', {
description: 'Run in debug mode?', alias: 'd',
default: false, type: 'boolean',
}) description: 'Run in debug mode?',
.option('all-files', { default: false,
alias: ['a'], })
type: 'boolean', .option('all-files', {
description: 'Include ALL files in context?', alias: ['a'],
default: false, type: 'boolean',
}) description: 'Include ALL files in context?',
.option('all_files', { default: false,
type: 'boolean', })
description: 'Include ALL files in context?', .option('all_files', {
default: false, type: 'boolean',
}) description: 'Include ALL files in context?',
.deprecateOption( default: false,
'all_files', })
'Use --all-files instead. We will be removing --all_files in the coming weeks.', .deprecateOption(
'all_files',
'Use --all-files instead. We will be removing --all_files in the coming weeks.',
)
.option('show-memory-usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.option('show_memory_usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.deprecateOption(
'show_memory_usage',
'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
)
.option('yolo', {
alias: 'y',
type: 'boolean',
description:
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
default: false,
})
.option('telemetry', {
type: 'boolean',
description:
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
})
.option('telemetry-target', {
type: 'string',
choices: ['local', 'gcp'],
description:
'Set the telemetry target (local or gcp). Overrides settings files.',
})
.option('telemetry-otlp-endpoint', {
type: 'string',
description:
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
})
.option('telemetry-log-prompts', {
type: 'boolean',
description:
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
})
.option('telemetry-outfile', {
type: 'string',
description: 'Redirect all telemetry output to the specified file.',
})
.option('checkpointing', {
alias: 'c',
type: 'boolean',
description: 'Enables checkpointing of file edits',
default: false,
})
.option('experimental-acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
description: 'Allowed MCP server names',
})
.option('extensions', {
alias: 'e',
type: 'array',
string: true,
description:
'A list of extensions to use. If not provided, all extensions are used.',
})
.option('list-extensions', {
alias: 'l',
type: 'boolean',
description: 'List all available extensions and exit.',
})
.option('ide-mode-feature', {
type: 'boolean',
description: 'Run in IDE mode?',
})
.option('proxy', {
type: 'string',
description:
'Proxy for gemini client, like schema://user:password@host:port',
})
.option('include-directories', {
type: 'array',
string: true,
description:
'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
coerce: (dirs: string[]) =>
// Handle comma-separated values
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
})
.option('openai-logging', {
type: 'boolean',
description:
'Enable logging of OpenAI API calls for debugging and analysis',
})
.option('openai-api-key', {
type: 'string',
description: 'OpenAI API key to use for authentication',
})
.option('openai-base-url', {
type: 'string',
description: 'OpenAI base URL (for custom endpoints)',
})
.option('tavily-api-key', {
type: 'string',
description: 'Tavily API key for web search functionality',
})
.check((argv) => {
if (argv.prompt && argv.promptInteractive) {
throw new Error(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
);
}
return true;
}),
) )
.option('show-memory-usage', { // Register MCP subcommands
type: 'boolean', .command(mcpCommand)
description: 'Show memory usage in status bar',
default: false,
})
.option('show_memory_usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.deprecateOption(
'show_memory_usage',
'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
)
.option('yolo', {
alias: 'y',
type: 'boolean',
description:
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
default: false,
})
.option('telemetry', {
type: 'boolean',
description:
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
})
.option('telemetry-target', {
type: 'string',
choices: ['local', 'gcp'],
description:
'Set the telemetry target (local or gcp). Overrides settings files.',
})
.option('telemetry-otlp-endpoint', {
type: 'string',
description:
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
})
.option('telemetry-log-prompts', {
type: 'boolean',
description:
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
})
.option('telemetry-outfile', {
type: 'string',
description: 'Redirect all telemetry output to the specified file.',
})
.option('checkpointing', {
alias: 'c',
type: 'boolean',
description: 'Enables checkpointing of file edits',
default: false,
})
.option('experimental-acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
description: 'Allowed MCP server names',
})
.option('extensions', {
alias: 'e',
type: 'array',
string: true,
description:
'A list of extensions to use. If not provided, all extensions are used.',
})
.option('list-extensions', {
alias: 'l',
type: 'boolean',
description: 'List all available extensions and exit.',
})
.option('ide-mode-feature', {
type: 'boolean',
description: 'Run in IDE mode?',
})
.option('openai-logging', {
type: 'boolean',
description:
'Enable logging of OpenAI API calls for debugging and analysis',
})
.option('openai-api-key', {
type: 'string',
description: 'OpenAI API key to use for authentication',
})
.option('openai-base-url', {
type: 'string',
description: 'OpenAI base URL (for custom endpoints)',
})
.option('tavily-api-key', {
type: 'string',
description: 'Tavily API key for web search functionality',
})
.option('proxy', {
type: 'string',
description:
'Proxy for gemini client, like schema://user:password@host:port',
})
.option('include-directories', {
type: 'array',
string: true,
description:
'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
coerce: (dirs: string[]) =>
// Handle comma-separated values
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
})
.option('load-memory-from-include-directories', {
type: 'boolean',
description:
'If true, when refreshing memory, QWEN.md files should be loaded from all directories that are added. If false, QWEN.md files should only be loaded from the primary working directory.',
default: false,
})
.version(await getCliVersion()) // This will enable the --version flag based on package.json .version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version') .alias('v', 'version')
.help() .help()
.alias('h', 'help') .alias('h', 'help')
.strict() .strict()
.check((argv) => { .demandCommand(0, 0); // Allow base command to run with no subcommands
if (argv.prompt && argv.promptInteractive) {
throw new Error(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
);
}
return true;
});
yargsInstance.wrap(yargsInstance.terminalWidth()); yargsInstance.wrap(yargsInstance.terminalWidth());
const result = yargsInstance.parseSync(); const result = await yargsInstance.parse();
// Handle case where MCP subcommands are executed - they should exit the process
// and not return to main CLI logic
if (result._.length > 0 && result._[0] === 'mcp') {
// MCP commands handle their own execution and process exit
process.exit(0);
}
// The import format is now only controlled by settings.memoryImportFormat // The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument // We no longer accept it as a CLI argument
return result as CliArgs; return result as unknown as CliArgs;
} }
// This function is now a thin wrapper around the server's implementation. // This function is now a thin wrapper around the server's implementation.
@@ -321,6 +332,10 @@ export async function loadCliConfig(
const ideModeFeature = const ideModeFeature =
argv.ideModeFeature ?? settings.ideModeFeature ?? false; argv.ideModeFeature ?? settings.ideModeFeature ?? false;
const folderTrustFeature = settings.folderTrustFeature ?? false;
const folderTrustSetting = settings.folderTrust ?? false;
const folderTrust = folderTrustFeature && folderTrustSetting;
const allExtensions = annotateActiveExtensions( const allExtensions = annotateActiveExtensions(
extensions, extensions,
argv.extensions || [], argv.extensions || [],
@@ -383,17 +398,31 @@ export async function loadCliConfig(
); );
let mcpServers = mergeMcpServers(settings, activeExtensions); let mcpServers = mergeMcpServers(settings, activeExtensions);
const excludeTools = mergeExcludeTools(settings, activeExtensions); const question = argv.promptInteractive || argv.prompt || '';
const approvalMode =
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
const interactive =
!!argv.promptInteractive || (process.stdin.isTTY && question.length === 0);
// In non-interactive and non-yolo mode, exclude interactive built in tools.
const extraExcludes =
!interactive && approvalMode !== ApprovalMode.YOLO
? [ShellTool.Name, EditTool.Name, WriteFileTool.Name]
: undefined;
const excludeTools = mergeExcludeTools(
settings,
activeExtensions,
extraExcludes,
);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = []; const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
if (!argv.allowedMcpServerNames) { if (!argv.allowedMcpServerNames) {
if (settings.allowMCPServers) { if (settings.allowMCPServers) {
const allowedNames = new Set(settings.allowMCPServers.filter(Boolean)); mcpServers = allowedMcpServers(
if (allowedNames.size > 0) { mcpServers,
mcpServers = Object.fromEntries( settings.allowMCPServers,
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)), blockedMcpServers,
); );
}
} }
if (settings.excludeMCPServers) { if (settings.excludeMCPServers) {
@@ -407,29 +436,11 @@ export async function loadCliConfig(
} }
if (argv.allowedMcpServerNames) { if (argv.allowedMcpServerNames) {
const allowedNames = new Set(argv.allowedMcpServerNames.filter(Boolean)); mcpServers = allowedMcpServers(
if (allowedNames.size > 0) { mcpServers,
mcpServers = Object.fromEntries( argv.allowedMcpServerNames,
Object.entries(mcpServers).filter(([key, server]) => { blockedMcpServers,
const isAllowed = allowedNames.has(key); );
if (!isAllowed) {
blockedMcpServers.push({
name: key,
extensionName: server.extensionName || '',
});
}
return isAllowed;
}),
);
} else {
blockedMcpServers.push(
...Object.entries(mcpServers).map(([key, server]) => ({
name: key,
extensionName: server.extensionName || '',
})),
);
mcpServers = {};
}
} }
const sandboxConfig = await loadSandboxConfig(settings, argv); const sandboxConfig = await loadSandboxConfig(settings, argv);
@@ -442,11 +453,9 @@ export async function loadCliConfig(
targetDir: process.cwd(), targetDir: process.cwd(),
includeDirectories, includeDirectories,
loadMemoryFromIncludeDirectories: loadMemoryFromIncludeDirectories:
argv.loadMemoryFromIncludeDirectories || settings.loadMemoryFromIncludeDirectories || false,
settings.loadMemoryFromIncludeDirectories ||
false,
debugMode, debugMode,
question: argv.promptInteractive || argv.prompt || '', question,
fullContext: argv.allFiles || argv.all_files || false, fullContext: argv.allFiles || argv.all_files || false,
coreTools: settings.coreTools || undefined, coreTools: settings.coreTools || undefined,
excludeTools, excludeTools,
@@ -456,7 +465,7 @@ export async function loadCliConfig(
mcpServers, mcpServers,
userMemory: memoryContent, userMemory: memoryContent,
geminiMdFileCount: fileCount, geminiMdFileCount: fileCount,
approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT, approvalMode,
showMemoryUsage: showMemoryUsage:
argv.showMemoryUsage || argv.showMemoryUsage ||
argv.show_memory_usage || argv.show_memory_usage ||
@@ -496,7 +505,6 @@ export async function loadCliConfig(
extensionContextFilePaths, extensionContextFilePaths,
maxSessionTurns: settings.maxSessionTurns ?? -1, maxSessionTurns: settings.maxSessionTurns ?? -1,
sessionTokenLimit: settings.sessionTokenLimit ?? -1, sessionTokenLimit: settings.sessionTokenLimit ?? -1,
maxFolderItems: settings.maxFolderItems ?? 20,
experimentalAcp: argv.experimentalAcp || false, experimentalAcp: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false, listExtensions: argv.listExtensions || false,
extensions: allExtensions, extensions: allExtensions,
@@ -510,7 +518,7 @@ export async function loadCliConfig(
? settings.enableOpenAILogging ? settings.enableOpenAILogging
: argv.openaiLogging) ?? false, : argv.openaiLogging) ?? false,
sampling_params: settings.sampling_params, sampling_params: settings.sampling_params,
systemPromptMappings: settings.systemPromptMappings ?? [ systemPromptMappings: (settings.systemPromptMappings ?? [
{ {
baseUrls: [ baseUrls: [
'https://dashscope.aliyuncs.com/compatible-mode/v1/', 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
@@ -519,15 +527,50 @@ export async function loadCliConfig(
modelNames: ['qwen3-coder-plus'], modelNames: ['qwen3-coder-plus'],
template: template:
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}', 'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
}, }
], ]) as ConfigParameters['systemPromptMappings'],
contentGenerator: settings.contentGenerator, contentGenerator: settings.contentGenerator,
cliVersion, cliVersion,
tavilyApiKey: tavilyApiKey:
argv.tavilyApiKey || settings.tavilyApiKey || process.env.TAVILY_API_KEY, argv.tavilyApiKey || settings.tavilyApiKey || process.env.TAVILY_API_KEY,
chatCompression: settings.chatCompression,
folderTrustFeature,
folderTrust,
interactive,
}); });
} }
function allowedMcpServers(
mcpServers: { [x: string]: MCPServerConfig },
allowMCPServers: string[],
blockedMcpServers: Array<{ name: string; extensionName: string }>,
) {
const allowedNames = new Set(allowMCPServers.filter(Boolean));
if (allowedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key, server]) => {
const isAllowed = allowedNames.has(key);
if (!isAllowed) {
blockedMcpServers.push({
name: key,
extensionName: server.extensionName || '',
});
}
return isAllowed;
}),
);
} else {
blockedMcpServers.push(
...Object.entries(mcpServers).map(([key, server]) => ({
name: key,
extensionName: server.extensionName || '',
})),
);
mcpServers = {};
}
return mcpServers;
}
function mergeMcpServers(settings: Settings, extensions: Extension[]) { function mergeMcpServers(settings: Settings, extensions: Extension[]) {
const mcpServers = { ...(settings.mcpServers || {}) }; const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) { for (const extension of extensions) {
@@ -552,8 +595,12 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) {
function mergeExcludeTools( function mergeExcludeTools(
settings: Settings, settings: Settings,
extensions: Extension[], extensions: Extension[],
extraExcludes?: string[] | undefined,
): string[] { ): string[] {
const allExcludeTools = new Set(settings.excludeTools || []); const allExcludeTools = new Set([
...(settings.excludeTools || []),
...(extraExcludes || []),
]);
for (const extension of extensions) { for (const extension of extensions) {
for (const tool of extension.config.excludeTools || []) { for (const tool of extension.config.excludeTools || []) {
allExcludeTools.add(tool); allExcludeTools.add(tool);

View File

@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
Command,
KeyBindingConfig,
defaultKeyBindings,
} from './keyBindings.js';
describe('keyBindings config', () => {
describe('defaultKeyBindings', () => {
it('should have bindings for all commands', () => {
const commands = Object.values(Command);
for (const command of commands) {
expect(defaultKeyBindings[command]).toBeDefined();
expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
}
});
it('should have valid key binding structures', () => {
for (const [_, bindings] of Object.entries(defaultKeyBindings)) {
for (const binding of bindings) {
// Each binding should have either key or sequence, but not both
const hasKey = binding.key !== undefined;
const hasSequence = binding.sequence !== undefined;
expect(hasKey || hasSequence).toBe(true);
expect(hasKey && hasSequence).toBe(false);
// Modifier properties should be boolean or undefined
if (binding.ctrl !== undefined) {
expect(typeof binding.ctrl).toBe('boolean');
}
if (binding.shift !== undefined) {
expect(typeof binding.shift).toBe('boolean');
}
if (binding.command !== undefined) {
expect(typeof binding.command).toBe('boolean');
}
if (binding.paste !== undefined) {
expect(typeof binding.paste).toBe('boolean');
}
}
}
});
it('should export all required types', () => {
// Basic type checks
expect(typeof Command.HOME).toBe('string');
expect(typeof Command.END).toBe('string');
// Config should be readonly
const config: KeyBindingConfig = defaultKeyBindings;
expect(config[Command.HOME]).toBeDefined();
});
});
});

View File

@@ -0,0 +1,179 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Command enum for all available keyboard shortcuts
*/
export enum Command {
// Basic bindings
RETURN = 'return',
ESCAPE = 'escape',
// Cursor movement
HOME = 'home',
END = 'end',
// Text deletion
KILL_LINE_RIGHT = 'killLineRight',
KILL_LINE_LEFT = 'killLineLeft',
CLEAR_INPUT = 'clearInput',
// Screen control
CLEAR_SCREEN = 'clearScreen',
// History navigation
HISTORY_UP = 'historyUp',
HISTORY_DOWN = 'historyDown',
NAVIGATION_UP = 'navigationUp',
NAVIGATION_DOWN = 'navigationDown',
// Auto-completion
ACCEPT_SUGGESTION = 'acceptSuggestion',
COMPLETION_UP = 'completionUp',
COMPLETION_DOWN = 'completionDown',
// Text input
SUBMIT = 'submit',
NEWLINE = 'newline',
// External tools
OPEN_EXTERNAL_EDITOR = 'openExternalEditor',
PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage',
// App level bindings
SHOW_ERROR_DETAILS = 'showErrorDetails',
TOGGLE_TOOL_DESCRIPTIONS = 'toggleToolDescriptions',
TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail',
QUIT = 'quit',
EXIT = 'exit',
SHOW_MORE_LINES = 'showMoreLines',
// Shell commands
REVERSE_SEARCH = 'reverseSearch',
SUBMIT_REVERSE_SEARCH = 'submitReverseSearch',
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch',
}
/**
* Data-driven key binding structure for user configuration
*/
export interface KeyBinding {
/** The key name (e.g., 'a', 'return', 'tab', 'escape') */
key?: string;
/** The key sequence (e.g., '\x18' for Ctrl+X) - alternative to key name */
sequence?: string;
/** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
ctrl?: boolean;
/** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
shift?: boolean;
/** Command/meta key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
command?: boolean;
/** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */
paste?: boolean;
}
/**
* Configuration type mapping commands to their key bindings
*/
export type KeyBindingConfig = {
readonly [C in Command]: readonly KeyBinding[];
};
/**
* Default key binding configuration
* Matches the original hard-coded logic exactly
*/
export const defaultKeyBindings: KeyBindingConfig = {
// Basic bindings
[Command.RETURN]: [{ key: 'return' }],
// Original: key.name === 'escape'
[Command.ESCAPE]: [{ key: 'escape' }],
// Cursor movement
// Original: key.ctrl && key.name === 'a'
[Command.HOME]: [{ key: 'a', ctrl: true }],
// Original: key.ctrl && key.name === 'e'
[Command.END]: [{ key: 'e', ctrl: true }],
// Text deletion
// Original: key.ctrl && key.name === 'k'
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
// Original: key.ctrl && key.name === 'u'
[Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
// Original: key.ctrl && key.name === 'c'
[Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
// Screen control
// Original: key.ctrl && key.name === 'l'
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
// History navigation
// Original: key.ctrl && key.name === 'p'
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true }],
// Original: key.ctrl && key.name === 'n'
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }],
// Original: key.name === 'up'
[Command.NAVIGATION_UP]: [{ key: 'up' }],
// Original: key.name === 'down'
[Command.NAVIGATION_DOWN]: [{ key: 'down' }],
// Auto-completion
// Original: key.name === 'tab' || (key.name === 'return' && !key.ctrl)
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation (arrow or Ctrl+P/N)
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
// Text input
// Original: key.name === 'return' && !key.ctrl && !key.meta && !key.paste
[Command.SUBMIT]: [
{
key: 'return',
ctrl: false,
command: false,
paste: false,
},
],
// Original: key.name === 'return' && (key.ctrl || key.meta || key.paste)
// Split into multiple data-driven bindings
[Command.NEWLINE]: [
{ key: 'return', ctrl: true },
{ key: 'return', command: true },
{ key: 'return', paste: true },
],
// External tools
// Original: key.ctrl && (key.name === 'x' || key.sequence === '\x18')
[Command.OPEN_EXTERNAL_EDITOR]: [
{ key: 'x', ctrl: true },
{ sequence: '\x18', ctrl: true },
],
// Original: key.ctrl && key.name === 'v'
[Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }],
// App level bindings
// Original: key.ctrl && key.name === 'o'
[Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }],
// Original: key.ctrl && key.name === 't'
[Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }],
// Original: key.ctrl && key.name === 'e'
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'e', ctrl: true }],
// Original: key.ctrl && (key.name === 'c' || key.name === 'C')
[Command.QUIT]: [{ key: 'c', ctrl: true }],
// Original: key.ctrl && (key.name === 'd' || key.name === 'D')
[Command.EXIT]: [{ key: 'd', ctrl: true }],
// Original: key.ctrl && key.name === 's'
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
// Shell commands
// Original: key.ctrl && key.name === 'r'
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
// Original: key.name === 'return' && !key.ctrl
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
// Original: key.name === 'tab'
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
};

View File

@@ -113,6 +113,7 @@ describe('Settings Loading and Merging', () => {
customThemes: {}, customThemes: {},
mcpServers: {}, mcpServers: {},
includeDirectories: [], includeDirectories: [],
chatCompression: {},
}); });
expect(settings.errors.length).toBe(0); expect(settings.errors.length).toBe(0);
}); });
@@ -147,6 +148,7 @@ describe('Settings Loading and Merging', () => {
customThemes: {}, customThemes: {},
mcpServers: {}, mcpServers: {},
includeDirectories: [], includeDirectories: [],
chatCompression: {},
}); });
}); });
@@ -181,6 +183,7 @@ describe('Settings Loading and Merging', () => {
customThemes: {}, customThemes: {},
mcpServers: {}, mcpServers: {},
includeDirectories: [], includeDirectories: [],
chatCompression: {},
}); });
}); });
@@ -213,6 +216,7 @@ describe('Settings Loading and Merging', () => {
customThemes: {}, customThemes: {},
mcpServers: {}, mcpServers: {},
includeDirectories: [], includeDirectories: [],
chatCompression: {},
}); });
}); });
@@ -251,6 +255,7 @@ describe('Settings Loading and Merging', () => {
customThemes: {}, customThemes: {},
mcpServers: {}, mcpServers: {},
includeDirectories: [], includeDirectories: [],
chatCompression: {},
}); });
}); });
@@ -301,9 +306,66 @@ describe('Settings Loading and Merging', () => {
customThemes: {}, customThemes: {},
mcpServers: {}, mcpServers: {},
includeDirectories: [], includeDirectories: [],
chatCompression: {},
}); });
}); });
it('should ignore folderTrust from workspace settings', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
folderTrust: true,
};
const workspaceSettingsContent = {
folderTrust: false, // This should be ignored
};
const systemSettingsContent = {
// No folderTrust here
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.folderTrust).toBe(true); // User setting should be used
});
it('should use system folderTrust over user setting', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
folderTrust: false,
};
const workspaceSettingsContent = {
folderTrust: true, // This should be ignored
};
const systemSettingsContent = {
folderTrust: true,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.folderTrust).toBe(true); // System setting should be used
});
it('should handle contextFileName correctly when only in user settings', () => { it('should handle contextFileName correctly when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation( (mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH, (p: fs.PathLike) => p === USER_SETTINGS_PATH,
@@ -622,6 +684,116 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.mcpServers).toEqual({}); expect(settings.merged.mcpServers).toEqual({});
}); });
it('should merge chatCompression settings, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
chatCompression: { contextPercentageThreshold: 0.5 },
};
const workspaceSettingsContent = {
chatCompression: { contextPercentageThreshold: 0.8 },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
expect(settings.workspace.settings.chatCompression).toEqual({
contextPercentageThreshold: 0.8,
});
expect(settings.merged.chatCompression).toEqual({
contextPercentageThreshold: 0.8,
});
});
it('should handle chatCompression when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
chatCompression: { contextPercentageThreshold: 0.5 },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
});
it('should have chatCompression as an empty object if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
(fs.readFileSync as Mock).mockReturnValue('{}');
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.chatCompression).toEqual({});
});
it('should ignore chatCompression if contextPercentageThreshold is invalid', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
chatCompression: { contextPercentageThreshold: 1.5 },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.chatCompression).toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
'Invalid value for chatCompression.contextPercentageThreshold: "1.5". Please use a value between 0 and 1. Using default compression settings.',
);
warnSpy.mockRestore();
});
it('should deep merge chatCompression settings', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
chatCompression: { contextPercentageThreshold: 0.5 },
};
const workspaceSettingsContent = {
chatCompression: {},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
});
it('should merge includeDirectories from all scopes', () => { it('should merge includeDirectories from all scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true); (mockFsExistsSync as Mock).mockReturnValue(true);
const systemSettingsContent = { const systemSettingsContent = {
@@ -695,6 +867,7 @@ describe('Settings Loading and Merging', () => {
customThemes: {}, customThemes: {},
mcpServers: {}, mcpServers: {},
includeDirectories: [], includeDirectories: [],
chatCompression: {},
}); });
// Check that error objects are populated in settings.errors // Check that error objects are populated in settings.errors
@@ -1132,6 +1305,7 @@ describe('Settings Loading and Merging', () => {
customThemes: {}, customThemes: {},
mcpServers: {}, mcpServers: {},
includeDirectories: [], includeDirectories: [],
chatCompression: {},
}); });
}); });
}); });

View File

@@ -9,17 +9,15 @@ import * as path from 'path';
import { homedir, platform } from 'os'; import { homedir, platform } from 'os';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import { import {
MCPServerConfig,
GEMINI_CONFIG_DIR as GEMINI_DIR, GEMINI_CONFIG_DIR as GEMINI_DIR,
getErrorMessage, getErrorMessage,
BugCommandSettings,
TelemetrySettings,
AuthType,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import stripJsonComments from 'strip-json-comments'; import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js'; import { DefaultLight } from '../ui/themes/default-light.js';
import { DefaultDark } from '../ui/themes/default.js'; import { DefaultDark } from '../ui/themes/default.js';
import { CustomTheme } from '../ui/themes/theme.js'; import { Settings, MemoryImportFormat } from './settingsSchema.js';
export type { Settings, MemoryImportFormat };
export const SETTINGS_DIRECTORY_NAME = '.qwen'; export const SETTINGS_DIRECTORY_NAME = '.qwen';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
@@ -43,7 +41,7 @@ export function getWorkspaceSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json'); return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
} }
export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; export type { DnsResolutionOrder } from './settingsSchema.js';
export enum SettingScope { export enum SettingScope {
User = 'User', User = 'User',
@@ -63,95 +61,6 @@ export interface AccessibilitySettings {
disableLoadingPhrases?: boolean; disableLoadingPhrases?: boolean;
} }
export interface Settings {
theme?: string;
customThemes?: Record<string, CustomTheme>;
selectedAuthType?: AuthType;
useExternalAuth?: boolean;
sandbox?: boolean | string;
coreTools?: string[];
excludeTools?: string[];
toolDiscoveryCommand?: string;
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
allowMCPServers?: string[];
excludeMCPServers?: string[];
showMemoryUsage?: boolean;
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
telemetry?: TelemetrySettings;
usageStatisticsEnabled?: boolean;
preferredEditor?: string;
bugCommand?: BugCommandSettings;
checkpointing?: CheckpointingSettings;
autoConfigureMaxOldSpaceSize?: boolean;
/** The model name to use (e.g 'gemini-9.0-pro') */
model?: string;
enableOpenAILogging?: boolean;
// Git-aware file filtering settings
fileFiltering?: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
enableRecursiveFileSearch?: boolean;
};
hideWindowTitle?: boolean;
hideTips?: boolean;
hideBanner?: boolean;
// Setting for setting maximum number of user/model/tool turns in a session.
maxSessionTurns?: number;
// Setting for maximum token limit for conversation history before blocking requests
sessionTokenLimit?: number;
// Setting for maximum number of files and folders to show in folder structure
maxFolderItems?: number;
// A map of tool names to their summarization settings.
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
vimMode?: boolean;
memoryImportFormat?: 'tree' | 'flat';
// Flag to be removed post-launch.
ideModeFeature?: boolean;
/// IDE mode setting configured via slash command toggle.
ideMode?: boolean;
// Setting for disabling auto-update.
disableAutoUpdate?: boolean;
// Setting for disabling the update nag message.
disableUpdateNag?: boolean;
memoryDiscoveryMaxDirs?: number;
// Environment variables to exclude from project .env files
excludedProjectEnvVars?: string[];
dnsResolutionOrder?: DnsResolutionOrder;
sampling_params?: Record<string, unknown>;
systemPromptMappings?: Array<{
baseUrls: string[];
modelNames: string[];
template: string;
}>;
contentGenerator?: {
timeout?: number;
maxRetries?: number;
};
includeDirectories?: string[];
loadMemoryFromIncludeDirectories?: boolean;
// Web search API keys
tavilyApiKey?: string;
}
export interface SettingsError { export interface SettingsError {
message: string; message: string;
path: string; path: string;
@@ -191,9 +100,13 @@ export class LoadedSettings {
const user = this.user.settings; const user = this.user.settings;
const workspace = this.workspace.settings; const workspace = this.workspace.settings;
// folderTrust is not supported at workspace level.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { folderTrust, ...workspaceWithoutFolderTrust } = workspace;
return { return {
...user, ...user,
...workspace, ...workspaceWithoutFolderTrust,
...system, ...system,
customThemes: { customThemes: {
...(user.customThemes || {}), ...(user.customThemes || {}),
@@ -210,6 +123,11 @@ export class LoadedSettings {
...(user.includeDirectories || []), ...(user.includeDirectories || []),
...(workspace.includeDirectories || []), ...(workspace.includeDirectories || []),
], ],
chatCompression: {
...(system.chatCompression || {}),
...(user.chatCompression || {}),
...(workspace.chatCompression || {}),
},
}; };
} }
@@ -498,6 +416,19 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
settingsErrors, settingsErrors,
); );
// Validate chatCompression settings
const chatCompression = loadedSettings.merged.chatCompression;
const threshold = chatCompression?.contextPercentageThreshold;
if (
threshold != null &&
(typeof threshold !== 'number' || threshold < 0 || threshold > 1)
) {
console.warn(
`Invalid value for chatCompression.contextPercentageThreshold: "${threshold}". Please use a value between 0 and 1. Using default compression settings.`,
);
delete loadedSettings.merged.chatCompression;
}
// Load environment with merged settings // Load environment with merged settings
loadEnvironment(loadedSettings.merged); loadEnvironment(loadedSettings.merged);

View File

@@ -0,0 +1,253 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { SETTINGS_SCHEMA, Settings } from './settingsSchema.js';
describe('SettingsSchema', () => {
describe('SETTINGS_SCHEMA', () => {
it('should contain all expected top-level settings', () => {
const expectedSettings = [
'theme',
'customThemes',
'showMemoryUsage',
'usageStatisticsEnabled',
'autoConfigureMaxOldSpaceSize',
'preferredEditor',
'maxSessionTurns',
'memoryImportFormat',
'memoryDiscoveryMaxDirs',
'contextFileName',
'vimMode',
'ideMode',
'accessibility',
'checkpointing',
'fileFiltering',
'disableAutoUpdate',
'hideWindowTitle',
'hideTips',
'hideBanner',
'selectedAuthType',
'useExternalAuth',
'sandbox',
'coreTools',
'excludeTools',
'toolDiscoveryCommand',
'toolCallCommand',
'mcpServerCommand',
'mcpServers',
'allowMCPServers',
'excludeMCPServers',
'telemetry',
'bugCommand',
'summarizeToolOutput',
'ideModeFeature',
'dnsResolutionOrder',
'excludedProjectEnvVars',
'disableUpdateNag',
'includeDirectories',
'loadMemoryFromIncludeDirectories',
'model',
'hasSeenIdeIntegrationNudge',
'folderTrustFeature',
];
expectedSettings.forEach((setting) => {
expect(
SETTINGS_SCHEMA[setting as keyof typeof SETTINGS_SCHEMA],
).toBeDefined();
});
});
it('should have correct structure for each setting', () => {
Object.entries(SETTINGS_SCHEMA).forEach(([_key, definition]) => {
expect(definition).toHaveProperty('type');
expect(definition).toHaveProperty('label');
expect(definition).toHaveProperty('category');
expect(definition).toHaveProperty('requiresRestart');
expect(definition).toHaveProperty('default');
expect(typeof definition.type).toBe('string');
expect(typeof definition.label).toBe('string');
expect(typeof definition.category).toBe('string');
expect(typeof definition.requiresRestart).toBe('boolean');
});
});
it('should have correct nested setting structure', () => {
const nestedSettings = [
'accessibility',
'checkpointing',
'fileFiltering',
];
nestedSettings.forEach((setting) => {
const definition = SETTINGS_SCHEMA[
setting as keyof typeof SETTINGS_SCHEMA
] as (typeof SETTINGS_SCHEMA)[keyof typeof SETTINGS_SCHEMA] & {
properties: unknown;
};
expect(definition.type).toBe('object');
expect(definition.properties).toBeDefined();
expect(typeof definition.properties).toBe('object');
});
});
it('should have accessibility nested properties', () => {
expect(
SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases,
).toBeDefined();
expect(
SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases.type,
).toBe('boolean');
});
it('should have checkpointing nested properties', () => {
expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled).toBeDefined();
expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled.type).toBe(
'boolean',
);
});
it('should have fileFiltering nested properties', () => {
expect(
SETTINGS_SCHEMA.fileFiltering.properties?.respectGitIgnore,
).toBeDefined();
expect(
SETTINGS_SCHEMA.fileFiltering.properties?.respectGeminiIgnore,
).toBeDefined();
expect(
SETTINGS_SCHEMA.fileFiltering.properties?.enableRecursiveFileSearch,
).toBeDefined();
});
it('should have unique categories', () => {
const categories = new Set();
// Collect categories from top-level settings
Object.values(SETTINGS_SCHEMA).forEach((definition) => {
categories.add(definition.category);
// Also collect from nested properties
const defWithProps = definition as typeof definition & {
properties?: Record<string, unknown>;
};
if (defWithProps.properties) {
Object.values(defWithProps.properties).forEach(
(nestedDef: unknown) => {
const nestedDefTyped = nestedDef as { category?: string };
if (nestedDefTyped.category) {
categories.add(nestedDefTyped.category);
}
},
);
}
});
expect(categories.size).toBeGreaterThan(0);
expect(categories).toContain('General');
expect(categories).toContain('UI');
expect(categories).toContain('Mode');
expect(categories).toContain('Updates');
expect(categories).toContain('Accessibility');
expect(categories).toContain('Checkpointing');
expect(categories).toContain('File Filtering');
expect(categories).toContain('Advanced');
});
it('should have consistent default values for boolean settings', () => {
const checkBooleanDefaults = (schema: Record<string, unknown>) => {
Object.entries(schema).forEach(
([_key, definition]: [string, unknown]) => {
const def = definition as {
type?: string;
default?: unknown;
properties?: Record<string, unknown>;
};
if (def.type === 'boolean') {
// Boolean settings can have boolean or undefined defaults (for optional settings)
expect(['boolean', 'undefined']).toContain(typeof def.default);
}
if (def.properties) {
checkBooleanDefaults(def.properties);
}
},
);
};
checkBooleanDefaults(SETTINGS_SCHEMA as Record<string, unknown>);
});
it('should have showInDialog property configured', () => {
// Check that user-facing settings are marked for dialog display
expect(SETTINGS_SCHEMA.showMemoryUsage.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.vimMode.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.ideMode.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.disableAutoUpdate.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.hideWindowTitle.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.hideTips.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.hideBanner.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.usageStatisticsEnabled.showInDialog).toBe(true);
// Check that advanced settings are hidden from dialog
expect(SETTINGS_SCHEMA.selectedAuthType.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.coreTools.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.mcpServers.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.telemetry.showInDialog).toBe(false);
// Check that some settings are appropriately hidden
expect(SETTINGS_SCHEMA.theme.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.customThemes.showInDialog).toBe(false); // Managed via theme editor
expect(SETTINGS_SCHEMA.checkpointing.showInDialog).toBe(false); // Experimental feature
expect(SETTINGS_SCHEMA.accessibility.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.fileFiltering.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.preferredEditor.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.autoConfigureMaxOldSpaceSize.showInDialog).toBe(
true,
);
});
it('should infer Settings type correctly', () => {
// This test ensures that the Settings type is properly inferred from the schema
const settings: Settings = {
theme: 'dark',
includeDirectories: ['/path/to/dir'],
loadMemoryFromIncludeDirectories: true,
};
// TypeScript should not complain about these properties
expect(settings.theme).toBe('dark');
expect(settings.includeDirectories).toEqual(['/path/to/dir']);
expect(settings.loadMemoryFromIncludeDirectories).toBe(true);
});
it('should have includeDirectories setting in schema', () => {
expect(SETTINGS_SCHEMA.includeDirectories).toBeDefined();
expect(SETTINGS_SCHEMA.includeDirectories.type).toBe('array');
expect(SETTINGS_SCHEMA.includeDirectories.category).toBe('General');
expect(SETTINGS_SCHEMA.includeDirectories.default).toEqual([]);
});
it('should have loadMemoryFromIncludeDirectories setting in schema', () => {
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories).toBeDefined();
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.type).toBe(
'boolean',
);
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.category).toBe(
'General',
);
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.default).toBe(
false,
);
});
it('should have folderTrustFeature setting in schema', () => {
expect(SETTINGS_SCHEMA.folderTrustFeature).toBeDefined();
expect(SETTINGS_SCHEMA.folderTrustFeature.type).toBe('boolean');
expect(SETTINGS_SCHEMA.folderTrustFeature.category).toBe('General');
expect(SETTINGS_SCHEMA.folderTrustFeature.default).toBe(false);
expect(SETTINGS_SCHEMA.folderTrustFeature.showInDialog).toBe(true);
});
});
});

View File

@@ -0,0 +1,571 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
MCPServerConfig,
BugCommandSettings,
TelemetrySettings,
AuthType,
ChatCompressionSettings,
} from '@qwen-code/qwen-code-core';
import { CustomTheme } from '../ui/themes/theme.js';
export interface SettingDefinition {
type: 'boolean' | 'string' | 'number' | 'array' | 'object';
label: string;
category: string;
requiresRestart: boolean;
default: boolean | string | number | string[] | object | undefined;
description?: string;
parentKey?: string;
childKey?: string;
key?: string;
properties?: SettingsSchema;
showInDialog?: boolean;
}
export interface SettingsSchema {
[key: string]: SettingDefinition;
}
export type MemoryImportFormat = 'tree' | 'flat';
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
/**
* The canonical schema for all settings.
* The structure of this object defines the structure of the `Settings` type.
* `as const` is crucial for TypeScript to infer the most specific types possible.
*/
export const SETTINGS_SCHEMA = {
// UI Settings
theme: {
type: 'string',
label: 'Theme',
category: 'UI',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The color theme for the UI.',
showInDialog: false,
},
customThemes: {
type: 'object',
label: 'Custom Themes',
category: 'UI',
requiresRestart: false,
default: {} as Record<string, CustomTheme>,
description: 'Custom theme definitions.',
showInDialog: false,
},
hideWindowTitle: {
type: 'boolean',
label: 'Hide Window Title',
category: 'UI',
requiresRestart: true,
default: false,
description: 'Hide the window title bar',
showInDialog: true,
},
hideTips: {
type: 'boolean',
label: 'Hide Tips',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Hide helpful tips in the UI',
showInDialog: true,
},
hideBanner: {
type: 'boolean',
label: 'Hide Banner',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Hide the application banner',
showInDialog: true,
},
showMemoryUsage: {
type: 'boolean',
label: 'Show Memory Usage',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Display memory usage information in the UI',
showInDialog: true,
},
usageStatisticsEnabled: {
type: 'boolean',
label: 'Enable Usage Statistics',
category: 'General',
requiresRestart: true,
default: true,
description: 'Enable collection of usage statistics',
showInDialog: true,
},
autoConfigureMaxOldSpaceSize: {
type: 'boolean',
label: 'Auto Configure Max Old Space Size',
category: 'General',
requiresRestart: true,
default: false,
description: 'Automatically configure Node.js memory limits',
showInDialog: true,
},
preferredEditor: {
type: 'string',
label: 'Preferred Editor',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The preferred editor to open files in.',
showInDialog: false,
},
maxSessionTurns: {
type: 'number',
label: 'Max Session Turns',
category: 'General',
requiresRestart: false,
default: undefined as number | undefined,
description:
'Maximum number of user/model/tool turns to keep in a session.',
showInDialog: false,
},
memoryImportFormat: {
type: 'string',
label: 'Memory Import Format',
category: 'General',
requiresRestart: false,
default: undefined as MemoryImportFormat | undefined,
description: 'The format to use when importing memory.',
showInDialog: false,
},
memoryDiscoveryMaxDirs: {
type: 'number',
label: 'Memory Discovery Max Dirs',
category: 'General',
requiresRestart: false,
default: undefined as number | undefined,
description: 'Maximum number of directories to search for memory.',
showInDialog: false,
},
contextFileName: {
type: 'object',
label: 'Context File Name',
category: 'General',
requiresRestart: false,
default: undefined as string | string[] | undefined,
description: 'The name of the context file.',
showInDialog: false,
},
vimMode: {
type: 'boolean',
label: 'Vim Mode',
category: 'Mode',
requiresRestart: false,
default: false,
description: 'Enable Vim keybindings',
showInDialog: true,
},
ideMode: {
type: 'boolean',
label: 'IDE Mode',
category: 'Mode',
requiresRestart: true,
default: false,
description: 'Enable IDE integration mode',
showInDialog: true,
},
accessibility: {
type: 'object',
label: 'Accessibility',
category: 'Accessibility',
requiresRestart: true,
default: {},
description: 'Accessibility settings.',
showInDialog: false,
properties: {
disableLoadingPhrases: {
type: 'boolean',
label: 'Disable Loading Phrases',
category: 'Accessibility',
requiresRestart: true,
default: false,
description: 'Disable loading phrases for accessibility',
showInDialog: true,
},
},
},
checkpointing: {
type: 'object',
label: 'Checkpointing',
category: 'Checkpointing',
requiresRestart: true,
default: {},
description: 'Session checkpointing settings.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Checkpointing',
category: 'Checkpointing',
requiresRestart: true,
default: false,
description: 'Enable session checkpointing for recovery',
showInDialog: false,
},
},
},
fileFiltering: {
type: 'object',
label: 'File Filtering',
category: 'File Filtering',
requiresRestart: true,
default: {},
description: 'Settings for git-aware file filtering.',
showInDialog: false,
properties: {
respectGitIgnore: {
type: 'boolean',
label: 'Respect .gitignore',
category: 'File Filtering',
requiresRestart: true,
default: true,
description: 'Respect .gitignore files when searching',
showInDialog: true,
},
respectGeminiIgnore: {
type: 'boolean',
label: 'Respect .geminiignore',
category: 'File Filtering',
requiresRestart: true,
default: true,
description: 'Respect .geminiignore files when searching',
showInDialog: true,
},
enableRecursiveFileSearch: {
type: 'boolean',
label: 'Enable Recursive File Search',
category: 'File Filtering',
requiresRestart: true,
default: true,
description: 'Enable recursive file search functionality',
showInDialog: true,
},
},
},
disableAutoUpdate: {
type: 'boolean',
label: 'Disable Auto Update',
category: 'Updates',
requiresRestart: false,
default: false,
description: 'Disable automatic updates',
showInDialog: true,
},
selectedAuthType: {
type: 'string',
label: 'Selected Auth Type',
category: 'Advanced',
requiresRestart: true,
default: undefined as AuthType | undefined,
description: 'The currently selected authentication type.',
showInDialog: false,
},
useExternalAuth: {
type: 'boolean',
label: 'Use External Auth',
category: 'Advanced',
requiresRestart: true,
default: undefined as boolean | undefined,
description: 'Whether to use an external authentication flow.',
showInDialog: false,
},
sandbox: {
type: 'object',
label: 'Sandbox',
category: 'Advanced',
requiresRestart: true,
default: undefined as boolean | string | undefined,
description:
'Sandbox execution environment (can be a boolean or a path string).',
showInDialog: false,
},
coreTools: {
type: 'array',
label: 'Core Tools',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: 'Paths to core tool definitions.',
showInDialog: false,
},
excludeTools: {
type: 'array',
label: 'Exclude Tools',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: 'Tool names to exclude from discovery.',
showInDialog: false,
},
toolDiscoveryCommand: {
type: 'string',
label: 'Tool Discovery Command',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Command to run for tool discovery.',
showInDialog: false,
},
toolCallCommand: {
type: 'string',
label: 'Tool Call Command',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Command to run for tool calls.',
showInDialog: false,
},
mcpServerCommand: {
type: 'string',
label: 'MCP Server Command',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Command to start an MCP server.',
showInDialog: false,
},
mcpServers: {
type: 'object',
label: 'MCP Servers',
category: 'Advanced',
requiresRestart: true,
default: {} as Record<string, MCPServerConfig>,
description: 'Configuration for MCP servers.',
showInDialog: false,
},
allowMCPServers: {
type: 'array',
label: 'Allow MCP Servers',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: 'A whitelist of MCP servers to allow.',
showInDialog: false,
},
excludeMCPServers: {
type: 'array',
label: 'Exclude MCP Servers',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: 'A blacklist of MCP servers to exclude.',
showInDialog: false,
},
telemetry: {
type: 'object',
label: 'Telemetry',
category: 'Advanced',
requiresRestart: true,
default: undefined as TelemetrySettings | undefined,
description: 'Telemetry configuration.',
showInDialog: false,
},
bugCommand: {
type: 'object',
label: 'Bug Command',
category: 'Advanced',
requiresRestart: false,
default: undefined as BugCommandSettings | undefined,
description: 'Configuration for the bug report command.',
showInDialog: false,
},
summarizeToolOutput: {
type: 'object',
label: 'Summarize Tool Output',
category: 'Advanced',
requiresRestart: false,
default: undefined as Record<string, { tokenBudget?: number }> | undefined,
description: 'Settings for summarizing tool output.',
showInDialog: false,
},
ideModeFeature: {
type: 'boolean',
label: 'IDE Mode Feature Flag',
category: 'Advanced',
requiresRestart: true,
default: undefined as boolean | undefined,
description: 'Internal feature flag for IDE mode.',
showInDialog: false,
},
dnsResolutionOrder: {
type: 'string',
label: 'DNS Resolution Order',
category: 'Advanced',
requiresRestart: true,
default: undefined as DnsResolutionOrder | undefined,
description: 'The DNS resolution order.',
showInDialog: false,
},
excludedProjectEnvVars: {
type: 'array',
label: 'Excluded Project Environment Variables',
category: 'Advanced',
requiresRestart: false,
default: ['DEBUG', 'DEBUG_MODE'] as string[],
description: 'Environment variables to exclude from project context.',
showInDialog: false,
},
disableUpdateNag: {
type: 'boolean',
label: 'Disable Update Nag',
category: 'Updates',
requiresRestart: false,
default: false,
description: 'Disable update notification prompts.',
showInDialog: false,
},
includeDirectories: {
type: 'array',
label: 'Include Directories',
category: 'General',
requiresRestart: false,
default: [] as string[],
description: 'Additional directories to include in the workspace context.',
showInDialog: false,
},
loadMemoryFromIncludeDirectories: {
type: 'boolean',
label: 'Load Memory From Include Directories',
category: 'General',
requiresRestart: false,
default: false,
description: 'Whether to load memory files from include directories.',
showInDialog: true,
},
model: {
type: 'string',
label: 'Model',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The Gemini model to use for conversations.',
showInDialog: false,
},
hasSeenIdeIntegrationNudge: {
type: 'boolean',
label: 'Has Seen IDE Integration Nudge',
category: 'General',
requiresRestart: false,
default: false,
description: 'Whether the user has seen the IDE integration nudge.',
showInDialog: false,
},
folderTrustFeature: {
type: 'boolean',
label: 'Folder Trust Feature',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable folder trust feature for enhanced security.',
showInDialog: true,
},
folderTrust: {
type: 'boolean',
label: 'Folder Trust',
category: 'General',
requiresRestart: false,
default: false,
description: 'Setting to track whether Folder trust is enabled.',
showInDialog: true,
},
chatCompression: {
type: 'object',
label: 'Chat Compression',
category: 'General',
requiresRestart: false,
default: undefined as ChatCompressionSettings | undefined,
description: 'Chat compression settings.',
showInDialog: false,
},
showLineNumbers: {
type: 'boolean',
label: 'Show Line Numbers',
category: 'General',
requiresRestart: false,
default: false,
description: 'Show line numbers in the chat.',
showInDialog: true,
},
contentGenerator: {
type: 'object',
label: 'Content Generator',
category: 'General',
requiresRestart: false,
default: undefined as Record<string, unknown> | undefined,
description: 'Content generator settings.',
showInDialog: false,
},
sampling_params: {
type: 'object',
label: 'Sampling Params',
category: 'General',
requiresRestart: false,
default: undefined as Record<string, unknown> | undefined,
description: 'Sampling parameters for the model.',
showInDialog: false,
},
enableOpenAILogging: {
type: 'boolean',
label: 'Enable OpenAI Logging',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable OpenAI logging.',
showInDialog: true,
},
sessionTokenLimit: {
type: 'number',
label: 'Session Token Limit',
category: 'General',
requiresRestart: false,
default: undefined as number | undefined,
description: 'The maximum number of tokens allowed in a session.',
showInDialog: false,
},
systemPromptMappings: {
type: 'object',
label: 'System Prompt Mappings',
category: 'General',
requiresRestart: false,
default: undefined as Record<string, string> | undefined,
description: 'Mappings of system prompts to model names.',
showInDialog: false,
},
tavilyApiKey: {
type: 'string',
label: 'Tavily API Key',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The API key for the Tavily API.',
showInDialog: false,
},
} as const;
type InferSettings<T extends SettingsSchema> = {
-readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema }
? InferSettings<T[K]['properties']>
: T[K]['default'] extends boolean
? boolean
: T[K]['default'];
};
export type Settings = InferSettings<typeof SETTINGS_SCHEMA>;

View File

@@ -7,7 +7,7 @@
import React from 'react'; import React from 'react';
import { render } from 'ink'; import { render } from 'ink';
import { AppWrapper } from './ui/App.js'; import { AppWrapper } from './ui/App.js';
import { loadCliConfig, parseArguments, CliArgs } from './config/config.js'; import { loadCliConfig, parseArguments } from './config/config.js';
import { readStdin } from './utils/readStdin.js'; import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path'; import { basename } from 'node:path';
import v8 from 'node:v8'; import v8 from 'node:v8';
@@ -25,19 +25,18 @@ import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js'; import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { runNonInteractive } from './nonInteractiveCli.js'; import { runNonInteractive } from './nonInteractiveCli.js';
import { loadExtensions, Extension } from './config/extension.js'; import { loadExtensions } from './config/extension.js';
import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js'; import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js'; import { getCliVersion } from './utils/version.js';
import { import {
ApprovalMode,
Config, Config,
EditTool,
ShellTool,
WriteFileTool,
sessionId, sessionId,
logUserPrompt, logUserPrompt,
AuthType, AuthType,
getOauthClient, getOauthClient,
logIdeConnection,
IdeConnectionEvent,
IdeConnectionType,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { validateAuthMethod } from './config/auth.js'; import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
@@ -45,6 +44,7 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js'; import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js'; import { appEvents, AppEvent } from './utils/events.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
export function validateDnsResolutionOrder( export function validateDnsResolutionOrder(
order: string | undefined, order: string | undefined,
@@ -191,6 +191,11 @@ export async function main() {
await config.initialize(); await config.initialize();
if (config.getIdeMode() && config.getIdeModeFeature()) {
await config.getIdeClient().connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
}
// Load custom themes from settings // Load custom themes from settings
themeManager.loadCustomThemes(settings.merged.customThemes); themeManager.loadCustomThemes(settings.merged.customThemes);
@@ -255,21 +260,20 @@ export async function main() {
...(await getUserStartupWarnings(workspaceRoot)), ...(await getUserStartupWarnings(workspaceRoot)),
]; ];
const shouldBeInteractive =
!!argv.promptInteractive || (process.stdin.isTTY && input?.length === 0);
// Render UI, passing necessary config values. Check that there is no command line question. // Render UI, passing necessary config values. Check that there is no command line question.
if (shouldBeInteractive) { if (config.isInteractive()) {
const version = await getCliVersion(); const version = await getCliVersion();
setWindowTitle(basename(workspaceRoot), settings); setWindowTitle(basename(workspaceRoot), settings);
const instance = render( const instance = render(
<React.StrictMode> <React.StrictMode>
<AppWrapper <SettingsContext.Provider value={settings}>
config={config} <AppWrapper
settings={settings} config={config}
startupWarnings={startupWarnings} settings={settings}
version={version} startupWarnings={startupWarnings}
/> version={version}
/>
</SettingsContext.Provider>
</React.StrictMode>, </React.StrictMode>,
{ exitOnCtrlC: false }, { exitOnCtrlC: false },
); );
@@ -308,12 +312,10 @@ export async function main() {
prompt_length: input.length, prompt_length: input.length,
}); });
// Non-interactive mode handled by runNonInteractive const nonInteractiveConfig = await validateNonInteractiveAuth(
const nonInteractiveConfig = await loadNonInteractiveConfig( settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
config, config,
extensions,
settings,
argv,
); );
await runNonInteractive(nonInteractiveConfig, input, prompt_id); await runNonInteractive(nonInteractiveConfig, input, prompt_id);
@@ -334,43 +336,3 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
}); });
} }
} }
async function loadNonInteractiveConfig(
config: Config,
extensions: Extension[],
settings: LoadedSettings,
argv: CliArgs,
) {
let finalConfig = config;
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
// Everything is not allowed, ensure that only read-only tools are configured.
const existingExcludeTools = settings.merged.excludeTools || [];
const interactiveTools = [
ShellTool.Name,
EditTool.Name,
WriteFileTool.Name,
];
const newExcludeTools = [
...new Set([...existingExcludeTools, ...interactiveTools]),
];
const nonInteractiveSettings = {
...settings.merged,
excludeTools: newExcludeTools,
};
finalConfig = await loadCliConfig(
nonInteractiveSettings,
extensions,
config.getSessionId(),
argv,
);
await finalConfig.initialize();
}
return await validateNonInteractiveAuth(
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
finalConfig,
);
}

View File

@@ -30,7 +30,6 @@ export async function runNonInteractive(
}); });
try { try {
await config.initialize();
consolePatcher.patch(); consolePatcher.patch();
// Handle EPIPE errors when the output is piped to a command that closes early. // Handle EPIPE errors when the output is piped to a command that closes early.
process.stdout.on('error', (err: NodeJS.ErrnoException) => { process.stdout.on('error', (err: NodeJS.ErrnoException) => {

View File

@@ -30,9 +30,9 @@ import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { isGitHubRepository } from '../utils/gitUtils.js';
/** /**
* Loads the core, hard-coded slash commands that are an integral part * Loads the core, hard-coded slash commands that are an integral part
@@ -73,8 +73,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
statsCommand, statsCommand,
themeCommand, themeCommand,
toolsCommand, toolsCommand,
settingsCommand,
vimCommand, vimCommand,
...(isGitHubRepository() ? [setupGithubCommand] : []), setupGithubCommand,
]; ];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);

View File

@@ -16,6 +16,7 @@ import {
SandboxConfig, SandboxConfig,
GeminiClient, GeminiClient,
ideContext, ideContext,
type AuthType,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js'; import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
import process from 'node:process'; import process from 'node:process';
@@ -27,6 +28,7 @@ import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { updateEventEmitter } from '../utils/updateEventEmitter.js'; import { updateEventEmitter } from '../utils/updateEventEmitter.js';
import * as auth from '../config/auth.js'; import * as auth from '../config/auth.js';
import * as useTerminalSize from './hooks/useTerminalSize.js';
// Define a more complete mock server config based on actual Config // Define a more complete mock server config based on actual Config
interface MockServerConfig { interface MockServerConfig {
@@ -84,6 +86,7 @@ interface MockServerConfig {
getAllGeminiMdFilenames: Mock<() => string[]>; getAllGeminiMdFilenames: Mock<() => string[]>;
getGeminiClient: Mock<() => GeminiClient | undefined>; getGeminiClient: Mock<() => GeminiClient | undefined>;
getUserTier: Mock<() => Promise<string | undefined>>; getUserTier: Mock<() => Promise<string | undefined>>;
getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>;
} }
// Mock @qwen-code/qwen-code-core and its Config class // Mock @qwen-code/qwen-code-core and its Config class
@@ -157,6 +160,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getWorkspaceContext: vi.fn(() => ({ getWorkspaceContext: vi.fn(() => ({
getDirectories: vi.fn(() => []), getDirectories: vi.fn(() => []),
})), })),
getIdeClient: vi.fn(() => ({
getCurrentIde: vi.fn(() => 'vscode'),
})),
}; };
}); });
@@ -182,6 +188,7 @@ vi.mock('./hooks/useGeminiStream', () => ({
submitQuery: vi.fn(), submitQuery: vi.fn(),
initError: null, initError: null,
pendingHistoryItems: [], pendingHistoryItems: [],
thought: null,
})), })),
})); }));
@@ -196,6 +203,13 @@ vi.mock('./hooks/useAuthCommand', () => ({
})), })),
})); }));
vi.mock('./hooks/useFolderTrust', () => ({
useFolderTrust: vi.fn(() => ({
isFolderTrustDialogOpen: false,
handleFolderTrustSelect: vi.fn(),
})),
}));
vi.mock('./hooks/useLogger', () => ({ vi.mock('./hooks/useLogger', () => ({
useLogger: vi.fn(() => ({ useLogger: vi.fn(() => ({
getPreviousUserMessages: vi.fn().mockResolvedValue([]), getPreviousUserMessages: vi.fn().mockResolvedValue([]),
@@ -233,10 +247,14 @@ vi.mock('./utils/updateCheck.js', () => ({
checkForUpdates: vi.fn(), checkForUpdates: vi.fn(),
})); }));
vi.mock('./config/auth.js', () => ({ vi.mock('../config/auth.js', () => ({
validateAuthMethod: vi.fn(), validateAuthMethod: vi.fn(),
})); }));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const mockedCheckForUpdates = vi.mocked(checkForUpdates); const mockedCheckForUpdates = vi.mocked(checkForUpdates);
const { isGitRepository: mockedIsGitRepository } = vi.mocked( const { isGitRepository: mockedIsGitRepository } = vi.mocked(
await import('@qwen-code/qwen-code-core'), await import('@qwen-code/qwen-code-core'),
@@ -278,6 +296,11 @@ describe('App UI', () => {
}; };
beforeEach(() => { beforeEach(() => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 24,
});
const ServerConfigMocked = vi.mocked(ServerConfig, true); const ServerConfigMocked = vi.mocked(ServerConfig, true);
mockConfig = new ServerConfigMocked({ mockConfig = new ServerConfigMocked({
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
@@ -1050,4 +1073,44 @@ describe('App UI', () => {
expect(validateAuthMethodSpy).not.toHaveBeenCalled(); expect(validateAuthMethodSpy).not.toHaveBeenCalled();
}); });
}); });
describe('when in a narrow terminal', () => {
it('should render with a column layout', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 60,
rows: 24,
});
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(lastFrame()).toMatchSnapshot();
});
});
describe('FolderTrustDialog', () => {
it('should display the folder trust dialog when isFolderTrustDialogOpen is true', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isFolderTrustDialogOpen: true,
handleFolderTrustSelect: vi.fn(),
});
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Do you trust this folder?');
});
});
}); });

View File

@@ -13,8 +13,6 @@ import {
Text, Text,
useStdin, useStdin,
useStdout, useStdout,
useInput,
type Key as InkKeyType,
} from 'ink'; } from 'ink';
import { StreamingState, type HistoryItem, MessageType } from './types.js'; import { StreamingState, type HistoryItem, MessageType } from './types.js';
import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useTerminalSize } from './hooks/useTerminalSize.js';
@@ -23,6 +21,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './hooks/useAuthCommand.js'; import { useAuthCommand } from './hooks/useAuthCommand.js';
import { useQwenAuth } from './hooks/useQwenAuth.js'; import { useQwenAuth } from './hooks/useQwenAuth.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
@@ -38,17 +37,18 @@ import { AuthDialog } from './components/AuthDialog.js';
import { AuthInProgress } from './components/AuthInProgress.js'; import { AuthInProgress } from './components/AuthInProgress.js';
import { QwenOAuthProgress } from './components/QwenOAuthProgress.js'; import { QwenOAuthProgress } from './components/QwenOAuthProgress.js';
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { FolderTrustDialog } from './components/FolderTrustDialog.js';
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { Colors } from './colors.js'; import { Colors } from './colors.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js';
import { LoadedSettings } from '../config/settings.js'; import { LoadedSettings, SettingScope } from '../config/settings.js';
import { Tips } from './components/Tips.js'; import { Tips } from './components/Tips.js';
import { ConsolePatcher } from './utils/ConsolePatcher.js'; import { ConsolePatcher } from './utils/ConsolePatcher.js';
import { registerCleanup } from '../utils/cleanup.js'; import { registerCleanup } from '../utils/cleanup.js';
import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js'; import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js'; import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js'; import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
import { IDEContextDetailDisplay } from './components/IDEContextDetailDisplay.js';
import { useHistory } from './hooks/useHistoryManager.js'; import { useHistory } from './hooks/useHistoryManager.js';
import process from 'node:process'; import process from 'node:process';
import { import {
@@ -64,6 +64,10 @@ import {
type IdeContext, type IdeContext,
ideContext, ideContext,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import {
IdeIntegrationNudge,
IdeIntegrationNudgeResult,
} from './IdeIntegrationNudge.js';
import { validateAuthMethod } from '../config/auth.js'; import { validateAuthMethod } from '../config/auth.js';
import { useLogger } from './hooks/useLogger.js'; import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js'; import { StreamingContext } from './contexts/StreamingContext.js';
@@ -77,6 +81,8 @@ import { useBracketedPaste } from './hooks/useBracketedPaste.js';
import { useTextBuffer } from './components/shared/text-buffer.js'; import { useTextBuffer } from './components/shared/text-buffer.js';
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js'; import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
import { useVim } from './hooks/vim.js'; import { useVim } from './hooks/vim.js';
import { useKeypress, Key } from './hooks/useKeypress.js';
import { keyMatchers, Command } from './keyMatchers.js';
import * as fs from 'fs'; import * as fs from 'fs';
import { UpdateNotification } from './components/UpdateNotification.js'; import { UpdateNotification } from './components/UpdateNotification.js';
import { import {
@@ -89,8 +95,11 @@ import ansiEscapes from 'ansi-escapes';
import { OverflowProvider } from './contexts/OverflowContext.js'; import { OverflowProvider } from './contexts/OverflowContext.js';
import { ShowMoreLines } from './components/ShowMoreLines.js'; import { ShowMoreLines } from './components/ShowMoreLines.js';
import { PrivacyNotice } from './privacy/PrivacyNotice.js'; import { PrivacyNotice } from './privacy/PrivacyNotice.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { SettingsDialog } from './components/SettingsDialog.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from '../utils/events.js'; import { appEvents, AppEvent } from '../utils/events.js';
import { isNarrowWidth } from './utils/isNarrowWidth.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -117,6 +126,18 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const nightly = version.includes('nightly'); const nightly = version.includes('nightly');
const { history, addItem, clearItems, loadHistory } = useHistory(); const { history, addItem, clearItems, loadHistory } = useHistory();
const [idePromptAnswered, setIdePromptAnswered] = useState(false);
const currentIDE = config.getIdeClient().getCurrentIde();
useEffect(() => {
registerCleanup(() => config.getIdeClient().disconnect());
}, [config]);
const shouldShowIdePrompt =
config.getIdeModeFeature() &&
currentIDE &&
!config.getIdeMode() &&
!settings.merged.hasSeenIdeIntegrationNudge &&
!idePromptAnswered;
useEffect(() => { useEffect(() => {
const cleanup = setUpdateHandler(addItem, setUpdateInfo); const cleanup = setUpdateHandler(addItem, setUpdateInfo);
return cleanup; return cleanup;
@@ -157,8 +178,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false); const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
const [showToolDescriptions, setShowToolDescriptions] = const [showToolDescriptions, setShowToolDescriptions] =
useState<boolean>(false); useState<boolean>(false);
const [showIDEContextDetail, setShowIDEContextDetail] =
useState<boolean>(false);
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
const [quittingMessages, setQuittingMessages] = useState< const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null HistoryItem[] | null
@@ -174,6 +194,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [ideContextState, setIdeContextState] = useState< const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined IdeContext | undefined
>(); >();
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [isProcessing, setIsProcessing] = useState<boolean>(false); const [isProcessing, setIsProcessing] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
@@ -208,6 +229,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const openPrivacyNotice = useCallback(() => { const openPrivacyNotice = useCallback(() => {
setShowPrivacyNotice(true); setShowPrivacyNotice(true);
}, []); }, []);
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
setShowEscapePrompt(showPrompt);
}, []);
const initialPromptSubmitted = useRef(false); const initialPromptSubmitted = useRef(false);
const errorCount = useMemo( const errorCount = useMemo(
@@ -225,6 +251,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
handleThemeHighlight, handleThemeHighlight,
} = useThemeCommand(settings, setThemeError, addItem); } = useThemeCommand(settings, setThemeError, addItem);
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const { isFolderTrustDialogOpen, handleFolderTrustSelect } =
useFolderTrust(settings);
const { const {
isAuthDialogOpen, isAuthDialogOpen,
openAuthDialog, openAuthDialog,
@@ -452,6 +484,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
// Terminal and UI setup // Terminal and UI setup
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const { stdin, setRawMode } = useStdin(); const { stdin, setRawMode } = useStdin();
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
@@ -460,7 +493,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
20, 20,
Math.floor(terminalWidth * widthFraction) - 3, Math.floor(terminalWidth * widthFraction) - 3,
); );
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 0.8));
// Utility callbacks // Utility callbacks
const isValidPath = useCallback((filePath: string): boolean => { const isValidPath = useCallback((filePath: string): boolean => {
@@ -499,6 +532,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
pendingHistoryItems: pendingSlashCommandHistoryItems, pendingHistoryItems: pendingSlashCommandHistoryItems,
commandContext, commandContext,
shellConfirmationRequest, shellConfirmationRequest,
confirmationRequest,
} = useSlashCommandProcessor( } = useSlashCommandProcessor(
config, config,
settings, settings,
@@ -513,17 +547,37 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
toggleCorgiMode, toggleCorgiMode,
setQuittingMessages, setQuittingMessages,
openPrivacyNotice, openPrivacyNotice,
openSettingsDialog,
toggleVimEnabled, toggleVimEnabled,
setIsProcessing, setIsProcessing,
setGeminiMdFileCount, setGeminiMdFileCount,
); );
const buffer = useTextBuffer({
initialText: '',
viewport: { height: 10, width: inputWidth },
stdin,
setRawMode,
isValidPath,
shellModeActive,
});
const [userMessages, setUserMessages] = useState<string[]>([]);
const handleUserCancel = useCallback(() => {
const lastUserMessage = userMessages.at(-1);
if (lastUserMessage) {
buffer.setText(lastUserMessage);
}
}, [buffer, userMessages]);
const { const {
streamingState, streamingState,
submitQuery, submitQuery,
initError, initError,
pendingHistoryItems: pendingGeminiHistoryItems, pendingHistoryItems: pendingGeminiHistoryItems,
thought, thought,
cancelOngoingRequest,
} = useGeminiStream( } = useGeminiStream(
config.getGeminiClient(), config.getGeminiClient(),
history, history,
@@ -538,6 +592,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
modelSwitchedFromQuotaError, modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError, setModelSwitchedFromQuotaError,
refreshStatic, refreshStatic,
handleUserCancel,
); );
// Input handling // Input handling
@@ -551,14 +606,26 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
[submitQuery], [submitQuery],
); );
const buffer = useTextBuffer({ const handleIdePromptComplete = useCallback(
initialText: '', (result: IdeIntegrationNudgeResult) => {
viewport: { height: 10, width: inputWidth }, if (result === 'yes') {
stdin, handleSlashCommand('/ide install');
setRawMode, settings.setValue(
isValidPath, SettingScope.User,
shellModeActive, 'hasSeenIdeIntegrationNudge',
}); true,
);
} else if (result === 'dismiss') {
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
}
setIdePromptAnswered(true);
},
[handleSlashCommand, settings],
);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
@@ -591,46 +658,75 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
[handleSlashCommand], [handleSlashCommand],
); );
useInput((input: string, key: InkKeyType) => { const handleGlobalKeypress = useCallback(
let enteringConstrainHeightMode = false; (key: Key) => {
if (!constrainHeight) { let enteringConstrainHeightMode = false;
// Automatically re-enter constrain height mode if the user types if (!constrainHeight) {
// anything. When constrainHeight==false, the user will experience enteringConstrainHeightMode = true;
// significant flickering so it is best to disable it immediately when setConstrainHeight(true);
// the user starts interacting with the app.
enteringConstrainHeightMode = true;
setConstrainHeight(true);
}
if (key.ctrl && input === 'o') {
setShowErrorDetails((prev) => !prev);
} else if (key.ctrl && input === 't') {
const newValue = !showToolDescriptions;
setShowToolDescriptions(newValue);
const mcpServers = config.getMcpServers();
if (Object.keys(mcpServers || {}).length > 0) {
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
} }
} else if (
key.ctrl && if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
input === 'e' && setShowErrorDetails((prev) => !prev);
config.getIdeMode() && } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) {
ideContextState const newValue = !showToolDescriptions;
) { setShowToolDescriptions(newValue);
setShowIDEContextDetail((prev) => !prev);
} else if (key.ctrl && (input === 'c' || input === 'C')) { const mcpServers = config.getMcpServers();
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); if (Object.keys(mcpServers || {}).length > 0) {
} else if (key.ctrl && (input === 'd' || input === 'D')) { handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
if (buffer.text.length > 0) { }
// Do nothing if there is text in the input. } else if (
return; keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) &&
config.getIdeMode() &&
ideContextState
) {
// Show IDE status when in IDE mode and context is available.
handleSlashCommand('/ide status');
} else if (keyMatchers[Command.QUIT](key)) {
// When authenticating, let AuthInProgress component handle Ctrl+C.
if (isAuthenticating) {
return;
}
if (!ctrlCPressedOnce) {
cancelOngoingRequest?.();
}
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
} else if (keyMatchers[Command.EXIT](key)) {
if (buffer.text.length > 0) {
return;
}
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
} else if (
keyMatchers[Command.SHOW_MORE_LINES](key) &&
!enteringConstrainHeightMode
) {
setConstrainHeight(false);
} }
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); },
} else if (key.ctrl && input === 's' && !enteringConstrainHeightMode) { [
setConstrainHeight(false); constrainHeight,
} setConstrainHeight,
}); setShowErrorDetails,
showToolDescriptions,
setShowToolDescriptions,
config,
ideContextState,
handleExit,
ctrlCPressedOnce,
setCtrlCPressedOnce,
ctrlCTimerRef,
buffer.text.length,
ctrlDPressedOnce,
setCtrlDPressedOnce,
ctrlDTimerRef,
handleSlashCommand,
isAuthenticating,
cancelOngoingRequest,
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useEffect(() => { useEffect(() => {
if (config) { if (config) {
@@ -639,7 +735,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
}, [config, config.getGeminiMdFileCount]); }, [config, config.getGeminiMdFileCount]);
const logger = useLogger(); const logger = useLogger();
const [userMessages, setUserMessages] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
const fetchUserMessages = async () => { const fetchUserMessages = async () => {
@@ -791,6 +886,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box> </Box>
); );
} }
const mainAreaWidth = Math.floor(terminalWidth * 0.9); const mainAreaWidth = Math.floor(terminalWidth * 0.9);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5)); const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5));
// Arbitrary threshold to ensure that items in the static area are large // Arbitrary threshold to ensure that items in the static area are large
@@ -819,11 +915,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
items={[ items={[
<Box flexDirection="column" key="header"> <Box flexDirection="column" key="header">
{!settings.merged.hideBanner && ( {!settings.merged.hideBanner && (
<Header <Header version={version} nightly={nightly} />
terminalWidth={terminalWidth}
version={version}
nightly={nightly}
/>
)} )}
{!settings.merged.hideTips && <Tips config={config} />} {!settings.merged.hideTips && <Tips config={config} />}
</Box>, </Box>,
@@ -882,8 +974,30 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box> </Box>
)} )}
{shellConfirmationRequest ? ( {shouldShowIdePrompt ? (
<IdeIntegrationNudge
ideName={config.getIdeClient().getDetectedIdeDisplayName()}
onComplete={handleIdePromptComplete}
/>
) : isFolderTrustDialogOpen ? (
<FolderTrustDialog onSelect={handleFolderTrustSelect} />
) : shellConfirmationRequest ? (
<ShellConfirmationDialog request={shellConfirmationRequest} /> <ShellConfirmationDialog request={shellConfirmationRequest} />
) : confirmationRequest ? (
<Box flexDirection="column">
{confirmationRequest.prompt}
<Box paddingY={1}>
<RadioButtonSelect
items={[
{ label: 'Yes', value: true },
{ label: 'No', value: false },
]}
onSelect={(value: boolean) => {
confirmationRequest.onConfirm(value);
}}
/>
</Box>
</Box>
) : isThemeDialogOpen ? ( ) : isThemeDialogOpen ? (
<Box flexDirection="column"> <Box flexDirection="column">
{themeError && ( {themeError && (
@@ -903,6 +1017,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
terminalWidth={mainAreaWidth} terminalWidth={mainAreaWidth}
/> />
</Box> </Box>
) : isSettingsDialogOpen ? (
<Box flexDirection="column">
<SettingsDialog
settings={settings}
onSelect={() => closeSettingsDialog()}
onRestartRequest={() => process.exit(0)}
/>
</Box>
) : isAuthenticating ? ( ) : isAuthenticating ? (
<> <>
{isQwenAuth && isQwenAuthenticating ? ( {isQwenAuth && isQwenAuthenticating ? (
@@ -994,9 +1116,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Box <Box
marginTop={1} marginTop={1}
display="flex"
justifyContent="space-between" justifyContent="space-between"
width="100%" width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
> >
<Box> <Box>
{process.env.GEMINI_SYSTEM_MD && ( {process.env.GEMINI_SYSTEM_MD && (
@@ -1010,6 +1133,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Text color={Colors.AccentYellow}> <Text color={Colors.AccentYellow}>
Press Ctrl+D again to exit. Press Ctrl+D again to exit.
</Text> </Text>
) : showEscapePrompt ? (
<Text color={Colors.Gray}>Press Esc again to clear.</Text>
) : ( ) : (
<ContextSummaryDisplay <ContextSummaryDisplay
ideContext={ideContextState} ideContext={ideContextState}
@@ -1021,7 +1146,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
/> />
)} )}
</Box> </Box>
<Box> <Box paddingTop={isNarrow ? 1 : 0}>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT && {showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
!shellModeActive && ( !shellModeActive && (
<AutoAcceptIndicator <AutoAcceptIndicator
@@ -1031,14 +1156,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
{shellModeActive && <ShellModeIndicator />} {shellModeActive && <ShellModeIndicator />}
</Box> </Box>
</Box> </Box>
{showIDEContextDetail && (
<IDEContextDetailDisplay
ideContext={ideContextState}
detectedIdeDisplay={config
.getIdeClient()
.getDetectedIdeDisplayName()}
/>
)}
{showErrorDetails && ( {showErrorDetails && (
<OverflowProvider> <OverflowProvider>
<Box flexDirection="column"> <Box flexDirection="column">
@@ -1067,6 +1185,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
commandContext={commandContext} commandContext={commandContext}
shellModeActive={shellModeActive} shellModeActive={shellModeActive}
setShellModeActive={setShellModeActive} setShellModeActive={setShellModeActive}
onEscapePromptChange={handleEscapePromptChange}
focus={isFocused} focus={isFocused}
vimHandleInput={vimHandleInput} vimHandleInput={vimHandleInput}
placeholder={placeholder} placeholder={placeholder}
@@ -1117,7 +1236,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
errorCount={errorCount} errorCount={errorCount}
showErrorDetails={showErrorDetails} showErrorDetails={showErrorDetails}
showMemoryUsage={ showMemoryUsage={
config.getDebugMode() || config.getShowMemoryUsage() config.getDebugMode() || settings.merged.showMemoryUsage || false
} }
promptTokenCount={sessionStats.lastPromptTokenCount} promptTokenCount={sessionStats.lastPromptTokenCount}
nightly={nightly} nightly={nightly}

View File

@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import {
RadioButtonSelect,
RadioSelectItem,
} from './components/shared/RadioButtonSelect.js';
export type IdeIntegrationNudgeResult = 'yes' | 'no' | 'dismiss';
interface IdeIntegrationNudgeProps {
ideName?: string;
onComplete: (result: IdeIntegrationNudgeResult) => void;
}
export function IdeIntegrationNudge({
ideName,
onComplete,
}: IdeIntegrationNudgeProps) {
useInput((_input, key) => {
if (key.escape) {
onComplete('no');
}
});
const OPTIONS: Array<RadioSelectItem<IdeIntegrationNudgeResult>> = [
{
label: 'Yes',
value: 'yes',
},
{
label: 'No (esc)',
value: 'no',
},
{
label: "No, don't ask again",
value: 'dismiss',
},
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="yellow"
padding={1}
width="100%"
marginLeft={1}
>
<Box marginBottom={1} flexDirection="column">
<Text>
<Text color="yellow">{'> '}</Text>
{`Do you want to connect your ${ideName ?? 'your'} editor to Gemini CLI?`}
</Text>
<Text
dimColor
>{`If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ideName ?? 'your editor'}.`}</Text>
</Box>
<RadioButtonSelect
items={OPTIONS}
onSelect={onComplete}
isFocused={true}
/>
</Box>
);
}

View File

@@ -10,9 +10,22 @@ exports[`App UI > should render correctly with the prompt input box 1`] = `
`; `;
exports[`App UI > should render the initial UI correctly 1`] = ` exports[`App UI > should render the initial UI correctly 1`] = `
" " I'm Feeling Lucky (esc to cancel, 0s)
I'm Feeling Lucky (esc to cancel, 0s)
/test/dir no sandbox (see /docs) model (100% context left)" /test/dir no sandbox (see /docs) model (100% context left)"
`; `;
exports[`App UI > when in a narrow terminal > should render with a column layout 1`] = `
"
╭────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │
╰────────────────────────────────────────────────────────────────────────────────────────╯
dir
no sandbox (see /docs)
model (100% context left)| ✖ 5 errors (ctrl+o for details)"
`;

View File

@@ -168,8 +168,12 @@ describe('chatCommand', () => {
describe('save subcommand', () => { describe('save subcommand', () => {
let saveCommand: SlashCommand; let saveCommand: SlashCommand;
const tag = 'my-tag'; const tag = 'my-tag';
let mockCheckpointExists: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
saveCommand = getSubCommand('save'); saveCommand = getSubCommand('save');
mockCheckpointExists = vi.fn().mockResolvedValue(false);
mockContext.services.logger.checkpointExists = mockCheckpointExists;
}); });
it('should return an error if tag is missing', async () => { it('should return an error if tag is missing', async () => {
@@ -191,7 +195,7 @@ describe('chatCommand', () => {
}); });
}); });
it('should save the conversation', async () => { it('should save the conversation if checkpoint does not exist', async () => {
const history: HistoryItemWithoutId[] = [ const history: HistoryItemWithoutId[] = [
{ {
type: 'user', type: 'user',
@@ -199,8 +203,52 @@ describe('chatCommand', () => {
}, },
]; ];
mockGetHistory.mockReturnValue(history); mockGetHistory.mockReturnValue(history);
mockCheckpointExists.mockResolvedValue(false);
const result = await saveCommand?.action?.(mockContext, tag); const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${tag}.`,
});
});
it('should return confirm_action if checkpoint already exists', async () => {
mockCheckpointExists.mockResolvedValue(true);
mockContext.invocation = {
raw: `/chat save ${tag}`,
name: 'save',
args: tag,
};
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
expect(mockSaveCheckpoint).not.toHaveBeenCalled();
expect(result).toMatchObject({
type: 'confirm_action',
originalInvocation: { raw: `/chat save ${tag}` },
});
// Check that prompt is a React element
expect(result).toHaveProperty('prompt');
});
it('should save the conversation if overwrite is confirmed', async () => {
const history: HistoryItemWithoutId[] = [
{
type: 'user',
text: 'hello',
},
];
mockGetHistory.mockReturnValue(history);
mockContext.overwriteConfirmed = true;
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag); expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
expect(result).toEqual({ expect(result).toEqual({
type: 'message', type: 'message',

View File

@@ -5,11 +5,15 @@
*/ */
import * as fsPromises from 'fs/promises'; import * as fsPromises from 'fs/promises';
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { import {
CommandContext, CommandContext,
SlashCommand, SlashCommand,
MessageActionReturn, MessageActionReturn,
CommandKind, CommandKind,
SlashCommandActionReturn,
} from './types.js'; } from './types.js';
import path from 'path'; import path from 'path';
import { HistoryItemWithoutId, MessageType } from '../types.js'; import { HistoryItemWithoutId, MessageType } from '../types.js';
@@ -96,7 +100,7 @@ const saveCommand: SlashCommand = {
description: description:
'Save the current conversation as a checkpoint. Usage: /chat save <tag>', 'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => { action: async (context, args): Promise<SlashCommandActionReturn | void> => {
const tag = args.trim(); const tag = args.trim();
if (!tag) { if (!tag) {
return { return {
@@ -108,6 +112,26 @@ const saveCommand: SlashCommand = {
const { logger, config } = context.services; const { logger, config } = context.services;
await logger.initialize(); await logger.initialize();
if (!context.overwriteConfirmed) {
const exists = await logger.checkpointExists(tag);
if (exists) {
return {
type: 'confirm_action',
prompt: React.createElement(
Text,
null,
'A checkpoint with the tag ',
React.createElement(Text, { color: Colors.AccentPurple }, tag),
' already exists. Do you want to overwrite it?',
),
originalInvocation: {
raw: context.invocation?.raw || `/chat save ${tag}`,
},
};
}
}
const chat = await config?.getGeminiClient()?.getChat(); const chat = await config?.getGeminiClient()?.getChat();
if (!chat) { if (!chat) {
return { return {

View File

@@ -93,13 +93,14 @@ describe('ideCommand', () => {
} as unknown as ReturnType<Config['getIdeClient']>); } as unknown as ReturnType<Config['getIdeClient']>);
}); });
it('should show connected status', () => { it('should show connected status', async () => {
mockGetConnectionStatus.mockReturnValue({ mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Connected, status: core.IDEConnectionStatus.Connected,
}); });
const command = ideCommand(mockConfig); const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')! const result = await command!.subCommands!.find(
.action!(mockContext, ''); (c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
type: 'message', type: 'message',
@@ -108,13 +109,14 @@ describe('ideCommand', () => {
}); });
}); });
it('should show connecting status', () => { it('should show connecting status', async () => {
mockGetConnectionStatus.mockReturnValue({ mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Connecting, status: core.IDEConnectionStatus.Connecting,
}); });
const command = ideCommand(mockConfig); const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')! const result = await command!.subCommands!.find(
.action!(mockContext, ''); (c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
type: 'message', type: 'message',
@@ -122,13 +124,14 @@ describe('ideCommand', () => {
content: `🟡 Connecting...`, content: `🟡 Connecting...`,
}); });
}); });
it('should show disconnected status', () => { it('should show disconnected status', async () => {
mockGetConnectionStatus.mockReturnValue({ mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Disconnected, status: core.IDEConnectionStatus.Disconnected,
}); });
const command = ideCommand(mockConfig); const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')! const result = await command!.subCommands!.find(
.action!(mockContext, ''); (c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
type: 'message', type: 'message',
@@ -137,15 +140,16 @@ describe('ideCommand', () => {
}); });
}); });
it('should show disconnected status with details', () => { it('should show disconnected status with details', async () => {
const details = 'Something went wrong'; const details = 'Something went wrong';
mockGetConnectionStatus.mockReturnValue({ mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Disconnected, status: core.IDEConnectionStatus.Disconnected,
details, details,
}); });
const command = ideCommand(mockConfig); const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')! const result = await command!.subCommands!.find(
.action!(mockContext, ''); (c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
type: 'message', type: 'message',

View File

@@ -8,10 +8,13 @@ import {
Config, Config,
DetectedIde, DetectedIde,
IDEConnectionStatus, IDEConnectionStatus,
IdeClient,
getIdeDisplayName, getIdeDisplayName,
getIdeInstaller, getIdeInstaller,
IdeClient,
type File,
ideContext,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import path from 'node:path';
import { import {
CommandContext, CommandContext,
SlashCommand, SlashCommand,
@@ -49,6 +52,68 @@ function getIdeStatusMessage(ideClient: IdeClient): {
} }
} }
function formatFileList(openFiles: File[]): string {
const basenameCounts = new Map<string, number>();
for (const file of openFiles) {
const basename = path.basename(file.path);
basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1);
}
const fileList = openFiles
.map((file: File) => {
const basename = path.basename(file.path);
const isDuplicate = (basenameCounts.get(basename) || 0) > 1;
const parentDir = path.basename(path.dirname(file.path));
const displayName = isDuplicate
? `${basename} (/${parentDir})`
: basename;
return ` - ${displayName}${file.isActive ? ' (active)' : ''}`;
})
.join('\n');
const infoMessage = `
(Note: The file list is limited to a number of recently accessed files within your workspace and only includes local files on disk)`;
return `\n\nOpen files:\n${fileList}\n${infoMessage}`;
}
async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
messageType: 'info' | 'error';
content: string;
}> {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected: {
let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`;
const context = ideContext.getIdeContext();
const openFiles = context?.workspaceState?.openFiles;
if (openFiles && openFiles.length > 0) {
content += formatFileList(openFiles);
}
return {
messageType: 'info',
content,
};
}
case IDEConnectionStatus.Connecting:
return {
messageType: 'info',
content: `🟡 Connecting...`,
};
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
messageType: 'error',
content,
};
}
}
}
export const ideCommand = (config: Config | null): SlashCommand | null => { export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config || !config.getIdeModeFeature()) { if (!config || !config.getIdeModeFeature()) {
return null; return null;
@@ -84,8 +149,9 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
name: 'status', name: 'status',
description: 'check status of IDE integration', description: 'check status of IDE integration',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: (): SlashCommandActionReturn => { action: async (): Promise<SlashCommandActionReturn> => {
const { messageType, content } = getIdeStatusMessage(ideClient); const { messageType, content } =
await getIdeStatusMessageWithFiles(ideClient);
return { return {
type: 'message', type: 'message',
messageType, messageType,

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { settingsCommand } from './settingsCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('settingsCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the settings dialog', () => {
if (!settingsCommand.action) {
throw new Error('The settings command must have an action.');
}
const result = settingsCommand.action(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'settings',
});
});
it('should have the correct name and description', () => {
expect(settingsCommand.name).toBe('settings');
expect(settingsCommand.description).toBe(
'View and edit Gemini CLI settings',
);
});
});

View File

@@ -0,0 +1,17 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const settingsCommand: SlashCommand = {
name: 'settings',
description: 'View and edit Gemini CLI settings',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'settings',
}),
};

View File

@@ -4,63 +4,103 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest'; import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest';
import * as child_process from 'child_process'; import * as gitUtils from '../../utils/gitUtils.js';
import { setupGithubCommand } from './setupGithubCommand.js'; import { setupGithubCommand } from './setupGithubCommand.js';
import { CommandContext, ToolActionReturn } from './types.js'; import { CommandContext, ToolActionReturn } from './types.js';
import * as commandUtils from '../utils/commandUtils.js';
vi.mock('child_process'); vi.mock('child_process');
describe('setupGithubCommand', () => { // Mock fetch globally
beforeEach(() => { global.fetch = vi.fn();
vi.mock('../../utils/gitUtils.js', () => ({
isGitHubRepository: vi.fn(),
getGitRepoRoot: vi.fn(),
getLatestGitHubRelease: vi.fn(),
getGitHubRepoInfo: vi.fn(),
}));
vi.mock('../utils/commandUtils.js', () => ({
getUrlOpenCommand: vi.fn(),
}));
describe('setupGithubCommand', async () => {
let scratchDir = '';
beforeEach(async () => {
vi.resetAllMocks(); vi.resetAllMocks();
scratchDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'setup-github-command-'),
);
}); });
afterEach(() => { afterEach(async () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
if (scratchDir) await fs.rm(scratchDir, { recursive: true });
}); });
it('returns a tool action to download github workflows and handles paths', () => { it('returns a tool action to download github workflows and handles paths', async () => {
const fakeRepoRoot = '/github.com/fake/repo/root'; const fakeRepoOwner = 'fake';
vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot); const fakeRepoName = 'repo';
const fakeRepoRoot = scratchDir;
const fakeReleaseVersion = 'v1.2.3';
const result = setupGithubCommand.action?.( const workflows = [
'gemini-cli.yml',
'gemini-issue-automated-triage.yml',
'gemini-issue-scheduled-triage.yml',
'gemini-pr-review.yml',
];
for (const workflow of workflows) {
vi.mocked(global.fetch).mockReturnValueOnce(
Promise.resolve(new Response(workflow)),
);
}
vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true);
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce(
fakeReleaseVersion,
);
vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({
owner: fakeRepoOwner,
repo: fakeRepoName,
});
vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValueOnce(
'fakeOpenCommand',
);
const result = (await setupGithubCommand.action?.(
{} as CommandContext, {} as CommandContext,
'', '',
) as ToolActionReturn; )) as ToolActionReturn;
expect(result.type).toBe('tool');
expect(result.toolName).toBe('run_shell_command');
expect(child_process.execSync).toHaveBeenCalledWith(
'git rev-parse --show-toplevel',
{
encoding: 'utf-8',
},
);
expect(child_process.execSync).toHaveBeenCalledWith('git remote -v', {
encoding: 'utf-8',
});
const { command } = result.toolArgs; const { command } = result.toolArgs;
const expectedSubstrings = [ const expectedSubstrings = [
`mkdir -p "${fakeRepoRoot}/.github/workflows"`, `set -eEuo pipefail`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-cli.yml"`, `fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`,
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/v0/examples/workflows/',
]; ];
for (const substring of expectedSubstrings) { for (const substring of expectedSubstrings) {
expect(command).toContain(substring); expect(command).toContain(substring);
} }
});
it('throws an error if git root cannot be determined', () => { for (const workflow of workflows) {
vi.mocked(child_process.execSync).mockReturnValue(''); const workflowFile = path.join(
expect(() => { scratchDir,
setupGithubCommand.action?.({} as CommandContext, ''); '.github',
}).toThrow('Unable to determine the Git root directory.'); 'workflows',
workflow,
);
const contents = await fs.readFile(workflowFile, 'utf8');
expect(contents).toContain(workflow);
}
}); });
}); });

View File

@@ -4,32 +4,93 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import path from 'path'; import path from 'node:path';
import { execSync } from 'child_process'; import * as fs from 'node:fs';
import { isGitHubRepository } from '../../utils/gitUtils.js'; import { Writable } from 'node:stream';
import { ProxyAgent } from 'undici';
import { CommandContext } from '../../ui/commands/types.js';
import {
getGitRepoRoot,
getLatestGitHubRelease,
isGitHubRepository,
getGitHubRepoInfo,
} from '../../utils/gitUtils.js';
import { import {
CommandKind, CommandKind,
SlashCommand, SlashCommand,
SlashCommandActionReturn, SlashCommandActionReturn,
} from './types.js'; } from './types.js';
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
// Generate OS-specific commands to open the GitHub pages needed for setup.
function getOpenUrlsCommands(readmeUrl: string): string[] {
// Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc
const openCmd = getUrlOpenCommand();
// Build a list of URLs to open
const urlsToOpen = [readmeUrl];
const repoInfo = getGitHubRepoInfo();
if (repoInfo) {
urlsToOpen.push(
`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`,
);
}
// Create and join the individual commands
const commands = urlsToOpen.map((url) => `${openCmd} "${url}"`);
return commands;
}
export const setupGithubCommand: SlashCommand = { export const setupGithubCommand: SlashCommand = {
name: 'setup-github', name: 'setup-github',
description: 'Set up GitHub Actions', description: 'Set up GitHub Actions',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: (): SlashCommandActionReturn => { action: async (
const gitRootRepo = execSync('git rev-parse --show-toplevel', { context: CommandContext,
encoding: 'utf-8', ): Promise<SlashCommandActionReturn> => {
}).trim(); const abortController = new AbortController();
if (!isGitHubRepository()) { if (!isGitHubRepository()) {
throw new Error('Unable to determine the Git root directory.'); throw new Error(
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
);
} }
const version = 'v0'; // Find the root directory of the repo
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`; let gitRepoRoot: string;
try {
gitRepoRoot = getGitRepoRoot();
} catch (_error) {
console.debug(`Failed to get git repo root:`, _error);
throw new Error(
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
);
}
// Get the latest release tag from GitHub
const proxy = context?.services?.config?.getProxy();
const releaseTag = await getLatestGitHubRelease(proxy);
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
// Create the .github/workflows directory to download the files into
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
try {
await fs.promises.mkdir(githubWorkflowsDir, { recursive: true });
} catch (_error) {
console.debug(
`Failed to create ${githubWorkflowsDir} directory:`,
_error,
);
throw new Error(
`Unable to create ${githubWorkflowsDir} directory. Do you have file permissions in the current directory?`,
);
}
// Download each workflow in parallel - there aren't enough files to warrant
// a full workerpool model here.
const workflows = [ const workflows = [
'gemini-cli/gemini-cli.yml', 'gemini-cli/gemini-cli.yml',
'issue-triage/gemini-issue-automated-triage.yml', 'issue-triage/gemini-issue-automated-triage.yml',
@@ -37,15 +98,63 @@ export const setupGithubCommand: SlashCommand = {
'pr-review/gemini-pr-review.yml', 'pr-review/gemini-pr-review.yml',
]; ];
const command = [ const downloads = [];
'set -e', for (const workflow of workflows) {
`mkdir -p "${gitRootRepo}/.github/workflows"`, downloads.push(
...workflows.map((workflow) => { (async () => {
const fileName = path.basename(workflow); const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
return `curl -fsSL -o "${gitRootRepo}/.github/workflows/${fileName}" "${workflowBaseUrl}/${workflow}"`; const response = await fetch(endpoint, {
}), method: 'GET',
'echo "Workflows downloaded successfully."', dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
].join(' && '); signal: AbortSignal.any([
AbortSignal.timeout(30_000),
abortController.signal,
]),
} as RequestInit);
if (!response.ok) {
throw new Error(
`Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`,
);
}
const body = response.body;
if (!body) {
throw new Error(
`Empty body while downloading ${endpoint}: ${response.status} - ${response.statusText}`,
);
}
const destination = path.resolve(
githubWorkflowsDir,
path.basename(workflow),
);
const fileStream = fs.createWriteStream(destination, {
mode: 0o644, // -rw-r--r--, user(rw), group(r), other(r)
flags: 'w', // write and overwrite
flush: true,
});
await body.pipeTo(Writable.toWeb(fileStream));
})(),
);
}
// Wait for all downloads to complete
await Promise.all(downloads).finally(() => {
// Stop existing downloads
abortController.abort();
});
// Print out a message
const commands = [];
commands.push('set -eEuo pipefail');
commands.push(
`echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
);
commands.push(...getOpenUrlsCommands(readmeUrl));
const command = `(${commands.join(' && ')})`;
return { return {
type: 'tool', type: 'tool',
toolName: 'run_shell_command', toolName: 'run_shell_command',

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { type ReactNode } from 'react';
import { Content } from '@google/genai'; import { Content } from '@google/genai';
import { HistoryItemWithoutId } from '../types.js'; import { HistoryItemWithoutId } from '../types.js';
import { Config, GitService, Logger } from '@qwen-code/qwen-code-core'; import { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
@@ -68,6 +69,8 @@ export interface CommandContext {
/** A transient list of shell commands the user has approved for this session. */ /** A transient list of shell commands the user has approved for this session. */
sessionShellAllowlist: Set<string>; sessionShellAllowlist: Set<string>;
}; };
// Flag to indicate if an overwrite has been confirmed
overwriteConfirmed?: boolean;
} }
/** /**
@@ -100,7 +103,8 @@ export interface MessageActionReturn {
*/ */
export interface OpenDialogActionReturn { export interface OpenDialogActionReturn {
type: 'dialog'; type: 'dialog';
dialog: 'auth' | 'theme' | 'editor' | 'privacy';
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings';
} }
/** /**
@@ -136,6 +140,16 @@ export interface ConfirmShellCommandsActionReturn {
}; };
} }
export interface ConfirmActionReturn {
type: 'confirm_action';
/** The React node to display as the confirmation prompt. */
prompt: ReactNode;
/** The original invocation context to be re-run after confirmation. */
originalInvocation: {
raw: string;
};
}
export type SlashCommandActionReturn = export type SlashCommandActionReturn =
| ToolActionReturn | ToolActionReturn
| MessageActionReturn | MessageActionReturn
@@ -143,7 +157,8 @@ export type SlashCommandActionReturn =
| OpenDialogActionReturn | OpenDialogActionReturn
| LoadHistoryActionReturn | LoadHistoryActionReturn
| SubmitPromptActionReturn | SubmitPromptActionReturn
| ConfirmShellCommandsActionReturn; | ConfirmShellCommandsActionReturn
| ConfirmActionReturn;
export enum CommandKind { export enum CommandKind {
BUILT_IN = 'built-in', BUILT_IN = 'built-in',

View File

@@ -20,3 +20,14 @@ export const longAsciiLogo = `
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ ██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
`; `;
export const tinyAsciiLogo = `
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
`;

View File

@@ -18,8 +18,8 @@ export function AuthInProgress({
}: AuthInProgressProps): React.JSX.Element { }: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false); const [timedOut, setTimedOut] = useState(false);
useInput((_, key) => { useInput((input, key) => {
if (key.escape) { if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
onTimeout(); onTimeout();
} }
}); });
@@ -48,7 +48,8 @@ export function AuthInProgress({
) : ( ) : (
<Box> <Box>
<Text> <Text>
<Spinner type="dots" /> Waiting for auth... (Press ESC to cancel) <Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
cancel)
</Text> </Text>
</Box> </Box>
)} )}

View File

@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const renderWithWidth = (
width: number,
props: React.ComponentProps<typeof ContextSummaryDisplay>,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<ContextSummaryDisplay {...props} />);
};
describe('<ContextSummaryDisplay />', () => {
const baseProps = {
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
showToolDescriptions: false,
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c' }],
},
},
};
it('should render on a single line on a wide screen', () => {
const { lastFrame } = renderWithWidth(120, baseProps);
const output = lastFrame();
expect(output).toContain(
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file | 1 MCP server (ctrl+t to view)',
);
// Check for absence of newlines
expect(output.includes('\n')).toBe(false);
});
it('should render on multiple lines on a narrow screen', () => {
const { lastFrame } = renderWithWidth(60, baseProps);
const output = lastFrame();
const expectedLines = [
'Using:',
' - 1 open file (ctrl+e to view)',
' - 1 GEMINI.md file',
' - 1 MCP server (ctrl+t to view)',
];
const actualLines = output.split('\n');
expect(actualLines).toEqual(expectedLines);
});
it('should switch layout at the 80-column breakpoint', () => {
// At 80 columns, should be on one line
const { lastFrame: wideFrame } = renderWithWidth(80, baseProps);
expect(wideFrame().includes('\n')).toBe(false);
// At 79 columns, should be on multiple lines
const { lastFrame: narrowFrame } = renderWithWidth(79, baseProps);
expect(narrowFrame().includes('\n')).toBe(true);
expect(narrowFrame().split('\n').length).toBe(4);
});
it('should not render empty parts', () => {
const props = {
...baseProps,
geminiMdFileCount: 0,
mcpServers: {},
};
const { lastFrame } = renderWithWidth(60, props);
const expectedLines = ['Using:', ' - 1 open file (ctrl+e to view)'];
const actualLines = lastFrame().split('\n');
expect(actualLines).toEqual(expectedLines);
});
});

View File

@@ -5,12 +5,14 @@
*/ */
import React from 'react'; import React from 'react';
import { Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { import {
type IdeContext, type IdeContext,
type MCPServerConfig, type MCPServerConfig,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface ContextSummaryDisplayProps { interface ContextSummaryDisplayProps {
geminiMdFileCount: number; geminiMdFileCount: number;
@@ -29,6 +31,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
showToolDescriptions, showToolDescriptions,
ideContext, ideContext,
}) => { }) => {
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const mcpServerCount = Object.keys(mcpServers || {}).length; const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0; const blockedMcpServerCount = blockedMcpServers?.length || 0;
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0; const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
@@ -81,30 +85,36 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
} }
parts.push(blockedText); parts.push(blockedText);
} }
return parts.join(', '); let text = parts.join(', ');
// Add ctrl+t hint when MCP servers are available
if (mcpServers && Object.keys(mcpServers).length > 0) {
if (showToolDescriptions) {
text += ' (ctrl+t to toggle)';
} else {
text += ' (ctrl+t to view)';
}
}
return text;
})(); })();
let summaryText = 'Using: '; const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean);
const summaryParts = [];
if (openFilesText) {
summaryParts.push(openFilesText);
}
if (geminiMdText) {
summaryParts.push(geminiMdText);
}
if (mcpText) {
summaryParts.push(mcpText);
}
summaryText += summaryParts.join(' | ');
// Add ctrl+t hint when MCP servers are available if (isNarrow) {
if (mcpServers && Object.keys(mcpServers).length > 0) { return (
if (showToolDescriptions) { <Box flexDirection="column">
summaryText += ' (ctrl+t to toggle)'; <Text color={Colors.Gray}>Using:</Text>
} else { {summaryParts.map((part, index) => (
summaryText += ' (ctrl+t to view)'; <Text key={index} color={Colors.Gray}>
} {' '}- {part}
</Text>
))}
</Box>
);
} }
return <Text color={Colors.Gray}>{summaryText}</Text>; return (
<Box>
<Text color={Colors.Gray}>Using: {summaryParts.join(' | ')}</Text>
</Box>
);
}; };

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { tokenLimit } from '@qwen-code/qwen-code-core';
export const ContextUsageDisplay = ({
promptTokenCount,
model,
}: {
promptTokenCount: number;
model: string;
}) => {
const percentage = promptTokenCount / tokenLimit(model);
return (
<Text color={Colors.Gray}>
({((1 - percentage) * 100).toFixed(0)}% context left)
</Text>
);
};

View File

@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
describe('FolderTrustDialog', () => {
it('should render the dialog with title and description', () => {
const { lastFrame } = render(<FolderTrustDialog onSelect={vi.fn()} />);
expect(lastFrame()).toContain('Do you trust this folder?');
expect(lastFrame()).toContain(
'Trusting a folder allows Gemini to execute commands it suggests.',
);
});
it('should call onSelect with DO_NOT_TRUST when escape is pressed', () => {
const onSelect = vi.fn();
const { stdin } = render(<FolderTrustDialog onSelect={onSelect} />);
stdin.write('\u001B'); // Simulate escape key
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
});
});

View File

@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
RadioSelectItem,
} from './shared/RadioButtonSelect.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
TRUST_PARENT = 'trust_parent',
DO_NOT_TRUST = 'do_not_trust',
}
interface FolderTrustDialogProps {
onSelect: (choice: FolderTrustChoice) => void;
}
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
}) => {
useInput((_, key) => {
if (key.escape) {
onSelect(FolderTrustChoice.DO_NOT_TRUST);
}
});
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{
label: 'Trust folder',
value: FolderTrustChoice.TRUST_FOLDER,
},
{
label: 'Trust parent folder',
value: FolderTrustChoice.TRUST_PARENT,
},
{
label: "Don't trust (esc)",
value: FolderTrustChoice.DO_NOT_TRUST,
},
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Do you trust this folder?</Text>
<Text>
Trusting a folder allows Gemini to execute commands it suggests. This
is a security feature to prevent accidental execution in untrusted
directories.
</Text>
</Box>
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
</Box>
);
};

View File

@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { Footer } from './Footer.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { tildeifyPath } from '@qwen-code/qwen-code-core';
import path from 'node:path';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
shortenPath: (p: string, len: number) => {
if (p.length > len) {
return '...' + p.slice(p.length - len + 3);
}
return p;
},
};
});
const defaultProps = {
model: 'gemini-pro',
targetDir:
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
branchName: 'main',
debugMode: false,
debugMessage: '',
corgiMode: false,
errorCount: 0,
showErrorDetails: false,
showMemoryUsage: false,
promptTokenCount: 100,
nightly: false,
};
const renderWithWidth = (width: number, props = defaultProps) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<Footer {...props} />);
};
describe('<Footer />', () => {
it('renders the component', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toBeDefined();
});
describe('path display', () => {
it('should display shortened path on a wide terminal', () => {
const { lastFrame } = renderWithWidth(120);
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 48 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should display only the base directory name on a narrow terminal', () => {
const { lastFrame } = renderWithWidth(79);
const expectedPath = path.basename(defaultProps.targetDir);
expect(lastFrame()).toContain(expectedPath);
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithWidth(80);
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 32 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame } = renderWithWidth(79);
const expectedPath = path.basename(defaultProps.targetDir);
expect(lastFrame()).toContain(expectedPath);
const tildePath = tildeifyPath(defaultProps.targetDir);
const unexpectedPath = '...' + tildePath.slice(tildePath.length - 31 + 3);
expect(lastFrame()).not.toContain(unexpectedPath);
});
});
it('displays the branch name when provided', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
});
it('does not display the branch name when not provided', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
branchName: undefined,
});
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
});
it('displays the model name and context percentage', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
});
});

View File

@@ -6,19 +6,19 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { theme } from '../semantic-colors.js';
import { import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
shortenPath,
tildeifyPath,
tokenLimit,
} from '@qwen-code/qwen-code-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process'; import process from 'node:process';
import path from 'node:path';
import Gradient from 'ink-gradient'; import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js'; import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js'; import { DebugProfiler } from './DebugProfiler.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface FooterProps { interface FooterProps {
model: string; model: string;
targetDir: string; targetDir: string;
@@ -48,29 +48,43 @@ export const Footer: React.FC<FooterProps> = ({
nightly, nightly,
vimMode, vimMode,
}) => { }) => {
const limit = tokenLimit(model); const { columns: terminalWidth } = useTerminalSize();
const percentage = promptTokenCount / limit;
const isNarrow = isNarrowWidth(terminalWidth);
// Adjust path length based on terminal width
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.4));
const displayPath = isNarrow
? path.basename(tildeifyPath(targetDir))
: shortenPath(tildeifyPath(targetDir), pathLength);
return ( return (
<Box justifyContent="space-between" width="100%"> <Box
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box> <Box>
{debugMode && <DebugProfiler />} {debugMode && <DebugProfiler />}
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>} {vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
{nightly ? ( {nightly ? (
<Gradient colors={Colors.GradientColors}> <Gradient colors={theme.ui.gradient}>
<Text> <Text>
{shortenPath(tildeifyPath(targetDir), 70)} {displayPath}
{branchName && <Text> ({branchName}*)</Text>} {branchName && <Text> ({branchName}*)</Text>}
</Text> </Text>
</Gradient> </Gradient>
) : ( ) : (
<Text color={Colors.LightBlue}> <Text color={theme.text.link}>
{shortenPath(tildeifyPath(targetDir), 70)} {displayPath}
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>} {branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text> </Text>
)} )}
{debugMode && ( {debugMode && (
<Text color={Colors.AccentRed}> <Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')} {' ' + (debugMessage || '--debug')}
</Text> </Text>
)} )}
@@ -78,49 +92,54 @@ export const Footer: React.FC<FooterProps> = ({
{/* Middle Section: Centered Sandbox Info */} {/* Middle Section: Centered Sandbox Info */}
<Box <Box
flexGrow={1} flexGrow={isNarrow ? 0 : 1}
alignItems="center" alignItems="center"
justifyContent="center" justifyContent={isNarrow ? 'flex-start' : 'center'}
display="flex" display="flex"
paddingX={isNarrow ? 0 : 1}
paddingTop={isNarrow ? 1 : 0}
> >
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? ( {process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
<Text color="green"> <Text color="green">
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')} {process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
</Text> </Text>
) : process.env.SANDBOX === 'sandbox-exec' ? ( ) : process.env.SANDBOX === 'sandbox-exec' ? (
<Text color={Colors.AccentYellow}> <Text color={theme.status.warning}>
macOS Seatbelt{' '} macOS Seatbelt{' '}
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text> <Text color={theme.text.secondary}>
({process.env.SEATBELT_PROFILE})
</Text>
</Text> </Text>
) : ( ) : (
<Text color={Colors.AccentRed}> <Text color={theme.status.error}>
no sandbox <Text color={Colors.Gray}>(see /docs)</Text> no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
</Text> </Text>
)} )}
</Box> </Box>
{/* Right Section: Gemini Label and Console Summary */} {/* Right Section: Gemini Label and Console Summary */}
<Box alignItems="center"> <Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
<Text color={Colors.AccentBlue}> <Text color={theme.text.accent}>
{' '} {isNarrow ? '' : ' '}
{model}{' '} {model}{' '}
<Text color={Colors.Gray}> <ContextUsageDisplay
({((1 - percentage) * 100).toFixed(0)}% context left) promptTokenCount={promptTokenCount}
</Text> model={model}
/>
</Text> </Text>
{corgiMode && ( {corgiMode && (
<Text> <Text>
<Text color={Colors.Gray}>| </Text> <Text color={theme.ui.symbol}>| </Text>
<Text color={Colors.AccentRed}></Text> <Text color={theme.status.error}></Text>
<Text color={Colors.Foreground}>(´</Text> <Text color={theme.text.primary}>(´</Text>
<Text color={Colors.AccentRed}></Text> <Text color={theme.status.error}></Text>
<Text color={Colors.Foreground}>`)</Text> <Text color={theme.text.primary}>`)</Text>
<Text color={Colors.AccentRed}>▼ </Text> <Text color={theme.status.error}>▼ </Text>
</Text> </Text>
)} )}
{!showErrorDetails && errorCount > 0 && ( {!showErrorDetails && errorCount > 0 && (
<Box> <Box>
<Text color={Colors.Gray}>| </Text> <Text color={theme.ui.symbol}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} /> <ConsoleSummaryDisplay errorCount={errorCount} />
</Box> </Box>
)} )}

View File

@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Header } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { longAsciiLogo } from './AsciiArt.js';
vi.mock('../hooks/useTerminalSize.js');
describe('<Header />', () => {
beforeEach(() => {});
it('renders the long logo on a wide terminal', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
expect(lastFrame()).toContain(longAsciiLogo);
});
it('renders custom ASCII art when provided', () => {
const customArt = 'CUSTOM ART';
const { lastFrame } = render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(lastFrame()).toContain(customArt);
});
it('displays the version number when nightly is true', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={true} />);
expect(lastFrame()).toContain('v1.0.0');
});
it('does not display the version number when nightly is false', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
expect(lastFrame()).not.toContain('v1.0.0');
});
});

View File

@@ -8,30 +8,34 @@ import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import Gradient from 'ink-gradient'; import Gradient from 'ink-gradient';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { shortAsciiLogo, longAsciiLogo } from './AsciiArt.js'; import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js'; import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
interface HeaderProps { interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art customAsciiArt?: string; // For user-defined ASCII art
terminalWidth: number; // For responsive logo
version: string; version: string;
nightly: boolean; nightly: boolean;
} }
export const Header: React.FC<HeaderProps> = ({ export const Header: React.FC<HeaderProps> = ({
customAsciiArt, customAsciiArt,
terminalWidth,
version, version,
nightly, nightly,
}) => { }) => {
const { columns: terminalWidth } = useTerminalSize();
let displayTitle; let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo); const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
if (customAsciiArt) { if (customAsciiArt) {
displayTitle = customAsciiArt; displayTitle = customAsciiArt;
} else if (terminalWidth >= widthOfLongLogo) {
displayTitle = longAsciiLogo;
} else if (terminalWidth >= widthOfShortLogo) {
displayTitle = shortAsciiLogo;
} else { } else {
displayTitle = displayTitle = tinyAsciiLogo;
terminalWidth >= widthOfLongLogo ? longAsciiLogo : shortAsciiLogo;
} }
const artWidth = getAsciiArtWidth(displayTitle); const artWidth = getAsciiArtWidth(displayTitle);
@@ -52,9 +56,13 @@ export const Header: React.FC<HeaderProps> = ({
)} )}
{nightly && ( {nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end"> <Box width="100%" flexDirection="row" justifyContent="flex-end">
<Gradient colors={Colors.GradientColors}> {Colors.GradientColors ? (
<Gradient colors={Colors.GradientColors}>
<Text>v{version}</Text>
</Gradient>
) : (
<Text>v{version}</Text> <Text>v{version}</Text>
</Gradient> )}
</Box> </Box>
)} )}
</Box> </Box>

View File

@@ -1,51 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type File, type IdeContext } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import path from 'node:path';
import { Colors } from '../colors.js';
interface IDEContextDetailDisplayProps {
ideContext: IdeContext | undefined;
detectedIdeDisplay: string | undefined;
}
export function IDEContextDetailDisplay({
ideContext,
detectedIdeDisplay,
}: IDEContextDetailDisplayProps) {
const openFiles = ideContext?.workspaceState?.openFiles;
if (!openFiles || openFiles.length === 0) {
return null;
}
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={Colors.AccentCyan}
paddingX={1}
>
<Text color={Colors.AccentCyan} bold>
{detectedIdeDisplay ? detectedIdeDisplay : 'IDE'} Context (ctrl+e to
toggle)
</Text>
{openFiles.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Open files:</Text>
{openFiles.map((file: File) => (
<Text key={file.path}>
- {path.basename(file.path)}
{file.isActive ? ' (active)' : ''}
</Text>
))}
</Box>
)}
</Box>
);
}

View File

@@ -1191,6 +1191,106 @@ describe('InputPrompt', () => {
}); });
}); });
describe('enhanced input UX - double ESC clear functionality', () => {
it('should clear buffer on second ESC press', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('text to clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
stdin.write('\x1B');
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
unmount();
});
it('should reset escape state on any non-ESC key', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(onEscapePromptChange).toHaveBeenCalledWith(true);
stdin.write('a');
await wait();
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
unmount();
});
it('should handle ESC in shell mode by disabling shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(props.setShellModeActive).toHaveBeenCalledWith(false);
unmount();
});
it('should handle ESC when completion suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'suggestion', value: 'suggestion' }],
});
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
unmount();
});
it('should not call onEscapePromptChange when not provided', async () => {
props.onEscapePromptChange = undefined;
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
unmount();
});
it('should not interfere with existing keyboard shortcuts', async () => {
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x0C');
await wait();
expect(props.onClearScreen).toHaveBeenCalled();
stdin.write('\x01');
await wait();
expect(props.buffer.move).toHaveBeenCalledWith('home');
unmount();
});
});
describe('reverse search', () => { describe('reverse search', () => {
beforeEach(async () => { beforeEach(async () => {
props.shellModeActive = true; props.shellModeActive = true;

View File

@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState, useRef } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { theme } from '../semantic-colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js'; import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js'; import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
@@ -17,6 +17,7 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js'; import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js'; import { useKeypress, Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CommandContext, SlashCommand } from '../commands/types.js'; import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen-code/qwen-code-core'; import { Config } from '@qwen-code/qwen-code-core';
import { import {
@@ -40,6 +41,7 @@ export interface InputPromptProps {
suggestionsWidth: number; suggestionsWidth: number;
shellModeActive: boolean; shellModeActive: boolean;
setShellModeActive: (value: boolean) => void; setShellModeActive: (value: boolean) => void;
onEscapePromptChange?: (showPrompt: boolean) => void;
vimHandleInput?: (key: Key) => boolean; vimHandleInput?: (key: Key) => boolean;
} }
@@ -57,9 +59,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsWidth, suggestionsWidth,
shellModeActive, shellModeActive,
setShellModeActive, setShellModeActive,
onEscapePromptChange,
vimHandleInput, vimHandleInput,
}) => { }) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const [dirs, setDirs] = useState<readonly string[]>( const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(), config.getWorkspaceContext().getDirectories(),
@@ -97,6 +103,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetReverseSearchCompletionState = const resetReverseSearchCompletionState =
reverseSearchCompletion.resetCompletionState; reverseSearchCompletion.resetCompletionState;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
setEscPressCount(0);
setShowEscapePrompt(false);
}, []);
// Notify parent component about escape prompt state changes
useEffect(() => {
if (onEscapePromptChange) {
onEscapePromptChange(showEscapePrompt);
}
}, [showEscapePrompt, onEscapePromptChange]);
// Clear escape prompt timer on unmount
useEffect(
() => () => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
},
[],
);
const handleSubmitAndClear = useCallback( const handleSubmitAndClear = useCallback(
(submittedValue: string) => { (submittedValue: string) => {
if (shellModeActive) { if (shellModeActive) {
@@ -211,6 +243,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return; return;
} }
// Reset ESC count and hide prompt on any non-ESC key
if (key.name !== 'escape') {
if (escPressCount > 0 || showEscapePrompt) {
resetEscapeState();
}
}
if ( if (
key.sequence === '!' && key.sequence === '!' &&
buffer.text === '' && buffer.text === '' &&
@@ -221,7 +260,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return; return;
} }
if (key.name === 'escape') { if (keyMatchers[Command.ESCAPE](key)) {
if (reverseSearchActive) { if (reverseSearchActive) {
setReverseSearchActive(false); setReverseSearchActive(false);
reverseSearchCompletion.resetCompletionState(); reverseSearchCompletion.resetCompletionState();
@@ -234,26 +273,48 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.moveToOffset(offset); buffer.moveToOffset(offset);
return; return;
} }
if (shellModeActive) { if (shellModeActive) {
setShellModeActive(false); setShellModeActive(false);
resetEscapeState();
return; return;
} }
if (completion.showSuggestions) { if (completion.showSuggestions) {
completion.resetCompletionState(); completion.resetCompletionState();
resetEscapeState();
return; return;
} }
// Handle double ESC for clearing input
if (escPressCount === 0) {
if (buffer.text === '') {
return;
}
setEscPressCount(1);
setShowEscapePrompt(true);
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
} else {
// clear input and immediately reset state
buffer.setText('');
resetCompletionState();
resetEscapeState();
}
return;
} }
if (shellModeActive && key.ctrl && key.name === 'r') { if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true); setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text); setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor); setCursorPosition(buffer.cursor);
return; return;
} }
if (key.ctrl && key.name === 'l') { if (keyMatchers[Command.CLEAR_SCREEN](key)) {
onClearScreen(); onClearScreen();
return; return;
} }
@@ -268,15 +329,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} = reverseSearchCompletion; } = reverseSearchCompletion;
if (showSuggestions) { if (showSuggestions) {
if (key.name === 'up') { if (keyMatchers[Command.NAVIGATION_UP](key)) {
navigateUp(); navigateUp();
return; return;
} }
if (key.name === 'down') { if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
navigateDown(); navigateDown();
return; return;
} }
if (key.name === 'tab') { if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex); reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
reverseSearchCompletion.resetCompletionState(); reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false); setReverseSearchActive(false);
@@ -284,7 +345,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
} }
if (key.name === 'return' && !key.ctrl) { if (keyMatchers[Command.SUBMIT_REVERSE_SEARCH](key)) {
const textToSubmit = const textToSubmit =
showSuggestions && activeSuggestionIndex > -1 showSuggestions && activeSuggestionIndex > -1
? suggestions[activeSuggestionIndex].value ? suggestions[activeSuggestionIndex].value
@@ -296,30 +357,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
// Prevent up/down from falling through to regular history navigation // Prevent up/down from falling through to regular history navigation
if (key.name === 'up' || key.name === 'down') { if (
keyMatchers[Command.NAVIGATION_UP](key) ||
keyMatchers[Command.NAVIGATION_DOWN](key)
) {
return; return;
} }
} }
// If the command is a perfect match, pressing enter should execute it. // If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && key.name === 'return') { if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
handleSubmitAndClear(buffer.text); handleSubmitAndClear(buffer.text);
return; return;
} }
if (completion.showSuggestions) { if (completion.showSuggestions) {
if (completion.suggestions.length > 1) { if (completion.suggestions.length > 1) {
if (key.name === 'up' || (key.ctrl && key.name === 'p')) { if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp(); completion.navigateUp();
return; return;
} }
if (key.name === 'down' || (key.ctrl && key.name === 'n')) { if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown(); completion.navigateDown();
return; return;
} }
} }
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) { if (keyMatchers[Command.ACCEPT_SUGGESTION](key)) {
if (completion.suggestions.length > 0) { if (completion.suggestions.length > 0) {
const targetIndex = const targetIndex =
completion.activeSuggestionIndex === -1 completion.activeSuggestionIndex === -1
@@ -334,17 +398,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
if (!shellModeActive) { if (!shellModeActive) {
if (key.ctrl && key.name === 'p') { if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp(); inputHistory.navigateUp();
return; return;
} }
if (key.ctrl && key.name === 'n') { if (keyMatchers[Command.HISTORY_DOWN](key)) {
inputHistory.navigateDown(); inputHistory.navigateDown();
return; return;
} }
// Handle arrow-up/down for history on single-line or at edges // Handle arrow-up/down for history on single-line or at edges
if ( if (
key.name === 'up' && keyMatchers[Command.NAVIGATION_UP](key) &&
(buffer.allVisualLines.length === 1 || (buffer.allVisualLines.length === 1 ||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
) { ) {
@@ -352,7 +416,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return; return;
} }
if ( if (
key.name === 'down' && keyMatchers[Command.NAVIGATION_DOWN](key) &&
(buffer.allVisualLines.length === 1 || (buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1) buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) { ) {
@@ -360,18 +424,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return; return;
} }
} else { } else {
if (key.name === 'up') { // Shell History Navigation
if (keyMatchers[Command.NAVIGATION_UP](key)) {
const prevCommand = shellHistory.getPreviousCommand(); const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand); if (prevCommand !== null) buffer.setText(prevCommand);
return; return;
} }
if (key.name === 'down') { if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
const nextCommand = shellHistory.getNextCommand(); const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) buffer.setText(nextCommand); if (nextCommand !== null) buffer.setText(nextCommand);
return; return;
} }
} }
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) { if (buffer.text.trim()) {
const [row, col] = buffer.cursor; const [row, col] = buffer.cursor;
const line = buffer.lines[row]; const line = buffer.lines[row];
@@ -387,50 +453,48 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
// Newline insertion // Newline insertion
if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) { if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline(); buffer.newline();
return; return;
} }
// Ctrl+A (Home) / Ctrl+E (End) // Ctrl+A (Home) / Ctrl+E (End)
if (key.ctrl && key.name === 'a') { if (keyMatchers[Command.HOME](key)) {
buffer.move('home'); buffer.move('home');
return; return;
} }
if (key.ctrl && key.name === 'e') { if (keyMatchers[Command.END](key)) {
buffer.move('end'); buffer.move('end');
buffer.moveToOffset(cpLen(buffer.text)); buffer.moveToOffset(cpLen(buffer.text));
return; return;
} }
// Ctrl+C (Clear input) // Ctrl+C (Clear input)
if (key.ctrl && key.name === 'c') { if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) { if (buffer.text.length > 0) {
buffer.setText(''); buffer.setText('');
resetCompletionState(); resetCompletionState();
return;
} }
return; return;
} }
// Kill line commands // Kill line commands
if (key.ctrl && key.name === 'k') { if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight(); buffer.killLineRight();
return; return;
} }
if (key.ctrl && key.name === 'u') { if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft(); buffer.killLineLeft();
return; return;
} }
// External editor // External editor
const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18'); if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
if (isCtrlX) {
buffer.openInExternalEditor(); buffer.openInExternalEditor();
return; return;
} }
// Ctrl+V for clipboard image paste // Ctrl+V for clipboard image paste
if (key.ctrl && key.name === 'v') { if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
handleClipboardImage(); handleClipboardImage();
return; return;
} }
@@ -451,6 +515,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchCompletion, reverseSearchCompletion,
handleClipboardImage, handleClipboardImage,
resetCompletionState, resetCompletionState,
escPressCount,
showEscapePrompt,
resetEscapeState,
vimHandleInput, vimHandleInput,
reverseSearchActive, reverseSearchActive,
textBeforeReverseSearch, textBeforeReverseSearch,
@@ -469,15 +536,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<> <>
<Box <Box
borderStyle="round" borderStyle="round"
borderColor={shellModeActive ? Colors.AccentYellow : Colors.AccentBlue} borderColor={
shellModeActive ? theme.status.warning : theme.border.focused
}
paddingX={1} paddingX={1}
> >
<Text <Text
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple} color={shellModeActive ? theme.status.warning : theme.text.accent}
> >
{shellModeActive ? ( {shellModeActive ? (
reverseSearchActive ? ( reverseSearchActive ? (
<Text color={Colors.AccentCyan}>(r:) </Text> <Text color={theme.text.link}>(r:) </Text>
) : ( ) : (
'! ' '! '
) )
@@ -490,10 +559,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
focus ? ( focus ? (
<Text> <Text>
{chalk.inverse(placeholder.slice(0, 1))} {chalk.inverse(placeholder.slice(0, 1))}
<Text color={Colors.Gray}>{placeholder.slice(1)}</Text> <Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text> </Text>
) : ( ) : (
<Text color={Colors.Gray}>{placeholder}</Text> <Text color={theme.text.secondary}>{placeholder}</Text>
) )
) : ( ) : (
linesToRender.map((lineText, visualIdxInRenderedSet) => { linesToRender.map((lineText, visualIdxInRenderedSet) => {
@@ -536,7 +605,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box> </Box>
</Box> </Box>
{completion.showSuggestions && ( {completion.showSuggestions && (
<Box> <Box paddingRight={2}>
<SuggestionsDisplay <SuggestionsDisplay
suggestions={completion.suggestions} suggestions={completion.suggestions}
activeIndex={completion.activeSuggestionIndex} activeIndex={completion.activeSuggestionIndex}
@@ -548,7 +617,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box> </Box>
)} )}
{reverseSearchActive && ( {reverseSearchActive && (
<Box> <Box paddingRight={2}>
<SuggestionsDisplay <SuggestionsDisplay
suggestions={reverseSearchCompletion.suggestions} suggestions={reverseSearchCompletion.suggestions}
activeIndex={reverseSearchCompletion.activeSuggestionIndex} activeIndex={reverseSearchCompletion.activeSuggestionIndex}

View File

@@ -11,6 +11,7 @@ import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js'; import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js'; import { StreamingState } from '../types.js';
import { vi } from 'vitest'; import { vi } from 'vitest';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
// Mock GeminiRespondingSpinner // Mock GeminiRespondingSpinner
vi.mock('./GeminiRespondingSpinner.js', () => ({ vi.mock('./GeminiRespondingSpinner.js', () => ({
@@ -29,10 +30,18 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({
}, },
})); }));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const renderWithContext = ( const renderWithContext = (
ui: React.ReactElement, ui: React.ReactElement,
streamingStateValue: StreamingState, streamingStateValue: StreamingState,
width = 120,
) => { ) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
const contextValue: StreamingState = streamingStateValue; const contextValue: StreamingState = streamingStateValue;
return render( return render(
<StreamingContext.Provider value={contextValue}> <StreamingContext.Provider value={contextValue}>
@@ -223,4 +232,65 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('This should be displayed'); expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed'); expect(output).not.toContain('This should not be displayed');
}); });
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
120,
);
const output = lastFrame();
// Check for single line output
expect(output?.includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('Right');
});
it('should render on multiple lines on a narrow terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
79,
);
const output = lastFrame();
const lines = output?.split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
// 3. Right Content
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('Right');
}
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
80,
);
expect(lastFrame()?.includes('\n')).toBe(false);
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
expect(lastFrame()?.includes('\n')).toBe(true);
});
});
}); });

View File

@@ -12,6 +12,8 @@ import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js'; import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js'; import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface LoadingIndicatorProps { interface LoadingIndicatorProps {
currentLoadingPhrase?: string; currentLoadingPhrase?: string;
@@ -27,6 +29,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
thought, thought,
}) => { }) => {
const streamingState = useStreamingContext(); const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
if (streamingState === StreamingState.Idle) { if (streamingState === StreamingState.Idle) {
return null; return null;
@@ -34,28 +38,45 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText = thought?.subject || currentLoadingPhrase; const primaryText = thought?.subject || currentLoadingPhrase;
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;
return ( return (
<Box marginTop={1} paddingLeft={0} flexDirection="column"> <Box paddingLeft={0} flexDirection="column">
{/* Main loading line */} {/* Main loading line */}
<Box> <Box
<Box marginRight={1}> width="100%"
<GeminiRespondingSpinner flexDirection={isNarrow ? 'column' : 'row'}
nonRespondingDisplay={ alignItems={isNarrow ? 'flex-start' : 'center'}
streamingState === StreamingState.WaitingForConfirmation >
? '⠏' <Box>
: '' <Box marginRight={1}>
} <GeminiRespondingSpinner
/> nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
{primaryText && (
<Text color={Colors.AccentPurple}>{primaryText}</Text>
)}
{!isNarrow && cancelAndTimerContent && (
<Text color={Colors.Gray}> {cancelAndTimerContent}</Text>
)}
</Box> </Box>
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>} {!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
<Text color={Colors.Gray}> {!isNarrow && rightContent && <Box>{rightContent}</Box>}
{streamingState === StreamingState.WaitingForConfirmation
? ''
: ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`}
</Text>
<Box flexGrow={1}>{/* Spacer */}</Box>
{rightContent && <Box>{rightContent}</Box>}
</Box> </Box>
{isNarrow && cancelAndTimerContent && (
<Box>
<Text color={Colors.Gray}>{cancelAndTimerContent}</Text>
</Box>
)}
{isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box> </Box>
); );
}; };

View File

@@ -0,0 +1,831 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
*
*
* This test suite covers:
* - Initial rendering and display state
* - Keyboard navigation (arrows, vim keys, Tab)
* - Settings toggling (Enter, Space)
* - 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
*
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn();
const mockSetVimMode = vi.fn();
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(),
};
});
// Mock console.log to avoid noise in tests
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
describe('SettingsDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
beforeEach(() => {
vi.clearAllMocks();
console.log = vi.fn();
console.error = vi.fn();
mockToggleVimEnabled.mockResolvedValue(true);
});
afterEach(() => {
console.log = originalConsoleLog;
console.error = originalConsoleError;
});
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {}, ...systemSettings },
path: '/system/settings.json',
},
{
settings: {
customThemes: {},
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings },
path: '/workspace/settings.json',
},
[],
);
describe('Initial Rendering', () => {
it('should render the settings dialog with default state', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
expect(output).toContain('Settings');
expect(output).toContain('Apply To');
expect(output).toContain('Use Enter to select, Tab to change focus');
});
it('should show settings list with default values', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Should show some default settings
expect(output).toContain('●'); // Active indicator
});
it('should highlight first setting by default', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// First item should be highlighted with green color and active indicator
expect(output).toContain('●');
});
});
describe('Settings Navigation', () => {
it('should navigate down with arrow key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press down arrow
stdin.write('\u001B[B'); // Down arrow
await wait();
// The active index should have changed (tested indirectly through behavior)
unmount();
});
it('should navigate up with arrow key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// First go down, then up
stdin.write('\u001B[B'); // Down arrow
await wait();
stdin.write('\u001B[A'); // Up arrow
await wait();
unmount();
});
it('should navigate with vim keys (j/k)', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate with vim keys
stdin.write('j'); // Down
await wait();
stdin.write('k'); // Up
await wait();
unmount();
});
it('should not navigate beyond bounds', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Try to go up from first item
stdin.write('\u001B[A'); // Up arrow
await wait();
// Should still be on first item
unmount();
});
});
describe('Settings Toggling', () => {
it('should toggle setting with Enter key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Enter to toggle current setting
stdin.write('\u000D'); // Enter key
await wait();
unmount();
});
it('should toggle setting with Space key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Space to toggle current setting
stdin.write(' '); // Space key
await wait();
unmount();
});
it('should handle vim mode setting specially', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate to vim mode setting and toggle it
// This would require knowing the exact position, so we'll just test that the mock is called
stdin.write('\u000D'); // Enter key
await wait();
// The mock should potentially be called if vim mode was toggled
unmount();
});
});
describe('Scope Selection', () => {
it('should switch between scopes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Switch to scope focus
stdin.write('\t'); // Tab key
await wait();
// Select different scope (numbers 1-3 typically available)
stdin.write('2'); // Select second scope option
await wait();
unmount();
});
it('should reset to settings focus when scope is selected', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Switch to scope focus
stdin.write('\t'); // Tab key
await wait();
expect(lastFrame()).toContain('> Apply To');
// Select a scope
stdin.write('1'); // Select first scope option
await wait();
// Should be back to settings focus
expect(lastFrame()).toContain(' Apply To');
unmount();
});
});
describe('Restart Prompt', () => {
it('should show restart prompt for restart-required settings', async () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
const { unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
);
// This test would need to trigger a restart-required setting change
// The exact steps depend on which settings require restart
await wait();
unmount();
});
it('should handle restart request when r is pressed', async () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
);
// Press 'r' key (this would only work if restart prompt is showing)
stdin.write('r');
await wait();
// If restart prompt was showing, onRestartRequest should be called
unmount();
});
});
describe('Escape Key Behavior', () => {
it('should call onSelect with undefined when Escape is pressed', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Escape key
stdin.write('\u001B'); // ESC key
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount();
});
});
describe('Settings Persistence', () => {
it('should persist settings across scope changes', async () => {
const settings = createMockSettings({ vimMode: true });
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Switch to scope selector
stdin.write('\t'); // Tab
await wait();
// Change scope
stdin.write('2'); // Select workspace scope
await wait();
// Settings should be reloaded for new scope
unmount();
});
it('should show different values for different scopes', () => {
const settings = createMockSettings(
{ vimMode: true }, // User settings
{ vimMode: false }, // System settings
{ autoUpdate: false }, // Workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Should show user scope values initially
const output = lastFrame();
expect(output).toContain('Settings');
});
});
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 } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Try to toggle a setting (this might trigger vim mode toggle)
stdin.write('\u000D'); // Enter
await wait();
// Should not crash
unmount();
});
});
describe('Complex State Management', () => {
it('should track modified settings correctly', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Toggle a setting
stdin.write('\u000D'); // Enter
await wait();
// Toggle another setting
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u000D'); // Enter
await wait();
// Should track multiple modified settings
unmount();
});
it('should handle scrolling when there are many settings', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate down many times to test scrolling
for (let i = 0; i < 10; i++) {
stdin.write('\u001B[B'); // Down arrow
await wait(10);
}
unmount();
});
});
describe('VimMode Integration', () => {
it('should sync with VimModeContext when vim mode is toggled', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<VimModeProvider settings={settings}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</VimModeProvider>,
);
// Navigate to and toggle vim mode setting
// This would require knowing the exact position of vim mode setting
stdin.write('\u000D'); // Enter
await wait();
unmount();
});
});
describe('Specific Settings Behavior', () => {
it('should show correct display values for settings with different states', () => {
const settings = createMockSettings(
{ vimMode: true, hideTips: false }, // User settings
{ hideWindowTitle: true }, // System settings
{ ideMode: false }, // Workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Should contain settings labels
expect(output).toContain('Settings');
});
it('should handle immediate settings save for non-restart-required settings', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Toggle a non-restart-required setting (like hideTips)
stdin.write('\u000D'); // Enter - toggle current setting
await wait();
// Should save immediately without showing restart prompt
unmount();
});
it('should show restart prompt for restart-required settings', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// This test would need to navigate to a specific restart-required setting
// Since we can't easily target specific settings, we test the general behavior
await wait();
// Should not show restart prompt initially
expect(lastFrame()).not.toContain(
'To see changes, Gemini CLI must be restarted',
);
unmount();
});
it('should clear restart prompt when switching scopes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Restart prompt should be cleared when switching scopes
unmount();
});
});
describe('Settings Display Values', () => {
it('should show correct values for inherited settings', () => {
const settings = createMockSettings(
{}, // No user settings
{ vimMode: true, hideWindowTitle: false }, // System settings
{}, // No workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Settings should show inherited values
expect(output).toContain('Settings');
});
it('should show override indicator for overridden settings', () => {
const settings = createMockSettings(
{ vimMode: false }, // User overrides
{ vimMode: true }, // System default
{}, // No workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Should show settings with override indicators
expect(output).toContain('Settings');
});
});
describe('Keyboard Shortcuts Edge Cases', () => {
it('should handle rapid key presses gracefully', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Rapid navigation
for (let i = 0; i < 5; i++) {
stdin.write('\u001B[B'); // Down arrow
stdin.write('\u001B[A'); // Up arrow
}
await wait(100);
// Should not crash
unmount();
});
it('should handle Ctrl+C to reset current setting to default', async () => {
const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Ctrl+C to reset current setting to default
stdin.write('\u0003'); // Ctrl+C
await wait();
// Should reset the current setting to its default value
unmount();
});
it('should handle Ctrl+L to reset current setting to default', async () => {
const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Ctrl+L to reset current setting to default
stdin.write('\u000C'); // Ctrl+L
await wait();
// Should reset the current setting to its default value
unmount();
});
it('should handle navigation when only one setting exists', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Try to navigate when potentially at bounds
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u001B[A'); // Up
await wait();
unmount();
});
it('should properly handle Tab navigation between sections', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Start in settings section
expect(lastFrame()).toContain(' Apply To');
// Tab to scope section
stdin.write('\t');
await wait();
expect(lastFrame()).toContain('> Apply To');
// Tab back to settings section
stdin.write('\t');
await wait();
expect(lastFrame()).toContain(' Apply To');
unmount();
});
});
describe('Error Recovery', () => {
it('should handle malformed settings gracefully', () => {
// Create settings with potentially problematic values
const settings = createMockSettings(
{ vimMode: null as unknown as boolean }, // Invalid value
{},
{},
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Should still render without crashing
expect(lastFrame()).toContain('Settings');
});
it('should handle missing setting definitions gracefully', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
// Should not crash even if some settings are missing definitions
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
expect(lastFrame()).toContain('Settings');
});
});
describe('Complex User Interactions', () => {
it('should handle complete user workflow: navigate, toggle, change scope, exit', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate down a few settings
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u001B[B'); // Down
await wait();
// Toggle a setting
stdin.write('\u000D'); // Enter
await wait();
// Switch to scope selector
stdin.write('\t'); // Tab
await wait();
// Change scope
stdin.write('2'); // Select workspace
await wait();
// Go back to settings
stdin.write('\t'); // Tab
await wait();
// Navigate and toggle another setting
stdin.write('\u001B[B'); // Down
await wait();
stdin.write(' '); // Space to toggle
await wait();
// Exit
stdin.write('\u001B'); // Escape
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, expect.any(String));
unmount();
});
it('should allow changing multiple settings without losing pending changes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Toggle first setting (should require restart)
stdin.write('\u000D'); // Enter
await wait();
// Navigate to next setting and toggle it (should not require restart - e.g., vimMode)
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u000D'); // Enter
await wait();
// Navigate to another setting and toggle it (should also require restart)
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u000D'); // Enter
await wait();
// The test verifies that all changes are preserved and the dialog still works
// This tests the fix for the bug where changing one setting would reset all pending changes
unmount();
});
it('should maintain state consistency during complex interactions', async () => {
const settings = createMockSettings({ vimMode: true });
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Multiple scope changes
stdin.write('\t'); // Tab to scope
await wait();
stdin.write('2'); // Workspace
await wait();
stdin.write('\t'); // Tab to settings
await wait();
stdin.write('\t'); // Tab to scope
await wait();
stdin.write('1'); // User
await wait();
// Should maintain consistent state
unmount();
});
it('should handle restart workflow correctly', async () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
);
// This would test the restart workflow if we could trigger it
stdin.write('r'); // Try restart key
await wait();
// Without restart prompt showing, this should have no effect
expect(onRestartRequest).not.toHaveBeenCalled();
unmount();
});
});
});

View File

@@ -0,0 +1,465 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import {
LoadedSettings,
SettingScope,
Settings,
} from '../../config/settings.js';
import {
getScopeItems,
getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
getDialogSettingKeys,
getSettingValue,
setPendingSettingValue,
getDisplayValue,
hasRestartRequiredSettings,
saveModifiedSettings,
getSettingDefinition,
isDefaultValue,
requiresRestart,
getRestartRequiredFromModified,
getDefaultValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
interface SettingsDialogProps {
settings: LoadedSettings;
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
onRestartRequest?: () => void;
}
const maxItemsToShow = 8;
export function SettingsDialog({
settings,
onSelect,
onRestartRequest,
}: SettingsDialogProps): React.JSX.Element {
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
// Focus state: 'settings' or 'scope'
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
'settings',
);
// Scope selector state (User by default)
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
// Active indices
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
// Scroll offset for settings
const [scrollOffset, setScrollOffset] = useState(0);
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
// Local pending settings state for the selected scope
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
// Deep clone to avoid mutation
structuredClone(settings.forScope(selectedScope).settings),
);
// Track which settings have been modified by the user
const [modifiedSettings, setModifiedSettings] = useState<Set<string>>(
new Set(),
);
// Track the intended values for modified settings
const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>(
new Map(),
);
// Track restart-required settings across scope changes
const [restartRequiredSettings, setRestartRequiredSettings] = useState<
Set<string>
>(new Set());
useEffect(() => {
setPendingSettings(
structuredClone(settings.forScope(selectedScope).settings),
);
// Don't reset modifiedSettings when scope changes - preserve user's pending changes
if (restartRequiredSettings.size === 0) {
setShowRestartPrompt(false);
}
}, [selectedScope, settings, restartRequiredSettings]);
// Preserve pending changes when scope changes
useEffect(() => {
if (modifiedSettings.size > 0) {
setPendingSettings((prevPending) => {
let updatedPending = { ...prevPending };
// Reapply all modified settings to the new pending settings using stored values
modifiedSettings.forEach((key) => {
const storedValue = modifiedValues.get(key);
if (storedValue !== undefined) {
updatedPending = setPendingSettingValue(
key,
storedValue,
updatedPending,
);
}
});
return updatedPending;
});
}
}, [selectedScope, modifiedSettings, modifiedValues, settings]);
const generateSettingsItems = () => {
const settingKeys = getDialogSettingKeys();
return settingKeys.map((key: string) => {
const currentValue = getSettingValue(key, pendingSettings, {});
const definition = getSettingDefinition(key);
return {
label: definition?.label || key,
value: key,
checked: currentValue,
toggle: () => {
const newValue = !currentValue;
setPendingSettings((prev) =>
setPendingSettingValue(key, newValue, prev),
);
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const immediateSettingsObject = setPendingSettingValue(
key,
newValue,
{},
);
console.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 === 'vimMode' && newValue !== vimEnabled) {
// Call toggleVimEnabled to sync the VimModeContext local state
toggleVimEnabled().catch((error) => {
console.error('Failed to toggle vim mode:', error);
});
}
// Capture the current modified settings before updating state
const currentModifiedSettings = new Set(modifiedSettings);
// Remove the saved setting from modifiedSettings since it's now saved
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Remove from modifiedValues as well
setModifiedValues((prev) => {
const updated = new Map(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;
});
setPendingSettings((_prevPending) => {
let updatedPending = structuredClone(
settings.forScope(selectedScope).settings,
);
currentModifiedSettings.forEach((modifiedKey) => {
if (modifiedKey !== key) {
const modifiedValue = modifiedValues.get(modifiedKey);
if (modifiedValue !== undefined) {
updatedPending = setPendingSettingValue(
modifiedKey,
modifiedValue,
updatedPending,
);
}
}
});
return updatedPending;
});
} else {
// For restart-required settings, store the actual value
setModifiedValues((prev) => {
const updated = new Map(prev);
updated.set(key, newValue);
return updated;
});
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
console.log(
`[DEBUG SettingsDialog] Modified settings:`,
Array.from(updated),
'Needs restart:',
needsRestart,
);
if (needsRestart) {
setShowRestartPrompt(true);
setRestartRequiredSettings((prevRestart) =>
new Set(prevRestart).add(key),
);
}
return updated;
});
}
},
};
});
};
const items = generateSettingsItems();
// Scope selector items
const scopeItems = getScopeItems();
const handleScopeHighlight = (scope: SettingScope) => {
setSelectedScope(scope);
};
const handleScopeSelect = (scope: SettingScope) => {
handleScopeHighlight(scope);
setFocusSection('settings');
};
// Scroll logic for settings
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
// Always show arrows for consistent UI and to indicate circular navigation
const showScrollUp = true;
const showScrollDown = true;
useInput((input, key) => {
if (key.tab) {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
if (key.upArrow || input === 'k') {
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === items.length - 1) {
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
} else if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
} else if (key.downArrow || input === 'j') {
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === 0) {
setScrollOffset(0);
} else if (newIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newIndex - maxItemsToShow + 1);
}
} else if (key.return || input === ' ') {
items[activeSettingIndex]?.toggle();
} else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex];
if (currentSetting) {
const defaultValue = getDefaultValue(currentSetting.value);
// Ensure defaultValue is a boolean for setPendingSettingValue
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
// Update pending settings to default value
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
// Remove from modified settings since it's now at default
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// Remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// If this setting doesn't require restart, save it immediately
if (!requiresRestart(currentSetting.value)) {
const immediateSettings = new Set([currentSetting.value]);
const immediateSettingsObject = setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
{},
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
}
}
}
}
if (showRestartPrompt && input === 'r') {
// Only save settings that require restart (non-restart settings were already saved immediately)
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="row"
padding={1}
width="100%"
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
<Text bold color={Colors.AccentBlue}>
Settings
</Text>
<Box height={1} />
{showScrollUp && <Text color={Colors.Gray}></Text>}
{visibleItems.map((item, idx) => {
const isActive =
focusSection === 'settings' &&
activeSettingIndex === idx + scrollOffset;
const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged;
const displayValue = getDisplayValue(
item.value,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
// Generate scope message for this setting
const scopeMessage = getScopeMessageForSetting(
item.value,
selectedScope,
settings,
);
return (
<React.Fragment key={item.value}>
<Box flexDirection="row" alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isActive ? Colors.AccentGreen : Colors.Gray}>
{isActive ? '●' : ''}
</Text>
</Box>
<Box minWidth={50}>
<Text
color={isActive ? Colors.AccentGreen : Colors.Foreground}
>
{item.label}
{scopeMessage && (
<Text color={Colors.Gray}> {scopeMessage}</Text>
)}
</Text>
</Box>
<Box minWidth={3} />
<Text
color={
isActive
? Colors.AccentGreen
: shouldBeGreyedOut
? Colors.Gray
: Colors.Foreground
}
>
{displayValue}
</Text>
</Box>
<Box height={1} />
</React.Fragment>
);
})}
{showScrollDown && <Text color={Colors.Gray}></Text>}
<Box height={1} />
<Box marginTop={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0}
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
showNumbers={focusSection === 'scope'}
/>
</Box>
<Box height={1} />
<Text color={Colors.Gray}>
(Use Enter to select, Tab to change focus)
</Text>
{showRestartPrompt && (
<Text color={Colors.AccentYellow}>
To see changes, Gemini CLI must be restarted. Press r to exit and
apply changes now.
</Text>
)}
</Box>
</Box>
);
}

View File

@@ -82,7 +82,7 @@ export function SuggestionsDisplay({
)} )}
{suggestion.description ? ( {suggestion.description ? (
<Box flexGrow={1}> <Box flexGrow={1}>
<Text color={textColor} wrap="wrap"> <Text color={textColor} wrap="truncate">
{suggestion.description} {suggestion.description}
</Text> </Text>
</Box> </Box>

View File

@@ -12,6 +12,10 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js'; import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js'; import { colorizeCode } from '../utils/CodeColorizer.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js';
import {
getScopeItems,
getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js';
interface ThemeDialogProps { interface ThemeDialogProps {
/** Callback function when a theme is selected */ /** Callback function when a theme is selected */
@@ -76,11 +80,7 @@ export function ThemeDialog({
// If not found, fall back to the first theme // If not found, fall back to the first theme
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0; const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
const scopeItems = [ const scopeItems = getScopeItems();
{ label: 'User Settings', value: SettingScope.User },
{ label: 'Workspace Settings', value: SettingScope.Workspace },
{ label: 'System Settings', value: SettingScope.System },
];
const handleThemeSelect = useCallback( const handleThemeSelect = useCallback(
(themeName: string) => { (themeName: string) => {
@@ -120,23 +120,13 @@ export function ThemeDialog({
} }
}); });
const otherScopes = Object.values(SettingScope).filter( // Generate scope message for theme setting
(scope) => scope !== selectedScope, const otherScopeModifiedMessage = getScopeMessageForSetting(
'theme',
selectedScope,
settings,
); );
const modifiedInOtherScopes = otherScopes.filter(
(scope) => settings.forScope(scope).settings.theme !== undefined,
);
let otherScopeModifiedMessage = '';
if (modifiedInOtherScopes.length > 0) {
const modifiedScopesStr = modifiedInOtherScopes.join(', ');
otherScopeModifiedMessage =
settings.forScope(selectedScope).settings.theme !== undefined
? `(Also modified in ${modifiedScopesStr})`
: `(Modified in ${modifiedScopesStr})`;
}
// Constants for calculating preview pane layout. // Constants for calculating preview pane layout.
// These values are based on the JSX structure below. // These values are based on the JSX structure below.
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55; const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;

View File

@@ -0,0 +1,24 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`IDEContextDetailDisplay > handles duplicate basenames by showing path hints 1`] = `
"
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ VS Code Context (ctrl+e to toggle) │
│ │
│ Open files: │
│ - bar.txt (/foo) (active) │
│ - bar.txt (/qux) │
│ - unique.txt │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`IDEContextDetailDisplay > renders a list of open files with active status 1`] = `
"
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ VS Code Context (ctrl+e to toggle) │
│ │
│ Open files: │
│ - bar.txt (active) │
│ - baz.txt │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -33,6 +33,7 @@ export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps ToolConfirmationMessageProps
> = ({ > = ({
confirmationDetails, confirmationDetails,
config,
isFocused = true, isFocused = true,
availableTerminalHeight, availableTerminalHeight,
terminalWidth, terminalWidth,
@@ -40,14 +41,29 @@ export const ToolConfirmationMessage: React.FC<
const { onConfirm } = confirmationDetails; const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding const childWidth = terminalWidth - 2; // 2 for padding
useInput((_, key) => { const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
if (confirmationDetails.type === 'edit') {
const ideClient = config?.getIdeClient();
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient?.resolveDiffFromCli(
confirmationDetails.filePath,
cliOutcome,
);
}
}
onConfirm(outcome);
};
useInput((input, key) => {
if (!isFocused) return; if (!isFocused) return;
if (key.escape) { if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
onConfirm(ToolConfirmationOutcome.Cancel); handleConfirm(ToolConfirmationOutcome.Cancel);
} }
}); });
const handleSelect = (item: ToolConfirmationOutcome) => onConfirm(item); const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
let question: string; let question: string;
@@ -85,6 +101,7 @@ export const ToolConfirmationMessage: React.FC<
HEIGHT_OPTIONS; HEIGHT_OPTIONS;
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
} }
if (confirmationDetails.type === 'edit') { if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) { if (confirmationDetails.isModifying) {
return ( return (
@@ -114,15 +131,23 @@ export const ToolConfirmationMessage: React.FC<
label: 'Yes, allow always', label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways, value: ToolConfirmationOutcome.ProceedAlways,
}, },
{ );
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
options.push({
label: 'No (esc)',
value: ToolConfirmationOutcome.Cancel,
});
} else {
options.push({
label: 'Modify with external editor', label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor, value: ToolConfirmationOutcome.ModifyWithEditor,
}, });
{ options.push({
label: 'No, suggest changes (esc)', label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel, value: ToolConfirmationOutcome.Cancel,
}, });
); }
bodyContent = ( bodyContent = (
<DiffRenderer <DiffRenderer
diffContent={confirmationDetails.fileDiff} diffContent={confirmationDetails.fileDiff}

View File

@@ -15,7 +15,11 @@ import {
textBufferReducer, textBufferReducer,
TextBufferState, TextBufferState,
TextBufferAction, TextBufferAction,
findWordEndInLine,
findNextWordStartInLine,
isWordCharStrict,
} from './text-buffer.js'; } from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
const initialState: TextBufferState = { const initialState: TextBufferState = {
lines: [''], lines: [''],
@@ -1591,3 +1595,94 @@ describe('textBufferReducer vim operations', () => {
}); });
}); });
}); });
describe('Unicode helper functions', () => {
describe('findWordEndInLine with Unicode', () => {
it('should handle combining characters', () => {
// café with combining accent
const cafeWithCombining = 'cafe\u0301';
const result = findWordEndInLine(cafeWithCombining + ' test', 0);
expect(result).toBe(3); // End of 'café' at base character 'e', not combining accent
});
it('should handle precomposed characters with diacritics', () => {
// café with precomposed é (U+00E9)
const cafePrecomposed = 'café';
const result = findWordEndInLine(cafePrecomposed + ' test', 0);
expect(result).toBe(3); // End of 'café' at precomposed character 'é'
});
it('should return null when no word end found', () => {
const result = findWordEndInLine(' ', 0);
expect(result).toBeNull(); // No word end found in whitespace-only string string
});
});
describe('findNextWordStartInLine with Unicode', () => {
it('should handle right-to-left text', () => {
const result = findNextWordStartInLine('hello مرحبا world', 0);
expect(result).toBe(6); // Start of Arabic word
});
it('should handle Chinese characters', () => {
const result = findNextWordStartInLine('hello 你好 world', 0);
expect(result).toBe(6); // Start of Chinese word
});
it('should return null at end of line', () => {
const result = findNextWordStartInLine('hello', 10);
expect(result).toBeNull();
});
it('should handle combining characters', () => {
// café with combining accent + next word
const textWithCombining = 'cafe\u0301 test';
const result = findNextWordStartInLine(textWithCombining, 0);
expect(result).toBe(6); // Start of 'test' after 'café ' (combining char makes string longer)
});
it('should handle precomposed characters with diacritics', () => {
// café with precomposed é + next word
const textPrecomposed = 'café test';
const result = findNextWordStartInLine(textPrecomposed, 0);
expect(result).toBe(5); // Start of 'test' after 'café '
});
});
describe('isWordCharStrict with Unicode', () => {
it('should return true for ASCII word characters', () => {
expect(isWordCharStrict('a')).toBe(true);
expect(isWordCharStrict('Z')).toBe(true);
expect(isWordCharStrict('0')).toBe(true);
expect(isWordCharStrict('_')).toBe(true);
});
it('should return false for punctuation', () => {
expect(isWordCharStrict('.')).toBe(false);
expect(isWordCharStrict(',')).toBe(false);
expect(isWordCharStrict('!')).toBe(false);
});
it('should return true for non-Latin scripts', () => {
expect(isWordCharStrict('你')).toBe(true); // Chinese character
expect(isWordCharStrict('م')).toBe(true); // Arabic character
});
it('should return false for whitespace', () => {
expect(isWordCharStrict(' ')).toBe(false);
expect(isWordCharStrict('\t')).toBe(false);
});
});
describe('cpLen with Unicode', () => {
it('should handle combining characters', () => {
expect(cpLen('é')).toBe(1); // Precomposed
expect(cpLen('e\u0301')).toBe(2); // e + combining acute
});
it('should handle Chinese and Arabic text', () => {
expect(cpLen('hello 你好 world')).toBe(14); // 5 + 1 + 2 + 1 + 5 = 14
expect(cpLen('hello مرحبا world')).toBe(17);
});
});
});

View File

@@ -33,143 +33,329 @@ function isWordChar(ch: string | undefined): boolean {
return !/[\s,.;!?]/.test(ch); return !/[\s,.;!?]/.test(ch);
} }
// Vim-specific word boundary functions // Helper functions for line-based word navigation
export const findNextWordStart = ( export const isWordCharStrict = (char: string): boolean =>
text: string, /[\w\p{L}\p{N}]/u.test(char); // Matches a single character that is any Unicode letter, any Unicode number, or an underscore
currentOffset: number,
): number => {
let i = currentOffset;
if (i >= text.length) return i; export const isWhitespace = (char: string): boolean => /\s/.test(char);
const currentChar = text[i]; // Check if a character is a combining mark (only diacritics for now)
export const isCombiningMark = (char: string): boolean => /\p{M}/u.test(char);
// Check if a character should be considered part of a word (including combining marks)
export const isWordCharWithCombining = (char: string): boolean =>
isWordCharStrict(char) || isCombiningMark(char);
// Get the script of a character (simplified for common scripts)
export const getCharScript = (char: string): string => {
if (/[\p{Script=Latin}]/u.test(char)) return 'latin'; // All Latin script chars including diacritics
if (/[\p{Script=Han}]/u.test(char)) return 'han'; // Chinese
if (/[\p{Script=Arabic}]/u.test(char)) return 'arabic';
if (/[\p{Script=Hiragana}]/u.test(char)) return 'hiragana';
if (/[\p{Script=Katakana}]/u.test(char)) return 'katakana';
if (/[\p{Script=Cyrillic}]/u.test(char)) return 'cyrillic';
return 'other';
};
// Check if two characters are from different scripts (indicating word boundary)
export const isDifferentScript = (char1: string, char2: string): boolean => {
if (!isWordCharStrict(char1) || !isWordCharStrict(char2)) return false;
return getCharScript(char1) !== getCharScript(char2);
};
// Find next word start within a line, starting from col
export const findNextWordStartInLine = (
line: string,
col: number,
): number | null => {
const chars = toCodePoints(line);
let i = col;
if (i >= chars.length) return null;
const currentChar = chars[i];
// Skip current word/sequence based on character type // Skip current word/sequence based on character type
if (/\w/.test(currentChar)) { if (isWordCharStrict(currentChar)) {
// Skip current word characters while (i < chars.length && isWordCharWithCombining(chars[i])) {
while (i < text.length && /\w/.test(text[i])) { // Check for script boundary - if next character is from different script, stop here
if (
i + 1 < chars.length &&
isWordCharStrict(chars[i + 1]) &&
isDifferentScript(chars[i], chars[i + 1])
) {
i++; // Include current character
break; // Stop at script boundary
}
i++; i++;
} }
} else if (!/\s/.test(currentChar)) { } else if (!isWhitespace(currentChar)) {
// Skip current non-word, non-whitespace characters (like "/", ".", etc.) while (
while (i < text.length && !/\w/.test(text[i]) && !/\s/.test(text[i])) { i < chars.length &&
!isWordCharStrict(chars[i]) &&
!isWhitespace(chars[i])
) {
i++; i++;
} }
} }
// Skip whitespace // Skip whitespace
while (i < text.length && /\s/.test(text[i])) { while (i < chars.length && isWhitespace(chars[i])) {
i++; i++;
} }
// If we reached the end of text and there's no next word, return i < chars.length ? i : null;
// vim behavior for dw is to delete to the end of the current word
if (i >= text.length) {
// Go back to find the end of the last word
let endOfLastWord = text.length - 1;
while (endOfLastWord >= 0 && /\s/.test(text[endOfLastWord])) {
endOfLastWord--;
}
// For dw on last word, return position AFTER the last character to delete entire word
return Math.max(currentOffset + 1, endOfLastWord + 1);
}
return i;
}; };
export const findPrevWordStart = ( // Find previous word start within a line
text: string, export const findPrevWordStartInLine = (
currentOffset: number, line: string,
): number => { col: number,
let i = currentOffset; ): number | null => {
const chars = toCodePoints(line);
let i = col;
// If at beginning of text, return current position if (i <= 0) return null;
if (i <= 0) {
return currentOffset;
}
// Move back one character to start searching
i--; i--;
// Skip whitespace moving backwards // Skip whitespace moving backwards
while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n')) { while (i >= 0 && isWhitespace(chars[i])) {
i--; i--;
} }
if (i < 0) { if (i < 0) return null;
return 0; // Reached beginning of text
}
const charAtI = text[i]; if (isWordCharStrict(chars[i])) {
if (/\w/.test(charAtI)) {
// We're in a word, move to its beginning // We're in a word, move to its beginning
while (i >= 0 && /\w/.test(text[i])) { while (i >= 0 && isWordCharStrict(chars[i])) {
// Check for script boundary - if previous character is from different script, stop here
if (
i - 1 >= 0 &&
isWordCharStrict(chars[i - 1]) &&
isDifferentScript(chars[i], chars[i - 1])
) {
return i; // Return current position at script boundary
}
i--; i--;
} }
return i + 1; // Return first character of word return i + 1;
} else { } else {
// We're in punctuation, move to its beginning // We're in punctuation, move to its beginning
while ( while (i >= 0 && !isWordCharStrict(chars[i]) && !isWhitespace(chars[i])) {
i >= 0 &&
!/\w/.test(text[i]) &&
text[i] !== ' ' &&
text[i] !== '\t' &&
text[i] !== '\n'
) {
i--; i--;
} }
return i + 1; // Return first character of punctuation sequence return i + 1;
} }
}; };
export const findWordEnd = (text: string, currentOffset: number): number => { // Find word end within a line
let i = currentOffset; export const findWordEndInLine = (line: string, col: number): number | null => {
const chars = toCodePoints(line);
let i = col;
// If we're already at the end of a word, advance to next word // If we're already at the end of a word (including punctuation sequences), advance to next word
if ( // This includes both regular word endings and script boundaries
i < text.length && const atEndOfWordChar =
/\w/.test(text[i]) && i < chars.length &&
(i + 1 >= text.length || !/\w/.test(text[i + 1])) isWordCharWithCombining(chars[i]) &&
) { (i + 1 >= chars.length ||
// We're at the end of a word, move forward to find next word !isWordCharWithCombining(chars[i + 1]) ||
(isWordCharStrict(chars[i]) &&
i + 1 < chars.length &&
isWordCharStrict(chars[i + 1]) &&
isDifferentScript(chars[i], chars[i + 1])));
const atEndOfPunctuation =
i < chars.length &&
!isWordCharWithCombining(chars[i]) &&
!isWhitespace(chars[i]) &&
(i + 1 >= chars.length ||
isWhitespace(chars[i + 1]) ||
isWordCharWithCombining(chars[i + 1]));
if (atEndOfWordChar || atEndOfPunctuation) {
// We're at the end of a word or punctuation sequence, move forward to find next word
i++; i++;
// Skip whitespace/punctuation to find next word // Skip whitespace to find next word or punctuation
while (i < text.length && !/\w/.test(text[i])) { while (i < chars.length && isWhitespace(chars[i])) {
i++; i++;
} }
} }
// If we're not on a word character, find the next word // If we're not on a word character, find the next word or punctuation sequence
if (i < text.length && !/\w/.test(text[i])) { if (i < chars.length && !isWordCharWithCombining(chars[i])) {
while (i < text.length && !/\w/.test(text[i])) { // Skip whitespace to find next word or punctuation
while (i < chars.length && isWhitespace(chars[i])) {
i++; i++;
} }
} }
// Move to end of current word // Move to end of current word (including combining marks, but stop at script boundaries)
while (i < text.length && /\w/.test(text[i])) { let foundWord = false;
i++; let lastBaseCharPos = -1;
if (i < chars.length && isWordCharWithCombining(chars[i])) {
// Handle word characters
while (i < chars.length && isWordCharWithCombining(chars[i])) {
foundWord = true;
// Track the position of the last base character (not combining mark)
if (isWordCharStrict(chars[i])) {
lastBaseCharPos = i;
}
// Check if next character is from a different script (word boundary)
if (
i + 1 < chars.length &&
isWordCharStrict(chars[i + 1]) &&
isDifferentScript(chars[i], chars[i + 1])
) {
i++; // Include current character
if (isWordCharStrict(chars[i - 1])) {
lastBaseCharPos = i - 1;
}
break; // Stop at script boundary
}
i++;
}
} else if (i < chars.length && !isWhitespace(chars[i])) {
// Handle punctuation sequences (like ████)
while (
i < chars.length &&
!isWordCharStrict(chars[i]) &&
!isWhitespace(chars[i])
) {
foundWord = true;
lastBaseCharPos = i;
i++;
}
} }
// Move back one to be on the last character of the word // Only return a position if we actually found a word
return Math.max(currentOffset, i - 1); // Return the position of the last base character, not combining marks
if (foundWord && lastBaseCharPos >= col) {
return lastBaseCharPos;
}
return null;
}; };
// Helper functions for vim operations // Find next word across lines
export const getOffsetFromPosition = ( export const findNextWordAcrossLines = (
row: number,
col: number,
lines: string[], lines: string[],
): number => { cursorRow: number,
let offset = 0; cursorCol: number,
for (let i = 0; i < row; i++) { searchForWordStart: boolean,
offset += lines[i].length + 1; // +1 for newline ): { row: number; col: number } | null => {
// First try current line
const currentLine = lines[cursorRow] || '';
const colInCurrentLine = searchForWordStart
? findNextWordStartInLine(currentLine, cursorCol)
: findWordEndInLine(currentLine, cursorCol);
if (colInCurrentLine !== null) {
return { row: cursorRow, col: colInCurrentLine };
} }
offset += col;
return offset; // Search subsequent lines
for (let row = cursorRow + 1; row < lines.length; row++) {
const line = lines[row] || '';
const chars = toCodePoints(line);
// For empty lines, if we haven't found any words yet, return the empty line
if (chars.length === 0) {
// Check if there are any words in remaining lines
let hasWordsInLaterLines = false;
for (let laterRow = row + 1; laterRow < lines.length; laterRow++) {
const laterLine = lines[laterRow] || '';
const laterChars = toCodePoints(laterLine);
let firstNonWhitespace = 0;
while (
firstNonWhitespace < laterChars.length &&
isWhitespace(laterChars[firstNonWhitespace])
) {
firstNonWhitespace++;
}
if (firstNonWhitespace < laterChars.length) {
hasWordsInLaterLines = true;
break;
}
}
// If no words in later lines, return the empty line
if (!hasWordsInLaterLines) {
return { row, col: 0 };
}
continue;
}
// Find first non-whitespace
let firstNonWhitespace = 0;
while (
firstNonWhitespace < chars.length &&
isWhitespace(chars[firstNonWhitespace])
) {
firstNonWhitespace++;
}
if (firstNonWhitespace < chars.length) {
if (searchForWordStart) {
return { row, col: firstNonWhitespace };
} else {
// For word end, find the end of the first word
const endCol = findWordEndInLine(line, firstNonWhitespace);
if (endCol !== null) {
return { row, col: endCol };
}
}
}
}
return null;
}; };
// Find previous word across lines
export const findPrevWordAcrossLines = (
lines: string[],
cursorRow: number,
cursorCol: number,
): { row: number; col: number } | null => {
// First try current line
const currentLine = lines[cursorRow] || '';
const colInCurrentLine = findPrevWordStartInLine(currentLine, cursorCol);
if (colInCurrentLine !== null) {
return { row: cursorRow, col: colInCurrentLine };
}
// Search previous lines
for (let row = cursorRow - 1; row >= 0; row--) {
const line = lines[row] || '';
const chars = toCodePoints(line);
if (chars.length === 0) continue;
// Find last word start
let lastWordStart = chars.length;
while (lastWordStart > 0 && isWhitespace(chars[lastWordStart - 1])) {
lastWordStart--;
}
if (lastWordStart > 0) {
// Find start of this word
const wordStart = findPrevWordStartInLine(line, lastWordStart);
if (wordStart !== null) {
return { row, col: wordStart };
}
}
}
return null;
};
// Helper functions for vim line operations
export const getPositionFromOffsets = ( export const getPositionFromOffsets = (
startOffset: number, startOffset: number,
endOffset: number, endOffset: number,

View File

@@ -140,6 +140,25 @@ describe('vim-buffer-actions', () => {
expect(result.cursorRow).toBe(1); expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
it('should skip over combining marks to avoid cursor disappearing', () => {
// Test case for combining character cursor disappearing bug
// "café test" where é is represented as e + combining acute accent
const state = createTestState(['cafe\u0301 test'], 0, 2); // Start at 'f'
const action = {
type: 'vim_move_right' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // Should be on 'e' of 'café'
// Move right again - should skip combining mark and land on space
const result2 = handleVimAction(result, action);
expect(result2).toHaveOnlyValidCharacters();
expect(result2.cursorCol).toBe(5); // Should be on space after 'café'
});
}); });
describe('vim_move_up', () => { describe('vim_move_up', () => {
@@ -169,7 +188,7 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters(); expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(5); // End of 'short' expect(result.cursorCol).toBe(4); // Last character 't' of 'short', not past it
}); });
}); });
@@ -236,6 +255,20 @@ describe('vim-buffer-actions', () => {
expect(result).toHaveOnlyValidCharacters(); expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5); // Start of ',' expect(result.cursorCol).toBe(5); // Start of ','
}); });
it('should move across empty lines when starting from within a word', () => {
// Testing the exact scenario: cursor on 'w' of 'hello world', w should move to next line
const state = createTestState(['hello world', ''], 0, 6); // At 'w' of 'world'
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // Should move to empty line
expect(result.cursorCol).toBe(0); // Beginning of empty line
});
}); });
describe('vim_move_word_backward', () => { describe('vim_move_word_backward', () => {
@@ -288,6 +321,85 @@ describe('vim-buffer-actions', () => {
expect(result).toHaveOnlyValidCharacters(); expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(10); // End of 'world' expect(result.cursorCol).toBe(10); // End of 'world'
}); });
it('should move across empty lines when at word end', () => {
const state = createTestState(['hello world', '', 'test'], 0, 10); // At 'd' of 'world'
const action = {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(2); // Should move to line with 'test'
expect(result.cursorCol).toBe(3); // Should be at 't' (end of 'test')
});
it('should handle consecutive word-end movements across empty lines', () => {
// Testing the exact scenario: cursor on 'w' of world, press 'e' twice
const state = createTestState(['hello world', ''], 0, 6); // At 'w' of 'world'
// First 'e' should move to 'd' of 'world'
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(10); // At 'd' of 'world'
// Second 'e' should move to the empty line (end of file in this case)
result = handleVimAction(result, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // Should move to empty line
expect(result.cursorCol).toBe(0); // Empty line has col 0
});
it('should handle combining characters - advance from end of base character', () => {
// Test case for combining character word end bug
// "café test" where é is represented as e + combining acute accent
const state = createTestState(['cafe\u0301 test'], 0, 0); // Start at 'c'
// First 'e' command should move to the 'e' (position 3)
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // At 'e' of café
// Second 'e' command should advance to end of "test" (position 9), not stay stuck
result = handleVimAction(result, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(9); // At 't' of "test"
});
it('should handle precomposed characters with diacritics', () => {
// Test case with precomposed é for comparison
const state = createTestState(['café test'], 0, 0); // Start at 'c'
// First 'e' command should move to the 'é' (position 3)
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // At 'é' of café
// Second 'e' command should advance to end of "test" (position 8)
result = handleVimAction(result, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(8); // At 't' of "test"
});
}); });
describe('Position commands', () => { describe('Position commands', () => {
@@ -793,4 +905,215 @@ describe('vim-buffer-actions', () => {
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
}); });
}); });
describe('UTF-32 character handling in word/line operations', () => {
describe('Right-to-left text handling', () => {
it('should handle Arabic text in word movements', () => {
const state = createTestState(['hello مرحبا world'], 0, 0);
// Move to end of 'hello'
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // End of 'hello'
// Move to end of Arabic word
result = handleVimAction(result, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(10); // End of Arabic word 'مرحبا'
});
});
describe('Chinese character handling', () => {
it('should handle Chinese characters in word movements', () => {
const state = createTestState(['hello 你好 world'], 0, 0);
// Move to end of 'hello'
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // End of 'hello'
// Move forward to start of 'world'
result = handleVimAction(result, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of '你好'
});
});
describe('Mixed script handling', () => {
it('should handle mixed Latin and non-Latin scripts with word end commands', () => {
const state = createTestState(['test中文test'], 0, 0);
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // End of 'test'
// Second word end command should move to end of '中文'
result = handleVimAction(result, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5); // End of '中文'
});
it('should handle mixed Latin and non-Latin scripts with word forward commands', () => {
const state = createTestState(['test中文test'], 0, 0);
let result = handleVimAction(state, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // Start of '中'
// Second word forward command should move to start of final 'test'
result = handleVimAction(result, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of final 'test'
});
it('should handle mixed Latin and non-Latin scripts with word backward commands', () => {
const state = createTestState(['test中文test'], 0, 9); // Start at end of final 'test'
let result = handleVimAction(state, {
type: 'vim_move_word_backward' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of final 'test'
// Second word backward command should move to start of '中文'
result = handleVimAction(result, {
type: 'vim_move_word_backward' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // Start of '中'
});
it('should handle Unicode block characters consistently with w and e commands', () => {
const state = createTestState(['██ █████ ██'], 0, 0);
// Test w command progression
let wResult = handleVimAction(state, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult).toHaveOnlyValidCharacters();
expect(wResult.cursorCol).toBe(3); // Start of second block sequence
wResult = handleVimAction(wResult, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult).toHaveOnlyValidCharacters();
expect(wResult.cursorCol).toBe(9); // Start of third block sequence
// Test e command progression from beginning
let eResult = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(eResult).toHaveOnlyValidCharacters();
expect(eResult.cursorCol).toBe(1); // End of first block sequence
eResult = handleVimAction(eResult, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(eResult).toHaveOnlyValidCharacters();
expect(eResult.cursorCol).toBe(7); // End of second block sequence
eResult = handleVimAction(eResult, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(eResult).toHaveOnlyValidCharacters();
expect(eResult.cursorCol).toBe(10); // End of third block sequence
});
it('should handle strings starting with Chinese characters', () => {
const state = createTestState(['中文test英文word'], 0, 0);
// Test 'w' command - when at start of non-Latin word, w moves to next word
let wResult = handleVimAction(state, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult).toHaveOnlyValidCharacters();
expect(wResult.cursorCol).toBe(2); // Start of 'test'
wResult = handleVimAction(wResult, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult.cursorCol).toBe(6); // Start of '英文'
// Test 'e' command
let eResult = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(eResult).toHaveOnlyValidCharacters();
expect(eResult.cursorCol).toBe(1); // End of 中文
eResult = handleVimAction(eResult, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(eResult.cursorCol).toBe(5); // End of test
});
it('should handle strings starting with Arabic characters', () => {
const state = createTestState(['مرحباhelloسلام'], 0, 0);
// Test 'w' command - when at start of non-Latin word, w moves to next word
let wResult = handleVimAction(state, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult).toHaveOnlyValidCharacters();
expect(wResult.cursorCol).toBe(5); // Start of 'hello'
wResult = handleVimAction(wResult, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult.cursorCol).toBe(10); // Start of 'سلام'
// Test 'b' command from end
const bState = createTestState(['مرحباhelloسلام'], 0, 13);
let bResult = handleVimAction(bState, {
type: 'vim_move_word_backward' as const,
payload: { count: 1 },
});
expect(bResult).toHaveOnlyValidCharacters();
expect(bResult.cursorCol).toBe(10); // Start of سلام
bResult = handleVimAction(bResult, {
type: 'vim_move_word_backward' as const,
payload: { count: 1 },
});
expect(bResult.cursorCol).toBe(5); // Start of hello
});
});
});
}); });

View File

@@ -7,16 +7,35 @@
import { import {
TextBufferState, TextBufferState,
TextBufferAction, TextBufferAction,
findNextWordStart,
findPrevWordStart,
findWordEnd,
getOffsetFromPosition,
getPositionFromOffsets,
getLineRangeOffsets, getLineRangeOffsets,
getPositionFromOffsets,
replaceRangeInternal, replaceRangeInternal,
pushUndo, pushUndo,
isWordCharStrict,
isWordCharWithCombining,
isCombiningMark,
findNextWordAcrossLines,
findPrevWordAcrossLines,
findWordEndInLine,
} from './text-buffer.js'; } from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js'; import { cpLen, toCodePoints } from '../../utils/textUtils.js';
// Check if we're at the end of a base word (on the last base character)
// Returns true if current position has a base character followed only by combining marks until non-word
function isAtEndOfBaseWord(lineCodePoints: string[], col: number): boolean {
if (!isWordCharStrict(lineCodePoints[col])) return false;
// Look ahead to see if we have only combining marks followed by non-word
let i = col + 1;
// Skip any combining marks
while (i < lineCodePoints.length && isCombiningMark(lineCodePoints[i])) {
i++;
}
// If we hit end of line or non-word character, we were at end of base word
return i >= lineCodePoints.length || !isWordCharStrict(lineCodePoints[i]);
}
export type VimAction = Extract< export type VimAction = Extract<
TextBufferAction, TextBufferAction,
@@ -59,167 +78,38 @@ export function handleVimAction(
action: VimAction, action: VimAction,
): TextBufferState { ): TextBufferState {
const { lines, cursorRow, cursorCol } = state; const { lines, cursorRow, cursorCol } = state;
// Cache text join to avoid repeated calculations for word operations
let text: string | null = null;
const getText = () => text ?? (text = lines.join('\n'));
switch (action.type) { switch (action.type) {
case 'vim_delete_word_forward': { case 'vim_delete_word_forward':
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let endOffset = currentOffset;
let searchOffset = currentOffset;
for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), searchOffset);
if (nextWordOffset > searchOffset) {
searchOffset = nextWordOffset;
endOffset = nextWordOffset;
} else {
// If no next word, delete to end of current word
const wordEndOffset = findWordEnd(getText(), searchOffset);
endOffset = Math.min(wordEndOffset + 1, getText().length);
break;
}
}
if (endOffset > currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
currentOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_delete_word_backward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let startOffset = currentOffset;
let searchOffset = currentOffset;
for (let i = 0; i < count; i++) {
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
if (prevWordOffset < searchOffset) {
searchOffset = prevWordOffset;
startOffset = prevWordOffset;
} else {
break;
}
}
if (startOffset < currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
startOffset,
currentOffset,
nextState.lines,
);
const newState = replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
// Cursor is already at the correct position after deletion
return newState;
}
return state;
}
case 'vim_delete_word_end': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let offset = currentOffset;
let endOffset = currentOffset;
for (let i = 0; i < count; i++) {
const wordEndOffset = findWordEnd(getText(), offset);
if (wordEndOffset >= offset) {
endOffset = wordEndOffset + 1; // Include the character at word end
// For next iteration, move to start of next word
if (i < count - 1) {
const nextWordStart = findNextWordStart(
getText(),
wordEndOffset + 1,
);
offset = nextWordStart;
if (nextWordStart <= wordEndOffset) {
break; // No more words
}
}
} else {
break;
}
}
endOffset = Math.min(endOffset, getText().length);
if (endOffset > currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
currentOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_change_word_forward': { case 'vim_change_word_forward': {
const { count } = action.payload; const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines); let endRow = cursorRow;
let endCol = cursorCol;
let searchOffset = currentOffset;
let endOffset = currentOffset;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), searchOffset); const nextWord = findNextWordAcrossLines(lines, endRow, endCol, true);
if (nextWordOffset > searchOffset) { if (nextWord) {
searchOffset = nextWordOffset; endRow = nextWord.row;
endOffset = nextWordOffset; endCol = nextWord.col;
} else { } else {
// If no next word, change to end of current word // No more words, delete/change to end of current word or line
const wordEndOffset = findWordEnd(getText(), searchOffset); const currentLine = lines[endRow] || '';
endOffset = Math.min(wordEndOffset + 1, getText().length); const wordEnd = findWordEndInLine(currentLine, endCol);
if (wordEnd !== null) {
endCol = wordEnd + 1; // Include the character at word end
} else {
endCol = cpLen(currentLine);
}
break; break;
} }
} }
if (endOffset > currentOffset) { if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = pushUndo(state); const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
currentOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal( return replaceRangeInternal(
nextState, nextState,
startRow, cursorRow,
startCol, cursorCol,
endRow, endRow,
endCol, endCol,
'', '',
@@ -228,61 +118,61 @@ export function handleVimAction(
return state; return state;
} }
case 'vim_delete_word_backward':
case 'vim_change_word_backward': { case 'vim_change_word_backward': {
const { count } = action.payload; const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines); let startRow = cursorRow;
let startCol = cursorCol;
let startOffset = currentOffset;
let searchOffset = currentOffset;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const prevWordOffset = findPrevWordStart(getText(), searchOffset); const prevWord = findPrevWordAcrossLines(lines, startRow, startCol);
if (prevWordOffset < searchOffset) { if (prevWord) {
searchOffset = prevWordOffset; startRow = prevWord.row;
startOffset = prevWordOffset; startCol = prevWord.col;
} else { } else {
break; break;
} }
} }
if (startOffset < currentOffset) { if (startRow !== cursorRow || startCol !== cursorCol) {
const nextState = pushUndo(state); const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
startOffset,
currentOffset,
nextState.lines,
);
return replaceRangeInternal( return replaceRangeInternal(
nextState, nextState,
startRow, startRow,
startCol, startCol,
endRow, cursorRow,
endCol, cursorCol,
'', '',
); );
} }
return state; return state;
} }
case 'vim_delete_word_end':
case 'vim_change_word_end': { case 'vim_change_word_end': {
const { count } = action.payload; const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines); let row = cursorRow;
let col = cursorCol;
let offset = currentOffset; let endRow = cursorRow;
let endOffset = currentOffset; let endCol = cursorCol;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const wordEndOffset = findWordEnd(getText(), offset); const wordEnd = findNextWordAcrossLines(lines, row, col, false);
if (wordEndOffset >= offset) { if (wordEnd) {
endOffset = wordEndOffset + 1; // Include the character at word end endRow = wordEnd.row;
endCol = wordEnd.col + 1; // Include the character at word end
// For next iteration, move to start of next word // For next iteration, move to start of next word
if (i < count - 1) { if (i < count - 1) {
const nextWordStart = findNextWordStart( const nextWord = findNextWordAcrossLines(
getText(), lines,
wordEndOffset + 1, wordEnd.row,
wordEnd.col + 1,
true,
); );
offset = nextWordStart; if (nextWord) {
if (nextWordStart <= wordEndOffset) { row = nextWord.row;
col = nextWord.col;
} else {
break; // No more words break; // No more words
} }
} }
@@ -291,19 +181,18 @@ export function handleVimAction(
} }
} }
endOffset = Math.min(endOffset, getText().length); // Ensure we don't go past the end of the last line
if (endRow < lines.length) {
const lineLen = cpLen(lines[endRow] || '');
endCol = Math.min(endCol, lineLen);
}
if (endOffset !== currentOffset) { if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = pushUndo(state); const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
Math.min(currentOffset, endOffset),
Math.max(currentOffset, endOffset),
nextState.lines,
);
return replaceRangeInternal( return replaceRangeInternal(
nextState, nextState,
startRow, cursorRow,
startCol, cursorCol,
endRow, endRow,
endCol, endCol,
'', '',
@@ -376,32 +265,17 @@ export function handleVimAction(
); );
} }
case 'vim_delete_to_end_of_line': { case 'vim_delete_to_end_of_line':
const currentLine = lines[cursorRow] || '';
if (cursorCol < currentLine.length) {
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
currentLine.length,
'',
);
}
return state;
}
case 'vim_change_to_end_of_line': { case 'vim_change_to_end_of_line': {
const currentLine = lines[cursorRow] || ''; const currentLine = lines[cursorRow] || '';
if (cursorCol < currentLine.length) { if (cursorCol < cpLen(currentLine)) {
const nextState = pushUndo(state); const nextState = pushUndo(state);
return replaceRangeInternal( return replaceRangeInternal(
nextState, nextState,
cursorRow, cursorRow,
cursorCol, cursorCol,
cursorRow, cursorRow,
currentLine.length, cpLen(currentLine),
'', '',
); );
} }
@@ -578,6 +452,16 @@ export function handleVimAction(
} }
} else if (newCol < lineLength - 1) { } else if (newCol < lineLength - 1) {
newCol++; newCol++;
// Skip over combining marks - don't let cursor land on them
const currentLinePoints = toCodePoints(currentLine);
while (
newCol < currentLinePoints.length &&
isCombiningMark(currentLinePoints[newCol]) &&
newCol < lineLength - 1
) {
newCol++;
}
} else if (newRow < lines.length - 1) { } else if (newRow < lines.length - 1) {
// At end of line - move to beginning of next line // At end of line - move to beginning of next line
newRow++; newRow++;
@@ -597,7 +481,12 @@ export function handleVimAction(
const { count } = action.payload; const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state; const { cursorRow, cursorCol, lines } = state;
const newRow = Math.max(0, cursorRow - count); const newRow = Math.max(0, cursorRow - count);
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || '')); const targetLine = lines[newRow] || '';
const targetLineLength = cpLen(targetLine);
const newCol = Math.min(
cursorCol,
targetLineLength > 0 ? targetLineLength - 1 : 0,
);
return { return {
...state, ...state,
@@ -611,7 +500,12 @@ export function handleVimAction(
const { count } = action.payload; const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state; const { cursorRow, cursorCol, lines } = state;
const newRow = Math.min(lines.length - 1, cursorRow + count); const newRow = Math.min(lines.length - 1, cursorRow + count);
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || '')); const targetLine = lines[newRow] || '';
const targetLineLength = cpLen(targetLine);
const newCol = Math.min(
cursorCol,
targetLineLength > 0 ? targetLineLength - 1 : 0,
);
return { return {
...state, ...state,
@@ -623,69 +517,101 @@ export function handleVimAction(
case 'vim_move_word_forward': { case 'vim_move_word_forward': {
const { count } = action.payload; const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines); let row = cursorRow;
let col = cursorCol;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), offset); const nextWord = findNextWordAcrossLines(lines, row, col, true);
if (nextWordOffset > offset) { if (nextWord) {
offset = nextWordOffset; row = nextWord.row;
col = nextWord.col;
} else { } else {
// No more words to move to // No more words to move to
break; break;
} }
} }
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return { return {
...state, ...state,
cursorRow: startRow, cursorRow: row,
cursorCol: startCol, cursorCol: col,
preferredCol: null, preferredCol: null,
}; };
} }
case 'vim_move_word_backward': { case 'vim_move_word_backward': {
const { count } = action.payload; const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines); let row = cursorRow;
let col = cursorCol;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
offset = findPrevWordStart(getText(), offset); const prevWord = findPrevWordAcrossLines(lines, row, col);
if (prevWord) {
row = prevWord.row;
col = prevWord.col;
} else {
break;
}
} }
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return { return {
...state, ...state,
cursorRow: startRow, cursorRow: row,
cursorCol: startCol, cursorCol: col,
preferredCol: null, preferredCol: null,
}; };
} }
case 'vim_move_word_end': { case 'vim_move_word_end': {
const { count } = action.payload; const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines); let row = cursorRow;
let col = cursorCol;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
offset = findWordEnd(getText(), offset); // Special handling for the first iteration when we're at end of word
if (i === 0) {
const currentLine = lines[row] || '';
const lineCodePoints = toCodePoints(currentLine);
// Check if we're at the end of a word (on the last base character)
const atEndOfWord =
col < lineCodePoints.length &&
isWordCharStrict(lineCodePoints[col]) &&
(col + 1 >= lineCodePoints.length ||
!isWordCharWithCombining(lineCodePoints[col + 1]) ||
// Or if we're on a base char followed only by combining marks until non-word
(isWordCharStrict(lineCodePoints[col]) &&
isAtEndOfBaseWord(lineCodePoints, col)));
if (atEndOfWord) {
// We're already at end of word, find next word end
const nextWord = findNextWordAcrossLines(
lines,
row,
col + 1,
false,
);
if (nextWord) {
row = nextWord.row;
col = nextWord.col;
continue;
}
}
}
const wordEnd = findNextWordAcrossLines(lines, row, col, false);
if (wordEnd) {
row = wordEnd.row;
col = wordEnd.col;
} else {
break;
}
} }
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return { return {
...state, ...state,
cursorRow: startRow, cursorRow: row,
cursorCol: startCol, cursorCol: col,
preferredCol: null, preferredCol: null,
}; };
} }
@@ -783,7 +709,7 @@ export function handleVimAction(
let col = 0; let col = 0;
// Find first non-whitespace character using proper Unicode handling // Find first non-whitespace character using proper Unicode handling
const lineCodePoints = [...currentLine]; // Proper Unicode iteration const lineCodePoints = toCodePoints(currentLine);
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) { while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
col++; col++;
} }
@@ -820,7 +746,7 @@ export function handleVimAction(
let col = 0; let col = 0;
// Find first non-whitespace character using proper Unicode handling // Find first non-whitespace character using proper Unicode handling
const lineCodePoints = [...currentLine]; // Proper Unicode iteration const lineCodePoints = toCodePoints(currentLine);
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) { while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
col++; col++;
} }

View File

@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useContext } from 'react';
import { LoadedSettings } from '../../config/settings.js';
export const SettingsContext = React.createContext<LoadedSettings | undefined>(
undefined,
);
export const useSettings = () => {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
};

View File

@@ -8,6 +8,7 @@ import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { PartListUnion, PartUnion } from '@google/genai'; import { PartListUnion, PartUnion } from '@google/genai';
import { import {
AnyToolInvocation,
Config, Config,
getErrorMessage, getErrorMessage,
isNodeError, isNodeError,
@@ -254,7 +255,7 @@ export async function handleAtCommand({
`Path ${pathName} not found directly, attempting glob search.`, `Path ${pathName} not found directly, attempting glob search.`,
); );
try { try {
const globResult = await globTool.execute( const globResult = await globTool.buildAndExecute(
{ {
pattern: `**/*${pathName}*`, pattern: `**/*${pathName}*`,
path: dir, path: dir,
@@ -411,12 +412,14 @@ export async function handleAtCommand({
}; };
let toolCallDisplay: IndividualToolCallDisplay; let toolCallDisplay: IndividualToolCallDisplay;
let invocation: AnyToolInvocation | undefined = undefined;
try { try {
const result = await readManyFilesTool.execute(toolArgs, signal); invocation = readManyFilesTool.build(toolArgs);
const result = await invocation.execute(signal);
toolCallDisplay = { toolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`, callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName, name: readManyFilesTool.displayName,
description: readManyFilesTool.getDescription(toolArgs), description: invocation.getDescription(),
status: ToolCallStatus.Success, status: ToolCallStatus.Success,
resultDisplay: resultDisplay:
result.returnDisplay || result.returnDisplay ||
@@ -466,7 +469,9 @@ export async function handleAtCommand({
toolCallDisplay = { toolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`, callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName, name: readManyFilesTool.displayName,
description: readManyFilesTool.getDescription(toolArgs), description:
invocation?.getDescription() ??
'Error attempting to execute tool to read files',
status: ToolCallStatus.Error, status: ToolCallStatus.Error,
resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
confirmationDetails: undefined, confirmationDetails: undefined,

View File

@@ -60,6 +60,14 @@ vi.mock('../contexts/SessionContext.js', () => ({
useSessionStats: vi.fn(() => ({ stats: {} })), useSessionStats: vi.fn(() => ({ stats: {} })),
})); }));
const { mockRunExitCleanup } = vi.hoisted(() => ({
mockRunExitCleanup: vi.fn(),
}));
vi.mock('../../utils/cleanup.js', () => ({
runExitCleanup: mockRunExitCleanup,
}));
import { act, renderHook, waitFor } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { useSlashCommandProcessor } from './slashCommandProcessor.js'; import { useSlashCommandProcessor } from './slashCommandProcessor.js';
@@ -139,6 +147,7 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // toggleCorgiMode vi.fn(), // toggleCorgiMode
mockSetQuittingMessages, mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice vi.fn(), // openPrivacyNotice
vi.fn(), // openSettingsDialog
vi.fn(), // toggleVimEnabled vi.fn(), // toggleVimEnabled
setIsProcessing, setIsProcessing,
), ),
@@ -405,6 +414,37 @@ describe('useSlashCommandProcessor', () => {
vi.useRealTimers(); vi.useRealTimers();
} }
}); });
it('should call runExitCleanup when handling a "quit" action', async () => {
const quitAction = vi
.fn()
.mockResolvedValue({ type: 'quit', messages: [] });
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = setupProcessorHook([command]);
await waitFor(() =>
expect(result.current.slashCommands).toHaveLength(1),
);
vi.useFakeTimers();
try {
await act(async () => {
await result.current.handleSlashCommand('/exit');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
});
expect(mockRunExitCleanup).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
}); });
it('should handle "submit_prompt" action returned from a file-based command', async () => { it('should handle "submit_prompt" action returned from a file-based command', async () => {
@@ -825,6 +865,9 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // toggleCorgiMode vi.fn(), // toggleCorgiMode
mockSetQuittingMessages, mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice vi.fn(), // openPrivacyNotice
vi.fn(), // openSettingsDialog
vi.fn(), // toggleVimEnabled
vi.fn().mockResolvedValue(false), // toggleVimEnabled vi.fn().mockResolvedValue(false), // toggleVimEnabled
vi.fn(), // setIsProcessing vi.fn(), // setIsProcessing
), ),

View File

@@ -18,6 +18,7 @@ import {
ToolConfirmationOutcome, ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { useSessionStats } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { import {
Message, Message,
MessageType, MessageType,
@@ -49,6 +50,7 @@ export const useSlashCommandProcessor = (
toggleCorgiMode: () => void, toggleCorgiMode: () => void,
setQuittingMessages: (message: HistoryItem[]) => void, setQuittingMessages: (message: HistoryItem[]) => void,
openPrivacyNotice: () => void, openPrivacyNotice: () => void,
openSettingsDialog: () => void,
toggleVimEnabled: () => Promise<boolean>, toggleVimEnabled: () => Promise<boolean>,
setIsProcessing: (isProcessing: boolean) => void, setIsProcessing: (isProcessing: boolean) => void,
setGeminiMdFileCount: (count: number) => void, setGeminiMdFileCount: (count: number) => void,
@@ -63,6 +65,11 @@ export const useSlashCommandProcessor = (
approvedCommands?: string[], approvedCommands?: string[],
) => void; ) => void;
}>(null); }>(null);
const [confirmationRequest, setConfirmationRequest] = useState<null | {
prompt: React.ReactNode;
onConfirm: (confirmed: boolean) => void;
}>(null);
const [sessionShellAllowlist, setSessionShellAllowlist] = useState( const [sessionShellAllowlist, setSessionShellAllowlist] = useState(
new Set<string>(), new Set<string>(),
); );
@@ -221,6 +228,7 @@ export const useSlashCommandProcessor = (
async ( async (
rawQuery: PartListUnion, rawQuery: PartListUnion,
oneTimeShellAllowlist?: Set<string>, oneTimeShellAllowlist?: Set<string>,
overwriteConfirmed?: boolean,
): Promise<SlashCommandProcessorResult | false> => { ): Promise<SlashCommandProcessorResult | false> => {
setIsProcessing(true); setIsProcessing(true);
try { try {
@@ -300,6 +308,7 @@ export const useSlashCommandProcessor = (
name: commandToExecute.name, name: commandToExecute.name,
args, args,
}, },
overwriteConfirmed,
}; };
// If a one-time list is provided for a "Proceed" action, temporarily // If a one-time list is provided for a "Proceed" action, temporarily
@@ -353,6 +362,11 @@ export const useSlashCommandProcessor = (
case 'privacy': case 'privacy':
openPrivacyNotice(); openPrivacyNotice();
return { type: 'handled' }; return { type: 'handled' };
case 'settings':
openSettingsDialog();
return { type: 'handled' };
case 'help':
return { type: 'handled' };
default: { default: {
const unhandled: never = result.dialog; const unhandled: never = result.dialog;
throw new Error( throw new Error(
@@ -372,7 +386,8 @@ export const useSlashCommandProcessor = (
} }
case 'quit': case 'quit':
setQuittingMessages(result.messages); setQuittingMessages(result.messages);
setTimeout(() => { setTimeout(async () => {
await runExitCleanup();
process.exit(0); process.exit(0);
}, 100); }, 100);
return { type: 'handled' }; return { type: 'handled' };
@@ -422,6 +437,36 @@ export const useSlashCommandProcessor = (
new Set(approvedCommands), new Set(approvedCommands),
); );
} }
case 'confirm_action': {
const { confirmed } = await new Promise<{
confirmed: boolean;
}>((resolve) => {
setConfirmationRequest({
prompt: result.prompt,
onConfirm: (resolvedConfirmed) => {
setConfirmationRequest(null);
resolve({ confirmed: resolvedConfirmed });
},
});
});
if (!confirmed) {
addItem(
{
type: MessageType.INFO,
text: 'Operation cancelled.',
},
Date.now(),
);
return { type: 'handled' };
}
return await handleSlashCommand(
result.originalInvocation.raw,
undefined,
true,
);
}
default: { default: {
const unhandled: never = result; const unhandled: never = result;
throw new Error( throw new Error(
@@ -475,9 +520,11 @@ export const useSlashCommandProcessor = (
openPrivacyNotice, openPrivacyNotice,
openEditorDialog, openEditorDialog,
setQuittingMessages, setQuittingMessages,
openSettingsDialog,
setShellConfirmationRequest, setShellConfirmationRequest,
setSessionShellAllowlist, setSessionShellAllowlist,
setIsProcessing, setIsProcessing,
setConfirmationRequest,
], ],
); );
@@ -487,5 +534,6 @@ export const useSlashCommandProcessor = (
pendingHistoryItems, pendingHistoryItems,
commandContext, commandContext,
shellConfirmationRequest, shellConfirmationRequest,
confirmationRequest,
}; };
}; };

View File

@@ -50,6 +50,7 @@ describe('useAtCompletion', () => {
respectGitIgnore: true, respectGitIgnore: true,
respectGeminiIgnore: true, respectGeminiIgnore: true,
})), })),
getEnableRecursiveFileSearch: () => true,
} as unknown as Config; } as unknown as Config;
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -113,8 +114,8 @@ describe('useAtCompletion', () => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([ expect(result.current.suggestions.map((s) => s.value)).toEqual([
'src/', 'src/',
'src/components/', 'src/components/',
'src/components/Button.tsx',
'src/index.js', 'src/index.js',
'src/components/Button.tsx',
]); ]);
}); });
@@ -156,7 +157,7 @@ describe('useAtCompletion', () => {
}); });
}); });
it('should NOT show a loading indicator for subsequent searches that complete under 100ms', async () => { it('should NOT show a loading indicator for subsequent searches that complete under 200ms', async () => {
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
testRootDir = await createTmpDir(structure); testRootDir = await createTmpDir(structure);
@@ -185,7 +186,7 @@ describe('useAtCompletion', () => {
expect(result.current.isLoadingSuggestions).toBe(false); expect(result.current.isLoadingSuggestions).toBe(false);
}); });
it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 100ms', async () => { it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 200ms', async () => {
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
testRootDir = await createTmpDir(structure); testRootDir = await createTmpDir(structure);
@@ -193,7 +194,7 @@ describe('useAtCompletion', () => {
const originalSearch = FileSearch.prototype.search; const originalSearch = FileSearch.prototype.search;
vi.spyOn(FileSearch.prototype, 'search').mockImplementation( vi.spyOn(FileSearch.prototype, 'search').mockImplementation(
async function (...args) { async function (...args) {
await new Promise((resolve) => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 300));
return originalSearch.apply(this, args); return originalSearch.apply(this, args);
}, },
); );
@@ -283,6 +284,61 @@ describe('useAtCompletion', () => {
}); });
}); });
describe('State Management', () => {
it('should reset the state when disabled after being in a READY state', async () => {
const structure: FileSystemStructure = { 'a.txt': '' };
testRootDir = await createTmpDir(structure);
const { result, rerender } = renderHook(
({ enabled }) =>
useTestHarnessForAtCompletion(enabled, 'a', mockConfig, testRootDir),
{ initialProps: { enabled: true } },
);
// Wait for the hook to be ready and have suggestions
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'a.txt',
]);
});
// Now, disable the hook
rerender({ enabled: false });
// The suggestions should be cleared immediately because of the RESET action
expect(result.current.suggestions).toEqual([]);
});
it('should reset the state when disabled after being in an ERROR state', async () => {
testRootDir = await createTmpDir({});
// Force an error during initialization
vi.spyOn(FileSearch.prototype, 'initialize').mockRejectedValueOnce(
new Error('Initialization failed'),
);
const { result, rerender } = renderHook(
({ enabled }) =>
useTestHarnessForAtCompletion(enabled, '', mockConfig, testRootDir),
{ initialProps: { enabled: true } },
);
// Wait for the hook to enter the error state
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
expect(result.current.suggestions).toEqual([]); // No suggestions on error
// Now, disable the hook
rerender({ enabled: false });
// The state should still be reset (though visually it's the same)
// We can't directly inspect the internal state, but we can ensure it doesn't crash
// and the suggestions remain empty.
expect(result.current.suggestions).toEqual([]);
});
});
describe('Filtering and Configuration', () => { describe('Filtering and Configuration', () => {
it('should respect .gitignore files', async () => { it('should respect .gitignore files', async () => {
const gitignoreContent = ['dist/', '*.log'].join('\n'); const gitignoreContent = ['dist/', '*.log'].join('\n');
@@ -376,5 +432,42 @@ describe('useAtCompletion', () => {
await cleanupTmpDir(rootDir1); await cleanupTmpDir(rootDir1);
await cleanupTmpDir(rootDir2); await cleanupTmpDir(rootDir2);
}); });
it('should perform a non-recursive search when enableRecursiveFileSearch is false', async () => {
const structure: FileSystemStructure = {
'file.txt': '',
src: {
'index.js': '',
},
};
testRootDir = await createTmpDir(structure);
const nonRecursiveConfig = {
getEnableRecursiveFileSearch: () => false,
getFileFilteringOptions: vi.fn(() => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
})),
} as unknown as Config;
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(
true,
'',
nonRecursiveConfig,
testRootDir,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
// Should only contain top-level items
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'src/',
'file.txt',
]);
});
}); });
}); });

View File

@@ -165,6 +165,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true, config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true,
cache: true, cache: true,
cacheTtl: 30, // 30 seconds cacheTtl: 30, // 30 seconds
maxDepth: !(config?.getEnableRecursiveFileSearch() ?? true)
? 0
: undefined,
}); });
await searcher.initialize(); await searcher.initialize();
fileSearch.current = searcher; fileSearch.current = searcher;
@@ -191,7 +194,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
slowSearchTimer.current = setTimeout(() => { slowSearchTimer.current = setTimeout(() => {
dispatch({ type: 'SET_LOADING', payload: true }); dispatch({ type: 'SET_LOADING', payload: true });
}, 100); }, 200);
try { try {
const results = await fileSearch.current.search(state.pattern, { const results = await fileSearch.current.search(state.pattern, {

View File

@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest';
import { useFolderTrust } from './useFolderTrust.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
describe('useFolderTrust', () => {
it('should set isFolderTrustDialogOpen to true when folderTrustFeature is true and folderTrust is undefined', () => {
const settings = {
merged: {
folderTrustFeature: true,
folderTrust: undefined,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
expect(result.current.isFolderTrustDialogOpen).toBe(true);
});
it('should set isFolderTrustDialogOpen to false when folderTrustFeature is false', () => {
const settings = {
merged: {
folderTrustFeature: false,
folderTrust: undefined,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should set isFolderTrustDialogOpen to false when folderTrust is defined', () => {
const settings = {
merged: {
folderTrustFeature: true,
folderTrust: true,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should call setValue and set isFolderTrustDialogOpen to false on handleFolderTrustSelect', () => {
const settings = {
merged: {
folderTrustFeature: true,
folderTrust: undefined,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
});
expect(settings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'folderTrust',
true,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
});

View File

@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
export const useFolderTrust = (settings: LoadedSettings) => {
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(
!!settings.merged.folderTrustFeature &&
// TODO: Update to avoid showing dialog for folders that are trusted.
settings.merged.folderTrust === undefined,
);
const handleFolderTrustSelect = useCallback(
(_choice: FolderTrustChoice) => {
// TODO: Store folderPath in the trusted folders config file based on the choice.
settings.setValue(SettingScope.User, 'folderTrust', true);
setIsFolderTrustDialogOpen(false);
},
[settings],
);
return {
isFolderTrustDialogOpen,
handleFolderTrustSelect,
};
};

View File

@@ -21,6 +21,7 @@ import {
EditorType, EditorType,
AuthType, AuthType,
GeminiEventType as ServerGeminiEventType, GeminiEventType as ServerGeminiEventType,
AnyToolInvocation,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { Part, PartListUnion } from '@google/genai'; import { Part, PartListUnion } from '@google/genai';
import { UseHistoryManagerReturn } from './useHistoryManager.js'; import { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -405,6 +406,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
); );
}, },
{ {
@@ -453,9 +456,13 @@ describe('useGeminiStream', () => {
}, },
tool: { tool: {
name: 'tool1', name: 'tool1',
displayName: 'tool1',
description: 'desc1', description: 'desc1',
getDescription: vi.fn(), build: vi.fn(),
} as any, } as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
startTime: Date.now(), startTime: Date.now(),
endTime: Date.now(), endTime: Date.now(),
} as TrackedCompletedToolCall, } as TrackedCompletedToolCall,
@@ -470,9 +477,13 @@ describe('useGeminiStream', () => {
responseSubmittedToGemini: false, responseSubmittedToGemini: false,
tool: { tool: {
name: 'tool2', name: 'tool2',
displayName: 'tool2',
description: 'desc2', description: 'desc2',
getDescription: vi.fn(), build: vi.fn(),
} as any, } as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
startTime: Date.now(), startTime: Date.now(),
liveOutput: '...', liveOutput: '...',
} as TrackedExecutingToolCall, } as TrackedExecutingToolCall,
@@ -507,6 +518,12 @@ describe('useGeminiStream', () => {
status: 'success', status: 'success',
responseSubmittedToGemini: false, responseSubmittedToGemini: false,
response: { callId: 'call1', responseParts: toolCall1ResponseParts }, response: { callId: 'call1', responseParts: toolCall1ResponseParts },
tool: {
displayName: 'MockTool',
},
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as TrackedCompletedToolCall, } as TrackedCompletedToolCall,
{ {
request: { request: {
@@ -546,6 +563,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -585,6 +604,12 @@ describe('useGeminiStream', () => {
status: 'cancelled', status: 'cancelled',
response: { callId: '1', responseParts: [{ text: 'cancelled' }] }, response: { callId: '1', responseParts: [{ text: 'cancelled' }] },
responseSubmittedToGemini: false, responseSubmittedToGemini: false,
tool: {
displayName: 'mock tool',
},
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as TrackedCancelledToolCall, } as TrackedCancelledToolCall,
]; ];
const client = new MockedGeminiClientClass(mockConfig); const client = new MockedGeminiClientClass(mockConfig);
@@ -613,6 +638,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -645,9 +672,13 @@ describe('useGeminiStream', () => {
}, },
tool: { tool: {
name: 'toolA', name: 'toolA',
displayName: 'toolA',
description: 'descA', description: 'descA',
getDescription: vi.fn(), build: vi.fn(),
} as any, } as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
status: 'cancelled', status: 'cancelled',
response: { response: {
callId: 'cancel-1', callId: 'cancel-1',
@@ -670,9 +701,13 @@ describe('useGeminiStream', () => {
}, },
tool: { tool: {
name: 'toolB', name: 'toolB',
displayName: 'toolB',
description: 'descB', description: 'descB',
getDescription: vi.fn(), build: vi.fn(),
} as any, } as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
status: 'cancelled', status: 'cancelled',
response: { response: {
callId: 'cancel-2', callId: 'cancel-2',
@@ -711,6 +746,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -763,9 +800,13 @@ describe('useGeminiStream', () => {
responseSubmittedToGemini: false, responseSubmittedToGemini: false,
tool: { tool: {
name: 'tool1', name: 'tool1',
displayName: 'tool1',
description: 'desc', description: 'desc',
getDescription: vi.fn(), build: vi.fn(),
} as any, } as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
startTime: Date.now(), startTime: Date.now(),
} as TrackedExecutingToolCall, } as TrackedExecutingToolCall,
]; ];
@@ -814,6 +855,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -914,6 +957,44 @@ describe('useGeminiStream', () => {
expect(result.current.streamingState).toBe(StreamingState.Idle); expect(result.current.streamingState).toBe(StreamingState.Idle);
}); });
it('should call onCancelSubmit handler when escape is pressed', async () => {
const cancelSubmitSpy = vi.fn();
const mockStream = (async function* () {
yield { type: 'content', value: 'Part 1' };
// Keep the stream open
await new Promise(() => {});
})();
mockSendMessageStream.mockReturnValue(mockStream);
const { result } = renderHook(() =>
useGeminiStream(
mockConfig.getGeminiClient(),
[],
mockAddItem,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
cancelSubmitSpy,
),
);
// Start a query
await act(async () => {
result.current.submitQuery('test query');
});
simulateEscapeKeyPress();
expect(cancelSubmitSpy).toHaveBeenCalled();
});
it('should not do anything if escape is pressed when not responding', () => { it('should not do anything if escape is pressed when not responding', () => {
const { result } = renderTestHook(); const { result } = renderTestHook();
@@ -984,8 +1065,13 @@ describe('useGeminiStream', () => {
tool: { tool: {
name: 'tool1', name: 'tool1',
description: 'desc1', description: 'desc1',
getDescription: vi.fn(), build: vi.fn().mockImplementation((_) => ({
getDescription: () => `Mock description`,
})),
} as any, } as any,
invocation: {
getDescription: () => `Mock description`,
},
startTime: Date.now(), startTime: Date.now(),
liveOutput: '...', liveOutput: '...',
} as TrackedExecutingToolCall, } as TrackedExecutingToolCall,
@@ -1136,9 +1222,13 @@ describe('useGeminiStream', () => {
}, },
tool: { tool: {
name: 'save_memory', name: 'save_memory',
displayName: 'save_memory',
description: 'Saves memory', description: 'Saves memory',
getDescription: vi.fn(), build: vi.fn(),
} as any, } as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
}; };
// Capture the onComplete callback // Capture the onComplete callback
@@ -1165,6 +1255,8 @@ describe('useGeminiStream', () => {
mockPerformMemoryRefresh, mockPerformMemoryRefresh,
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -1216,6 +1308,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -1264,6 +1358,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -1310,6 +1406,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -1357,6 +1455,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -1444,6 +1544,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -1498,6 +1600,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -1574,6 +1678,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );
@@ -1626,6 +1732,8 @@ describe('useGeminiStream', () => {
() => Promise.resolve(), () => Promise.resolve(),
false, false,
() => {}, () => {},
() => {},
() => {},
), ),
); );

View File

@@ -94,6 +94,7 @@ export const useGeminiStream = (
modelSwitchedFromQuotaError: boolean, modelSwitchedFromQuotaError: boolean,
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>, setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onEditorClose: () => void, onEditorClose: () => void,
onCancelSubmit: () => void,
) => { ) => {
const [initError, setInitError] = useState<string | null>(null); const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@@ -183,25 +184,39 @@ export const useGeminiStream = (
return StreamingState.Idle; return StreamingState.Idle;
}, [isResponding, toolCalls]); }, [isResponding, toolCalls]);
const cancelOngoingRequest = useCallback(() => {
if (streamingState !== StreamingState.Responding) {
return;
}
if (turnCancelledRef.current) {
return;
}
turnCancelledRef.current = true;
abortControllerRef.current?.abort();
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, Date.now());
}
addItem(
{
type: MessageType.INFO,
text: 'Request cancelled.',
},
Date.now(),
);
setPendingHistoryItem(null);
onCancelSubmit();
setIsResponding(false);
}, [
streamingState,
addItem,
setPendingHistoryItem,
onCancelSubmit,
pendingHistoryItemRef,
]);
useInput((_input, key) => { useInput((_input, key) => {
if (streamingState === StreamingState.Responding && key.escape) { if (key.escape) {
if (turnCancelledRef.current) { cancelOngoingRequest();
return;
}
turnCancelledRef.current = true;
abortControllerRef.current?.abort();
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, Date.now());
}
addItem(
{
type: MessageType.INFO,
text: 'Request cancelled.',
},
Date.now(),
);
setPendingHistoryItem(null);
setIsResponding(false);
} }
}); });
@@ -985,5 +1000,6 @@ export const useGeminiStream = (
initError, initError,
pendingHistoryItems, pendingHistoryItems,
thought, thought,
cancelOngoingRequest,
}; };
}; };

View File

@@ -43,7 +43,6 @@ export const WITTY_LOADING_PHRASES = [
'Garbage collecting... be right back...', 'Garbage collecting... be right back...',
'Assembling the interwebs...', 'Assembling the interwebs...',
'Converting coffee into code...', 'Converting coffee into code...',
'Pushing to production (and hoping for the best)...',
'Updating the syntax for reality...', 'Updating the syntax for reality...',
'Rewiring the synapses...', 'Rewiring the synapses...',
'Looking for a misplaced semicolon...', 'Looking for a misplaced semicolon...',
@@ -99,7 +98,7 @@ export const WITTY_LOADING_PHRASES = [
'Why did the computer go to therapy? It had too many bytes...', 'Why did the computer go to therapy? It had too many bytes...',
"Why don't programmers like nature? It has too many bugs...", "Why don't programmers like nature? It has too many bugs...",
'Why do programmers prefer dark mode? Because light attracts bugs...', 'Why do programmers prefer dark mode? Because light attracts bugs...',
'Why did the developer go broke? Because he used up all his cache...', 'Why did the developer go broke? Because they used up all their cache...',
"What can you do with a broken pencil? Nothing, it's pointless...", "What can you do with a broken pencil? Nothing, it's pointless...",
'Applying percussive maintenance...', 'Applying percussive maintenance...',
'Searching for the correct USB orientation...', 'Searching for the correct USB orientation...',
@@ -136,6 +135,7 @@ export const WITTY_LOADING_PHRASES = [
"It's not a bug, it's a feature... of this loading screen.", "It's not a bug, it's a feature... of this loading screen.",
'Have you tried turning it off and on again? (The loading screen, not me.)', 'Have you tried turning it off and on again? (The loading screen, not me.)',
'Constructing additional pylons...', 'Constructing additional pylons...',
'New line? Thats Ctrl+J.',
]; ];
export const PHRASE_CHANGE_INTERVAL_MS = 15000; export const PHRASE_CHANGE_INTERVAL_MS = 15000;

View File

@@ -17,7 +17,6 @@ import {
OutputUpdateHandler, OutputUpdateHandler,
AllToolCallsCompleteHandler, AllToolCallsCompleteHandler,
ToolCallsUpdateHandler, ToolCallsUpdateHandler,
Tool,
ToolCall, ToolCall,
Status as CoreStatus, Status as CoreStatus,
EditorType, EditorType,
@@ -64,7 +63,7 @@ export type TrackedToolCall =
| TrackedCancelledToolCall; | TrackedCancelledToolCall;
export function useReactToolScheduler( export function useReactToolScheduler(
onComplete: (tools: CompletedToolCall[]) => void, onComplete: (tools: CompletedToolCall[]) => Promise<void>,
config: Config, config: Config,
setPendingHistoryItem: React.Dispatch< setPendingHistoryItem: React.Dispatch<
React.SetStateAction<HistoryItemWithoutId | null> React.SetStateAction<HistoryItemWithoutId | null>
@@ -107,8 +106,8 @@ export function useReactToolScheduler(
); );
const allToolCallsCompleteHandler: AllToolCallsCompleteHandler = useCallback( const allToolCallsCompleteHandler: AllToolCallsCompleteHandler = useCallback(
(completedToolCalls) => { async (completedToolCalls) => {
onComplete(completedToolCalls); await onComplete(completedToolCalls);
}, },
[onComplete], [onComplete],
); );
@@ -158,7 +157,7 @@ export function useReactToolScheduler(
request: ToolCallRequestInfo | ToolCallRequestInfo[], request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal, signal: AbortSignal,
) => { ) => {
scheduler.schedule(request, signal); void scheduler.schedule(request, signal);
}, },
[scheduler], [scheduler],
); );
@@ -216,23 +215,20 @@ export function mapToDisplay(
const toolDisplays = toolCalls.map( const toolDisplays = toolCalls.map(
(trackedCall): IndividualToolCallDisplay => { (trackedCall): IndividualToolCallDisplay => {
let displayName = trackedCall.request.name; let displayName: string;
let description = ''; let description: string;
let renderOutputAsMarkdown = false; let renderOutputAsMarkdown = false;
const currentToolInstance = if (trackedCall.status === 'error') {
'tool' in trackedCall && trackedCall.tool displayName =
? (trackedCall as { tool: Tool }).tool trackedCall.tool === undefined
: undefined; ? trackedCall.request.name
: trackedCall.tool.displayName;
if (currentToolInstance) {
displayName = currentToolInstance.displayName;
description = currentToolInstance.getDescription(
trackedCall.request.args,
);
renderOutputAsMarkdown = currentToolInstance.isOutputMarkdown;
} else if ('request' in trackedCall && 'args' in trackedCall.request) {
description = JSON.stringify(trackedCall.request.args); description = JSON.stringify(trackedCall.request.args);
} else {
displayName = trackedCall.tool.displayName;
description = trackedCall.invocation.getDescription();
renderOutputAsMarkdown = trackedCall.tool.isOutputMarkdown;
} }
const baseDisplayProperties: Omit< const baseDisplayProperties: Omit<
@@ -256,7 +252,6 @@ export function mapToDisplay(
case 'error': case 'error':
return { return {
...baseDisplayProperties, ...baseDisplayProperties,
name: currentToolInstance?.displayName ?? trackedCall.request.name,
status: mapCoreStatusToDisplayStatus(trackedCall.status), status: mapCoreStatusToDisplayStatus(trackedCall.status),
resultDisplay: trackedCall.response.resultDisplay, resultDisplay: trackedCall.response.resultDisplay,
confirmationDetails: undefined, confirmationDetails: undefined,

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
export function useSettingsCommand() {
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
const openSettingsDialog = useCallback(() => {
setIsSettingsDialogOpen(true);
}, []);
const closeSettingsDialog = useCallback(() => {
setIsSettingsDialogOpen(false);
}, []);
return {
isSettingsDialogOpen,
openSettingsDialog,
closeSettingsDialog,
};
}

View File

@@ -15,7 +15,6 @@ import { PartUnion, FunctionResponse } from '@google/genai';
import { import {
Config, Config,
ToolCallRequestInfo, ToolCallRequestInfo,
Tool,
ToolRegistry, ToolRegistry,
ToolResult, ToolResult,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
@@ -25,6 +24,9 @@ import {
Status as ToolCallStatusType, Status as ToolCallStatusType,
ApprovalMode, ApprovalMode,
Icon, Icon,
BaseTool,
AnyDeclarativeTool,
AnyToolInvocation,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { import {
HistoryItemWithoutId, HistoryItemWithoutId,
@@ -53,46 +55,55 @@ const mockConfig = {
getDebugMode: () => false, getDebugMode: () => false,
}; };
const mockTool: Tool = { class MockTool extends BaseTool<object, ToolResult> {
name: 'mockTool', constructor(
displayName: 'Mock Tool', name: string,
description: 'A mock tool for testing', displayName: string,
icon: Icon.Hammer, canUpdateOutput = false,
toolLocations: vi.fn(), shouldConfirm = false,
isOutputMarkdown: false, isOutputMarkdown = false,
canUpdateOutput: false, ) {
schema: {}, super(
validateToolParams: vi.fn(), name,
execute: vi.fn(), displayName,
shouldConfirmExecute: vi.fn(), 'A mock tool for testing',
getDescription: vi.fn((args) => `Description for ${JSON.stringify(args)}`), Icon.Hammer,
}; {},
isOutputMarkdown,
canUpdateOutput,
);
if (shouldConfirm) {
this.shouldConfirmExecute = vi.fn(
async (): Promise<ToolCallConfirmationDetails | false> => ({
type: 'edit',
title: 'Mock Tool Requires Confirmation',
onConfirm: mockOnUserConfirmForToolConfirmation,
fileName: 'mockToolRequiresConfirmation.ts',
fileDiff: 'Mock tool requires confirmation',
originalContent: 'Original content',
newContent: 'New content',
}),
);
}
}
const mockToolWithLiveOutput: Tool = { execute = vi.fn();
...mockTool, shouldConfirmExecute = vi.fn();
name: 'mockToolWithLiveOutput', }
displayName: 'Mock Tool With Live Output',
canUpdateOutput: true,
};
const mockTool = new MockTool('mockTool', 'Mock Tool');
const mockToolWithLiveOutput = new MockTool(
'mockToolWithLiveOutput',
'Mock Tool With Live Output',
true,
);
let mockOnUserConfirmForToolConfirmation: Mock; let mockOnUserConfirmForToolConfirmation: Mock;
const mockToolRequiresConfirmation = new MockTool(
const mockToolRequiresConfirmation: Tool = { 'mockToolRequiresConfirmation',
...mockTool, 'Mock Tool Requires Confirmation',
name: 'mockToolRequiresConfirmation', false,
displayName: 'Mock Tool Requires Confirmation', true,
shouldConfirmExecute: vi.fn( );
async (): Promise<ToolCallConfirmationDetails | false> => ({
type: 'edit',
title: 'Mock Tool Requires Confirmation',
onConfirm: mockOnUserConfirmForToolConfirmation,
fileName: 'mockToolRequiresConfirmation.ts',
fileDiff: 'Mock tool requires confirmation',
originalContent: 'Original content',
newContent: 'New content',
}),
),
};
describe('useReactToolScheduler in YOLO Mode', () => { describe('useReactToolScheduler in YOLO Mode', () => {
let onComplete: Mock; let onComplete: Mock;
@@ -646,28 +657,21 @@ describe('useReactToolScheduler', () => {
}); });
it('should schedule and execute multiple tool calls', async () => { it('should schedule and execute multiple tool calls', async () => {
const tool1 = { const tool1 = new MockTool('tool1', 'Tool 1');
...mockTool, tool1.execute.mockResolvedValue({
name: 'tool1', llmContent: 'Output 1',
displayName: 'Tool 1', returnDisplay: 'Display 1',
execute: vi.fn().mockResolvedValue({ summary: 'Summary 1',
llmContent: 'Output 1', } as ToolResult);
returnDisplay: 'Display 1', tool1.shouldConfirmExecute.mockResolvedValue(null);
summary: 'Summary 1',
} as ToolResult), const tool2 = new MockTool('tool2', 'Tool 2');
shouldConfirmExecute: vi.fn().mockResolvedValue(null), tool2.execute.mockResolvedValue({
}; llmContent: 'Output 2',
const tool2 = { returnDisplay: 'Display 2',
...mockTool, summary: 'Summary 2',
name: 'tool2', } as ToolResult);
displayName: 'Tool 2', tool2.shouldConfirmExecute.mockResolvedValue(null);
execute: vi.fn().mockResolvedValue({
llmContent: 'Output 2',
returnDisplay: 'Display 2',
summary: 'Summary 2',
} as ToolResult),
shouldConfirmExecute: vi.fn().mockResolvedValue(null),
};
mockToolRegistry.getTool.mockImplementation((name) => { mockToolRegistry.getTool.mockImplementation((name) => {
if (name === 'tool1') return tool1; if (name === 'tool1') return tool1;
@@ -805,20 +809,7 @@ describe('mapToDisplay', () => {
args: { foo: 'bar' }, args: { foo: 'bar' },
}; };
const baseTool: Tool = { const baseTool = new MockTool('testTool', 'Test Tool Display');
name: 'testTool',
displayName: 'Test Tool Display',
description: 'Test Description',
isOutputMarkdown: false,
canUpdateOutput: false,
schema: {},
icon: Icon.Hammer,
toolLocations: vi.fn(),
validateToolParams: vi.fn(),
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
getDescription: vi.fn((args) => `Desc: ${JSON.stringify(args)}`),
};
const baseResponse: ToolCallResponseInfo = { const baseResponse: ToolCallResponseInfo = {
callId: 'testCallId', callId: 'testCallId',
@@ -840,13 +831,15 @@ describe('mapToDisplay', () => {
// This helps ensure that tool and confirmationDetails are only accessed when they are expected to exist. // This helps ensure that tool and confirmationDetails are only accessed when they are expected to exist.
type MapToDisplayExtraProps = type MapToDisplayExtraProps =
| { | {
tool?: Tool; tool?: AnyDeclarativeTool;
invocation?: AnyToolInvocation;
liveOutput?: string; liveOutput?: string;
response?: ToolCallResponseInfo; response?: ToolCallResponseInfo;
confirmationDetails?: ToolCallConfirmationDetails; confirmationDetails?: ToolCallConfirmationDetails;
} }
| { | {
tool: Tool; tool: AnyDeclarativeTool;
invocation?: AnyToolInvocation;
response?: ToolCallResponseInfo; response?: ToolCallResponseInfo;
confirmationDetails?: ToolCallConfirmationDetails; confirmationDetails?: ToolCallConfirmationDetails;
} }
@@ -857,10 +850,12 @@ describe('mapToDisplay', () => {
} }
| { | {
confirmationDetails: ToolCallConfirmationDetails; confirmationDetails: ToolCallConfirmationDetails;
tool?: Tool; tool?: AnyDeclarativeTool;
invocation?: AnyToolInvocation;
response?: ToolCallResponseInfo; response?: ToolCallResponseInfo;
}; };
const baseInvocation = baseTool.build(baseRequest.args);
const testCases: Array<{ const testCases: Array<{
name: string; name: string;
status: ToolCallStatusType; status: ToolCallStatusType;
@@ -873,7 +868,7 @@ describe('mapToDisplay', () => {
{ {
name: 'validating', name: 'validating',
status: 'validating', status: 'validating',
extraProps: { tool: baseTool }, extraProps: { tool: baseTool, invocation: baseInvocation },
expectedStatus: ToolCallStatus.Executing, expectedStatus: ToolCallStatus.Executing,
expectedName: baseTool.displayName, expectedName: baseTool.displayName,
expectedDescription: baseTool.getDescription(baseRequest.args), expectedDescription: baseTool.getDescription(baseRequest.args),
@@ -883,6 +878,7 @@ describe('mapToDisplay', () => {
status: 'awaiting_approval', status: 'awaiting_approval',
extraProps: { extraProps: {
tool: baseTool, tool: baseTool,
invocation: baseInvocation,
confirmationDetails: { confirmationDetails: {
onConfirm: vi.fn(), onConfirm: vi.fn(),
type: 'edit', type: 'edit',
@@ -903,7 +899,7 @@ describe('mapToDisplay', () => {
{ {
name: 'scheduled', name: 'scheduled',
status: 'scheduled', status: 'scheduled',
extraProps: { tool: baseTool }, extraProps: { tool: baseTool, invocation: baseInvocation },
expectedStatus: ToolCallStatus.Pending, expectedStatus: ToolCallStatus.Pending,
expectedName: baseTool.displayName, expectedName: baseTool.displayName,
expectedDescription: baseTool.getDescription(baseRequest.args), expectedDescription: baseTool.getDescription(baseRequest.args),
@@ -911,7 +907,7 @@ describe('mapToDisplay', () => {
{ {
name: 'executing no live output', name: 'executing no live output',
status: 'executing', status: 'executing',
extraProps: { tool: baseTool }, extraProps: { tool: baseTool, invocation: baseInvocation },
expectedStatus: ToolCallStatus.Executing, expectedStatus: ToolCallStatus.Executing,
expectedName: baseTool.displayName, expectedName: baseTool.displayName,
expectedDescription: baseTool.getDescription(baseRequest.args), expectedDescription: baseTool.getDescription(baseRequest.args),
@@ -919,7 +915,11 @@ describe('mapToDisplay', () => {
{ {
name: 'executing with live output', name: 'executing with live output',
status: 'executing', status: 'executing',
extraProps: { tool: baseTool, liveOutput: 'Live test output' }, extraProps: {
tool: baseTool,
invocation: baseInvocation,
liveOutput: 'Live test output',
},
expectedStatus: ToolCallStatus.Executing, expectedStatus: ToolCallStatus.Executing,
expectedResultDisplay: 'Live test output', expectedResultDisplay: 'Live test output',
expectedName: baseTool.displayName, expectedName: baseTool.displayName,
@@ -928,7 +928,11 @@ describe('mapToDisplay', () => {
{ {
name: 'success', name: 'success',
status: 'success', status: 'success',
extraProps: { tool: baseTool, response: baseResponse }, extraProps: {
tool: baseTool,
invocation: baseInvocation,
response: baseResponse,
},
expectedStatus: ToolCallStatus.Success, expectedStatus: ToolCallStatus.Success,
expectedResultDisplay: baseResponse.resultDisplay as any, expectedResultDisplay: baseResponse.resultDisplay as any,
expectedName: baseTool.displayName, expectedName: baseTool.displayName,
@@ -970,6 +974,7 @@ describe('mapToDisplay', () => {
status: 'cancelled', status: 'cancelled',
extraProps: { extraProps: {
tool: baseTool, tool: baseTool,
invocation: baseInvocation,
response: { response: {
...baseResponse, ...baseResponse,
resultDisplay: 'Cancelled display', resultDisplay: 'Cancelled display',
@@ -1030,12 +1035,21 @@ describe('mapToDisplay', () => {
request: { ...baseRequest, callId: 'call1' }, request: { ...baseRequest, callId: 'call1' },
status: 'success', status: 'success',
tool: baseTool, tool: baseTool,
invocation: baseTool.build(baseRequest.args),
response: { ...baseResponse, callId: 'call1' }, response: { ...baseResponse, callId: 'call1' },
} as ToolCall; } as ToolCall;
const toolForCall2 = new MockTool(
baseTool.name,
baseTool.displayName,
false,
false,
true,
);
const toolCall2: ToolCall = { const toolCall2: ToolCall = {
request: { ...baseRequest, callId: 'call2' }, request: { ...baseRequest, callId: 'call2' },
status: 'executing', status: 'executing',
tool: { ...baseTool, isOutputMarkdown: true }, tool: toolForCall2,
invocation: toolForCall2.build(baseRequest.args),
liveOutput: 'markdown output', liveOutput: 'markdown output',
} as ToolCall; } as ToolCall;

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