diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ab2e964..99b13777 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,14 @@ jobs: - name: Run linter 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 run: npm run build diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index 39b8e0bc..b9343033 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -2,7 +2,33 @@ name: Qwen Automated Issue Triage on: 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: triage-issue: @@ -28,30 +54,39 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} settings_json: | { + "maxSessionTurns": 25, "coreTools": [ + "run_shell_command(echo)", "run_shell_command(gh label list)", "run_shell_command(gh issue edit)", "run_shell_command(gh issue list)" ], "sandbox": false } - 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. - Steps: + prompt: |- + ## Role + + 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. - 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. 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"` - 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 doesn’t look like it has sufficient information recommend the status/need-information label - 9. Use Area definitions mentioned below to help you narrow down issues - Guidelines: + 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. + 8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label. + 9. Use Area definitions mentioned below to help you narrow down issues. + + ## Guidelines + - Only use labels that already exist in the repository. - Do not add comments or modify the issue content. - Triage only the current issue. - - Apply only one area/ label - - Apply only one kind/ label + - Apply only one area/ 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. - 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: @@ -130,3 +165,163 @@ jobs: - could also pertain to latency, - other general software performance like, memory usage, CPU consumption, and algorithmic efficiency. - 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 --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: `` + 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 ``. + - 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 --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). diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index 254f1cad..ac698dd7 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -3,7 +3,22 @@ name: Qwen Scheduled Issue Triage on: schedule: - 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: triage-issues: @@ -23,16 +38,19 @@ jobs: 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) - echo "🏷️ Finding issues that need triage..." - NEED_TRIAGE_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue label:\"status/need-triage\"" --json number,title,body) + 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)" - echo "🔄 Merging and deduplicating issues..." - ISSUES=$(echo "$NO_LABEL_ISSUES" "$NEED_TRIAGE_ISSUES" | jq -c -s 'add | unique_by(.number)') + echo '🏷️ Finding issues that need triage...' + 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 "issues_to_triage=$ISSUES" >> "$GITHUB_OUTPUT" + echo '🔄 Merging and deduplicating issues...' + 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 if: steps.find_issues.outputs.issues_to_triage != '[]' @@ -48,18 +66,25 @@ jobs: OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }} settings_json: | { + "maxSessionTurns": 25, "coreTools": [ "run_shell_command(echo)", "run_shell_command(gh label list)", "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 } - prompt: | - You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. - Steps: + prompt: |- + ## Role + + 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. 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. @@ -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"` - 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. - 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 remove labels titled help wanted or good first issue. - Triage only the current issue. diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..183356b0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["vitest.explorer", "esbenp.prettier-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 1e9031c3..25fad7e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" + } } diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 9af6e1eb..d8e2c532 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -99,6 +99,11 @@ Slash commands provide meta-level control over the CLI itself. - **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. +- **`/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`** - **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. diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 08a6d8a2..7b66cf08 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -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. - **Default:** `undefined` (web search disabled) - **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`: diff --git a/docs/integration-tests.md b/docs/integration-tests.md index 53ddd155..7a4c8489 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -109,10 +109,10 @@ To check for linting errors, run the following command: 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 -npm run lint --fix +npm run lint:fix ``` ## Directory structure diff --git a/docs/telemetry.md b/docs/telemetry.md index c7b88ba9..68c3aed2 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -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`. ```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 @@ -173,9 +183,10 @@ Logs are timestamped records of specific events. The following events are logged - `function_args` - `duration_ms` - `success` (boolean) - - `decision` (string: "accept", "reject", or "modify", if applicable) + - `decision` (string: "accept", "reject", "auto_accept", or "modify", if applicable) - `error` (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. - **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. - `mimetype` (string, if applicable): Mimetype 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. diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 850c228e..17138bae 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -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. + +## 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] [args...] +``` + +- ``: A unique name for the server. +- ``: 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 [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 + +# 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 + +# 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 +``` + +**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`). diff --git a/eslint.config.js b/eslint.config.js index b0d4af99..e123329e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,7 @@ export default tseslint.config( 'packages/vscode-ide-companion/dist/**', 'bundle/**', 'package/bundle/**', + '.integration-tests/**', ], }, eslint.configs.recommended, diff --git a/integration-tests/file-system.test.js b/integration-tests/file-system.test.ts similarity index 100% rename from integration-tests/file-system.test.js rename to integration-tests/file-system.test.ts diff --git a/integration-tests/list_directory.test.js b/integration-tests/list_directory.test.ts similarity index 94% rename from integration-tests/list_directory.test.js rename to integration-tests/list_directory.test.ts index 16f49f4b..023eca12 100644 --- a/integration-tests/list_directory.test.js +++ b/integration-tests/list_directory.test.ts @@ -21,8 +21,8 @@ test('should be able to list a directory', async () => { await rig.poll( () => { // Check if the files exist in the test directory - const file1Path = join(rig.testDir, 'file1.txt'); - const subdirPath = join(rig.testDir, 'subdir'); + const file1Path = join(rig.testDir!, 'file1.txt'); + const subdirPath = join(rig.testDir!, 'subdir'); return existsSync(file1Path) && existsSync(subdirPath); }, 1000, // 1 second max wait diff --git a/integration-tests/mcp_server_cyclic_schema.test.js b/integration-tests/mcp_server_cyclic_schema.test.js new file mode 100644 index 00000000..1ace98f1 --- /dev/null +++ b/integration-tests/mcp_server_cyclic_schema.test.js @@ -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/, + ); + }); +}); diff --git a/integration-tests/read_many_files.test.js b/integration-tests/read_many_files.test.ts similarity index 100% rename from integration-tests/read_many_files.test.js rename to integration-tests/read_many_files.test.ts diff --git a/integration-tests/replace.test.js b/integration-tests/replace.test.ts similarity index 100% rename from integration-tests/replace.test.js rename to integration-tests/replace.test.ts diff --git a/integration-tests/run-tests.js b/integration-tests/run-tests.js index 05fb349e..b33e1afa 100644 --- a/integration-tests/run-tests.js +++ b/integration-tests/run-tests.js @@ -52,13 +52,13 @@ async function main() { const testPatterns = args.length > 0 - ? args.map((arg) => `integration-tests/${arg}.test.js`) - : ['integration-tests/*.test.js']; + ? args.map((arg) => `integration-tests/${arg}.test.ts`) + : ['integration-tests/*.test.ts']; const testFiles = glob.sync(testPatterns, { cwd: rootDir, absolute: true }); for (const testFile of testFiles) { const testFileName = basename(testFile); - console.log(`\tFound test file: ${testFileName}`); + console.log(` Found test file: ${testFileName}`); } const MAX_RETRIES = 3; @@ -92,7 +92,7 @@ async function main() { } nodeArgs.push(testFile); - const child = spawn('node', nodeArgs, { + const child = spawn('npx', ['tsx', ...nodeArgs], { stdio: 'pipe', env: { ...process.env, diff --git a/integration-tests/run_shell_command.test.js b/integration-tests/run_shell_command.test.ts similarity index 100% rename from integration-tests/run_shell_command.test.js rename to integration-tests/run_shell_command.test.ts diff --git a/integration-tests/save_memory.test.js b/integration-tests/save_memory.test.ts similarity index 100% rename from integration-tests/save_memory.test.js rename to integration-tests/save_memory.test.ts diff --git a/integration-tests/simple-mcp-server.test.js b/integration-tests/simple-mcp-server.test.ts similarity index 96% rename from integration-tests/simple-mcp-server.test.js rename to integration-tests/simple-mcp-server.test.ts index 987f69d2..c4191078 100644 --- a/integration-tests/simple-mcp-server.test.js +++ b/integration-tests/simple-mcp-server.test.ts @@ -14,11 +14,8 @@ import { test, describe, before } from 'node:test'; import { strict as assert } from 'node:assert'; import { TestRig, validateModelOutput } 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 @@ -185,7 +182,7 @@ describe('simple-mcp-server', () => { }); // 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); // Make the script executable (though running with 'node' should work anyway) diff --git a/integration-tests/test-helper.js b/integration-tests/test-helper.ts similarity index 84% rename from integration-tests/test-helper.js rename to integration-tests/test-helper.ts index 6556c5c3..2bd067b4 100644 --- a/integration-tests/test-helper.js +++ b/integration-tests/test-helper.ts @@ -14,7 +14,7 @@ import { fileExists } from '../scripts/telemetry_utils.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -function sanitizeTestName(name) { +function sanitizeTestName(name: string) { return name .toLowerCase() .replace(/[^a-z0-9]/g, '-') @@ -22,7 +22,11 @@ function sanitizeTestName(name) { } // 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) ? expectedTools.join(' or ') : expectedTools; @@ -34,7 +38,11 @@ export function createToolCallErrorMessage(expectedTools, foundTools, result) { } // Helper to print debug information when tests fail -export function printDebugInfo(rig, result, context = {}) { +export function printDebugInfo( + rig: TestRig, + result: string, + context: Record = {}, +) { console.error('Test failed - Debug info:'); console.error('Result length:', result.length); 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 export function validateModelOutput( - result, - expectedContent = null, + result: string, + expectedContent: string | (string | RegExp)[] | null = null, testName = '', ) { // 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 { + bundlePath: string; + testDir: string | null; + testName?: string; + _lastRunStdout?: string; + constructor() { this.bundlePath = join(__dirname, '..', 'bundle/gemini.js'); this.testDir = null; @@ -114,10 +127,13 @@ export class TestRig { return 15000; // 15s locally } - setup(testName, options = {}) { + setup( + testName: string, + options: { settings?: Record } = {}, + ) { this.testName = 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 }); // Create a settings file to point the CLI to the local collector @@ -146,36 +162,43 @@ export class TestRig { ); } - createFile(fileName, content) { - const filePath = join(this.testDir, fileName); + createFile(fileName: string, content: string) { + const filePath = join(this.testDir!, fileName); writeFileSync(filePath, content); return filePath; } - mkdir(dir) { - mkdirSync(join(this.testDir, dir), { recursive: true }); + mkdir(dir: string) { + mkdirSync(join(this.testDir!, dir), { recursive: true }); } sync() { // 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 { let command = `node ${this.bundlePath} --yolo`; - const execOptions = { - cwd: this.testDir, + const execOptions: { + cwd: string; + encoding: 'utf-8'; + input?: string; + } = { + cwd: this.testDir!, encoding: 'utf-8', }; if (typeof promptOrOptions === 'string') { - command += ` --prompt "${promptOrOptions}"`; + command += ` --prompt ${JSON.stringify(promptOrOptions)}`; } else if ( typeof promptOrOptions === 'object' && promptOrOptions !== null ) { if (promptOrOptions.prompt) { - command += ` --prompt "${promptOrOptions.prompt}"`; + command += ` --prompt ${JSON.stringify(promptOrOptions.prompt)}`; } if (promptOrOptions.stdin) { execOptions.input = promptOrOptions.stdin; @@ -185,10 +208,10 @@ export class TestRig { command += ` ${args.join(' ')}`; const commandArgs = parse(command); - const node = commandArgs.shift(); + const node = commandArgs.shift() as string; - const child = spawn(node, commandArgs, { - cwd: this.testDir, + const child = spawn(node, commandArgs as string[], { + cwd: this.testDir!, stdio: 'pipe', }); @@ -197,26 +220,26 @@ export class TestRig { // Handle stdin if provided if (execOptions.input) { - child.stdin.write(execOptions.input); - child.stdin.end(); + child.stdin!.write(execOptions.input); + child.stdin!.end(); } - child.stdout.on('data', (data) => { + child.stdout!.on('data', (data: Buffer) => { stdout += data; if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { process.stdout.write(data); } }); - child.stderr.on('data', (data) => { + child.stderr!.on('data', (data: Buffer) => { stderr += data; if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { process.stderr.write(data); } }); - const promise = new Promise((resolve, reject) => { - child.on('close', (code) => { + const promise = new Promise((resolve, reject) => { + child.on('close', (code: number) => { if (code === 0) { // Store the raw stdout for Podman telemetry parsing this._lastRunStdout = stdout; @@ -258,6 +281,11 @@ export class TestRig { result = filteredLines.join('\n'); } + // If we have stderr output, include that also + if (stderr) { + result += `\n\nStdErr:\n${stderr}`; + } + resolve(result); } else { reject(new Error(`Process exited with code ${code}:\n${stderr}`)); @@ -268,13 +296,13 @@ export class TestRig { return promise; } - readFile(fileName) { - const content = readFileSync(join(this.testDir, fileName), 'utf-8'); + readFile(fileName: string) { + const content = readFileSync(join(this.testDir!, fileName), 'utf-8'); if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { - const testId = `${env.TEST_FILE_NAME.replace( + const testId = `${env.TEST_FILE_NAME!.replace( '.test.js', '', - )}:${this.testName.replace(/ /g, '-')}`; + )}:${this.testName!.replace(/ /g, '-')}`; console.log(`--- FILE: ${testId}/${fileName} ---`); console.log(content); console.log(`--- END FILE: ${testId}/${fileName} ---`); @@ -290,7 +318,7 @@ export class TestRig { } catch (error) { // Ignore cleanup errors 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 const logFilePath = env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false' - ? join(this.testDir, 'telemetry.log') + ? join(this.testDir!, 'telemetry.log') : env.TELEMETRY_LOG_FILE; if (!logFilePath) return; @@ -313,7 +341,7 @@ export class TestRig { const content = readFileSync(logFilePath, 'utf-8'); // Check if file has meaningful content (at least one complete JSON object) return content.includes('"event.name"'); - } catch (_e) { + } catch { return false; } }, @@ -322,7 +350,7 @@ export class TestRig { ); } - async waitForToolCall(toolName, timeout) { + async waitForToolCall(toolName: string, timeout?: number) { // Use environment-specific timeout if (!timeout) { timeout = this.getDefaultTimeout(); @@ -341,7 +369,7 @@ export class TestRig { ); } - async waitForAnyToolCall(toolNames, timeout) { + async waitForAnyToolCall(toolNames: string[], timeout?: number) { // Use environment-specific timeout if (!timeout) { timeout = this.getDefaultTimeout(); @@ -362,7 +390,11 @@ export class TestRig { ); } - async poll(predicate, timeout, interval) { + async poll( + predicate: () => boolean, + timeout: number, + interval: number, + ): Promise { const startTime = Date.now(); let attempts = 0; while (Date.now() - startTime < timeout) { @@ -384,8 +416,16 @@ export class TestRig { return false; } - _parseToolLogsFromStdout(stdout) { - const logs = []; + _parseToolLogsFromStdout(stdout: string) { + 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 // Look for tool call events in the output @@ -488,7 +528,7 @@ export class TestRig { }, }); } - } catch (_e) { + } catch { // Not valid JSON } currentObject = ''; @@ -505,7 +545,7 @@ export class TestRig { // If not, fall back to parsing from stdout if (env.GEMINI_SANDBOX === 'podman') { // Try reading from file first - const logFilePath = join(this.testDir, 'telemetry.log'); + const logFilePath = join(this.testDir!, 'telemetry.log'); if (fileExists(logFilePath)) { try { @@ -517,7 +557,7 @@ export class TestRig { // File exists but is empty or doesn't have events, parse from stdout return this._parseToolLogsFromStdout(this._lastRunStdout); } - } catch (_e) { + } catch { // Error reading file, fall back to stdout if (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 const logFilePath = env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false' - ? join(this.testDir, 'telemetry.log') + ? join(this.testDir!, 'telemetry.log') : env.TELEMETRY_LOG_FILE; if (!logFilePath) { @@ -548,7 +588,7 @@ export class TestRig { const content = readFileSync(logFilePath, 'utf-8'); // Split the content into individual JSON objects - // They are separated by "}\n{" pattern + // They are separated by "}\n{" const jsonObjects = content .split(/}\s*\n\s*{/) .map((obj, index, array) => { @@ -559,7 +599,14 @@ export class TestRig { }) .filter((obj) => obj); - const logs = []; + const logs: { + toolRequest: { + name: string; + args: string; + success: boolean; + duration_ms: number; + }; + }[] = []; for (const jsonStr of jsonObjects) { try { @@ -579,10 +626,13 @@ export class TestRig { }, }); } - } catch (_e) { + } catch (e) { // Skip objects that aren't valid JSON if (env.VERBOSE === 'true') { - console.error('Failed to parse telemetry object:', _e.message); + console.error( + 'Failed to parse telemetry object:', + (e as Error).message, + ); } } } diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json new file mode 100644 index 00000000..3e053d04 --- /dev/null +++ b/integration-tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "allowJs": true + }, + "include": ["**/*.ts"] +} diff --git a/integration-tests/web_search.test.js b/integration-tests/web_search.test.ts similarity index 90% rename from integration-tests/web_search.test.js rename to integration-tests/web_search.test.ts index f69295ac..957691ef 100644 --- a/integration-tests/web_search.test.js +++ b/integration-tests/web_search.test.ts @@ -23,10 +23,13 @@ test('should be able to search the web', async () => { } catch (error) { // Network errors can occur in CI environments if ( - error.message.includes('network') || - error.message.includes('timeout') + error instanceof Error && + (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 } throw error; // Re-throw if not a network error diff --git a/integration-tests/write_file.test.js b/integration-tests/write_file.test.ts similarity index 100% rename from integration-tests/write_file.test.js rename to integration-tests/write_file.test.ts diff --git a/package-lock.json b/package-lock.json index 3bd6e6d7..ef109c01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,9 +37,11 @@ "json": "^11.0.0", "lodash": "^4.17.21", "memfs": "^4.17.2", + "mnemonist": "^0.40.3", "mock-fs": "^5.5.0", "prettier": "^3.5.3", "react-devtools-core": "^4.28.5", + "tsx": "^4.20.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", "yargs": "^17.7.2" @@ -2515,15 +2517,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "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" + "dev": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -5547,6 +5541,12 @@ "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": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", @@ -5695,6 +5695,19 @@ "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": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -7873,6 +7886,15 @@ "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": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", @@ -8303,6 +8325,12 @@ "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": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9271,6 +9299,16 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -10577,6 +10615,26 @@ "dev": true, "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": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11704,6 +11762,7 @@ "dependencies": { "@google/genai": "1.9.0", "@iarna/toml": "^2.2.5", + "@modelcontextprotocol/sdk": "^1.15.1", "@qwen-code/qwen-code-core": "file:../core", "@types/update-notifier": "^6.0.8", "command-exists": "^1.2.9", @@ -11727,7 +11786,7 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", - "tiktoken": "^1.0.21", + "undici": "^7.10.0", "update-notifier": "^7.3.1", "yargs": "^17.7.2", "zod": "^3.23.8" @@ -11920,6 +11979,7 @@ "diff": "^7.0.0", "dotenv": "^17.1.0", "fdir": "^6.4.6", + "fzf": "^0.5.2", "glob": "^10.4.5", "google-auth-library": "^9.11.0", "html-to-text": "^9.0.5", @@ -11928,6 +11988,7 @@ "jsonrepair": "^3.13.0", "marked": "^15.0.12", "micromatch": "^4.0.8", + "mnemonist": "^0.40.3", "open": "^10.1.2", "openai": "5.11.0", "picomatch": "^4.0.1", @@ -12052,7 +12113,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.1.18", + "version": "0.0.7", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" @@ -12075,7 +12136,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/node": "20.x", - "@types/vscode": "^1.101.0", + "@types/vscode": "^1.99.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "esbuild": "^0.25.3", @@ -12085,8 +12146,15 @@ "vitest": "^3.2.4" }, "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" } } } diff --git a/package.json b/package.json index 7e23b867..aaf96516 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "build:packages": "npm run build --workspaces", "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", - "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:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", "test:e2e": "npm run test:integration:sandbox:none -- --verbose --keep-output", @@ -39,7 +39,7 @@ "lint": "eslint . --ext .ts,.tsx && eslint integration-tests", "lint:fix": "eslint . --fix && eslint integration-tests --fix", "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", "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", @@ -83,8 +83,10 @@ "mock-fs": "^5.5.0", "prettier": "^3.5.3", "react-devtools-core": "^4.28.5", + "tsx": "^4.20.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "mnemonist": "^0.40.3" } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 8008c596..1247e954 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,6 +31,7 @@ "@google/genai": "1.9.0", "@iarna/toml": "^2.2.5", "@qwen-code/qwen-code-core": "file:../core", + "@modelcontextprotocol/sdk": "^1.15.1", "@types/update-notifier": "^6.0.8", "command-exists": "^1.2.9", "diff": "^7.0.0", @@ -53,7 +54,7 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", - "tiktoken": "^1.0.21", + "undici": "^7.10.0", "update-notifier": "^7.3.1", "yargs": "^17.7.2", "zod": "^3.23.8" diff --git a/packages/cli/src/acp/acpPeer.ts b/packages/cli/src/acp/acpPeer.ts index bebd9592..18e37f35 100644 --- a/packages/cli/src/acp/acpPeer.ts +++ b/packages/cli/src/acp/acpPeer.ts @@ -239,65 +239,62 @@ class GeminiAgent implements Agent { ); } - let toolCallId; - 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; - } - + let toolCallId: number | undefined = undefined; 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); await this.client.updateToolCall({ @@ -320,12 +317,13 @@ class GeminiAgent implements Agent { return convertToFunctionResponse(fc.name, callId, toolResult.llmContent); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); - await this.client.updateToolCall({ - toolCallId, - status: 'error', - content: { type: 'markdown', markdown: error.message }, - }); - + if (toolCallId) { + await this.client.updateToolCall({ + toolCallId, + status: 'error', + content: { type: 'markdown', markdown: error.message }, + }); + } return errorResponse(error); } } @@ -408,7 +406,7 @@ class GeminiAgent implements Agent { `Path ${pathName} not found directly, attempting glob search.`, ); try { - const globResult = await globTool.execute( + const globResult = await globTool.buildAndExecute( { pattern: `**/*${pathName}*`, path: this.config.getTargetDir(), @@ -530,12 +528,15 @@ class GeminiAgent implements Agent { respectGitIgnore, // Use configuration setting }; - const toolCall = await this.client.pushToolCall({ - icon: readManyFilesTool.icon, - label: readManyFilesTool.getDescription(toolArgs), - }); + let toolCallId: number | undefined = undefined; 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) || { type: 'markdown', markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`, @@ -578,14 +579,16 @@ class GeminiAgent implements Agent { return processedQueryParts; } catch (error: unknown) { - await this.client.updateToolCall({ - toolCallId: toolCall.id, - status: 'error', - content: { - type: 'markdown', - markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, - }, - }); + if (toolCallId) { + await this.client.updateToolCall({ + toolCallId, + status: 'error', + content: { + type: 'markdown', + markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, + }, + }); + } throw error; } } diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts new file mode 100644 index 00000000..b4e9980c --- /dev/null +++ b/packages/cli/src/commands/mcp.test.ts @@ -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 [args...]'); + expect(commandNames).toContain('remove '); + expect(commandNames).toContain('list'); + + expect(mockYargs.demandCommand).toHaveBeenCalledWith( + 1, + 'You need at least one command before continuing.', + ); + }); +}); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 00000000..5e55286c --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -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. + }, +}; diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts new file mode 100644 index 00000000..1d431c48 --- /dev/null +++ b/packages/cli/src/commands/mcp/add.test.ts @@ -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' }, + }, + }, + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts new file mode 100644 index 00000000..ac383aa1 --- /dev/null +++ b/packages/cli/src/commands/mcp/add.ts @@ -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 | 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 = {}; + + 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, + ); + + 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, + ), + 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 [args...]', + describe: 'Add a server', + builder: (yargs) => + yargs + .usage('Usage: gemini mcp add [options] [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, + { + 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, + }, + ); + }, +}; diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts new file mode 100644 index 00000000..c268fdbd --- /dev/null +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -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', + ), + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts new file mode 100644 index 00000000..57bed6d8 --- /dev/null +++ b/packages/cli/src/commands/mcp/list.ts @@ -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 +> { + 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 { + 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 { + // Test all server types by attempting actual connection + return await testMCPConnection(serverName, server); +} + +export async function listMcpServers(): Promise { + 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(); + }, +}; diff --git a/packages/cli/src/commands/mcp/remove.test.ts b/packages/cli/src/commands/mcp/remove.test.ts new file mode 100644 index 00000000..eb7dedce --- /dev/null +++ b/packages/cli/src/commands/mcp/remove.test.ts @@ -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; + + 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.', + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts new file mode 100644 index 00000000..80d66234 --- /dev/null +++ b/packages/cli/src/commands/mcp/remove.ts @@ -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 ', + describe: 'Remove a server', + builder: (yargs) => + yargs + .usage('Usage: gemini mcp remove [options] ') + .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, + }); + }, +}; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 2774f621..27f37073 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; +import { ShellTool, EditTool, WriteFileTool } from '@qwen-code/qwen-code-core'; import { loadCliConfig, parseArguments } from './config.js'; import { Settings } from './settings.js'; import { Extension } from './extension.js'; @@ -635,6 +636,17 @@ describe('loadCliConfig systemPromptMappings', () => { }); 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 () => { const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; const extensions: Extension[] = [ @@ -729,7 +741,8 @@ describe('mergeExcludeTools', () => { 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 extensions: Extension[] = []; process.argv = ['node', 'script.js']; @@ -743,6 +756,21 @@ describe('mergeExcludeTools', () => { 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 () => { process.argv = ['node', 'script.js']; 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 () => { const actualFs = await vi.importActual('fs'); 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); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index af879a99..8ecb70d0 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -10,6 +10,7 @@ import { homedir } from 'node:os'; import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; +import { mcpCommand } from '../commands/mcp.js'; import { Config, loadServerHierarchicalMemory, @@ -22,6 +23,11 @@ import { FileDiscoveryService, TelemetryTarget, FileFilteringOptions, + ShellTool, + EditTool, + WriteFileTool, + MCPServerConfig, + ConfigParameters, } from '@qwen-code/qwen-code-core'; import { Settings } from './settings.js'; @@ -68,7 +74,6 @@ export interface CliArgs { openaiBaseUrl: string | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; - loadMemoryFromIncludeDirectories: boolean | undefined; tavilyApiKey: string | undefined; } @@ -76,190 +81,196 @@ export async function parseArguments(): Promise { const yargsInstance = yargs(hideBin(process.argv)) .scriptName('qwen') .usage( - '$0 [options]', - 'Qwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode', + 'Usage: qwen [options] [command]\n\nQwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode', ) - .option('model', { - alias: 'm', - type: 'string', - description: `Model`, - default: process.env.GEMINI_MODEL, - }) - .option('prompt', { - alias: 'p', - type: 'string', - description: 'Prompt. Appended to input on stdin (if any).', - }) - .option('prompt-interactive', { - alias: 'i', - type: 'string', - description: - 'Execute the provided prompt and continue in interactive mode', - }) - .option('sandbox', { - alias: 's', - type: 'boolean', - description: 'Run in sandbox?', - }) - .option('sandbox-image', { - type: 'string', - description: 'Sandbox image URI.', - }) - .option('debug', { - alias: 'd', - type: 'boolean', - description: 'Run in debug mode?', - default: false, - }) - .option('all-files', { - alias: ['a'], - type: 'boolean', - description: 'Include ALL files in context?', - default: false, - }) - .option('all_files', { - type: 'boolean', - description: 'Include ALL files in context?', - default: false, - }) - .deprecateOption( - 'all_files', - 'Use --all-files instead. We will be removing --all_files in the coming weeks.', + .command('$0', 'Launch Qwen Code', (yargsInstance) => + yargsInstance + .option('model', { + alias: 'm', + type: 'string', + description: `Model`, + default: process.env.GEMINI_MODEL, + }) + .option('prompt', { + alias: 'p', + type: 'string', + description: 'Prompt. Appended to input on stdin (if any).', + }) + .option('prompt-interactive', { + alias: 'i', + type: 'string', + description: + 'Execute the provided prompt and continue in interactive mode', + }) + .option('sandbox', { + alias: 's', + type: 'boolean', + description: 'Run in sandbox?', + }) + .option('sandbox-image', { + type: 'string', + description: 'Sandbox image URI.', + }) + .option('debug', { + alias: 'd', + type: 'boolean', + description: 'Run in debug mode?', + default: false, + }) + .option('all-files', { + alias: ['a'], + type: 'boolean', + description: 'Include ALL files in context?', + default: false, + }) + .option('all_files', { + type: 'boolean', + description: 'Include ALL files in context?', + default: false, + }) + .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', { - 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('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, - }) + // Register MCP subcommands + .command(mcpCommand) .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') .help() .alias('h', 'help') .strict() - .check((argv) => { - if (argv.prompt && argv.promptInteractive) { - throw new Error( - 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', - ); - } - return true; - }); + .demandCommand(0, 0); // Allow base command to run with no subcommands 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 // 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. @@ -321,6 +332,10 @@ export async function loadCliConfig( const ideModeFeature = argv.ideModeFeature ?? settings.ideModeFeature ?? false; + const folderTrustFeature = settings.folderTrustFeature ?? false; + const folderTrustSetting = settings.folderTrust ?? false; + const folderTrust = folderTrustFeature && folderTrustSetting; + const allExtensions = annotateActiveExtensions( extensions, argv.extensions || [], @@ -383,17 +398,31 @@ export async function loadCliConfig( ); 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 }> = []; if (!argv.allowedMcpServerNames) { if (settings.allowMCPServers) { - const allowedNames = new Set(settings.allowMCPServers.filter(Boolean)); - if (allowedNames.size > 0) { - mcpServers = Object.fromEntries( - Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)), - ); - } + mcpServers = allowedMcpServers( + mcpServers, + settings.allowMCPServers, + blockedMcpServers, + ); } if (settings.excludeMCPServers) { @@ -407,29 +436,11 @@ export async function loadCliConfig( } if (argv.allowedMcpServerNames) { - const allowedNames = new Set(argv.allowedMcpServerNames.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 = {}; - } + mcpServers = allowedMcpServers( + mcpServers, + argv.allowedMcpServerNames, + blockedMcpServers, + ); } const sandboxConfig = await loadSandboxConfig(settings, argv); @@ -442,11 +453,9 @@ export async function loadCliConfig( targetDir: process.cwd(), includeDirectories, loadMemoryFromIncludeDirectories: - argv.loadMemoryFromIncludeDirectories || - settings.loadMemoryFromIncludeDirectories || - false, + settings.loadMemoryFromIncludeDirectories || false, debugMode, - question: argv.promptInteractive || argv.prompt || '', + question, fullContext: argv.allFiles || argv.all_files || false, coreTools: settings.coreTools || undefined, excludeTools, @@ -456,7 +465,7 @@ export async function loadCliConfig( mcpServers, userMemory: memoryContent, geminiMdFileCount: fileCount, - approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT, + approvalMode, showMemoryUsage: argv.showMemoryUsage || argv.show_memory_usage || @@ -496,7 +505,6 @@ export async function loadCliConfig( extensionContextFilePaths, maxSessionTurns: settings.maxSessionTurns ?? -1, sessionTokenLimit: settings.sessionTokenLimit ?? -1, - maxFolderItems: settings.maxFolderItems ?? 20, experimentalAcp: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, extensions: allExtensions, @@ -510,7 +518,7 @@ export async function loadCliConfig( ? settings.enableOpenAILogging : argv.openaiLogging) ?? false, sampling_params: settings.sampling_params, - systemPromptMappings: settings.systemPromptMappings ?? [ + systemPromptMappings: (settings.systemPromptMappings ?? [ { baseUrls: [ 'https://dashscope.aliyuncs.com/compatible-mode/v1/', @@ -519,15 +527,50 @@ export async function loadCliConfig( modelNames: ['qwen3-coder-plus'], template: 'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}', - }, - ], + } + ]) as ConfigParameters['systemPromptMappings'], contentGenerator: settings.contentGenerator, cliVersion, tavilyApiKey: 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[]) { const mcpServers = { ...(settings.mcpServers || {}) }; for (const extension of extensions) { @@ -552,8 +595,12 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) { function mergeExcludeTools( settings: Settings, extensions: Extension[], + extraExcludes?: string[] | undefined, ): string[] { - const allExcludeTools = new Set(settings.excludeTools || []); + const allExcludeTools = new Set([ + ...(settings.excludeTools || []), + ...(extraExcludes || []), + ]); for (const extension of extensions) { for (const tool of extension.config.excludeTools || []) { allExcludeTools.add(tool); diff --git a/packages/cli/src/config/keyBindings.test.ts b/packages/cli/src/config/keyBindings.test.ts new file mode 100644 index 00000000..2e89e421 --- /dev/null +++ b/packages/cli/src/config/keyBindings.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts new file mode 100644 index 00000000..6f4a21a2 --- /dev/null +++ b/packages/cli/src/config/keyBindings.ts @@ -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' }], +}; diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index d0266720..353a5783 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -113,6 +113,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); expect(settings.errors.length).toBe(0); }); @@ -147,6 +148,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); @@ -181,6 +183,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); @@ -213,6 +216,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); @@ -251,6 +255,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); @@ -301,9 +306,66 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, 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', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, @@ -622,6 +684,116 @@ describe('Settings Loading and Merging', () => { 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', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { @@ -695,6 +867,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); // Check that error objects are populated in settings.errors @@ -1132,6 +1305,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 5dfc137a..b298afc2 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -9,17 +9,15 @@ import * as path from 'path'; import { homedir, platform } from 'os'; import * as dotenv from 'dotenv'; import { - MCPServerConfig, GEMINI_CONFIG_DIR as GEMINI_DIR, getErrorMessage, - BugCommandSettings, - TelemetrySettings, - AuthType, } from '@qwen-code/qwen-code-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.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 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'); } -export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; +export type { DnsResolutionOrder } from './settingsSchema.js'; export enum SettingScope { User = 'User', @@ -63,95 +61,6 @@ export interface AccessibilitySettings { disableLoadingPhrases?: boolean; } -export interface Settings { - theme?: string; - customThemes?: Record; - selectedAuthType?: AuthType; - useExternalAuth?: boolean; - sandbox?: boolean | string; - coreTools?: string[]; - excludeTools?: string[]; - toolDiscoveryCommand?: string; - toolCallCommand?: string; - mcpServerCommand?: string; - mcpServers?: Record; - 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; - - 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; - 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 { message: string; path: string; @@ -191,9 +100,13 @@ export class LoadedSettings { const user = this.user.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 { ...user, - ...workspace, + ...workspaceWithoutFolderTrust, ...system, customThemes: { ...(user.customThemes || {}), @@ -210,6 +123,11 @@ export class LoadedSettings { ...(user.includeDirectories || []), ...(workspace.includeDirectories || []), ], + chatCompression: { + ...(system.chatCompression || {}), + ...(user.chatCompression || {}), + ...(workspace.chatCompression || {}), + }, }; } @@ -498,6 +416,19 @@ export function loadSettings(workspaceDir: string): LoadedSettings { 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 loadEnvironment(loadedSettings.merged); diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts new file mode 100644 index 00000000..ab820ee1 --- /dev/null +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -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; + }; + 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) => { + Object.entries(schema).forEach( + ([_key, definition]: [string, unknown]) => { + const def = definition as { + type?: string; + default?: unknown; + properties?: Record; + }; + 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); + }); + + 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); + }); + }); +}); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts new file mode 100644 index 00000000..73ffebdc --- /dev/null +++ b/packages/cli/src/config/settingsSchema.ts @@ -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, + 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, + 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 | 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 | undefined, + description: 'Content generator settings.', + showInDialog: false, + }, + sampling_params: { + type: 'object', + label: 'Sampling Params', + category: 'General', + requiresRestart: false, + default: undefined as Record | 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 | 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 = { + -readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema } + ? InferSettings + : T[K]['default'] extends boolean + ? boolean + : T[K]['default']; +}; + +export type Settings = InferSettings; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index e9e0420b..a7e4c75b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render } from 'ink'; 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 { basename } from 'node:path'; import v8 from 'node:v8'; @@ -25,19 +25,18 @@ import { themeManager } from './ui/themes/theme-manager.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.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 { getCliVersion } from './utils/version.js'; import { - ApprovalMode, Config, - EditTool, - ShellTool, - WriteFileTool, sessionId, logUserPrompt, AuthType, getOauthClient, + logIdeConnection, + IdeConnectionEvent, + IdeConnectionType, } from '@qwen-code/qwen-code-core'; import { validateAuthMethod } from './config/auth.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 { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; +import { SettingsContext } from './ui/contexts/SettingsContext.js'; export function validateDnsResolutionOrder( order: string | undefined, @@ -191,6 +191,11 @@ export async function main() { await config.initialize(); + if (config.getIdeMode() && config.getIdeModeFeature()) { + await config.getIdeClient().connect(); + logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); + } + // Load custom themes from settings themeManager.loadCustomThemes(settings.merged.customThemes); @@ -255,21 +260,20 @@ export async function main() { ...(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. - if (shouldBeInteractive) { + if (config.isInteractive()) { const version = await getCliVersion(); setWindowTitle(basename(workspaceRoot), settings); const instance = render( - + + + , { exitOnCtrlC: false }, ); @@ -308,12 +312,10 @@ export async function main() { prompt_length: input.length, }); - // Non-interactive mode handled by runNonInteractive - const nonInteractiveConfig = await loadNonInteractiveConfig( + const nonInteractiveConfig = await validateNonInteractiveAuth( + settings.merged.selectedAuthType, + settings.merged.useExternalAuth, config, - extensions, - settings, - argv, ); 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, - ); -} diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 44bba009..24f5cb10 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -30,7 +30,6 @@ export async function runNonInteractive( }); try { - await config.initialize(); consolePatcher.patch(); // Handle EPIPE errors when the output is piped to a command that closes early. process.stdout.on('error', (err: NodeJS.ErrnoException) => { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 87d9af85..5343fd10 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -30,9 +30,9 @@ import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; +import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.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 @@ -73,8 +73,9 @@ export class BuiltinCommandLoader implements ICommandLoader { statsCommand, themeCommand, toolsCommand, + settingsCommand, vimCommand, - ...(isGitHubRepository() ? [setupGithubCommand] : []), + setupGithubCommand, ]; return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index dc14d795..97b0bf97 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -16,6 +16,7 @@ import { SandboxConfig, GeminiClient, ideContext, + type AuthType, } from '@qwen-code/qwen-code-core'; import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js'; import process from 'node:process'; @@ -27,6 +28,7 @@ import { checkForUpdates, UpdateObject } from './utils/updateCheck.js'; import { EventEmitter } from 'events'; import { updateEventEmitter } from '../utils/updateEventEmitter.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 interface MockServerConfig { @@ -84,6 +86,7 @@ interface MockServerConfig { getAllGeminiMdFilenames: Mock<() => string[]>; getGeminiClient: Mock<() => GeminiClient | undefined>; getUserTier: Mock<() => Promise>; + getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>; } // 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(() => ({ getDirectories: vi.fn(() => []), })), + getIdeClient: vi.fn(() => ({ + getCurrentIde: vi.fn(() => 'vscode'), + })), }; }); @@ -182,6 +188,7 @@ vi.mock('./hooks/useGeminiStream', () => ({ submitQuery: vi.fn(), initError: null, 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', () => ({ useLogger: vi.fn(() => ({ getPreviousUserMessages: vi.fn().mockResolvedValue([]), @@ -233,10 +247,14 @@ vi.mock('./utils/updateCheck.js', () => ({ checkForUpdates: vi.fn(), })); -vi.mock('./config/auth.js', () => ({ +vi.mock('../config/auth.js', () => ({ validateAuthMethod: vi.fn(), })); +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: vi.fn(), +})); + const mockedCheckForUpdates = vi.mocked(checkForUpdates); const { isGitRepository: mockedIsGitRepository } = vi.mocked( await import('@qwen-code/qwen-code-core'), @@ -278,6 +296,11 @@ describe('App UI', () => { }; beforeEach(() => { + vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({ + columns: 120, + rows: 24, + }); + const ServerConfigMocked = vi.mocked(ServerConfig, true); mockConfig = new ServerConfigMocked({ embeddingModel: 'test-embedding-model', @@ -1050,4 +1073,44 @@ describe('App UI', () => { 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( + , + ); + 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( + , + ); + currentUnmount = unmount; + await Promise.resolve(); + expect(lastFrame()).toContain('Do you trust this folder?'); + }); + }); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 9482a1bb..fffb4167 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -13,8 +13,6 @@ import { Text, useStdin, useStdout, - useInput, - type Key as InkKeyType, } from 'ink'; import { StreamingState, type HistoryItem, MessageType } from './types.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; @@ -23,6 +21,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useAuthCommand } from './hooks/useAuthCommand.js'; import { useQwenAuth } from './hooks/useQwenAuth.js'; +import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; @@ -38,17 +37,18 @@ import { AuthDialog } from './components/AuthDialog.js'; import { AuthInProgress } from './components/AuthInProgress.js'; import { QwenOAuthProgress } from './components/QwenOAuthProgress.js'; import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; +import { FolderTrustDialog } from './components/FolderTrustDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; +import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js'; import { Colors } from './colors.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 { ConsolePatcher } from './utils/ConsolePatcher.js'; import { registerCleanup } from '../utils/cleanup.js'; import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js'; import { HistoryItemDisplay } from './components/HistoryItemDisplay.js'; import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js'; -import { IDEContextDetailDisplay } from './components/IDEContextDetailDisplay.js'; import { useHistory } from './hooks/useHistoryManager.js'; import process from 'node:process'; import { @@ -64,6 +64,10 @@ import { type IdeContext, ideContext, } from '@qwen-code/qwen-code-core'; +import { + IdeIntegrationNudge, + IdeIntegrationNudgeResult, +} from './IdeIntegrationNudge.js'; import { validateAuthMethod } from '../config/auth.js'; import { useLogger } from './hooks/useLogger.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 { useVimMode, VimModeProvider } from './contexts/VimModeContext.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 { UpdateNotification } from './components/UpdateNotification.js'; import { @@ -89,8 +95,11 @@ import ansiEscapes from 'ansi-escapes'; import { OverflowProvider } from './contexts/OverflowContext.js'; import { ShowMoreLines } from './components/ShowMoreLines.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 { appEvents, AppEvent } from '../utils/events.js'; +import { isNarrowWidth } from './utils/isNarrowWidth.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -117,6 +126,18 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const nightly = version.includes('nightly'); 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(() => { const cleanup = setUpdateHandler(addItem, setUpdateInfo); return cleanup; @@ -157,8 +178,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const [showErrorDetails, setShowErrorDetails] = useState(false); const [showToolDescriptions, setShowToolDescriptions] = useState(false); - const [showIDEContextDetail, setShowIDEContextDetail] = - useState(false); + const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const [quittingMessages, setQuittingMessages] = useState< HistoryItem[] | null @@ -174,6 +194,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const [ideContextState, setIdeContextState] = useState< IdeContext | undefined >(); + const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [isProcessing, setIsProcessing] = useState(false); useEffect(() => { @@ -208,6 +229,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const openPrivacyNotice = useCallback(() => { setShowPrivacyNotice(true); }, []); + + const handleEscapePromptChange = useCallback((showPrompt: boolean) => { + setShowEscapePrompt(showPrompt); + }, []); + const initialPromptSubmitted = useRef(false); const errorCount = useMemo( @@ -225,6 +251,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { handleThemeHighlight, } = useThemeCommand(settings, setThemeError, addItem); + const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = + useSettingsCommand(); + + const { isFolderTrustDialogOpen, handleFolderTrustSelect } = + useFolderTrust(settings); + const { isAuthDialogOpen, openAuthDialog, @@ -452,6 +484,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { // Terminal and UI setup const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); + const isNarrow = isNarrowWidth(terminalWidth); const { stdin, setRawMode } = useStdin(); const isInitialMount = useRef(true); @@ -460,7 +493,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { 20, 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 const isValidPath = useCallback((filePath: string): boolean => { @@ -499,6 +532,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { pendingHistoryItems: pendingSlashCommandHistoryItems, commandContext, shellConfirmationRequest, + confirmationRequest, } = useSlashCommandProcessor( config, settings, @@ -513,17 +547,37 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { toggleCorgiMode, setQuittingMessages, openPrivacyNotice, + openSettingsDialog, toggleVimEnabled, setIsProcessing, setGeminiMdFileCount, ); + const buffer = useTextBuffer({ + initialText: '', + viewport: { height: 10, width: inputWidth }, + stdin, + setRawMode, + isValidPath, + shellModeActive, + }); + + const [userMessages, setUserMessages] = useState([]); + + const handleUserCancel = useCallback(() => { + const lastUserMessage = userMessages.at(-1); + if (lastUserMessage) { + buffer.setText(lastUserMessage); + } + }, [buffer, userMessages]); + const { streamingState, submitQuery, initError, pendingHistoryItems: pendingGeminiHistoryItems, thought, + cancelOngoingRequest, } = useGeminiStream( config.getGeminiClient(), history, @@ -538,6 +592,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError, refreshStatic, + handleUserCancel, ); // Input handling @@ -551,14 +606,26 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { [submitQuery], ); - const buffer = useTextBuffer({ - initialText: '', - viewport: { height: 10, width: inputWidth }, - stdin, - setRawMode, - isValidPath, - shellModeActive, - }); + const handleIdePromptComplete = useCallback( + (result: IdeIntegrationNudgeResult) => { + if (result === 'yes') { + handleSlashCommand('/ide install'); + settings.setValue( + SettingScope.User, + 'hasSeenIdeIntegrationNudge', + true, + ); + } else if (result === 'dismiss') { + settings.setValue( + SettingScope.User, + 'hasSeenIdeIntegrationNudge', + true, + ); + } + setIdePromptAnswered(true); + }, + [handleSlashCommand, settings], + ); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; @@ -591,46 +658,75 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { [handleSlashCommand], ); - useInput((input: string, key: InkKeyType) => { - let enteringConstrainHeightMode = false; - if (!constrainHeight) { - // Automatically re-enter constrain height mode if the user types - // anything. When constrainHeight==false, the user will experience - // significant flickering so it is best to disable it immediately when - // 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'); + const handleGlobalKeypress = useCallback( + (key: Key) => { + let enteringConstrainHeightMode = false; + if (!constrainHeight) { + enteringConstrainHeightMode = true; + setConstrainHeight(true); } - } else if ( - key.ctrl && - input === 'e' && - config.getIdeMode() && - ideContextState - ) { - setShowIDEContextDetail((prev) => !prev); - } else if (key.ctrl && (input === 'c' || input === 'C')) { - handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); - } else if (key.ctrl && (input === 'd' || input === 'D')) { - if (buffer.text.length > 0) { - // Do nothing if there is text in the input. - return; + + if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { + setShowErrorDetails((prev) => !prev); + } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) { + const newValue = !showToolDescriptions; + setShowToolDescriptions(newValue); + + const mcpServers = config.getMcpServers(); + if (Object.keys(mcpServers || {}).length > 0) { + handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc'); + } + } else if ( + 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(() => { if (config) { @@ -639,7 +735,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { }, [config, config.getGeminiMdFileCount]); const logger = useLogger(); - const [userMessages, setUserMessages] = useState([]); useEffect(() => { const fetchUserMessages = async () => { @@ -791,6 +886,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { ); } + const mainAreaWidth = Math.floor(terminalWidth * 0.9); const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5)); // Arbitrary threshold to ensure that items in the static area are large @@ -819,11 +915,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { items={[ {!settings.merged.hideBanner && ( -
+
)} {!settings.merged.hideTips && } , @@ -882,8 +974,30 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { )} - {shellConfirmationRequest ? ( + {shouldShowIdePrompt ? ( + + ) : isFolderTrustDialogOpen ? ( + + ) : shellConfirmationRequest ? ( + ) : confirmationRequest ? ( + + {confirmationRequest.prompt} + + { + confirmationRequest.onConfirm(value); + }} + /> + + ) : isThemeDialogOpen ? ( {themeError && ( @@ -903,6 +1017,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { terminalWidth={mainAreaWidth} /> + ) : isSettingsDialogOpen ? ( + + closeSettingsDialog()} + onRestartRequest={() => process.exit(0)} + /> + ) : isAuthenticating ? ( <> {isQwenAuth && isQwenAuthenticating ? ( @@ -994,9 +1116,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { {process.env.GEMINI_SYSTEM_MD && ( @@ -1010,6 +1133,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { Press Ctrl+D again to exit. + ) : showEscapePrompt ? ( + Press Esc again to clear. ) : ( { /> )} - + {showAutoAcceptIndicator !== ApprovalMode.DEFAULT && !shellModeActive && ( { {shellModeActive && } - {showIDEContextDetail && ( - - )} + {showErrorDetails && ( @@ -1067,6 +1185,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { commandContext={commandContext} shellModeActive={shellModeActive} setShellModeActive={setShellModeActive} + onEscapePromptChange={handleEscapePromptChange} focus={isFocused} vimHandleInput={vimHandleInput} placeholder={placeholder} @@ -1117,7 +1236,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { errorCount={errorCount} showErrorDetails={showErrorDetails} showMemoryUsage={ - config.getDebugMode() || config.getShowMemoryUsage() + config.getDebugMode() || settings.merged.showMemoryUsage || false } promptTokenCount={sessionStats.lastPromptTokenCount} nightly={nightly} diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx new file mode 100644 index 00000000..f0c6172d --- /dev/null +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -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> = [ + { + label: 'Yes', + value: 'yes', + }, + { + label: 'No (esc)', + value: 'no', + }, + { + label: "No, don't ask again", + value: 'dismiss', + }, + ]; + + return ( + + + + {'> '} + {`Do you want to connect your ${ideName ?? 'your'} editor to Gemini CLI?`} + + {`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'}.`} + + + + ); +} diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 891a16af..f5425dba 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -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`] = ` -" - 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)" `; + +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)" +`; diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index 533e697d..4c959c7f 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -168,8 +168,12 @@ describe('chatCommand', () => { describe('save subcommand', () => { let saveCommand: SlashCommand; const tag = 'my-tag'; + let mockCheckpointExists: ReturnType; + beforeEach(() => { saveCommand = getSubCommand('save'); + mockCheckpointExists = vi.fn().mockResolvedValue(false); + mockContext.services.logger.checkpointExists = mockCheckpointExists; }); 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[] = [ { type: 'user', @@ -199,8 +203,52 @@ describe('chatCommand', () => { }, ]; mockGetHistory.mockReturnValue(history); + mockCheckpointExists.mockResolvedValue(false); + 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(result).toEqual({ type: 'message', diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index a5fa13da..56eebe1a 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -5,11 +5,15 @@ */ import * as fsPromises from 'fs/promises'; +import React from 'react'; +import { Text } from 'ink'; +import { Colors } from '../colors.js'; import { CommandContext, SlashCommand, MessageActionReturn, CommandKind, + SlashCommandActionReturn, } from './types.js'; import path from 'path'; import { HistoryItemWithoutId, MessageType } from '../types.js'; @@ -96,7 +100,7 @@ const saveCommand: SlashCommand = { description: 'Save the current conversation as a checkpoint. Usage: /chat save ', kind: CommandKind.BUILT_IN, - action: async (context, args): Promise => { + action: async (context, args): Promise => { const tag = args.trim(); if (!tag) { return { @@ -108,6 +112,26 @@ const saveCommand: SlashCommand = { const { logger, config } = context.services; 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(); if (!chat) { return { diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 73b9b13c..03689c28 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -93,13 +93,14 @@ describe('ideCommand', () => { } as unknown as ReturnType); }); - it('should show connected status', () => { + it('should show connected status', async () => { mockGetConnectionStatus.mockReturnValue({ status: core.IDEConnectionStatus.Connected, }); const command = ideCommand(mockConfig); - const result = command!.subCommands!.find((c) => c.name === 'status')! - .action!(mockContext, ''); + const result = await command!.subCommands!.find( + (c) => c.name === 'status', + )!.action!(mockContext, ''); expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', @@ -108,13 +109,14 @@ describe('ideCommand', () => { }); }); - it('should show connecting status', () => { + it('should show connecting status', async () => { mockGetConnectionStatus.mockReturnValue({ status: core.IDEConnectionStatus.Connecting, }); const command = ideCommand(mockConfig); - const result = command!.subCommands!.find((c) => c.name === 'status')! - .action!(mockContext, ''); + const result = await command!.subCommands!.find( + (c) => c.name === 'status', + )!.action!(mockContext, ''); expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', @@ -122,13 +124,14 @@ describe('ideCommand', () => { content: `🟡 Connecting...`, }); }); - it('should show disconnected status', () => { + it('should show disconnected status', async () => { mockGetConnectionStatus.mockReturnValue({ status: core.IDEConnectionStatus.Disconnected, }); const command = ideCommand(mockConfig); - const result = command!.subCommands!.find((c) => c.name === 'status')! - .action!(mockContext, ''); + const result = await command!.subCommands!.find( + (c) => c.name === 'status', + )!.action!(mockContext, ''); expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ 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'; mockGetConnectionStatus.mockReturnValue({ status: core.IDEConnectionStatus.Disconnected, details, }); const command = ideCommand(mockConfig); - const result = command!.subCommands!.find((c) => c.name === 'status')! - .action!(mockContext, ''); + const result = await command!.subCommands!.find( + (c) => c.name === 'status', + )!.action!(mockContext, ''); expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 15a099dc..eff0a5b6 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -8,10 +8,13 @@ import { Config, DetectedIde, IDEConnectionStatus, - IdeClient, getIdeDisplayName, getIdeInstaller, + IdeClient, + type File, + ideContext, } from '@qwen-code/qwen-code-core'; +import path from 'node:path'; import { CommandContext, SlashCommand, @@ -49,6 +52,68 @@ function getIdeStatusMessage(ideClient: IdeClient): { } } +function formatFileList(openFiles: File[]): string { + const basenameCounts = new Map(); + 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 => { if (!config || !config.getIdeModeFeature()) { return null; @@ -84,8 +149,9 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { name: 'status', description: 'check status of IDE integration', kind: CommandKind.BUILT_IN, - action: (): SlashCommandActionReturn => { - const { messageType, content } = getIdeStatusMessage(ideClient); + action: async (): Promise => { + const { messageType, content } = + await getIdeStatusMessageWithFiles(ideClient); return { type: 'message', messageType, diff --git a/packages/cli/src/ui/commands/settingsCommand.test.ts b/packages/cli/src/ui/commands/settingsCommand.test.ts new file mode 100644 index 00000000..96d0d511 --- /dev/null +++ b/packages/cli/src/ui/commands/settingsCommand.test.ts @@ -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', + ); + }); +}); diff --git a/packages/cli/src/ui/commands/settingsCommand.ts b/packages/cli/src/ui/commands/settingsCommand.ts new file mode 100644 index 00000000..26807852 --- /dev/null +++ b/packages/cli/src/ui/commands/settingsCommand.ts @@ -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', + }), +}; diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index 891c84e7..38589e58 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -4,63 +4,103 @@ * 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 * as child_process from 'child_process'; +import * as gitUtils from '../../utils/gitUtils.js'; import { setupGithubCommand } from './setupGithubCommand.js'; import { CommandContext, ToolActionReturn } from './types.js'; +import * as commandUtils from '../utils/commandUtils.js'; vi.mock('child_process'); -describe('setupGithubCommand', () => { - beforeEach(() => { +// Mock fetch globally +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(); + scratchDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'setup-github-command-'), + ); }); - afterEach(() => { + afterEach(async () => { vi.restoreAllMocks(); + if (scratchDir) await fs.rm(scratchDir, { recursive: true }); }); - it('returns a tool action to download github workflows and handles paths', () => { - const fakeRepoRoot = '/github.com/fake/repo/root'; - vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot); + it('returns a tool action to download github workflows and handles paths', async () => { + const fakeRepoOwner = 'fake'; + 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 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', - }); + )) as ToolActionReturn; const { command } = result.toolArgs; const expectedSubstrings = [ - `mkdir -p "${fakeRepoRoot}/.github/workflows"`, - `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-cli.yml"`, - `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/', + `set -eEuo pipefail`, + `fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`, ]; for (const substring of expectedSubstrings) { expect(command).toContain(substring); } - }); - it('throws an error if git root cannot be determined', () => { - vi.mocked(child_process.execSync).mockReturnValue(''); - expect(() => { - setupGithubCommand.action?.({} as CommandContext, ''); - }).toThrow('Unable to determine the Git root directory.'); + for (const workflow of workflows) { + const workflowFile = path.join( + scratchDir, + '.github', + 'workflows', + workflow, + ); + const contents = await fs.readFile(workflowFile, 'utf8'); + expect(contents).toContain(workflow); + } }); }); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index e330cfab..2f024e60 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -4,32 +4,93 @@ * SPDX-License-Identifier: Apache-2.0 */ -import path from 'path'; -import { execSync } from 'child_process'; -import { isGitHubRepository } from '../../utils/gitUtils.js'; +import path from 'node:path'; +import * as fs from 'node:fs'; +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 { CommandKind, SlashCommand, SlashCommandActionReturn, } 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 = { name: 'setup-github', description: 'Set up GitHub Actions', kind: CommandKind.BUILT_IN, - action: (): SlashCommandActionReturn => { - const gitRootRepo = execSync('git rev-parse --show-toplevel', { - encoding: 'utf-8', - }).trim(); + action: async ( + context: CommandContext, + ): Promise => { + const abortController = new AbortController(); 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'; - const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`; + // Find the root directory of the repo + 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 = [ 'gemini-cli/gemini-cli.yml', 'issue-triage/gemini-issue-automated-triage.yml', @@ -37,15 +98,63 @@ export const setupGithubCommand: SlashCommand = { 'pr-review/gemini-pr-review.yml', ]; - const command = [ - 'set -e', - `mkdir -p "${gitRootRepo}/.github/workflows"`, - ...workflows.map((workflow) => { - const fileName = path.basename(workflow); - return `curl -fsSL -o "${gitRootRepo}/.github/workflows/${fileName}" "${workflowBaseUrl}/${workflow}"`; - }), - 'echo "Workflows downloaded successfully."', - ].join(' && '); + const downloads = []; + for (const workflow of workflows) { + downloads.push( + (async () => { + const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`; + const response = await fetch(endpoint, { + method: 'GET', + dispatcher: proxy ? new ProxyAgent(proxy) : undefined, + 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 { type: 'tool', toolName: 'run_shell_command', diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index dde92b87..de21f8eb 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { type ReactNode } from 'react'; import { Content } from '@google/genai'; import { HistoryItemWithoutId } from '../types.js'; 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. */ sessionShellAllowlist: Set; }; + // Flag to indicate if an overwrite has been confirmed + overwriteConfirmed?: boolean; } /** @@ -100,7 +103,8 @@ export interface MessageActionReturn { */ export interface OpenDialogActionReturn { 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 = | ToolActionReturn | MessageActionReturn @@ -143,7 +157,8 @@ export type SlashCommandActionReturn = | OpenDialogActionReturn | LoadHistoryActionReturn | SubmitPromptActionReturn - | ConfirmShellCommandsActionReturn; + | ConfirmShellCommandsActionReturn + | ConfirmActionReturn; export enum CommandKind { BUILT_IN = 'built-in', diff --git a/packages/cli/src/ui/components/AsciiArt.ts b/packages/cli/src/ui/components/AsciiArt.ts index 3202c29e..8a37a5df 100644 --- a/packages/cli/src/ui/components/AsciiArt.ts +++ b/packages/cli/src/ui/components/AsciiArt.ts @@ -20,3 +20,14 @@ export const longAsciiLogo = ` ██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ ╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ `; + +export const tinyAsciiLogo = ` + ███ █████████ +░░░███ ███░░░░░███ + ░░░███ ███ ░░░ + ░░░███░███ + ███░ ░███ █████ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ +`; diff --git a/packages/cli/src/ui/components/AuthInProgress.tsx b/packages/cli/src/ui/components/AuthInProgress.tsx index 196097f2..f05efe1d 100644 --- a/packages/cli/src/ui/components/AuthInProgress.tsx +++ b/packages/cli/src/ui/components/AuthInProgress.tsx @@ -18,8 +18,8 @@ export function AuthInProgress({ }: AuthInProgressProps): React.JSX.Element { const [timedOut, setTimedOut] = useState(false); - useInput((_, key) => { - if (key.escape) { + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) { onTimeout(); } }); @@ -48,7 +48,8 @@ export function AuthInProgress({ ) : ( - Waiting for auth... (Press ESC to cancel) + Waiting for auth... (Press ESC or CTRL+C to + cancel) )} diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx new file mode 100644 index 00000000..d70bb4ca --- /dev/null +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -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, +) => { + useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); + return render(); +}; + +describe('', () => { + 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); + }); +}); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx index bbc564fc..aac828b7 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -5,12 +5,14 @@ */ import React from 'react'; -import { Text } from 'ink'; +import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { type IdeContext, type MCPServerConfig, } from '@qwen-code/qwen-code-core'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { isNarrowWidth } from '../utils/isNarrowWidth.js'; interface ContextSummaryDisplayProps { geminiMdFileCount: number; @@ -29,6 +31,8 @@ export const ContextSummaryDisplay: React.FC = ({ showToolDescriptions, ideContext, }) => { + const { columns: terminalWidth } = useTerminalSize(); + const isNarrow = isNarrowWidth(terminalWidth); const mcpServerCount = Object.keys(mcpServers || {}).length; const blockedMcpServerCount = blockedMcpServers?.length || 0; const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0; @@ -81,30 +85,36 @@ export const ContextSummaryDisplay: React.FC = ({ } 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 = []; - if (openFilesText) { - summaryParts.push(openFilesText); - } - if (geminiMdText) { - summaryParts.push(geminiMdText); - } - if (mcpText) { - summaryParts.push(mcpText); - } - summaryText += summaryParts.join(' | '); + const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean); - // Add ctrl+t hint when MCP servers are available - if (mcpServers && Object.keys(mcpServers).length > 0) { - if (showToolDescriptions) { - summaryText += ' (ctrl+t to toggle)'; - } else { - summaryText += ' (ctrl+t to view)'; - } + if (isNarrow) { + return ( + + Using: + {summaryParts.map((part, index) => ( + + {' '}- {part} + + ))} + + ); } - return {summaryText}; + return ( + + Using: {summaryParts.join(' | ')} + + ); }; diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx new file mode 100644 index 00000000..f0813409 --- /dev/null +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -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 ( + + ({((1 - percentage) * 100).toFixed(0)}% context left) + + ); +}; diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx new file mode 100644 index 00000000..01394d0f --- /dev/null +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -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(); + + 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(); + + stdin.write('\u001B'); // Simulate escape key + + expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST); + }); +}); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx new file mode 100644 index 00000000..1918998c --- /dev/null +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -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 = ({ + onSelect, +}) => { + useInput((_, key) => { + if (key.escape) { + onSelect(FolderTrustChoice.DO_NOT_TRUST); + } + }); + + const options: Array> = [ + { + 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 ( + + + Do you trust this folder? + + Trusting a folder allows Gemini to execute commands it suggests. This + is a security feature to prevent accidental execution in untrusted + directories. + + + + + + ); +}; diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx new file mode 100644 index 00000000..1d511805 --- /dev/null +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -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(); + 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(