Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
b8d61a77e8 chore(release): v0.0.5 2025-08-08 13:45:55 +00:00
329 changed files with 9336 additions and 29032 deletions

View File

@@ -1,65 +0,0 @@
name: Build and Publish Docker Image
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
publish:
description: 'Publish to GHCR (only works on main branch)'
type: boolean
default: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-to-ghcr:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-,format=short
- name: Log in to the Container registry
if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
CLI_VERSION_ARG=${{ github.sha }}

View File

@@ -36,14 +36,6 @@ 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

View File

@@ -2,33 +2,7 @@ name: Qwen Automated Issue Triage
on:
issues:
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'
types: [opened, reopened]
jobs:
triage-issue:
@@ -50,43 +24,34 @@ jobs:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
with:
version: 0.0.7
version: 0.0.4
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: |-
## 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
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:
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Review the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
2. Review the issue title, body and any comments provided in the environment variables.
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 doesnt look like it has sufficient information recommend the status/need-information label.
9. Use Area definitions mentioned below to help you narrow down issues.
## 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 doesnt look like it has sufficient information recommend the status/need-information label
9. Use Area definitions mentioned below to help you narrow down issues
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:
@@ -165,163 +130,3 @@ 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 <issue-number> --json title,body,comments` to fetch its content.
- Also fetch the content of the original issue: `gh issue view "${ISSUE_NUMBER}" --json title,body,comments`.
- Carefully analyze the content (title, body, comments) of the original issue and all potential duplicates.
- It is very important if the comments on either issue mention that they are not duplicates of each other, to treat them as not duplicates.
- Based on your analysis, create a final list containing only the issues you are highly confident are actual duplicates.
- If your final list is empty, you are done.
- Print to the logs if you omitted any potential duplicates based on your analysis.
- If the `duplicates` tool returned 15+ results, use the top 15 matches (based on descending similarity score value) to perform this step.
3. **Format Final Duplicates List:**
Format the final list of duplicates into a markdown string.
The format should be:
"Found possible duplicate issues:\n\n- #${issue_number}\n\nIf you believe this is not a duplicate, please remove the `status/possible-duplicate` label."
Add an HTML comment to the end for identification: `<!-- gemini-cli-deduplication -->`
4. **Check for Existing Comment:**
- Run `gh issue view "${ISSUE_NUMBER}" --json comments` to get all
comments on the issue.
- Look for a comment made by a bot (the author's login often ends in `[bot]`) that contains `<!-- gemini-cli-deduplication -->`.
- If you find such a comment, store its `id` and `body`.
5. **Decide Action:**
- **If an existing comment is found:**
- Compare the new list of duplicate issues with the list from the existing comment's body.
- If they are the same, do nothing.
- If they are different, edit the existing comment. Use
`gh issue comment "${ISSUE_NUMBER}" --edit-comment <comment-id> --body "..."`.
The new body should be the new list of duplicates, but with the header "Found possible duplicate issues (updated):".
- **If no existing comment is found:**
- Create a new comment with the list of duplicates.
- Use `gh issue comment "${ISSUE_NUMBER}" --body "..."`.
6. **Add Duplicate Label:**
- If you created or updated a comment in the previous step, add the `duplicate` label to the current issue.
- Use `gh issue edit "${ISSUE_NUMBER}" --add-label "status/possible-duplicate"`.
## Guidelines
- Only use the `duplicates` and `run_shell_command` tools.
- The `run_shell_command` tool can be used with `gh issue view`, `gh issue comment`, and `gh issue edit`.
- Do not download or read media files like images, videos, or links. The `--json` flag for `gh issue view` will prevent this.
- Do not modify the issue content or status.
- Only comment on and label the current issue.
- Reference all shell variables as "${VAR}" (with quotes and braces).

View File

@@ -3,22 +3,7 @@ name: Qwen Scheduled Issue Triage
on:
schedule:
- cron: '0 * * * *' # Runs every hour
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'
workflow_dispatch: {}
jobs:
triage-issues:
@@ -38,19 +23,16 @@ 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 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 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 "🔄 Merging and deduplicating issues..."
ISSUES=$(echo "$NO_LABEL_ISSUES" "$NEED_TRIAGE_ISSUES" | jq -c -s 'add | unique_by(.number)')
echo '🔄 Merging and deduplicating issues...'
ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
echo "📝 Setting output for GitHub Actions..."
echo "issues_to_triage=$ISSUES" >> "$GITHUB_OUTPUT"
echo '📝 Setting output for GitHub Actions...'
echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}"
echo "✅ Found $(echo "$ISSUES" | jq 'length') issues to triage! 🎯"
- name: Run Qwen Issue Triage
if: steps.find_issues.outputs.issues_to_triage != '[]'
@@ -60,31 +42,24 @@ jobs:
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
REPOSITORY: ${{ github.repository }}
with:
version: 0.0.7
version: 0.0.4
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
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 view)",
"run_shell_command(gh issue list)"
"run_shell_command(gh issue list)",
"run_shell_command(gh issue view)"
],
"sandbox": false
}
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
prompt: |
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels.
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.
@@ -109,10 +84,8 @@ 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.

View File

@@ -1,7 +1,7 @@
name: 🧐 Qwen Pull Request Review
on:
pull_request_target:
pull_request:
types: [opened]
pull_request_review_comment:
types: [created]
@@ -18,11 +18,7 @@ jobs:
review-pr:
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request_target' &&
github.event.action == 'opened' &&
(github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR')) ||
(github.event_name == 'pull_request' && github.event.action == 'opened') ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@qwen /review') &&
@@ -53,9 +49,9 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Get PR details (pull_request_target & workflow_dispatch)
- name: Get PR details (pull_request & workflow_dispatch)
id: get_pr
if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch'
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |

View File

@@ -84,11 +84,6 @@ jobs:
echo "RELEASE_TAG=$(echo $VERSION_JSON | jq -r .releaseTag)" >> $GITHUB_OUTPUT
echo "RELEASE_VERSION=$(echo $VERSION_JSON | jq -r .releaseVersion)" >> $GITHUB_OUTPUT
echo "NPM_TAG=$(echo $VERSION_JSON | jq -r .npmTag)" >> $GITHUB_OUTPUT
# Get the previous tag for release notes generation
CURRENT_TAG=$(echo $VERSION_JSON | jq -r .releaseTag)
PREVIOUS_TAG=$(node scripts/get-previous-tag.js "$CURRENT_TAG" || echo "")
echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> $GITHUB_OUTPUT
env:
IS_NIGHTLY: ${{ steps.vars.outputs.is_nightly }}
MANUAL_VERSION: ${{ inputs.version }}
@@ -163,20 +158,11 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_BRANCH: ${{ steps.release_branch.outputs.BRANCH_NAME }}
run: |
# Build the gh release create command with appropriate options
RELEASE_CMD="gh release create ${{ steps.version.outputs.RELEASE_TAG }} bundle/gemini.js --target \"$RELEASE_BRANCH\" --title \"Release ${{ steps.version.outputs.RELEASE_TAG }}\""
# Add previous tag for release notes if available
if [[ -n "${{ steps.version.outputs.PREVIOUS_TAG }}" ]]; then
echo "Generating release notes from previous tag: ${{ steps.version.outputs.PREVIOUS_TAG }}"
RELEASE_CMD="$RELEASE_CMD --generate-notes --notes-start-tag ${{ steps.version.outputs.PREVIOUS_TAG }}"
else
echo "No previous tag found, generating release notes from repository history"
RELEASE_CMD="$RELEASE_CMD --generate-notes"
fi
# Execute the release command
eval $RELEASE_CMD
gh release create ${{ steps.version.outputs.RELEASE_TAG }} \
bundle/gemini.js \
--target "$RELEASE_BRANCH" \
--title "Release ${{ steps.version.outputs.RELEASE_TAG }}" \
--generate-notes
- name: Create Issue on Failure
if: failure()

View File

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

15
.vscode/settings.json vendored
View File

@@ -1,16 +1,3 @@
{
"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"
}
"typescript.tsserver.experimental.enableProjectDiagnostics": true
}

View File

@@ -1,26 +0,0 @@
# Changelog
## 0.0.7
- Fix MCP tools
- Fix Web Fetch tool
- Fix Web Search tool, by replacing web search from Google/Gemini to Tavily API
- Fix: Compatible with occasional tool call parameters returned by LLM that are invalid JSON
- Fix: prevent concurrent query submissions on some rare cases
- Fix: incorrect qwen logger exit handler setup
- Fix: seperate static QR code and dynamic spin components
- Sync gemini-cli to v0.1.18
## 0.0.6
- Add usage statistics logging for Qwen integration
- Make `/init` command respect configured context filename and align docs with QWEN.md
- Fix EPERM error when run `qwen --sandbox` in macOS
- Fix terminal flicker when waiting for login
- Fix `glm-4.5` model request error
## 0.0.5
- Support Qwen OAuth login and provide up to 2000 free requests per day
- Sync gemini-cli to v0.1.17
- Add systemPromptMappings Configuration Feature

View File

@@ -1,31 +1,3 @@
# Build stage
FROM docker.io/library/node:20-slim AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up npm global package folder
RUN mkdir -p /usr/local/share/npm-global
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Copy source code
COPY . /home/node/app
WORKDIR /home/node/app
# Install dependencies and build packages
RUN npm ci \
&& npm run build --workspaces \
&& npm pack -w @qwen-code/qwen-code --pack-destination ./packages/cli/dist \
&& npm pack -w @qwen-code/qwen-code-core --pack-destination ./packages/core/dist
# Runtime stage
FROM docker.io/library/node:20-slim
ARG SANDBOX_NAME="qwen-code-sandbox"
@@ -33,9 +5,11 @@ ARG CLI_VERSION_ARG
ENV SANDBOX="$SANDBOX_NAME"
ENV CLI_VERSION=$CLI_VERSION_ARG
# Install runtime dependencies
# install minimal set of packages, then clean up
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
man-db \
curl \
dnsutils \
@@ -55,19 +29,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up npm global package folder
RUN mkdir -p /usr/local/share/npm-global
# set up npm global package folder under /usr/local/share
# give it to non-root user node, already set up in base image
RUN mkdir -p /usr/local/share/npm-global \
&& chown -R node:node /usr/local/share/npm-global
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Copy built packages from builder stage
COPY --from=builder /home/node/app/packages/cli/dist/*.tgz /tmp/
COPY --from=builder /home/node/app/packages/core/dist/*.tgz /tmp/
# switch to non-root user node
USER node
# Install built packages globally
RUN npm install -g /tmp/*.tgz \
# install qwen-code and clean up
COPY packages/cli/dist/qwen-code-*.tgz /usr/local/share/npm-global/qwen-code.tgz
COPY packages/core/dist/qwen-code-qwen-code-core-*.tgz /usr/local/share/npm-global/qwen-code-core.tgz
RUN npm install -g /usr/local/share/npm-global/qwen-code.tgz /usr/local/share/npm-global/qwen-code-core.tgz \
&& npm cache clean --force \
&& rm -rf /tmp/*.tgz
&& rm -f /usr/local/share/npm-global/qwen-{code,code-core}.tgz
# Default entrypoint when none specified
CMD ["qwen"]
# default entrypoint when none specified
CMD ["qwen"]

View File

@@ -53,7 +53,7 @@ debug:
run-npx:
npx https://github.com/QwenLM/qwen-code
npx https://github.com/google-gemini/gemini-cli
create-alias:
scripts/create_alias.sh

View File

@@ -15,20 +15,6 @@
</div>
<div align="center">
<!-- Keep these links. Translations will automatically update with the README. -->
<a href="https://readme-i18n.com/de/QwenLM/qwen-code">Deutsch</a> |
<a href="https://readme-i18n.com/es/QwenLM/qwen-code">Español</a> |
<a href="https://readme-i18n.com/fr/QwenLM/qwen-code">français</a> |
<a href="https://readme-i18n.com/ja/QwenLM/qwen-code">日本語</a> |
<a href="https://readme-i18n.com/ko/QwenLM/qwen-code">한국어</a> |
<a href="https://readme-i18n.com/pt/QwenLM/qwen-code">Português</a> |
<a href="https://readme-i18n.com/ru/QwenLM/qwen-code">Русский</a> |
<a href="https://readme-i18n.com/zh/QwenLM/qwen-code">中文</a>
</div>
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
## 💡 Free Options Available
@@ -115,7 +101,7 @@ Create or edit `.qwen/settings.json` in your home directory:
- **`/compress`** - Compress conversation history to continue within token limits
- **`/clear`** - Clear all conversation history and start fresh
- **`/stats`** - Check current token usage and limits
- **`/status`** - Check current token usage and limits
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
@@ -324,7 +310,7 @@ qwen
- `/help` - Display available commands
- `/clear` - Clear conversation history
- `/compress` - Compress history to save tokens
- `/stats` - Show current session information
- `/status` - Show current session information
- `/exit` or `/quit` - Exit Qwen Code
### Keyboard Shortcuts

View File

@@ -56,7 +56,7 @@ find initiatives that interest you.
Gemini CLI is an open-source project, and we welcome contributions from the community! Whether you're a developer, a designer, or just an enthusiastic user you can find our [Community Guidelines here](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) to learn how to get started. There are many ways to get involved:
- **Roadmap:** Please review and find areas in our [roadmap](https://github.com/google-gemini/gemini-cli/issues/4191) that you would like to contribute to. Contributions based on this will be easiest to integrate with.
- **Report Bugs:** If you find an issue, please create a [bug](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priority/p0`.
- **Report Bugs:** If you find an issue, please create a bug(https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priorty/p0`.
- **Suggest Features:** Have a great idea? We'd love to hear it! Open a [feature request](https://github.com/google-gemini/gemini-cli/issues/new?template=feature_request.yml).
- **Contribute Code:** Check out our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) file for guidelines on how to submit pull requests. We have a list of "good first issues" for new contributors.
- **Write Documentation:** Help us improve our documentation, tutorials, and examples.

View File

@@ -1,5 +1,8 @@
# Reporting Security Issues
Please report any security issue or Higress crash report to [ASRC](https://security.alibaba.com/) (Alibaba Security Response Center) where the issue will be triaged appropriately.
To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz).
We use g.co/vulnz for our intake, and do coordination and disclosure here on
GitHub (including using GitHub Security Advisory). The Google Security Team will
respond within 5 working days of your report on g.co/vulnz.
Thank you for helping keep our project secure.
[GitHub Security Advisory]: https://github.com/google-gemini/gemini-cli/security/advisories

View File

@@ -27,9 +27,6 @@ Slash commands provide meta-level control over the CLI itself.
- **Usage:** `/chat resume <tag>`
- **`list`**
- **Description:** Lists available tags for chat state resumption.
- **`delete`**
- **Description:** Deletes a saved conversation checkpoint.
- **Usage:** `/chat delete <tag>`
- **`/clear`**
- **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared.
@@ -49,18 +46,7 @@ Slash commands provide meta-level control over the CLI itself.
- **Usage:** `/directory add <path1>,<path2>`
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
- **`show`**:
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
- **Usage:** `/directory show`
- **`/directory`** (or **`/dir`**)
- **Description:** Manage workspace directories for multi-directory support.
- **Sub-commands:**
- **`add`**:
- **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well.
- **Usage:** `/directory add <path1>,<path2>`
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
- **`show`**:
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
- **Description:** Display all directories added by `/direcotry add` and `--include-directories`.
- **Usage:** `/directory show`
- **`/editor`**
@@ -84,26 +70,21 @@ Slash commands provide meta-level control over the CLI itself.
- **Keyboard Shortcut:** Press **Ctrl+T** at any time to toggle between showing and hiding tool descriptions.
- **`/memory`**
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `QWEN.md` files by default; configurable via `contextFileName`).
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `GEMINI.md` files).
- **Sub-commands:**
- **`add`**:
- **Description:** Adds the following text to the AI's memory. Usage: `/memory add <text to remember>`
- **`show`**:
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all context files (e.g., `QWEN.md`). This lets you inspect the instructional context being provided to the model.
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all `GEMINI.md` files. This lets you inspect the instructional context being provided to the Gemini model.
- **`refresh`**:
- **Description:** Reload the hierarchical instructional memory from all context files (default: `QWEN.md`) found in the configured locations (global, project/ancestors, and sub-directories). This updates the model with the latest context content.
- **Note:** For more details on how context files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#context-files-hierarchical-instructional-context).
- **Description:** Reload the hierarchical instructional memory from all `GEMINI.md` files found in the configured locations (global, project/ancestors, and sub-directories). This command updates the model with the latest `GEMINI.md` content.
- **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#4-geminimd-files-hierarchical-instructional-context).
- **`/restore`**
- **Description:** Restores the project files to the state they were in just before a tool was executed. This is particularly useful for undoing file edits made by a tool. If run without a tool call ID, it will list available checkpoints to restore from.
- **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.
@@ -142,7 +123,7 @@ Slash commands provide meta-level control over the CLI itself.
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer
- **`/init`**
- **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions.
- **Description:** To help users easily create a `GEMINI.md` file, this command analyzes the current directory and generates a tailored context file, making it simpler for them to provide project-specific instructions to the Gemini agent.
### Custom Commands
@@ -272,7 +253,7 @@ Please generate a Conventional Commit message based on the following git diff:
```diff
!{git diff --staged}
```
````
"""
@@ -293,7 +274,7 @@ First, ensure the user commands directory exists, then create a `refactor` subdi
```bash
mkdir -p ~/.gemini/commands/refactor
touch ~/.gemini/commands/refactor/pure.toml
```
````
**2. Add the content to the file:**

View File

@@ -38,8 +38,8 @@ In addition to a project settings file, a project's `.gemini` directory can cont
### Available settings in `settings.json`:
- **`contextFileName`** (string or array of strings):
- **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
- **Default:** `QWEN.md`
- **Description:** Specifies the filename for context files (e.g., `GEMINI.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
- **Default:** `GEMINI.md`
- **Example:** `"contextFileName": "AGENTS.md"`
- **`bugCommand`** (object):
@@ -248,50 +248,6 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
```
- **`includeDirectories`** (array of strings):
- **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. This allows you to work with files across multiple directories as if they were one. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag.
- **Default:** `[]`
- **Example:**
```json
"includeDirectories": [
"/path/to/another/project",
"../shared-library",
"~/common-utils"
]
```
- **`loadMemoryFromIncludeDirectories`** (boolean):
- **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory.
- **Default:** `false`
- **Example:**
```json
"loadMemoryFromIncludeDirectories": true
```
- **`tavilyApiKey`** (string):
- **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`:
```json
@@ -300,7 +256,6 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"sandbox": "docker",
"toolDiscoveryCommand": "bin/get_tools",
"toolCallCommand": "bin/call_tool",
"tavilyApiKey": "$TAVILY_API_KEY",
"mcpServers": {
"mainServer": {
"command": "bin/mcp_server.py"
@@ -325,9 +280,7 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"tokenBudget": 100
}
},
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"],
"includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"],
"loadMemoryFromIncludeDirectories": true
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
}
```
@@ -398,11 +351,6 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- **`CODE_ASSIST_ENDPOINT`**:
- Specifies the endpoint for the code assist server.
- This is useful for development and testing.
- **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service.
- Required to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped.
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments
@@ -460,9 +408,6 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `gemini --tavily-api-key tvly-your-api-key-here`
## Context Files (Hierarchical Instructional Context)
@@ -470,7 +415,7 @@ While not strictly configuration for the CLI's _behavior_, context files (defaul
- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Gemini model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically.
### Example Context File Content (e.g., `QWEN.md`)
### Example Context File Content (e.g., `GEMINI.md`)
Here's a conceptual example of what a context file at the root of a TypeScript project might contain:
@@ -505,9 +450,9 @@ Here's a conceptual example of what a context file at the root of a TypeScript p
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `GEMINI.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
1. **Global Context File:**
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
- Location: `~/.gemini/<contextFileName>` (e.g., `~/.gemini/GEMINI.md` in your user home directory).
- Scope: Provides default instructions for all your projects.
2. **Project Root & Ancestors Context Files:**
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
@@ -578,5 +523,3 @@ You can opt out of usage statistics collection at any time by setting the `usage
"usageStatisticsEnabled": false
}
```
Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint.

View File

@@ -5,14 +5,14 @@ Gemini CLI's core package (`packages/core`) is the backend portion of Gemini CLI
## Navigating this section
- **[Core tools API](./tools-api.md):** Information on how tools are defined, registered, and used by the core.
- **[Memory Import Processor](./memport.md):** Documentation for the modular QWEN.md import feature using @file.md syntax.
- **[Memory Import Processor](./memport.md):** Documentation for the modular GEMINI.md import feature using @file.md syntax.
## Role of the core
While the `packages/cli` portion of Gemini CLI provides the user interface, `packages/core` is responsible for:
- **Gemini API interaction:** Securely communicating with the Google Gemini API, sending user prompts, and receiving model responses.
- **Prompt engineering:** Constructing effective prompts for the model, potentially incorporating conversation history, tool definitions, and instructional context from context files (e.g., `QWEN.md`).
- **Prompt engineering:** Constructing effective prompts for the Gemini model, potentially incorporating conversation history, tool definitions, and instructional context from `GEMINI.md` files.
- **Tool management & orchestration:**
- Registering available tools (e.g., file system tools, shell command execution).
- Interpreting tool use requests from the Gemini model.
@@ -48,8 +48,8 @@ The file discovery service is responsible for finding files in the project that
## Memory discovery service
The memory discovery service is responsible for finding and loading the context files (default: `QWEN.md`) that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
The memory discovery service is responsible for finding and loading the `GEMINI.md` files that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
This allows you to have global, project-level, and component-level context files, which are all combined to provide the model with the most relevant information.
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded context files.
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded `GEMINI.md` files.

View File

@@ -1,17 +1,17 @@
# Memory Import Processor
The Memory Import Processor is a feature that allows you to modularize your context files (e.g., `QWEN.md`) by importing content from other files using the `@file.md` syntax.
The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other files using the `@file.md` syntax.
## Overview
This feature enables you to break down large context files (e.g., `QWEN.md`) into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
This feature enables you to break down large GEMINI.md files into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
## Syntax
Use the `@` symbol followed by the path to the file you want to import:
```markdown
# Main QWEN.md file
# Main GEMINI.md file
This is the main content.
@@ -39,7 +39,7 @@ More content here.
### Basic Import
```markdown
# My QWEN.md
# My GEMINI.md
Welcome to my project!
@@ -110,13 +110,13 @@ The import processor uses the `marked` library to detect code blocks and inline
## Import Tree Structure
The processor returns an import tree that shows the hierarchy of imported files. This helps users debug problems with their context files by showing which files were read and their import relationships.
The processor returns an import tree that shows the hierarchy of imported files, similar to Claude's `/memory` feature. This helps users debug problems with their GEMINI.md files by showing which files were read and their import relationships.
Example tree structure:
```
Memory Files
L project: QWEN.md
Memory Files
L project: GEMINI.md
L a.md
L b.md
L c.md
@@ -138,7 +138,7 @@ Note: The import tree is mainly for clarity during development and has limited r
### `processImports(content, basePath, debugMode?, importState?)`
Processes import statements in context file content.
Processes import statements in GEMINI.md content.
**Parameters:**

