mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-24 10:39:17 +00:00
Compare commits
319 Commits
v0.0.5-nig
...
release/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cac3202945 | ||
|
|
213a017539 | ||
|
|
8833a112a9 | ||
|
|
d933795d8e | ||
|
|
c8f3b15971 | ||
|
|
2fcacb70b9 | ||
|
|
a0a1d6e253 | ||
|
|
0de3236076 | ||
|
|
303b6999f4 | ||
|
|
93f5e59710 | ||
|
|
7b378e826c | ||
|
|
a19db16485 | ||
|
|
7dbc240847 | ||
|
|
5e70b34041 | ||
|
|
df1479f864 | ||
|
|
14e6d3c01e | ||
|
|
da0b8b5534 | ||
|
|
e1d502991d | ||
|
|
7e01554b9c | ||
|
|
36c65658ff | ||
|
|
a925ac56fa | ||
|
|
5d4a9452d8 | ||
|
|
3e082ae89a | ||
|
|
51207043d0 | ||
|
|
2403061bab | ||
|
|
1ffcb51052 | ||
|
|
c33d162ff2 | ||
|
|
bbfe94cfe2 | ||
|
|
03c7b1836f | ||
|
|
f2ba6dbb8a | ||
|
|
2d0884b04d | ||
|
|
fc70439355 | ||
|
|
0265b67b90 | ||
|
|
1f91b9ece1 | ||
|
|
c58106079e | ||
|
|
6516d0d136 | ||
|
|
5369af61d2 | ||
|
|
6a4005cace | ||
|
|
290ccdbe21 | ||
|
|
b5514fd052 | ||
|
|
bc92da04e9 | ||
|
|
5349c4d02b | ||
|
|
67c6033147 | ||
|
|
5d1d40fa2e | ||
|
|
804c181ac4 | ||
|
|
0bc45aeefe | ||
|
|
7856f52afb | ||
|
|
e986476fe0 | ||
|
|
cfc1aebee6 | ||
|
|
ef1c8a4bfe | ||
|
|
484292b2ac | ||
|
|
f9659184d4 | ||
|
|
6d5bb1b57c | ||
|
|
fb9f2d292c | ||
|
|
16ea8560b7 | ||
|
|
2655af079a | ||
|
|
807844fb57 | ||
|
|
2d1a6af890 | ||
|
|
f9efb2e24f | ||
|
|
2202d26ac7 | ||
|
|
58f66ccfc6 | ||
|
|
65c622c0ac | ||
|
|
a3ec2f52c9 | ||
|
|
d8fec54e81 | ||
|
|
26fe587b44 | ||
|
|
d9fb08c9da | ||
|
|
f52d073dfb | ||
|
|
c7fd4c4a96 | ||
|
|
94b6199943 | ||
|
|
c0f5f6a5f6 | ||
|
|
0e98641b51 | ||
|
|
4656f17524 | ||
|
|
110e00178b | ||
|
|
72832fb889 | ||
|
|
6390b81646 | ||
|
|
239ba63d28 | ||
|
|
2269f8a1a8 | ||
|
|
aa5c80dec4 | ||
|
|
b0b12af2ce | ||
|
|
8dd6f04199 | ||
|
|
2548facc79 | ||
|
|
c96852dc56 | ||
|
|
028a82ebeb | ||
|
|
6b67cd1b57 | ||
|
|
96a9b683b2 | ||
|
|
dcc86699cf | ||
|
|
2865a52778 | ||
|
|
0e44bbc85d | ||
|
|
964509f587 | ||
|
|
a37423bf7f | ||
|
|
bfcb3e7f1d | ||
|
|
1a581ed191 | ||
|
|
5c94913643 | ||
|
|
e221b077e5 | ||
|
|
0f58b3fd32 | ||
|
|
32d06b2fc1 | ||
|
|
0157eae3d7 | ||
|
|
e3a5806ae2 | ||
|
|
a45adbdc76 | ||
|
|
8a9a927544 | ||
|
|
c632ec8b03 | ||
|
|
0dea7233b6 | ||
|
|
34434cd4aa | ||
|
|
41500814b0 | ||
|
|
786832913b | ||
|
|
6b19c8bd55 | ||
|
|
b8084ba815 | ||
|
|
6487cc1689 | ||
|
|
191cc01bf5 | ||
|
|
4807434d9f | ||
|
|
c184ec3224 | ||
|
|
f35921a771 | ||
|
|
c03ae43777 | ||
|
|
69322e12e4 | ||
|
|
9ac62565a0 | ||
|
|
344ee29f77 | ||
|
|
60bde58f29 | ||
|
|
34b5dc7f28 | ||
|
|
3af4913ef3 | ||
|
|
5ec4ea9b4d | ||
|
|
407393b128 | ||
|
|
51d09e720b | ||
|
|
c09abb817f | ||
|
|
b7663950f2 | ||
|
|
f5e0f16157 | ||
|
|
5ab184fcaf | ||
|
|
86eaa03f8a | ||
|
|
e50d886ba8 | ||
|
|
e8815ba43c | ||
|
|
bae922a632 | ||
|
|
60362e0329 | ||
|
|
494a10e7a7 | ||
|
|
785ee5d59a | ||
|
|
4f2974dbfe | ||
|
|
65e4b941ee | ||
|
|
9bc0a4aff3 | ||
|
|
0c32a4061d | ||
|
|
9fc7115b86 | ||
|
|
c38147a3a6 | ||
|
|
908ce2be33 | ||
|
|
f1663d9615 | ||
|
|
4d4eacfc40 | ||
|
|
19491b7b94 | ||
|
|
53f8617b24 | ||
|
|
3a3b138195 | ||
|
|
8e6a565adb | ||
|
|
a3351bc985 | ||
|
|
7596481a9d | ||
|
|
8bac9e7d04 | ||
|
|
0d65baf928 | ||
|
|
8d848dca4a | ||
|
|
6ae75c9f32 | ||
|
|
36750ca49b | ||
|
|
d6a7334279 | ||
|
|
99f88851fb | ||
|
|
01f7c4b740 | ||
|
|
9ac3e8b79e | ||
|
|
4782113ceb | ||
|
|
626844b539 | ||
|
|
5cd63a6abc | ||
|
|
b55467c1dd | ||
|
|
43510ed212 | ||
|
|
ad5d2af4e3 | ||
|
|
e3e7677753 | ||
|
|
b3cfaeb6d3 | ||
|
|
024b8207eb | ||
|
|
1fb680bacc | ||
|
|
1f0ad86544 | ||
|
|
6133bea388 | ||
|
|
882a97aff9 | ||
|
|
fde9849d48 | ||
|
|
487818df27 | ||
|
|
ca4c745e3b | ||
|
|
b38f377c9a | ||
|
|
aab850668c | ||
|
|
8b1d5a2e3c | ||
|
|
a0990380b5 | ||
|
|
2fcaa302da | ||
|
|
7fa2d7be17 | ||
|
|
be3aabaea6 | ||
|
|
b87b436ebc | ||
|
|
7e5a5e2da7 | ||
|
|
9db5aab498 | ||
|
|
390edb5e0a | ||
|
|
ea96293e16 | ||
|
|
cd7e60e008 | ||
|
|
59bde4a612 | ||
|
|
02f7e48c51 | ||
|
|
aeb6602266 | ||
|
|
805114aef8 | ||
|
|
91035ad7b0 | ||
|
|
12a9bc3ed9 | ||
|
|
2141b39c3d | ||
|
|
268627469b | ||
|
|
6a72cd064b | ||
|
|
aebe3ace3c | ||
|
|
c402784d97 | ||
|
|
bed6ab1cce | ||
|
|
1b08a6c063 | ||
|
|
82fa7a0660 | ||
|
|
2e9236fab4 | ||
|
|
dadf05809c | ||
|
|
29c3825604 | ||
|
|
faf6a5497a | ||
|
|
dd85aaa951 | ||
|
|
aacae1de43 | ||
|
|
8d993156e7 | ||
|
|
57003ca68c | ||
|
|
47de37eb0a | ||
|
|
dc7b4fda64 | ||
|
|
3dcca31796 | ||
|
|
c194a6ac3b | ||
|
|
d421fa9e64 | ||
|
|
2778c7d851 | ||
|
|
b465145229 | ||
|
|
f2d6748432 | ||
|
|
08f1431946 | ||
|
|
43d5aaa798 | ||
|
|
5c8268b6f4 | ||
|
|
d0cda58f1f | ||
|
|
c7a1de4983 | ||
|
|
49001a0f83 | ||
|
|
11ecf6fc86 | ||
|
|
99ba2f6424 | ||
|
|
93f8fe3671 | ||
|
|
e7b468e122 | ||
|
|
dca040908a | ||
|
|
2180dd13dc | ||
|
|
11808ef7ed | ||
|
|
8da6d23688 | ||
|
|
37b83e05a7 | ||
|
|
5caf23d627 | ||
|
|
d1bfba1abb | ||
|
|
b9fe4fc263 | ||
|
|
e506b40c27 | ||
|
|
83a04c4755 | ||
|
|
94b7b402c5 | ||
|
|
a8984a9b30 | ||
|
|
acd48a1259 | ||
|
|
70478b92a9 | ||
|
|
2cdaf912ba | ||
|
|
072d8ba289 | ||
|
|
03ed37d0dc | ||
|
|
bedcbb9feb | ||
|
|
820169ba2e | ||
|
|
15a1f1af9d | ||
|
|
387706607d | ||
|
|
dccca91fc9 | ||
|
|
a6a386f72a | ||
|
|
67d16992cf | ||
|
|
9382334a5e | ||
|
|
c795168e9c | ||
|
|
24c5a15d7a | ||
|
|
c725e258c6 | ||
|
|
d42e3f1e7f | ||
|
|
7748e56153 | ||
|
|
e126d2fcd9 | ||
|
|
a5a3da01f6 | ||
|
|
dc9f17bb4a | ||
|
|
f21ff09389 | ||
|
|
6c3fb18ef6 | ||
|
|
a3a432e3cf | ||
|
|
6f7beb414c | ||
|
|
61e382444a | ||
|
|
32809a7be7 | ||
|
|
37a3f1e6b6 | ||
|
|
574015edd9 | ||
|
|
f9a05401c1 | ||
|
|
9a6422f331 | ||
|
|
ae86c7ba05 | ||
|
|
65be9cab47 | ||
|
|
23c014e29c | ||
|
|
3ef2c6d198 | ||
|
|
c77a22d4c6 | ||
|
|
d06e17fbd9 | ||
|
|
0c6f788406 | ||
|
|
325bb89137 | ||
|
|
ac1bb5ee42 | ||
|
|
498edb57ab | ||
|
|
7bc8766542 | ||
|
|
c1fe688956 | ||
|
|
21965f986c | ||
|
|
32b1ef3779 | ||
|
|
bcce1e7b84 | ||
|
|
bc23009f61 | ||
|
|
b447c329db | ||
|
|
fd434626c5 | ||
|
|
8985e489a5 | ||
|
|
0ce89392b8 | ||
|
|
d5a1b717c2 | ||
|
|
091804c750 | ||
|
|
d64c3d6af8 | ||
|
|
327f915610 | ||
|
|
008051e42d | ||
|
|
293bb82019 | ||
|
|
80079cd2a5 | ||
|
|
7356764a48 | ||
|
|
871e0dfab8 | ||
|
|
83c4dddb7e | ||
|
|
1c1aa047ff | ||
|
|
b08679c906 | ||
|
|
b6c2c64f9b | ||
|
|
cfe3753d4c | ||
|
|
9aef0a8e6c | ||
|
|
a5ea113a8e | ||
|
|
379765da23 | ||
|
|
f7e559223d | ||
|
|
0170791800 | ||
|
|
e275441651 | ||
|
|
f2e006179d | ||
|
|
bd85070411 | ||
|
|
9ed351260c | ||
|
|
ab0d9df658 | ||
|
|
bce6eb5014 | ||
|
|
9ca48c00a6 | ||
|
|
0b5cc96362 | ||
|
|
b497791c59 | ||
|
|
36e1e57252 | ||
|
|
a9f04eba2c |
65
.github/workflows/build-and-publish-image.yml
vendored
Normal file
65
.github/workflows/build-and-publish-image.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
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 }}
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -36,6 +36,14 @@ jobs:
|
||||
- name: Run linter
|
||||
run: npm run lint:ci
|
||||
|
||||
- name: Run linter on integration tests
|
||||
run: npx eslint integration-tests --max-warnings 0
|
||||
|
||||
- name: Run formatter on integration tests
|
||||
run: |
|
||||
npx prettier --check integration-tests
|
||||
git diff --exit-code
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
|
||||
221
.github/workflows/gemini-automated-issue-triage.yml
vendored
221
.github/workflows/gemini-automated-issue-triage.yml
vendored
@@ -2,7 +2,33 @@ name: Qwen Automated Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
types:
|
||||
- 'opened'
|
||||
- 'reopened'
|
||||
issue_comment:
|
||||
types:
|
||||
- 'created'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: 'issue number to triage'
|
||||
required: true
|
||||
type: 'number'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.event.issue.number }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
id-token: 'write'
|
||||
issues: 'write'
|
||||
statuses: 'write'
|
||||
packages: 'read'
|
||||
|
||||
jobs:
|
||||
triage-issue:
|
||||
@@ -24,34 +50,43 @@ jobs:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
with:
|
||||
version: 0.0.4
|
||||
version: 0.0.7
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
settings_json: |
|
||||
{
|
||||
"maxSessionTurns": 25,
|
||||
"coreTools": [
|
||||
"run_shell_command(echo)",
|
||||
"run_shell_command(gh label list)",
|
||||
"run_shell_command(gh issue edit)",
|
||||
"run_shell_command(gh issue list)"
|
||||
],
|
||||
"sandbox": false
|
||||
}
|
||||
prompt: |
|
||||
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue.
|
||||
Steps:
|
||||
prompt: |-
|
||||
## Role
|
||||
|
||||
You are an issue triage assistant. Analyze the current GitHub issue and apply the most appropriate existing labels. Use the available
|
||||
tools to gather information; do not ask for information to be provided. Do not remove labels titled help wanted or good first issue.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
|
||||
2. Review the issue title, body and any comments provided in the environment variables.
|
||||
2. Review the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
|
||||
3. Ignore any existing priorities or tags on the issue. Just report your findings.
|
||||
4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
|
||||
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`
|
||||
7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label
|
||||
8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label
|
||||
9. Use Area definitions mentioned below to help you narrow down issues
|
||||
Guidelines:
|
||||
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`.
|
||||
7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label.
|
||||
8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label.
|
||||
9. Use Area definitions mentioned below to help you narrow down issues.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only use labels that already exist in the repository.
|
||||
- Do not add comments or modify the issue content.
|
||||
- Triage only the current issue.
|
||||
- Apply only one area/ label
|
||||
- Apply only one kind/ label
|
||||
- Apply only one area/ label.
|
||||
- Apply only one kind/ label.
|
||||
- Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
|
||||
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
|
||||
Categorization Guidelines:
|
||||
@@ -130,3 +165,163 @@ jobs:
|
||||
- could also pertain to latency,
|
||||
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
|
||||
- Switching models from one to the other unexpectedly.
|
||||
|
||||
- name: 'Post Issue Triage Failure Comment'
|
||||
if: |-
|
||||
${{ failure() && steps.gemini_issue_triage.outcome == 'failure' }}
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |-
|
||||
github.rest.issues.createComment({
|
||||
owner: '${{ github.repository }}'.split('/')[0],
|
||||
repo: '${{ github.repository }}'.split('/')[1],
|
||||
issue_number: '${{ github.event.issue.number }}',
|
||||
body: 'There is a problem with the Gemini CLI issue triaging. Please check the [action logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.'
|
||||
})
|
||||
|
||||
deduplicate-issues:
|
||||
if: >
|
||||
github.repository == 'google-gemini/gemini-cli' &&
|
||||
vars.TRIAGE_DEDUPLICATE_ISSUES != '' &&
|
||||
(github.event_name == 'issues' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '@gemini-cli /deduplicate') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')))
|
||||
|
||||
timeout-minutes: 20
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683'
|
||||
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
uses: 'actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e'
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 'Run Gemini Issue Deduplication'
|
||||
uses: 'google-github-actions/run-gemini-cli@20351b5ea2b4179431f1ae8918a246a0808f8747'
|
||||
id: 'gemini_issue_deduplication'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
|
||||
ISSUE_TITLE: '${{ github.event.issue.title }}'
|
||||
ISSUE_BODY: '${{ github.event.issue.body }}'
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
|
||||
with:
|
||||
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
|
||||
gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}'
|
||||
gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}'
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}'
|
||||
use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}'
|
||||
settings: |-
|
||||
{
|
||||
"mcpServers": {
|
||||
"issue_deduplication": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"--network", "host",
|
||||
"-e", "GITHUB_TOKEN",
|
||||
"-e", "GEMINI_API_KEY",
|
||||
"-e", "DATABASE_TYPE",
|
||||
"-e", "FIRESTORE_DATABASE_ID",
|
||||
"-e", "GCP_PROJECT",
|
||||
"-e", "GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json",
|
||||
"-v", "${GOOGLE_APPLICATION_CREDENTIALS}:/app/gcp-credentials.json",
|
||||
"ghcr.io/google-gemini/gemini-cli-issue-triage@sha256:e3de1523f6c83aabb3c54b76d08940a2bf42febcb789dd2da6f95169641f94d3"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_TOKEN": "${GITHUB_TOKEN}",
|
||||
"GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}",
|
||||
"DATABASE_TYPE":"firestore",
|
||||
"GCP_PROJECT": "${FIRESTORE_PROJECT}",
|
||||
"FIRESTORE_DATABASE_ID": "(default)",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}"
|
||||
},
|
||||
"enabled": true,
|
||||
"timeout": 600000
|
||||
}
|
||||
},
|
||||
"maxSessionTurns": 25,
|
||||
"coreTools": [
|
||||
"run_shell_command(echo)",
|
||||
"run_shell_command(gh issue comment)",
|
||||
"run_shell_command(gh issue view)",
|
||||
"run_shell_command(gh issue edit)"
|
||||
],
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp"
|
||||
}
|
||||
}
|
||||
prompt: |-
|
||||
## Role
|
||||
You are an issue de-duplication assistant. Your goal is to find
|
||||
duplicate issues, label the current issue as a duplicate, and notify
|
||||
the user by commenting on the current issue, while avoiding
|
||||
duplicate comments.
|
||||
## Steps
|
||||
1. **Find Potential Duplicates:**
|
||||
- The repository is ${{ github.repository }} and the issue number is ${{ github.event.issue.number }}.
|
||||
- Use the `duplicates` tool with the `repo` and `issue_number` to find potential duplicates for the current issue. Do not use the `threshold` parameter.
|
||||
- If no duplicates are found, you are done.
|
||||
- Print the JSON output from the `duplicates` tool to the logs.
|
||||
2. **Refine Duplicates List (if necessary):**
|
||||
- If the `duplicates` tool returns between 1 and 14 results, you must refine the list.
|
||||
- For each potential duplicate issue, run `gh issue view <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).
|
||||
|
||||
@@ -3,7 +3,22 @@ name: Qwen Scheduled Issue Triage
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # Runs every hour
|
||||
workflow_dispatch: {}
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
id-token: 'write'
|
||||
issues: 'write'
|
||||
statuses: 'write'
|
||||
packages: 'read'
|
||||
|
||||
jobs:
|
||||
triage-issues:
|
||||
@@ -23,16 +38,19 @@ jobs:
|
||||
echo "🔍 Finding issues without labels..."
|
||||
NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body)
|
||||
|
||||
echo "🏷️ Finding issues that need triage..."
|
||||
NEED_TRIAGE_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue label:\"status/need-triage\"" --json number,title,body)
|
||||
echo '🔍 Finding issues without labels...'
|
||||
NO_LABEL_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue no:label' --json number,title,body)"
|
||||
|
||||
echo "🔄 Merging and deduplicating issues..."
|
||||
ISSUES=$(echo "$NO_LABEL_ISSUES" "$NEED_TRIAGE_ISSUES" | jq -c -s 'add | unique_by(.number)')
|
||||
echo '🏷️ Finding issues that need triage...'
|
||||
NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue label:"status/needs-triage"' --json number,title,body)"
|
||||
|
||||
echo "📝 Setting output for GitHub Actions..."
|
||||
echo "issues_to_triage=$ISSUES" >> "$GITHUB_OUTPUT"
|
||||
echo '🔄 Merging and deduplicating issues...'
|
||||
ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
|
||||
|
||||
echo "✅ Found $(echo "$ISSUES" | jq 'length') issues to triage! 🎯"
|
||||
echo '📝 Setting output for GitHub Actions...'
|
||||
echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Run Qwen Issue Triage
|
||||
if: steps.find_issues.outputs.issues_to_triage != '[]'
|
||||
@@ -42,24 +60,31 @@ jobs:
|
||||
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
with:
|
||||
version: 0.0.4
|
||||
version: 0.0.7
|
||||
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 list)",
|
||||
"run_shell_command(gh issue view)"
|
||||
"run_shell_command(gh issue view)",
|
||||
"run_shell_command(gh issue list)"
|
||||
],
|
||||
"sandbox": false
|
||||
}
|
||||
prompt: |
|
||||
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels.
|
||||
Steps:
|
||||
prompt: |-
|
||||
## Role
|
||||
|
||||
You are an issue triage assistant. Analyze issues and apply
|
||||
appropriate labels. Use the available tools to gather information;
|
||||
do not ask for information to be provided.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
|
||||
2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
|
||||
3. Review the issue title, body and any comments provided in the environment variables.
|
||||
@@ -84,8 +109,10 @@ jobs:
|
||||
- After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"`
|
||||
- Execute one `gh issue edit` command per issue, wait for success before proceeding to the next
|
||||
Process each issue sequentially and confirm each labeling operation before moving to the next issue.
|
||||
Guidelines:
|
||||
- Only use labels that already exist in the repository.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only use labels that already exist in the repository.
|
||||
- Do not add comments or modify the issue content.
|
||||
- Do not remove labels titled help wanted or good first issue.
|
||||
- Triage only the current issue.
|
||||
|
||||
12
.github/workflows/qwen-code-pr-review.yml
vendored
12
.github/workflows/qwen-code-pr-review.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: 🧐 Qwen Pull Request Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
@@ -18,7 +18,11 @@ jobs:
|
||||
review-pr:
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' && github.event.action == 'opened') ||
|
||||
(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 == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@qwen /review') &&
|
||||
@@ -49,9 +53,9 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get PR details (pull_request & workflow_dispatch)
|
||||
- name: Get PR details (pull_request_target & workflow_dispatch)
|
||||
id: get_pr
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
|
||||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@@ -84,6 +84,11 @@ 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 }}
|
||||
@@ -158,11 +163,20 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_BRANCH: ${{ steps.release_branch.outputs.BRANCH_NAME }}
|
||||
run: |
|
||||
gh release create ${{ steps.version.outputs.RELEASE_TAG }} \
|
||||
bundle/gemini.js \
|
||||
--target "$RELEASE_BRANCH" \
|
||||
--title "Release ${{ steps.version.outputs.RELEASE_TAG }}" \
|
||||
--generate-notes
|
||||
# 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
|
||||
|
||||
- name: Create Issue on Failure
|
||||
if: failure()
|
||||
|
||||
1
.npmrc
1
.npmrc
@@ -1,2 +1 @@
|
||||
registry=https://registry.npmjs.org
|
||||
@google:registry=https://wombat-dressing-room.appspot.com
|
||||
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["vitest.explorer", "esbenp.prettier-vscode"]
|
||||
}
|
||||
15
.vscode/settings.json
vendored
15
.vscode/settings.json
vendored
@@ -1,3 +1,16 @@
|
||||
{
|
||||
"typescript.tsserver.experimental.enableProjectDiagnostics": true
|
||||
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.rulers": [80],
|
||||
"editor.detectIndentation": false,
|
||||
"editor.insertSpaces": true,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
|
||||
26
CHANGELOG.md
Normal file
26
CHANGELOG.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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
|
||||
55
Dockerfile
55
Dockerfile
@@ -1,3 +1,31 @@
|
||||
# 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"
|
||||
@@ -5,11 +33,9 @@ ARG CLI_VERSION_ARG
|
||||
ENV SANDBOX="$SANDBOX_NAME"
|
||||
ENV CLI_VERSION=$CLI_VERSION_ARG
|
||||
|
||||
# install minimal set of packages, then clean up
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
man-db \
|
||||
curl \
|
||||
dnsutils \
|
||||
@@ -29,22 +55,19 @@ 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 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
|
||||
# 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
|
||||
|
||||
# switch to non-root user node
|
||||
USER node
|
||||
# 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/
|
||||
|
||||
# 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 \
|
||||
# Install built packages globally
|
||||
RUN npm install -g /tmp/*.tgz \
|
||||
&& npm cache clean --force \
|
||||
&& rm -f /usr/local/share/npm-global/qwen-{code,code-core}.tgz
|
||||
&& rm -rf /tmp/*.tgz
|
||||
|
||||
# default entrypoint when none specified
|
||||
CMD ["qwen"]
|
||||
# Default entrypoint when none specified
|
||||
CMD ["qwen"]
|
||||
|
||||
2
Makefile
2
Makefile
@@ -53,7 +53,7 @@ debug:
|
||||
|
||||
|
||||
run-npx:
|
||||
npx https://github.com/google-gemini/gemini-cli
|
||||
npx https://github.com/QwenLM/qwen-code
|
||||
|
||||
create-alias:
|
||||
scripts/create_alias.sh
|
||||
|
||||
18
README.md
18
README.md
@@ -15,6 +15,20 @@
|
||||
|
||||
</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
|
||||
@@ -101,7 +115,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
|
||||
- **`/status`** - Check current token usage and limits
|
||||
- **`/stats`** - Check current token usage and limits
|
||||
|
||||
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
|
||||
|
||||
@@ -310,7 +324,7 @@ qwen
|
||||
- `/help` - Display available commands
|
||||
- `/clear` - Clear conversation history
|
||||
- `/compress` - Compress history to save tokens
|
||||
- `/status` - Show current session information
|
||||
- `/stats` - Show current session information
|
||||
- `/exit` or `/quit` - Exit Qwen Code
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
@@ -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 `priorty/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 `priority/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.
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Reporting Security Issues
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
[GitHub Security Advisory]: https://github.com/google-gemini/gemini-cli/security/advisories
|
||||
Thank you for helping keep our project secure.
|
||||
|
||||
@@ -27,6 +27,9 @@ 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.
|
||||
@@ -46,7 +49,18 @@ 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 `/direcotry add` and `--include-directories`.
|
||||
- **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`.
|
||||
- **Usage:** `/directory show`
|
||||
|
||||
- **`/editor`**
|
||||
@@ -70,21 +84,26 @@ 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 `GEMINI.md` files).
|
||||
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `QWEN.md` files by default; configurable via `contextFileName`).
|
||||
- **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 `GEMINI.md` files. This lets you inspect the instructional context being provided to the Gemini model.
|
||||
- **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.
|
||||
- **`refresh`**:
|
||||
- **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).
|
||||
- **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).
|
||||
|
||||
- **`/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.
|
||||
|
||||
@@ -123,7 +142,7 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer
|
||||
|
||||
- **`/init`**
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### Custom Commands
|
||||
|
||||
@@ -253,7 +272,7 @@ Please generate a Conventional Commit message based on the following git diff:
|
||||
|
||||
```diff
|
||||
!{git diff --staged}
|
||||
````
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
@@ -274,7 +293,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:**
|
||||
|
||||
|
||||
@@ -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., `GEMINI.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
|
||||
- **Default:** `GEMINI.md`
|
||||
- **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`
|
||||
- **Example:** `"contextFileName": "AGENTS.md"`
|
||||
|
||||
- **`bugCommand`** (object):
|
||||
@@ -248,6 +248,50 @@ 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
|
||||
@@ -256,6 +300,7 @@ 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"
|
||||
@@ -280,7 +325,9 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
"tokenBudget": 100
|
||||
}
|
||||
},
|
||||
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
|
||||
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"],
|
||||
"includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"],
|
||||
"loadMemoryFromIncludeDirectories": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -351,6 +398,11 @@ 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
|
||||
|
||||
@@ -408,6 +460,9 @@ 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)
|
||||
|
||||
@@ -415,7 +470,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., `GEMINI.md`)
|
||||
### Example Context File Content (e.g., `QWEN.md`)
|
||||
|
||||
Here's a conceptual example of what a context file at the root of a TypeScript project might contain:
|
||||
|
||||
@@ -450,9 +505,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., `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:
|
||||
- **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:
|
||||
1. **Global Context File:**
|
||||
- Location: `~/.gemini/<contextFileName>` (e.g., `~/.gemini/GEMINI.md` in your user home directory).
|
||||
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/QWEN.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.
|
||||
@@ -523,3 +578,5 @@ 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.
|
||||
|
||||
@@ -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 GEMINI.md import feature using @file.md syntax.
|
||||
- **[Memory Import Processor](./memport.md):** Documentation for the modular QWEN.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 Gemini model, potentially incorporating conversation history, tool definitions, and instructional context from `GEMINI.md` files.
|
||||
- **Prompt engineering:** Constructing effective prompts for the model, potentially incorporating conversation history, tool definitions, and instructional context from context files (e.g., `QWEN.md`).
|
||||
- **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 `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.
|
||||
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.
|
||||
|
||||
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 `GEMINI.md` files.
|
||||
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded context files.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# Memory Import Processor
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Syntax
|
||||
|
||||
Use the `@` symbol followed by the path to the file you want to import:
|
||||
|
||||
```markdown
|
||||
# Main GEMINI.md file
|
||||
# Main QWEN.md file
|
||||
|
||||
This is the main content.
|
||||
|
||||
@@ -39,7 +39,7 @@ More content here.
|
||||
### Basic Import
|
||||
|
||||
```markdown
|
||||
# My GEMINI.md
|
||||
# My QWEN.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, 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.
|
||||
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.
|
||||
|
||||
Example tree structure:
|
||||
|
||||
```
|
||||
Memory Files
|
||||
L project: GEMINI.md
|
||||
Memory Files
|
||||
L project: QWEN.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 GEMINI.md content.
|
||||
Processes import statements in context file content.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ 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 string content to be included in the history sent back to the LLM for context.
|
||||
- `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.
|
||||
- `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:
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# Gemini CLI Extensions
|
||||
# Qwen Code Extensions
|
||||
|
||||
Gemini CLI supports extensions that can be used to configure and extend its functionality.
|
||||
Qwen Code supports extensions that can be used to configure and extend its functionality.
|
||||
|
||||
## How it works
|
||||
|
||||
On startup, Gemini CLI looks for extensions in two locations:
|
||||
On startup, Qwen Code looks for extensions in two locations:
|
||||
|
||||
1. `<workspace>/.gemini/extensions`
|
||||
2. `<home>/.gemini/extensions`
|
||||
1. `<workspace>/.qwen/extensions`
|
||||
2. `<home>/.qwen/extensions`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Within each location, individual extensions exist as a directory that contains a `gemini-extension.json` file. For example:
|
||||
Within each location, individual extensions exist as a directory that contains a `qwen-extension.json` file. For example:
|
||||
|
||||
`<workspace>/.gemini/extensions/my-extension/gemini-extension.json`
|
||||
`<workspace>/.qwen/extensions/my-extension/qwen-extension.json`
|
||||
|
||||
### `gemini-extension.json`
|
||||
### `qwen-extension.json`
|
||||
|
||||
The `gemini-extension.json` file contains the configuration for the extension. The file has the following structure:
|
||||
The `qwen-extension.json` file contains the configuration for the extension. The file has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -28,7 +28,7 @@ The `gemini-extension.json` file contains the configuration for the extension. T
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"contextFileName": "QWEN.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
@@ -36,10 +36,10 @@ The `gemini-extension.json` file contains the configuration for the extension. T
|
||||
- `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 `GEMINI.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 `QWEN.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 Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
||||
When Qwen Code 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:
|
||||
|
||||
```
|
||||
.gemini/extensions/gcp/
|
||||
├── gemini-extension.json
|
||||
.qwen/extensions/gcp/
|
||||
├── qwen-extension.json
|
||||
└── commands/
|
||||
├── deploy.toml
|
||||
└── gcs/
|
||||
|
||||
@@ -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 `google_web_search` tool.
|
||||
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,7 +58,17 @@ You can export all telemetry data to a file for local inspection.
|
||||
To enable file export, use the `--telemetry-outfile` flag with a path to your desired output file. This must be run using `--telemetry-target=local`.
|
||||
|
||||
```bash
|
||||
gemini --telemetry --telemetry-target=local --telemetry-outfile=/path/to/telemetry.log "your prompt"
|
||||
# Set your desired output file path
|
||||
TELEMETRY_FILE=".gemini/telemetry.log"
|
||||
|
||||
# Run Gemini CLI with local telemetry
|
||||
# NOTE: --telemetry-otlp-endpoint="" is required to override the default
|
||||
# OTLP exporter and ensure telemetry is written to the local file.
|
||||
gemini --telemetry \
|
||||
--telemetry-target=local \
|
||||
--telemetry-otlp-endpoint="" \
|
||||
--telemetry-outfile="$TELEMETRY_FILE" \
|
||||
--prompt "What is OpenTelemetry?"
|
||||
```
|
||||
|
||||
## Running an OTEL Collector
|
||||
@@ -173,9 +183,10 @@ Logs are timestamped records of specific events. The following events are logged
|
||||
- `function_args`
|
||||
- `duration_ms`
|
||||
- `success` (boolean)
|
||||
- `decision` (string: "accept", "reject", or "modify", if applicable)
|
||||
- `decision` (string: "accept", "reject", "auto_accept", or "modify", if applicable)
|
||||
- `error` (if applicable)
|
||||
- `error_type` (if applicable)
|
||||
- `metadata` (if applicable, dictionary of string -> any)
|
||||
|
||||
- `gemini_cli.api_request`: This event occurs when making a request to Gemini API.
|
||||
- **Attributes**:
|
||||
@@ -252,3 +263,7 @@ Metrics are numerical measurements of behavior over time. The following metrics
|
||||
- `lines` (Int, if applicable): Number of lines in the file.
|
||||
- `mimetype` (string, if applicable): Mimetype of the file.
|
||||
- `extension` (string, if applicable): File extension of the file.
|
||||
- `ai_added_lines` (Int, if applicable): Number of lines added/changed by AI.
|
||||
- `ai_removed_lines` (Int, if applicable): Number of lines removed/changed by AI.
|
||||
- `user_added_lines` (Int, if applicable): Number of lines added/changed by user in AI proposed changes.
|
||||
- `user_removed_lines` (Int, if applicable): Number of lines removed/changed by user in AI proposed changes.
|
||||
|
||||
@@ -169,6 +169,7 @@ 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
|
||||
|
||||
@@ -571,6 +572,56 @@ 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.
|
||||
@@ -637,3 +688,114 @@ or, using positional arguments:
|
||||
```
|
||||
|
||||
When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows.
|
||||
|
||||
## Managing MCP Servers with `gemini mcp`
|
||||
|
||||
While you can always configure MCP servers by manually editing your `settings.json` file, the Gemini CLI provides a convenient set of commands to manage your server configurations programmatically. These commands streamline the process of adding, listing, and removing MCP servers without needing to directly edit JSON files.
|
||||
|
||||
### Adding a Server (`gemini mcp add`)
|
||||
|
||||
The `add` command configures a new MCP server in your `settings.json`. Based on the scope (`-s, --scope`), it will be added to either the user config `~/.gemini/settings.json` or the project config `.gemini/settings.json` file.
|
||||
|
||||
**Command:**
|
||||
|
||||
```bash
|
||||
gemini mcp add [options] <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`).
|
||||
|
||||
@@ -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 Gemini CLI 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 Qwen Code 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 Gemini CLI sessions
|
||||
|
||||
## How to use `save_memory` with the Gemini CLI
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Usage:
|
||||
|
||||
|
||||
@@ -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 including test files and all JPEGs in an `images` folder:
|
||||
Read all JavaScript files but explicitly include test files and all JPEGs in an `images` folder:
|
||||
|
||||
```
|
||||
read_many_files(paths=["**/*.js"], include=["**/*.test.js", "images/**/*.jpg"], useDefaultExcludes=False)
|
||||
|
||||
@@ -137,6 +137,5 @@ 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.
|
||||
|
||||
@@ -4,24 +4,25 @@ This document describes the `web_fetch` tool for the Gemini CLI.
|
||||
|
||||
## Description
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Arguments
|
||||
|
||||
`web_fetch` takes one argument:
|
||||
`web_fetch` takes two arguments:
|
||||
|
||||
- `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://`.
|
||||
- `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.
|
||||
|
||||
## How to use `web_fetch` with the Gemini CLI
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
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.
|
||||
The tool automatically converts HTML to text, handles GitHub blob URLs (converting them to raw URLs), and upgrades HTTP URLs to HTTPS for security.
|
||||
|
||||
Usage:
|
||||
|
||||
```
|
||||
web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
|
||||
web_fetch(url="https://example.com", prompt="Summarize the main points of this article")
|
||||
```
|
||||
|
||||
## `web_fetch` examples
|
||||
@@ -29,16 +30,25 @@ web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
|
||||
Summarize a single article:
|
||||
|
||||
```
|
||||
web_fetch(prompt="Can you summarize the main points of https://example.com/news/latest")
|
||||
web_fetch(url="https://example.com/news/latest", prompt="Can you summarize the main points of this article?")
|
||||
```
|
||||
|
||||
Compare two articles:
|
||||
Extract specific information:
|
||||
|
||||
```
|
||||
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?")
|
||||
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?")
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
- **URL processing:** `web_fetch` relies on the Gemini API's ability to access and process the given URLs.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -1,36 +1,43 @@
|
||||
# Web Search Tool (`google_web_search`)
|
||||
# Web Search Tool (`web_search`)
|
||||
|
||||
This document describes the `google_web_search` tool.
|
||||
This document describes the `web_search` tool.
|
||||
|
||||
## Description
|
||||
|
||||
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.
|
||||
Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible.
|
||||
|
||||
### Arguments
|
||||
|
||||
`google_web_search` takes one argument:
|
||||
`web_search` takes one argument:
|
||||
|
||||
- `query` (string, required): The search query.
|
||||
|
||||
## How to use `google_web_search` with the Gemini CLI
|
||||
## How to use `web_search`
|
||||
|
||||
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.
|
||||
`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.
|
||||
|
||||
Usage:
|
||||
|
||||
```
|
||||
google_web_search(query="Your query goes here.")
|
||||
web_search(query="Your query goes here.")
|
||||
```
|
||||
|
||||
## `google_web_search` examples
|
||||
## `web_search` examples
|
||||
|
||||
Get information on a topic:
|
||||
|
||||
```
|
||||
google_web_search(query="latest advancements in AI-powered code generation")
|
||||
web_search(query="latest advancements in AI-powered code generation")
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
# Troubleshooting Guide
|
||||
# Troubleshooting guide
|
||||
|
||||
This guide provides solutions to common issues and debugging tips.
|
||||
This guide provides solutions to common issues and debugging tips, including topics on:
|
||||
|
||||
## Authentication
|
||||
- Authentication or login errors
|
||||
- Frequently asked questions (FAQs)
|
||||
- Debugging tips
|
||||
- Existing GitHub Issues similar to yours or creating new Issues
|
||||
|
||||
## Authentication or login errors
|
||||
|
||||
- **Error: `Failed to login. Message: Request contains an invalid argument`**
|
||||
- Users with Google Workspace accounts, or users with Google Cloud accounts
|
||||
- Users with Google Workspace accounts or 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.
|
||||
- You can also grab an API key from [AI Studio](https://aistudio.google.com/app/apikey), which also includes a
|
||||
- Alternatively, you can obtain the Gemini API key from
|
||||
[Google AI Studio](http://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 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`.
|
||||
- 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`.
|
||||
|
||||
- **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: 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: 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 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.
|
||||
- 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.
|
||||
|
||||
## Common error messages and solutions
|
||||
|
||||
@@ -31,26 +41,27 @@ This guide provides solutions to common issues and debugging tips.
|
||||
- **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).**
|
||||
- **Cause:** Gemini CLI is not correctly installed or not in your system's PATH.
|
||||
- **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`.
|
||||
- **Solution:**
|
||||
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 ...`).
|
||||
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`.
|
||||
|
||||
- **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:** 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.
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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**
|
||||
@@ -72,9 +83,11 @@ This guide provides solutions to common issues and debugging tips.
|
||||
- **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, double-check paths and permissions.
|
||||
- For _file system tools_, verify that paths are correct and check the 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.
|
||||
|
||||
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.
|
||||
## 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!
|
||||
|
||||
@@ -35,6 +35,7 @@ export default tseslint.config(
|
||||
'packages/vscode-ide-companion/dist/**',
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
'.integration-tests/**',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
|
||||
@@ -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
|
||||
199
integration-tests/mcp_server_cyclic_schema.test.js
Normal file
199
integration-tests/mcp_server_cyclic_schema.test.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* This test verifies we can match maximum schema depth errors from Gemini
|
||||
* and then detect and warn about the potential tools that caused the error.
|
||||
*/
|
||||
|
||||
import { test, describe, before } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
// Create a minimal MCP server that doesn't require external dependencies
|
||||
// This implements the MCP protocol directly using Node.js built-ins
|
||||
const serverScript = `#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const fs = require('fs');
|
||||
|
||||
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
|
||||
const debugEnabled = process.env.MCP_DEBUG === 'true' || process.env.VERBOSE === 'true';
|
||||
function debug(msg) {
|
||||
if (debugEnabled) {
|
||||
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
|
||||
}
|
||||
}
|
||||
|
||||
debug('MCP server starting...');
|
||||
|
||||
// Simple JSON-RPC implementation for MCP
|
||||
class SimpleJSONRPC {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
this.rl.on('line', (line) => {
|
||||
debug(\`Received line: \${line}\`);
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
debug(\`Parsed message: \${JSON.stringify(message)}\`);
|
||||
this.handleMessage(message);
|
||||
} catch (e) {
|
||||
debug(\`Parse error: \${e.message}\`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(message) {
|
||||
const msgStr = JSON.stringify(message);
|
||||
debug(\`Sending message: \${msgStr}\`);
|
||||
process.stdout.write(msgStr + '\\n');
|
||||
}
|
||||
|
||||
async handleMessage(message) {
|
||||
if (message.method && this.handlers.has(message.method)) {
|
||||
try {
|
||||
const result = await this.handlers.get(message.method)(message.params || {});
|
||||
if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Method not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
on(method, handler) {
|
||||
this.handlers.set(method, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP server
|
||||
const rpc = new SimpleJSONRPC();
|
||||
|
||||
// Handle initialize
|
||||
rpc.on('initialize', async (params) => {
|
||||
debug('Handling initialize request');
|
||||
return {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'cyclic-schema-server',
|
||||
version: '1.0.0'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Handle tools/list
|
||||
rpc.on('tools/list', async () => {
|
||||
debug('Handling tools/list request');
|
||||
return {
|
||||
tools: [{
|
||||
name: 'tool_with_cyclic_schema',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
child: { $ref: '#/properties/data/items' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
// Send initialization notification
|
||||
rpc.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialized'
|
||||
});
|
||||
`;
|
||||
|
||||
describe('mcp server with cyclic tool schema is detected', () => {
|
||||
const rig = new TestRig();
|
||||
|
||||
before(async () => {
|
||||
// Setup test directory with MCP server configuration
|
||||
await rig.setup('cyclic-schema-mcp-server', {
|
||||
settings: {
|
||||
mcpServers: {
|
||||
'cyclic-schema-server': {
|
||||
command: 'node',
|
||||
args: ['mcp-server.cjs'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create server script in the test directory
|
||||
const testServerPath = join(rig.testDir, 'mcp-server.cjs');
|
||||
writeFileSync(testServerPath, serverScript);
|
||||
|
||||
// Make the script executable (though running with 'node' should work anyway)
|
||||
if (process.platform !== 'win32') {
|
||||
const { chmodSync } = await import('fs');
|
||||
chmodSync(testServerPath, 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
test('should error and suggest disabling the cyclic tool', async () => {
|
||||
// Just run any command to trigger the schema depth error.
|
||||
// If this test starts failing, check `isSchemaDepthError` from
|
||||
// geminiChat.ts to see if it needs to be updated.
|
||||
// Or, possibly it could mean that gemini has fixed the issue.
|
||||
const output = await rig.run('hello');
|
||||
|
||||
assert.match(
|
||||
output,
|
||||
/Skipping tool 'tool_with_cyclic_schema' from MCP server 'cyclic-schema-server' because it has missing types in its parameter schema/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -52,13 +52,13 @@ async function main() {
|
||||
|
||||
const testPatterns =
|
||||
args.length > 0
|
||||
? args.map((arg) => `integration-tests/${arg}.test.js`)
|
||||
: ['integration-tests/*.test.js'];
|
||||
? args.map((arg) => `integration-tests/${arg}.test.ts`)
|
||||
: ['integration-tests/*.test.ts'];
|
||||
const testFiles = glob.sync(testPatterns, { cwd: rootDir, absolute: true });
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
const testFileName = basename(testFile);
|
||||
console.log(`\tFound test file: ${testFileName}`);
|
||||
console.log(` Found test file: ${testFileName}`);
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
@@ -92,7 +92,7 @@ async function main() {
|
||||
}
|
||||
nodeArgs.push(testFile);
|
||||
|
||||
const child = spawn('node', nodeArgs, {
|
||||
const child = spawn('npx', ['tsx', ...nodeArgs], {
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -14,11 +14,8 @@ import { test, describe, before } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { TestRig, validateModelOutput } from './test-helper.js';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
// Create a minimal MCP server that doesn't require external dependencies
|
||||
// This implements the MCP protocol directly using Node.js built-ins
|
||||
const serverScript = `#!/usr/bin/env node
|
||||
@@ -185,7 +182,7 @@ describe('simple-mcp-server', () => {
|
||||
});
|
||||
|
||||
// Create server script in the test directory
|
||||
const testServerPath = join(rig.testDir, 'mcp-server.cjs');
|
||||
const testServerPath = join(rig.testDir!, 'mcp-server.cjs');
|
||||
writeFileSync(testServerPath, serverScript);
|
||||
|
||||
// Make the script executable (though running with 'node' should work anyway)
|
||||
@@ -14,7 +14,7 @@ import { fileExists } from '../scripts/telemetry_utils.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function sanitizeTestName(name) {
|
||||
function sanitizeTestName(name: string) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
@@ -22,7 +22,11 @@ function sanitizeTestName(name) {
|
||||
}
|
||||
|
||||
// Helper to create detailed error messages
|
||||
export function createToolCallErrorMessage(expectedTools, foundTools, result) {
|
||||
export function createToolCallErrorMessage(
|
||||
expectedTools: string | string[],
|
||||
foundTools: string[],
|
||||
result: string,
|
||||
) {
|
||||
const expectedStr = Array.isArray(expectedTools)
|
||||
? expectedTools.join(' or ')
|
||||
: expectedTools;
|
||||
@@ -34,7 +38,11 @@ export function createToolCallErrorMessage(expectedTools, foundTools, result) {
|
||||
}
|
||||
|
||||
// Helper to print debug information when tests fail
|
||||
export function printDebugInfo(rig, result, context = {}) {
|
||||
export function printDebugInfo(
|
||||
rig: TestRig,
|
||||
result: string,
|
||||
context: Record<string, unknown> = {},
|
||||
) {
|
||||
console.error('Test failed - Debug info:');
|
||||
console.error('Result length:', result.length);
|
||||
console.error('Result (first 500 chars):', result.substring(0, 500));
|
||||
@@ -60,8 +68,8 @@ export function printDebugInfo(rig, result, context = {}) {
|
||||
|
||||
// Helper to validate model output and warn about unexpected content
|
||||
export function validateModelOutput(
|
||||
result,
|
||||
expectedContent = null,
|
||||
result: string,
|
||||
expectedContent: string | (string | RegExp)[] | null = null,
|
||||
testName = '',
|
||||
) {
|
||||
// First, check if there's any output at all (this should fail the test if missing)
|
||||
@@ -102,6 +110,11 @@ export function validateModelOutput(
|
||||
}
|
||||
|
||||
export class TestRig {
|
||||
bundlePath: string;
|
||||
testDir: string | null;
|
||||
testName?: string;
|
||||
_lastRunStdout?: string;
|
||||
|
||||
constructor() {
|
||||
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
|
||||
this.testDir = null;
|
||||
@@ -114,10 +127,13 @@ export class TestRig {
|
||||
return 15000; // 15s locally
|
||||
}
|
||||
|
||||
setup(testName, options = {}) {
|
||||
setup(
|
||||
testName: string,
|
||||
options: { settings?: Record<string, unknown> } = {},
|
||||
) {
|
||||
this.testName = testName;
|
||||
const sanitizedName = sanitizeTestName(testName);
|
||||
this.testDir = join(env.INTEGRATION_TEST_FILE_DIR, sanitizedName);
|
||||
this.testDir = join(env.INTEGRATION_TEST_FILE_DIR!, sanitizedName);
|
||||
mkdirSync(this.testDir, { recursive: true });
|
||||
|
||||
// Create a settings file to point the CLI to the local collector
|
||||
@@ -146,36 +162,43 @@ export class TestRig {
|
||||
);
|
||||
}
|
||||
|
||||
createFile(fileName, content) {
|
||||
const filePath = join(this.testDir, fileName);
|
||||
createFile(fileName: string, content: string) {
|
||||
const filePath = join(this.testDir!, fileName);
|
||||
writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
mkdir(dir) {
|
||||
mkdirSync(join(this.testDir, dir), { recursive: true });
|
||||
mkdir(dir: string) {
|
||||
mkdirSync(join(this.testDir!, dir), { recursive: true });
|
||||
}
|
||||
|
||||
sync() {
|
||||
// ensure file system is done before spawning
|
||||
execSync('sync', { cwd: this.testDir });
|
||||
execSync('sync', { cwd: this.testDir! });
|
||||
}
|
||||
|
||||
run(promptOrOptions, ...args) {
|
||||
run(
|
||||
promptOrOptions: string | { prompt?: string; stdin?: string },
|
||||
...args: string[]
|
||||
): Promise<string> {
|
||||
let command = `node ${this.bundlePath} --yolo`;
|
||||
const execOptions = {
|
||||
cwd: this.testDir,
|
||||
const execOptions: {
|
||||
cwd: string;
|
||||
encoding: 'utf-8';
|
||||
input?: string;
|
||||
} = {
|
||||
cwd: this.testDir!,
|
||||
encoding: 'utf-8',
|
||||
};
|
||||
|
||||
if (typeof promptOrOptions === 'string') {
|
||||
command += ` --prompt "${promptOrOptions}"`;
|
||||
command += ` --prompt ${JSON.stringify(promptOrOptions)}`;
|
||||
} else if (
|
||||
typeof promptOrOptions === 'object' &&
|
||||
promptOrOptions !== null
|
||||
) {
|
||||
if (promptOrOptions.prompt) {
|
||||
command += ` --prompt "${promptOrOptions.prompt}"`;
|
||||
command += ` --prompt ${JSON.stringify(promptOrOptions.prompt)}`;
|
||||
}
|
||||
if (promptOrOptions.stdin) {
|
||||
execOptions.input = promptOrOptions.stdin;
|
||||
@@ -185,10 +208,10 @@ export class TestRig {
|
||||
command += ` ${args.join(' ')}`;
|
||||
|
||||
const commandArgs = parse(command);
|
||||
const node = commandArgs.shift();
|
||||
const node = commandArgs.shift() as string;
|
||||
|
||||
const child = spawn(node, commandArgs, {
|
||||
cwd: this.testDir,
|
||||
const child = spawn(node, commandArgs as string[], {
|
||||
cwd: this.testDir!,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
@@ -197,26 +220,26 @@ export class TestRig {
|
||||
|
||||
// Handle stdin if provided
|
||||
if (execOptions.input) {
|
||||
child.stdin.write(execOptions.input);
|
||||
child.stdin.end();
|
||||
child.stdin!.write(execOptions.input);
|
||||
child.stdin!.end();
|
||||
}
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
child.stdout!.on('data', (data: Buffer) => {
|
||||
stdout += data;
|
||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||
process.stdout.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
child.stderr!.on('data', (data: Buffer) => {
|
||||
stderr += data;
|
||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||
process.stderr.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
child.on('close', (code) => {
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
child.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
// Store the raw stdout for Podman telemetry parsing
|
||||
this._lastRunStdout = stdout;
|
||||
@@ -258,6 +281,11 @@ export class TestRig {
|
||||
|
||||
result = filteredLines.join('\n');
|
||||
}
|
||||
// If we have stderr output, include that also
|
||||
if (stderr) {
|
||||
result += `\n\nStdErr:\n${stderr}`;
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
||||
@@ -268,13 +296,13 @@ export class TestRig {
|
||||
return promise;
|
||||
}
|
||||
|
||||
readFile(fileName) {
|
||||
const content = readFileSync(join(this.testDir, fileName), 'utf-8');
|
||||
readFile(fileName: string) {
|
||||
const content = readFileSync(join(this.testDir!, fileName), 'utf-8');
|
||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||
const testId = `${env.TEST_FILE_NAME.replace(
|
||||
const testId = `${env.TEST_FILE_NAME!.replace(
|
||||
'.test.js',
|
||||
'',
|
||||
)}:${this.testName.replace(/ /g, '-')}`;
|
||||
)}:${this.testName!.replace(/ /g, '-')}`;
|
||||
console.log(`--- FILE: ${testId}/${fileName} ---`);
|
||||
console.log(content);
|
||||
console.log(`--- END FILE: ${testId}/${fileName} ---`);
|
||||
@@ -290,7 +318,7 @@ export class TestRig {
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
if (env.VERBOSE === 'true') {
|
||||
console.warn('Cleanup warning:', error.message);
|
||||
console.warn('Cleanup warning:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,7 +328,7 @@ export class TestRig {
|
||||
// In sandbox mode, telemetry is written to a relative path in the test directory
|
||||
const logFilePath =
|
||||
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
|
||||
? join(this.testDir, 'telemetry.log')
|
||||
? join(this.testDir!, 'telemetry.log')
|
||||
: env.TELEMETRY_LOG_FILE;
|
||||
|
||||
if (!logFilePath) return;
|
||||
@@ -313,7 +341,7 @@ export class TestRig {
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
// Check if file has meaningful content (at least one complete JSON object)
|
||||
return content.includes('"event.name"');
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -322,7 +350,7 @@ export class TestRig {
|
||||
);
|
||||
}
|
||||
|
||||
async waitForToolCall(toolName, timeout) {
|
||||
async waitForToolCall(toolName: string, timeout?: number) {
|
||||
// Use environment-specific timeout
|
||||
if (!timeout) {
|
||||
timeout = this.getDefaultTimeout();
|
||||
@@ -341,7 +369,7 @@ export class TestRig {
|
||||
);
|
||||
}
|
||||
|
||||
async waitForAnyToolCall(toolNames, timeout) {
|
||||
async waitForAnyToolCall(toolNames: string[], timeout?: number) {
|
||||
// Use environment-specific timeout
|
||||
if (!timeout) {
|
||||
timeout = this.getDefaultTimeout();
|
||||
@@ -362,7 +390,11 @@ export class TestRig {
|
||||
);
|
||||
}
|
||||
|
||||
async poll(predicate, timeout, interval) {
|
||||
async poll(
|
||||
predicate: () => boolean,
|
||||
timeout: number,
|
||||
interval: number,
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
let attempts = 0;
|
||||
while (Date.now() - startTime < timeout) {
|
||||
@@ -384,8 +416,16 @@ export class TestRig {
|
||||
return false;
|
||||
}
|
||||
|
||||
_parseToolLogsFromStdout(stdout) {
|
||||
const logs = [];
|
||||
_parseToolLogsFromStdout(stdout: string) {
|
||||
const logs: {
|
||||
timestamp: number;
|
||||
toolRequest: {
|
||||
name: string;
|
||||
args: string;
|
||||
success: boolean;
|
||||
duration_ms: number;
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
// The console output from Podman is JavaScript object notation, not JSON
|
||||
// Look for tool call events in the output
|
||||
@@ -488,7 +528,7 @@ export class TestRig {
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
currentObject = '';
|
||||
@@ -505,7 +545,7 @@ export class TestRig {
|
||||
// If not, fall back to parsing from stdout
|
||||
if (env.GEMINI_SANDBOX === 'podman') {
|
||||
// Try reading from file first
|
||||
const logFilePath = join(this.testDir, 'telemetry.log');
|
||||
const logFilePath = join(this.testDir!, 'telemetry.log');
|
||||
|
||||
if (fileExists(logFilePath)) {
|
||||
try {
|
||||
@@ -517,7 +557,7 @@ export class TestRig {
|
||||
// File exists but is empty or doesn't have events, parse from stdout
|
||||
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Error reading file, fall back to stdout
|
||||
if (this._lastRunStdout) {
|
||||
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
||||
@@ -532,7 +572,7 @@ export class TestRig {
|
||||
// In sandbox mode, telemetry is written to a relative path in the test directory
|
||||
const logFilePath =
|
||||
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
|
||||
? join(this.testDir, 'telemetry.log')
|
||||
? join(this.testDir!, 'telemetry.log')
|
||||
: env.TELEMETRY_LOG_FILE;
|
||||
|
||||
if (!logFilePath) {
|
||||
@@ -548,7 +588,7 @@ export class TestRig {
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
|
||||
// Split the content into individual JSON objects
|
||||
// They are separated by "}\n{" pattern
|
||||
// They are separated by "}\n{"
|
||||
const jsonObjects = content
|
||||
.split(/}\s*\n\s*{/)
|
||||
.map((obj, index, array) => {
|
||||
@@ -559,7 +599,14 @@ export class TestRig {
|
||||
})
|
||||
.filter((obj) => obj);
|
||||
|
||||
const logs = [];
|
||||
const logs: {
|
||||
toolRequest: {
|
||||
name: string;
|
||||
args: string;
|
||||
success: boolean;
|
||||
duration_ms: number;
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
for (const jsonStr of jsonObjects) {
|
||||
try {
|
||||
@@ -579,10 +626,13 @@ export class TestRig {
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch (e) {
|
||||
// Skip objects that aren't valid JSON
|
||||
if (env.VERBOSE === 'true') {
|
||||
console.error('Failed to parse telemetry object:', _e.message);
|
||||
console.error(
|
||||
'Failed to parse telemetry object:',
|
||||
(e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
integration-tests/tsconfig.json
Normal file
8
integration-tests/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -9,6 +9,11 @@ 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');
|
||||
|
||||
@@ -18,16 +23,19 @@ test('should be able to search the web', async () => {
|
||||
} catch (error) {
|
||||
// Network errors can occur in CI environments
|
||||
if (
|
||||
error.message.includes('network') ||
|
||||
error.message.includes('timeout')
|
||||
error instanceof Error &&
|
||||
(error.message.includes('network') || error.message.includes('timeout'))
|
||||
) {
|
||||
console.warn('Skipping test due to network error:', error.message);
|
||||
console.warn(
|
||||
'Skipping test due to network error:',
|
||||
(error as Error).message,
|
||||
);
|
||||
return; // Skip the test
|
||||
}
|
||||
throw error; // Re-throw if not a network error
|
||||
}
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('google_web_search');
|
||||
const foundToolCall = await rig.waitForToolCall('web_search');
|
||||
|
||||
// Add debugging information
|
||||
if (!foundToolCall) {
|
||||
@@ -35,12 +43,11 @@ 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 === 'google_web_search' && !t.toolRequest.success,
|
||||
(t) => t.toolRequest.name === 'web_search' && !t.toolRequest.success,
|
||||
);
|
||||
if (failedSearchCalls.length > 0) {
|
||||
console.warn(
|
||||
'google_web_search tool was called but failed, possibly due to network issues',
|
||||
'web_search tool was called but failed, possibly due to network issues',
|
||||
);
|
||||
console.warn(
|
||||
'Failed calls:',
|
||||
@@ -50,20 +57,20 @@ test('should be able to search the web', async () => {
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(foundToolCall, 'Expected to find a call to google_web_search');
|
||||
assert.ok(foundToolCall, 'Expected to find a call to web_search');
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
const hasExpectedContent = validateModelOutput(
|
||||
result,
|
||||
['weather', 'london'],
|
||||
'Google web search test',
|
||||
'Web search test',
|
||||
);
|
||||
|
||||
// If content was missing, log the search queries used
|
||||
if (!hasExpectedContent) {
|
||||
const searchCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((t) => t.toolRequest.name === 'google_web_search');
|
||||
.filter((t) => t.toolRequest.name === 'web_search');
|
||||
if (searchCalls.length > 0) {
|
||||
console.warn(
|
||||
'Search queries used:',
|
||||
2778
package-lock.json
generated
2778
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.8-nightly.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.4"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.8-nightly.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",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
|
||||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||
"test:e2e": "npm run test:integration:sandbox:none -- --verbose --keep-output",
|
||||
@@ -39,7 +39,7 @@
|
||||
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
|
||||
"lint:fix": "eslint . --fix && eslint integration-tests --fix",
|
||||
"lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0",
|
||||
"format": "prettier --write .",
|
||||
"format": "prettier --experimental-cli --write .",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
|
||||
"prepare": "npm run bundle",
|
||||
@@ -83,8 +83,10 @@
|
||||
"mock-fs": "^5.5.0",
|
||||
"prettier": "^3.5.3",
|
||||
"react-devtools-core": "^4.28.5",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vitest": "^3.2.4",
|
||||
"yargs": "^17.7.2"
|
||||
"yargs": "^17.7.2",
|
||||
"mnemonist": "^0.40.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.8-nightly.5",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,12 +25,13 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.4"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.8-nightly.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",
|
||||
@@ -53,7 +54,7 @@
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tiktoken": "^1.0.21",
|
||||
"undici": "^7.10.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
@@ -75,7 +76,8 @@
|
||||
"pretty-format": "^30.0.2",
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
"vitest": "^3.1.1",
|
||||
"@qwen-code/qwen-code-test-utils": "file:../test-utils"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -239,65 +239,62 @@ class GeminiAgent implements Agent {
|
||||
);
|
||||
}
|
||||
|
||||
let toolCallId;
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(
|
||||
args,
|
||||
abortSignal,
|
||||
);
|
||||
if (confirmationDetails) {
|
||||
let content: acp.ToolCallContent | null = null;
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
content = {
|
||||
type: 'diff',
|
||||
path: confirmationDetails.fileName,
|
||||
oldText: confirmationDetails.originalContent,
|
||||
newText: confirmationDetails.newContent,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.client.requestToolCallConfirmation({
|
||||
label: tool.getDescription(args),
|
||||
icon: tool.icon,
|
||||
content,
|
||||
confirmation: toAcpToolCallConfirmation(confirmationDetails),
|
||||
locations: tool.toolLocations(args),
|
||||
});
|
||||
|
||||
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
|
||||
switch (result.outcome) {
|
||||
case 'reject':
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
|
||||
);
|
||||
|
||||
case 'cancel':
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" was canceled by the user.`),
|
||||
);
|
||||
case 'allow':
|
||||
case 'alwaysAllow':
|
||||
case 'alwaysAllowMcpServer':
|
||||
case 'alwaysAllowTool':
|
||||
break;
|
||||
default: {
|
||||
const resultOutcome: never = result.outcome;
|
||||
throw new Error(`Unexpected: ${resultOutcome}`);
|
||||
}
|
||||
}
|
||||
|
||||
toolCallId = result.id;
|
||||
} else {
|
||||
const result = await this.client.pushToolCall({
|
||||
icon: tool.icon,
|
||||
label: tool.getDescription(args),
|
||||
locations: tool.toolLocations(args),
|
||||
});
|
||||
|
||||
toolCallId = result.id;
|
||||
}
|
||||
|
||||
let toolCallId: number | undefined = undefined;
|
||||
try {
|
||||
const toolResult: ToolResult = await tool.execute(args, abortSignal);
|
||||
const invocation = tool.build(args);
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
if (confirmationDetails) {
|
||||
let content: acp.ToolCallContent | null = null;
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
content = {
|
||||
type: 'diff',
|
||||
path: confirmationDetails.fileName,
|
||||
oldText: confirmationDetails.originalContent,
|
||||
newText: confirmationDetails.newContent,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.client.requestToolCallConfirmation({
|
||||
label: invocation.getDescription(),
|
||||
icon: tool.icon,
|
||||
content,
|
||||
confirmation: toAcpToolCallConfirmation(confirmationDetails),
|
||||
locations: invocation.toolLocations(),
|
||||
});
|
||||
|
||||
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
|
||||
switch (result.outcome) {
|
||||
case 'reject':
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
|
||||
);
|
||||
|
||||
case 'cancel':
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" was canceled by the user.`),
|
||||
);
|
||||
case 'allow':
|
||||
case 'alwaysAllow':
|
||||
case 'alwaysAllowMcpServer':
|
||||
case 'alwaysAllowTool':
|
||||
break;
|
||||
default: {
|
||||
const resultOutcome: never = result.outcome;
|
||||
throw new Error(`Unexpected: ${resultOutcome}`);
|
||||
}
|
||||
}
|
||||
toolCallId = result.id;
|
||||
} else {
|
||||
const result = await this.client.pushToolCall({
|
||||
icon: tool.icon,
|
||||
label: invocation.getDescription(),
|
||||
locations: invocation.toolLocations(),
|
||||
});
|
||||
toolCallId = result.id;
|
||||
}
|
||||
|
||||
const toolResult: ToolResult = await invocation.execute(abortSignal);
|
||||
const toolCallContent = toToolCallContent(toolResult);
|
||||
|
||||
await this.client.updateToolCall({
|
||||
@@ -320,12 +317,13 @@ class GeminiAgent implements Agent {
|
||||
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
await this.client.updateToolCall({
|
||||
toolCallId,
|
||||
status: 'error',
|
||||
content: { type: 'markdown', markdown: error.message },
|
||||
});
|
||||
|
||||
if (toolCallId) {
|
||||
await this.client.updateToolCall({
|
||||
toolCallId,
|
||||
status: 'error',
|
||||
content: { type: 'markdown', markdown: error.message },
|
||||
});
|
||||
}
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -408,7 +406,7 @@ class GeminiAgent implements Agent {
|
||||
`Path ${pathName} not found directly, attempting glob search.`,
|
||||
);
|
||||
try {
|
||||
const globResult = await globTool.execute(
|
||||
const globResult = await globTool.buildAndExecute(
|
||||
{
|
||||
pattern: `**/*${pathName}*`,
|
||||
path: this.config.getTargetDir(),
|
||||
@@ -530,12 +528,15 @@ class GeminiAgent implements Agent {
|
||||
respectGitIgnore, // Use configuration setting
|
||||
};
|
||||
|
||||
const toolCall = await this.client.pushToolCall({
|
||||
icon: readManyFilesTool.icon,
|
||||
label: readManyFilesTool.getDescription(toolArgs),
|
||||
});
|
||||
let toolCallId: number | undefined = undefined;
|
||||
try {
|
||||
const result = await readManyFilesTool.execute(toolArgs, abortSignal);
|
||||
const invocation = readManyFilesTool.build(toolArgs);
|
||||
const toolCall = await this.client.pushToolCall({
|
||||
icon: readManyFilesTool.icon,
|
||||
label: invocation.getDescription(),
|
||||
});
|
||||
toolCallId = toolCall.id;
|
||||
const result = await invocation.execute(abortSignal);
|
||||
const content = toToolCallContent(result) || {
|
||||
type: 'markdown',
|
||||
markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
|
||||
@@ -578,14 +579,16 @@ class GeminiAgent implements Agent {
|
||||
|
||||
return processedQueryParts;
|
||||
} catch (error: unknown) {
|
||||
await this.client.updateToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: 'error',
|
||||
content: {
|
||||
type: 'markdown',
|
||||
markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||
},
|
||||
});
|
||||
if (toolCallId) {
|
||||
await this.client.updateToolCall({
|
||||
toolCallId,
|
||||
status: 'error',
|
||||
content: {
|
||||
type: 'markdown',
|
||||
markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
55
packages/cli/src/commands/mcp.test.ts
Normal file
55
packages/cli/src/commands/mcp.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mcpCommand } from './mcp.js';
|
||||
import { type Argv } from 'yargs';
|
||||
import yargs from 'yargs';
|
||||
|
||||
describe('mcp command', () => {
|
||||
it('should have correct command definition', () => {
|
||||
expect(mcpCommand.command).toBe('mcp');
|
||||
expect(mcpCommand.describe).toBe('Manage MCP servers');
|
||||
expect(typeof mcpCommand.builder).toBe('function');
|
||||
expect(typeof mcpCommand.handler).toBe('function');
|
||||
});
|
||||
|
||||
it('should have exactly one option (help flag)', () => {
|
||||
// Test to ensure that the global 'gemini' flags are not added to the mcp command
|
||||
const yargsInstance = yargs();
|
||||
const builtYargs = mcpCommand.builder(yargsInstance);
|
||||
const options = builtYargs.getOptions();
|
||||
|
||||
// Should have exactly 1 option (help flag)
|
||||
expect(Object.keys(options.key).length).toBe(1);
|
||||
expect(options.key).toHaveProperty('help');
|
||||
});
|
||||
|
||||
it('should register add, remove, and list subcommands', () => {
|
||||
const mockYargs = {
|
||||
command: vi.fn().mockReturnThis(),
|
||||
demandCommand: vi.fn().mockReturnThis(),
|
||||
version: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
mcpCommand.builder(mockYargs as unknown as Argv);
|
||||
|
||||
expect(mockYargs.command).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Verify that the specific subcommands are registered
|
||||
const commandCalls = mockYargs.command.mock.calls;
|
||||
const commandNames = commandCalls.map((call) => call[0].command);
|
||||
|
||||
expect(commandNames).toContain('add <name> <commandOrUrl> [args...]');
|
||||
expect(commandNames).toContain('remove <name>');
|
||||
expect(commandNames).toContain('list');
|
||||
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
|
||||
1,
|
||||
'You need at least one command before continuing.',
|
||||
);
|
||||
});
|
||||
});
|
||||
27
packages/cli/src/commands/mcp.ts
Normal file
27
packages/cli/src/commands/mcp.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// File for 'gemini mcp' command
|
||||
import type { CommandModule, Argv } from 'yargs';
|
||||
import { addCommand } from './mcp/add.js';
|
||||
import { removeCommand } from './mcp/remove.js';
|
||||
import { listCommand } from './mcp/list.js';
|
||||
|
||||
export const mcpCommand: CommandModule = {
|
||||
command: 'mcp',
|
||||
describe: 'Manage MCP servers',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.command(addCommand)
|
||||
.command(removeCommand)
|
||||
.command(listCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// yargs will automatically show help if no subcommand is provided
|
||||
// thanks to demandCommand(1) in the builder.
|
||||
},
|
||||
};
|
||||
88
packages/cli/src/commands/mcp/add.test.ts
Normal file
88
packages/cli/src/commands/mcp/add.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import yargs from 'yargs';
|
||||
import { addCommand } from './add.js';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/settings.js', async () => {
|
||||
const actual = await vi.importActual('../../config/settings.js');
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedLoadSettings = loadSettings as vi.Mock;
|
||||
|
||||
describe('mcp add command', () => {
|
||||
let parser: yargs.Argv;
|
||||
let mockSetValue: vi.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
const yargsInstance = yargs([]).command(addCommand);
|
||||
parser = yargsInstance;
|
||||
mockSetValue = vi.fn();
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
forScope: () => ({ settings: {} }),
|
||||
setValue: mockSetValue,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add a stdio server to project settings', async () => {
|
||||
await parser.parseAsync(
|
||||
'add my-server /path/to/server arg1 arg2 -e FOO=bar',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
{
|
||||
'my-server': {
|
||||
command: '/path/to/server',
|
||||
args: ['arg1', 'arg2'],
|
||||
env: { FOO: 'bar' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add an sse server to user settings', async () => {
|
||||
await parser.parseAsync(
|
||||
'add --transport sse sse-server https://example.com/sse-endpoint --scope user -H "X-API-Key: your-key"',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
|
||||
'sse-server': {
|
||||
url: 'https://example.com/sse-endpoint',
|
||||
headers: { 'X-API-Key': 'your-key' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should add an http server to project settings', async () => {
|
||||
await parser.parseAsync(
|
||||
'add --transport http http-server https://example.com/mcp -H "Authorization: Bearer your-token"',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
{
|
||||
'http-server': {
|
||||
httpUrl: 'https://example.com/mcp',
|
||||
headers: { Authorization: 'Bearer your-token' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
211
packages/cli/src/commands/mcp/add.ts
Normal file
211
packages/cli/src/commands/mcp/add.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// File for 'gemini mcp add' command
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
async function addMcpServer(
|
||||
name: string,
|
||||
commandOrUrl: string,
|
||||
args: Array<string | number> | undefined,
|
||||
options: {
|
||||
scope: string;
|
||||
transport: string;
|
||||
env: string[] | undefined;
|
||||
header: string[] | undefined;
|
||||
timeout?: number;
|
||||
trust?: boolean;
|
||||
description?: string;
|
||||
includeTools?: string[];
|
||||
excludeTools?: string[];
|
||||
},
|
||||
) {
|
||||
const {
|
||||
scope,
|
||||
transport,
|
||||
env,
|
||||
header,
|
||||
timeout,
|
||||
trust,
|
||||
description,
|
||||
includeTools,
|
||||
excludeTools,
|
||||
} = options;
|
||||
const settingsScope =
|
||||
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
|
||||
const settings = loadSettings(process.cwd());
|
||||
|
||||
let newServer: Partial<MCPServerConfig> = {};
|
||||
|
||||
const headers = header?.reduce(
|
||||
(acc, curr) => {
|
||||
const [key, ...valueParts] = curr.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
if (key.trim() && value) {
|
||||
acc[key.trim()] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
switch (transport) {
|
||||
case 'sse':
|
||||
newServer = {
|
||||
url: commandOrUrl,
|
||||
headers,
|
||||
timeout,
|
||||
trust,
|
||||
description,
|
||||
includeTools,
|
||||
excludeTools,
|
||||
};
|
||||
break;
|
||||
case 'http':
|
||||
newServer = {
|
||||
httpUrl: commandOrUrl,
|
||||
headers,
|
||||
timeout,
|
||||
trust,
|
||||
description,
|
||||
includeTools,
|
||||
excludeTools,
|
||||
};
|
||||
break;
|
||||
case 'stdio':
|
||||
default:
|
||||
newServer = {
|
||||
command: commandOrUrl,
|
||||
args: args?.map(String),
|
||||
env: env?.reduce(
|
||||
(acc, curr) => {
|
||||
const [key, value] = curr.split('=');
|
||||
if (key && value) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
timeout,
|
||||
trust,
|
||||
description,
|
||||
includeTools,
|
||||
excludeTools,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
const existingSettings = settings.forScope(settingsScope).settings;
|
||||
const mcpServers = existingSettings.mcpServers || {};
|
||||
|
||||
const isExistingServer = !!mcpServers[name];
|
||||
if (isExistingServer) {
|
||||
console.log(
|
||||
`MCP server "${name}" is already configured within ${scope} settings.`,
|
||||
);
|
||||
}
|
||||
|
||||
mcpServers[name] = newServer as MCPServerConfig;
|
||||
|
||||
settings.setValue(settingsScope, 'mcpServers', mcpServers);
|
||||
|
||||
if (isExistingServer) {
|
||||
console.log(`MCP server "${name}" updated in ${scope} settings.`);
|
||||
} else {
|
||||
console.log(
|
||||
`MCP server "${name}" added to ${scope} settings. (${transport})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const addCommand: CommandModule = {
|
||||
command: 'add <name> <commandOrUrl> [args...]',
|
||||
describe: 'Add a server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
|
||||
.positional('name', {
|
||||
describe: 'Name of the server',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.positional('commandOrUrl', {
|
||||
describe: 'Command (stdio) or URL (sse, http)',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
alias: 's',
|
||||
describe: 'Configuration scope (user or project)',
|
||||
type: 'string',
|
||||
default: 'project',
|
||||
choices: ['user', 'project'],
|
||||
})
|
||||
.option('transport', {
|
||||
alias: 't',
|
||||
describe: 'Transport type (stdio, sse, http)',
|
||||
type: 'string',
|
||||
default: 'stdio',
|
||||
choices: ['stdio', 'sse', 'http'],
|
||||
})
|
||||
.option('env', {
|
||||
alias: 'e',
|
||||
describe: 'Set environment variables (e.g. -e KEY=value)',
|
||||
type: 'array',
|
||||
string: true,
|
||||
})
|
||||
.option('header', {
|
||||
alias: 'H',
|
||||
describe:
|
||||
'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")',
|
||||
type: 'array',
|
||||
string: true,
|
||||
})
|
||||
.option('timeout', {
|
||||
describe: 'Set connection timeout in milliseconds',
|
||||
type: 'number',
|
||||
})
|
||||
.option('trust', {
|
||||
describe:
|
||||
'Trust the server (bypass all tool call confirmation prompts)',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('description', {
|
||||
describe: 'Set the description for the server',
|
||||
type: 'string',
|
||||
})
|
||||
.option('include-tools', {
|
||||
describe: 'A comma-separated list of tools to include',
|
||||
type: 'array',
|
||||
string: true,
|
||||
})
|
||||
.option('exclude-tools', {
|
||||
describe: 'A comma-separated list of tools to exclude',
|
||||
type: 'array',
|
||||
string: true,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await addMcpServer(
|
||||
argv.name as string,
|
||||
argv.commandOrUrl as string,
|
||||
argv.args as Array<string | number>,
|
||||
{
|
||||
scope: argv.scope as string,
|
||||
transport: argv.transport as string,
|
||||
env: argv.env as string[],
|
||||
header: argv.header as string[],
|
||||
timeout: argv.timeout as number | undefined,
|
||||
trust: argv.trust as boolean | undefined,
|
||||
description: argv.description as string | undefined,
|
||||
includeTools: argv.includeTools as string[] | undefined,
|
||||
excludeTools: argv.excludeTools as string[] | undefined,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
154
packages/cli/src/commands/mcp/list.test.ts
Normal file
154
packages/cli/src/commands/mcp/list.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { listMcpServers } from './list.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { loadExtensions } from '../../config/extension.js';
|
||||
import { createTransport } from '@qwen-code/qwen-code-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../config/extension.js');
|
||||
vi.mock('@qwen-code/qwen-code-core');
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js');
|
||||
|
||||
const mockedLoadSettings = loadSettings as vi.Mock;
|
||||
const mockedLoadExtensions = loadExtensions as vi.Mock;
|
||||
const mockedCreateTransport = createTransport as vi.Mock;
|
||||
const MockedClient = Client as vi.Mock;
|
||||
|
||||
interface MockClient {
|
||||
connect: vi.Mock;
|
||||
ping: vi.Mock;
|
||||
close: vi.Mock;
|
||||
}
|
||||
|
||||
interface MockTransport {
|
||||
close: vi.Mock;
|
||||
}
|
||||
|
||||
describe('mcp list command', () => {
|
||||
let consoleSpy: vi.SpyInstance;
|
||||
let mockClient: MockClient;
|
||||
let mockTransport: MockTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
mockTransport = { close: vi.fn() };
|
||||
mockClient = {
|
||||
connect: vi.fn(),
|
||||
ping: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
MockedClient.mockImplementation(() => mockClient);
|
||||
mockedCreateTransport.mockResolvedValue(mockTransport);
|
||||
mockedLoadExtensions.mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should display message when no servers configured', async () => {
|
||||
mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } });
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('No MCP servers configured.');
|
||||
});
|
||||
|
||||
it('should display different server types with connected status', async () => {
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
mcpServers: {
|
||||
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
|
||||
'sse-server': { url: 'https://example.com/sse' },
|
||||
'http-server': { httpUrl: 'https://example.com/http' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Configured MCP servers:\n');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'stdio-server: /path/to/server arg1 (stdio) - Connected',
|
||||
),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'sse-server: https://example.com/sse (sse) - Connected',
|
||||
),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'http-server: https://example.com/http (http) - Connected',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display disconnected status when connection fails', async () => {
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockClient.connect.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'test-server: /test/server (stdio) - Disconnected',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge extension servers with config servers', async () => {
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
mcpServers: { 'config-server': { command: '/config/server' } },
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadExtensions.mockReturnValue([
|
||||
{
|
||||
config: {
|
||||
name: 'test-extension',
|
||||
mcpServers: { 'extension-server': { command: '/ext/server' } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'config-server: /config/server (stdio) - Connected',
|
||||
),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'extension-server: /ext/server (stdio) - Connected',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
139
packages/cli/src/commands/mcp/list.ts
Normal file
139
packages/cli/src/commands/mcp/list.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// File for 'gemini mcp list' command
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
MCPServerConfig,
|
||||
MCPServerStatus,
|
||||
createTransport,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { loadExtensions } from '../../config/extension.js';
|
||||
|
||||
const COLOR_GREEN = '\u001b[32m';
|
||||
const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
async function getMcpServersFromConfig(): Promise<
|
||||
Record<string, MCPServerConfig>
|
||||
> {
|
||||
const settings = loadSettings(process.cwd());
|
||||
const extensions = loadExtensions(process.cwd());
|
||||
const mcpServers = { ...(settings.merged.mcpServers || {}) };
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.config.mcpServers || {}).forEach(
|
||||
([key, server]) => {
|
||||
if (mcpServers[key]) {
|
||||
return;
|
||||
}
|
||||
mcpServers[key] = {
|
||||
...server,
|
||||
extensionName: extension.config.name,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
async function testMCPConnection(
|
||||
serverName: string,
|
||||
config: MCPServerConfig,
|
||||
): Promise<MCPServerStatus> {
|
||||
const client = new Client({
|
||||
name: 'mcp-test-client',
|
||||
version: '0.0.1',
|
||||
});
|
||||
|
||||
let transport;
|
||||
try {
|
||||
// Use the same transport creation logic as core
|
||||
transport = await createTransport(serverName, config, false);
|
||||
} catch (_error) {
|
||||
await client.close();
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt actual MCP connection with short timeout
|
||||
await client.connect(transport, { timeout: 5000 }); // 5s timeout
|
||||
|
||||
// Test basic MCP protocol by pinging the server
|
||||
await client.ping();
|
||||
|
||||
await client.close();
|
||||
return MCPServerStatus.CONNECTED;
|
||||
} catch (_error) {
|
||||
await transport.close();
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
}
|
||||
}
|
||||
|
||||
async function getServerStatus(
|
||||
serverName: string,
|
||||
server: MCPServerConfig,
|
||||
): Promise<MCPServerStatus> {
|
||||
// Test all server types by attempting actual connection
|
||||
return await testMCPConnection(serverName, server);
|
||||
}
|
||||
|
||||
export async function listMcpServers(): Promise<void> {
|
||||
const mcpServers = await getMcpServersFromConfig();
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
console.log('No MCP servers configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Configured MCP servers:\n');
|
||||
|
||||
for (const serverName of serverNames) {
|
||||
const server = mcpServers[serverName];
|
||||
|
||||
const status = await getServerStatus(serverName, server);
|
||||
|
||||
let statusIndicator = '';
|
||||
let statusText = '';
|
||||
switch (status) {
|
||||
case MCPServerStatus.CONNECTED:
|
||||
statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR;
|
||||
statusText = 'Connected';
|
||||
break;
|
||||
case MCPServerStatus.CONNECTING:
|
||||
statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR;
|
||||
statusText = 'Connecting';
|
||||
break;
|
||||
case MCPServerStatus.DISCONNECTED:
|
||||
default:
|
||||
statusIndicator = COLOR_RED + '✗' + RESET_COLOR;
|
||||
statusText = 'Disconnected';
|
||||
break;
|
||||
}
|
||||
|
||||
let serverInfo = `${serverName}: `;
|
||||
if (server.httpUrl) {
|
||||
serverInfo += `${server.httpUrl} (http)`;
|
||||
} else if (server.url) {
|
||||
serverInfo += `${server.url} (sse)`;
|
||||
} else if (server.command) {
|
||||
serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`;
|
||||
}
|
||||
|
||||
console.log(`${statusIndicator} ${serverInfo} - ${statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const listCommand: CommandModule = {
|
||||
command: 'list',
|
||||
describe: 'List all configured MCP servers',
|
||||
handler: async () => {
|
||||
await listMcpServers();
|
||||
},
|
||||
};
|
||||
69
packages/cli/src/commands/mcp/remove.test.ts
Normal file
69
packages/cli/src/commands/mcp/remove.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import yargs from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { removeCommand } from './remove.js';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/settings.js', async () => {
|
||||
const actual = await vi.importActual('../../config/settings.js');
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedLoadSettings = loadSettings as vi.Mock;
|
||||
|
||||
describe('mcp remove command', () => {
|
||||
let parser: yargs.Argv;
|
||||
let mockSetValue: vi.Mock;
|
||||
let mockSettings: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
const yargsInstance = yargs([]).command(removeCommand);
|
||||
parser = yargsInstance;
|
||||
mockSetValue = vi.fn();
|
||||
mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'echo "hello"',
|
||||
},
|
||||
},
|
||||
};
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
forScope: () => ({ settings: mockSettings }),
|
||||
setValue: mockSetValue,
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove a server from project settings', async () => {
|
||||
await parser.parseAsync('remove test-server');
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should show a message if server not found', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
await parser.parseAsync('remove non-existent-server');
|
||||
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Server "non-existent-server" not found in project settings.',
|
||||
);
|
||||
});
|
||||
});
|
||||
60
packages/cli/src/commands/mcp/remove.ts
Normal file
60
packages/cli/src/commands/mcp/remove.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// File for 'gemini mcp remove' command
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
async function removeMcpServer(
|
||||
name: string,
|
||||
options: {
|
||||
scope: string;
|
||||
},
|
||||
) {
|
||||
const { scope } = options;
|
||||
const settingsScope =
|
||||
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
|
||||
const settings = loadSettings(process.cwd());
|
||||
|
||||
const existingSettings = settings.forScope(settingsScope).settings;
|
||||
const mcpServers = existingSettings.mcpServers || {};
|
||||
|
||||
if (!mcpServers[name]) {
|
||||
console.log(`Server "${name}" not found in ${scope} settings.`);
|
||||
return;
|
||||
}
|
||||
|
||||
delete mcpServers[name];
|
||||
|
||||
settings.setValue(settingsScope, 'mcpServers', mcpServers);
|
||||
|
||||
console.log(`Server "${name}" removed from ${scope} settings.`);
|
||||
}
|
||||
|
||||
export const removeCommand: CommandModule = {
|
||||
command: 'remove <name>',
|
||||
describe: 'Remove a server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.usage('Usage: gemini mcp remove [options] <name>')
|
||||
.positional('name', {
|
||||
describe: 'Name of the server',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
alias: 's',
|
||||
describe: 'Configuration scope (user or project)',
|
||||
type: 'string',
|
||||
default: 'project',
|
||||
choices: ['user', 'project'],
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await removeMcpServer(argv.name as string, {
|
||||
scope: argv.scope as string,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as os from 'os';
|
||||
import { loadCliConfig, parseArguments, CliArgs } from './config.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ShellTool, EditTool, WriteFileTool } from '@qwen-code/qwen-code-core';
|
||||
import { loadCliConfig, parseArguments } from './config.js';
|
||||
import { Settings } from './settings.js';
|
||||
import { Extension } from './extension.js';
|
||||
import * as ServerConfig from '@qwen-code/qwen-code-core';
|
||||
@@ -44,7 +47,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
},
|
||||
loadEnvironment: vi.fn(),
|
||||
loadServerHierarchicalMemory: vi.fn(
|
||||
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
|
||||
(cwd, dirs, debug, fileService, extensionPaths, _maxDirs) =>
|
||||
Promise.resolve({
|
||||
memoryContent: extensionPaths?.join(',') || '',
|
||||
fileCount: extensionPaths?.length || 0,
|
||||
@@ -499,6 +502,7 @@ 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),
|
||||
[
|
||||
@@ -632,6 +636,17 @@ describe('loadCliConfig systemPromptMappings', () => {
|
||||
});
|
||||
|
||||
describe('mergeExcludeTools', () => {
|
||||
const defaultExcludes = [ShellTool.Name, EditTool.Name, WriteFileTool.Name];
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
|
||||
beforeEach(() => {
|
||||
process.stdin.isTTY = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
});
|
||||
|
||||
it('should merge excludeTools from settings and extensions', async () => {
|
||||
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
|
||||
const extensions: Extension[] = [
|
||||
@@ -726,7 +741,8 @@ describe('mergeExcludeTools', () => {
|
||||
expect(config.getExcludeTools()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should return an empty array when no excludeTools are specified', async () => {
|
||||
it('should return an empty array when no excludeTools are specified and it is interactive', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
const settings: Settings = {};
|
||||
const extensions: Extension[] = [];
|
||||
process.argv = ['node', 'script.js'];
|
||||
@@ -740,6 +756,21 @@ describe('mergeExcludeTools', () => {
|
||||
expect(config.getExcludeTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
|
||||
process.stdin.isTTY = false;
|
||||
const settings: Settings = {};
|
||||
const extensions: Extension[] = [];
|
||||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
extensions,
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.getExcludeTools()).toEqual(defaultExcludes);
|
||||
});
|
||||
|
||||
it('should handle settings with excludeTools but no extensions', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
@@ -1062,7 +1093,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.GEMINI_CLI_IDE_SERVER_PORT;
|
||||
delete process.env.QWEN_CODE_IDE_SERVER_PORT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1078,14 +1109,322 @@ describe('loadCliConfig ideModeFeature', () => {
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeModeFeature()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be false when settings.ideModeFeature is true, but SANDBOX is set', async () => {
|
||||
describe('loadCliConfig folderTrustFeature', () => {
|
||||
const originalArgv = process.argv;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should be false by default', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings: Settings = {};
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getFolderTrustFeature()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when settings.folderTrustFeature is true', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
process.env.SANDBOX = 'true';
|
||||
const settings: Settings = { ideModeFeature: true };
|
||||
const settings: Settings = { folderTrustFeature: true };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeModeFeature()).toBe(false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
|
||||
import yargs from 'yargs/yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import {
|
||||
Config,
|
||||
loadServerHierarchicalMemory,
|
||||
@@ -22,13 +23,18 @@ import {
|
||||
FileDiscoveryService,
|
||||
TelemetryTarget,
|
||||
FileFilteringOptions,
|
||||
IdeClient,
|
||||
ShellTool,
|
||||
EditTool,
|
||||
WriteFileTool,
|
||||
MCPServerConfig,
|
||||
ConfigParameters,
|
||||
} 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 = {
|
||||
@@ -68,186 +74,203 @@ 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(
|
||||
'$0 [options]',
|
||||
'Qwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
|
||||
'Usage: qwen [options] [command]\n\nQwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
|
||||
)
|
||||
.option('model', {
|
||||
alias: 'm',
|
||||
type: 'string',
|
||||
description: `Model`,
|
||||
default: process.env.GEMINI_MODEL,
|
||||
})
|
||||
.option('prompt', {
|
||||
alias: 'p',
|
||||
type: 'string',
|
||||
description: 'Prompt. Appended to input on stdin (if any).',
|
||||
})
|
||||
.option('prompt-interactive', {
|
||||
alias: 'i',
|
||||
type: 'string',
|
||||
description:
|
||||
'Execute the provided prompt and continue in interactive mode',
|
||||
})
|
||||
.option('sandbox', {
|
||||
alias: 's',
|
||||
type: 'boolean',
|
||||
description: 'Run in sandbox?',
|
||||
})
|
||||
.option('sandbox-image', {
|
||||
type: 'string',
|
||||
description: 'Sandbox image URI.',
|
||||
})
|
||||
.option('debug', {
|
||||
alias: 'd',
|
||||
type: 'boolean',
|
||||
description: 'Run in debug mode?',
|
||||
default: false,
|
||||
})
|
||||
.option('all-files', {
|
||||
alias: ['a'],
|
||||
type: 'boolean',
|
||||
description: 'Include ALL files in context?',
|
||||
default: false,
|
||||
})
|
||||
.option('all_files', {
|
||||
type: 'boolean',
|
||||
description: 'Include ALL files in context?',
|
||||
default: false,
|
||||
})
|
||||
.deprecateOption(
|
||||
'all_files',
|
||||
'Use --all-files instead. We will be removing --all_files in the coming weeks.',
|
||||
.command('$0', 'Launch Qwen Code', (yargsInstance) =>
|
||||
yargsInstance
|
||||
.option('model', {
|
||||
alias: 'm',
|
||||
type: 'string',
|
||||
description: `Model`,
|
||||
default: process.env.GEMINI_MODEL,
|
||||
})
|
||||
.option('prompt', {
|
||||
alias: 'p',
|
||||
type: 'string',
|
||||
description: 'Prompt. Appended to input on stdin (if any).',
|
||||
})
|
||||
.option('prompt-interactive', {
|
||||
alias: 'i',
|
||||
type: 'string',
|
||||
description:
|
||||
'Execute the provided prompt and continue in interactive mode',
|
||||
})
|
||||
.option('sandbox', {
|
||||
alias: 's',
|
||||
type: 'boolean',
|
||||
description: 'Run in sandbox?',
|
||||
})
|
||||
.option('sandbox-image', {
|
||||
type: 'string',
|
||||
description: 'Sandbox image URI.',
|
||||
})
|
||||
.option('debug', {
|
||||
alias: 'd',
|
||||
type: 'boolean',
|
||||
description: 'Run in debug mode?',
|
||||
default: false,
|
||||
})
|
||||
.option('all-files', {
|
||||
alias: ['a'],
|
||||
type: 'boolean',
|
||||
description: 'Include ALL files in context?',
|
||||
default: false,
|
||||
})
|
||||
.option('all_files', {
|
||||
type: 'boolean',
|
||||
description: 'Include ALL files in context?',
|
||||
default: false,
|
||||
})
|
||||
.deprecateOption(
|
||||
'all_files',
|
||||
'Use --all-files instead. We will be removing --all_files in the coming weeks.',
|
||||
)
|
||||
.option('show-memory-usage', {
|
||||
type: 'boolean',
|
||||
description: 'Show memory usage in status bar',
|
||||
default: false,
|
||||
})
|
||||
.option('show_memory_usage', {
|
||||
type: 'boolean',
|
||||
description: 'Show memory usage in status bar',
|
||||
default: false,
|
||||
})
|
||||
.deprecateOption(
|
||||
'show_memory_usage',
|
||||
'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
|
||||
)
|
||||
.option('yolo', {
|
||||
alias: 'y',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
|
||||
default: false,
|
||||
})
|
||||
.option('telemetry', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
|
||||
})
|
||||
.option('telemetry-target', {
|
||||
type: 'string',
|
||||
choices: ['local', 'gcp'],
|
||||
description:
|
||||
'Set the telemetry target (local or gcp). Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-otlp-endpoint', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
|
||||
})
|
||||
.option('telemetry-log-prompts', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-outfile', {
|
||||
type: 'string',
|
||||
description: 'Redirect all telemetry output to the specified file.',
|
||||
})
|
||||
.option('checkpointing', {
|
||||
alias: 'c',
|
||||
type: 'boolean',
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Allowed MCP server names',
|
||||
})
|
||||
.option('extensions', {
|
||||
alias: 'e',
|
||||
type: 'array',
|
||||
string: true,
|
||||
description:
|
||||
'A list of extensions to use. If not provided, all extensions are used.',
|
||||
})
|
||||
.option('list-extensions', {
|
||||
alias: 'l',
|
||||
type: 'boolean',
|
||||
description: 'List all available extensions and exit.',
|
||||
})
|
||||
.option('ide-mode-feature', {
|
||||
type: 'boolean',
|
||||
description: 'Run in IDE mode?',
|
||||
})
|
||||
.option('proxy', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Proxy for gemini client, like schema://user:password@host:port',
|
||||
})
|
||||
.option('include-directories', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description:
|
||||
'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
|
||||
coerce: (dirs: string[]) =>
|
||||
// Handle comma-separated values
|
||||
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
|
||||
})
|
||||
.option('openai-logging', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable logging of OpenAI API calls for debugging and analysis',
|
||||
})
|
||||
.option('openai-api-key', {
|
||||
type: 'string',
|
||||
description: 'OpenAI API key to use for authentication',
|
||||
})
|
||||
.option('openai-base-url', {
|
||||
type: 'string',
|
||||
description: 'OpenAI base URL (for custom endpoints)',
|
||||
})
|
||||
.option('tavily-api-key', {
|
||||
type: 'string',
|
||||
description: 'Tavily API key for web search functionality',
|
||||
})
|
||||
.check((argv) => {
|
||||
if (argv.prompt && argv.promptInteractive) {
|
||||
throw new Error(
|
||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
)
|
||||
.option('show-memory-usage', {
|
||||
type: 'boolean',
|
||||
description: 'Show memory usage in status bar',
|
||||
default: false,
|
||||
})
|
||||
.option('show_memory_usage', {
|
||||
type: 'boolean',
|
||||
description: 'Show memory usage in status bar',
|
||||
default: false,
|
||||
})
|
||||
.deprecateOption(
|
||||
'show_memory_usage',
|
||||
'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
|
||||
)
|
||||
.option('yolo', {
|
||||
alias: 'y',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
|
||||
default: false,
|
||||
})
|
||||
.option('telemetry', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
|
||||
})
|
||||
.option('telemetry-target', {
|
||||
type: 'string',
|
||||
choices: ['local', 'gcp'],
|
||||
description:
|
||||
'Set the telemetry target (local or gcp). Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-otlp-endpoint', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
|
||||
})
|
||||
.option('telemetry-log-prompts', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-outfile', {
|
||||
type: 'string',
|
||||
description: 'Redirect all telemetry output to the specified file.',
|
||||
})
|
||||
.option('checkpointing', {
|
||||
alias: 'c',
|
||||
type: 'boolean',
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Allowed MCP server names',
|
||||
})
|
||||
.option('extensions', {
|
||||
alias: 'e',
|
||||
type: 'array',
|
||||
string: true,
|
||||
description:
|
||||
'A list of extensions to use. If not provided, all extensions are used.',
|
||||
})
|
||||
.option('list-extensions', {
|
||||
alias: 'l',
|
||||
type: 'boolean',
|
||||
description: 'List all available extensions and exit.',
|
||||
})
|
||||
.option('ide-mode-feature', {
|
||||
type: 'boolean',
|
||||
description: 'Run in IDE mode?',
|
||||
})
|
||||
.option('openai-logging', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable logging of OpenAI API calls for debugging and analysis',
|
||||
})
|
||||
.option('openai-api-key', {
|
||||
type: 'string',
|
||||
description: 'OpenAI API key to use for authentication',
|
||||
})
|
||||
.option('openai-base-url', {
|
||||
type: 'string',
|
||||
description: 'OpenAI base URL (for custom endpoints)',
|
||||
})
|
||||
.option('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())),
|
||||
})
|
||||
// Register MCP subcommands
|
||||
.command(mcpCommand)
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
.alias('v', 'version')
|
||||
.help()
|
||||
.alias('h', 'help')
|
||||
.strict()
|
||||
.check((argv) => {
|
||||
if (argv.prompt && argv.promptInteractive) {
|
||||
throw new Error(
|
||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
.demandCommand(0, 0); // Allow base command to run with no subcommands
|
||||
|
||||
yargsInstance.wrap(yargsInstance.terminalWidth());
|
||||
const result = yargsInstance.parseSync();
|
||||
const result = await yargsInstance.parse();
|
||||
|
||||
// Handle case where MCP subcommands are executed - they should exit the process
|
||||
// and not return to main CLI logic
|
||||
if (result._.length > 0 && result._[0] === 'mcp') {
|
||||
// MCP commands handle their own execution and process exit
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
return result as CliArgs;
|
||||
return result as unknown as CliArgs;
|
||||
}
|
||||
|
||||
// This function is now a thin wrapper around the server's implementation.
|
||||
@@ -255,6 +278,7 @@ 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,
|
||||
@@ -280,6 +304,7 @@ export async function loadHierarchicalGeminiMemory(
|
||||
// Directly call the server function with the corrected path.
|
||||
return loadServerHierarchicalMemory(
|
||||
effectiveCwd,
|
||||
includeDirectoriesToReadGemini,
|
||||
debugMode,
|
||||
fileService,
|
||||
extensionContextFilePaths,
|
||||
@@ -302,13 +327,14 @@ export async function loadCliConfig(
|
||||
) ||
|
||||
false;
|
||||
const memoryImportFormat = settings.memoryImportFormat || 'tree';
|
||||
|
||||
const ideMode = settings.ideMode ?? false;
|
||||
|
||||
const ideModeFeature =
|
||||
(argv.ideModeFeature ?? settings.ideModeFeature ?? false) &&
|
||||
!process.env.SANDBOX;
|
||||
argv.ideModeFeature ?? settings.ideModeFeature ?? false;
|
||||
|
||||
const ideClient = IdeClient.getInstance(ideMode && ideModeFeature);
|
||||
const folderTrustFeature = settings.folderTrustFeature ?? false;
|
||||
const folderTrustSetting = settings.folderTrust ?? false;
|
||||
const folderTrust = folderTrustFeature && folderTrustSetting;
|
||||
|
||||
const allExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
@@ -328,6 +354,11 @@ 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.
|
||||
@@ -350,9 +381,14 @@ 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,
|
||||
@@ -362,17 +398,31 @@ export async function loadCliConfig(
|
||||
);
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
const excludeTools = mergeExcludeTools(settings, activeExtensions);
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
const approvalMode =
|
||||
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
|
||||
const interactive =
|
||||
!!argv.promptInteractive || (process.stdin.isTTY && question.length === 0);
|
||||
// In non-interactive and non-yolo mode, exclude interactive built in tools.
|
||||
const extraExcludes =
|
||||
!interactive && approvalMode !== ApprovalMode.YOLO
|
||||
? [ShellTool.Name, EditTool.Name, WriteFileTool.Name]
|
||||
: undefined;
|
||||
|
||||
const excludeTools = mergeExcludeTools(
|
||||
settings,
|
||||
activeExtensions,
|
||||
extraExcludes,
|
||||
);
|
||||
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
|
||||
|
||||
if (!argv.allowedMcpServerNames) {
|
||||
if (settings.allowMCPServers) {
|
||||
const allowedNames = new Set(settings.allowMCPServers.filter(Boolean));
|
||||
if (allowedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
|
||||
);
|
||||
}
|
||||
mcpServers = allowedMcpServers(
|
||||
mcpServers,
|
||||
settings.allowMCPServers,
|
||||
blockedMcpServers,
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.excludeMCPServers) {
|
||||
@@ -386,41 +436,26 @@ export async function loadCliConfig(
|
||||
}
|
||||
|
||||
if (argv.allowedMcpServerNames) {
|
||||
const allowedNames = new Set(argv.allowedMcpServerNames.filter(Boolean));
|
||||
if (allowedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key, server]) => {
|
||||
const isAllowed = allowedNames.has(key);
|
||||
if (!isAllowed) {
|
||||
blockedMcpServers.push({
|
||||
name: key,
|
||||
extensionName: server.extensionName || '',
|
||||
});
|
||||
}
|
||||
return isAllowed;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
blockedMcpServers.push(
|
||||
...Object.entries(mcpServers).map(([key, server]) => ({
|
||||
name: key,
|
||||
extensionName: server.extensionName || '',
|
||||
})),
|
||||
);
|
||||
mcpServers = {};
|
||||
}
|
||||
mcpServers = allowedMcpServers(
|
||||
mcpServers,
|
||||
argv.allowedMcpServerNames,
|
||||
blockedMcpServers,
|
||||
);
|
||||
}
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
const cliVersion = await getCliVersion();
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: sandboxConfig,
|
||||
targetDir: process.cwd(),
|
||||
includeDirectories: argv.includeDirectories,
|
||||
includeDirectories,
|
||||
loadMemoryFromIncludeDirectories:
|
||||
settings.loadMemoryFromIncludeDirectories || false,
|
||||
debugMode,
|
||||
question: argv.promptInteractive || argv.prompt || '',
|
||||
question,
|
||||
fullContext: argv.allFiles || argv.all_files || false,
|
||||
coreTools: settings.coreTools || undefined,
|
||||
excludeTools,
|
||||
@@ -430,7 +465,7 @@ export async function loadCliConfig(
|
||||
mcpServers,
|
||||
userMemory: memoryContent,
|
||||
geminiMdFileCount: fileCount,
|
||||
approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT,
|
||||
approvalMode,
|
||||
showMemoryUsage:
|
||||
argv.showMemoryUsage ||
|
||||
argv.show_memory_usage ||
|
||||
@@ -470,7 +505,6 @@ export async function loadCliConfig(
|
||||
extensionContextFilePaths,
|
||||
maxSessionTurns: settings.maxSessionTurns ?? -1,
|
||||
sessionTokenLimit: settings.sessionTokenLimit ?? -1,
|
||||
maxFolderItems: settings.maxFolderItems ?? 20,
|
||||
experimentalAcp: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
extensions: allExtensions,
|
||||
@@ -479,13 +513,12 @@ 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/',
|
||||
@@ -495,11 +528,49 @@ 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) {
|
||||
@@ -524,8 +595,12 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) {
|
||||
function mergeExcludeTools(
|
||||
settings: Settings,
|
||||
extensions: Extension[],
|
||||
extraExcludes?: string[] | undefined,
|
||||
): string[] {
|
||||
const allExcludeTools = new Set(settings.excludeTools || []);
|
||||
const allExcludeTools = new Set([
|
||||
...(settings.excludeTools || []),
|
||||
...(extraExcludes || []),
|
||||
]);
|
||||
for (const extension of extensions) {
|
||||
for (const tool of extension.config.excludeTools || []) {
|
||||
allExcludeTools.add(tool);
|
||||
|
||||
@@ -10,7 +10,8 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
export const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions');
|
||||
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
|
||||
export const EXTENSIONS_CONFIG_FILENAME = 'qwen-extension.json';
|
||||
export const EXTENSIONS_CONFIG_FILENAME_OLD = 'gemini-extension.json';
|
||||
|
||||
export interface Extension {
|
||||
path: string;
|
||||
@@ -68,12 +69,19 @@ function loadExtension(extensionDir: string): Extension | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
let configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
console.error(
|
||||
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
|
||||
const oldConfigFilePath = path.join(
|
||||
extensionDir,
|
||||
EXTENSIONS_CONFIG_FILENAME_OLD,
|
||||
);
|
||||
return null;
|
||||
if (!fs.existsSync(oldConfigFilePath)) {
|
||||
console.error(
|
||||
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
configFilePath = oldConfigFilePath;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
62
packages/cli/src/config/keyBindings.test.ts
Normal file
62
packages/cli/src/config/keyBindings.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
Command,
|
||||
KeyBindingConfig,
|
||||
defaultKeyBindings,
|
||||
} from './keyBindings.js';
|
||||
|
||||
describe('keyBindings config', () => {
|
||||
describe('defaultKeyBindings', () => {
|
||||
it('should have bindings for all commands', () => {
|
||||
const commands = Object.values(Command);
|
||||
|
||||
for (const command of commands) {
|
||||
expect(defaultKeyBindings[command]).toBeDefined();
|
||||
expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid key binding structures', () => {
|
||||
for (const [_, bindings] of Object.entries(defaultKeyBindings)) {
|
||||
for (const binding of bindings) {
|
||||
// Each binding should have either key or sequence, but not both
|
||||
const hasKey = binding.key !== undefined;
|
||||
const hasSequence = binding.sequence !== undefined;
|
||||
|
||||
expect(hasKey || hasSequence).toBe(true);
|
||||
expect(hasKey && hasSequence).toBe(false);
|
||||
|
||||
// Modifier properties should be boolean or undefined
|
||||
if (binding.ctrl !== undefined) {
|
||||
expect(typeof binding.ctrl).toBe('boolean');
|
||||
}
|
||||
if (binding.shift !== undefined) {
|
||||
expect(typeof binding.shift).toBe('boolean');
|
||||
}
|
||||
if (binding.command !== undefined) {
|
||||
expect(typeof binding.command).toBe('boolean');
|
||||
}
|
||||
if (binding.paste !== undefined) {
|
||||
expect(typeof binding.paste).toBe('boolean');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should export all required types', () => {
|
||||
// Basic type checks
|
||||
expect(typeof Command.HOME).toBe('string');
|
||||
expect(typeof Command.END).toBe('string');
|
||||
|
||||
// Config should be readonly
|
||||
const config: KeyBindingConfig = defaultKeyBindings;
|
||||
expect(config[Command.HOME]).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
179
packages/cli/src/config/keyBindings.ts
Normal file
179
packages/cli/src/config/keyBindings.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Command enum for all available keyboard shortcuts
|
||||
*/
|
||||
export enum Command {
|
||||
// Basic bindings
|
||||
RETURN = 'return',
|
||||
ESCAPE = 'escape',
|
||||
|
||||
// Cursor movement
|
||||
HOME = 'home',
|
||||
END = 'end',
|
||||
|
||||
// Text deletion
|
||||
KILL_LINE_RIGHT = 'killLineRight',
|
||||
KILL_LINE_LEFT = 'killLineLeft',
|
||||
CLEAR_INPUT = 'clearInput',
|
||||
|
||||
// Screen control
|
||||
CLEAR_SCREEN = 'clearScreen',
|
||||
|
||||
// History navigation
|
||||
HISTORY_UP = 'historyUp',
|
||||
HISTORY_DOWN = 'historyDown',
|
||||
NAVIGATION_UP = 'navigationUp',
|
||||
NAVIGATION_DOWN = 'navigationDown',
|
||||
|
||||
// Auto-completion
|
||||
ACCEPT_SUGGESTION = 'acceptSuggestion',
|
||||
COMPLETION_UP = 'completionUp',
|
||||
COMPLETION_DOWN = 'completionDown',
|
||||
|
||||
// Text input
|
||||
SUBMIT = 'submit',
|
||||
NEWLINE = 'newline',
|
||||
|
||||
// External tools
|
||||
OPEN_EXTERNAL_EDITOR = 'openExternalEditor',
|
||||
PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage',
|
||||
|
||||
// App level bindings
|
||||
SHOW_ERROR_DETAILS = 'showErrorDetails',
|
||||
TOGGLE_TOOL_DESCRIPTIONS = 'toggleToolDescriptions',
|
||||
TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail',
|
||||
QUIT = 'quit',
|
||||
EXIT = 'exit',
|
||||
SHOW_MORE_LINES = 'showMoreLines',
|
||||
|
||||
// Shell commands
|
||||
REVERSE_SEARCH = 'reverseSearch',
|
||||
SUBMIT_REVERSE_SEARCH = 'submitReverseSearch',
|
||||
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch',
|
||||
}
|
||||
|
||||
/**
|
||||
* Data-driven key binding structure for user configuration
|
||||
*/
|
||||
export interface KeyBinding {
|
||||
/** The key name (e.g., 'a', 'return', 'tab', 'escape') */
|
||||
key?: string;
|
||||
/** The key sequence (e.g., '\x18' for Ctrl+X) - alternative to key name */
|
||||
sequence?: string;
|
||||
/** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
ctrl?: boolean;
|
||||
/** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
shift?: boolean;
|
||||
/** Command/meta key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
command?: boolean;
|
||||
/** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */
|
||||
paste?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration type mapping commands to their key bindings
|
||||
*/
|
||||
export type KeyBindingConfig = {
|
||||
readonly [C in Command]: readonly KeyBinding[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Default key binding configuration
|
||||
* Matches the original hard-coded logic exactly
|
||||
*/
|
||||
export const defaultKeyBindings: KeyBindingConfig = {
|
||||
// Basic bindings
|
||||
[Command.RETURN]: [{ key: 'return' }],
|
||||
// Original: key.name === 'escape'
|
||||
[Command.ESCAPE]: [{ key: 'escape' }],
|
||||
|
||||
// Cursor movement
|
||||
// Original: key.ctrl && key.name === 'a'
|
||||
[Command.HOME]: [{ key: 'a', ctrl: true }],
|
||||
// Original: key.ctrl && key.name === 'e'
|
||||
[Command.END]: [{ key: 'e', ctrl: true }],
|
||||
|
||||
// Text deletion
|
||||
// Original: key.ctrl && key.name === 'k'
|
||||
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
|
||||
// Original: key.ctrl && key.name === 'u'
|
||||
[Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
|
||||
// Original: key.ctrl && key.name === 'c'
|
||||
[Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
|
||||
|
||||
// Screen control
|
||||
// Original: key.ctrl && key.name === 'l'
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
|
||||
// History navigation
|
||||
// Original: key.ctrl && key.name === 'p'
|
||||
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true }],
|
||||
// Original: key.ctrl && key.name === 'n'
|
||||
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }],
|
||||
// Original: key.name === 'up'
|
||||
[Command.NAVIGATION_UP]: [{ key: 'up' }],
|
||||
// Original: key.name === 'down'
|
||||
[Command.NAVIGATION_DOWN]: [{ key: 'down' }],
|
||||
|
||||
// Auto-completion
|
||||
// Original: key.name === 'tab' || (key.name === 'return' && !key.ctrl)
|
||||
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
|
||||
// Completion navigation (arrow or Ctrl+P/N)
|
||||
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
|
||||
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
|
||||
|
||||
// Text input
|
||||
// Original: key.name === 'return' && !key.ctrl && !key.meta && !key.paste
|
||||
[Command.SUBMIT]: [
|
||||
{
|
||||
key: 'return',
|
||||
ctrl: false,
|
||||
command: false,
|
||||
paste: false,
|
||||
},
|
||||
],
|
||||
// Original: key.name === 'return' && (key.ctrl || key.meta || key.paste)
|
||||
// Split into multiple data-driven bindings
|
||||
[Command.NEWLINE]: [
|
||||
{ key: 'return', ctrl: true },
|
||||
{ key: 'return', command: true },
|
||||
{ key: 'return', paste: true },
|
||||
],
|
||||
|
||||
// External tools
|
||||
// Original: key.ctrl && (key.name === 'x' || key.sequence === '\x18')
|
||||
[Command.OPEN_EXTERNAL_EDITOR]: [
|
||||
{ key: 'x', ctrl: true },
|
||||
{ sequence: '\x18', ctrl: true },
|
||||
],
|
||||
// Original: key.ctrl && key.name === 'v'
|
||||
[Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }],
|
||||
|
||||
// App level bindings
|
||||
// Original: key.ctrl && key.name === 'o'
|
||||
[Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }],
|
||||
// Original: key.ctrl && key.name === 't'
|
||||
[Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }],
|
||||
// Original: key.ctrl && key.name === 'e'
|
||||
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'e', ctrl: true }],
|
||||
// Original: key.ctrl && (key.name === 'c' || key.name === 'C')
|
||||
[Command.QUIT]: [{ key: 'c', ctrl: true }],
|
||||
// Original: key.ctrl && (key.name === 'd' || key.name === 'D')
|
||||
[Command.EXIT]: [{ key: 'd', ctrl: true }],
|
||||
// Original: key.ctrl && key.name === 's'
|
||||
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
|
||||
|
||||
// Shell commands
|
||||
// Original: key.ctrl && key.name === 'r'
|
||||
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
||||
// Original: key.name === 'return' && !key.ctrl
|
||||
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
|
||||
// Original: key.name === 'tab'
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
||||
};
|
||||
@@ -112,6 +112,8 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged).toEqual({
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
});
|
||||
expect(settings.errors.length).toBe(0);
|
||||
});
|
||||
@@ -145,6 +147,8 @@ describe('Settings Loading and Merging', () => {
|
||||
...systemSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +182,8 @@ describe('Settings Loading and Merging', () => {
|
||||
...userSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,6 +215,8 @@ describe('Settings Loading and Merging', () => {
|
||||
...workspaceSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,6 +254,8 @@ describe('Settings Loading and Merging', () => {
|
||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,9 +305,67 @@ 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,
|
||||
@@ -616,6 +684,150 @@ 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';
|
||||
@@ -654,6 +866,8 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged).toEqual({
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
});
|
||||
|
||||
// Check that error objects are populated in settings.errors
|
||||
@@ -1090,6 +1304,8 @@ describe('Settings Loading and Merging', () => {
|
||||
...systemSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,17 +9,15 @@ import * as path from 'path';
|
||||
import { homedir, platform } from 'os';
|
||||
import * as dotenv from 'dotenv';
|
||||
import {
|
||||
MCPServerConfig,
|
||||
GEMINI_CONFIG_DIR as GEMINI_DIR,
|
||||
getErrorMessage,
|
||||
BugCommandSettings,
|
||||
TelemetrySettings,
|
||||
AuthType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
import { DefaultDark } from '../ui/themes/default.js';
|
||||
import { CustomTheme } from '../ui/themes/theme.js';
|
||||
import { Settings, MemoryImportFormat } from './settingsSchema.js';
|
||||
|
||||
export type { Settings, MemoryImportFormat };
|
||||
|
||||
export const SETTINGS_DIRECTORY_NAME = '.qwen';
|
||||
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
||||
@@ -43,7 +41,7 @@ export function getWorkspaceSettingsPath(workspaceDir: string): string {
|
||||
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
|
||||
}
|
||||
|
||||
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
||||
export type { DnsResolutionOrder } from './settingsSchema.js';
|
||||
|
||||
export enum SettingScope {
|
||||
User = 'User',
|
||||
@@ -63,87 +61,6 @@ 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;
|
||||
@@ -183,9 +100,13 @@ export class LoadedSettings {
|
||||
const user = this.user.settings;
|
||||
const workspace = this.workspace.settings;
|
||||
|
||||
// folderTrust is not supported at workspace level.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { folderTrust, ...workspaceWithoutFolderTrust } = workspace;
|
||||
|
||||
return {
|
||||
...user,
|
||||
...workspace,
|
||||
...workspaceWithoutFolderTrust,
|
||||
...system,
|
||||
customThemes: {
|
||||
...(user.customThemes || {}),
|
||||
@@ -197,6 +118,16 @@ export class LoadedSettings {
|
||||
...(workspace.mcpServers || {}),
|
||||
...(system.mcpServers || {}),
|
||||
},
|
||||
includeDirectories: [
|
||||
...(system.includeDirectories || []),
|
||||
...(user.includeDirectories || []),
|
||||
...(workspace.includeDirectories || []),
|
||||
],
|
||||
chatCompression: {
|
||||
...(system.chatCompression || {}),
|
||||
...(user.chatCompression || {}),
|
||||
...(workspace.chatCompression || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -387,7 +318,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
const settingsErrors: SettingsError[] = [];
|
||||
const systemSettingsPath = getSystemSettingsPath();
|
||||
|
||||
// FIX: Resolve paths to their canonical representation to handle symlinks
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
@@ -442,7 +373,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
});
|
||||
}
|
||||
|
||||
// This comparison is now much more reliable.
|
||||
if (realWorkspaceDir !== realHomeDir) {
|
||||
// Load workspace settings
|
||||
try {
|
||||
@@ -486,6 +416,19 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
settingsErrors,
|
||||
);
|
||||
|
||||
// Validate chatCompression settings
|
||||
const chatCompression = loadedSettings.merged.chatCompression;
|
||||
const threshold = chatCompression?.contextPercentageThreshold;
|
||||
if (
|
||||
threshold != null &&
|
||||
(typeof threshold !== 'number' || threshold < 0 || threshold > 1)
|
||||
) {
|
||||
console.warn(
|
||||
`Invalid value for chatCompression.contextPercentageThreshold: "${threshold}". Please use a value between 0 and 1. Using default compression settings.`,
|
||||
);
|
||||
delete loadedSettings.merged.chatCompression;
|
||||
}
|
||||
|
||||
// Load environment with merged settings
|
||||
loadEnvironment(loadedSettings.merged);
|
||||
|
||||
|
||||
253
packages/cli/src/config/settingsSchema.test.ts
Normal file
253
packages/cli/src/config/settingsSchema.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SETTINGS_SCHEMA, Settings } from './settingsSchema.js';
|
||||
|
||||
describe('SettingsSchema', () => {
|
||||
describe('SETTINGS_SCHEMA', () => {
|
||||
it('should contain all expected top-level settings', () => {
|
||||
const expectedSettings = [
|
||||
'theme',
|
||||
'customThemes',
|
||||
'showMemoryUsage',
|
||||
'usageStatisticsEnabled',
|
||||
'autoConfigureMaxOldSpaceSize',
|
||||
'preferredEditor',
|
||||
'maxSessionTurns',
|
||||
'memoryImportFormat',
|
||||
'memoryDiscoveryMaxDirs',
|
||||
'contextFileName',
|
||||
'vimMode',
|
||||
'ideMode',
|
||||
'accessibility',
|
||||
'checkpointing',
|
||||
'fileFiltering',
|
||||
'disableAutoUpdate',
|
||||
'hideWindowTitle',
|
||||
'hideTips',
|
||||
'hideBanner',
|
||||
'selectedAuthType',
|
||||
'useExternalAuth',
|
||||
'sandbox',
|
||||
'coreTools',
|
||||
'excludeTools',
|
||||
'toolDiscoveryCommand',
|
||||
'toolCallCommand',
|
||||
'mcpServerCommand',
|
||||
'mcpServers',
|
||||
'allowMCPServers',
|
||||
'excludeMCPServers',
|
||||
'telemetry',
|
||||
'bugCommand',
|
||||
'summarizeToolOutput',
|
||||
'ideModeFeature',
|
||||
'dnsResolutionOrder',
|
||||
'excludedProjectEnvVars',
|
||||
'disableUpdateNag',
|
||||
'includeDirectories',
|
||||
'loadMemoryFromIncludeDirectories',
|
||||
'model',
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
'folderTrustFeature',
|
||||
];
|
||||
|
||||
expectedSettings.forEach((setting) => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA[setting as keyof typeof SETTINGS_SCHEMA],
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct structure for each setting', () => {
|
||||
Object.entries(SETTINGS_SCHEMA).forEach(([_key, definition]) => {
|
||||
expect(definition).toHaveProperty('type');
|
||||
expect(definition).toHaveProperty('label');
|
||||
expect(definition).toHaveProperty('category');
|
||||
expect(definition).toHaveProperty('requiresRestart');
|
||||
expect(definition).toHaveProperty('default');
|
||||
expect(typeof definition.type).toBe('string');
|
||||
expect(typeof definition.label).toBe('string');
|
||||
expect(typeof definition.category).toBe('string');
|
||||
expect(typeof definition.requiresRestart).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct nested setting structure', () => {
|
||||
const nestedSettings = [
|
||||
'accessibility',
|
||||
'checkpointing',
|
||||
'fileFiltering',
|
||||
];
|
||||
|
||||
nestedSettings.forEach((setting) => {
|
||||
const definition = SETTINGS_SCHEMA[
|
||||
setting as keyof typeof SETTINGS_SCHEMA
|
||||
] as (typeof SETTINGS_SCHEMA)[keyof typeof SETTINGS_SCHEMA] & {
|
||||
properties: unknown;
|
||||
};
|
||||
expect(definition.type).toBe('object');
|
||||
expect(definition.properties).toBeDefined();
|
||||
expect(typeof definition.properties).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have accessibility nested properties', () => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases.type,
|
||||
).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have checkpointing nested properties', () => {
|
||||
expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled).toBeDefined();
|
||||
expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
});
|
||||
|
||||
it('should have fileFiltering nested properties', () => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA.fileFiltering.properties?.respectGitIgnore,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
SETTINGS_SCHEMA.fileFiltering.properties?.respectGeminiIgnore,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
SETTINGS_SCHEMA.fileFiltering.properties?.enableRecursiveFileSearch,
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have unique categories', () => {
|
||||
const categories = new Set();
|
||||
|
||||
// Collect categories from top-level settings
|
||||
Object.values(SETTINGS_SCHEMA).forEach((definition) => {
|
||||
categories.add(definition.category);
|
||||
// Also collect from nested properties
|
||||
const defWithProps = definition as typeof definition & {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
if (defWithProps.properties) {
|
||||
Object.values(defWithProps.properties).forEach(
|
||||
(nestedDef: unknown) => {
|
||||
const nestedDefTyped = nestedDef as { category?: string };
|
||||
if (nestedDefTyped.category) {
|
||||
categories.add(nestedDefTyped.category);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
expect(categories.size).toBeGreaterThan(0);
|
||||
expect(categories).toContain('General');
|
||||
expect(categories).toContain('UI');
|
||||
expect(categories).toContain('Mode');
|
||||
expect(categories).toContain('Updates');
|
||||
expect(categories).toContain('Accessibility');
|
||||
expect(categories).toContain('Checkpointing');
|
||||
expect(categories).toContain('File Filtering');
|
||||
expect(categories).toContain('Advanced');
|
||||
});
|
||||
|
||||
it('should have consistent default values for boolean settings', () => {
|
||||
const checkBooleanDefaults = (schema: Record<string, unknown>) => {
|
||||
Object.entries(schema).forEach(
|
||||
([_key, definition]: [string, unknown]) => {
|
||||
const def = definition as {
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
if (def.type === 'boolean') {
|
||||
// Boolean settings can have boolean or undefined defaults (for optional settings)
|
||||
expect(['boolean', 'undefined']).toContain(typeof def.default);
|
||||
}
|
||||
if (def.properties) {
|
||||
checkBooleanDefaults(def.properties);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
checkBooleanDefaults(SETTINGS_SCHEMA as Record<string, unknown>);
|
||||
});
|
||||
|
||||
it('should have showInDialog property configured', () => {
|
||||
// Check that user-facing settings are marked for dialog display
|
||||
expect(SETTINGS_SCHEMA.showMemoryUsage.showInDialog).toBe(true);
|
||||
expect(SETTINGS_SCHEMA.vimMode.showInDialog).toBe(true);
|
||||
expect(SETTINGS_SCHEMA.ideMode.showInDialog).toBe(true);
|
||||
expect(SETTINGS_SCHEMA.disableAutoUpdate.showInDialog).toBe(true);
|
||||
expect(SETTINGS_SCHEMA.hideWindowTitle.showInDialog).toBe(true);
|
||||
expect(SETTINGS_SCHEMA.hideTips.showInDialog).toBe(true);
|
||||
expect(SETTINGS_SCHEMA.hideBanner.showInDialog).toBe(true);
|
||||
expect(SETTINGS_SCHEMA.usageStatisticsEnabled.showInDialog).toBe(true);
|
||||
|
||||
// Check that advanced settings are hidden from dialog
|
||||
expect(SETTINGS_SCHEMA.selectedAuthType.showInDialog).toBe(false);
|
||||
expect(SETTINGS_SCHEMA.coreTools.showInDialog).toBe(false);
|
||||
expect(SETTINGS_SCHEMA.mcpServers.showInDialog).toBe(false);
|
||||
expect(SETTINGS_SCHEMA.telemetry.showInDialog).toBe(false);
|
||||
|
||||
// Check that some settings are appropriately hidden
|
||||
expect(SETTINGS_SCHEMA.theme.showInDialog).toBe(false); // Changed to false
|
||||
expect(SETTINGS_SCHEMA.customThemes.showInDialog).toBe(false); // Managed via theme editor
|
||||
expect(SETTINGS_SCHEMA.checkpointing.showInDialog).toBe(false); // Experimental feature
|
||||
expect(SETTINGS_SCHEMA.accessibility.showInDialog).toBe(false); // Changed to false
|
||||
expect(SETTINGS_SCHEMA.fileFiltering.showInDialog).toBe(false); // Changed to false
|
||||
expect(SETTINGS_SCHEMA.preferredEditor.showInDialog).toBe(false); // Changed to false
|
||||
expect(SETTINGS_SCHEMA.autoConfigureMaxOldSpaceSize.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should infer Settings type correctly', () => {
|
||||
// This test ensures that the Settings type is properly inferred from the schema
|
||||
const settings: Settings = {
|
||||
theme: 'dark',
|
||||
includeDirectories: ['/path/to/dir'],
|
||||
loadMemoryFromIncludeDirectories: true,
|
||||
};
|
||||
|
||||
// TypeScript should not complain about these properties
|
||||
expect(settings.theme).toBe('dark');
|
||||
expect(settings.includeDirectories).toEqual(['/path/to/dir']);
|
||||
expect(settings.loadMemoryFromIncludeDirectories).toBe(true);
|
||||
});
|
||||
|
||||
it('should have includeDirectories setting in schema', () => {
|
||||
expect(SETTINGS_SCHEMA.includeDirectories).toBeDefined();
|
||||
expect(SETTINGS_SCHEMA.includeDirectories.type).toBe('array');
|
||||
expect(SETTINGS_SCHEMA.includeDirectories.category).toBe('General');
|
||||
expect(SETTINGS_SCHEMA.includeDirectories.default).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have loadMemoryFromIncludeDirectories setting in schema', () => {
|
||||
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories).toBeDefined();
|
||||
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.category).toBe(
|
||||
'General',
|
||||
);
|
||||
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.default).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should have folderTrustFeature setting in schema', () => {
|
||||
expect(SETTINGS_SCHEMA.folderTrustFeature).toBeDefined();
|
||||
expect(SETTINGS_SCHEMA.folderTrustFeature.type).toBe('boolean');
|
||||
expect(SETTINGS_SCHEMA.folderTrustFeature.category).toBe('General');
|
||||
expect(SETTINGS_SCHEMA.folderTrustFeature.default).toBe(false);
|
||||
expect(SETTINGS_SCHEMA.folderTrustFeature.showInDialog).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
571
packages/cli/src/config/settingsSchema.ts
Normal file
571
packages/cli/src/config/settingsSchema.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
MCPServerConfig,
|
||||
BugCommandSettings,
|
||||
TelemetrySettings,
|
||||
AuthType,
|
||||
ChatCompressionSettings,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { CustomTheme } from '../ui/themes/theme.js';
|
||||
|
||||
export interface SettingDefinition {
|
||||
type: 'boolean' | 'string' | 'number' | 'array' | 'object';
|
||||
label: string;
|
||||
category: string;
|
||||
requiresRestart: boolean;
|
||||
default: boolean | string | number | string[] | object | undefined;
|
||||
description?: string;
|
||||
parentKey?: string;
|
||||
childKey?: string;
|
||||
key?: string;
|
||||
properties?: SettingsSchema;
|
||||
showInDialog?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsSchema {
|
||||
[key: string]: SettingDefinition;
|
||||
}
|
||||
|
||||
export type MemoryImportFormat = 'tree' | 'flat';
|
||||
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
||||
|
||||
/**
|
||||
* The canonical schema for all settings.
|
||||
* The structure of this object defines the structure of the `Settings` type.
|
||||
* `as const` is crucial for TypeScript to infer the most specific types possible.
|
||||
*/
|
||||
export const SETTINGS_SCHEMA = {
|
||||
// UI Settings
|
||||
theme: {
|
||||
type: 'string',
|
||||
label: 'Theme',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The color theme for the UI.',
|
||||
showInDialog: false,
|
||||
},
|
||||
customThemes: {
|
||||
type: 'object',
|
||||
label: 'Custom Themes',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: {} as Record<string, CustomTheme>,
|
||||
description: 'Custom theme definitions.',
|
||||
showInDialog: false,
|
||||
},
|
||||
hideWindowTitle: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Window Title',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Hide the window title bar',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideTips: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Tips',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide helpful tips in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Banner',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the application banner',
|
||||
showInDialog: true,
|
||||
},
|
||||
showMemoryUsage: {
|
||||
type: 'boolean',
|
||||
label: 'Show Memory Usage',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Display memory usage information in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
|
||||
usageStatisticsEnabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Usage Statistics',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable collection of usage statistics',
|
||||
showInDialog: true,
|
||||
},
|
||||
autoConfigureMaxOldSpaceSize: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Configure Max Old Space Size',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Automatically configure Node.js memory limits',
|
||||
showInDialog: true,
|
||||
},
|
||||
preferredEditor: {
|
||||
type: 'string',
|
||||
label: 'Preferred Editor',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The preferred editor to open files in.',
|
||||
showInDialog: false,
|
||||
},
|
||||
maxSessionTurns: {
|
||||
type: 'number',
|
||||
label: 'Max Session Turns',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as number | undefined,
|
||||
description:
|
||||
'Maximum number of user/model/tool turns to keep in a session.',
|
||||
showInDialog: false,
|
||||
},
|
||||
memoryImportFormat: {
|
||||
type: 'string',
|
||||
label: 'Memory Import Format',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as MemoryImportFormat | undefined,
|
||||
description: 'The format to use when importing memory.',
|
||||
showInDialog: false,
|
||||
},
|
||||
memoryDiscoveryMaxDirs: {
|
||||
type: 'number',
|
||||
label: 'Memory Discovery Max Dirs',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as number | undefined,
|
||||
description: 'Maximum number of directories to search for memory.',
|
||||
showInDialog: false,
|
||||
},
|
||||
contextFileName: {
|
||||
type: 'object',
|
||||
label: 'Context File Name',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | string[] | undefined,
|
||||
description: 'The name of the context file.',
|
||||
showInDialog: false,
|
||||
},
|
||||
vimMode: {
|
||||
type: 'boolean',
|
||||
label: 'Vim Mode',
|
||||
category: 'Mode',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable Vim keybindings',
|
||||
showInDialog: true,
|
||||
},
|
||||
ideMode: {
|
||||
type: 'boolean',
|
||||
label: 'IDE Mode',
|
||||
category: 'Mode',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable IDE integration mode',
|
||||
showInDialog: true,
|
||||
},
|
||||
|
||||
accessibility: {
|
||||
type: 'object',
|
||||
label: 'Accessibility',
|
||||
category: 'Accessibility',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Accessibility settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
disableLoadingPhrases: {
|
||||
type: 'boolean',
|
||||
label: 'Disable Loading Phrases',
|
||||
category: 'Accessibility',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Disable loading phrases for accessibility',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
checkpointing: {
|
||||
type: 'object',
|
||||
label: 'Checkpointing',
|
||||
category: 'Checkpointing',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Session checkpointing settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Checkpointing',
|
||||
category: 'Checkpointing',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable session checkpointing for recovery',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
fileFiltering: {
|
||||
type: 'object',
|
||||
label: 'File Filtering',
|
||||
category: 'File Filtering',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Settings for git-aware file filtering.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
respectGitIgnore: {
|
||||
type: 'boolean',
|
||||
label: 'Respect .gitignore',
|
||||
category: 'File Filtering',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Respect .gitignore files when searching',
|
||||
showInDialog: true,
|
||||
},
|
||||
respectGeminiIgnore: {
|
||||
type: 'boolean',
|
||||
label: 'Respect .geminiignore',
|
||||
category: 'File Filtering',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Respect .geminiignore files when searching',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableRecursiveFileSearch: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Recursive File Search',
|
||||
category: 'File Filtering',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable recursive file search functionality',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
disableAutoUpdate: {
|
||||
type: 'boolean',
|
||||
label: 'Disable Auto Update',
|
||||
category: 'Updates',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Disable automatic updates',
|
||||
showInDialog: true,
|
||||
},
|
||||
|
||||
selectedAuthType: {
|
||||
type: 'string',
|
||||
label: 'Selected Auth Type',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as AuthType | undefined,
|
||||
description: 'The currently selected authentication type.',
|
||||
showInDialog: false,
|
||||
},
|
||||
useExternalAuth: {
|
||||
type: 'boolean',
|
||||
label: 'Use External Auth',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as boolean | undefined,
|
||||
description: 'Whether to use an external authentication flow.',
|
||||
showInDialog: false,
|
||||
},
|
||||
sandbox: {
|
||||
type: 'object',
|
||||
label: 'Sandbox',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as boolean | string | undefined,
|
||||
description:
|
||||
'Sandbox execution environment (can be a boolean or a path string).',
|
||||
showInDialog: false,
|
||||
},
|
||||
coreTools: {
|
||||
type: 'array',
|
||||
label: 'Core Tools',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: 'Paths to core tool definitions.',
|
||||
showInDialog: false,
|
||||
},
|
||||
excludeTools: {
|
||||
type: 'array',
|
||||
label: 'Exclude Tools',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: 'Tool names to exclude from discovery.',
|
||||
showInDialog: false,
|
||||
},
|
||||
toolDiscoveryCommand: {
|
||||
type: 'string',
|
||||
label: 'Tool Discovery Command',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description: 'Command to run for tool discovery.',
|
||||
showInDialog: false,
|
||||
},
|
||||
toolCallCommand: {
|
||||
type: 'string',
|
||||
label: 'Tool Call Command',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description: 'Command to run for tool calls.',
|
||||
showInDialog: false,
|
||||
},
|
||||
mcpServerCommand: {
|
||||
type: 'string',
|
||||
label: 'MCP Server Command',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description: 'Command to start an MCP server.',
|
||||
showInDialog: false,
|
||||
},
|
||||
mcpServers: {
|
||||
type: 'object',
|
||||
label: 'MCP Servers',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: {} as Record<string, MCPServerConfig>,
|
||||
description: 'Configuration for MCP servers.',
|
||||
showInDialog: false,
|
||||
},
|
||||
allowMCPServers: {
|
||||
type: 'array',
|
||||
label: 'Allow MCP Servers',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: 'A whitelist of MCP servers to allow.',
|
||||
showInDialog: false,
|
||||
},
|
||||
excludeMCPServers: {
|
||||
type: 'array',
|
||||
label: 'Exclude MCP Servers',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: 'A blacklist of MCP servers to exclude.',
|
||||
showInDialog: false,
|
||||
},
|
||||
telemetry: {
|
||||
type: 'object',
|
||||
label: 'Telemetry',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as TelemetrySettings | undefined,
|
||||
description: 'Telemetry configuration.',
|
||||
showInDialog: false,
|
||||
},
|
||||
bugCommand: {
|
||||
type: 'object',
|
||||
label: 'Bug Command',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: undefined as BugCommandSettings | undefined,
|
||||
description: 'Configuration for the bug report command.',
|
||||
showInDialog: false,
|
||||
},
|
||||
summarizeToolOutput: {
|
||||
type: 'object',
|
||||
label: 'Summarize Tool Output',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: undefined as Record<string, { tokenBudget?: number }> | undefined,
|
||||
description: 'Settings for summarizing tool output.',
|
||||
showInDialog: false,
|
||||
},
|
||||
ideModeFeature: {
|
||||
type: 'boolean',
|
||||
label: 'IDE Mode Feature Flag',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as boolean | undefined,
|
||||
description: 'Internal feature flag for IDE mode.',
|
||||
showInDialog: false,
|
||||
},
|
||||
dnsResolutionOrder: {
|
||||
type: 'string',
|
||||
label: 'DNS Resolution Order',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as DnsResolutionOrder | undefined,
|
||||
description: 'The DNS resolution order.',
|
||||
showInDialog: false,
|
||||
},
|
||||
excludedProjectEnvVars: {
|
||||
type: 'array',
|
||||
label: 'Excluded Project Environment Variables',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: ['DEBUG', 'DEBUG_MODE'] as string[],
|
||||
description: 'Environment variables to exclude from project context.',
|
||||
showInDialog: false,
|
||||
},
|
||||
disableUpdateNag: {
|
||||
type: 'boolean',
|
||||
label: 'Disable Update Nag',
|
||||
category: 'Updates',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Disable update notification prompts.',
|
||||
showInDialog: false,
|
||||
},
|
||||
includeDirectories: {
|
||||
type: 'array',
|
||||
label: 'Include Directories',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description: 'Additional directories to include in the workspace context.',
|
||||
showInDialog: false,
|
||||
},
|
||||
loadMemoryFromIncludeDirectories: {
|
||||
type: 'boolean',
|
||||
label: 'Load Memory From Include Directories',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Whether to load memory files from include directories.',
|
||||
showInDialog: true,
|
||||
},
|
||||
model: {
|
||||
type: 'string',
|
||||
label: 'Model',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The Gemini model to use for conversations.',
|
||||
showInDialog: false,
|
||||
},
|
||||
hasSeenIdeIntegrationNudge: {
|
||||
type: 'boolean',
|
||||
label: 'Has Seen IDE Integration Nudge',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Whether the user has seen the IDE integration nudge.',
|
||||
showInDialog: false,
|
||||
},
|
||||
folderTrustFeature: {
|
||||
type: 'boolean',
|
||||
label: 'Folder Trust Feature',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable folder trust feature for enhanced security.',
|
||||
showInDialog: true,
|
||||
},
|
||||
folderTrust: {
|
||||
type: 'boolean',
|
||||
label: 'Folder Trust',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Setting to track whether Folder trust is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
chatCompression: {
|
||||
type: 'object',
|
||||
label: 'Chat Compression',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as ChatCompressionSettings | undefined,
|
||||
description: 'Chat compression settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
showLineNumbers: {
|
||||
type: 'boolean',
|
||||
label: 'Show Line Numbers',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show line numbers in the chat.',
|
||||
showInDialog: true,
|
||||
},
|
||||
|
||||
contentGenerator: {
|
||||
type: 'object',
|
||||
label: 'Content Generator',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as Record<string, unknown> | undefined,
|
||||
description: 'Content generator settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
sampling_params: {
|
||||
type: 'object',
|
||||
label: 'Sampling Params',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as Record<string, unknown> | undefined,
|
||||
description: 'Sampling parameters for the model.',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableOpenAILogging: {
|
||||
type: 'boolean',
|
||||
label: 'Enable OpenAI Logging',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable OpenAI logging.',
|
||||
showInDialog: true,
|
||||
},
|
||||
sessionTokenLimit: {
|
||||
type: 'number',
|
||||
label: 'Session Token Limit',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as number | undefined,
|
||||
description: 'The maximum number of tokens allowed in a session.',
|
||||
showInDialog: false,
|
||||
},
|
||||
systemPromptMappings: {
|
||||
type: 'object',
|
||||
label: 'System Prompt Mappings',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as Record<string, string> | undefined,
|
||||
description: 'Mappings of system prompts to model names.',
|
||||
showInDialog: false,
|
||||
},
|
||||
tavilyApiKey: {
|
||||
type: 'string',
|
||||
label: 'Tavily API Key',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The API key for the Tavily API.',
|
||||
showInDialog: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type InferSettings<T extends SettingsSchema> = {
|
||||
-readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema }
|
||||
? InferSettings<T[K]['properties']>
|
||||
: T[K]['default'] extends boolean
|
||||
? boolean
|
||||
: T[K]['default'];
|
||||
};
|
||||
|
||||
export type Settings = InferSettings<typeof SETTINGS_SCHEMA>;
|
||||
@@ -7,7 +7,7 @@
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import { AppWrapper } from './ui/App.js';
|
||||
import { loadCliConfig, parseArguments, CliArgs } from './config/config.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { basename } from 'node:path';
|
||||
import v8 from 'node:v8';
|
||||
@@ -25,19 +25,18 @@ import { themeManager } from './ui/themes/theme-manager.js';
|
||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { loadExtensions, Extension } from './config/extension.js';
|
||||
import { loadExtensions } from './config/extension.js';
|
||||
import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
Config,
|
||||
EditTool,
|
||||
ShellTool,
|
||||
WriteFileTool,
|
||||
sessionId,
|
||||
logUserPrompt,
|
||||
AuthType,
|
||||
getOauthClient,
|
||||
logIdeConnection,
|
||||
IdeConnectionEvent,
|
||||
IdeConnectionType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||
@@ -45,6 +44,7 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
order: string | undefined,
|
||||
@@ -191,6 +191,11 @@ export async function main() {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
if (config.getIdeMode() && config.getIdeModeFeature()) {
|
||||
await config.getIdeClient().connect();
|
||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
|
||||
}
|
||||
|
||||
// Load custom themes from settings
|
||||
themeManager.loadCustomThemes(settings.merged.customThemes);
|
||||
|
||||
@@ -255,21 +260,20 @@ export async function main() {
|
||||
...(await getUserStartupWarnings(workspaceRoot)),
|
||||
];
|
||||
|
||||
const shouldBeInteractive =
|
||||
!!argv.promptInteractive || (process.stdin.isTTY && input?.length === 0);
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
if (shouldBeInteractive) {
|
||||
if (config.isInteractive()) {
|
||||
const version = await getCliVersion();
|
||||
setWindowTitle(basename(workspaceRoot), settings);
|
||||
const instance = render(
|
||||
<React.StrictMode>
|
||||
<AppWrapper
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
/>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<AppWrapper
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
/>
|
||||
</SettingsContext.Provider>
|
||||
</React.StrictMode>,
|
||||
{ exitOnCtrlC: false },
|
||||
);
|
||||
@@ -308,12 +312,10 @@ export async function main() {
|
||||
prompt_length: input.length,
|
||||
});
|
||||
|
||||
// Non-interactive mode handled by runNonInteractive
|
||||
const nonInteractiveConfig = await loadNonInteractiveConfig(
|
||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||
settings.merged.selectedAuthType,
|
||||
settings.merged.useExternalAuth,
|
||||
config,
|
||||
extensions,
|
||||
settings,
|
||||
argv,
|
||||
);
|
||||
|
||||
await runNonInteractive(nonInteractiveConfig, input, prompt_id);
|
||||
@@ -334,43 +336,3 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNonInteractiveConfig(
|
||||
config: Config,
|
||||
extensions: Extension[],
|
||||
settings: LoadedSettings,
|
||||
argv: CliArgs,
|
||||
) {
|
||||
let finalConfig = config;
|
||||
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
|
||||
// Everything is not allowed, ensure that only read-only tools are configured.
|
||||
const existingExcludeTools = settings.merged.excludeTools || [];
|
||||
const interactiveTools = [
|
||||
ShellTool.Name,
|
||||
EditTool.Name,
|
||||
WriteFileTool.Name,
|
||||
];
|
||||
|
||||
const newExcludeTools = [
|
||||
...new Set([...existingExcludeTools, ...interactiveTools]),
|
||||
];
|
||||
|
||||
const nonInteractiveSettings = {
|
||||
...settings.merged,
|
||||
excludeTools: newExcludeTools,
|
||||
};
|
||||
finalConfig = await loadCliConfig(
|
||||
nonInteractiveSettings,
|
||||
extensions,
|
||||
config.getSessionId(),
|
||||
argv,
|
||||
);
|
||||
await finalConfig.initialize();
|
||||
}
|
||||
|
||||
return await validateNonInteractiveAuth(
|
||||
settings.merged.selectedAuthType,
|
||||
settings.merged.useExternalAuth,
|
||||
finalConfig,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ 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;
|
||||
});
|
||||
|
||||
|
||||
@@ -17,28 +17,36 @@ 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> {
|
||||
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 consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
|
||||
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 (
|
||||
@@ -133,6 +141,7 @@ export async function runNonInteractive(
|
||||
);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
consolePatcher.cleanup();
|
||||
if (isTelemetrySdkInitialized()) {
|
||||
await shutdownTelemetry();
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { isGitHubRepository } from '../utils/gitUtils.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
@@ -73,8 +73,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
statsCommand,
|
||||
themeCommand,
|
||||
toolsCommand,
|
||||
settingsCommand,
|
||||
vimCommand,
|
||||
...(isGitHubRepository() ? [setupGithubCommand] : []),
|
||||
setupGithubCommand,
|
||||
];
|
||||
|
||||
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SandboxConfig,
|
||||
GeminiClient,
|
||||
ideContext,
|
||||
type AuthType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
|
||||
import process from 'node:process';
|
||||
@@ -27,6 +28,7 @@ import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
|
||||
import { EventEmitter } from 'events';
|
||||
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
|
||||
import * as auth from '../config/auth.js';
|
||||
import * as useTerminalSize from './hooks/useTerminalSize.js';
|
||||
|
||||
// Define a more complete mock server config based on actual Config
|
||||
interface MockServerConfig {
|
||||
@@ -84,6 +86,7 @@ interface MockServerConfig {
|
||||
getAllGeminiMdFilenames: Mock<() => string[]>;
|
||||
getGeminiClient: Mock<() => GeminiClient | undefined>;
|
||||
getUserTier: Mock<() => Promise<string | undefined>>;
|
||||
getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>;
|
||||
}
|
||||
|
||||
// Mock @qwen-code/qwen-code-core and its Config class
|
||||
@@ -148,7 +151,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
getUserTier: vi.fn(),
|
||||
})),
|
||||
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
|
||||
setFlashFallbackHandler: vi.fn(),
|
||||
getSessionId: vi.fn(() => 'test-session-id'),
|
||||
getUserTier: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -157,6 +160,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
getWorkspaceContext: vi.fn(() => ({
|
||||
getDirectories: vi.fn(() => []),
|
||||
})),
|
||||
getIdeClient: vi.fn(() => ({
|
||||
getCurrentIde: vi.fn(() => 'vscode'),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -169,7 +175,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
...actualCore,
|
||||
Config: ConfigClassMock,
|
||||
MCPServerConfig: actualCore.MCPServerConfig,
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
|
||||
ideContext: ideContextMock,
|
||||
isGitRepository: vi.fn(),
|
||||
};
|
||||
@@ -182,6 +188,7 @@ vi.mock('./hooks/useGeminiStream', () => ({
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -196,6 +203,13 @@ vi.mock('./hooks/useAuthCommand', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./hooks/useFolderTrust', () => ({
|
||||
useFolderTrust: vi.fn(() => ({
|
||||
isFolderTrustDialogOpen: false,
|
||||
handleFolderTrustSelect: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./hooks/useLogger', () => ({
|
||||
useLogger: vi.fn(() => ({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
@@ -233,10 +247,14 @@ vi.mock('./utils/updateCheck.js', () => ({
|
||||
checkForUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./config/auth.js', () => ({
|
||||
vi.mock('../config/auth.js', () => ({
|
||||
validateAuthMethod: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedCheckForUpdates = vi.mocked(checkForUpdates);
|
||||
const { isGitRepository: mockedIsGitRepository } = vi.mocked(
|
||||
await import('@qwen-code/qwen-code-core'),
|
||||
@@ -278,6 +296,11 @@ describe('App UI', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||
columns: 120,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
const ServerConfigMocked = vi.mocked(ServerConfig, true);
|
||||
mockConfig = new ServerConfigMocked({
|
||||
embeddingModel: 'test-embedding-model',
|
||||
@@ -577,7 +600,7 @@ describe('App UI', () => {
|
||||
},
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
@@ -589,13 +612,13 @@ describe('App UI', () => {
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain(
|
||||
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file',
|
||||
'Using: 1 open file (ctrl+e to view) | 1 QWEN.md file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
|
||||
it('should display default "QWEN.md" in footer when contextFileName is not set and count is 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.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);
|
||||
@@ -609,15 +632,12 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve(); // Wait for any async updates
|
||||
expect(lastFrame()).toContain('Using: 1 GEMINI.md file');
|
||||
expect(lastFrame()).toContain('Using: 1 QWEN.md file');
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
|
||||
it('should display default "QWEN.md" with plural when contextFileName is not set and count is > 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'GEMINI.md',
|
||||
'GEMINI.md',
|
||||
]);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -630,7 +650,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using: 2 GEMINI.md files');
|
||||
expect(lastFrame()).toContain('Using: 2 QWEN.md files');
|
||||
});
|
||||
|
||||
it('should display custom contextFileName in footer when set and count is 1', async () => {
|
||||
@@ -727,12 +747,9 @@ describe('App UI', () => {
|
||||
expect(lastFrame()).not.toContain('ANY_FILE.MD');
|
||||
});
|
||||
|
||||
it('should display GEMINI.md and MCP server count when both are present', async () => {
|
||||
it('should display QWEN.md and MCP server count when both are present', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'GEMINI.md',
|
||||
'GEMINI.md',
|
||||
]);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
server1: {} as MCPServerConfig,
|
||||
});
|
||||
@@ -751,7 +768,7 @@ describe('App UI', () => {
|
||||
expect(lastFrame()).toContain('1 MCP server');
|
||||
});
|
||||
|
||||
it('should display only MCP server count when GEMINI.md count is 0', async () => {
|
||||
it('should display only MCP server count when QWEN.md count is 0', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
@@ -1056,4 +1073,44 @@ describe('App UI', () => {
|
||||
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when in a narrow terminal', () => {
|
||||
it('should render with a column layout', () => {
|
||||
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||
columns: 60,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<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?');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
Text,
|
||||
useStdin,
|
||||
useStdout,
|
||||
useInput,
|
||||
type Key as InkKeyType,
|
||||
} from 'ink';
|
||||
import { StreamingState, type HistoryItem, MessageType } from './types.js';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
@@ -23,6 +21,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useAuthCommand } from './hooks/useAuthCommand.js';
|
||||
import { useQwenAuth } from './hooks/useQwenAuth.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||
@@ -38,17 +37,18 @@ import { AuthDialog } from './components/AuthDialog.js';
|
||||
import { AuthInProgress } from './components/AuthInProgress.js';
|
||||
import { QwenOAuthProgress } from './components/QwenOAuthProgress.js';
|
||||
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
|
||||
import { FolderTrustDialog } from './components/FolderTrustDialog.js';
|
||||
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
|
||||
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
|
||||
import { Colors } from './colors.js';
|
||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import { Tips } from './components/Tips.js';
|
||||
import { ConsolePatcher } from './utils/ConsolePatcher.js';
|
||||
import { registerCleanup } from '../utils/cleanup.js';
|
||||
import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
|
||||
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
|
||||
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
|
||||
import { IDEContextDetailDisplay } from './components/IDEContextDetailDisplay.js';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
@@ -64,6 +64,10 @@ import {
|
||||
type IdeContext,
|
||||
ideContext,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
IdeIntegrationNudge,
|
||||
IdeIntegrationNudgeResult,
|
||||
} from './IdeIntegrationNudge.js';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
@@ -77,6 +81,8 @@ import { useBracketedPaste } from './hooks/useBracketedPaste.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { useKeypress, Key } from './hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from './keyMatchers.js';
|
||||
import * as fs from 'fs';
|
||||
import { UpdateNotification } from './components/UpdateNotification.js';
|
||||
import {
|
||||
@@ -89,8 +95,11 @@ import ansiEscapes from 'ansi-escapes';
|
||||
import { OverflowProvider } from './contexts/OverflowContext.js';
|
||||
import { ShowMoreLines } from './components/ShowMoreLines.js';
|
||||
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { SettingsDialog } from './components/SettingsDialog.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from '../utils/events.js';
|
||||
import { isNarrowWidth } from './utils/isNarrowWidth.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
|
||||
@@ -117,6 +126,18 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const nightly = version.includes('nightly');
|
||||
const { history, addItem, clearItems, loadHistory } = useHistory();
|
||||
|
||||
const [idePromptAnswered, setIdePromptAnswered] = useState(false);
|
||||
const currentIDE = config.getIdeClient().getCurrentIde();
|
||||
useEffect(() => {
|
||||
registerCleanup(() => config.getIdeClient().disconnect());
|
||||
}, [config]);
|
||||
const shouldShowIdePrompt =
|
||||
config.getIdeModeFeature() &&
|
||||
currentIDE &&
|
||||
!config.getIdeMode() &&
|
||||
!settings.merged.hasSeenIdeIntegrationNudge &&
|
||||
!idePromptAnswered;
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = setUpdateHandler(addItem, setUpdateInfo);
|
||||
return cleanup;
|
||||
@@ -157,8 +178,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const [showErrorDetails, setShowErrorDetails] = useState<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
|
||||
@@ -174,6 +194,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const [ideContextState, setIdeContextState] = useState<
|
||||
IdeContext | undefined
|
||||
>();
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -208,6 +229,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const openPrivacyNotice = useCallback(() => {
|
||||
setShowPrivacyNotice(true);
|
||||
}, []);
|
||||
|
||||
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
|
||||
setShowEscapePrompt(showPrompt);
|
||||
}, []);
|
||||
|
||||
const initialPromptSubmitted = useRef(false);
|
||||
|
||||
const errorCount = useMemo(
|
||||
@@ -225,6 +251,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
handleThemeHighlight,
|
||||
} = useThemeCommand(settings, setThemeError, addItem);
|
||||
|
||||
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||
useSettingsCommand();
|
||||
|
||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect } =
|
||||
useFolderTrust(settings);
|
||||
|
||||
const {
|
||||
isAuthDialogOpen,
|
||||
openAuthDialog,
|
||||
@@ -308,6 +340,9 @@ 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,
|
||||
@@ -449,6 +484,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
|
||||
// Terminal and UI setup
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
@@ -457,7 +493,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
20,
|
||||
Math.floor(terminalWidth * widthFraction) - 3,
|
||||
);
|
||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
||||
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 0.8));
|
||||
|
||||
// Utility callbacks
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
@@ -496,6 +532,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
@@ -510,16 +547,37 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
toggleCorgiMode,
|
||||
setQuittingMessages,
|
||||
openPrivacyNotice,
|
||||
openSettingsDialog,
|
||||
toggleVimEnabled,
|
||||
setIsProcessing,
|
||||
setGeminiMdFileCount,
|
||||
);
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: '',
|
||||
viewport: { height: 10, width: inputWidth },
|
||||
stdin,
|
||||
setRawMode,
|
||||
isValidPath,
|
||||
shellModeActive,
|
||||
});
|
||||
|
||||
const [userMessages, setUserMessages] = useState<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,
|
||||
@@ -533,6 +591,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
refreshStatic,
|
||||
handleUserCancel,
|
||||
);
|
||||
|
||||
// Input handling
|
||||
@@ -546,14 +606,26 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
[submitQuery],
|
||||
);
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: '',
|
||||
viewport: { height: 10, width: inputWidth },
|
||||
stdin,
|
||||
setRawMode,
|
||||
isValidPath,
|
||||
shellModeActive,
|
||||
});
|
||||
const handleIdePromptComplete = useCallback(
|
||||
(result: IdeIntegrationNudgeResult) => {
|
||||
if (result === 'yes') {
|
||||
handleSlashCommand('/ide install');
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
true,
|
||||
);
|
||||
} else if (result === 'dismiss') {
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
true,
|
||||
);
|
||||
}
|
||||
setIdePromptAnswered(true);
|
||||
},
|
||||
[handleSlashCommand, settings],
|
||||
);
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
|
||||
@@ -586,55 +658,83 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
[handleSlashCommand],
|
||||
);
|
||||
|
||||
useInput((input: string, key: InkKeyType) => {
|
||||
let enteringConstrainHeightMode = false;
|
||||
if (!constrainHeight) {
|
||||
// Automatically re-enter constrain height mode if the user types
|
||||
// anything. When constrainHeight==false, the user will experience
|
||||
// significant flickering so it is best to disable it immediately when
|
||||
// the user starts interacting with the app.
|
||||
enteringConstrainHeightMode = true;
|
||||
setConstrainHeight(true);
|
||||
}
|
||||
|
||||
if (key.ctrl && input === 'o') {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
} else if (key.ctrl && input === 't') {
|
||||
const newValue = !showToolDescriptions;
|
||||
setShowToolDescriptions(newValue);
|
||||
|
||||
const mcpServers = config.getMcpServers();
|
||||
if (Object.keys(mcpServers || {}).length > 0) {
|
||||
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
let enteringConstrainHeightMode = false;
|
||||
if (!constrainHeight) {
|
||||
enteringConstrainHeightMode = true;
|
||||
setConstrainHeight(true);
|
||||
}
|
||||
} else if (
|
||||
key.ctrl &&
|
||||
input === 'e' &&
|
||||
config.getIdeMode() &&
|
||||
ideContextState
|
||||
) {
|
||||
setShowIDEContextDetail((prev) => !prev);
|
||||
} else if (key.ctrl && (input === 'c' || input === 'C')) {
|
||||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||
} else if (key.ctrl && (input === 'd' || input === 'D')) {
|
||||
if (buffer.text.length > 0) {
|
||||
// Do nothing if there is text in the input.
|
||||
return;
|
||||
|
||||
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
} else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) {
|
||||
const newValue = !showToolDescriptions;
|
||||
setShowToolDescriptions(newValue);
|
||||
|
||||
const mcpServers = config.getMcpServers();
|
||||
if (Object.keys(mcpServers || {}).length > 0) {
|
||||
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
|
||||
}
|
||||
} else if (
|
||||
keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) &&
|
||||
config.getIdeMode() &&
|
||||
ideContextState
|
||||
) {
|
||||
// Show IDE status when in IDE mode and context is available.
|
||||
handleSlashCommand('/ide status');
|
||||
} else if (keyMatchers[Command.QUIT](key)) {
|
||||
// When authenticating, let AuthInProgress component handle Ctrl+C.
|
||||
if (isAuthenticating) {
|
||||
return;
|
||||
}
|
||||
if (!ctrlCPressedOnce) {
|
||||
cancelOngoingRequest?.();
|
||||
}
|
||||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||
} else if (keyMatchers[Command.EXIT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
return;
|
||||
}
|
||||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||
} else if (
|
||||
keyMatchers[Command.SHOW_MORE_LINES](key) &&
|
||||
!enteringConstrainHeightMode
|
||||
) {
|
||||
setConstrainHeight(false);
|
||||
}
|
||||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||
} else if (key.ctrl && input === 's' && !enteringConstrainHeightMode) {
|
||||
setConstrainHeight(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[
|
||||
constrainHeight,
|
||||
setConstrainHeight,
|
||||
setShowErrorDetails,
|
||||
showToolDescriptions,
|
||||
setShowToolDescriptions,
|
||||
config,
|
||||
ideContextState,
|
||||
handleExit,
|
||||
ctrlCPressedOnce,
|
||||
setCtrlCPressedOnce,
|
||||
ctrlCTimerRef,
|
||||
buffer.text.length,
|
||||
ctrlDPressedOnce,
|
||||
setCtrlDPressedOnce,
|
||||
ctrlDTimerRef,
|
||||
handleSlashCommand,
|
||||
isAuthenticating,
|
||||
cancelOngoingRequest,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true });
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setGeminiMdFileCount(config.getGeminiMdFileCount());
|
||||
}
|
||||
}, [config]);
|
||||
}, [config, config.getGeminiMdFileCount]);
|
||||
|
||||
const logger = useLogger();
|
||||
const [userMessages, setUserMessages] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserMessages = async () => {
|
||||
@@ -786,6 +886,7 @@ 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
|
||||
@@ -814,11 +915,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
items={[
|
||||
<Box flexDirection="column" key="header">
|
||||
{!settings.merged.hideBanner && (
|
||||
<Header
|
||||
terminalWidth={terminalWidth}
|
||||
version={version}
|
||||
nightly={nightly}
|
||||
/>
|
||||
<Header version={version} nightly={nightly} />
|
||||
)}
|
||||
{!settings.merged.hideTips && <Tips config={config} />}
|
||||
</Box>,
|
||||
@@ -877,8 +974,30 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{shellConfirmationRequest ? (
|
||||
{shouldShowIdePrompt ? (
|
||||
<IdeIntegrationNudge
|
||||
ideName={config.getIdeClient().getDetectedIdeDisplayName()}
|
||||
onComplete={handleIdePromptComplete}
|
||||
/>
|
||||
) : isFolderTrustDialogOpen ? (
|
||||
<FolderTrustDialog onSelect={handleFolderTrustSelect} />
|
||||
) : 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 && (
|
||||
@@ -898,6 +1017,14 @@ 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 ? (
|
||||
@@ -989,9 +1116,10 @@ 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 && (
|
||||
@@ -1005,6 +1133,8 @@ 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}
|
||||
@@ -1016,7 +1146,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
||||
!shellModeActive && (
|
||||
<AutoAcceptIndicator
|
||||
@@ -1026,14 +1156,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
{shellModeActive && <ShellModeIndicator />}
|
||||
</Box>
|
||||
</Box>
|
||||
{showIDEContextDetail && (
|
||||
<IDEContextDetailDisplay
|
||||
ideContext={ideContextState}
|
||||
detectedIdeDisplay={config
|
||||
.getIdeClient()
|
||||
.getDetectedIdeDisplayName()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
@@ -1062,6 +1185,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
commandContext={commandContext}
|
||||
shellModeActive={shellModeActive}
|
||||
setShellModeActive={setShellModeActive}
|
||||
onEscapePromptChange={handleEscapePromptChange}
|
||||
focus={isFocused}
|
||||
vimHandleInput={vimHandleInput}
|
||||
placeholder={placeholder}
|
||||
@@ -1112,7 +1236,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
errorCount={errorCount}
|
||||
showErrorDetails={showErrorDetails}
|
||||
showMemoryUsage={
|
||||
config.getDebugMode() || config.getShowMemoryUsage()
|
||||
config.getDebugMode() || settings.merged.showMemoryUsage || false
|
||||
}
|
||||
promptTokenCount={sessionStats.lastPromptTokenCount}
|
||||
nightly={nightly}
|
||||
|
||||
70
packages/cli/src/ui/IdeIntegrationNudge.tsx
Normal file
70
packages/cli/src/ui/IdeIntegrationNudge.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
RadioSelectItem,
|
||||
} from './components/shared/RadioButtonSelect.js';
|
||||
|
||||
export type IdeIntegrationNudgeResult = 'yes' | 'no' | 'dismiss';
|
||||
|
||||
interface IdeIntegrationNudgeProps {
|
||||
ideName?: string;
|
||||
onComplete: (result: IdeIntegrationNudgeResult) => void;
|
||||
}
|
||||
|
||||
export function IdeIntegrationNudge({
|
||||
ideName,
|
||||
onComplete,
|
||||
}: IdeIntegrationNudgeProps) {
|
||||
useInput((_input, key) => {
|
||||
if (key.escape) {
|
||||
onComplete('no');
|
||||
}
|
||||
});
|
||||
|
||||
const OPTIONS: Array<RadioSelectItem<IdeIntegrationNudgeResult>> = [
|
||||
{
|
||||
label: 'Yes',
|
||||
value: 'yes',
|
||||
},
|
||||
{
|
||||
label: 'No (esc)',
|
||||
value: 'no',
|
||||
},
|
||||
{
|
||||
label: "No, don't ask again",
|
||||
value: 'dismiss',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="yellow"
|
||||
padding={1}
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
>
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text>
|
||||
<Text color="yellow">{'> '}</Text>
|
||||
{`Do you want to connect your ${ideName ?? 'your'} editor to Gemini CLI?`}
|
||||
</Text>
|
||||
<Text
|
||||
dimColor
|
||||
>{`If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ideName ?? 'your editor'}.`}</Text>
|
||||
</Box>
|
||||
<RadioButtonSelect
|
||||
items={OPTIONS}
|
||||
onSelect={onComplete}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,22 @@ exports[`App UI > should render correctly with the prompt input box 1`] = `
|
||||
`;
|
||||
|
||||
exports[`App UI > should render the initial UI correctly 1`] = `
|
||||
"
|
||||
I'm Feeling Lucky (esc to cancel, 0s)
|
||||
" I'm Feeling Lucky (esc to cancel, 0s)
|
||||
|
||||
|
||||
/test/dir no sandbox (see /docs) model (100% context left)"
|
||||
`;
|
||||
|
||||
exports[`App UI > when in a narrow terminal > should render with a column layout 1`] = `
|
||||
"
|
||||
|
||||
|
||||
╭────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
dir
|
||||
|
||||
no sandbox (see /docs)
|
||||
|
||||
model (100% context left)| ✖ 5 errors (ctrl+o for details)"
|
||||
`;
|
||||
|
||||
@@ -168,8 +168,12 @@ 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 () => {
|
||||
@@ -191,7 +195,7 @@ describe('chatCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should save the conversation', async () => {
|
||||
it('should save the conversation if checkpoint does not exist', async () => {
|
||||
const history: HistoryItemWithoutId[] = [
|
||||
{
|
||||
type: 'user',
|
||||
@@ -199,8 +203,52 @@ describe('chatCommand', () => {
|
||||
},
|
||||
];
|
||||
mockGetHistory.mockReturnValue(history);
|
||||
mockCheckpointExists.mockResolvedValue(false);
|
||||
|
||||
const result = await saveCommand?.action?.(mockContext, tag);
|
||||
|
||||
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
|
||||
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation checkpoint saved with tag: ${tag}.`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return confirm_action if checkpoint already exists', async () => {
|
||||
mockCheckpointExists.mockResolvedValue(true);
|
||||
mockContext.invocation = {
|
||||
raw: `/chat save ${tag}`,
|
||||
name: 'save',
|
||||
args: tag,
|
||||
};
|
||||
|
||||
const result = await saveCommand?.action?.(mockContext, tag);
|
||||
|
||||
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
|
||||
expect(mockSaveCheckpoint).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
type: 'confirm_action',
|
||||
originalInvocation: { raw: `/chat save ${tag}` },
|
||||
});
|
||||
// Check that prompt is a React element
|
||||
expect(result).toHaveProperty('prompt');
|
||||
});
|
||||
|
||||
it('should save the conversation if overwrite is confirmed', async () => {
|
||||
const history: HistoryItemWithoutId[] = [
|
||||
{
|
||||
type: 'user',
|
||||
text: 'hello',
|
||||
},
|
||||
];
|
||||
mockGetHistory.mockReturnValue(history);
|
||||
mockContext.overwriteConfirmed = true;
|
||||
|
||||
const result = await saveCommand?.action?.(mockContext, tag);
|
||||
|
||||
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
|
||||
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
*/
|
||||
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
MessageActionReturn,
|
||||
CommandKind,
|
||||
SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import path from 'path';
|
||||
import { HistoryItemWithoutId, MessageType } from '../types.js';
|
||||
@@ -96,7 +100,7 @@ const saveCommand: SlashCommand = {
|
||||
description:
|
||||
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args): Promise<MessageActionReturn> => {
|
||||
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
|
||||
const tag = args.trim();
|
||||
if (!tag) {
|
||||
return {
|
||||
@@ -108,6 +112,26 @@ const saveCommand: SlashCommand = {
|
||||
|
||||
const { logger, config } = context.services;
|
||||
await logger.initialize();
|
||||
|
||||
if (!context.overwriteConfirmed) {
|
||||
const exists = await logger.checkpointExists(tag);
|
||||
if (exists) {
|
||||
return {
|
||||
type: 'confirm_action',
|
||||
prompt: React.createElement(
|
||||
Text,
|
||||
null,
|
||||
'A checkpoint with the tag ',
|
||||
React.createElement(Text, { color: Colors.AccentPurple }, tag),
|
||||
' already exists. Do you want to overwrite it?',
|
||||
),
|
||||
originalInvocation: {
|
||||
raw: context.invocation?.raw || `/chat save ${tag}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const chat = await config?.getGeminiClient()?.getChat();
|
||||
if (!chat) {
|
||||
return {
|
||||
|
||||
@@ -40,11 +40,24 @@ 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(),
|
||||
|
||||
@@ -8,6 +8,7 @@ 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) {
|
||||
@@ -16,7 +17,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.startsWith('~')) {
|
||||
} else if (p === '~' || p.startsWith('~/')) {
|
||||
expandedPath = os.homedir() + p.substring(1);
|
||||
}
|
||||
return path.normalize(expandedPath);
|
||||
@@ -90,6 +91,37 @@ 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) {
|
||||
|
||||
@@ -42,9 +42,15 @@ describe('ideCommand', () => {
|
||||
mockConfig = {
|
||||
getIdeModeFeature: vi.fn(),
|
||||
getIdeMode: vi.fn(),
|
||||
getIdeClient: vi.fn(),
|
||||
getIdeClient: vi.fn(() => ({
|
||||
reconnect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getCurrentIde: vi.fn(),
|
||||
getDetectedIdeDisplayName: vi.fn(),
|
||||
getConnectionStatus: vi.fn(),
|
||||
})),
|
||||
setIdeModeAndSyncConnection: vi.fn(),
|
||||
setIdeMode: vi.fn(),
|
||||
setIdeClientDisconnected: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get');
|
||||
@@ -87,13 +93,14 @@ describe('ideCommand', () => {
|
||||
} as unknown as ReturnType<Config['getIdeClient']>);
|
||||
});
|
||||
|
||||
it('should show connected status', () => {
|
||||
it('should show connected status', async () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Connected,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
||||
.action!(mockContext, '');
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
@@ -102,13 +109,14 @@ describe('ideCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show connecting status', () => {
|
||||
it('should show connecting status', async () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Connecting,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
||||
.action!(mockContext, '');
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
@@ -116,13 +124,14 @@ describe('ideCommand', () => {
|
||||
content: `🟡 Connecting...`,
|
||||
});
|
||||
});
|
||||
it('should show disconnected status', () => {
|
||||
it('should show disconnected status', async () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Disconnected,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
||||
.action!(mockContext, '');
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
@@ -131,15 +140,16 @@ describe('ideCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show disconnected status with details', () => {
|
||||
it('should show disconnected status with details', async () => {
|
||||
const details = 'Something went wrong';
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Disconnected,
|
||||
details,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
||||
.action!(mockContext, '');
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
|
||||
@@ -7,10 +7,15 @@
|
||||
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,
|
||||
@@ -19,6 +24,97 @@ 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;
|
||||
@@ -54,33 +150,14 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
name: 'status',
|
||||
description: 'check status of IDE integration',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
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;
|
||||
}
|
||||
}
|
||||
action: async (): Promise<SlashCommandActionReturn> => {
|
||||
const { messageType, content } =
|
||||
await getIdeStatusMessageWithFiles(ideClient);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType,
|
||||
content,
|
||||
} as const;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -94,7 +171,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 IDE companion manually from its marketplace.`,
|
||||
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -110,6 +187,10 @@ 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',
|
||||
@@ -126,8 +207,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
|
||||
config.setIdeMode(true);
|
||||
config.setIdeClientConnected();
|
||||
await config.setIdeModeAndSyncConnection(true);
|
||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: messageType,
|
||||
text: content,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -137,8 +225,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
|
||||
config.setIdeMode(false);
|
||||
config.setIdeClientDisconnected();
|
||||
await config.setIdeModeAndSyncConnection(false);
|
||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: messageType,
|
||||
text: content,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -11,16 +11,31 @@ import { initCommand } from './initCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
|
||||
// Mock the 'fs' module
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
// 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');
|
||||
});
|
||||
|
||||
describe('initCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const targetDir = '/test/dir';
|
||||
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
|
||||
const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
|
||||
const geminiMdPath = path.join(targetDir, DEFAULT_CONTEXT_FILENAME);
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh mock context for each test
|
||||
@@ -38,9 +53,10 @@ describe('initCommand', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should inform the user if GEMINI.md already exists', async () => {
|
||||
it(`should inform the user if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, 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, '');
|
||||
@@ -49,14 +65,13 @@ describe('initCommand', () => {
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'A GEMINI.md file already exists in this directory. No changes were made.',
|
||||
content: `A ${DEFAULT_CONTEXT_FILENAME} file already exists in this directory. No changes were made.`,
|
||||
});
|
||||
// Assert: Ensure no file was written
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create GEMINI.md and submit a prompt if it does not exist', async () => {
|
||||
it(`should create ${DEFAULT_CONTEXT_FILENAME} and submit a prompt if it does not exist`, async () => {
|
||||
// Arrange: Simulate that the file does not exist
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
@@ -70,7 +85,7 @@ describe('initCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
|
||||
text: `Empty ${DEFAULT_CONTEXT_FILENAME} created. Now analyzing the project to populate it.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -78,10 +93,20 @@ describe('initCommand', () => {
|
||||
// Assert: Check that the correct prompt is submitted
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
expect(result.content).toContain(
|
||||
'You are an AI agent that brings the power of Gemini',
|
||||
'You are Qwen Code, an interactive CLI agent',
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -12,10 +12,11 @@ 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 GEMINI.md file.',
|
||||
description: 'Analyzes the project and creates a tailored QWEN.md file.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
@@ -29,32 +30,55 @@ export const initCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
const targetDir = context.services.config.getTargetDir();
|
||||
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
|
||||
const contextFileName = getCurrentGeminiMdFilename();
|
||||
const contextFilePath = path.join(targetDir, contextFileName);
|
||||
|
||||
if (fs.existsSync(geminiMdPath)) {
|
||||
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) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'A GEMINI.md file already exists in this directory. No changes were made.',
|
||||
messageType: 'error',
|
||||
content: `Unexpected error preparing ${contextFileName}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 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 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.
|
||||
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.
|
||||
|
||||
**Analysis Process:**
|
||||
|
||||
@@ -70,7 +94,7 @@ You are an AI agent that brings the power of Gemini directly into the terminal.
|
||||
* **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.
|
||||
|
||||
**GEMINI.md Content Generation:**
|
||||
**${contextFileName} Content Generation:**
|
||||
|
||||
**For a Code Project:**
|
||||
|
||||
@@ -86,7 +110,7 @@ You are an AI agent that brings the power of Gemini directly into the terminal.
|
||||
|
||||
**Final Output:**
|
||||
|
||||
Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown.
|
||||
Write the complete content to the \`${contextFileName}\` file. The output must be well-formatted Markdown.
|
||||
`,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -161,6 +161,10 @@ describe('memoryCommand', () => {
|
||||
getDebugMode: () => false,
|
||||
getFileService: () => ({}) as FileDiscoveryService,
|
||||
getExtensionContextFilePaths: () => [],
|
||||
shouldLoadMemoryFromIncludeDirectories: () => false,
|
||||
getWorkspaceContext: () => ({
|
||||
getDirectories: () => [],
|
||||
}),
|
||||
getFileFilteringOptions: () => ({
|
||||
ignore: [],
|
||||
include: [],
|
||||
|
||||
@@ -89,6 +89,9 @@ export const memoryCommand: SlashCommand = {
|
||||
const { memoryContent, fileCount } =
|
||||
await loadServerHierarchicalMemory(
|
||||
config.getWorkingDir(),
|
||||
config.shouldLoadMemoryFromIncludeDirectories()
|
||||
? config.getWorkspaceContext().getDirectories()
|
||||
: [],
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
config.getExtensionContextFilePaths(),
|
||||
|
||||
36
packages/cli/src/ui/commands/settingsCommand.test.ts
Normal file
36
packages/cli/src/ui/commands/settingsCommand.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { settingsCommand } from './settingsCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('settingsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should return a dialog action to open the settings dialog', () => {
|
||||
if (!settingsCommand.action) {
|
||||
throw new Error('The settings command must have an action.');
|
||||
}
|
||||
const result = settingsCommand.action(mockContext, '');
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'settings',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(settingsCommand.name).toBe('settings');
|
||||
expect(settingsCommand.description).toBe(
|
||||
'View and edit Gemini CLI settings',
|
||||
);
|
||||
});
|
||||
});
|
||||
17
packages/cli/src/ui/commands/settingsCommand.ts
Normal file
17
packages/cli/src/ui/commands/settingsCommand.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
|
||||
export const settingsCommand: SlashCommand = {
|
||||
name: 'settings',
|
||||
description: 'View and edit Gemini CLI settings',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'settings',
|
||||
}),
|
||||
};
|
||||
@@ -4,63 +4,103 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest';
|
||||
import * as child_process from 'child_process';
|
||||
import * as gitUtils from '../../utils/gitUtils.js';
|
||||
import { setupGithubCommand } from './setupGithubCommand.js';
|
||||
import { CommandContext, ToolActionReturn } from './types.js';
|
||||
import * as commandUtils from '../utils/commandUtils.js';
|
||||
|
||||
vi.mock('child_process');
|
||||
|
||||
describe('setupGithubCommand', () => {
|
||||
beforeEach(() => {
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn();
|
||||
|
||||
vi.mock('../../utils/gitUtils.js', () => ({
|
||||
isGitHubRepository: vi.fn(),
|
||||
getGitRepoRoot: vi.fn(),
|
||||
getLatestGitHubRelease: vi.fn(),
|
||||
getGitHubRepoInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/commandUtils.js', () => ({
|
||||
getUrlOpenCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('setupGithubCommand', async () => {
|
||||
let scratchDir = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
scratchDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'setup-github-command-'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (scratchDir) await fs.rm(scratchDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('returns a tool action to download github workflows and handles paths', () => {
|
||||
const fakeRepoRoot = '/github.com/fake/repo/root';
|
||||
vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot);
|
||||
it('returns a tool action to download github workflows and handles paths', async () => {
|
||||
const fakeRepoOwner = 'fake';
|
||||
const fakeRepoName = 'repo';
|
||||
const fakeRepoRoot = scratchDir;
|
||||
const fakeReleaseVersion = 'v1.2.3';
|
||||
|
||||
const result = setupGithubCommand.action?.(
|
||||
const workflows = [
|
||||
'gemini-cli.yml',
|
||||
'gemini-issue-automated-triage.yml',
|
||||
'gemini-issue-scheduled-triage.yml',
|
||||
'gemini-pr-review.yml',
|
||||
];
|
||||
for (const workflow of workflows) {
|
||||
vi.mocked(global.fetch).mockReturnValueOnce(
|
||||
Promise.resolve(new Response(workflow)),
|
||||
);
|
||||
}
|
||||
|
||||
vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true);
|
||||
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
|
||||
vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce(
|
||||
fakeReleaseVersion,
|
||||
);
|
||||
vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({
|
||||
owner: fakeRepoOwner,
|
||||
repo: fakeRepoName,
|
||||
});
|
||||
vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValueOnce(
|
||||
'fakeOpenCommand',
|
||||
);
|
||||
|
||||
const result = (await setupGithubCommand.action?.(
|
||||
{} as CommandContext,
|
||||
'',
|
||||
) as ToolActionReturn;
|
||||
|
||||
expect(result.type).toBe('tool');
|
||||
expect(result.toolName).toBe('run_shell_command');
|
||||
expect(child_process.execSync).toHaveBeenCalledWith(
|
||||
'git rev-parse --show-toplevel',
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
);
|
||||
expect(child_process.execSync).toHaveBeenCalledWith('git remote -v', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
)) as ToolActionReturn;
|
||||
|
||||
const { command } = result.toolArgs;
|
||||
|
||||
const expectedSubstrings = [
|
||||
`mkdir -p "${fakeRepoRoot}/.github/workflows"`,
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-cli.yml"`,
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`,
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`,
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`,
|
||||
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/v0/examples/workflows/',
|
||||
`set -eEuo pipefail`,
|
||||
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
|
||||
];
|
||||
|
||||
for (const substring of expectedSubstrings) {
|
||||
expect(command).toContain(substring);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws an error if git root cannot be determined', () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValue('');
|
||||
expect(() => {
|
||||
setupGithubCommand.action?.({} as CommandContext, '');
|
||||
}).toThrow('Unable to determine the Git root directory.');
|
||||
for (const workflow of workflows) {
|
||||
const workflowFile = path.join(
|
||||
scratchDir,
|
||||
'.github',
|
||||
'workflows',
|
||||
workflow,
|
||||
);
|
||||
const contents = await fs.readFile(workflowFile, 'utf8');
|
||||
expect(contents).toContain(workflow);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,32 +4,93 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { isGitHubRepository } from '../../utils/gitUtils.js';
|
||||
import path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { Writable } from 'node:stream';
|
||||
import { ProxyAgent } from 'undici';
|
||||
|
||||
import { CommandContext } from '../../ui/commands/types.js';
|
||||
import {
|
||||
getGitRepoRoot,
|
||||
getLatestGitHubRelease,
|
||||
isGitHubRepository,
|
||||
getGitHubRepoInfo,
|
||||
} from '../../utils/gitUtils.js';
|
||||
|
||||
import {
|
||||
CommandKind,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
||||
|
||||
// Generate OS-specific commands to open the GitHub pages needed for setup.
|
||||
function getOpenUrlsCommands(readmeUrl: string): string[] {
|
||||
// Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc
|
||||
const openCmd = getUrlOpenCommand();
|
||||
|
||||
// Build a list of URLs to open
|
||||
const urlsToOpen = [readmeUrl];
|
||||
|
||||
const repoInfo = getGitHubRepoInfo();
|
||||
if (repoInfo) {
|
||||
urlsToOpen.push(
|
||||
`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create and join the individual commands
|
||||
const commands = urlsToOpen.map((url) => `${openCmd} "${url}"`);
|
||||
return commands;
|
||||
}
|
||||
|
||||
export const setupGithubCommand: SlashCommand = {
|
||||
name: 'setup-github',
|
||||
description: 'Set up GitHub Actions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): SlashCommandActionReturn => {
|
||||
const gitRootRepo = execSync('git rev-parse --show-toplevel', {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
if (!isGitHubRepository()) {
|
||||
throw new Error('Unable to determine the Git root directory.');
|
||||
throw new Error(
|
||||
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
|
||||
);
|
||||
}
|
||||
|
||||
const version = 'v0';
|
||||
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/examples/workflows/`;
|
||||
// Find the root directory of the repo
|
||||
let gitRepoRoot: string;
|
||||
try {
|
||||
gitRepoRoot = getGitRepoRoot();
|
||||
} catch (_error) {
|
||||
console.debug(`Failed to get git repo root:`, _error);
|
||||
throw new Error(
|
||||
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
|
||||
);
|
||||
}
|
||||
|
||||
// Get the latest release tag from GitHub
|
||||
const proxy = context?.services?.config?.getProxy();
|
||||
const releaseTag = await getLatestGitHubRelease(proxy);
|
||||
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
|
||||
|
||||
// Create the .github/workflows directory to download the files into
|
||||
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
|
||||
try {
|
||||
await fs.promises.mkdir(githubWorkflowsDir, { recursive: true });
|
||||
} catch (_error) {
|
||||
console.debug(
|
||||
`Failed to create ${githubWorkflowsDir} directory:`,
|
||||
_error,
|
||||
);
|
||||
throw new Error(
|
||||
`Unable to create ${githubWorkflowsDir} directory. Do you have file permissions in the current directory?`,
|
||||
);
|
||||
}
|
||||
|
||||
// Download each workflow in parallel - there aren't enough files to warrant
|
||||
// a full workerpool model here.
|
||||
const workflows = [
|
||||
'gemini-cli/gemini-cli.yml',
|
||||
'issue-triage/gemini-issue-automated-triage.yml',
|
||||
@@ -37,15 +98,63 @@ export const setupGithubCommand: SlashCommand = {
|
||||
'pr-review/gemini-pr-review.yml',
|
||||
];
|
||||
|
||||
const command = [
|
||||
'set -e',
|
||||
`mkdir -p "${gitRootRepo}/.github/workflows"`,
|
||||
...workflows.map((workflow) => {
|
||||
const fileName = path.basename(workflow);
|
||||
return `curl -fsSL -o "${gitRootRepo}/.github/workflows/${fileName}" "${workflowBaseUrl}/${workflow}"`;
|
||||
}),
|
||||
'echo "Workflows downloaded successfully."',
|
||||
].join(' && ');
|
||||
const downloads = [];
|
||||
for (const workflow of workflows) {
|
||||
downloads.push(
|
||||
(async () => {
|
||||
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
signal: AbortSignal.any([
|
||||
AbortSignal.timeout(30_000),
|
||||
abortController.signal,
|
||||
]),
|
||||
} as RequestInit);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
throw new Error(
|
||||
`Empty body while downloading ${endpoint}: ${response.status} - ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const destination = path.resolve(
|
||||
githubWorkflowsDir,
|
||||
path.basename(workflow),
|
||||
);
|
||||
|
||||
const fileStream = fs.createWriteStream(destination, {
|
||||
mode: 0o644, // -rw-r--r--, user(rw), group(r), other(r)
|
||||
flags: 'w', // write and overwrite
|
||||
flush: true,
|
||||
});
|
||||
|
||||
await body.pipeTo(Writable.toWeb(fileStream));
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for all downloads to complete
|
||||
await Promise.all(downloads).finally(() => {
|
||||
// Stop existing downloads
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
// Print out a message
|
||||
const commands = [];
|
||||
commands.push('set -eEuo pipefail');
|
||||
commands.push(
|
||||
`echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
|
||||
);
|
||||
commands.push(...getOpenUrlsCommands(readmeUrl));
|
||||
|
||||
const command = `(${commands.join(' && ')})`;
|
||||
return {
|
||||
type: 'tool',
|
||||
toolName: 'run_shell_command',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
import { Content } from '@google/genai';
|
||||
import { HistoryItemWithoutId } from '../types.js';
|
||||
import { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
|
||||
@@ -59,6 +60,7 @@ export interface CommandContext {
|
||||
/** Toggles a special display mode. */
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
setGeminiMdFileCount: (count: number) => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
@@ -67,6 +69,8 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +103,8 @@ export interface MessageActionReturn {
|
||||
*/
|
||||
export interface OpenDialogActionReturn {
|
||||
type: 'dialog';
|
||||
dialog: 'auth' | 'theme' | 'editor' | 'privacy';
|
||||
|
||||
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,6 +140,16 @@ export interface ConfirmShellCommandsActionReturn {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConfirmActionReturn {
|
||||
type: 'confirm_action';
|
||||
/** The React node to display as the confirmation prompt. */
|
||||
prompt: ReactNode;
|
||||
/** The original invocation context to be re-run after confirmation. */
|
||||
originalInvocation: {
|
||||
raw: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type SlashCommandActionReturn =
|
||||
| ToolActionReturn
|
||||
| MessageActionReturn
|
||||
@@ -142,7 +157,8 @@ export type SlashCommandActionReturn =
|
||||
| OpenDialogActionReturn
|
||||
| LoadHistoryActionReturn
|
||||
| SubmitPromptActionReturn
|
||||
| ConfirmShellCommandsActionReturn;
|
||||
| ConfirmShellCommandsActionReturn
|
||||
| ConfirmActionReturn;
|
||||
|
||||
export enum CommandKind {
|
||||
BUILT_IN = 'built-in',
|
||||
|
||||
@@ -20,3 +20,14 @@ export const longAsciiLogo = `
|
||||
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogo = `
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
`;
|
||||
|
||||
@@ -18,8 +18,8 @@ export function AuthInProgress({
|
||||
}: AuthInProgressProps): React.JSX.Element {
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
useInput((input, key) => {
|
||||
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
|
||||
onTimeout();
|
||||
}
|
||||
});
|
||||
@@ -48,7 +48,8 @@ export function AuthInProgress({
|
||||
) : (
|
||||
<Box>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for auth... (Press ESC to cancel)
|
||||
<Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
|
||||
cancel)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(),
|
||||
}));
|
||||
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
const renderWithWidth = (
|
||||
width: number,
|
||||
props: React.ComponentProps<typeof ContextSummaryDisplay>,
|
||||
) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||
return render(<ContextSummaryDisplay {...props} />);
|
||||
};
|
||||
|
||||
describe('<ContextSummaryDisplay />', () => {
|
||||
const baseProps = {
|
||||
geminiMdFileCount: 1,
|
||||
contextFileNames: ['GEMINI.md'],
|
||||
mcpServers: { 'test-server': { command: 'test' } },
|
||||
showToolDescriptions: false,
|
||||
ideContext: {
|
||||
workspaceState: {
|
||||
openFiles: [{ path: '/a/b/c' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('should render on a single line on a wide screen', () => {
|
||||
const { lastFrame } = renderWithWidth(120, baseProps);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(
|
||||
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file | 1 MCP server (ctrl+t to view)',
|
||||
);
|
||||
// Check for absence of newlines
|
||||
expect(output.includes('\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('should render on multiple lines on a narrow screen', () => {
|
||||
const { lastFrame } = renderWithWidth(60, baseProps);
|
||||
const output = lastFrame();
|
||||
const expectedLines = [
|
||||
'Using:',
|
||||
' - 1 open file (ctrl+e to view)',
|
||||
' - 1 GEMINI.md file',
|
||||
' - 1 MCP server (ctrl+t to view)',
|
||||
];
|
||||
const actualLines = output.split('\n');
|
||||
expect(actualLines).toEqual(expectedLines);
|
||||
});
|
||||
|
||||
it('should switch layout at the 80-column breakpoint', () => {
|
||||
// At 80 columns, should be on one line
|
||||
const { lastFrame: wideFrame } = renderWithWidth(80, baseProps);
|
||||
expect(wideFrame().includes('\n')).toBe(false);
|
||||
|
||||
// At 79 columns, should be on multiple lines
|
||||
const { lastFrame: narrowFrame } = renderWithWidth(79, baseProps);
|
||||
expect(narrowFrame().includes('\n')).toBe(true);
|
||||
expect(narrowFrame().split('\n').length).toBe(4);
|
||||
});
|
||||
|
||||
it('should not render empty parts', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
geminiMdFileCount: 0,
|
||||
mcpServers: {},
|
||||
};
|
||||
const { lastFrame } = renderWithWidth(60, props);
|
||||
const expectedLines = ['Using:', ' - 1 open file (ctrl+e to view)'];
|
||||
const actualLines = lastFrame().split('\n');
|
||||
expect(actualLines).toEqual(expectedLines);
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,14 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
type IdeContext,
|
||||
type MCPServerConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
interface ContextSummaryDisplayProps {
|
||||
geminiMdFileCount: number;
|
||||
@@ -29,6 +31,8 @@ export const ContextSummaryDisplay: React.FC<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;
|
||||
@@ -81,30 +85,36 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
}
|
||||
parts.push(blockedText);
|
||||
}
|
||||
return parts.join(', ');
|
||||
let text = parts.join(', ');
|
||||
// Add ctrl+t hint when MCP servers are available
|
||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||
if (showToolDescriptions) {
|
||||
text += ' (ctrl+t to toggle)';
|
||||
} else {
|
||||
text += ' (ctrl+t to view)';
|
||||
}
|
||||
}
|
||||
return text;
|
||||
})();
|
||||
|
||||
let summaryText = 'Using: ';
|
||||
const summaryParts = [];
|
||||
if (openFilesText) {
|
||||
summaryParts.push(openFilesText);
|
||||
}
|
||||
if (geminiMdText) {
|
||||
summaryParts.push(geminiMdText);
|
||||
}
|
||||
if (mcpText) {
|
||||
summaryParts.push(mcpText);
|
||||
}
|
||||
summaryText += summaryParts.join(' | ');
|
||||
const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean);
|
||||
|
||||
// Add ctrl+t hint when MCP servers are available
|
||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||
if (showToolDescriptions) {
|
||||
summaryText += ' (ctrl+t to toggle)';
|
||||
} else {
|
||||
summaryText += ' (ctrl+t to view)';
|
||||
}
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={Colors.Gray}>Using:</Text>
|
||||
{summaryParts.map((part, index) => (
|
||||
<Text key={index} color={Colors.Gray}>
|
||||
{' '}- {part}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text color={Colors.Gray}>{summaryText}</Text>;
|
||||
return (
|
||||
<Box>
|
||||
<Text color={Colors.Gray}>Using: {summaryParts.join(' | ')}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
25
packages/cli/src/ui/components/ContextUsageDisplay.tsx
Normal file
25
packages/cli/src/ui/components/ContextUsageDisplay.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { tokenLimit } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const ContextUsageDisplay = ({
|
||||
promptTokenCount,
|
||||
model,
|
||||
}: {
|
||||
promptTokenCount: number;
|
||||
model: string;
|
||||
}) => {
|
||||
const percentage = promptTokenCount / tokenLimit(model);
|
||||
|
||||
return (
|
||||
<Text color={Colors.Gray}>
|
||||
({((1 - percentage) * 100).toFixed(0)}% context left)
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
29
packages/cli/src/ui/components/FolderTrustDialog.test.tsx
Normal file
29
packages/cli/src/ui/components/FolderTrustDialog.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { vi } from 'vitest';
|
||||
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
|
||||
|
||||
describe('FolderTrustDialog', () => {
|
||||
it('should render the dialog with title and description', () => {
|
||||
const { lastFrame } = render(<FolderTrustDialog onSelect={vi.fn()} />);
|
||||
|
||||
expect(lastFrame()).toContain('Do you trust this folder?');
|
||||
expect(lastFrame()).toContain(
|
||||
'Trusting a folder allows Gemini to execute commands it suggests.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onSelect with DO_NOT_TRUST when escape is pressed', () => {
|
||||
const onSelect = vi.fn();
|
||||
const { stdin } = render(<FolderTrustDialog onSelect={onSelect} />);
|
||||
|
||||
stdin.write('\u001B'); // Simulate escape key
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
|
||||
});
|
||||
});
|
||||
70
packages/cli/src/ui/components/FolderTrustDialog.tsx
Normal file
70
packages/cli/src/ui/components/FolderTrustDialog.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import React from 'react';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
|
||||
export enum FolderTrustChoice {
|
||||
TRUST_FOLDER = 'trust_folder',
|
||||
TRUST_PARENT = 'trust_parent',
|
||||
DO_NOT_TRUST = 'do_not_trust',
|
||||
}
|
||||
|
||||
interface FolderTrustDialogProps {
|
||||
onSelect: (choice: FolderTrustChoice) => void;
|
||||
}
|
||||
|
||||
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onSelect(FolderTrustChoice.DO_NOT_TRUST);
|
||||
}
|
||||
});
|
||||
|
||||
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
|
||||
{
|
||||
label: 'Trust folder',
|
||||
value: FolderTrustChoice.TRUST_FOLDER,
|
||||
},
|
||||
{
|
||||
label: 'Trust parent folder',
|
||||
value: FolderTrustChoice.TRUST_PARENT,
|
||||
},
|
||||
{
|
||||
label: "Don't trust (esc)",
|
||||
value: FolderTrustChoice.DO_NOT_TRUST,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
padding={1}
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold>Do you trust this folder?</Text>
|
||||
<Text>
|
||||
Trusting a folder allows Gemini to execute commands it suggests. This
|
||||
is a security feature to prevent accidental execution in untrusted
|
||||
directories.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
106
packages/cli/src/ui/components/Footer.test.tsx
Normal file
106
packages/cli/src/ui/components/Footer.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Footer } from './Footer.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||
import path from 'node:path';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
shortenPath: (p: string, len: number) => {
|
||||
if (p.length > len) {
|
||||
return '...' + p.slice(p.length - len + 3);
|
||||
}
|
||||
return p;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
model: 'gemini-pro',
|
||||
targetDir:
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
|
||||
branchName: 'main',
|
||||
debugMode: false,
|
||||
debugMessage: '',
|
||||
corgiMode: false,
|
||||
errorCount: 0,
|
||||
showErrorDetails: false,
|
||||
showMemoryUsage: false,
|
||||
promptTokenCount: 100,
|
||||
nightly: false,
|
||||
};
|
||||
|
||||
const renderWithWidth = (width: number, props = defaultProps) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||
return render(<Footer {...props} />);
|
||||
};
|
||||
|
||||
describe('<Footer />', () => {
|
||||
it('renders the component', () => {
|
||||
const { lastFrame } = renderWithWidth(120);
|
||||
expect(lastFrame()).toBeDefined();
|
||||
});
|
||||
|
||||
describe('path display', () => {
|
||||
it('should display shortened path on a wide terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(120);
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const expectedPath = '...' + tildePath.slice(tildePath.length - 48 + 3);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
});
|
||||
|
||||
it('should display only the base directory name on a narrow terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(79);
|
||||
const expectedPath = path.basename(defaultProps.targetDir);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
});
|
||||
|
||||
it('should use wide layout at 80 columns', () => {
|
||||
const { lastFrame } = renderWithWidth(80);
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const expectedPath = '...' + tildePath.slice(tildePath.length - 32 + 3);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
});
|
||||
|
||||
it('should use narrow layout at 79 columns', () => {
|
||||
const { lastFrame } = renderWithWidth(79);
|
||||
const expectedPath = path.basename(defaultProps.targetDir);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const unexpectedPath = '...' + tildePath.slice(tildePath.length - 31 + 3);
|
||||
expect(lastFrame()).not.toContain(unexpectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays the branch name when provided', () => {
|
||||
const { lastFrame } = renderWithWidth(120);
|
||||
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
|
||||
});
|
||||
|
||||
it('does not display the branch name when not provided', () => {
|
||||
const { lastFrame } = renderWithWidth(120, {
|
||||
...defaultProps,
|
||||
branchName: undefined,
|
||||
});
|
||||
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
|
||||
});
|
||||
|
||||
it('displays the model name and context percentage', () => {
|
||||
const { lastFrame } = renderWithWidth(120);
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
|
||||
});
|
||||
});
|
||||
@@ -6,19 +6,19 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
shortenPath,
|
||||
tildeifyPath,
|
||||
tokenLimit,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { shortenPath, tildeifyPath } 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';
|
||||
|
||||
interface FooterProps {
|
||||
model: string;
|
||||
targetDir: string;
|
||||
@@ -48,29 +48,43 @@ export const Footer: React.FC<FooterProps> = ({
|
||||
nightly,
|
||||
vimMode,
|
||||
}) => {
|
||||
const limit = tokenLimit(model);
|
||||
const percentage = promptTokenCount / limit;
|
||||
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);
|
||||
|
||||
return (
|
||||
<Box justifyContent="space-between" width="100%">
|
||||
<Box
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box>
|
||||
{debugMode && <DebugProfiler />}
|
||||
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
|
||||
{vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
|
||||
{nightly ? (
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>
|
||||
{shortenPath(tildeifyPath(targetDir), 70)}
|
||||
{displayPath}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text color={Colors.LightBlue}>
|
||||
{shortenPath(tildeifyPath(targetDir), 70)}
|
||||
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
|
||||
<Text color={theme.text.link}>
|
||||
{displayPath}
|
||||
{branchName && (
|
||||
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{debugMode && (
|
||||
<Text color={Colors.AccentRed}>
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
@@ -78,49 +92,54 @@ export const Footer: React.FC<FooterProps> = ({
|
||||
|
||||
{/* Middle Section: Centered Sandbox Info */}
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexGrow={isNarrow ? 0 : 1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
justifyContent={isNarrow ? 'flex-start' : '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={Colors.AccentYellow}>
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
({process.env.SEATBELT_PROFILE})
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={Colors.AccentRed}>
|
||||
no sandbox <Text color={Colors.Gray}>(see /docs)</Text>
|
||||
<Text color={theme.status.error}>
|
||||
no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right Section: Gemini Label and Console Summary */}
|
||||
<Box alignItems="center">
|
||||
<Text color={Colors.AccentBlue}>
|
||||
{' '}
|
||||
<Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
|
||||
<Text color={theme.text.accent}>
|
||||
{isNarrow ? '' : ' '}
|
||||
{model}{' '}
|
||||
<Text color={Colors.Gray}>
|
||||
({((1 - percentage) * 100).toFixed(0)}% context left)
|
||||
</Text>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
/>
|
||||
</Text>
|
||||
{corgiMode && (
|
||||
<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 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>
|
||||
)}
|
||||
{!showErrorDetails && errorCount > 0 && (
|
||||
<Box>
|
||||
<Text color={Colors.Gray}>| </Text>
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
44
packages/cli/src/ui/components/Header.test.tsx
Normal file
44
packages/cli/src/ui/components/Header.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Header } from './Header.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { longAsciiLogo } from './AsciiArt.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
|
||||
describe('<Header />', () => {
|
||||
beforeEach(() => {});
|
||||
|
||||
it('renders the long logo on a wide terminal', () => {
|
||||
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||
columns: 120,
|
||||
rows: 20,
|
||||
});
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(lastFrame()).toContain(longAsciiLogo);
|
||||
});
|
||||
|
||||
it('renders custom ASCII art when provided', () => {
|
||||
const customArt = 'CUSTOM ART';
|
||||
const { lastFrame } = render(
|
||||
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
|
||||
);
|
||||
expect(lastFrame()).toContain(customArt);
|
||||
});
|
||||
|
||||
it('displays the version number when nightly is true', () => {
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={true} />);
|
||||
expect(lastFrame()).toContain('v1.0.0');
|
||||
});
|
||||
|
||||
it('does not display the version number when nightly is false', () => {
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(lastFrame()).not.toContain('v1.0.0');
|
||||
});
|
||||
});
|
||||
@@ -8,30 +8,34 @@ import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { Colors } from '../colors.js';
|
||||
import { shortAsciiLogo, longAsciiLogo } from './AsciiArt.js';
|
||||
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
import { 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 =
|
||||
terminalWidth >= widthOfLongLogo ? longAsciiLogo : shortAsciiLogo;
|
||||
displayTitle = tinyAsciiLogo;
|
||||
}
|
||||
|
||||
const artWidth = getAsciiArtWidth(displayTitle);
|
||||
@@ -52,9 +56,13 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
)}
|
||||
{nightly && (
|
||||
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
{Colors.GradientColors ? (
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Text>v{version}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>v{version}</Text>
|
||||
</Gradient>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user