View File

@@ -15,11 +15,9 @@ The Gemini CLI core (`packages/core`) features a robust system for defining, reg
- `execute()`: The core method that performs the tool's action and returns a `ToolResult`.
- **`ToolResult` (`tools.ts`):** An interface defining the structure of a tool's execution outcome:
- `llmContent`: The factual content to be included in the history sent back to the LLM for context. This can be a simple string or a `PartListUnion` (an array of `Part` objects and strings) for rich content.
- `llmContent`: The factual string content to be included in the history sent back to the LLM for context.
- `returnDisplay`: A user-friendly string (often Markdown) or a special object (like `FileDiff`) for display in the CLI.
- **Returning Rich Content:** Tools are not limited to returning simple text. The `llmContent` can be a `PartListUnion`, which is an array that can contain a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a single tool execution to return multiple pieces of rich content.
- **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for:
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`).
- **Discovering Tools:** It can also discover tools dynamically:

View File

@@ -1,23 +1,23 @@
# Qwen Code Extensions
# Gemini CLI Extensions
Qwen Code supports extensions that can be used to configure and extend its functionality.
Gemini CLI supports extensions that can be used to configure and extend its functionality.
## How it works
On startup, Qwen Code looks for extensions in two locations:
On startup, Gemini CLI looks for extensions in two locations:
1. `<workspace>/.qwen/extensions`
2. `<home>/.qwen/extensions`
1. `<workspace>/.gemini/extensions`
2. `<home>/.gemini/extensions`
Qwen Code loads all extensions from both locations. If an extension with the same name exists in both locations, the extension in the workspace directory takes precedence.
Gemini CLI loads all extensions from both locations. If an extension with the same name exists in both locations, the extension in the workspace directory takes precedence.
Within each location, individual extensions exist as a directory that contains a `qwen-extension.json` file. For example:
Within each location, individual extensions exist as a directory that contains a `gemini-extension.json` file. For example:
`<workspace>/.qwen/extensions/my-extension/qwen-extension.json`
`<workspace>/.gemini/extensions/my-extension/gemini-extension.json`
### `qwen-extension.json`
### `gemini-extension.json`
The `qwen-extension.json` file contains the configuration for the extension. The file has the following structure:
The `gemini-extension.json` file contains the configuration for the extension. The file has the following structure:
```json
{
@@ -28,7 +28,7 @@ The `qwen-extension.json` file contains the configuration for the extension. The
"command": "node my-server.js"
}
},
"contextFileName": "QWEN.md",
"contextFileName": "GEMINI.md",
"excludeTools": ["run_shell_command"]
}
```
@@ -36,10 +36,10 @@ The `qwen-extension.json` file contains the configuration for the extension. The
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands.
- `version`: The version of the extension.
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command.
When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
## Extension Commands
@@ -50,8 +50,8 @@ Extensions can provide [custom commands](./cli/commands.md#custom-commands) by p
An extension named `gcp` with the following structure:
```
.qwen/extensions/gcp/
├── qwen-extension.json
.gemini/extensions/gcp/
├── gemini-extension.json
└── commands/
├── deploy.toml
└── gcs/

View File

@@ -28,7 +28,7 @@ This documentation is organized into the following sections:
- **[Multi-File Read Tool](./tools/multi-file.md):** Documentation for the `read_many_files` tool.
- **[Shell Tool](./tools/shell.md):** Documentation for the `run_shell_command` tool.
- **[Web Fetch Tool](./tools/web-fetch.md):** Documentation for the `web_fetch` tool.
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `web_search` tool.
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `google_web_search` tool.
- **[Memory Tool](./tools/memory.md):** Documentation for the `save_memory` tool.
- **[Contributing & Development Guide](../CONTRIBUTING.md):** Information for contributors and developers, including setup, building, testing, and coding conventions.
- **[NPM Workspaces and Publishing](./npm.md):** Details on how the project's packages are managed and published.

View File

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

View File

@@ -58,17 +58,7 @@ 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
# 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?"
gemini --telemetry --telemetry-target=local --telemetry-outfile=/path/to/telemetry.log "your prompt"
```
## Running an OTEL Collector
@@ -183,10 +173,9 @@ Logs are timestamped records of specific events. The following events are logged
- `function_args`
- `duration_ms`
- `success` (boolean)
- `decision` (string: "accept", "reject", "auto_accept", or "modify", if applicable)
- `decision` (string: "accept", "reject", 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**:
@@ -263,7 +252,3 @@ 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.

View File

@@ -169,7 +169,6 @@ Use the `/mcp auth` command to manage OAuth authentication:
- **`scopes`** (string[]): Required OAuth scopes
- **`redirectUri`** (string): Custom redirect URI (defaults to `http://localhost:7777/oauth/callback`)
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
- **`audiences`** (string[]): Audiences the token is valid for
#### Token Management
@@ -572,56 +571,6 @@ The MCP integration tracks several states:
This comprehensive integration makes MCP servers a powerful way to extend the Gemini CLI's capabilities while maintaining security, reliability, and ease of use.
## Returning Rich Content from Tools
MCP tools are not limited to returning simple text. You can return rich, multi-part content, including text, images, audio, and other binary data in a single tool response. This allows you to build powerful tools that can provide diverse information to the model in a single turn.
All data returned from the tool is processed and sent to the model as context for its next generation, enabling it to reason about or summarize the provided information.
### How It Works
To return rich content, your tool's response must adhere to the MCP specification for a [`CallToolResult`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result). The `content` field of the result should be an array of `ContentBlock` objects. The Gemini CLI will correctly process this array, separating text from binary data and packaging it for the model.
You can mix and match different content block types in the `content` array. The supported block types include:
- `text`
- `image`
- `audio`
- `resource` (embedded content)
- `resource_link`
### Example: Returning Text and an Image
Here is an example of a valid JSON response from an MCP tool that returns both a text description and an image:
```json
{
"content": [
{
"type": "text",
"text": "Here is the logo you requested."
},
{
"type": "image",
"data": "BASE64_ENCODED_IMAGE_DATA_HERE",
"mimeType": "image/png"
},
{
"type": "text",
"text": "The logo was created in 2025."
}
]
}
```
When the Gemini CLI receives this response, it will:
1. Extract all the text and combine it into a single `functionResponse` part for the model.
2. Present the image data as a separate `inlineData` part.
3. Provide a clean, user-friendly summary in the CLI, indicating that both text and an image were received.
This enables you to build sophisticated tools that can provide rich, multi-modal context to the Gemini model.
## MCP Prompts as Slash Commands
In addition to tools, MCP servers can expose predefined prompts that can be executed as slash commands within the Gemini CLI. This allows you to create shortcuts for common or complex queries that can be easily invoked by name.
@@ -688,114 +637,3 @@ 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] <name> <commandOrUrl> [args...]
```
- `<name>`: A unique name for the server.
- `<commandOrUrl>`: The command to execute (for `stdio`) or the URL (for `http`/`sse`).
- `[args...]`: Optional arguments for a `stdio` command.
**Options (Flags):**
- `-s, --scope`: Configuration scope (user or project). [default: "project"]
- `-t, --transport`: Transport type (stdio, sse, http). [default: "stdio"]
- `-e, --env`: Set environment variables (e.g. -e KEY=value).
- `-H, --header`: Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123").
- `--timeout`: Set connection timeout in milliseconds.
- `--trust`: Trust the server (bypass all tool call confirmation prompts).
- `--description`: Set the description for the server.
- `--include-tools`: A comma-separated list of tools to include.
- `--exclude-tools`: A comma-separated list of tools to exclude.
#### Adding an stdio server
This is the default transport for running local servers.
```bash
# Basic syntax
gemini mcp add <name> <command> [args...]
# Example: Adding a local server
gemini mcp add my-stdio-server -e API_KEY=123 /path/to/server arg1 arg2 arg3
# Example: Adding a local python server
gemini mcp add python-server python server.py --port 8080
```
#### Adding an HTTP server
This transport is for servers that use the streamable HTTP transport.
```bash
# Basic syntax
gemini mcp add --transport http <name> <url>
# Example: Adding an HTTP server
gemini mcp add --transport http http-server https://api.example.com/mcp/
# Example: Adding an HTTP server with an authentication header
gemini mcp add --transport http secure-http https://api.example.com/mcp/ --header "Authorization: Bearer abc123"
```
#### Adding an SSE server
This transport is for servers that use Server-Sent Events (SSE).
```bash
# Basic syntax
gemini mcp add --transport sse <name> <url>
# Example: Adding an SSE server
gemini mcp add --transport sse sse-server https://api.example.com/sse/
# Example: Adding an SSE server with an authentication header
gemini mcp add --transport sse secure-sse https://api.example.com/sse/ --header "Authorization: Bearer abc123"
```
### Listing Servers (`gemini mcp list`)
To view all MCP servers currently configured, use the `list` command. It displays each server's name, configuration details, and connection status.
**Command:**
```bash
gemini mcp list
```
**Example Output:**
```sh
✓ stdio-server: command: python3 server.py (stdio) - Connected
✓ http-server: https://api.example.com/mcp (http) - Connected
✗ sse-server: https://api.example.com/sse (sse) - Disconnected
```
### Removing a Server (`gemini mcp remove`)
To delete a server from your configuration, use the `remove` command with the server's name.
**Command:**
```bash
gemini mcp remove <name>
```
**Example:**
```bash
gemini mcp remove my-server
```
This will find and delete the "my-server" entry from the `mcpServers` object in the appropriate `settings.json` file based on the scope (`-s, --scope`).

View File

@@ -4,7 +4,7 @@ This document describes the `save_memory` tool for the Gemini CLI.
## Description
Use `save_memory` to save and recall information across your Qwen Code sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
Use `save_memory` to save and recall information across your Gemini CLI sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
### Arguments
@@ -14,9 +14,9 @@ Use `save_memory` to save and recall information across your Qwen Code sessions.
## How to use `save_memory` with the Gemini CLI
The tool appends the provided `fact` to your context file in the user's home directory (`~/.qwen/QWEN.md` by default). This filename can be configured via `contextFileName`.
The tool appends the provided `fact` to a special `GEMINI.md` file located in the user's home directory (`~/.gemini/GEMINI.md`). This file can be configured to have a different name.
Once added, the facts are stored under a `## Qwen Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
Once added, the facts are stored under a `## Gemini Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
Usage:

View File

@@ -52,7 +52,7 @@ Read the main README, all Markdown files in the `docs` directory, and a specific
read_many_files(paths=["README.md", "docs/**/*.md", "assets/logo.png"], exclude=["docs/OLD_README.md"])
```
Read all JavaScript files but explicitly include test files and all JPEGs in an `images` folder:
Read all JavaScript files but explicitly including test files and all JPEGs in an `images` folder:
```
read_many_files(paths=["**/*.js"], include=["**/*.test.js", "images/**/*.jpg"], useDefaultExcludes=False)

View File

@@ -137,5 +137,6 @@ To block all shell commands, add the `run_shell_command` wildcard to `excludeToo
## Security Note for `excludeTools`
Command-specific restrictions in `excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
Command-specific restrictions in
`excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
that can be executed.

View File

@@ -4,25 +4,24 @@ This document describes the `web_fetch` tool for the Gemini CLI.
## Description
Use `web_fetch` to fetch content from a specified URL and process it using an AI model. The tool takes a URL and a prompt as input, fetches the URL content, converts HTML to markdown, and processes the content with the prompt using a small, fast model.
Use `web_fetch` to summarize, compare, or extract information from web pages. The `web_fetch` tool processes content from one or more URLs (up to 20) embedded in a prompt. `web_fetch` takes a natural language prompt and returns a generated response.
### Arguments
`web_fetch` takes two arguments:
`web_fetch` takes one argument:
- `url` (string, required): The URL to fetch content from. Must be a fully-formed valid URL starting with `http://` or `https://`.
- `prompt` (string, required): The prompt describing what information you want to extract from the page content.
- `prompt` (string, required): A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content. For example: `"Summarize https://example.com/article and extract key points from https://another.com/data"`. The prompt must contain at least one URL starting with `http://` or `https://`.
## How to use `web_fetch` with the Gemini CLI
To use `web_fetch` with the Gemini CLI, provide a URL and a prompt describing what you want to extract from that URL. The tool will ask for confirmation before fetching the URL. Once confirmed, the tool will fetch the content directly and process it using an AI model.
To use `web_fetch` with the Gemini CLI, provide a natural language prompt that contains URLs. The tool will ask for confirmation before fetching any URLs. Once confirmed, the tool will process URLs through Gemini API's `urlContext`.
The tool automatically converts HTML to text, handles GitHub blob URLs (converting them to raw URLs), and upgrades HTTP URLs to HTTPS for security.
If the Gemini API cannot access the URL, the tool will fall back to fetching content directly from the local machine. The tool will format the response, including source attribution and citations where possible. The tool will then provide the response to the user.
Usage:
```
web_fetch(url="https://example.com", prompt="Summarize the main points of this article")
web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
```
## `web_fetch` examples
@@ -30,25 +29,16 @@ web_fetch(url="https://example.com", prompt="Summarize the main points of this a
Summarize a single article:
```
web_fetch(url="https://example.com/news/latest", prompt="Can you summarize the main points of this article?")
web_fetch(prompt="Can you summarize the main points of https://example.com/news/latest")
```
Extract specific information:
Compare two articles:
```
web_fetch(url="https://arxiv.org/abs/2401.0001", prompt="What are the key findings and methodology described in this paper?")
```
Analyze GitHub documentation:
```
web_fetch(url="https://github.com/google/gemini-react/blob/main/README.md", prompt="What are the installation steps and main features?")
web_fetch(prompt="What are the differences in the conclusions of these two papers: https://arxiv.org/abs/2401.0001 and https://arxiv.org/abs/2401.0002?")
```
## Important notes
- **Single URL processing:** `web_fetch` processes one URL at a time. To analyze multiple URLs, make separate calls to the tool.
- **URL format:** The tool automatically upgrades HTTP URLs to HTTPS and converts GitHub blob URLs to raw format for better content access.
- **Content processing:** The tool fetches content directly and processes it using an AI model, converting HTML to readable text format.
- **URL processing:** `web_fetch` relies on the Gemini API's ability to access and process the given URLs.
- **Output quality:** The quality of the output will depend on the clarity of the instructions in the prompt.
- **MCP tools:** If an MCP-provided web fetch tool is available (starting with "mcp\_\_"), prefer using that tool as it may have fewer restrictions.

View File

@@ -1,43 +1,36 @@
# Web Search Tool (`web_search`)
# Web Search Tool (`google_web_search`)
This document describes the `web_search` tool.
This document describes the `google_web_search` tool.
## Description
Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible.
Use `google_web_search` to perform a web search using Google Search via the Gemini API. The `google_web_search` tool returns a summary of web results with sources.
### Arguments
`web_search` takes one argument:
`google_web_search` takes one argument:
- `query` (string, required): The search query.
## How to use `web_search`
## How to use `google_web_search` with the Gemini CLI
`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods:
1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json`
2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file
3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI
If the key is not configured, the tool will be disabled and skipped.
The `google_web_search` tool sends a query to the Gemini API, which then performs a web search. `google_web_search` will return a generated response based on the search results, including citations and sources.
Usage:
```
web_search(query="Your query goes here.")
google_web_search(query="Your query goes here.")
```
## `web_search` examples
## `google_web_search` examples
Get information on a topic:
```
web_search(query="latest advancements in AI-powered code generation")
google_web_search(query="latest advancements in AI-powered code generation")
```
## Important notes
- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links.
- **Citations:** Source links are appended as a numbered list.
- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered.
- **Response returned:** The `google_web_search` tool returns a processed summary, not a raw list of search results.
- **Citations:** The response includes citations to the sources used to generate the summary.

View File

@@ -1,38 +1,28 @@
# Troubleshooting guide
# Troubleshooting Guide
This guide provides solutions to common issues and debugging tips, including topics on:
This guide provides solutions to common issues and debugging tips.
- Authentication or login errors
- Frequently asked questions (FAQs)
- Debugging tips
- Existing GitHub Issues similar to yours or creating new Issues
## Authentication or login errors
## Authentication
- **Error: `Failed to login. Message: Request contains an invalid argument`**
- Users with Google Workspace accounts or Google Cloud accounts
- Users with Google Workspace accounts, or users with Google Cloud accounts
associated with their Gmail accounts may not be able to activate the free
tier of the Google Code Assist plan.
- For Google Cloud accounts, you can work around this by setting
`GOOGLE_CLOUD_PROJECT` to your project ID.
- Alternatively, you can obtain the Gemini API key from
[Google AI Studio](http://aistudio.google.com/app/apikey), which also includes a
- You can also grab an API key from [AI Studio](https://aistudio.google.com/app/apikey), which also includes a
separate free tier.
## Frequently asked questions (FAQs)
- **Q: How do I update Gemini CLI to the latest version?**
- A: If you installed it globally via `npm`, update it using the command `npm install -g @google/gemini-cli@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
- A: If installed globally via npm, update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`. If run from source, pull the latest changes from the repository and rebuild using `npm run build`.
- **Q: Where are the Gemini CLI configuration or settings files stored?**
- A: The Gemini CLI configuration is stored in two `settings.json` files:
1. In your home directory: `~/.gemini/settings.json`.
2. In your project's root directory: `./.gemini/settings.json`.
Refer to [Gemini CLI Configuration](./cli/configuration.md) for more details.
- **Q: Where are Gemini CLI configuration files stored?**
- A: The CLI configuration is stored within two `settings.json` files: one in your home directory and one in your project's root directory. In both locations, `settings.json` is found in the `.gemini/` folder. Refer to [CLI Configuration](./cli/configuration.md) for more details.
- **Q: Why don't I see cached token counts in my stats output?**
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Gemini Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command in Gemini CLI.
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Vertex AI) but not for OAuth users (Google Personal/Enterprise accounts) at this time, as the Code Assist API does not support cached content creation. You can still view your total token usage with the `/stats` command.
## Common error messages and solutions
@@ -41,27 +31,26 @@ This guide provides solutions to common issues and debugging tips, including top
- **Solution:**
Either stop the other process that is using the port or configure the MCP server to use a different port.
- **Error: Command not found (when attempting to run Gemini CLI with `gemini`).**
- **Cause:** Gemini CLI is not correctly installed or it is not in your system's `PATH`.
- **Error: Command not found (when attempting to run Gemini CLI).**
- **Cause:** Gemini CLI is not correctly installed or not in your system's PATH.
- **Solution:**
The update depends on how you installed Gemini CLI:
- If you installed `gemini` globally, check that your `npm` global binary directory is in your `PATH`. You can update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`.
- If you are running `gemini` from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`). To update Gemini CLI, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
1. Ensure Gemini CLI installation was successful.
2. If installed globally, check that your npm global binary directory is in your PATH.
3. If running from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`).
- **Error: `MODULE_NOT_FOUND` or import errors.**
- **Cause:** Dependencies are not installed correctly, or the project hasn't been built.
- **Solution:**
1. Run `npm install` to ensure all dependencies are present.
2. Run `npm run build` to compile the project.
3. Verify that the build completed successfully with `npm run start`.
- **Error: "Operation not permitted", "Permission denied", or similar.**
- **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory.
- **Solution:** Refer to the [Configuration: Sandboxing](./cli/configuration.md#sandboxing) documentation for more information, including how to customize your sandbox configuration.
- **Cause:** If sandboxing is enabled, then the application is likely attempting an operation restricted by your sandbox, such as writing outside the project directory or system temp directory.
- **Solution:** See [Sandboxing](./cli/configuration.md#sandboxing) for more information, including how to customize your sandbox configuration.
- **Gemini CLI is not running in interactive mode in "CI" environments**
- **Issue:** The Gemini CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment.
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the Gemini CLI from starting in its interactive mode.
- **CLI is not interactive in "CI" environments**
- **Issue:** The CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment.
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode.
- **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN gemini`
- **DEBUG mode not working from project .env file**
@@ -83,11 +72,9 @@ This guide provides solutions to common issues and debugging tips, including top
- **Tool issues:**
- If a specific tool is failing, try to isolate the issue by running the simplest possible version of the command or operation the tool performs.
- For `run_shell_command`, check that the command works directly in your shell first.
- For _file system tools_, verify that paths are correct and check the permissions.
- For file system tools, double-check paths and permissions.
- **Pre-flight checks:**
- Always run `npm run preflight` before committing code. This can catch many common issues related to formatting, linting, and type errors.
## Existing GitHub Issues similar to yours or creating new Issues
If you encounter an issue that was not covered here in this _Troubleshooting guide_, consider searching the Gemini CLI [Issue tracker on GitHub](https://github.com/google-gemini/gemini-cli/issues). If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome!
If you encounter an issue not covered here, consider searching the project's issue tracker on GitHub or reporting a new issue with detailed information.

View File

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

View File

@@ -9,11 +9,6 @@ import { strict as assert } from 'assert';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to search the web', async () => {
// Skip if Tavily key is not configured
if (!process.env.TAVILY_API_KEY) {
console.warn('Skipping web search test: TAVILY_API_KEY not set');
return;
}
const rig = new TestRig();
await rig.setup('should be able to search the web');
@@ -23,19 +18,16 @@ test('should be able to search the web', async () => {
} catch (error) {
// Network errors can occur in CI environments
if (
error instanceof Error &&
(error.message.includes('network') || error.message.includes('timeout'))
error.message.includes('network') ||
error.message.includes('timeout')
) {
console.warn(
'Skipping test due to network error:',
(error as Error).message,
);
console.warn('Skipping test due to network error:', error.message);
return; // Skip the test
}
throw error; // Re-throw if not a network error
}
const foundToolCall = await rig.waitForToolCall('web_search');
const foundToolCall = await rig.waitForToolCall('google_web_search');
// Add debugging information
if (!foundToolCall) {
@@ -43,11 +35,12 @@ test('should be able to search the web', async () => {
// Check if the tool call failed due to network issues
const failedSearchCalls = allTools.filter(
(t) => t.toolRequest.name === 'web_search' && !t.toolRequest.success,
(t) =>
t.toolRequest.name === 'google_web_search' && !t.toolRequest.success,
);
if (failedSearchCalls.length > 0) {
console.warn(
'web_search tool was called but failed, possibly due to network issues',
'google_web_search tool was called but failed, possibly due to network issues',
);
console.warn(
'Failed calls:',
@@ -57,20 +50,20 @@ test('should be able to search the web', async () => {
}
}
assert.ok(foundToolCall, 'Expected to find a call to web_search');
assert.ok(foundToolCall, 'Expected to find a call to google_web_search');
// Validate model output - will throw if no output, warn if missing expected content
const hasExpectedContent = validateModelOutput(
result,
['weather', 'london'],
'Web search test',
'Google web search test',
);
// If content was missing, log the search queries used
if (!hasExpectedContent) {
const searchCalls = rig
.readToolLogs()
.filter((t) => t.toolRequest.name === 'web_search');
.filter((t) => t.toolRequest.name === 'google_web_search');
if (searchCalls.length > 0) {
console.warn(
'Search queries used:',

View File

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

View File

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

View File

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

View File

@@ -14,8 +14,11 @@ 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
@@ -182,7 +185,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)

View File

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

View File

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

2778
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.8-nightly.5",
"version": "0.0.5",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.8-nightly.5"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.5"
},
"scripts": {
"start": "node scripts/start.js",
@@ -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 --if-present",
"test": "npm run test --workspaces",
"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 --experimental-cli --write .",
"format": "prettier --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,10 +83,8 @@
"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",
"mnemonist": "^0.40.3"
"yargs": "^17.7.2"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.8-nightly.5",
"version": "0.0.5",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,13 +25,12 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.8-nightly.5"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.5"
},
"dependencies": {
"@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",
@@ -54,7 +53,7 @@
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"undici": "^7.10.0",
"tiktoken": "^1.0.21",
"update-notifier": "^7.3.1",
"yargs": "^17.7.2",
"zod": "^3.23.8"
@@ -76,8 +75,7 @@
"pretty-format": "^30.0.2",
"react-dom": "^19.1.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1",
"@qwen-code/qwen-code-test-utils": "file:../test-utils"
"vitest": "^3.1.1"
},
"engines": {
"node": ">=20"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,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 { loadCliConfig, parseArguments, CliArgs } from './config.js';
import { Settings } from './settings.js';
import { Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
@@ -47,7 +44,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
},
loadEnvironment: vi.fn(),
loadServerHierarchicalMemory: vi.fn(
(cwd, dirs, debug, fileService, extensionPaths, _maxDirs) =>
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
Promise.resolve({
memoryContent: extensionPaths?.join(',') || '',
fileCount: extensionPaths?.length || 0,
@@ -502,7 +499,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
await loadCliConfig(settings, extensions, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[],
false,
expect.any(Object),
[
@@ -636,17 +632,6 @@ 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[] = [
@@ -741,8 +726,7 @@ describe('mergeExcludeTools', () => {
expect(config.getExcludeTools()).toHaveLength(4);
});
it('should return an empty array when no excludeTools are specified and it is interactive', async () => {
process.stdin.isTTY = true;
it('should return an empty array when no excludeTools are specified', async () => {
const settings: Settings = {};
const extensions: Extension[] = [];
process.argv = ['node', 'script.js'];
@@ -756,21 +740,6 @@ 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();
@@ -1093,7 +1062,7 @@ describe('loadCliConfig ideModeFeature', () => {
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
delete process.env.SANDBOX;
delete process.env.QWEN_CODE_IDE_SERVER_PORT;
delete process.env.GEMINI_CLI_IDE_SERVER_PORT;
});
afterEach(() => {
@@ -1109,322 +1078,14 @@ describe('loadCliConfig ideModeFeature', () => {
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeModeFeature()).toBe(false);
});
});
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 () => {
it('should be false when settings.ideModeFeature is true, but SANDBOX is set', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { folderTrustFeature: true };
process.env.TERM_PROGRAM = 'vscode';
process.env.SANDBOX = 'true';
const settings: Settings = { ideModeFeature: 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<typeof fs>('fs');
const MOCK_CWD1 = process.cwd();
const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project');
const mockPaths = new Set([
MOCK_CWD1,
MOCK_CWD2,
path.resolve(path.sep, 'cli', 'path1'),
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(MOCK_CWD2, 'cli', 'path2'),
path.join(MOCK_CWD2, 'settings', 'path3'),
]);
return {
...actualFs,
existsSync: vi.fn((p) => mockPaths.has(p.toString())),
statSync: vi.fn((p) => {
if (mockPaths.has(p.toString())) {
return { isDirectory: () => true };
}
// Fallback for other paths if needed, though the test should be specific.
return actualFs.statSync(p);
}),
realpathSync: vi.fn((p) => p),
};
});
describe('loadCliConfig with includeDirectories', () => {
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';
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve(path.sep, 'home', 'user', 'project'),
);
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should combine and resolve paths from settings and CLI arguments', async () => {
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
process.argv = [
'node',
'script.js',
'--include-directories',
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
];
const argv = await parseArguments();
const settings: Settings = {
includeDirectories: [
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
],
};
const config = await loadCliConfig(settings, [], 'test-session', argv);
const expected = [
mockCwd,
path.resolve(path.sep, 'cli', 'path1'),
path.join(mockCwd, 'cli', 'path2'),
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
];
expect(config.getWorkspaceContext().getDirectories()).toEqual(
expect.arrayContaining(expected),
);
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
expected.length,
);
});
});
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);
expect(config.getIdeModeFeature()).toBe(false);
});
});

View File

@@ -10,7 +10,6 @@ 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,
@@ -23,18 +22,13 @@ import {
FileDiscoveryService,
TelemetryTarget,
FileFilteringOptions,
ShellTool,
EditTool,
WriteFileTool,
MCPServerConfig,
ConfigParameters,
IdeClient,
} from '@qwen-code/qwen-code-core';
import { Settings } from './settings.js';
import { Extension, annotateActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
@@ -74,203 +68,186 @@ export interface CliArgs {
openaiBaseUrl: string | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
const yargsInstance = yargs(hideBin(process.argv))
.scriptName('qwen')
.usage(
'Usage: qwen [options] [command]\n\nQwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
'$0 [options]',
'Qwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
)
.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('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.',
)
// Register MCP subcommands
.command(mcpCommand)
.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('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())),
})
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
.alias('h', 'help')
.strict()
.demandCommand(0, 0); // Allow base command to run with no subcommands
.check((argv) => {
if (argv.prompt && argv.promptInteractive) {
throw new Error(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
);
}
return true;
});
yargsInstance.wrap(yargsInstance.terminalWidth());
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);
}
const result = yargsInstance.parseSync();
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
return result as unknown as CliArgs;
return result as CliArgs;
}
// This function is now a thin wrapper around the server's implementation.
@@ -278,7 +255,6 @@ export async function parseArguments(): Promise<CliArgs> {
// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself.
export async function loadHierarchicalGeminiMemory(
currentWorkingDirectory: string,
includeDirectoriesToReadGemini: readonly string[] = [],
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
@@ -304,7 +280,6 @@ export async function loadHierarchicalGeminiMemory(
// Directly call the server function with the corrected path.
return loadServerHierarchicalMemory(
effectiveCwd,
includeDirectoriesToReadGemini,
debugMode,
fileService,
extensionContextFilePaths,
@@ -327,14 +302,13 @@ export async function loadCliConfig(
) ||
false;
const memoryImportFormat = settings.memoryImportFormat || 'tree';
const ideMode = settings.ideMode ?? false;
const ideModeFeature =
argv.ideModeFeature ?? settings.ideModeFeature ?? false;
const folderTrustFeature = settings.folderTrustFeature ?? false;
const folderTrustSetting = settings.folderTrust ?? false;
const folderTrust = folderTrustFeature && folderTrustSetting;
const ideModeFeature =
(argv.ideModeFeature ?? settings.ideModeFeature ?? false) &&
!process.env.SANDBOX;
const ideClient = IdeClient.getInstance(ideMode && ideModeFeature);
const allExtensions = annotateActiveExtensions(
extensions,
@@ -354,11 +328,6 @@ export async function loadCliConfig(
process.env.OPENAI_BASE_URL = argv.openaiBaseUrl;
}
// Handle Tavily API key from command line
if (argv.tavilyApiKey) {
process.env.TAVILY_API_KEY = argv.tavilyApiKey;
}
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
@@ -381,14 +350,9 @@ export async function loadCliConfig(
...settings.fileFiltering,
};
const includeDirectories = (settings.includeDirectories || [])
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.loadMemoryFromIncludeDirectories ? includeDirectories : [],
debugMode,
fileService,
settings,
@@ -398,31 +362,17 @@ export async function loadCliConfig(
);
let mcpServers = mergeMcpServers(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 excludeTools = mergeExcludeTools(settings, activeExtensions);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
if (!argv.allowedMcpServerNames) {
if (settings.allowMCPServers) {
mcpServers = allowedMcpServers(
mcpServers,
settings.allowMCPServers,
blockedMcpServers,
);
const allowedNames = new Set(settings.allowMCPServers.filter(Boolean));
if (allowedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
);
}
}
if (settings.excludeMCPServers) {
@@ -436,26 +386,41 @@ export async function loadCliConfig(
}
if (argv.allowedMcpServerNames) {
mcpServers = allowedMcpServers(
mcpServers,
argv.allowedMcpServerNames,
blockedMcpServers,
);
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 = {};
}
}
const sandboxConfig = await loadSandboxConfig(settings, argv);
const cliVersion = await getCliVersion();
return new Config({
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: process.cwd(),
includeDirectories,
loadMemoryFromIncludeDirectories:
settings.loadMemoryFromIncludeDirectories || false,
includeDirectories: argv.includeDirectories,
debugMode,
question,
question: argv.promptInteractive || argv.prompt || '',
fullContext: argv.allFiles || argv.all_files || false,
coreTools: settings.coreTools || undefined,
excludeTools,
@@ -465,7 +430,7 @@ export async function loadCliConfig(
mcpServers,
userMemory: memoryContent,
geminiMdFileCount: fileCount,
approvalMode,
approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT,
showMemoryUsage:
argv.showMemoryUsage ||
argv.show_memory_usage ||
@@ -505,6 +470,7 @@ 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,
@@ -513,12 +479,13 @@ export async function loadCliConfig(
summarizeToolOutput: settings.summarizeToolOutput,
ideMode,
ideModeFeature,
ideClient,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.enableOpenAILogging
: argv.openaiLogging) ?? false,
sampling_params: settings.sampling_params,
systemPromptMappings: (settings.systemPromptMappings ?? [
systemPromptMappings: settings.systemPromptMappings ?? [
{
baseUrls: [
'https://dashscope.aliyuncs.com/compatible-mode/v1/',
@@ -528,49 +495,11 @@ export async function loadCliConfig(
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) {
@@ -595,12 +524,8 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) {
function mergeExcludeTools(
settings: Settings,
extensions: Extension[],
extraExcludes?: string[] | undefined,
): string[] {
const allExcludeTools = new Set([
...(settings.excludeTools || []),
...(extraExcludes || []),
]);
const allExcludeTools = new Set(settings.excludeTools || []);
for (const extension of extensions) {
for (const tool of extension.config.excludeTools || []) {
allExcludeTools.add(tool);

View File

@@ -10,8 +10,7 @@ import * as path from 'path';
import * as os from 'os';
export const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'qwen-extension.json';
export const EXTENSIONS_CONFIG_FILENAME_OLD = 'gemini-extension.json';
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export interface Extension {
path: string;
@@ -69,19 +68,12 @@ function loadExtension(extensionDir: string): Extension | null {
return null;
}
let configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
const oldConfigFilePath = path.join(
extensionDir,
EXTENSIONS_CONFIG_FILENAME_OLD,
console.error(
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
);
if (!fs.existsSync(oldConfigFilePath)) {
console.error(
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
);
return null;
}
configFilePath = oldConfigFilePath;
return null;
}
try {

View File

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

View File

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

View File

@@ -112,8 +112,6 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged).toEqual({
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
expect(settings.errors.length).toBe(0);
});
@@ -147,8 +145,6 @@ describe('Settings Loading and Merging', () => {
...systemSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
@@ -182,8 +178,6 @@ describe('Settings Loading and Merging', () => {
...userSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
@@ -215,8 +209,6 @@ describe('Settings Loading and Merging', () => {
...workspaceSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
@@ -254,8 +246,6 @@ describe('Settings Loading and Merging', () => {
contextFileName: 'WORKSPACE_CONTEXT.md',
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
@@ -305,67 +295,9 @@ describe('Settings Loading and Merging', () => {
allowMCPServers: ['server1', 'server2'],
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,
@@ -684,150 +616,6 @@ 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 = {
includeDirectories: ['/system/dir'],
};
const userSettingsContent = {
includeDirectories: ['/user/dir1', '/user/dir2'],
};
const workspaceSettingsContent = {
includeDirectories: ['/workspace/dir'],
};
(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.includeDirectories).toEqual([
'/system/dir',
'/user/dir1',
'/user/dir2',
'/workspace/dir',
]);
});
it('should handle JSON parsing errors gracefully', () => {
(mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist"
const invalidJsonContent = 'invalid json';
@@ -866,8 +654,6 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged).toEqual({
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
// Check that error objects are populated in settings.errors
@@ -1304,8 +1090,6 @@ describe('Settings Loading and Merging', () => {
...systemSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
});

View File

@@ -9,15 +9,17 @@ 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 { Settings, MemoryImportFormat } from './settingsSchema.js';
export type { Settings, MemoryImportFormat };
import { CustomTheme } from '../ui/themes/theme.js';
export const SETTINGS_DIRECTORY_NAME = '.qwen';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
@@ -41,7 +43,7 @@ export function getWorkspaceSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
}
export type { DnsResolutionOrder } from './settingsSchema.js';
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
export enum SettingScope {
User = 'User',
@@ -61,6 +63,87 @@ export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
}
export interface Settings {
theme?: string;
customThemes?: Record<string, CustomTheme>;
selectedAuthType?: AuthType;
useExternalAuth?: boolean;
sandbox?: boolean | string;
coreTools?: string[];
excludeTools?: string[];
toolDiscoveryCommand?: string;
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
allowMCPServers?: string[];
excludeMCPServers?: string[];
showMemoryUsage?: boolean;
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
telemetry?: TelemetrySettings;
usageStatisticsEnabled?: boolean;
preferredEditor?: string;
bugCommand?: BugCommandSettings;
checkpointing?: CheckpointingSettings;
autoConfigureMaxOldSpaceSize?: boolean;
/** The model name to use (e.g 'gemini-9.0-pro') */
model?: string;
enableOpenAILogging?: boolean;
// Git-aware file filtering settings
fileFiltering?: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
enableRecursiveFileSearch?: boolean;
};
hideWindowTitle?: boolean;
hideTips?: boolean;
hideBanner?: boolean;
// Setting for setting maximum number of user/model/tool turns in a session.
maxSessionTurns?: number;
// Setting for maximum token limit for conversation history before blocking requests
sessionTokenLimit?: number;
// Setting for maximum number of files and folders to show in folder structure
maxFolderItems?: number;
// A map of tool names to their summarization settings.
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
vimMode?: boolean;
memoryImportFormat?: 'tree' | 'flat';
// Flag to be removed post-launch.
ideModeFeature?: boolean;
/// IDE mode setting configured via slash command toggle.
ideMode?: boolean;
// Setting for disabling auto-update.
disableAutoUpdate?: boolean;
// Setting for disabling the update nag message.
disableUpdateNag?: boolean;
memoryDiscoveryMaxDirs?: number;
// Environment variables to exclude from project .env files
excludedProjectEnvVars?: string[];
dnsResolutionOrder?: DnsResolutionOrder;
sampling_params?: Record<string, unknown>;
systemPromptMappings?: Array<{
baseUrls: string[];
modelNames: string[];
template: string;
}>;
contentGenerator?: {
timeout?: number;
maxRetries?: number;
};
}
export interface SettingsError {
message: string;
path: string;
@@ -100,13 +183,9 @@ 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,
...workspaceWithoutFolderTrust,
...workspace,
...system,
customThemes: {
...(user.customThemes || {}),
@@ -118,16 +197,6 @@ export class LoadedSettings {
...(workspace.mcpServers || {}),
...(system.mcpServers || {}),
},
includeDirectories: [
...(system.includeDirectories || []),
...(user.includeDirectories || []),
...(workspace.includeDirectories || []),
],
chatCompression: {
...(system.chatCompression || {}),
...(user.chatCompression || {}),
...(workspace.chatCompression || {}),
},
};
}
@@ -318,7 +387,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
// Resolve paths to their canonical representation to handle symlinks
// FIX: Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
const resolvedHomeDir = path.resolve(homedir());
@@ -373,6 +442,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
});
}
// This comparison is now much more reliable.
if (realWorkspaceDir !== realHomeDir) {
// Load workspace settings
try {
@@ -416,19 +486,6 @@ 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);

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
import React from 'react';
import { render } from 'ink';
import { AppWrapper } from './ui/App.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { loadCliConfig, parseArguments, CliArgs } from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
import v8 from 'node:v8';
@@ -25,18 +25,19 @@ 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 } from './config/extension.js';
import { loadExtensions, Extension } 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';
@@ -44,7 +45,6 @@ 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,11 +191,6 @@ 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);
@@ -260,20 +255,21 @@ 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 (config.isInteractive()) {
if (shouldBeInteractive) {
const version = await getCliVersion();
setWindowTitle(basename(workspaceRoot), settings);
const instance = render(
<React.StrictMode>
<SettingsContext.Provider value={settings}>
<AppWrapper
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
/>
</SettingsContext.Provider>
<AppWrapper
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
/>
</React.StrictMode>,
{ exitOnCtrlC: false },
);
@@ -312,10 +308,12 @@ export async function main() {
prompt_length: input.length,
});
const nonInteractiveConfig = await validateNonInteractiveAuth(
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
// Non-interactive mode handled by runNonInteractive
const nonInteractiveConfig = await loadNonInteractiveConfig(
config,
extensions,
settings,
argv,
);
await runNonInteractive(nonInteractiveConfig, input, prompt_id);
@@ -336,3 +334,43 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
});
}
}
async function loadNonInteractiveConfig(
config: Config,
extensions: Extension[],
settings: LoadedSettings,
argv: CliArgs,
) {
let finalConfig = config;
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
// Everything is not allowed, ensure that only read-only tools are configured.
const existingExcludeTools = settings.merged.excludeTools || [];
const interactiveTools = [
ShellTool.Name,
EditTool.Name,
WriteFileTool.Name,
];
const newExcludeTools = [
...new Set([...existingExcludeTools, ...interactiveTools]),
];
const nonInteractiveSettings = {
...settings.merged,
excludeTools: newExcludeTools,
};
finalConfig = await loadCliConfig(
nonInteractiveSettings,
extensions,
config.getSessionId(),
argv,
);
await finalConfig.initialize();
}
return await validateNonInteractiveAuth(
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
finalConfig,
);
}

View File

@@ -70,7 +70,6 @@ describe('runNonInteractive', () => {
getIdeMode: vi.fn().mockReturnValue(false),
getFullContext: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config;
});

View File

@@ -17,36 +17,28 @@ import {
import { Content, Part, FunctionCall } from '@google/genai';
import { parseAndFormatApiError } from './ui/utils/errorParsing.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
export async function runNonInteractive(
config: Config,
input: string,
prompt_id: string,
): Promise<void> {
const consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: config.getDebugMode(),
await config.initialize();
// Handle EPIPE errors when the output is piped to a command that closes early.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
// Exit gracefully if the pipe is closed.
process.exit(0);
}
});
const geminiClient = config.getGeminiClient();
const toolRegistry: ToolRegistry = await config.getToolRegistry();
const abortController = new AbortController();
let currentMessages: Content[] = [{ role: 'user', parts: [{ text: input }] }];
let turnCount = 0;
try {
consolePatcher.patch();
// Handle EPIPE errors when the output is piped to a command that closes early.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
// Exit gracefully if the pipe is closed.
process.exit(0);
}
});
const geminiClient = config.getGeminiClient();
const toolRegistry: ToolRegistry = await config.getToolRegistry();
const abortController = new AbortController();
let currentMessages: Content[] = [
{ role: 'user', parts: [{ text: input }] },
];
let turnCount = 0;
while (true) {
turnCount++;
if (
@@ -141,7 +133,6 @@ export async function runNonInteractive(
);
process.exit(1);
} finally {
consolePatcher.cleanup();
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry();
}

View File

@@ -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,9 +73,8 @@ export class BuiltinCommandLoader implements ICommandLoader {
statsCommand,
themeCommand,
toolsCommand,
settingsCommand,
vimCommand,
setupGithubCommand,
...(isGitHubRepository() ? [setupGithubCommand] : []),
];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);

View File

@@ -16,7 +16,6 @@ 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';
@@ -28,7 +27,6 @@ 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 {
@@ -86,7 +84,6 @@ interface MockServerConfig {
getAllGeminiMdFilenames: Mock<() => string[]>;
getGeminiClient: Mock<() => GeminiClient | undefined>;
getUserTier: Mock<() => Promise<string | undefined>>;
getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>;
}
// Mock @qwen-code/qwen-code-core and its Config class
@@ -151,7 +148,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getUserTier: vi.fn(),
})),
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
setFlashFallbackHandler: vi.fn(),
getSessionId: vi.fn(() => 'test-session-id'),
getUserTier: vi.fn().mockResolvedValue(undefined),
@@ -160,9 +157,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getWorkspaceContext: vi.fn(() => ({
getDirectories: vi.fn(() => []),
})),
getIdeClient: vi.fn(() => ({
getCurrentIde: vi.fn(() => 'vscode'),
})),
};
});
@@ -175,7 +169,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
...actualCore,
Config: ConfigClassMock,
MCPServerConfig: actualCore.MCPServerConfig,
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
ideContext: ideContextMock,
isGitRepository: vi.fn(),
};
@@ -188,7 +182,6 @@ vi.mock('./hooks/useGeminiStream', () => ({
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
})),
}));
@@ -203,13 +196,6 @@ 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([]),
@@ -247,14 +233,10 @@ 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'),
@@ -296,11 +278,6 @@ 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',
@@ -600,7 +577,7 @@ describe('App UI', () => {
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
const { lastFrame, unmount } = render(
<App
@@ -612,13 +589,13 @@ describe('App UI', () => {
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain(
'Using: 1 open file (ctrl+e to view) | 1 QWEN.md file',
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file',
);
});
it('should display default "QWEN.md" in footer when contextFileName is not set and count is 1', async () => {
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
// For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
@@ -632,12 +609,15 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve(); // Wait for any async updates
expect(lastFrame()).toContain('Using: 1 QWEN.md file');
expect(lastFrame()).toContain('Using: 1 GEMINI.md file');
});
it('should display default "QWEN.md" with plural when contextFileName is not set and count is > 1', async () => {
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'GEMINI.md',
'GEMINI.md',
]);
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
@@ -650,7 +630,7 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Using: 2 QWEN.md files');
expect(lastFrame()).toContain('Using: 2 GEMINI.md files');
});
it('should display custom contextFileName in footer when set and count is 1', async () => {
@@ -747,9 +727,12 @@ describe('App UI', () => {
expect(lastFrame()).not.toContain('ANY_FILE.MD');
});
it('should display QWEN.md and MCP server count when both are present', async () => {
it('should display GEMINI.md and MCP server count when both are present', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'GEMINI.md',
'GEMINI.md',
]);
mockConfig.getMcpServers.mockReturnValue({
server1: {} as MCPServerConfig,
});
@@ -768,7 +751,7 @@ describe('App UI', () => {
expect(lastFrame()).toContain('1 MCP server');
});
it('should display only MCP server count when QWEN.md count is 0', async () => {
it('should display only MCP server count when GEMINI.md count is 0', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
mockConfig.getMcpServers.mockReturnValue({
@@ -1073,44 +1056,4 @@ 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(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(lastFrame()).toMatchSnapshot();
});
});
describe('FolderTrustDialog', () => {
it('should display the folder trust dialog when isFolderTrustDialogOpen is true', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isFolderTrustDialogOpen: true,
handleFolderTrustSelect: vi.fn(),
});
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Do you trust this folder?');
});
});
});

View File

@@ -13,6 +13,8 @@ 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';
@@ -21,7 +23,6 @@ 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';
@@ -37,18 +38,17 @@ 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, SettingScope } from '../config/settings.js';
import { LoadedSettings } 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,10 +64,6 @@ 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';
@@ -81,8 +77,6 @@ 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 {
@@ -95,11 +89,8 @@ 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;
@@ -126,18 +117,6 @@ 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;
@@ -178,7 +157,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
const [showToolDescriptions, setShowToolDescriptions] =
useState<boolean>(false);
const [showIDEContextDetail, setShowIDEContextDetail] =
useState<boolean>(false);
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@@ -194,7 +174,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
>();
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
useEffect(() => {
@@ -229,11 +208,6 @@ 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(
@@ -251,12 +225,6 @@ 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,
@@ -340,9 +308,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
try {
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.merged.loadMemoryFromIncludeDirectories
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
config.getFileService(),
settings.merged,
@@ -484,7 +449,6 @@ 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);
@@ -493,7 +457,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
20,
Math.floor(terminalWidth * widthFraction) - 3,
);
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 0.8));
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
// Utility callbacks
const isValidPath = useCallback((filePath: string): boolean => {
@@ -532,7 +496,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
pendingHistoryItems: pendingSlashCommandHistoryItems,
commandContext,
shellConfirmationRequest,
confirmationRequest,
} = useSlashCommandProcessor(
config,
settings,
@@ -547,37 +510,16 @@ 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<string[]>([]);
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,
@@ -591,8 +533,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
performMemoryRefresh,
modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError,
refreshStatic,
handleUserCancel,
);
// Input handling
@@ -606,26 +546,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
[submitQuery],
);
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 buffer = useTextBuffer({
initialText: '',
viewport: { height: 10, width: inputWidth },
stdin,
setRawMode,
isValidPath,
shellModeActive,
});
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
@@ -658,83 +586,55 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
[handleSlashCommand],
);
const handleGlobalKeypress = useCallback(
(key: Key) => {
let enteringConstrainHeightMode = false;
if (!constrainHeight) {
enteringConstrainHeightMode = true;
setConstrainHeight(true);
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');
}
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);
} 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;
}
},
[
constrainHeight,
setConstrainHeight,
setShowErrorDetails,
showToolDescriptions,
setShowToolDescriptions,
config,
ideContextState,
handleExit,
ctrlCPressedOnce,
setCtrlCPressedOnce,
ctrlCTimerRef,
buffer.text.length,
ctrlDPressedOnce,
setCtrlDPressedOnce,
ctrlDTimerRef,
handleSlashCommand,
isAuthenticating,
cancelOngoingRequest,
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
} else if (key.ctrl && input === 's' && !enteringConstrainHeightMode) {
setConstrainHeight(false);
}
});
useEffect(() => {
if (config) {
setGeminiMdFileCount(config.getGeminiMdFileCount());
}
}, [config, config.getGeminiMdFileCount]);
}, [config]);
const logger = useLogger();
const [userMessages, setUserMessages] = useState<string[]>([]);
useEffect(() => {
const fetchUserMessages = async () => {
@@ -886,7 +786,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
);
}
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
@@ -915,7 +814,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
items={[
<Box flexDirection="column" key="header">
{!settings.merged.hideBanner && (
<Header version={version} nightly={nightly} />
<Header
terminalWidth={terminalWidth}
version={version}
nightly={nightly}
/>
)}
{!settings.merged.hideTips && <Tips config={config} />}
</Box>,
@@ -974,30 +877,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
)}
{shouldShowIdePrompt ? (
<IdeIntegrationNudge
ideName={config.getIdeClient().getDetectedIdeDisplayName()}
onComplete={handleIdePromptComplete}
/>
) : isFolderTrustDialogOpen ? (
<FolderTrustDialog onSelect={handleFolderTrustSelect} />
) : shellConfirmationRequest ? (
{shellConfirmationRequest ? (
<ShellConfirmationDialog request={shellConfirmationRequest} />
) : confirmationRequest ? (
<Box flexDirection="column">
{confirmationRequest.prompt}
<Box paddingY={1}>
<RadioButtonSelect
items={[
{ label: 'Yes', value: true },
{ label: 'No', value: false },
]}
onSelect={(value: boolean) => {
confirmationRequest.onConfirm(value);
}}
/>
</Box>
</Box>
) : isThemeDialogOpen ? (
<Box flexDirection="column">
{themeError && (
@@ -1017,14 +898,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
terminalWidth={mainAreaWidth}
/>
</Box>
) : isSettingsDialogOpen ? (
<Box flexDirection="column">
<SettingsDialog
settings={settings}
onSelect={() => closeSettingsDialog()}
onRestartRequest={() => process.exit(0)}
/>
</Box>
) : isAuthenticating ? (
<>
{isQwenAuth && isQwenAuthenticating ? (
@@ -1116,10 +989,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Box
marginTop={1}
display="flex"
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box>
{process.env.GEMINI_SYSTEM_MD && (
@@ -1133,8 +1005,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Text color={Colors.AccentYellow}>
Press Ctrl+D again to exit.
</Text>
) : showEscapePrompt ? (
<Text color={Colors.Gray}>Press Esc again to clear.</Text>
) : (
<ContextSummaryDisplay
ideContext={ideContextState}
@@ -1146,7 +1016,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
/>
)}
</Box>
<Box paddingTop={isNarrow ? 1 : 0}>
<Box>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
!shellModeActive && (
<AutoAcceptIndicator
@@ -1156,7 +1026,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
{shellModeActive && <ShellModeIndicator />}
</Box>
</Box>
{showIDEContextDetail && (
<IDEContextDetailDisplay
ideContext={ideContextState}
detectedIdeDisplay={config
.getIdeClient()
.getDetectedIdeDisplayName()}
/>
)}
{showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
@@ -1185,7 +1062,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
commandContext={commandContext}
shellModeActive={shellModeActive}
setShellModeActive={setShellModeActive}
onEscapePromptChange={handleEscapePromptChange}
focus={isFocused}
vimHandleInput={vimHandleInput}
placeholder={placeholder}
@@ -1236,7 +1112,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
errorCount={errorCount}
showErrorDetails={showErrorDetails}
showMemoryUsage={
config.getDebugMode() || settings.merged.showMemoryUsage || false
config.getDebugMode() || config.getShowMemoryUsage()
}
promptTokenCount={sessionStats.lastPromptTokenCount}
nightly={nightly}

View File

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

View File

@@ -10,22 +10,9 @@ 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)"
`;

View File

@@ -168,12 +168,8 @@ describe('chatCommand', () => {
describe('save subcommand', () => {
let saveCommand: SlashCommand;
const tag = 'my-tag';
let mockCheckpointExists: ReturnType<typeof vi.fn>;
beforeEach(() => {
saveCommand = getSubCommand('save');
mockCheckpointExists = vi.fn().mockResolvedValue(false);
mockContext.services.logger.checkpointExists = mockCheckpointExists;
});
it('should return an error if tag is missing', async () => {
@@ -195,7 +191,7 @@ describe('chatCommand', () => {
});
});
it('should save the conversation if checkpoint does not exist', async () => {
it('should save the conversation', async () => {
const history: HistoryItemWithoutId[] = [
{
type: 'user',
@@ -203,52 +199,8 @@ 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',

View File

@@ -5,15 +5,11 @@
*/
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';
@@ -100,7 +96,7 @@ const saveCommand: SlashCommand = {
description:
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
if (!tag) {
return {
@@ -112,26 +108,6 @@ 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 {

View File

@@ -40,24 +40,11 @@ describe('directoryCommand', () => {
getGeminiClient: vi.fn().mockReturnValue({
addDirectoryContext: vi.fn(),
}),
getWorkingDir: () => '/test/dir',
shouldLoadMemoryFromIncludeDirectories: () => false,
getDebugMode: () => false,
getFileService: () => ({}),
getExtensionContextFilePaths: () => [],
getFileFilteringOptions: () => ({ ignore: [], include: [] }),
setUserMemory: vi.fn(),
setGeminiMdFileCount: vi.fn(),
} as unknown as Config;
mockContext = {
services: {
config: mockConfig,
settings: {
merged: {
memoryDiscoveryMaxDirs: 1000,
},
},
},
ui: {
addItem: vi.fn(),

View File

@@ -8,7 +8,6 @@ import { SlashCommand, CommandContext, CommandKind } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'os';
import * as path from 'path';
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
export function expandHomeDir(p: string): string {
if (!p) {
@@ -17,7 +16,7 @@ export function expandHomeDir(p: string): string {
let expandedPath = p;
if (p.toLowerCase().startsWith('%userprofile%')) {
expandedPath = os.homedir() + p.substring('%userprofile%'.length);
} else if (p === '~' || p.startsWith('~/')) {
} else if (p.startsWith('~')) {
expandedPath = os.homedir() + p.substring(1);
}
return path.normalize(expandedPath);
@@ -91,37 +90,6 @@ export const directoryCommand: SlashCommand = {
}
}
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
[
...config.getWorkspaceContext().getDirectories(),
...pathsToAdd,
],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.memoryDiscoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
context.ui.setGeminiMdFileCount(fileCount);
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
if (added.length > 0) {
const gemini = config.getGeminiClient();
if (gemini) {

View File

@@ -42,15 +42,9 @@ describe('ideCommand', () => {
mockConfig = {
getIdeModeFeature: vi.fn(),
getIdeMode: vi.fn(),
getIdeClient: vi.fn(() => ({
reconnect: vi.fn(),
disconnect: vi.fn(),
getCurrentIde: vi.fn(),
getDetectedIdeDisplayName: vi.fn(),
getConnectionStatus: vi.fn(),
})),
setIdeModeAndSyncConnection: vi.fn(),
getIdeClient: vi.fn(),
setIdeMode: vi.fn(),
setIdeClientDisconnected: vi.fn(),
} as unknown as Config;
platformSpy = vi.spyOn(process, 'platform', 'get');
@@ -93,14 +87,13 @@ describe('ideCommand', () => {
} as unknown as ReturnType<Config['getIdeClient']>);
});
it('should show connected status', async () => {
it('should show connected status', () => {
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Connected,
});
const command = ideCommand(mockConfig);
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -109,14 +102,13 @@ describe('ideCommand', () => {
});
});
it('should show connecting status', async () => {
it('should show connecting status', () => {
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Connecting,
});
const command = ideCommand(mockConfig);
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -124,14 +116,13 @@ describe('ideCommand', () => {
content: `🟡 Connecting...`,
});
});
it('should show disconnected status', async () => {
it('should show disconnected status', () => {
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
});
const command = ideCommand(mockConfig);
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -140,16 +131,15 @@ describe('ideCommand', () => {
});
});
it('should show disconnected status with details', async () => {
it('should show disconnected status with details', () => {
const details = 'Something went wrong';
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
details,
});
const command = ideCommand(mockConfig);
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',

View File

@@ -7,15 +7,10 @@
import {
Config,
DetectedIde,
QWEN_CODE_COMPANION_EXTENSION_NAME,
IDEConnectionStatus,
getIdeDisplayName,
getIdeInstaller,
IdeClient,
type File,
ideContext,
} from '@qwen-code/qwen-code-core';
import path from 'node:path';
import {
CommandContext,
SlashCommand,
@@ -24,97 +19,6 @@ import {
} from './types.js';
import { SettingScope } from '../../config/settings.js';
function getIdeStatusMessage(ideClient: IdeClient): {
messageType: 'info' | 'error';
content: string;
} {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected:
return {
messageType: 'info',
content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
};
case IDEConnectionStatus.Connecting:
return {
messageType: 'info',
content: `🟡 Connecting...`,
};
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
messageType: 'error',
content,
};
}
}
}
function formatFileList(openFiles: File[]): string {
const basenameCounts = new Map<string, number>();
for (const file of openFiles) {
const basename = path.basename(file.path);
basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1);
}
const fileList = openFiles
.map((file: File) => {
const basename = path.basename(file.path);
const isDuplicate = (basenameCounts.get(basename) || 0) > 1;
const parentDir = path.basename(path.dirname(file.path));
const displayName = isDuplicate
? `${basename} (/${parentDir})`
: basename;
return ` - ${displayName}${file.isActive ? ' (active)' : ''}`;
})
.join('\n');
const infoMessage = `
(Note: The file list is limited to a number of recently accessed files within your workspace and only includes local files on disk)`;
return `\n\nOpen files:\n${fileList}\n${infoMessage}`;
}
async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
messageType: 'info' | 'error';
content: string;
}> {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected: {
let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`;
const context = ideContext.getIdeContext();
const openFiles = context?.workspaceState?.openFiles;
if (openFiles && openFiles.length > 0) {
content += formatFileList(openFiles);
}
return {
messageType: 'info',
content,
};
}
case IDEConnectionStatus.Connecting:
return {
messageType: 'info',
content: `🟡 Connecting...`,
};
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
messageType: 'error',
content,
};
}
}
}
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config || !config.getIdeModeFeature()) {
return null;
@@ -150,14 +54,33 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
name: 'status',
description: 'check status of IDE integration',
kind: CommandKind.BUILT_IN,
action: async (): Promise<SlashCommandActionReturn> => {
const { messageType, content } =
await getIdeStatusMessageWithFiles(ideClient);
return {
type: 'message',
messageType,
content,
} as const;
action: (_context: CommandContext): SlashCommandActionReturn => {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected:
return {
type: 'message',
messageType: 'info',
content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
} as const;
case IDEConnectionStatus.Connecting:
return {
type: 'message',
messageType: 'info',
content: `🟡 Connecting...`,
} as const;
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
type: 'message',
messageType: 'error',
content,
} as const;
}
}
},
};
@@ -171,7 +94,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
context.ui.addItem(
{
type: 'error',
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the IDE companion manually from its marketplace.`,
},
Date.now(),
);
@@ -187,10 +110,6 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
);
const result = await installer.install();
if (result.success) {
config.setIdeMode(true);
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
}
context.ui.addItem(
{
type: result.success ? 'info' : 'error',
@@ -207,15 +126,8 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
await config.setIdeModeAndSyncConnection(true);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{
type: messageType,
text: content,
},
Date.now(),
);
config.setIdeMode(true);
config.setIdeClientConnected();
},
};
@@ -225,15 +137,8 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
await config.setIdeModeAndSyncConnection(false);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{
type: messageType,
text: content,
},
Date.now(),
);
config.setIdeMode(false);
config.setIdeClientDisconnected();
},
};

View File

@@ -11,31 +11,16 @@ import { initCommand } from './initCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { type CommandContext } from './types.js';
// Mock the 'fs' module with both named and default exports to avoid breaking default import sites
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const existsSync = vi.fn();
const writeFileSync = vi.fn();
const readFileSync = vi.fn();
return {
...actual,
existsSync,
writeFileSync,
readFileSync,
default: {
...(actual as unknown as Record<string, unknown>),
existsSync,
writeFileSync,
readFileSync,
},
} as unknown as typeof import('fs');
});
// Mock the 'fs' module
vi.mock('fs', () => ({
existsSync: vi.fn(),
writeFileSync: vi.fn(),
}));
describe('initCommand', () => {
let mockContext: CommandContext;
const targetDir = '/test/dir';
const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
const geminiMdPath = path.join(targetDir, DEFAULT_CONTEXT_FILENAME);
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
beforeEach(() => {
// Create a fresh mock context for each test
@@ -53,10 +38,9 @@ describe('initCommand', () => {
vi.clearAllMocks();
});
it(`should inform the user if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, async () => {
it('should inform the user if GEMINI.md already exists', async () => {
// Arrange: Simulate that the file exists
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue('# Existing content');
// Act: Run the command's action
const result = await initCommand.action!(mockContext, '');
@@ -65,13 +49,14 @@ describe('initCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `A ${DEFAULT_CONTEXT_FILENAME} file already exists in this directory. No changes were made.`,
content:
'A GEMINI.md file already exists in this directory. No changes were made.',
});
// Assert: Ensure no file was written
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it(`should create ${DEFAULT_CONTEXT_FILENAME} and submit a prompt if it does not exist`, async () => {
it('should create GEMINI.md and submit a prompt if it does not exist', async () => {
// Arrange: Simulate that the file does not exist
vi.mocked(fs.existsSync).mockReturnValue(false);
@@ -85,7 +70,7 @@ describe('initCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: 'info',
text: `Empty ${DEFAULT_CONTEXT_FILENAME} created. Now analyzing the project to populate it.`,
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
},
expect.any(Number),
);
@@ -93,20 +78,10 @@ describe('initCommand', () => {
// Assert: Check that the correct prompt is submitted
expect(result.type).toBe('submit_prompt');
expect(result.content).toContain(
'You are Qwen Code, an interactive CLI agent',
'You are an AI agent that brings the power of Gemini',
);
});
it(`should proceed to initialize when ${DEFAULT_CONTEXT_FILENAME} exists but is empty`, async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue(' \n ');
const result = await initCommand.action!(mockContext, '');
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
expect(result.type).toBe('submit_prompt');
});
it('should return an error if config is not available', async () => {
// Arrange: Create a context without config
const noConfigContext = createMockCommandContext();

View File

@@ -12,11 +12,10 @@ import {
SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
export const initCommand: SlashCommand = {
name: 'init',
description: 'Analyzes the project and creates a tailored QWEN.md file.',
description: 'Analyzes the project and creates a tailored GEMINI.md file.',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -30,55 +29,32 @@ export const initCommand: SlashCommand = {
};
}
const targetDir = context.services.config.getTargetDir();
const contextFileName = getCurrentGeminiMdFilename();
const contextFilePath = path.join(targetDir, contextFileName);
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
try {
if (fs.existsSync(contextFilePath)) {
// If file exists but is empty (or whitespace), continue to initialize; otherwise, bail out
try {
const existing = fs.readFileSync(contextFilePath, 'utf8');
if (existing && existing.trim().length > 0) {
return {
type: 'message',
messageType: 'info',
content: `A ${contextFileName} file already exists in this directory. No changes were made.`,
};
}
} catch {
// If we fail to read, conservatively proceed to (re)create the file
}
}
// Ensure an empty context file exists before prompting the model to populate it
try {
fs.writeFileSync(contextFilePath, '', 'utf8');
context.ui.addItem(
{
type: 'info',
text: `Empty ${contextFileName} created. Now analyzing the project to populate it.`,
},
Date.now(),
);
} catch (err) {
return {
type: 'message',
messageType: 'error',
content: `Failed to create ${contextFileName}: ${err instanceof Error ? err.message : String(err)}`,
};
}
} catch (error) {
if (fs.existsSync(geminiMdPath)) {
return {
type: 'message',
messageType: 'error',
content: `Unexpected error preparing ${contextFileName}: ${error instanceof Error ? error.message : String(error)}`,
messageType: 'info',
content:
'A GEMINI.md file already exists in this directory. No changes were made.',
};
}
// Create an empty GEMINI.md file
fs.writeFileSync(geminiMdPath, '', 'utf8');
context.ui.addItem(
{
type: 'info',
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
},
Date.now(),
);
return {
type: 'submit_prompt',
content: `
You are Qwen Code, an interactive CLI agent. Analyze the current directory and generate a comprehensive ${contextFileName} file to be used as instructional context for future interactions.
You are an AI agent that brings the power of Gemini directly into the terminal. Your task is to analyze the current directory and generate a comprehensive GEMINI.md file to be used as instructional context for future interactions.
**Analysis Process:**
@@ -94,7 +70,7 @@ You are Qwen Code, an interactive CLI agent. Analyze the current directory and g
* **Code Project:** Look for clues like \`package.json\`, \`requirements.txt\`, \`pom.xml\`, \`go.mod\`, \`Cargo.toml\`, \`build.gradle\`, or a \`src\` directory. If you find them, this is likely a software project.
* **Non-Code Project:** If you don't find code-related files, this might be a directory for documentation, research papers, notes, or something else.
**${contextFileName} Content Generation:**
**GEMINI.md Content Generation:**
**For a Code Project:**
@@ -110,7 +86,7 @@ You are Qwen Code, an interactive CLI agent. Analyze the current directory and g
**Final Output:**
Write the complete content to the \`${contextFileName}\` file. The output must be well-formatted Markdown.
Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown.
`,
};
},

View File

@@ -161,10 +161,6 @@ describe('memoryCommand', () => {
getDebugMode: () => false,
getFileService: () => ({}) as FileDiscoveryService,
getExtensionContextFilePaths: () => [],
shouldLoadMemoryFromIncludeDirectories: () => false,
getWorkspaceContext: () => ({
getDirectories: () => [],
}),
getFileFilteringOptions: () => ({
ignore: [],
include: [],

View File

@@ -89,9 +89,6 @@ export const memoryCommand: SlashCommand = {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
config.shouldLoadMemoryFromIncludeDirectories()
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),

View File

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

View File

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

View File

@@ -4,103 +4,63 @@
* 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 gitUtils from '../../utils/gitUtils.js';
import * as child_process from 'child_process';
import { setupGithubCommand } from './setupGithubCommand.js';
import { CommandContext, ToolActionReturn } from './types.js';
import * as commandUtils from '../utils/commandUtils.js';
vi.mock('child_process');
// 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 () => {
describe('setupGithubCommand', () => {
beforeEach(() => {
vi.resetAllMocks();
scratchDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'setup-github-command-'),
);
});
afterEach(async () => {
afterEach(() => {
vi.restoreAllMocks();
if (scratchDir) await fs.rm(scratchDir, { recursive: true });
});
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';
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);
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?.(
const result = setupGithubCommand.action?.(
{} as CommandContext,
'',
)) as ToolActionReturn;
) as ToolActionReturn;
expect(result.type).toBe('tool');
expect(result.toolName).toBe('run_shell_command');
expect(child_process.execSync).toHaveBeenCalledWith(
'git rev-parse --show-toplevel',
{
encoding: 'utf-8',
},
);
expect(child_process.execSync).toHaveBeenCalledWith('git remote -v', {
encoding: 'utf-8',
});
const { command } = result.toolArgs;
const expectedSubstrings = [
`set -eEuo pipefail`,
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
`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/heads/v0/examples/workflows/',
];
for (const substring of expectedSubstrings) {
expect(command).toContain(substring);
}
});
for (const workflow of workflows) {
const workflowFile = path.join(
scratchDir,
'.github',
'workflows',
workflow,
);
const contents = await fs.readFile(workflowFile, 'utf8');
expect(contents).toContain(workflow);
}
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.');
});
});

View File

@@ -4,93 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
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 path from 'path';
import { execSync } from 'child_process';
import { isGitHubRepository } 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: async (
context: CommandContext,
): Promise<SlashCommandActionReturn> => {
const abortController = new AbortController();
action: (): SlashCommandActionReturn => {
const gitRootRepo = execSync('git rev-parse --show-toplevel', {
encoding: 'utf-8',
}).trim();
if (!isGitHubRepository()) {
throw new Error(
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
);
throw new Error('Unable to determine the Git root directory.');
}
// 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.',
);
}
const version = 'v0';
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/examples/workflows/`;
// 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',
@@ -98,63 +37,15 @@ export const setupGithubCommand: SlashCommand = {
'pr-review/gemini-pr-review.yml',
];
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(' && ')})`;
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(' && ');
return {
type: 'tool',
toolName: 'run_shell_command',

View File

@@ -4,7 +4,6 @@
* 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';
@@ -60,7 +59,6 @@ export interface CommandContext {
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
};
// Session-specific data
session: {
@@ -69,8 +67,6 @@ export interface CommandContext {
/** A transient list of shell commands the user has approved for this session. */
sessionShellAllowlist: Set<string>;
};
// Flag to indicate if an overwrite has been confirmed
overwriteConfirmed?: boolean;
}
/**
@@ -103,8 +99,7 @@ export interface MessageActionReturn {
*/
export interface OpenDialogActionReturn {
type: 'dialog';
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings';
dialog: 'auth' | 'theme' | 'editor' | 'privacy';
}
/**
@@ -140,16 +135,6 @@ 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
@@ -157,8 +142,7 @@ export type SlashCommandActionReturn =
| OpenDialogActionReturn
| LoadHistoryActionReturn
| SubmitPromptActionReturn
| ConfirmShellCommandsActionReturn
| ConfirmActionReturn;
| ConfirmShellCommandsActionReturn;
export enum CommandKind {
BUILT_IN = 'built-in',

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,12 @@
*/
import React from 'react';
import { Box, Text } from 'ink';
import { 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;
@@ -31,8 +29,6 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
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;
@@ -85,36 +81,30 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
}
parts.push(blockedText);
}
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;
return parts.join(', ');
})();
const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean);
let summaryText = 'Using: ';
const summaryParts = [];
if (openFilesText) {
summaryParts.push(openFilesText);
}
if (geminiMdText) {
summaryParts.push(geminiMdText);
}
if (mcpText) {
summaryParts.push(mcpText);
}
summaryText += summaryParts.join(' | ');
if (isNarrow) {
return (
<Box flexDirection="column">
<Text color={Colors.Gray}>Using:</Text>
{summaryParts.map((part, index) => (
<Text key={index} color={Colors.Gray}>
{' '}- {part}
</Text>
))}
</Box>
);
// 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)';
}
}
return (
<Box>
<Text color={Colors.Gray}>Using: {summaryParts.join(' | ')}</Text>
</Box>
);
return <Text color={Colors.Gray}>{summaryText}</Text>;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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