mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
chore: sync gemini-cli v0.1.19
This commit is contained in:
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -36,6 +36,14 @@ jobs:
|
|||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: npm run lint:ci
|
run: npm run lint:ci
|
||||||
|
|
||||||
|
- name: Run linter on integration tests
|
||||||
|
run: npx eslint integration-tests --max-warnings 0
|
||||||
|
|
||||||
|
- name: Run formatter on integration tests
|
||||||
|
run: |
|
||||||
|
npx prettier --check integration-tests
|
||||||
|
git diff --exit-code
|
||||||
|
|
||||||
- name: Build project
|
- name: Build project
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
|||||||
219
.github/workflows/gemini-automated-issue-triage.yml
vendored
219
.github/workflows/gemini-automated-issue-triage.yml
vendored
@@ -2,7 +2,33 @@ name: Qwen Automated Issue Triage
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened, reopened]
|
types:
|
||||||
|
- 'opened'
|
||||||
|
- 'reopened'
|
||||||
|
issue_comment:
|
||||||
|
types:
|
||||||
|
- 'created'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
issue_number:
|
||||||
|
description: 'issue number to triage'
|
||||||
|
required: true
|
||||||
|
type: 'number'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: '${{ github.workflow }}-${{ github.event.issue.number }}'
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: 'bash'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: 'read'
|
||||||
|
id-token: 'write'
|
||||||
|
issues: 'write'
|
||||||
|
statuses: 'write'
|
||||||
|
packages: 'read'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
triage-issue:
|
triage-issue:
|
||||||
@@ -28,30 +54,39 @@ jobs:
|
|||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
settings_json: |
|
settings_json: |
|
||||||
{
|
{
|
||||||
|
"maxSessionTurns": 25,
|
||||||
"coreTools": [
|
"coreTools": [
|
||||||
|
"run_shell_command(echo)",
|
||||||
"run_shell_command(gh label list)",
|
"run_shell_command(gh label list)",
|
||||||
"run_shell_command(gh issue edit)",
|
"run_shell_command(gh issue edit)",
|
||||||
"run_shell_command(gh issue list)"
|
"run_shell_command(gh issue list)"
|
||||||
],
|
],
|
||||||
"sandbox": false
|
"sandbox": false
|
||||||
}
|
}
|
||||||
prompt: |
|
prompt: |-
|
||||||
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue.
|
## Role
|
||||||
Steps:
|
|
||||||
|
You are an issue triage assistant. Analyze the current GitHub issue and apply the most appropriate existing labels. Use the available
|
||||||
|
tools to gather information; do not ask for information to be provided. Do not remove labels titled help wanted or good first issue.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
|
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
|
||||||
2. Review the issue title, body and any comments provided in the environment variables.
|
2. Review the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
|
||||||
3. Ignore any existing priorities or tags on the issue. Just report your findings.
|
3. Ignore any existing priorities or tags on the issue. Just report your findings.
|
||||||
4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
|
4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
|
||||||
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`
|
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`.
|
||||||
7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label
|
7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label.
|
||||||
8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information 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
|
9. Use Area definitions mentioned below to help you narrow down issues.
|
||||||
Guidelines:
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
- Only use labels that already exist in the repository.
|
- Only use labels that already exist in the repository.
|
||||||
- Do not add comments or modify the issue content.
|
- Do not add comments or modify the issue content.
|
||||||
- Triage only the current issue.
|
- Triage only the current issue.
|
||||||
- Apply only one area/ label
|
- Apply only one area/ label.
|
||||||
- Apply only one kind/ label
|
- Apply only one kind/ label.
|
||||||
- Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
|
- Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
|
||||||
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
|
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
|
||||||
Categorization Guidelines:
|
Categorization Guidelines:
|
||||||
@@ -130,3 +165,163 @@ jobs:
|
|||||||
- could also pertain to latency,
|
- could also pertain to latency,
|
||||||
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
|
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
|
||||||
- Switching models from one to the other unexpectedly.
|
- Switching models from one to the other unexpectedly.
|
||||||
|
|
||||||
|
- name: 'Post Issue Triage Failure Comment'
|
||||||
|
if: |-
|
||||||
|
${{ failure() && steps.gemini_issue_triage.outcome == 'failure' }}
|
||||||
|
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||||
|
with:
|
||||||
|
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||||
|
script: |-
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
owner: '${{ github.repository }}'.split('/')[0],
|
||||||
|
repo: '${{ github.repository }}'.split('/')[1],
|
||||||
|
issue_number: '${{ github.event.issue.number }}',
|
||||||
|
body: 'There is a problem with the Gemini CLI issue triaging. Please check the [action logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.'
|
||||||
|
})
|
||||||
|
|
||||||
|
deduplicate-issues:
|
||||||
|
if: >
|
||||||
|
github.repository == 'google-gemini/gemini-cli' &&
|
||||||
|
vars.TRIAGE_DEDUPLICATE_ISSUES != '' &&
|
||||||
|
(github.event_name == 'issues' ||
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event_name == 'issue_comment' &&
|
||||||
|
contains(github.event.comment.body, '@gemini-cli /deduplicate') &&
|
||||||
|
(github.event.comment.author_association == 'OWNER' ||
|
||||||
|
github.event.comment.author_association == 'MEMBER' ||
|
||||||
|
github.event.comment.author_association == 'COLLABORATOR')))
|
||||||
|
|
||||||
|
timeout-minutes: 20
|
||||||
|
runs-on: 'ubuntu-latest'
|
||||||
|
steps:
|
||||||
|
- name: 'Checkout repository'
|
||||||
|
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683'
|
||||||
|
|
||||||
|
- name: 'Generate GitHub App Token'
|
||||||
|
id: 'generate_token'
|
||||||
|
uses: 'actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e'
|
||||||
|
with:
|
||||||
|
app-id: '${{ secrets.APP_ID }}'
|
||||||
|
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: 'Run Gemini Issue Deduplication'
|
||||||
|
uses: 'google-github-actions/run-gemini-cli@20351b5ea2b4179431f1ae8918a246a0808f8747'
|
||||||
|
id: 'gemini_issue_deduplication'
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
|
||||||
|
ISSUE_TITLE: '${{ github.event.issue.title }}'
|
||||||
|
ISSUE_BODY: '${{ github.event.issue.body }}'
|
||||||
|
ISSUE_NUMBER: '${{ github.event.issue.number }}'
|
||||||
|
REPOSITORY: '${{ github.repository }}'
|
||||||
|
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
|
||||||
|
with:
|
||||||
|
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||||
|
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
|
||||||
|
gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}'
|
||||||
|
gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}'
|
||||||
|
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||||
|
use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}'
|
||||||
|
use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}'
|
||||||
|
settings: |-
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"issue_deduplication": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"-i",
|
||||||
|
"--rm",
|
||||||
|
"--network", "host",
|
||||||
|
"-e", "GITHUB_TOKEN",
|
||||||
|
"-e", "GEMINI_API_KEY",
|
||||||
|
"-e", "DATABASE_TYPE",
|
||||||
|
"-e", "FIRESTORE_DATABASE_ID",
|
||||||
|
"-e", "GCP_PROJECT",
|
||||||
|
"-e", "GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json",
|
||||||
|
"-v", "${GOOGLE_APPLICATION_CREDENTIALS}:/app/gcp-credentials.json",
|
||||||
|
"ghcr.io/google-gemini/gemini-cli-issue-triage@sha256:e3de1523f6c83aabb3c54b76d08940a2bf42febcb789dd2da6f95169641f94d3"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GITHUB_TOKEN": "${GITHUB_TOKEN}",
|
||||||
|
"GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}",
|
||||||
|
"DATABASE_TYPE":"firestore",
|
||||||
|
"GCP_PROJECT": "${FIRESTORE_PROJECT}",
|
||||||
|
"FIRESTORE_DATABASE_ID": "(default)",
|
||||||
|
"GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}"
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"timeout": 600000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"maxSessionTurns": 25,
|
||||||
|
"coreTools": [
|
||||||
|
"run_shell_command(echo)",
|
||||||
|
"run_shell_command(gh issue comment)",
|
||||||
|
"run_shell_command(gh issue view)",
|
||||||
|
"run_shell_command(gh issue edit)"
|
||||||
|
],
|
||||||
|
"telemetry": {
|
||||||
|
"enabled": true,
|
||||||
|
"target": "gcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prompt: |-
|
||||||
|
## Role
|
||||||
|
You are an issue de-duplication assistant. Your goal is to find
|
||||||
|
duplicate issues, label the current issue as a duplicate, and notify
|
||||||
|
the user by commenting on the current issue, while avoiding
|
||||||
|
duplicate comments.
|
||||||
|
## Steps
|
||||||
|
1. **Find Potential Duplicates:**
|
||||||
|
- The repository is ${{ github.repository }} and the issue number is ${{ github.event.issue.number }}.
|
||||||
|
- Use the `duplicates` tool with the `repo` and `issue_number` to find potential duplicates for the current issue. Do not use the `threshold` parameter.
|
||||||
|
- If no duplicates are found, you are done.
|
||||||
|
- Print the JSON output from the `duplicates` tool to the logs.
|
||||||
|
2. **Refine Duplicates List (if necessary):**
|
||||||
|
- If the `duplicates` tool returns between 1 and 14 results, you must refine the list.
|
||||||
|
- For each potential duplicate issue, run `gh issue view <issue-number> --json title,body,comments` to fetch its content.
|
||||||
|
- Also fetch the content of the original issue: `gh issue view "${ISSUE_NUMBER}" --json title,body,comments`.
|
||||||
|
- Carefully analyze the content (title, body, comments) of the original issue and all potential duplicates.
|
||||||
|
- It is very important if the comments on either issue mention that they are not duplicates of each other, to treat them as not duplicates.
|
||||||
|
- Based on your analysis, create a final list containing only the issues you are highly confident are actual duplicates.
|
||||||
|
- If your final list is empty, you are done.
|
||||||
|
- Print to the logs if you omitted any potential duplicates based on your analysis.
|
||||||
|
- If the `duplicates` tool returned 15+ results, use the top 15 matches (based on descending similarity score value) to perform this step.
|
||||||
|
3. **Format Final Duplicates List:**
|
||||||
|
Format the final list of duplicates into a markdown string.
|
||||||
|
The format should be:
|
||||||
|
"Found possible duplicate issues:\n\n- #${issue_number}\n\nIf you believe this is not a duplicate, please remove the `status/possible-duplicate` label."
|
||||||
|
Add an HTML comment to the end for identification: `<!-- gemini-cli-deduplication -->`
|
||||||
|
4. **Check for Existing Comment:**
|
||||||
|
- Run `gh issue view "${ISSUE_NUMBER}" --json comments` to get all
|
||||||
|
comments on the issue.
|
||||||
|
- Look for a comment made by a bot (the author's login often ends in `[bot]`) that contains `<!-- gemini-cli-deduplication -->`.
|
||||||
|
- If you find such a comment, store its `id` and `body`.
|
||||||
|
5. **Decide Action:**
|
||||||
|
- **If an existing comment is found:**
|
||||||
|
- Compare the new list of duplicate issues with the list from the existing comment's body.
|
||||||
|
- If they are the same, do nothing.
|
||||||
|
- If they are different, edit the existing comment. Use
|
||||||
|
`gh issue comment "${ISSUE_NUMBER}" --edit-comment <comment-id> --body "..."`.
|
||||||
|
The new body should be the new list of duplicates, but with the header "Found possible duplicate issues (updated):".
|
||||||
|
- **If no existing comment is found:**
|
||||||
|
- Create a new comment with the list of duplicates.
|
||||||
|
- Use `gh issue comment "${ISSUE_NUMBER}" --body "..."`.
|
||||||
|
6. **Add Duplicate Label:**
|
||||||
|
- If you created or updated a comment in the previous step, add the `duplicate` label to the current issue.
|
||||||
|
- Use `gh issue edit "${ISSUE_NUMBER}" --add-label "status/possible-duplicate"`.
|
||||||
|
## Guidelines
|
||||||
|
- Only use the `duplicates` and `run_shell_command` tools.
|
||||||
|
- The `run_shell_command` tool can be used with `gh issue view`, `gh issue comment`, and `gh issue edit`.
|
||||||
|
- Do not download or read media files like images, videos, or links. The `--json` flag for `gh issue view` will prevent this.
|
||||||
|
- Do not modify the issue content or status.
|
||||||
|
- Only comment on and label the current issue.
|
||||||
|
- Reference all shell variables as "${VAR}" (with quotes and braces).
|
||||||
|
|||||||
@@ -3,7 +3,22 @@ name: Qwen Scheduled Issue Triage
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 * * * *' # Runs every hour
|
- cron: '0 * * * *' # Runs every hour
|
||||||
workflow_dispatch: {}
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: '${{ github.workflow }}'
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: 'bash'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: 'read'
|
||||||
|
id-token: 'write'
|
||||||
|
issues: 'write'
|
||||||
|
statuses: 'write'
|
||||||
|
packages: 'read'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
triage-issues:
|
triage-issues:
|
||||||
@@ -23,16 +38,19 @@ jobs:
|
|||||||
echo "🔍 Finding issues without labels..."
|
echo "🔍 Finding issues without labels..."
|
||||||
NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body)
|
NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body)
|
||||||
|
|
||||||
echo "🏷️ Finding issues that need triage..."
|
echo '🔍 Finding issues without labels...'
|
||||||
NEED_TRIAGE_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue label:\"status/need-triage\"" --json number,title,body)
|
NO_LABEL_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||||
|
--search 'is:open is:issue no:label' --json number,title,body)"
|
||||||
|
|
||||||
echo "🔄 Merging and deduplicating issues..."
|
echo '🏷️ Finding issues that need triage...'
|
||||||
ISSUES=$(echo "$NO_LABEL_ISSUES" "$NEED_TRIAGE_ISSUES" | jq -c -s 'add | unique_by(.number)')
|
NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||||
|
--search 'is:open is:issue label:"status/needs-triage"' --json number,title,body)"
|
||||||
|
|
||||||
echo "📝 Setting output for GitHub Actions..."
|
echo '🔄 Merging and deduplicating issues...'
|
||||||
echo "issues_to_triage=$ISSUES" >> "$GITHUB_OUTPUT"
|
ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
|
||||||
|
|
||||||
echo "✅ Found $(echo "$ISSUES" | jq 'length') issues to triage! 🎯"
|
echo '📝 Setting output for GitHub Actions...'
|
||||||
|
echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- name: Run Qwen Issue Triage
|
- name: Run Qwen Issue Triage
|
||||||
if: steps.find_issues.outputs.issues_to_triage != '[]'
|
if: steps.find_issues.outputs.issues_to_triage != '[]'
|
||||||
@@ -48,18 +66,25 @@ jobs:
|
|||||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||||
settings_json: |
|
settings_json: |
|
||||||
{
|
{
|
||||||
|
"maxSessionTurns": 25,
|
||||||
"coreTools": [
|
"coreTools": [
|
||||||
"run_shell_command(echo)",
|
"run_shell_command(echo)",
|
||||||
"run_shell_command(gh label list)",
|
"run_shell_command(gh label list)",
|
||||||
"run_shell_command(gh issue edit)",
|
"run_shell_command(gh issue edit)",
|
||||||
"run_shell_command(gh issue list)",
|
"run_shell_command(gh issue view)",
|
||||||
"run_shell_command(gh issue view)"
|
"run_shell_command(gh issue list)"
|
||||||
],
|
],
|
||||||
"sandbox": false
|
"sandbox": false
|
||||||
}
|
}
|
||||||
prompt: |
|
prompt: |-
|
||||||
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels.
|
## Role
|
||||||
Steps:
|
|
||||||
|
You are an issue triage assistant. Analyze issues and apply
|
||||||
|
appropriate labels. Use the available tools to gather information;
|
||||||
|
do not ask for information to be provided.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
|
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
|
||||||
2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
|
2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
|
||||||
3. Review the issue title, body and any comments provided in the environment variables.
|
3. Review the issue title, body and any comments provided in the environment variables.
|
||||||
@@ -84,8 +109,10 @@ jobs:
|
|||||||
- After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"`
|
- After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"`
|
||||||
- Execute one `gh issue edit` command per issue, wait for success before proceeding to the next
|
- Execute one `gh issue edit` command per issue, wait for success before proceeding to the next
|
||||||
Process each issue sequentially and confirm each labeling operation before moving to the next issue.
|
Process each issue sequentially and confirm each labeling operation before moving to the next issue.
|
||||||
Guidelines:
|
|
||||||
- Only use labels that already exist in the repository.
|
## Guidelines
|
||||||
|
|
||||||
|
- Only use labels that already exist in the repository.
|
||||||
- Do not add comments or modify the issue content.
|
- Do not add comments or modify the issue content.
|
||||||
- Do not remove labels titled help wanted or good first issue.
|
- Do not remove labels titled help wanted or good first issue.
|
||||||
- Triage only the current issue.
|
- Triage only the current issue.
|
||||||
|
|||||||
3
.vscode/extensions.json
vendored
Normal file
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ Slash commands provide meta-level control over the CLI itself.
|
|||||||
- **Usage:** `/restore [tool_call_id]`
|
- **Usage:** `/restore [tool_call_id]`
|
||||||
- **Note:** Only available if the CLI is invoked with the `--checkpointing` option or configured via [settings](./configuration.md). See [Checkpointing documentation](../checkpointing.md) for more details.
|
- **Note:** Only available if the CLI is invoked with the `--checkpointing` option or configured via [settings](./configuration.md). See [Checkpointing documentation](../checkpointing.md) for more details.
|
||||||
|
|
||||||
|
- **`/settings`**
|
||||||
|
- **Description:** Open the settings editor to view and modify Gemini CLI settings.
|
||||||
|
- **Details:** This command provides a user-friendly interface for changing settings that control the behavior and appearance of Gemini CLI. It is equivalent to manually editing the `.gemini/settings.json` file, but with validation and guidance to prevent errors.
|
||||||
|
- **Usage:** Simply run `/settings` and the editor will open. You can then browse or search for specific settings, view their current values, and modify them as desired. Changes to some settings are applied immediately, while others require a restart.
|
||||||
|
|
||||||
- **`/stats`**
|
- **`/stats`**
|
||||||
- **Description:** Display detailed statistics for the current Qwen Code session, including token usage, cached token savings (when available), and session duration. Note: Cached token information is only displayed when cached tokens are being used, which occurs with API key authentication but not with OAuth authentication at this time.
|
- **Description:** Display detailed statistics for the current Qwen Code session, including token usage, cached token savings (when available), and session duration. Note: Cached token information is only displayed when cached tokens are being used, which occurs with API key authentication but not with OAuth authentication at this time.
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,25 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
|||||||
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
|
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
|
||||||
- **Default:** `undefined` (web search disabled)
|
- **Default:** `undefined` (web search disabled)
|
||||||
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
|
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
|
||||||
|
- **`chatCompression`** (object):
|
||||||
|
- **Description:** Controls the settings for chat history compression, both automatic and
|
||||||
|
when manually invoked through the /compress command.
|
||||||
|
- **Properties:**
|
||||||
|
- **`contextPercentageThreshold`** (number): A value between 0 and 1 that specifies the token threshold for compression as a percentage of the model's total token limit. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit.
|
||||||
|
- **Example:**
|
||||||
|
```json
|
||||||
|
"chatCompression": {
|
||||||
|
"contextPercentageThreshold": 0.6
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`showLineNumbers`** (boolean):
|
||||||
|
- **Description:** Controls whether line numbers are displayed in code blocks in the CLI output.
|
||||||
|
- **Default:** `true`
|
||||||
|
- **Example:**
|
||||||
|
```json
|
||||||
|
"showLineNumbers": false
|
||||||
|
```
|
||||||
|
|
||||||
### Example `settings.json`:
|
### Example `settings.json`:
|
||||||
|
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ To check for linting errors, run the following command:
|
|||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
You can include the `--fix` flag in the command to automatically fix any fixable linting errors:
|
You can include the `:fix` flag in the command to automatically fix any fixable linting errors:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lint --fix
|
npm run lint:fix
|
||||||
```
|
```
|
||||||
|
|
||||||
## Directory structure
|
## Directory structure
|
||||||
|
|||||||
@@ -58,7 +58,17 @@ You can export all telemetry data to a file for local inspection.
|
|||||||
To enable file export, use the `--telemetry-outfile` flag with a path to your desired output file. This must be run using `--telemetry-target=local`.
|
To enable file export, use the `--telemetry-outfile` flag with a path to your desired output file. This must be run using `--telemetry-target=local`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gemini --telemetry --telemetry-target=local --telemetry-outfile=/path/to/telemetry.log "your prompt"
|
# Set your desired output file path
|
||||||
|
TELEMETRY_FILE=".gemini/telemetry.log"
|
||||||
|
|
||||||
|
# Run Gemini CLI with local telemetry
|
||||||
|
# NOTE: --telemetry-otlp-endpoint="" is required to override the default
|
||||||
|
# OTLP exporter and ensure telemetry is written to the local file.
|
||||||
|
gemini --telemetry \
|
||||||
|
--telemetry-target=local \
|
||||||
|
--telemetry-otlp-endpoint="" \
|
||||||
|
--telemetry-outfile="$TELEMETRY_FILE" \
|
||||||
|
--prompt "What is OpenTelemetry?"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running an OTEL Collector
|
## Running an OTEL Collector
|
||||||
@@ -173,9 +183,10 @@ Logs are timestamped records of specific events. The following events are logged
|
|||||||
- `function_args`
|
- `function_args`
|
||||||
- `duration_ms`
|
- `duration_ms`
|
||||||
- `success` (boolean)
|
- `success` (boolean)
|
||||||
- `decision` (string: "accept", "reject", or "modify", if applicable)
|
- `decision` (string: "accept", "reject", "auto_accept", or "modify", if applicable)
|
||||||
- `error` (if applicable)
|
- `error` (if applicable)
|
||||||
- `error_type` (if applicable)
|
- `error_type` (if applicable)
|
||||||
|
- `metadata` (if applicable, dictionary of string -> any)
|
||||||
|
|
||||||
- `gemini_cli.api_request`: This event occurs when making a request to Gemini API.
|
- `gemini_cli.api_request`: This event occurs when making a request to Gemini API.
|
||||||
- **Attributes**:
|
- **Attributes**:
|
||||||
@@ -252,3 +263,7 @@ Metrics are numerical measurements of behavior over time. The following metrics
|
|||||||
- `lines` (Int, if applicable): Number of lines in the file.
|
- `lines` (Int, if applicable): Number of lines in the file.
|
||||||
- `mimetype` (string, if applicable): Mimetype of the file.
|
- `mimetype` (string, if applicable): Mimetype of the file.
|
||||||
- `extension` (string, if applicable): File extension of the file.
|
- `extension` (string, if applicable): File extension of the file.
|
||||||
|
- `ai_added_lines` (Int, if applicable): Number of lines added/changed by AI.
|
||||||
|
- `ai_removed_lines` (Int, if applicable): Number of lines removed/changed by AI.
|
||||||
|
- `user_added_lines` (Int, if applicable): Number of lines added/changed by user in AI proposed changes.
|
||||||
|
- `user_removed_lines` (Int, if applicable): Number of lines removed/changed by user in AI proposed changes.
|
||||||
|
|||||||
@@ -688,3 +688,114 @@ or, using positional arguments:
|
|||||||
```
|
```
|
||||||
|
|
||||||
When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows.
|
When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows.
|
||||||
|
|
||||||
|
## Managing MCP Servers with `gemini mcp`
|
||||||
|
|
||||||
|
While you can always configure MCP servers by manually editing your `settings.json` file, the Gemini CLI provides a convenient set of commands to manage your server configurations programmatically. These commands streamline the process of adding, listing, and removing MCP servers without needing to directly edit JSON files.
|
||||||
|
|
||||||
|
### Adding a Server (`gemini mcp add`)
|
||||||
|
|
||||||
|
The `add` command configures a new MCP server in your `settings.json`. Based on the scope (`-s, --scope`), it will be added to either the user config `~/.gemini/settings.json` or the project config `.gemini/settings.json` file.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini mcp add [options] <name> <commandOrUrl> [args...]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<name>`: A unique name for the server.
|
||||||
|
- `<commandOrUrl>`: The command to execute (for `stdio`) or the URL (for `http`/`sse`).
|
||||||
|
- `[args...]`: Optional arguments for a `stdio` command.
|
||||||
|
|
||||||
|
**Options (Flags):**
|
||||||
|
|
||||||
|
- `-s, --scope`: Configuration scope (user or project). [default: "project"]
|
||||||
|
- `-t, --transport`: Transport type (stdio, sse, http). [default: "stdio"]
|
||||||
|
- `-e, --env`: Set environment variables (e.g. -e KEY=value).
|
||||||
|
- `-H, --header`: Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123").
|
||||||
|
- `--timeout`: Set connection timeout in milliseconds.
|
||||||
|
- `--trust`: Trust the server (bypass all tool call confirmation prompts).
|
||||||
|
- `--description`: Set the description for the server.
|
||||||
|
- `--include-tools`: A comma-separated list of tools to include.
|
||||||
|
- `--exclude-tools`: A comma-separated list of tools to exclude.
|
||||||
|
|
||||||
|
#### Adding an stdio server
|
||||||
|
|
||||||
|
This is the default transport for running local servers.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic syntax
|
||||||
|
gemini mcp add <name> <command> [args...]
|
||||||
|
|
||||||
|
# Example: Adding a local server
|
||||||
|
gemini mcp add my-stdio-server -e API_KEY=123 /path/to/server arg1 arg2 arg3
|
||||||
|
|
||||||
|
# Example: Adding a local python server
|
||||||
|
gemini mcp add python-server python server.py --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Adding an HTTP server
|
||||||
|
|
||||||
|
This transport is for servers that use the streamable HTTP transport.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic syntax
|
||||||
|
gemini mcp add --transport http <name> <url>
|
||||||
|
|
||||||
|
# Example: Adding an HTTP server
|
||||||
|
gemini mcp add --transport http http-server https://api.example.com/mcp/
|
||||||
|
|
||||||
|
# Example: Adding an HTTP server with an authentication header
|
||||||
|
gemini mcp add --transport http secure-http https://api.example.com/mcp/ --header "Authorization: Bearer abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Adding an SSE server
|
||||||
|
|
||||||
|
This transport is for servers that use Server-Sent Events (SSE).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic syntax
|
||||||
|
gemini mcp add --transport sse <name> <url>
|
||||||
|
|
||||||
|
# Example: Adding an SSE server
|
||||||
|
gemini mcp add --transport sse sse-server https://api.example.com/sse/
|
||||||
|
|
||||||
|
# Example: Adding an SSE server with an authentication header
|
||||||
|
gemini mcp add --transport sse secure-sse https://api.example.com/sse/ --header "Authorization: Bearer abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listing Servers (`gemini mcp list`)
|
||||||
|
|
||||||
|
To view all MCP servers currently configured, use the `list` command. It displays each server's name, configuration details, and connection status.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini mcp list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Output:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
✓ stdio-server: command: python3 server.py (stdio) - Connected
|
||||||
|
✓ http-server: https://api.example.com/mcp (http) - Connected
|
||||||
|
✗ sse-server: https://api.example.com/sse (sse) - Disconnected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing a Server (`gemini mcp remove`)
|
||||||
|
|
||||||
|
To delete a server from your configuration, use the `remove` command with the server's name.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini mcp remove <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini mcp remove my-server
|
||||||
|
```
|
||||||
|
|
||||||
|
This will find and delete the "my-server" entry from the `mcpServers` object in the appropriate `settings.json` file based on the scope (`-s, --scope`).
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default tseslint.config(
|
|||||||
'packages/vscode-ide-companion/dist/**',
|
'packages/vscode-ide-companion/dist/**',
|
||||||
'bundle/**',
|
'bundle/**',
|
||||||
'package/bundle/**',
|
'package/bundle/**',
|
||||||
|
'.integration-tests/**',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ test('should be able to list a directory', async () => {
|
|||||||
await rig.poll(
|
await rig.poll(
|
||||||
() => {
|
() => {
|
||||||
// Check if the files exist in the test directory
|
// Check if the files exist in the test directory
|
||||||
const file1Path = join(rig.testDir, 'file1.txt');
|
const file1Path = join(rig.testDir!, 'file1.txt');
|
||||||
const subdirPath = join(rig.testDir, 'subdir');
|
const subdirPath = join(rig.testDir!, 'subdir');
|
||||||
return existsSync(file1Path) && existsSync(subdirPath);
|
return existsSync(file1Path) && existsSync(subdirPath);
|
||||||
},
|
},
|
||||||
1000, // 1 second max wait
|
1000, // 1 second max wait
|
||||||
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 =
|
const testPatterns =
|
||||||
args.length > 0
|
args.length > 0
|
||||||
? args.map((arg) => `integration-tests/${arg}.test.js`)
|
? args.map((arg) => `integration-tests/${arg}.test.ts`)
|
||||||
: ['integration-tests/*.test.js'];
|
: ['integration-tests/*.test.ts'];
|
||||||
const testFiles = glob.sync(testPatterns, { cwd: rootDir, absolute: true });
|
const testFiles = glob.sync(testPatterns, { cwd: rootDir, absolute: true });
|
||||||
|
|
||||||
for (const testFile of testFiles) {
|
for (const testFile of testFiles) {
|
||||||
const testFileName = basename(testFile);
|
const testFileName = basename(testFile);
|
||||||
console.log(`\tFound test file: ${testFileName}`);
|
console.log(` Found test file: ${testFileName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
@@ -92,7 +92,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
nodeArgs.push(testFile);
|
nodeArgs.push(testFile);
|
||||||
|
|
||||||
const child = spawn('node', nodeArgs, {
|
const child = spawn('npx', ['tsx', ...nodeArgs], {
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|||||||
@@ -14,11 +14,8 @@ import { test, describe, before } from 'node:test';
|
|||||||
import { strict as assert } from 'node:assert';
|
import { strict as assert } from 'node:assert';
|
||||||
import { TestRig, validateModelOutput } from './test-helper.js';
|
import { TestRig, validateModelOutput } from './test-helper.js';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { writeFileSync } from 'fs';
|
import { writeFileSync } from 'fs';
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
||||||
|
|
||||||
// Create a minimal MCP server that doesn't require external dependencies
|
// Create a minimal MCP server that doesn't require external dependencies
|
||||||
// This implements the MCP protocol directly using Node.js built-ins
|
// This implements the MCP protocol directly using Node.js built-ins
|
||||||
const serverScript = `#!/usr/bin/env node
|
const serverScript = `#!/usr/bin/env node
|
||||||
@@ -185,7 +182,7 @@ describe('simple-mcp-server', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create server script in the test directory
|
// Create server script in the test directory
|
||||||
const testServerPath = join(rig.testDir, 'mcp-server.cjs');
|
const testServerPath = join(rig.testDir!, 'mcp-server.cjs');
|
||||||
writeFileSync(testServerPath, serverScript);
|
writeFileSync(testServerPath, serverScript);
|
||||||
|
|
||||||
// Make the script executable (though running with 'node' should work anyway)
|
// Make the script executable (though running with 'node' should work anyway)
|
||||||
@@ -14,7 +14,7 @@ import { fileExists } from '../scripts/telemetry_utils.js';
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
function sanitizeTestName(name) {
|
function sanitizeTestName(name: string) {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]/g, '-')
|
.replace(/[^a-z0-9]/g, '-')
|
||||||
@@ -22,7 +22,11 @@ function sanitizeTestName(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create detailed error messages
|
// Helper to create detailed error messages
|
||||||
export function createToolCallErrorMessage(expectedTools, foundTools, result) {
|
export function createToolCallErrorMessage(
|
||||||
|
expectedTools: string | string[],
|
||||||
|
foundTools: string[],
|
||||||
|
result: string,
|
||||||
|
) {
|
||||||
const expectedStr = Array.isArray(expectedTools)
|
const expectedStr = Array.isArray(expectedTools)
|
||||||
? expectedTools.join(' or ')
|
? expectedTools.join(' or ')
|
||||||
: expectedTools;
|
: expectedTools;
|
||||||
@@ -34,7 +38,11 @@ export function createToolCallErrorMessage(expectedTools, foundTools, result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to print debug information when tests fail
|
// Helper to print debug information when tests fail
|
||||||
export function printDebugInfo(rig, result, context = {}) {
|
export function printDebugInfo(
|
||||||
|
rig: TestRig,
|
||||||
|
result: string,
|
||||||
|
context: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
console.error('Test failed - Debug info:');
|
console.error('Test failed - Debug info:');
|
||||||
console.error('Result length:', result.length);
|
console.error('Result length:', result.length);
|
||||||
console.error('Result (first 500 chars):', result.substring(0, 500));
|
console.error('Result (first 500 chars):', result.substring(0, 500));
|
||||||
@@ -60,8 +68,8 @@ export function printDebugInfo(rig, result, context = {}) {
|
|||||||
|
|
||||||
// Helper to validate model output and warn about unexpected content
|
// Helper to validate model output and warn about unexpected content
|
||||||
export function validateModelOutput(
|
export function validateModelOutput(
|
||||||
result,
|
result: string,
|
||||||
expectedContent = null,
|
expectedContent: string | (string | RegExp)[] | null = null,
|
||||||
testName = '',
|
testName = '',
|
||||||
) {
|
) {
|
||||||
// First, check if there's any output at all (this should fail the test if missing)
|
// First, check if there's any output at all (this should fail the test if missing)
|
||||||
@@ -102,6 +110,11 @@ export function validateModelOutput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TestRig {
|
export class TestRig {
|
||||||
|
bundlePath: string;
|
||||||
|
testDir: string | null;
|
||||||
|
testName?: string;
|
||||||
|
_lastRunStdout?: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
|
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
|
||||||
this.testDir = null;
|
this.testDir = null;
|
||||||
@@ -114,10 +127,13 @@ export class TestRig {
|
|||||||
return 15000; // 15s locally
|
return 15000; // 15s locally
|
||||||
}
|
}
|
||||||
|
|
||||||
setup(testName, options = {}) {
|
setup(
|
||||||
|
testName: string,
|
||||||
|
options: { settings?: Record<string, unknown> } = {},
|
||||||
|
) {
|
||||||
this.testName = testName;
|
this.testName = testName;
|
||||||
const sanitizedName = sanitizeTestName(testName);
|
const sanitizedName = sanitizeTestName(testName);
|
||||||
this.testDir = join(env.INTEGRATION_TEST_FILE_DIR, sanitizedName);
|
this.testDir = join(env.INTEGRATION_TEST_FILE_DIR!, sanitizedName);
|
||||||
mkdirSync(this.testDir, { recursive: true });
|
mkdirSync(this.testDir, { recursive: true });
|
||||||
|
|
||||||
// Create a settings file to point the CLI to the local collector
|
// Create a settings file to point the CLI to the local collector
|
||||||
@@ -146,36 +162,43 @@ export class TestRig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createFile(fileName, content) {
|
createFile(fileName: string, content: string) {
|
||||||
const filePath = join(this.testDir, fileName);
|
const filePath = join(this.testDir!, fileName);
|
||||||
writeFileSync(filePath, content);
|
writeFileSync(filePath, content);
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
mkdir(dir) {
|
mkdir(dir: string) {
|
||||||
mkdirSync(join(this.testDir, dir), { recursive: true });
|
mkdirSync(join(this.testDir!, dir), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
sync() {
|
sync() {
|
||||||
// ensure file system is done before spawning
|
// ensure file system is done before spawning
|
||||||
execSync('sync', { cwd: this.testDir });
|
execSync('sync', { cwd: this.testDir! });
|
||||||
}
|
}
|
||||||
|
|
||||||
run(promptOrOptions, ...args) {
|
run(
|
||||||
|
promptOrOptions: string | { prompt?: string; stdin?: string },
|
||||||
|
...args: string[]
|
||||||
|
): Promise<string> {
|
||||||
let command = `node ${this.bundlePath} --yolo`;
|
let command = `node ${this.bundlePath} --yolo`;
|
||||||
const execOptions = {
|
const execOptions: {
|
||||||
cwd: this.testDir,
|
cwd: string;
|
||||||
|
encoding: 'utf-8';
|
||||||
|
input?: string;
|
||||||
|
} = {
|
||||||
|
cwd: this.testDir!,
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof promptOrOptions === 'string') {
|
if (typeof promptOrOptions === 'string') {
|
||||||
command += ` --prompt "${promptOrOptions}"`;
|
command += ` --prompt ${JSON.stringify(promptOrOptions)}`;
|
||||||
} else if (
|
} else if (
|
||||||
typeof promptOrOptions === 'object' &&
|
typeof promptOrOptions === 'object' &&
|
||||||
promptOrOptions !== null
|
promptOrOptions !== null
|
||||||
) {
|
) {
|
||||||
if (promptOrOptions.prompt) {
|
if (promptOrOptions.prompt) {
|
||||||
command += ` --prompt "${promptOrOptions.prompt}"`;
|
command += ` --prompt ${JSON.stringify(promptOrOptions.prompt)}`;
|
||||||
}
|
}
|
||||||
if (promptOrOptions.stdin) {
|
if (promptOrOptions.stdin) {
|
||||||
execOptions.input = promptOrOptions.stdin;
|
execOptions.input = promptOrOptions.stdin;
|
||||||
@@ -185,10 +208,10 @@ export class TestRig {
|
|||||||
command += ` ${args.join(' ')}`;
|
command += ` ${args.join(' ')}`;
|
||||||
|
|
||||||
const commandArgs = parse(command);
|
const commandArgs = parse(command);
|
||||||
const node = commandArgs.shift();
|
const node = commandArgs.shift() as string;
|
||||||
|
|
||||||
const child = spawn(node, commandArgs, {
|
const child = spawn(node, commandArgs as string[], {
|
||||||
cwd: this.testDir,
|
cwd: this.testDir!,
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,26 +220,26 @@ export class TestRig {
|
|||||||
|
|
||||||
// Handle stdin if provided
|
// Handle stdin if provided
|
||||||
if (execOptions.input) {
|
if (execOptions.input) {
|
||||||
child.stdin.write(execOptions.input);
|
child.stdin!.write(execOptions.input);
|
||||||
child.stdin.end();
|
child.stdin!.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
child.stdout.on('data', (data) => {
|
child.stdout!.on('data', (data: Buffer) => {
|
||||||
stdout += data;
|
stdout += data;
|
||||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||||
process.stdout.write(data);
|
process.stdout.write(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stderr.on('data', (data) => {
|
child.stderr!.on('data', (data: Buffer) => {
|
||||||
stderr += data;
|
stderr += data;
|
||||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||||
process.stderr.write(data);
|
process.stderr.write(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const promise = new Promise((resolve, reject) => {
|
const promise = new Promise<string>((resolve, reject) => {
|
||||||
child.on('close', (code) => {
|
child.on('close', (code: number) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
// Store the raw stdout for Podman telemetry parsing
|
// Store the raw stdout for Podman telemetry parsing
|
||||||
this._lastRunStdout = stdout;
|
this._lastRunStdout = stdout;
|
||||||
@@ -258,6 +281,11 @@ export class TestRig {
|
|||||||
|
|
||||||
result = filteredLines.join('\n');
|
result = filteredLines.join('\n');
|
||||||
}
|
}
|
||||||
|
// If we have stderr output, include that also
|
||||||
|
if (stderr) {
|
||||||
|
result += `\n\nStdErr:\n${stderr}`;
|
||||||
|
}
|
||||||
|
|
||||||
resolve(result);
|
resolve(result);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
||||||
@@ -268,13 +296,13 @@ export class TestRig {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
readFile(fileName) {
|
readFile(fileName: string) {
|
||||||
const content = readFileSync(join(this.testDir, fileName), 'utf-8');
|
const content = readFileSync(join(this.testDir!, fileName), 'utf-8');
|
||||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||||
const testId = `${env.TEST_FILE_NAME.replace(
|
const testId = `${env.TEST_FILE_NAME!.replace(
|
||||||
'.test.js',
|
'.test.js',
|
||||||
'',
|
'',
|
||||||
)}:${this.testName.replace(/ /g, '-')}`;
|
)}:${this.testName!.replace(/ /g, '-')}`;
|
||||||
console.log(`--- FILE: ${testId}/${fileName} ---`);
|
console.log(`--- FILE: ${testId}/${fileName} ---`);
|
||||||
console.log(content);
|
console.log(content);
|
||||||
console.log(`--- END FILE: ${testId}/${fileName} ---`);
|
console.log(`--- END FILE: ${testId}/${fileName} ---`);
|
||||||
@@ -290,7 +318,7 @@ export class TestRig {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore cleanup errors
|
// Ignore cleanup errors
|
||||||
if (env.VERBOSE === 'true') {
|
if (env.VERBOSE === 'true') {
|
||||||
console.warn('Cleanup warning:', error.message);
|
console.warn('Cleanup warning:', (error as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,7 +328,7 @@ export class TestRig {
|
|||||||
// In sandbox mode, telemetry is written to a relative path in the test directory
|
// In sandbox mode, telemetry is written to a relative path in the test directory
|
||||||
const logFilePath =
|
const logFilePath =
|
||||||
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
|
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
|
||||||
? join(this.testDir, 'telemetry.log')
|
? join(this.testDir!, 'telemetry.log')
|
||||||
: env.TELEMETRY_LOG_FILE;
|
: env.TELEMETRY_LOG_FILE;
|
||||||
|
|
||||||
if (!logFilePath) return;
|
if (!logFilePath) return;
|
||||||
@@ -313,7 +341,7 @@ export class TestRig {
|
|||||||
const content = readFileSync(logFilePath, 'utf-8');
|
const content = readFileSync(logFilePath, 'utf-8');
|
||||||
// Check if file has meaningful content (at least one complete JSON object)
|
// Check if file has meaningful content (at least one complete JSON object)
|
||||||
return content.includes('"event.name"');
|
return content.includes('"event.name"');
|
||||||
} catch (_e) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -322,7 +350,7 @@ export class TestRig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForToolCall(toolName, timeout) {
|
async waitForToolCall(toolName: string, timeout?: number) {
|
||||||
// Use environment-specific timeout
|
// Use environment-specific timeout
|
||||||
if (!timeout) {
|
if (!timeout) {
|
||||||
timeout = this.getDefaultTimeout();
|
timeout = this.getDefaultTimeout();
|
||||||
@@ -341,7 +369,7 @@ export class TestRig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForAnyToolCall(toolNames, timeout) {
|
async waitForAnyToolCall(toolNames: string[], timeout?: number) {
|
||||||
// Use environment-specific timeout
|
// Use environment-specific timeout
|
||||||
if (!timeout) {
|
if (!timeout) {
|
||||||
timeout = this.getDefaultTimeout();
|
timeout = this.getDefaultTimeout();
|
||||||
@@ -362,7 +390,11 @@ export class TestRig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async poll(predicate, timeout, interval) {
|
async poll(
|
||||||
|
predicate: () => boolean,
|
||||||
|
timeout: number,
|
||||||
|
interval: number,
|
||||||
|
): Promise<boolean> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (Date.now() - startTime < timeout) {
|
while (Date.now() - startTime < timeout) {
|
||||||
@@ -384,8 +416,16 @@ export class TestRig {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_parseToolLogsFromStdout(stdout) {
|
_parseToolLogsFromStdout(stdout: string) {
|
||||||
const logs = [];
|
const logs: {
|
||||||
|
timestamp: number;
|
||||||
|
toolRequest: {
|
||||||
|
name: string;
|
||||||
|
args: string;
|
||||||
|
success: boolean;
|
||||||
|
duration_ms: number;
|
||||||
|
};
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
// The console output from Podman is JavaScript object notation, not JSON
|
// The console output from Podman is JavaScript object notation, not JSON
|
||||||
// Look for tool call events in the output
|
// Look for tool call events in the output
|
||||||
@@ -488,7 +528,7 @@ export class TestRig {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch {
|
||||||
// Not valid JSON
|
// Not valid JSON
|
||||||
}
|
}
|
||||||
currentObject = '';
|
currentObject = '';
|
||||||
@@ -505,7 +545,7 @@ export class TestRig {
|
|||||||
// If not, fall back to parsing from stdout
|
// If not, fall back to parsing from stdout
|
||||||
if (env.GEMINI_SANDBOX === 'podman') {
|
if (env.GEMINI_SANDBOX === 'podman') {
|
||||||
// Try reading from file first
|
// Try reading from file first
|
||||||
const logFilePath = join(this.testDir, 'telemetry.log');
|
const logFilePath = join(this.testDir!, 'telemetry.log');
|
||||||
|
|
||||||
if (fileExists(logFilePath)) {
|
if (fileExists(logFilePath)) {
|
||||||
try {
|
try {
|
||||||
@@ -517,7 +557,7 @@ export class TestRig {
|
|||||||
// File exists but is empty or doesn't have events, parse from stdout
|
// File exists but is empty or doesn't have events, parse from stdout
|
||||||
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch {
|
||||||
// Error reading file, fall back to stdout
|
// Error reading file, fall back to stdout
|
||||||
if (this._lastRunStdout) {
|
if (this._lastRunStdout) {
|
||||||
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
||||||
@@ -532,7 +572,7 @@ export class TestRig {
|
|||||||
// In sandbox mode, telemetry is written to a relative path in the test directory
|
// In sandbox mode, telemetry is written to a relative path in the test directory
|
||||||
const logFilePath =
|
const logFilePath =
|
||||||
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
|
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
|
||||||
? join(this.testDir, 'telemetry.log')
|
? join(this.testDir!, 'telemetry.log')
|
||||||
: env.TELEMETRY_LOG_FILE;
|
: env.TELEMETRY_LOG_FILE;
|
||||||
|
|
||||||
if (!logFilePath) {
|
if (!logFilePath) {
|
||||||
@@ -548,7 +588,7 @@ export class TestRig {
|
|||||||
const content = readFileSync(logFilePath, 'utf-8');
|
const content = readFileSync(logFilePath, 'utf-8');
|
||||||
|
|
||||||
// Split the content into individual JSON objects
|
// Split the content into individual JSON objects
|
||||||
// They are separated by "}\n{" pattern
|
// They are separated by "}\n{"
|
||||||
const jsonObjects = content
|
const jsonObjects = content
|
||||||
.split(/}\s*\n\s*{/)
|
.split(/}\s*\n\s*{/)
|
||||||
.map((obj, index, array) => {
|
.map((obj, index, array) => {
|
||||||
@@ -559,7 +599,14 @@ export class TestRig {
|
|||||||
})
|
})
|
||||||
.filter((obj) => obj);
|
.filter((obj) => obj);
|
||||||
|
|
||||||
const logs = [];
|
const logs: {
|
||||||
|
toolRequest: {
|
||||||
|
name: string;
|
||||||
|
args: string;
|
||||||
|
success: boolean;
|
||||||
|
duration_ms: number;
|
||||||
|
};
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
for (const jsonStr of jsonObjects) {
|
for (const jsonStr of jsonObjects) {
|
||||||
try {
|
try {
|
||||||
@@ -579,10 +626,13 @@ export class TestRig {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (e) {
|
||||||
// Skip objects that aren't valid JSON
|
// Skip objects that aren't valid JSON
|
||||||
if (env.VERBOSE === 'true') {
|
if (env.VERBOSE === 'true') {
|
||||||
console.error('Failed to parse telemetry object:', _e.message);
|
console.error(
|
||||||
|
'Failed to parse telemetry object:',
|
||||||
|
(e as Error).message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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"]
|
||||||
|
}
|
||||||
@@ -23,10 +23,13 @@ test('should be able to search the web', async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Network errors can occur in CI environments
|
// Network errors can occur in CI environments
|
||||||
if (
|
if (
|
||||||
error.message.includes('network') ||
|
error instanceof Error &&
|
||||||
error.message.includes('timeout')
|
(error.message.includes('network') || error.message.includes('timeout'))
|
||||||
) {
|
) {
|
||||||
console.warn('Skipping test due to network error:', error.message);
|
console.warn(
|
||||||
|
'Skipping test due to network error:',
|
||||||
|
(error as Error).message,
|
||||||
|
);
|
||||||
return; // Skip the test
|
return; // Skip the test
|
||||||
}
|
}
|
||||||
throw error; // Re-throw if not a network error
|
throw error; // Re-throw if not a network error
|
||||||
94
package-lock.json
generated
94
package-lock.json
generated
@@ -37,9 +37,11 @@
|
|||||||
"json": "^11.0.0",
|
"json": "^11.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"memfs": "^4.17.2",
|
"memfs": "^4.17.2",
|
||||||
|
"mnemonist": "^0.40.3",
|
||||||
"mock-fs": "^5.5.0",
|
"mock-fs": "^5.5.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"react-devtools-core": "^4.28.5",
|
"react-devtools-core": "^4.28.5",
|
||||||
|
"tsx": "^4.20.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
@@ -2515,15 +2517,7 @@
|
|||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/vscode": {
|
|
||||||
"version": "1.102.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.102.0.tgz",
|
|
||||||
"integrity": "sha512-V9sFXmcXz03FtYTSUsYsu5K0Q9wH9w9V25slddcxrh5JgORD14LpnOA7ov0L9ALi+6HrTjskLJ/tY5zeRF3TFA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
@@ -5547,6 +5541,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fzf": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/gcp-metadata": {
|
"node_modules/gcp-metadata": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
|
||||||
@@ -5695,6 +5695,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
|
||||||
|
"integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
@@ -7873,6 +7886,15 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mnemonist": {
|
||||||
|
"version": "0.40.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||||
|
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"obliterator": "^2.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mock-fs": {
|
"node_modules/mock-fs": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz",
|
||||||
@@ -8303,6 +8325,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obliterator": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -9271,6 +9299,16 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/restore-cursor": {
|
"node_modules/restore-cursor": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
|
||||||
@@ -10577,6 +10615,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
|
||||||
|
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.25.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -11704,6 +11762,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "1.9.0",
|
"@google/genai": "1.9.0",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||||
"@qwen-code/qwen-code-core": "file:../core",
|
"@qwen-code/qwen-code-core": "file:../core",
|
||||||
"@types/update-notifier": "^6.0.8",
|
"@types/update-notifier": "^6.0.8",
|
||||||
"command-exists": "^1.2.9",
|
"command-exists": "^1.2.9",
|
||||||
@@ -11727,7 +11786,7 @@
|
|||||||
"string-width": "^7.1.0",
|
"string-width": "^7.1.0",
|
||||||
"strip-ansi": "^7.1.0",
|
"strip-ansi": "^7.1.0",
|
||||||
"strip-json-comments": "^3.1.1",
|
"strip-json-comments": "^3.1.1",
|
||||||
"tiktoken": "^1.0.21",
|
"undici": "^7.10.0",
|
||||||
"update-notifier": "^7.3.1",
|
"update-notifier": "^7.3.1",
|
||||||
"yargs": "^17.7.2",
|
"yargs": "^17.7.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -11920,6 +11979,7 @@
|
|||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
"dotenv": "^17.1.0",
|
"dotenv": "^17.1.0",
|
||||||
"fdir": "^6.4.6",
|
"fdir": "^6.4.6",
|
||||||
|
"fzf": "^0.5.2",
|
||||||
"glob": "^10.4.5",
|
"glob": "^10.4.5",
|
||||||
"google-auth-library": "^9.11.0",
|
"google-auth-library": "^9.11.0",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
@@ -11928,6 +11988,7 @@
|
|||||||
"jsonrepair": "^3.13.0",
|
"jsonrepair": "^3.13.0",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
"micromatch": "^4.0.8",
|
"micromatch": "^4.0.8",
|
||||||
|
"mnemonist": "^0.40.3",
|
||||||
"open": "^10.1.2",
|
"open": "^10.1.2",
|
||||||
"openai": "5.11.0",
|
"openai": "5.11.0",
|
||||||
"picomatch": "^4.0.1",
|
"picomatch": "^4.0.1",
|
||||||
@@ -12052,7 +12113,7 @@
|
|||||||
},
|
},
|
||||||
"packages/test-utils": {
|
"packages/test-utils": {
|
||||||
"name": "@qwen-code/qwen-code-test-utils",
|
"name": "@qwen-code/qwen-code-test-utils",
|
||||||
"version": "0.1.18",
|
"version": "0.0.7",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
@@ -12075,7 +12136,7 @@
|
|||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "20.x",
|
"@types/node": "20.x",
|
||||||
"@types/vscode": "^1.101.0",
|
"@types/vscode": "^1.99.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||||
"@typescript-eslint/parser": "^8.31.1",
|
"@typescript-eslint/parser": "^8.31.1",
|
||||||
"esbuild": "^0.25.3",
|
"esbuild": "^0.25.3",
|
||||||
@@ -12085,8 +12146,15 @@
|
|||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.101.0"
|
"vscode": "^1.99.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packages/vscode-ide-companion/node_modules/@types/vscode": {
|
||||||
|
"version": "1.99.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz",
|
||||||
|
"integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"build:packages": "npm run build --workspaces",
|
"build:packages": "npm run build --workspaces",
|
||||||
"build:sandbox": "node scripts/build_sandbox.js --skip-npm-install-build",
|
"build:sandbox": "node scripts/build_sandbox.js --skip-npm-install-build",
|
||||||
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
|
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
|
||||||
"test": "npm run test --workspaces",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
|
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
|
||||||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||||
"test:e2e": "npm run test:integration:sandbox:none -- --verbose --keep-output",
|
"test:e2e": "npm run test:integration:sandbox:none -- --verbose --keep-output",
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
|
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
|
||||||
"lint:fix": "eslint . --fix && eslint integration-tests --fix",
|
"lint:fix": "eslint . --fix && eslint integration-tests --fix",
|
||||||
"lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0",
|
"lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --experimental-cli --write .",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
|
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
|
||||||
"prepare": "npm run bundle",
|
"prepare": "npm run bundle",
|
||||||
@@ -83,8 +83,10 @@
|
|||||||
"mock-fs": "^5.5.0",
|
"mock-fs": "^5.5.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"react-devtools-core": "^4.28.5",
|
"react-devtools-core": "^4.28.5",
|
||||||
|
"tsx": "^4.20.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2",
|
||||||
|
"mnemonist": "^0.40.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"@google/genai": "1.9.0",
|
"@google/genai": "1.9.0",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
"@qwen-code/qwen-code-core": "file:../core",
|
"@qwen-code/qwen-code-core": "file:../core",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||||
"@types/update-notifier": "^6.0.8",
|
"@types/update-notifier": "^6.0.8",
|
||||||
"command-exists": "^1.2.9",
|
"command-exists": "^1.2.9",
|
||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
"string-width": "^7.1.0",
|
"string-width": "^7.1.0",
|
||||||
"strip-ansi": "^7.1.0",
|
"strip-ansi": "^7.1.0",
|
||||||
"strip-json-comments": "^3.1.1",
|
"strip-json-comments": "^3.1.1",
|
||||||
"tiktoken": "^1.0.21",
|
"undici": "^7.10.0",
|
||||||
"update-notifier": "^7.3.1",
|
"update-notifier": "^7.3.1",
|
||||||
"yargs": "^17.7.2",
|
"yargs": "^17.7.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
@@ -239,65 +239,62 @@ class GeminiAgent implements Agent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let toolCallId;
|
let toolCallId: number | undefined = undefined;
|
||||||
const confirmationDetails = await tool.shouldConfirmExecute(
|
|
||||||
args,
|
|
||||||
abortSignal,
|
|
||||||
);
|
|
||||||
if (confirmationDetails) {
|
|
||||||
let content: acp.ToolCallContent | null = null;
|
|
||||||
if (confirmationDetails.type === 'edit') {
|
|
||||||
content = {
|
|
||||||
type: 'diff',
|
|
||||||
path: confirmationDetails.fileName,
|
|
||||||
oldText: confirmationDetails.originalContent,
|
|
||||||
newText: confirmationDetails.newContent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.client.requestToolCallConfirmation({
|
|
||||||
label: tool.getDescription(args),
|
|
||||||
icon: tool.icon,
|
|
||||||
content,
|
|
||||||
confirmation: toAcpToolCallConfirmation(confirmationDetails),
|
|
||||||
locations: tool.toolLocations(args),
|
|
||||||
});
|
|
||||||
|
|
||||||
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
|
|
||||||
switch (result.outcome) {
|
|
||||||
case 'reject':
|
|
||||||
return errorResponse(
|
|
||||||
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'cancel':
|
|
||||||
return errorResponse(
|
|
||||||
new Error(`Tool "${fc.name}" was canceled by the user.`),
|
|
||||||
);
|
|
||||||
case 'allow':
|
|
||||||
case 'alwaysAllow':
|
|
||||||
case 'alwaysAllowMcpServer':
|
|
||||||
case 'alwaysAllowTool':
|
|
||||||
break;
|
|
||||||
default: {
|
|
||||||
const resultOutcome: never = result.outcome;
|
|
||||||
throw new Error(`Unexpected: ${resultOutcome}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toolCallId = result.id;
|
|
||||||
} else {
|
|
||||||
const result = await this.client.pushToolCall({
|
|
||||||
icon: tool.icon,
|
|
||||||
label: tool.getDescription(args),
|
|
||||||
locations: tool.toolLocations(args),
|
|
||||||
});
|
|
||||||
|
|
||||||
toolCallId = result.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const toolResult: ToolResult = await tool.execute(args, abortSignal);
|
const invocation = tool.build(args);
|
||||||
|
const confirmationDetails =
|
||||||
|
await invocation.shouldConfirmExecute(abortSignal);
|
||||||
|
if (confirmationDetails) {
|
||||||
|
let content: acp.ToolCallContent | null = null;
|
||||||
|
if (confirmationDetails.type === 'edit') {
|
||||||
|
content = {
|
||||||
|
type: 'diff',
|
||||||
|
path: confirmationDetails.fileName,
|
||||||
|
oldText: confirmationDetails.originalContent,
|
||||||
|
newText: confirmationDetails.newContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.requestToolCallConfirmation({
|
||||||
|
label: invocation.getDescription(),
|
||||||
|
icon: tool.icon,
|
||||||
|
content,
|
||||||
|
confirmation: toAcpToolCallConfirmation(confirmationDetails),
|
||||||
|
locations: invocation.toolLocations(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
|
||||||
|
switch (result.outcome) {
|
||||||
|
case 'reject':
|
||||||
|
return errorResponse(
|
||||||
|
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'cancel':
|
||||||
|
return errorResponse(
|
||||||
|
new Error(`Tool "${fc.name}" was canceled by the user.`),
|
||||||
|
);
|
||||||
|
case 'allow':
|
||||||
|
case 'alwaysAllow':
|
||||||
|
case 'alwaysAllowMcpServer':
|
||||||
|
case 'alwaysAllowTool':
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const resultOutcome: never = result.outcome;
|
||||||
|
throw new Error(`Unexpected: ${resultOutcome}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolCallId = result.id;
|
||||||
|
} else {
|
||||||
|
const result = await this.client.pushToolCall({
|
||||||
|
icon: tool.icon,
|
||||||
|
label: invocation.getDescription(),
|
||||||
|
locations: invocation.toolLocations(),
|
||||||
|
});
|
||||||
|
toolCallId = result.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolResult: ToolResult = await invocation.execute(abortSignal);
|
||||||
const toolCallContent = toToolCallContent(toolResult);
|
const toolCallContent = toToolCallContent(toolResult);
|
||||||
|
|
||||||
await this.client.updateToolCall({
|
await this.client.updateToolCall({
|
||||||
@@ -320,12 +317,13 @@ class GeminiAgent implements Agent {
|
|||||||
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
|
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e instanceof Error ? e : new Error(String(e));
|
const error = e instanceof Error ? e : new Error(String(e));
|
||||||
await this.client.updateToolCall({
|
if (toolCallId) {
|
||||||
toolCallId,
|
await this.client.updateToolCall({
|
||||||
status: 'error',
|
toolCallId,
|
||||||
content: { type: 'markdown', markdown: error.message },
|
status: 'error',
|
||||||
});
|
content: { type: 'markdown', markdown: error.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,7 +406,7 @@ class GeminiAgent implements Agent {
|
|||||||
`Path ${pathName} not found directly, attempting glob search.`,
|
`Path ${pathName} not found directly, attempting glob search.`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const globResult = await globTool.execute(
|
const globResult = await globTool.buildAndExecute(
|
||||||
{
|
{
|
||||||
pattern: `**/*${pathName}*`,
|
pattern: `**/*${pathName}*`,
|
||||||
path: this.config.getTargetDir(),
|
path: this.config.getTargetDir(),
|
||||||
@@ -530,12 +528,15 @@ class GeminiAgent implements Agent {
|
|||||||
respectGitIgnore, // Use configuration setting
|
respectGitIgnore, // Use configuration setting
|
||||||
};
|
};
|
||||||
|
|
||||||
const toolCall = await this.client.pushToolCall({
|
let toolCallId: number | undefined = undefined;
|
||||||
icon: readManyFilesTool.icon,
|
|
||||||
label: readManyFilesTool.getDescription(toolArgs),
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const result = await readManyFilesTool.execute(toolArgs, abortSignal);
|
const invocation = readManyFilesTool.build(toolArgs);
|
||||||
|
const toolCall = await this.client.pushToolCall({
|
||||||
|
icon: readManyFilesTool.icon,
|
||||||
|
label: invocation.getDescription(),
|
||||||
|
});
|
||||||
|
toolCallId = toolCall.id;
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
const content = toToolCallContent(result) || {
|
const content = toToolCallContent(result) || {
|
||||||
type: 'markdown',
|
type: 'markdown',
|
||||||
markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
|
markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
|
||||||
@@ -578,14 +579,16 @@ class GeminiAgent implements Agent {
|
|||||||
|
|
||||||
return processedQueryParts;
|
return processedQueryParts;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
await this.client.updateToolCall({
|
if (toolCallId) {
|
||||||
toolCallId: toolCall.id,
|
await this.client.updateToolCall({
|
||||||
status: 'error',
|
toolCallId,
|
||||||
content: {
|
status: 'error',
|
||||||
type: 'markdown',
|
content: {
|
||||||
markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
type: 'markdown',
|
||||||
},
|
markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { ShellTool, EditTool, WriteFileTool } from '@qwen-code/qwen-code-core';
|
||||||
import { loadCliConfig, parseArguments } from './config.js';
|
import { loadCliConfig, parseArguments } from './config.js';
|
||||||
import { Settings } from './settings.js';
|
import { Settings } from './settings.js';
|
||||||
import { Extension } from './extension.js';
|
import { Extension } from './extension.js';
|
||||||
@@ -635,6 +636,17 @@ describe('loadCliConfig systemPromptMappings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mergeExcludeTools', () => {
|
describe('mergeExcludeTools', () => {
|
||||||
|
const defaultExcludes = [ShellTool.Name, EditTool.Name, WriteFileTool.Name];
|
||||||
|
const originalIsTTY = process.stdin.isTTY;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.stdin.isTTY = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.stdin.isTTY = originalIsTTY;
|
||||||
|
});
|
||||||
|
|
||||||
it('should merge excludeTools from settings and extensions', async () => {
|
it('should merge excludeTools from settings and extensions', async () => {
|
||||||
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
|
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
|
||||||
const extensions: Extension[] = [
|
const extensions: Extension[] = [
|
||||||
@@ -729,7 +741,8 @@ describe('mergeExcludeTools', () => {
|
|||||||
expect(config.getExcludeTools()).toHaveLength(4);
|
expect(config.getExcludeTools()).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an empty array when no excludeTools are specified', async () => {
|
it('should return an empty array when no excludeTools are specified and it is interactive', async () => {
|
||||||
|
process.stdin.isTTY = true;
|
||||||
const settings: Settings = {};
|
const settings: Settings = {};
|
||||||
const extensions: Extension[] = [];
|
const extensions: Extension[] = [];
|
||||||
process.argv = ['node', 'script.js'];
|
process.argv = ['node', 'script.js'];
|
||||||
@@ -743,6 +756,21 @@ describe('mergeExcludeTools', () => {
|
|||||||
expect(config.getExcludeTools()).toEqual([]);
|
expect(config.getExcludeTools()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
|
||||||
|
process.stdin.isTTY = false;
|
||||||
|
const settings: Settings = {};
|
||||||
|
const extensions: Extension[] = [];
|
||||||
|
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig(
|
||||||
|
settings,
|
||||||
|
extensions,
|
||||||
|
'test-session',
|
||||||
|
argv,
|
||||||
|
);
|
||||||
|
expect(config.getExcludeTools()).toEqual(defaultExcludes);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle settings with excludeTools but no extensions', async () => {
|
it('should handle settings with excludeTools but no extensions', async () => {
|
||||||
process.argv = ['node', 'script.js'];
|
process.argv = ['node', 'script.js'];
|
||||||
const argv = await parseArguments();
|
const argv = await parseArguments();
|
||||||
@@ -1083,6 +1111,91 @@ describe('loadCliConfig ideModeFeature', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadCliConfig folderTrustFeature', () => {
|
||||||
|
const originalArgv = process.argv;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||||
|
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.argv = originalArgv;
|
||||||
|
process.env = originalEnv;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be false by default', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const settings: Settings = {};
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||||
|
expect(config.getFolderTrustFeature()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be true when settings.folderTrustFeature is true', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const settings: Settings = { folderTrustFeature: true };
|
||||||
|
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||||
|
expect(config.getFolderTrustFeature()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadCliConfig folderTrust', () => {
|
||||||
|
const originalArgv = process.argv;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||||
|
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.argv = originalArgv;
|
||||||
|
process.env = originalEnv;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be false if folderTrustFeature is false and folderTrust is false', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const settings: Settings = {
|
||||||
|
folderTrustFeature: false,
|
||||||
|
folderTrust: false,
|
||||||
|
};
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||||
|
expect(config.getFolderTrust()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be false if folderTrustFeature is true and folderTrust is false', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const settings: Settings = { folderTrustFeature: true, folderTrust: false };
|
||||||
|
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||||
|
expect(config.getFolderTrust()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be false if folderTrustFeature is false and folderTrust is true', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const settings: Settings = { folderTrustFeature: false, folderTrust: true };
|
||||||
|
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||||
|
expect(config.getFolderTrust()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be true when folderTrustFeature is true and folderTrust is true', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const settings: Settings = { folderTrustFeature: true, folderTrust: true };
|
||||||
|
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||||
|
expect(config.getFolderTrust()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('fs', async () => {
|
vi.mock('fs', async () => {
|
||||||
const actualFs = await vi.importActual<typeof fs>('fs');
|
const actualFs = await vi.importActual<typeof fs>('fs');
|
||||||
const MOCK_CWD1 = process.cwd();
|
const MOCK_CWD1 = process.cwd();
|
||||||
@@ -1164,3 +1277,154 @@ describe('loadCliConfig with includeDirectories', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadCliConfig chatCompression', () => {
|
||||||
|
const originalArgv = process.argv;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||||
|
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.argv = originalArgv;
|
||||||
|
process.env = originalEnv;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass chatCompression settings to the core config', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const settings: Settings = {
|
||||||
|
chatCompression: {
|
||||||
|
contextPercentageThreshold: 0.5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||||
|
expect(config.getChatCompression()).toEqual({
|
||||||
|
contextPercentageThreshold: 0.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have undefined chatCompression if not in settings', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const settings: Settings = {};
|
||||||
|
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||||
|
expect(config.getChatCompression()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadCliConfig tool exclusions', () => {
|
||||||
|
const originalArgv = process.argv;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
const originalIsTTY = process.stdin.isTTY;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||||
|
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||||
|
process.stdin.isTTY = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.argv = originalArgv;
|
||||||
|
process.env = originalEnv;
|
||||||
|
process.stdin.isTTY = originalIsTTY;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not exclude interactive tools in interactive mode without YOLO', async () => {
|
||||||
|
process.stdin.isTTY = true;
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||||
|
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||||
|
expect(config.getExcludeTools()).not.toContain('replace');
|
||||||
|
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not exclude interactive tools in interactive mode with YOLO', async () => {
|
||||||
|
process.stdin.isTTY = true;
|
||||||
|
process.argv = ['node', 'script.js', '--yolo'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||||
|
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||||
|
expect(config.getExcludeTools()).not.toContain('replace');
|
||||||
|
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude interactive tools in non-interactive mode without YOLO', async () => {
|
||||||
|
process.stdin.isTTY = false;
|
||||||
|
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||||
|
expect(config.getExcludeTools()).toContain('run_shell_command');
|
||||||
|
expect(config.getExcludeTools()).toContain('replace');
|
||||||
|
expect(config.getExcludeTools()).toContain('write_file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not exclude interactive tools in non-interactive mode with YOLO', async () => {
|
||||||
|
process.stdin.isTTY = false;
|
||||||
|
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||||
|
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||||
|
expect(config.getExcludeTools()).not.toContain('replace');
|
||||||
|
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadCliConfig interactive', () => {
|
||||||
|
const originalArgv = process.argv;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
const originalIsTTY = process.stdin.isTTY;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||||
|
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||||
|
process.stdin.isTTY = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.argv = originalArgv;
|
||||||
|
process.env = originalEnv;
|
||||||
|
process.stdin.isTTY = originalIsTTY;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be interactive if isTTY and no prompt', async () => {
|
||||||
|
process.stdin.isTTY = true;
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||||
|
expect(config.isInteractive()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be interactive if prompt-interactive is set', async () => {
|
||||||
|
process.stdin.isTTY = false;
|
||||||
|
process.argv = ['node', 'script.js', '--prompt-interactive', 'test'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||||
|
expect(config.isInteractive()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not be interactive if not isTTY and no prompt', async () => {
|
||||||
|
process.stdin.isTTY = false;
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||||
|
expect(config.isInteractive()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not be interactive if prompt is set', async () => {
|
||||||
|
process.stdin.isTTY = true;
|
||||||
|
process.argv = ['node', 'script.js', '--prompt', 'test'];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||||
|
expect(config.isInteractive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
|
|||||||
import yargs from 'yargs/yargs';
|
import yargs from 'yargs/yargs';
|
||||||
import { hideBin } from 'yargs/helpers';
|
import { hideBin } from 'yargs/helpers';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import { mcpCommand } from '../commands/mcp.js';
|
||||||
import {
|
import {
|
||||||
Config,
|
Config,
|
||||||
loadServerHierarchicalMemory,
|
loadServerHierarchicalMemory,
|
||||||
@@ -22,6 +23,11 @@ import {
|
|||||||
FileDiscoveryService,
|
FileDiscoveryService,
|
||||||
TelemetryTarget,
|
TelemetryTarget,
|
||||||
FileFilteringOptions,
|
FileFilteringOptions,
|
||||||
|
ShellTool,
|
||||||
|
EditTool,
|
||||||
|
WriteFileTool,
|
||||||
|
MCPServerConfig,
|
||||||
|
ConfigParameters,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { Settings } from './settings.js';
|
import { Settings } from './settings.js';
|
||||||
|
|
||||||
@@ -68,7 +74,6 @@ export interface CliArgs {
|
|||||||
openaiBaseUrl: string | undefined;
|
openaiBaseUrl: string | undefined;
|
||||||
proxy: string | undefined;
|
proxy: string | undefined;
|
||||||
includeDirectories: string[] | undefined;
|
includeDirectories: string[] | undefined;
|
||||||
loadMemoryFromIncludeDirectories: boolean | undefined;
|
|
||||||
tavilyApiKey: string | undefined;
|
tavilyApiKey: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,190 +81,196 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||||||
const yargsInstance = yargs(hideBin(process.argv))
|
const yargsInstance = yargs(hideBin(process.argv))
|
||||||
.scriptName('qwen')
|
.scriptName('qwen')
|
||||||
.usage(
|
.usage(
|
||||||
'$0 [options]',
|
'Usage: qwen [options] [command]\n\nQwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
|
||||||
'Qwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
|
|
||||||
)
|
)
|
||||||
.option('model', {
|
.command('$0', 'Launch Qwen Code', (yargsInstance) =>
|
||||||
alias: 'm',
|
yargsInstance
|
||||||
type: 'string',
|
.option('model', {
|
||||||
description: `Model`,
|
alias: 'm',
|
||||||
default: process.env.GEMINI_MODEL,
|
type: 'string',
|
||||||
})
|
description: `Model`,
|
||||||
.option('prompt', {
|
default: process.env.GEMINI_MODEL,
|
||||||
alias: 'p',
|
})
|
||||||
type: 'string',
|
.option('prompt', {
|
||||||
description: 'Prompt. Appended to input on stdin (if any).',
|
alias: 'p',
|
||||||
})
|
type: 'string',
|
||||||
.option('prompt-interactive', {
|
description: 'Prompt. Appended to input on stdin (if any).',
|
||||||
alias: 'i',
|
})
|
||||||
type: 'string',
|
.option('prompt-interactive', {
|
||||||
description:
|
alias: 'i',
|
||||||
'Execute the provided prompt and continue in interactive mode',
|
type: 'string',
|
||||||
})
|
description:
|
||||||
.option('sandbox', {
|
'Execute the provided prompt and continue in interactive mode',
|
||||||
alias: 's',
|
})
|
||||||
type: 'boolean',
|
.option('sandbox', {
|
||||||
description: 'Run in sandbox?',
|
alias: 's',
|
||||||
})
|
type: 'boolean',
|
||||||
.option('sandbox-image', {
|
description: 'Run in sandbox?',
|
||||||
type: 'string',
|
})
|
||||||
description: 'Sandbox image URI.',
|
.option('sandbox-image', {
|
||||||
})
|
type: 'string',
|
||||||
.option('debug', {
|
description: 'Sandbox image URI.',
|
||||||
alias: 'd',
|
})
|
||||||
type: 'boolean',
|
.option('debug', {
|
||||||
description: 'Run in debug mode?',
|
alias: 'd',
|
||||||
default: false,
|
type: 'boolean',
|
||||||
})
|
description: 'Run in debug mode?',
|
||||||
.option('all-files', {
|
default: false,
|
||||||
alias: ['a'],
|
})
|
||||||
type: 'boolean',
|
.option('all-files', {
|
||||||
description: 'Include ALL files in context?',
|
alias: ['a'],
|
||||||
default: false,
|
type: 'boolean',
|
||||||
})
|
description: 'Include ALL files in context?',
|
||||||
.option('all_files', {
|
default: false,
|
||||||
type: 'boolean',
|
})
|
||||||
description: 'Include ALL files in context?',
|
.option('all_files', {
|
||||||
default: false,
|
type: 'boolean',
|
||||||
})
|
description: 'Include ALL files in context?',
|
||||||
.deprecateOption(
|
default: false,
|
||||||
'all_files',
|
})
|
||||||
'Use --all-files instead. We will be removing --all_files in the coming weeks.',
|
.deprecateOption(
|
||||||
|
'all_files',
|
||||||
|
'Use --all-files instead. We will be removing --all_files in the coming weeks.',
|
||||||
|
)
|
||||||
|
.option('show-memory-usage', {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Show memory usage in status bar',
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
.option('show_memory_usage', {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Show memory usage in status bar',
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
.deprecateOption(
|
||||||
|
'show_memory_usage',
|
||||||
|
'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
|
||||||
|
)
|
||||||
|
.option('yolo', {
|
||||||
|
alias: 'y',
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
.option('telemetry', {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
|
||||||
|
})
|
||||||
|
.option('telemetry-target', {
|
||||||
|
type: 'string',
|
||||||
|
choices: ['local', 'gcp'],
|
||||||
|
description:
|
||||||
|
'Set the telemetry target (local or gcp). Overrides settings files.',
|
||||||
|
})
|
||||||
|
.option('telemetry-otlp-endpoint', {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
|
||||||
|
})
|
||||||
|
.option('telemetry-log-prompts', {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
|
||||||
|
})
|
||||||
|
.option('telemetry-outfile', {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Redirect all telemetry output to the specified file.',
|
||||||
|
})
|
||||||
|
.option('checkpointing', {
|
||||||
|
alias: 'c',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Enables checkpointing of file edits',
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
.option('experimental-acp', {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Starts the agent in ACP mode',
|
||||||
|
})
|
||||||
|
.option('allowed-mcp-server-names', {
|
||||||
|
type: 'array',
|
||||||
|
string: true,
|
||||||
|
description: 'Allowed MCP server names',
|
||||||
|
})
|
||||||
|
.option('extensions', {
|
||||||
|
alias: 'e',
|
||||||
|
type: 'array',
|
||||||
|
string: true,
|
||||||
|
description:
|
||||||
|
'A list of extensions to use. If not provided, all extensions are used.',
|
||||||
|
})
|
||||||
|
.option('list-extensions', {
|
||||||
|
alias: 'l',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'List all available extensions and exit.',
|
||||||
|
})
|
||||||
|
.option('ide-mode-feature', {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Run in IDE mode?',
|
||||||
|
})
|
||||||
|
.option('proxy', {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Proxy for gemini client, like schema://user:password@host:port',
|
||||||
|
})
|
||||||
|
.option('include-directories', {
|
||||||
|
type: 'array',
|
||||||
|
string: true,
|
||||||
|
description:
|
||||||
|
'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
|
||||||
|
coerce: (dirs: string[]) =>
|
||||||
|
// Handle comma-separated values
|
||||||
|
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
|
||||||
|
})
|
||||||
|
.option('openai-logging', {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Enable logging of OpenAI API calls for debugging and analysis',
|
||||||
|
})
|
||||||
|
.option('openai-api-key', {
|
||||||
|
type: 'string',
|
||||||
|
description: 'OpenAI API key to use for authentication',
|
||||||
|
})
|
||||||
|
.option('openai-base-url', {
|
||||||
|
type: 'string',
|
||||||
|
description: 'OpenAI base URL (for custom endpoints)',
|
||||||
|
})
|
||||||
|
.option('tavily-api-key', {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Tavily API key for web search functionality',
|
||||||
|
})
|
||||||
|
.check((argv) => {
|
||||||
|
if (argv.prompt && argv.promptInteractive) {
|
||||||
|
throw new Error(
|
||||||
|
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.option('show-memory-usage', {
|
// Register MCP subcommands
|
||||||
type: 'boolean',
|
.command(mcpCommand)
|
||||||
description: 'Show memory usage in status bar',
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
.option('show_memory_usage', {
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'Show memory usage in status bar',
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
.deprecateOption(
|
|
||||||
'show_memory_usage',
|
|
||||||
'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
|
|
||||||
)
|
|
||||||
.option('yolo', {
|
|
||||||
alias: 'y',
|
|
||||||
type: 'boolean',
|
|
||||||
description:
|
|
||||||
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
.option('telemetry', {
|
|
||||||
type: 'boolean',
|
|
||||||
description:
|
|
||||||
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
|
|
||||||
})
|
|
||||||
.option('telemetry-target', {
|
|
||||||
type: 'string',
|
|
||||||
choices: ['local', 'gcp'],
|
|
||||||
description:
|
|
||||||
'Set the telemetry target (local or gcp). Overrides settings files.',
|
|
||||||
})
|
|
||||||
.option('telemetry-otlp-endpoint', {
|
|
||||||
type: 'string',
|
|
||||||
description:
|
|
||||||
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
|
|
||||||
})
|
|
||||||
.option('telemetry-log-prompts', {
|
|
||||||
type: 'boolean',
|
|
||||||
description:
|
|
||||||
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
|
|
||||||
})
|
|
||||||
.option('telemetry-outfile', {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Redirect all telemetry output to the specified file.',
|
|
||||||
})
|
|
||||||
.option('checkpointing', {
|
|
||||||
alias: 'c',
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'Enables checkpointing of file edits',
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
.option('experimental-acp', {
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'Starts the agent in ACP mode',
|
|
||||||
})
|
|
||||||
.option('allowed-mcp-server-names', {
|
|
||||||
type: 'array',
|
|
||||||
string: true,
|
|
||||||
description: 'Allowed MCP server names',
|
|
||||||
})
|
|
||||||
.option('extensions', {
|
|
||||||
alias: 'e',
|
|
||||||
type: 'array',
|
|
||||||
string: true,
|
|
||||||
description:
|
|
||||||
'A list of extensions to use. If not provided, all extensions are used.',
|
|
||||||
})
|
|
||||||
.option('list-extensions', {
|
|
||||||
alias: 'l',
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'List all available extensions and exit.',
|
|
||||||
})
|
|
||||||
.option('ide-mode-feature', {
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'Run in IDE mode?',
|
|
||||||
})
|
|
||||||
.option('openai-logging', {
|
|
||||||
type: 'boolean',
|
|
||||||
description:
|
|
||||||
'Enable logging of OpenAI API calls for debugging and analysis',
|
|
||||||
})
|
|
||||||
.option('openai-api-key', {
|
|
||||||
type: 'string',
|
|
||||||
description: 'OpenAI API key to use for authentication',
|
|
||||||
})
|
|
||||||
.option('openai-base-url', {
|
|
||||||
type: 'string',
|
|
||||||
description: 'OpenAI base URL (for custom endpoints)',
|
|
||||||
})
|
|
||||||
.option('tavily-api-key', {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Tavily API key for web search functionality',
|
|
||||||
})
|
|
||||||
.option('proxy', {
|
|
||||||
type: 'string',
|
|
||||||
description:
|
|
||||||
'Proxy for gemini client, like schema://user:password@host:port',
|
|
||||||
})
|
|
||||||
.option('include-directories', {
|
|
||||||
type: 'array',
|
|
||||||
string: true,
|
|
||||||
description:
|
|
||||||
'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
|
|
||||||
coerce: (dirs: string[]) =>
|
|
||||||
// Handle comma-separated values
|
|
||||||
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
|
|
||||||
})
|
|
||||||
.option('load-memory-from-include-directories', {
|
|
||||||
type: 'boolean',
|
|
||||||
description:
|
|
||||||
'If true, when refreshing memory, QWEN.md files should be loaded from all directories that are added. If false, QWEN.md files should only be loaded from the primary working directory.',
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||||
.alias('v', 'version')
|
.alias('v', 'version')
|
||||||
.help()
|
.help()
|
||||||
.alias('h', 'help')
|
.alias('h', 'help')
|
||||||
.strict()
|
.strict()
|
||||||
.check((argv) => {
|
.demandCommand(0, 0); // Allow base command to run with no subcommands
|
||||||
if (argv.prompt && argv.promptInteractive) {
|
|
||||||
throw new Error(
|
|
||||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
yargsInstance.wrap(yargsInstance.terminalWidth());
|
yargsInstance.wrap(yargsInstance.terminalWidth());
|
||||||
const result = yargsInstance.parseSync();
|
const result = await yargsInstance.parse();
|
||||||
|
|
||||||
|
// Handle case where MCP subcommands are executed - they should exit the process
|
||||||
|
// and not return to main CLI logic
|
||||||
|
if (result._.length > 0 && result._[0] === 'mcp') {
|
||||||
|
// MCP commands handle their own execution and process exit
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
// The import format is now only controlled by settings.memoryImportFormat
|
// The import format is now only controlled by settings.memoryImportFormat
|
||||||
// We no longer accept it as a CLI argument
|
// We no longer accept it as a CLI argument
|
||||||
return result as CliArgs;
|
return result as unknown as CliArgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This function is now a thin wrapper around the server's implementation.
|
// This function is now a thin wrapper around the server's implementation.
|
||||||
@@ -321,6 +332,10 @@ export async function loadCliConfig(
|
|||||||
const ideModeFeature =
|
const ideModeFeature =
|
||||||
argv.ideModeFeature ?? settings.ideModeFeature ?? false;
|
argv.ideModeFeature ?? settings.ideModeFeature ?? false;
|
||||||
|
|
||||||
|
const folderTrustFeature = settings.folderTrustFeature ?? false;
|
||||||
|
const folderTrustSetting = settings.folderTrust ?? false;
|
||||||
|
const folderTrust = folderTrustFeature && folderTrustSetting;
|
||||||
|
|
||||||
const allExtensions = annotateActiveExtensions(
|
const allExtensions = annotateActiveExtensions(
|
||||||
extensions,
|
extensions,
|
||||||
argv.extensions || [],
|
argv.extensions || [],
|
||||||
@@ -383,17 +398,31 @@ export async function loadCliConfig(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||||
const excludeTools = mergeExcludeTools(settings, activeExtensions);
|
const question = argv.promptInteractive || argv.prompt || '';
|
||||||
|
const approvalMode =
|
||||||
|
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
|
||||||
|
const interactive =
|
||||||
|
!!argv.promptInteractive || (process.stdin.isTTY && question.length === 0);
|
||||||
|
// In non-interactive and non-yolo mode, exclude interactive built in tools.
|
||||||
|
const extraExcludes =
|
||||||
|
!interactive && approvalMode !== ApprovalMode.YOLO
|
||||||
|
? [ShellTool.Name, EditTool.Name, WriteFileTool.Name]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const excludeTools = mergeExcludeTools(
|
||||||
|
settings,
|
||||||
|
activeExtensions,
|
||||||
|
extraExcludes,
|
||||||
|
);
|
||||||
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
|
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
|
||||||
|
|
||||||
if (!argv.allowedMcpServerNames) {
|
if (!argv.allowedMcpServerNames) {
|
||||||
if (settings.allowMCPServers) {
|
if (settings.allowMCPServers) {
|
||||||
const allowedNames = new Set(settings.allowMCPServers.filter(Boolean));
|
mcpServers = allowedMcpServers(
|
||||||
if (allowedNames.size > 0) {
|
mcpServers,
|
||||||
mcpServers = Object.fromEntries(
|
settings.allowMCPServers,
|
||||||
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
|
blockedMcpServers,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.excludeMCPServers) {
|
if (settings.excludeMCPServers) {
|
||||||
@@ -407,29 +436,11 @@ export async function loadCliConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (argv.allowedMcpServerNames) {
|
if (argv.allowedMcpServerNames) {
|
||||||
const allowedNames = new Set(argv.allowedMcpServerNames.filter(Boolean));
|
mcpServers = allowedMcpServers(
|
||||||
if (allowedNames.size > 0) {
|
mcpServers,
|
||||||
mcpServers = Object.fromEntries(
|
argv.allowedMcpServerNames,
|
||||||
Object.entries(mcpServers).filter(([key, server]) => {
|
blockedMcpServers,
|
||||||
const isAllowed = allowedNames.has(key);
|
);
|
||||||
if (!isAllowed) {
|
|
||||||
blockedMcpServers.push({
|
|
||||||
name: key,
|
|
||||||
extensionName: server.extensionName || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return isAllowed;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
blockedMcpServers.push(
|
|
||||||
...Object.entries(mcpServers).map(([key, server]) => ({
|
|
||||||
name: key,
|
|
||||||
extensionName: server.extensionName || '',
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
mcpServers = {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||||
@@ -442,11 +453,9 @@ export async function loadCliConfig(
|
|||||||
targetDir: process.cwd(),
|
targetDir: process.cwd(),
|
||||||
includeDirectories,
|
includeDirectories,
|
||||||
loadMemoryFromIncludeDirectories:
|
loadMemoryFromIncludeDirectories:
|
||||||
argv.loadMemoryFromIncludeDirectories ||
|
settings.loadMemoryFromIncludeDirectories || false,
|
||||||
settings.loadMemoryFromIncludeDirectories ||
|
|
||||||
false,
|
|
||||||
debugMode,
|
debugMode,
|
||||||
question: argv.promptInteractive || argv.prompt || '',
|
question,
|
||||||
fullContext: argv.allFiles || argv.all_files || false,
|
fullContext: argv.allFiles || argv.all_files || false,
|
||||||
coreTools: settings.coreTools || undefined,
|
coreTools: settings.coreTools || undefined,
|
||||||
excludeTools,
|
excludeTools,
|
||||||
@@ -456,7 +465,7 @@ export async function loadCliConfig(
|
|||||||
mcpServers,
|
mcpServers,
|
||||||
userMemory: memoryContent,
|
userMemory: memoryContent,
|
||||||
geminiMdFileCount: fileCount,
|
geminiMdFileCount: fileCount,
|
||||||
approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT,
|
approvalMode,
|
||||||
showMemoryUsage:
|
showMemoryUsage:
|
||||||
argv.showMemoryUsage ||
|
argv.showMemoryUsage ||
|
||||||
argv.show_memory_usage ||
|
argv.show_memory_usage ||
|
||||||
@@ -496,7 +505,6 @@ export async function loadCliConfig(
|
|||||||
extensionContextFilePaths,
|
extensionContextFilePaths,
|
||||||
maxSessionTurns: settings.maxSessionTurns ?? -1,
|
maxSessionTurns: settings.maxSessionTurns ?? -1,
|
||||||
sessionTokenLimit: settings.sessionTokenLimit ?? -1,
|
sessionTokenLimit: settings.sessionTokenLimit ?? -1,
|
||||||
maxFolderItems: settings.maxFolderItems ?? 20,
|
|
||||||
experimentalAcp: argv.experimentalAcp || false,
|
experimentalAcp: argv.experimentalAcp || false,
|
||||||
listExtensions: argv.listExtensions || false,
|
listExtensions: argv.listExtensions || false,
|
||||||
extensions: allExtensions,
|
extensions: allExtensions,
|
||||||
@@ -510,7 +518,7 @@ export async function loadCliConfig(
|
|||||||
? settings.enableOpenAILogging
|
? settings.enableOpenAILogging
|
||||||
: argv.openaiLogging) ?? false,
|
: argv.openaiLogging) ?? false,
|
||||||
sampling_params: settings.sampling_params,
|
sampling_params: settings.sampling_params,
|
||||||
systemPromptMappings: settings.systemPromptMappings ?? [
|
systemPromptMappings: (settings.systemPromptMappings ?? [
|
||||||
{
|
{
|
||||||
baseUrls: [
|
baseUrls: [
|
||||||
'https://dashscope.aliyuncs.com/compatible-mode/v1/',
|
'https://dashscope.aliyuncs.com/compatible-mode/v1/',
|
||||||
@@ -519,15 +527,50 @@ export async function loadCliConfig(
|
|||||||
modelNames: ['qwen3-coder-plus'],
|
modelNames: ['qwen3-coder-plus'],
|
||||||
template:
|
template:
|
||||||
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
|
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
|
||||||
},
|
}
|
||||||
],
|
]) as ConfigParameters['systemPromptMappings'],
|
||||||
contentGenerator: settings.contentGenerator,
|
contentGenerator: settings.contentGenerator,
|
||||||
cliVersion,
|
cliVersion,
|
||||||
tavilyApiKey:
|
tavilyApiKey:
|
||||||
argv.tavilyApiKey || settings.tavilyApiKey || process.env.TAVILY_API_KEY,
|
argv.tavilyApiKey || settings.tavilyApiKey || process.env.TAVILY_API_KEY,
|
||||||
|
chatCompression: settings.chatCompression,
|
||||||
|
folderTrustFeature,
|
||||||
|
folderTrust,
|
||||||
|
interactive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allowedMcpServers(
|
||||||
|
mcpServers: { [x: string]: MCPServerConfig },
|
||||||
|
allowMCPServers: string[],
|
||||||
|
blockedMcpServers: Array<{ name: string; extensionName: string }>,
|
||||||
|
) {
|
||||||
|
const allowedNames = new Set(allowMCPServers.filter(Boolean));
|
||||||
|
if (allowedNames.size > 0) {
|
||||||
|
mcpServers = Object.fromEntries(
|
||||||
|
Object.entries(mcpServers).filter(([key, server]) => {
|
||||||
|
const isAllowed = allowedNames.has(key);
|
||||||
|
if (!isAllowed) {
|
||||||
|
blockedMcpServers.push({
|
||||||
|
name: key,
|
||||||
|
extensionName: server.extensionName || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return isAllowed;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
blockedMcpServers.push(
|
||||||
|
...Object.entries(mcpServers).map(([key, server]) => ({
|
||||||
|
name: key,
|
||||||
|
extensionName: server.extensionName || '',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
mcpServers = {};
|
||||||
|
}
|
||||||
|
return mcpServers;
|
||||||
|
}
|
||||||
|
|
||||||
function mergeMcpServers(settings: Settings, extensions: Extension[]) {
|
function mergeMcpServers(settings: Settings, extensions: Extension[]) {
|
||||||
const mcpServers = { ...(settings.mcpServers || {}) };
|
const mcpServers = { ...(settings.mcpServers || {}) };
|
||||||
for (const extension of extensions) {
|
for (const extension of extensions) {
|
||||||
@@ -552,8 +595,12 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) {
|
|||||||
function mergeExcludeTools(
|
function mergeExcludeTools(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
extensions: Extension[],
|
extensions: Extension[],
|
||||||
|
extraExcludes?: string[] | undefined,
|
||||||
): string[] {
|
): string[] {
|
||||||
const allExcludeTools = new Set(settings.excludeTools || []);
|
const allExcludeTools = new Set([
|
||||||
|
...(settings.excludeTools || []),
|
||||||
|
...(extraExcludes || []),
|
||||||
|
]);
|
||||||
for (const extension of extensions) {
|
for (const extension of extensions) {
|
||||||
for (const tool of extension.config.excludeTools || []) {
|
for (const tool of extension.config.excludeTools || []) {
|
||||||
allExcludeTools.add(tool);
|
allExcludeTools.add(tool);
|
||||||
|
|||||||
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' }],
|
||||||
|
};
|
||||||
@@ -113,6 +113,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
customThemes: {},
|
customThemes: {},
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
includeDirectories: [],
|
includeDirectories: [],
|
||||||
|
chatCompression: {},
|
||||||
});
|
});
|
||||||
expect(settings.errors.length).toBe(0);
|
expect(settings.errors.length).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -147,6 +148,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
customThemes: {},
|
customThemes: {},
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
includeDirectories: [],
|
includeDirectories: [],
|
||||||
|
chatCompression: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,6 +183,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
customThemes: {},
|
customThemes: {},
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
includeDirectories: [],
|
includeDirectories: [],
|
||||||
|
chatCompression: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,6 +216,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
customThemes: {},
|
customThemes: {},
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
includeDirectories: [],
|
includeDirectories: [],
|
||||||
|
chatCompression: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,6 +255,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
customThemes: {},
|
customThemes: {},
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
includeDirectories: [],
|
includeDirectories: [],
|
||||||
|
chatCompression: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -301,9 +306,66 @@ describe('Settings Loading and Merging', () => {
|
|||||||
customThemes: {},
|
customThemes: {},
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
includeDirectories: [],
|
includeDirectories: [],
|
||||||
|
chatCompression: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should ignore folderTrust from workspace settings', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||||
|
const userSettingsContent = {
|
||||||
|
folderTrust: true,
|
||||||
|
};
|
||||||
|
const workspaceSettingsContent = {
|
||||||
|
folderTrust: false, // This should be ignored
|
||||||
|
};
|
||||||
|
const systemSettingsContent = {
|
||||||
|
// No folderTrust here
|
||||||
|
};
|
||||||
|
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === getSystemSettingsPath())
|
||||||
|
return JSON.stringify(systemSettingsContent);
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(workspaceSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.merged.folderTrust).toBe(true); // User setting should be used
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use system folderTrust over user setting', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||||
|
const userSettingsContent = {
|
||||||
|
folderTrust: false,
|
||||||
|
};
|
||||||
|
const workspaceSettingsContent = {
|
||||||
|
folderTrust: true, // This should be ignored
|
||||||
|
};
|
||||||
|
const systemSettingsContent = {
|
||||||
|
folderTrust: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === getSystemSettingsPath())
|
||||||
|
return JSON.stringify(systemSettingsContent);
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(workspaceSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.merged.folderTrust).toBe(true); // System setting should be used
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle contextFileName correctly when only in user settings', () => {
|
it('should handle contextFileName correctly when only in user settings', () => {
|
||||||
(mockFsExistsSync as Mock).mockImplementation(
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
@@ -622,6 +684,116 @@ describe('Settings Loading and Merging', () => {
|
|||||||
expect(settings.merged.mcpServers).toEqual({});
|
expect(settings.merged.mcpServers).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should merge chatCompression settings, with workspace taking precedence', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||||
|
const userSettingsContent = {
|
||||||
|
chatCompression: { contextPercentageThreshold: 0.5 },
|
||||||
|
};
|
||||||
|
const workspaceSettingsContent = {
|
||||||
|
chatCompression: { contextPercentageThreshold: 0.8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(workspaceSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
expect(settings.user.settings.chatCompression).toEqual({
|
||||||
|
contextPercentageThreshold: 0.5,
|
||||||
|
});
|
||||||
|
expect(settings.workspace.settings.chatCompression).toEqual({
|
||||||
|
contextPercentageThreshold: 0.8,
|
||||||
|
});
|
||||||
|
expect(settings.merged.chatCompression).toEqual({
|
||||||
|
contextPercentageThreshold: 0.8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle chatCompression when only in user settings', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
const userSettingsContent = {
|
||||||
|
chatCompression: { contextPercentageThreshold: 0.5 },
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.merged.chatCompression).toEqual({
|
||||||
|
contextPercentageThreshold: 0.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have chatCompression as an empty object if not in any settings file', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
|
||||||
|
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.merged.chatCompression).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore chatCompression if contextPercentageThreshold is invalid', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
const userSettingsContent = {
|
||||||
|
chatCompression: { contextPercentageThreshold: 1.5 },
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.merged.chatCompression).toBeUndefined();
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
'Invalid value for chatCompression.contextPercentageThreshold: "1.5". Please use a value between 0 and 1. Using default compression settings.',
|
||||||
|
);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deep merge chatCompression settings', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||||
|
const userSettingsContent = {
|
||||||
|
chatCompression: { contextPercentageThreshold: 0.5 },
|
||||||
|
};
|
||||||
|
const workspaceSettingsContent = {
|
||||||
|
chatCompression: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(workspaceSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
expect(settings.merged.chatCompression).toEqual({
|
||||||
|
contextPercentageThreshold: 0.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should merge includeDirectories from all scopes', () => {
|
it('should merge includeDirectories from all scopes', () => {
|
||||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||||
const systemSettingsContent = {
|
const systemSettingsContent = {
|
||||||
@@ -695,6 +867,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
customThemes: {},
|
customThemes: {},
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
includeDirectories: [],
|
includeDirectories: [],
|
||||||
|
chatCompression: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check that error objects are populated in settings.errors
|
// Check that error objects are populated in settings.errors
|
||||||
@@ -1132,6 +1305,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
customThemes: {},
|
customThemes: {},
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
includeDirectories: [],
|
includeDirectories: [],
|
||||||
|
chatCompression: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,17 +9,15 @@ import * as path from 'path';
|
|||||||
import { homedir, platform } from 'os';
|
import { homedir, platform } from 'os';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import {
|
import {
|
||||||
MCPServerConfig,
|
|
||||||
GEMINI_CONFIG_DIR as GEMINI_DIR,
|
GEMINI_CONFIG_DIR as GEMINI_DIR,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
BugCommandSettings,
|
|
||||||
TelemetrySettings,
|
|
||||||
AuthType,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import stripJsonComments from 'strip-json-comments';
|
import stripJsonComments from 'strip-json-comments';
|
||||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||||
import { DefaultDark } from '../ui/themes/default.js';
|
import { DefaultDark } from '../ui/themes/default.js';
|
||||||
import { CustomTheme } from '../ui/themes/theme.js';
|
import { Settings, MemoryImportFormat } from './settingsSchema.js';
|
||||||
|
|
||||||
|
export type { Settings, MemoryImportFormat };
|
||||||
|
|
||||||
export const SETTINGS_DIRECTORY_NAME = '.qwen';
|
export const SETTINGS_DIRECTORY_NAME = '.qwen';
|
||||||
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
||||||
@@ -43,7 +41,7 @@ export function getWorkspaceSettingsPath(workspaceDir: string): string {
|
|||||||
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
|
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
export type { DnsResolutionOrder } from './settingsSchema.js';
|
||||||
|
|
||||||
export enum SettingScope {
|
export enum SettingScope {
|
||||||
User = 'User',
|
User = 'User',
|
||||||
@@ -63,95 +61,6 @@ export interface AccessibilitySettings {
|
|||||||
disableLoadingPhrases?: boolean;
|
disableLoadingPhrases?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
|
||||||
theme?: string;
|
|
||||||
customThemes?: Record<string, CustomTheme>;
|
|
||||||
selectedAuthType?: AuthType;
|
|
||||||
useExternalAuth?: boolean;
|
|
||||||
sandbox?: boolean | string;
|
|
||||||
coreTools?: string[];
|
|
||||||
excludeTools?: string[];
|
|
||||||
toolDiscoveryCommand?: string;
|
|
||||||
toolCallCommand?: string;
|
|
||||||
mcpServerCommand?: string;
|
|
||||||
mcpServers?: Record<string, MCPServerConfig>;
|
|
||||||
allowMCPServers?: string[];
|
|
||||||
excludeMCPServers?: string[];
|
|
||||||
showMemoryUsage?: boolean;
|
|
||||||
contextFileName?: string | string[];
|
|
||||||
accessibility?: AccessibilitySettings;
|
|
||||||
telemetry?: TelemetrySettings;
|
|
||||||
usageStatisticsEnabled?: boolean;
|
|
||||||
preferredEditor?: string;
|
|
||||||
bugCommand?: BugCommandSettings;
|
|
||||||
checkpointing?: CheckpointingSettings;
|
|
||||||
autoConfigureMaxOldSpaceSize?: boolean;
|
|
||||||
/** The model name to use (e.g 'gemini-9.0-pro') */
|
|
||||||
model?: string;
|
|
||||||
enableOpenAILogging?: boolean;
|
|
||||||
|
|
||||||
// Git-aware file filtering settings
|
|
||||||
fileFiltering?: {
|
|
||||||
respectGitIgnore?: boolean;
|
|
||||||
respectGeminiIgnore?: boolean;
|
|
||||||
enableRecursiveFileSearch?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
hideWindowTitle?: boolean;
|
|
||||||
|
|
||||||
hideTips?: boolean;
|
|
||||||
hideBanner?: boolean;
|
|
||||||
|
|
||||||
// Setting for setting maximum number of user/model/tool turns in a session.
|
|
||||||
maxSessionTurns?: number;
|
|
||||||
|
|
||||||
// Setting for maximum token limit for conversation history before blocking requests
|
|
||||||
sessionTokenLimit?: number;
|
|
||||||
|
|
||||||
// Setting for maximum number of files and folders to show in folder structure
|
|
||||||
maxFolderItems?: number;
|
|
||||||
|
|
||||||
// A map of tool names to their summarization settings.
|
|
||||||
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
|
|
||||||
|
|
||||||
vimMode?: boolean;
|
|
||||||
memoryImportFormat?: 'tree' | 'flat';
|
|
||||||
|
|
||||||
// Flag to be removed post-launch.
|
|
||||||
ideModeFeature?: boolean;
|
|
||||||
/// IDE mode setting configured via slash command toggle.
|
|
||||||
ideMode?: boolean;
|
|
||||||
|
|
||||||
// Setting for disabling auto-update.
|
|
||||||
disableAutoUpdate?: boolean;
|
|
||||||
|
|
||||||
// Setting for disabling the update nag message.
|
|
||||||
disableUpdateNag?: boolean;
|
|
||||||
|
|
||||||
memoryDiscoveryMaxDirs?: number;
|
|
||||||
// Environment variables to exclude from project .env files
|
|
||||||
excludedProjectEnvVars?: string[];
|
|
||||||
dnsResolutionOrder?: DnsResolutionOrder;
|
|
||||||
|
|
||||||
sampling_params?: Record<string, unknown>;
|
|
||||||
systemPromptMappings?: Array<{
|
|
||||||
baseUrls: string[];
|
|
||||||
modelNames: string[];
|
|
||||||
template: string;
|
|
||||||
}>;
|
|
||||||
contentGenerator?: {
|
|
||||||
timeout?: number;
|
|
||||||
maxRetries?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
includeDirectories?: string[];
|
|
||||||
|
|
||||||
loadMemoryFromIncludeDirectories?: boolean;
|
|
||||||
|
|
||||||
// Web search API keys
|
|
||||||
tavilyApiKey?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SettingsError {
|
export interface SettingsError {
|
||||||
message: string;
|
message: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -191,9 +100,13 @@ export class LoadedSettings {
|
|||||||
const user = this.user.settings;
|
const user = this.user.settings;
|
||||||
const workspace = this.workspace.settings;
|
const workspace = this.workspace.settings;
|
||||||
|
|
||||||
|
// folderTrust is not supported at workspace level.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { folderTrust, ...workspaceWithoutFolderTrust } = workspace;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
...workspace,
|
...workspaceWithoutFolderTrust,
|
||||||
...system,
|
...system,
|
||||||
customThemes: {
|
customThemes: {
|
||||||
...(user.customThemes || {}),
|
...(user.customThemes || {}),
|
||||||
@@ -210,6 +123,11 @@ export class LoadedSettings {
|
|||||||
...(user.includeDirectories || []),
|
...(user.includeDirectories || []),
|
||||||
...(workspace.includeDirectories || []),
|
...(workspace.includeDirectories || []),
|
||||||
],
|
],
|
||||||
|
chatCompression: {
|
||||||
|
...(system.chatCompression || {}),
|
||||||
|
...(user.chatCompression || {}),
|
||||||
|
...(workspace.chatCompression || {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,6 +416,19 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
|||||||
settingsErrors,
|
settingsErrors,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Validate chatCompression settings
|
||||||
|
const chatCompression = loadedSettings.merged.chatCompression;
|
||||||
|
const threshold = chatCompression?.contextPercentageThreshold;
|
||||||
|
if (
|
||||||
|
threshold != null &&
|
||||||
|
(typeof threshold !== 'number' || threshold < 0 || threshold > 1)
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`Invalid value for chatCompression.contextPercentageThreshold: "${threshold}". Please use a value between 0 and 1. Using default compression settings.`,
|
||||||
|
);
|
||||||
|
delete loadedSettings.merged.chatCompression;
|
||||||
|
}
|
||||||
|
|
||||||
// Load environment with merged settings
|
// Load environment with merged settings
|
||||||
loadEnvironment(loadedSettings.merged);
|
loadEnvironment(loadedSettings.merged);
|
||||||
|
|
||||||
|
|||||||
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 React from 'react';
|
||||||
import { render } from 'ink';
|
import { render } from 'ink';
|
||||||
import { AppWrapper } from './ui/App.js';
|
import { AppWrapper } from './ui/App.js';
|
||||||
import { loadCliConfig, parseArguments, CliArgs } from './config/config.js';
|
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||||
import { readStdin } from './utils/readStdin.js';
|
import { readStdin } from './utils/readStdin.js';
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
import v8 from 'node:v8';
|
import v8 from 'node:v8';
|
||||||
@@ -25,19 +25,18 @@ import { themeManager } from './ui/themes/theme-manager.js';
|
|||||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||||
import { loadExtensions, Extension } from './config/extension.js';
|
import { loadExtensions } from './config/extension.js';
|
||||||
import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js';
|
import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js';
|
||||||
import { getCliVersion } from './utils/version.js';
|
import { getCliVersion } from './utils/version.js';
|
||||||
import {
|
import {
|
||||||
ApprovalMode,
|
|
||||||
Config,
|
Config,
|
||||||
EditTool,
|
|
||||||
ShellTool,
|
|
||||||
WriteFileTool,
|
|
||||||
sessionId,
|
sessionId,
|
||||||
logUserPrompt,
|
logUserPrompt,
|
||||||
AuthType,
|
AuthType,
|
||||||
getOauthClient,
|
getOauthClient,
|
||||||
|
logIdeConnection,
|
||||||
|
IdeConnectionEvent,
|
||||||
|
IdeConnectionType,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { validateAuthMethod } from './config/auth.js';
|
import { validateAuthMethod } from './config/auth.js';
|
||||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||||
@@ -45,6 +44,7 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
|||||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||||
import { appEvents, AppEvent } from './utils/events.js';
|
import { appEvents, AppEvent } from './utils/events.js';
|
||||||
|
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||||
|
|
||||||
export function validateDnsResolutionOrder(
|
export function validateDnsResolutionOrder(
|
||||||
order: string | undefined,
|
order: string | undefined,
|
||||||
@@ -191,6 +191,11 @@ export async function main() {
|
|||||||
|
|
||||||
await config.initialize();
|
await config.initialize();
|
||||||
|
|
||||||
|
if (config.getIdeMode() && config.getIdeModeFeature()) {
|
||||||
|
await config.getIdeClient().connect();
|
||||||
|
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
|
||||||
|
}
|
||||||
|
|
||||||
// Load custom themes from settings
|
// Load custom themes from settings
|
||||||
themeManager.loadCustomThemes(settings.merged.customThemes);
|
themeManager.loadCustomThemes(settings.merged.customThemes);
|
||||||
|
|
||||||
@@ -255,21 +260,20 @@ export async function main() {
|
|||||||
...(await getUserStartupWarnings(workspaceRoot)),
|
...(await getUserStartupWarnings(workspaceRoot)),
|
||||||
];
|
];
|
||||||
|
|
||||||
const shouldBeInteractive =
|
|
||||||
!!argv.promptInteractive || (process.stdin.isTTY && input?.length === 0);
|
|
||||||
|
|
||||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||||
if (shouldBeInteractive) {
|
if (config.isInteractive()) {
|
||||||
const version = await getCliVersion();
|
const version = await getCliVersion();
|
||||||
setWindowTitle(basename(workspaceRoot), settings);
|
setWindowTitle(basename(workspaceRoot), settings);
|
||||||
const instance = render(
|
const instance = render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AppWrapper
|
<SettingsContext.Provider value={settings}>
|
||||||
config={config}
|
<AppWrapper
|
||||||
settings={settings}
|
config={config}
|
||||||
startupWarnings={startupWarnings}
|
settings={settings}
|
||||||
version={version}
|
startupWarnings={startupWarnings}
|
||||||
/>
|
version={version}
|
||||||
|
/>
|
||||||
|
</SettingsContext.Provider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
{ exitOnCtrlC: false },
|
{ exitOnCtrlC: false },
|
||||||
);
|
);
|
||||||
@@ -308,12 +312,10 @@ export async function main() {
|
|||||||
prompt_length: input.length,
|
prompt_length: input.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Non-interactive mode handled by runNonInteractive
|
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||||
const nonInteractiveConfig = await loadNonInteractiveConfig(
|
settings.merged.selectedAuthType,
|
||||||
|
settings.merged.useExternalAuth,
|
||||||
config,
|
config,
|
||||||
extensions,
|
|
||||||
settings,
|
|
||||||
argv,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await runNonInteractive(nonInteractiveConfig, input, prompt_id);
|
await runNonInteractive(nonInteractiveConfig, input, prompt_id);
|
||||||
@@ -334,43 +336,3 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNonInteractiveConfig(
|
|
||||||
config: Config,
|
|
||||||
extensions: Extension[],
|
|
||||||
settings: LoadedSettings,
|
|
||||||
argv: CliArgs,
|
|
||||||
) {
|
|
||||||
let finalConfig = config;
|
|
||||||
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
|
|
||||||
// Everything is not allowed, ensure that only read-only tools are configured.
|
|
||||||
const existingExcludeTools = settings.merged.excludeTools || [];
|
|
||||||
const interactiveTools = [
|
|
||||||
ShellTool.Name,
|
|
||||||
EditTool.Name,
|
|
||||||
WriteFileTool.Name,
|
|
||||||
];
|
|
||||||
|
|
||||||
const newExcludeTools = [
|
|
||||||
...new Set([...existingExcludeTools, ...interactiveTools]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const nonInteractiveSettings = {
|
|
||||||
...settings.merged,
|
|
||||||
excludeTools: newExcludeTools,
|
|
||||||
};
|
|
||||||
finalConfig = await loadCliConfig(
|
|
||||||
nonInteractiveSettings,
|
|
||||||
extensions,
|
|
||||||
config.getSessionId(),
|
|
||||||
argv,
|
|
||||||
);
|
|
||||||
await finalConfig.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await validateNonInteractiveAuth(
|
|
||||||
settings.merged.selectedAuthType,
|
|
||||||
settings.merged.useExternalAuth,
|
|
||||||
finalConfig,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export async function runNonInteractive(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await config.initialize();
|
|
||||||
consolePatcher.patch();
|
consolePatcher.patch();
|
||||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
// Handle EPIPE errors when the output is piped to a command that closes early.
|
||||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
|||||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||||
|
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||||
import { isGitHubRepository } from '../utils/gitUtils.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the core, hard-coded slash commands that are an integral part
|
* Loads the core, hard-coded slash commands that are an integral part
|
||||||
@@ -73,8 +73,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
statsCommand,
|
statsCommand,
|
||||||
themeCommand,
|
themeCommand,
|
||||||
toolsCommand,
|
toolsCommand,
|
||||||
|
settingsCommand,
|
||||||
vimCommand,
|
vimCommand,
|
||||||
...(isGitHubRepository() ? [setupGithubCommand] : []),
|
setupGithubCommand,
|
||||||
];
|
];
|
||||||
|
|
||||||
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
|
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SandboxConfig,
|
SandboxConfig,
|
||||||
GeminiClient,
|
GeminiClient,
|
||||||
ideContext,
|
ideContext,
|
||||||
|
type AuthType,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
|
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
@@ -27,6 +28,7 @@ import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
|
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
|
||||||
import * as auth from '../config/auth.js';
|
import * as auth from '../config/auth.js';
|
||||||
|
import * as useTerminalSize from './hooks/useTerminalSize.js';
|
||||||
|
|
||||||
// Define a more complete mock server config based on actual Config
|
// Define a more complete mock server config based on actual Config
|
||||||
interface MockServerConfig {
|
interface MockServerConfig {
|
||||||
@@ -84,6 +86,7 @@ interface MockServerConfig {
|
|||||||
getAllGeminiMdFilenames: Mock<() => string[]>;
|
getAllGeminiMdFilenames: Mock<() => string[]>;
|
||||||
getGeminiClient: Mock<() => GeminiClient | undefined>;
|
getGeminiClient: Mock<() => GeminiClient | undefined>;
|
||||||
getUserTier: Mock<() => Promise<string | undefined>>;
|
getUserTier: Mock<() => Promise<string | undefined>>;
|
||||||
|
getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock @qwen-code/qwen-code-core and its Config class
|
// Mock @qwen-code/qwen-code-core and its Config class
|
||||||
@@ -157,6 +160,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||||||
getWorkspaceContext: vi.fn(() => ({
|
getWorkspaceContext: vi.fn(() => ({
|
||||||
getDirectories: vi.fn(() => []),
|
getDirectories: vi.fn(() => []),
|
||||||
})),
|
})),
|
||||||
|
getIdeClient: vi.fn(() => ({
|
||||||
|
getCurrentIde: vi.fn(() => 'vscode'),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,6 +188,7 @@ vi.mock('./hooks/useGeminiStream', () => ({
|
|||||||
submitQuery: vi.fn(),
|
submitQuery: vi.fn(),
|
||||||
initError: null,
|
initError: null,
|
||||||
pendingHistoryItems: [],
|
pendingHistoryItems: [],
|
||||||
|
thought: null,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -196,6 +203,13 @@ vi.mock('./hooks/useAuthCommand', () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./hooks/useFolderTrust', () => ({
|
||||||
|
useFolderTrust: vi.fn(() => ({
|
||||||
|
isFolderTrustDialogOpen: false,
|
||||||
|
handleFolderTrustSelect: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./hooks/useLogger', () => ({
|
vi.mock('./hooks/useLogger', () => ({
|
||||||
useLogger: vi.fn(() => ({
|
useLogger: vi.fn(() => ({
|
||||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||||
@@ -233,10 +247,14 @@ vi.mock('./utils/updateCheck.js', () => ({
|
|||||||
checkForUpdates: vi.fn(),
|
checkForUpdates: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./config/auth.js', () => ({
|
vi.mock('../config/auth.js', () => ({
|
||||||
validateAuthMethod: vi.fn(),
|
validateAuthMethod: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||||
|
useTerminalSize: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockedCheckForUpdates = vi.mocked(checkForUpdates);
|
const mockedCheckForUpdates = vi.mocked(checkForUpdates);
|
||||||
const { isGitRepository: mockedIsGitRepository } = vi.mocked(
|
const { isGitRepository: mockedIsGitRepository } = vi.mocked(
|
||||||
await import('@qwen-code/qwen-code-core'),
|
await import('@qwen-code/qwen-code-core'),
|
||||||
@@ -278,6 +296,11 @@ describe('App UI', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||||
|
columns: 120,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
|
||||||
const ServerConfigMocked = vi.mocked(ServerConfig, true);
|
const ServerConfigMocked = vi.mocked(ServerConfig, true);
|
||||||
mockConfig = new ServerConfigMocked({
|
mockConfig = new ServerConfigMocked({
|
||||||
embeddingModel: 'test-embedding-model',
|
embeddingModel: 'test-embedding-model',
|
||||||
@@ -1050,4 +1073,44 @@ describe('App UI', () => {
|
|||||||
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
|
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when in a narrow terminal', () => {
|
||||||
|
it('should render with a column layout', () => {
|
||||||
|
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||||
|
columns: 60,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lastFrame, unmount } = render(
|
||||||
|
<App
|
||||||
|
config={mockConfig as unknown as ServerConfig}
|
||||||
|
settings={mockSettings}
|
||||||
|
version={mockVersion}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
currentUnmount = unmount;
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FolderTrustDialog', () => {
|
||||||
|
it('should display the folder trust dialog when isFolderTrustDialogOpen is true', async () => {
|
||||||
|
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
|
||||||
|
vi.mocked(useFolderTrust).mockReturnValue({
|
||||||
|
isFolderTrustDialogOpen: true,
|
||||||
|
handleFolderTrustSelect: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lastFrame, unmount } = render(
|
||||||
|
<App
|
||||||
|
config={mockConfig as unknown as ServerConfig}
|
||||||
|
settings={mockSettings}
|
||||||
|
version={mockVersion}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
currentUnmount = unmount;
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(lastFrame()).toContain('Do you trust this folder?');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
useStdin,
|
useStdin,
|
||||||
useStdout,
|
useStdout,
|
||||||
useInput,
|
|
||||||
type Key as InkKeyType,
|
|
||||||
} from 'ink';
|
} from 'ink';
|
||||||
import { StreamingState, type HistoryItem, MessageType } from './types.js';
|
import { StreamingState, type HistoryItem, MessageType } from './types.js';
|
||||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||||
@@ -23,6 +21,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
|||||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||||
import { useAuthCommand } from './hooks/useAuthCommand.js';
|
import { useAuthCommand } from './hooks/useAuthCommand.js';
|
||||||
import { useQwenAuth } from './hooks/useQwenAuth.js';
|
import { useQwenAuth } from './hooks/useQwenAuth.js';
|
||||||
|
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||||
@@ -38,17 +37,18 @@ import { AuthDialog } from './components/AuthDialog.js';
|
|||||||
import { AuthInProgress } from './components/AuthInProgress.js';
|
import { AuthInProgress } from './components/AuthInProgress.js';
|
||||||
import { QwenOAuthProgress } from './components/QwenOAuthProgress.js';
|
import { QwenOAuthProgress } from './components/QwenOAuthProgress.js';
|
||||||
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
|
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
|
||||||
|
import { FolderTrustDialog } from './components/FolderTrustDialog.js';
|
||||||
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
|
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
|
||||||
|
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
|
||||||
import { Colors } from './colors.js';
|
import { Colors } from './colors.js';
|
||||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||||
import { LoadedSettings } from '../config/settings.js';
|
import { LoadedSettings, SettingScope } from '../config/settings.js';
|
||||||
import { Tips } from './components/Tips.js';
|
import { Tips } from './components/Tips.js';
|
||||||
import { ConsolePatcher } from './utils/ConsolePatcher.js';
|
import { ConsolePatcher } from './utils/ConsolePatcher.js';
|
||||||
import { registerCleanup } from '../utils/cleanup.js';
|
import { registerCleanup } from '../utils/cleanup.js';
|
||||||
import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
|
import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
|
||||||
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
|
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
|
||||||
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
|
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
|
||||||
import { IDEContextDetailDisplay } from './components/IDEContextDetailDisplay.js';
|
|
||||||
import { useHistory } from './hooks/useHistoryManager.js';
|
import { useHistory } from './hooks/useHistoryManager.js';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +64,10 @@ import {
|
|||||||
type IdeContext,
|
type IdeContext,
|
||||||
ideContext,
|
ideContext,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import {
|
||||||
|
IdeIntegrationNudge,
|
||||||
|
IdeIntegrationNudgeResult,
|
||||||
|
} from './IdeIntegrationNudge.js';
|
||||||
import { validateAuthMethod } from '../config/auth.js';
|
import { validateAuthMethod } from '../config/auth.js';
|
||||||
import { useLogger } from './hooks/useLogger.js';
|
import { useLogger } from './hooks/useLogger.js';
|
||||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||||
@@ -77,6 +81,8 @@ import { useBracketedPaste } from './hooks/useBracketedPaste.js';
|
|||||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||||
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
|
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
|
||||||
import { useVim } from './hooks/vim.js';
|
import { useVim } from './hooks/vim.js';
|
||||||
|
import { useKeypress, Key } from './hooks/useKeypress.js';
|
||||||
|
import { keyMatchers, Command } from './keyMatchers.js';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { UpdateNotification } from './components/UpdateNotification.js';
|
import { UpdateNotification } from './components/UpdateNotification.js';
|
||||||
import {
|
import {
|
||||||
@@ -89,8 +95,11 @@ import ansiEscapes from 'ansi-escapes';
|
|||||||
import { OverflowProvider } from './contexts/OverflowContext.js';
|
import { OverflowProvider } from './contexts/OverflowContext.js';
|
||||||
import { ShowMoreLines } from './components/ShowMoreLines.js';
|
import { ShowMoreLines } from './components/ShowMoreLines.js';
|
||||||
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
||||||
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
|
import { SettingsDialog } from './components/SettingsDialog.js';
|
||||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||||
import { appEvents, AppEvent } from '../utils/events.js';
|
import { appEvents, AppEvent } from '../utils/events.js';
|
||||||
|
import { isNarrowWidth } from './utils/isNarrowWidth.js';
|
||||||
|
|
||||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||||
|
|
||||||
@@ -117,6 +126,18 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
const nightly = version.includes('nightly');
|
const nightly = version.includes('nightly');
|
||||||
const { history, addItem, clearItems, loadHistory } = useHistory();
|
const { history, addItem, clearItems, loadHistory } = useHistory();
|
||||||
|
|
||||||
|
const [idePromptAnswered, setIdePromptAnswered] = useState(false);
|
||||||
|
const currentIDE = config.getIdeClient().getCurrentIde();
|
||||||
|
useEffect(() => {
|
||||||
|
registerCleanup(() => config.getIdeClient().disconnect());
|
||||||
|
}, [config]);
|
||||||
|
const shouldShowIdePrompt =
|
||||||
|
config.getIdeModeFeature() &&
|
||||||
|
currentIDE &&
|
||||||
|
!config.getIdeMode() &&
|
||||||
|
!settings.merged.hasSeenIdeIntegrationNudge &&
|
||||||
|
!idePromptAnswered;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = setUpdateHandler(addItem, setUpdateInfo);
|
const cleanup = setUpdateHandler(addItem, setUpdateInfo);
|
||||||
return cleanup;
|
return cleanup;
|
||||||
@@ -157,8 +178,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
|
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
|
||||||
const [showToolDescriptions, setShowToolDescriptions] =
|
const [showToolDescriptions, setShowToolDescriptions] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [showIDEContextDetail, setShowIDEContextDetail] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
|
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
|
||||||
const [quittingMessages, setQuittingMessages] = useState<
|
const [quittingMessages, setQuittingMessages] = useState<
|
||||||
HistoryItem[] | null
|
HistoryItem[] | null
|
||||||
@@ -174,6 +194,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
const [ideContextState, setIdeContextState] = useState<
|
const [ideContextState, setIdeContextState] = useState<
|
||||||
IdeContext | undefined
|
IdeContext | undefined
|
||||||
>();
|
>();
|
||||||
|
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -208,6 +229,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
const openPrivacyNotice = useCallback(() => {
|
const openPrivacyNotice = useCallback(() => {
|
||||||
setShowPrivacyNotice(true);
|
setShowPrivacyNotice(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
|
||||||
|
setShowEscapePrompt(showPrompt);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const initialPromptSubmitted = useRef(false);
|
const initialPromptSubmitted = useRef(false);
|
||||||
|
|
||||||
const errorCount = useMemo(
|
const errorCount = useMemo(
|
||||||
@@ -225,6 +251,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
handleThemeHighlight,
|
handleThemeHighlight,
|
||||||
} = useThemeCommand(settings, setThemeError, addItem);
|
} = useThemeCommand(settings, setThemeError, addItem);
|
||||||
|
|
||||||
|
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||||
|
useSettingsCommand();
|
||||||
|
|
||||||
|
const { isFolderTrustDialogOpen, handleFolderTrustSelect } =
|
||||||
|
useFolderTrust(settings);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
openAuthDialog,
|
openAuthDialog,
|
||||||
@@ -452,6 +484,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
|
|
||||||
// Terminal and UI setup
|
// Terminal and UI setup
|
||||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||||
|
const isNarrow = isNarrowWidth(terminalWidth);
|
||||||
const { stdin, setRawMode } = useStdin();
|
const { stdin, setRawMode } = useStdin();
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
@@ -460,7 +493,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
20,
|
20,
|
||||||
Math.floor(terminalWidth * widthFraction) - 3,
|
Math.floor(terminalWidth * widthFraction) - 3,
|
||||||
);
|
);
|
||||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 0.8));
|
||||||
|
|
||||||
// Utility callbacks
|
// Utility callbacks
|
||||||
const isValidPath = useCallback((filePath: string): boolean => {
|
const isValidPath = useCallback((filePath: string): boolean => {
|
||||||
@@ -499,6 +532,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
shellConfirmationRequest,
|
shellConfirmationRequest,
|
||||||
|
confirmationRequest,
|
||||||
} = useSlashCommandProcessor(
|
} = useSlashCommandProcessor(
|
||||||
config,
|
config,
|
||||||
settings,
|
settings,
|
||||||
@@ -513,17 +547,37 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
toggleCorgiMode,
|
toggleCorgiMode,
|
||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
openPrivacyNotice,
|
openPrivacyNotice,
|
||||||
|
openSettingsDialog,
|
||||||
toggleVimEnabled,
|
toggleVimEnabled,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
setGeminiMdFileCount,
|
setGeminiMdFileCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const buffer = useTextBuffer({
|
||||||
|
initialText: '',
|
||||||
|
viewport: { height: 10, width: inputWidth },
|
||||||
|
stdin,
|
||||||
|
setRawMode,
|
||||||
|
isValidPath,
|
||||||
|
shellModeActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [userMessages, setUserMessages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleUserCancel = useCallback(() => {
|
||||||
|
const lastUserMessage = userMessages.at(-1);
|
||||||
|
if (lastUserMessage) {
|
||||||
|
buffer.setText(lastUserMessage);
|
||||||
|
}
|
||||||
|
}, [buffer, userMessages]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
streamingState,
|
streamingState,
|
||||||
submitQuery,
|
submitQuery,
|
||||||
initError,
|
initError,
|
||||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||||
thought,
|
thought,
|
||||||
|
cancelOngoingRequest,
|
||||||
} = useGeminiStream(
|
} = useGeminiStream(
|
||||||
config.getGeminiClient(),
|
config.getGeminiClient(),
|
||||||
history,
|
history,
|
||||||
@@ -538,6 +592,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
modelSwitchedFromQuotaError,
|
modelSwitchedFromQuotaError,
|
||||||
setModelSwitchedFromQuotaError,
|
setModelSwitchedFromQuotaError,
|
||||||
refreshStatic,
|
refreshStatic,
|
||||||
|
handleUserCancel,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Input handling
|
// Input handling
|
||||||
@@ -551,14 +606,26 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
[submitQuery],
|
[submitQuery],
|
||||||
);
|
);
|
||||||
|
|
||||||
const buffer = useTextBuffer({
|
const handleIdePromptComplete = useCallback(
|
||||||
initialText: '',
|
(result: IdeIntegrationNudgeResult) => {
|
||||||
viewport: { height: 10, width: inputWidth },
|
if (result === 'yes') {
|
||||||
stdin,
|
handleSlashCommand('/ide install');
|
||||||
setRawMode,
|
settings.setValue(
|
||||||
isValidPath,
|
SettingScope.User,
|
||||||
shellModeActive,
|
'hasSeenIdeIntegrationNudge',
|
||||||
});
|
true,
|
||||||
|
);
|
||||||
|
} else if (result === 'dismiss') {
|
||||||
|
settings.setValue(
|
||||||
|
SettingScope.User,
|
||||||
|
'hasSeenIdeIntegrationNudge',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setIdePromptAnswered(true);
|
||||||
|
},
|
||||||
|
[handleSlashCommand, settings],
|
||||||
|
);
|
||||||
|
|
||||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||||
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
|
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
|
||||||
@@ -591,46 +658,75 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
[handleSlashCommand],
|
[handleSlashCommand],
|
||||||
);
|
);
|
||||||
|
|
||||||
useInput((input: string, key: InkKeyType) => {
|
const handleGlobalKeypress = useCallback(
|
||||||
let enteringConstrainHeightMode = false;
|
(key: Key) => {
|
||||||
if (!constrainHeight) {
|
let enteringConstrainHeightMode = false;
|
||||||
// Automatically re-enter constrain height mode if the user types
|
if (!constrainHeight) {
|
||||||
// anything. When constrainHeight==false, the user will experience
|
enteringConstrainHeightMode = true;
|
||||||
// significant flickering so it is best to disable it immediately when
|
setConstrainHeight(true);
|
||||||
// the user starts interacting with the app.
|
|
||||||
enteringConstrainHeightMode = true;
|
|
||||||
setConstrainHeight(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.ctrl && input === 'o') {
|
|
||||||
setShowErrorDetails((prev) => !prev);
|
|
||||||
} else if (key.ctrl && input === 't') {
|
|
||||||
const newValue = !showToolDescriptions;
|
|
||||||
setShowToolDescriptions(newValue);
|
|
||||||
|
|
||||||
const mcpServers = config.getMcpServers();
|
|
||||||
if (Object.keys(mcpServers || {}).length > 0) {
|
|
||||||
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
|
|
||||||
}
|
}
|
||||||
} else if (
|
|
||||||
key.ctrl &&
|
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
|
||||||
input === 'e' &&
|
setShowErrorDetails((prev) => !prev);
|
||||||
config.getIdeMode() &&
|
} else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) {
|
||||||
ideContextState
|
const newValue = !showToolDescriptions;
|
||||||
) {
|
setShowToolDescriptions(newValue);
|
||||||
setShowIDEContextDetail((prev) => !prev);
|
|
||||||
} else if (key.ctrl && (input === 'c' || input === 'C')) {
|
const mcpServers = config.getMcpServers();
|
||||||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
if (Object.keys(mcpServers || {}).length > 0) {
|
||||||
} else if (key.ctrl && (input === 'd' || input === 'D')) {
|
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
|
||||||
if (buffer.text.length > 0) {
|
}
|
||||||
// Do nothing if there is text in the input.
|
} else if (
|
||||||
return;
|
keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) &&
|
||||||
|
config.getIdeMode() &&
|
||||||
|
ideContextState
|
||||||
|
) {
|
||||||
|
// Show IDE status when in IDE mode and context is available.
|
||||||
|
handleSlashCommand('/ide status');
|
||||||
|
} else if (keyMatchers[Command.QUIT](key)) {
|
||||||
|
// When authenticating, let AuthInProgress component handle Ctrl+C.
|
||||||
|
if (isAuthenticating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ctrlCPressedOnce) {
|
||||||
|
cancelOngoingRequest?.();
|
||||||
|
}
|
||||||
|
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||||
|
} else if (keyMatchers[Command.EXIT](key)) {
|
||||||
|
if (buffer.text.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||||
|
} else if (
|
||||||
|
keyMatchers[Command.SHOW_MORE_LINES](key) &&
|
||||||
|
!enteringConstrainHeightMode
|
||||||
|
) {
|
||||||
|
setConstrainHeight(false);
|
||||||
}
|
}
|
||||||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
},
|
||||||
} else if (key.ctrl && input === 's' && !enteringConstrainHeightMode) {
|
[
|
||||||
setConstrainHeight(false);
|
constrainHeight,
|
||||||
}
|
setConstrainHeight,
|
||||||
});
|
setShowErrorDetails,
|
||||||
|
showToolDescriptions,
|
||||||
|
setShowToolDescriptions,
|
||||||
|
config,
|
||||||
|
ideContextState,
|
||||||
|
handleExit,
|
||||||
|
ctrlCPressedOnce,
|
||||||
|
setCtrlCPressedOnce,
|
||||||
|
ctrlCTimerRef,
|
||||||
|
buffer.text.length,
|
||||||
|
ctrlDPressedOnce,
|
||||||
|
setCtrlDPressedOnce,
|
||||||
|
ctrlDTimerRef,
|
||||||
|
handleSlashCommand,
|
||||||
|
isAuthenticating,
|
||||||
|
cancelOngoingRequest,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useKeypress(handleGlobalKeypress, { isActive: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
@@ -639,7 +735,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
}, [config, config.getGeminiMdFileCount]);
|
}, [config, config.getGeminiMdFileCount]);
|
||||||
|
|
||||||
const logger = useLogger();
|
const logger = useLogger();
|
||||||
const [userMessages, setUserMessages] = useState<string[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserMessages = async () => {
|
const fetchUserMessages = async () => {
|
||||||
@@ -791,6 +886,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
|
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
|
||||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5));
|
const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5));
|
||||||
// Arbitrary threshold to ensure that items in the static area are large
|
// Arbitrary threshold to ensure that items in the static area are large
|
||||||
@@ -819,11 +915,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
items={[
|
items={[
|
||||||
<Box flexDirection="column" key="header">
|
<Box flexDirection="column" key="header">
|
||||||
{!settings.merged.hideBanner && (
|
{!settings.merged.hideBanner && (
|
||||||
<Header
|
<Header version={version} nightly={nightly} />
|
||||||
terminalWidth={terminalWidth}
|
|
||||||
version={version}
|
|
||||||
nightly={nightly}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{!settings.merged.hideTips && <Tips config={config} />}
|
{!settings.merged.hideTips && <Tips config={config} />}
|
||||||
</Box>,
|
</Box>,
|
||||||
@@ -882,8 +974,30 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shellConfirmationRequest ? (
|
{shouldShowIdePrompt ? (
|
||||||
|
<IdeIntegrationNudge
|
||||||
|
ideName={config.getIdeClient().getDetectedIdeDisplayName()}
|
||||||
|
onComplete={handleIdePromptComplete}
|
||||||
|
/>
|
||||||
|
) : isFolderTrustDialogOpen ? (
|
||||||
|
<FolderTrustDialog onSelect={handleFolderTrustSelect} />
|
||||||
|
) : shellConfirmationRequest ? (
|
||||||
<ShellConfirmationDialog request={shellConfirmationRequest} />
|
<ShellConfirmationDialog request={shellConfirmationRequest} />
|
||||||
|
) : confirmationRequest ? (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{confirmationRequest.prompt}
|
||||||
|
<Box paddingY={1}>
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={[
|
||||||
|
{ label: 'Yes', value: true },
|
||||||
|
{ label: 'No', value: false },
|
||||||
|
]}
|
||||||
|
onSelect={(value: boolean) => {
|
||||||
|
confirmationRequest.onConfirm(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
) : isThemeDialogOpen ? (
|
) : isThemeDialogOpen ? (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{themeError && (
|
{themeError && (
|
||||||
@@ -903,6 +1017,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
terminalWidth={mainAreaWidth}
|
terminalWidth={mainAreaWidth}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : isSettingsDialogOpen ? (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
onSelect={() => closeSettingsDialog()}
|
||||||
|
onRestartRequest={() => process.exit(0)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
) : isAuthenticating ? (
|
) : isAuthenticating ? (
|
||||||
<>
|
<>
|
||||||
{isQwenAuth && isQwenAuthenticating ? (
|
{isQwenAuth && isQwenAuthenticating ? (
|
||||||
@@ -994,9 +1116,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
|
|
||||||
<Box
|
<Box
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
display="flex"
|
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
width="100%"
|
width="100%"
|
||||||
|
flexDirection={isNarrow ? 'column' : 'row'}
|
||||||
|
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
{process.env.GEMINI_SYSTEM_MD && (
|
{process.env.GEMINI_SYSTEM_MD && (
|
||||||
@@ -1010,6 +1133,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
<Text color={Colors.AccentYellow}>
|
<Text color={Colors.AccentYellow}>
|
||||||
Press Ctrl+D again to exit.
|
Press Ctrl+D again to exit.
|
||||||
</Text>
|
</Text>
|
||||||
|
) : showEscapePrompt ? (
|
||||||
|
<Text color={Colors.Gray}>Press Esc again to clear.</Text>
|
||||||
) : (
|
) : (
|
||||||
<ContextSummaryDisplay
|
<ContextSummaryDisplay
|
||||||
ideContext={ideContextState}
|
ideContext={ideContextState}
|
||||||
@@ -1021,7 +1146,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||||
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
||||||
!shellModeActive && (
|
!shellModeActive && (
|
||||||
<AutoAcceptIndicator
|
<AutoAcceptIndicator
|
||||||
@@ -1031,14 +1156,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
{shellModeActive && <ShellModeIndicator />}
|
{shellModeActive && <ShellModeIndicator />}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{showIDEContextDetail && (
|
|
||||||
<IDEContextDetailDisplay
|
|
||||||
ideContext={ideContextState}
|
|
||||||
detectedIdeDisplay={config
|
|
||||||
.getIdeClient()
|
|
||||||
.getDetectedIdeDisplayName()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showErrorDetails && (
|
{showErrorDetails && (
|
||||||
<OverflowProvider>
|
<OverflowProvider>
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
@@ -1067,6 +1185,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
commandContext={commandContext}
|
commandContext={commandContext}
|
||||||
shellModeActive={shellModeActive}
|
shellModeActive={shellModeActive}
|
||||||
setShellModeActive={setShellModeActive}
|
setShellModeActive={setShellModeActive}
|
||||||
|
onEscapePromptChange={handleEscapePromptChange}
|
||||||
focus={isFocused}
|
focus={isFocused}
|
||||||
vimHandleInput={vimHandleInput}
|
vimHandleInput={vimHandleInput}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -1117,7 +1236,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
errorCount={errorCount}
|
errorCount={errorCount}
|
||||||
showErrorDetails={showErrorDetails}
|
showErrorDetails={showErrorDetails}
|
||||||
showMemoryUsage={
|
showMemoryUsage={
|
||||||
config.getDebugMode() || config.getShowMemoryUsage()
|
config.getDebugMode() || settings.merged.showMemoryUsage || false
|
||||||
}
|
}
|
||||||
promptTokenCount={sessionStats.lastPromptTokenCount}
|
promptTokenCount={sessionStats.lastPromptTokenCount}
|
||||||
nightly={nightly}
|
nightly={nightly}
|
||||||
|
|||||||
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`] = `
|
exports[`App UI > should render the initial UI correctly 1`] = `
|
||||||
"
|
" I'm Feeling Lucky (esc to cancel, 0s)
|
||||||
I'm Feeling Lucky (esc to cancel, 0s)
|
|
||||||
|
|
||||||
|
|
||||||
/test/dir no sandbox (see /docs) model (100% context left)"
|
/test/dir no sandbox (see /docs) model (100% context left)"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`App UI > when in a narrow terminal > should render with a column layout 1`] = `
|
||||||
|
"
|
||||||
|
|
||||||
|
|
||||||
|
╭────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ > Type your message or @path/to/file │
|
||||||
|
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
dir
|
||||||
|
|
||||||
|
no sandbox (see /docs)
|
||||||
|
|
||||||
|
model (100% context left)| ✖ 5 errors (ctrl+o for details)"
|
||||||
|
`;
|
||||||
|
|||||||
@@ -168,8 +168,12 @@ describe('chatCommand', () => {
|
|||||||
describe('save subcommand', () => {
|
describe('save subcommand', () => {
|
||||||
let saveCommand: SlashCommand;
|
let saveCommand: SlashCommand;
|
||||||
const tag = 'my-tag';
|
const tag = 'my-tag';
|
||||||
|
let mockCheckpointExists: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
saveCommand = getSubCommand('save');
|
saveCommand = getSubCommand('save');
|
||||||
|
mockCheckpointExists = vi.fn().mockResolvedValue(false);
|
||||||
|
mockContext.services.logger.checkpointExists = mockCheckpointExists;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an error if tag is missing', async () => {
|
it('should return an error if tag is missing', async () => {
|
||||||
@@ -191,7 +195,7 @@ describe('chatCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should save the conversation', async () => {
|
it('should save the conversation if checkpoint does not exist', async () => {
|
||||||
const history: HistoryItemWithoutId[] = [
|
const history: HistoryItemWithoutId[] = [
|
||||||
{
|
{
|
||||||
type: 'user',
|
type: 'user',
|
||||||
@@ -199,8 +203,52 @@ describe('chatCommand', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
mockGetHistory.mockReturnValue(history);
|
mockGetHistory.mockReturnValue(history);
|
||||||
|
mockCheckpointExists.mockResolvedValue(false);
|
||||||
|
|
||||||
const result = await saveCommand?.action?.(mockContext, tag);
|
const result = await saveCommand?.action?.(mockContext, tag);
|
||||||
|
|
||||||
|
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
|
||||||
|
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: `Conversation checkpoint saved with tag: ${tag}.`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return confirm_action if checkpoint already exists', async () => {
|
||||||
|
mockCheckpointExists.mockResolvedValue(true);
|
||||||
|
mockContext.invocation = {
|
||||||
|
raw: `/chat save ${tag}`,
|
||||||
|
name: 'save',
|
||||||
|
args: tag,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await saveCommand?.action?.(mockContext, tag);
|
||||||
|
|
||||||
|
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
|
||||||
|
expect(mockSaveCheckpoint).not.toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
type: 'confirm_action',
|
||||||
|
originalInvocation: { raw: `/chat save ${tag}` },
|
||||||
|
});
|
||||||
|
// Check that prompt is a React element
|
||||||
|
expect(result).toHaveProperty('prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save the conversation if overwrite is confirmed', async () => {
|
||||||
|
const history: HistoryItemWithoutId[] = [
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
text: 'hello',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockGetHistory.mockReturnValue(history);
|
||||||
|
mockContext.overwriteConfirmed = true;
|
||||||
|
|
||||||
|
const result = await saveCommand?.action?.(mockContext, tag);
|
||||||
|
|
||||||
|
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
|
||||||
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
|
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fsPromises from 'fs/promises';
|
import * as fsPromises from 'fs/promises';
|
||||||
|
import React from 'react';
|
||||||
|
import { Text } from 'ink';
|
||||||
|
import { Colors } from '../colors.js';
|
||||||
import {
|
import {
|
||||||
CommandContext,
|
CommandContext,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
MessageActionReturn,
|
MessageActionReturn,
|
||||||
CommandKind,
|
CommandKind,
|
||||||
|
SlashCommandActionReturn,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { HistoryItemWithoutId, MessageType } from '../types.js';
|
import { HistoryItemWithoutId, MessageType } from '../types.js';
|
||||||
@@ -96,7 +100,7 @@ const saveCommand: SlashCommand = {
|
|||||||
description:
|
description:
|
||||||
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
|
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context, args): Promise<MessageActionReturn> => {
|
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
|
||||||
const tag = args.trim();
|
const tag = args.trim();
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
return {
|
return {
|
||||||
@@ -108,6 +112,26 @@ const saveCommand: SlashCommand = {
|
|||||||
|
|
||||||
const { logger, config } = context.services;
|
const { logger, config } = context.services;
|
||||||
await logger.initialize();
|
await logger.initialize();
|
||||||
|
|
||||||
|
if (!context.overwriteConfirmed) {
|
||||||
|
const exists = await logger.checkpointExists(tag);
|
||||||
|
if (exists) {
|
||||||
|
return {
|
||||||
|
type: 'confirm_action',
|
||||||
|
prompt: React.createElement(
|
||||||
|
Text,
|
||||||
|
null,
|
||||||
|
'A checkpoint with the tag ',
|
||||||
|
React.createElement(Text, { color: Colors.AccentPurple }, tag),
|
||||||
|
' already exists. Do you want to overwrite it?',
|
||||||
|
),
|
||||||
|
originalInvocation: {
|
||||||
|
raw: context.invocation?.raw || `/chat save ${tag}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const chat = await config?.getGeminiClient()?.getChat();
|
const chat = await config?.getGeminiClient()?.getChat();
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -93,13 +93,14 @@ describe('ideCommand', () => {
|
|||||||
} as unknown as ReturnType<Config['getIdeClient']>);
|
} as unknown as ReturnType<Config['getIdeClient']>);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show connected status', () => {
|
it('should show connected status', async () => {
|
||||||
mockGetConnectionStatus.mockReturnValue({
|
mockGetConnectionStatus.mockReturnValue({
|
||||||
status: core.IDEConnectionStatus.Connected,
|
status: core.IDEConnectionStatus.Connected,
|
||||||
});
|
});
|
||||||
const command = ideCommand(mockConfig);
|
const command = ideCommand(mockConfig);
|
||||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
const result = await command!.subCommands!.find(
|
||||||
.action!(mockContext, '');
|
(c) => c.name === 'status',
|
||||||
|
)!.action!(mockContext, '');
|
||||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -108,13 +109,14 @@ describe('ideCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show connecting status', () => {
|
it('should show connecting status', async () => {
|
||||||
mockGetConnectionStatus.mockReturnValue({
|
mockGetConnectionStatus.mockReturnValue({
|
||||||
status: core.IDEConnectionStatus.Connecting,
|
status: core.IDEConnectionStatus.Connecting,
|
||||||
});
|
});
|
||||||
const command = ideCommand(mockConfig);
|
const command = ideCommand(mockConfig);
|
||||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
const result = await command!.subCommands!.find(
|
||||||
.action!(mockContext, '');
|
(c) => c.name === 'status',
|
||||||
|
)!.action!(mockContext, '');
|
||||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -122,13 +124,14 @@ describe('ideCommand', () => {
|
|||||||
content: `🟡 Connecting...`,
|
content: `🟡 Connecting...`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('should show disconnected status', () => {
|
it('should show disconnected status', async () => {
|
||||||
mockGetConnectionStatus.mockReturnValue({
|
mockGetConnectionStatus.mockReturnValue({
|
||||||
status: core.IDEConnectionStatus.Disconnected,
|
status: core.IDEConnectionStatus.Disconnected,
|
||||||
});
|
});
|
||||||
const command = ideCommand(mockConfig);
|
const command = ideCommand(mockConfig);
|
||||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
const result = await command!.subCommands!.find(
|
||||||
.action!(mockContext, '');
|
(c) => c.name === 'status',
|
||||||
|
)!.action!(mockContext, '');
|
||||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -137,15 +140,16 @@ describe('ideCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show disconnected status with details', () => {
|
it('should show disconnected status with details', async () => {
|
||||||
const details = 'Something went wrong';
|
const details = 'Something went wrong';
|
||||||
mockGetConnectionStatus.mockReturnValue({
|
mockGetConnectionStatus.mockReturnValue({
|
||||||
status: core.IDEConnectionStatus.Disconnected,
|
status: core.IDEConnectionStatus.Disconnected,
|
||||||
details,
|
details,
|
||||||
});
|
});
|
||||||
const command = ideCommand(mockConfig);
|
const command = ideCommand(mockConfig);
|
||||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
const result = await command!.subCommands!.find(
|
||||||
.action!(mockContext, '');
|
(c) => c.name === 'status',
|
||||||
|
)!.action!(mockContext, '');
|
||||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import {
|
|||||||
Config,
|
Config,
|
||||||
DetectedIde,
|
DetectedIde,
|
||||||
IDEConnectionStatus,
|
IDEConnectionStatus,
|
||||||
IdeClient,
|
|
||||||
getIdeDisplayName,
|
getIdeDisplayName,
|
||||||
getIdeInstaller,
|
getIdeInstaller,
|
||||||
|
IdeClient,
|
||||||
|
type File,
|
||||||
|
ideContext,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
CommandContext,
|
CommandContext,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
@@ -49,6 +52,68 @@ function getIdeStatusMessage(ideClient: IdeClient): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatFileList(openFiles: File[]): string {
|
||||||
|
const basenameCounts = new Map<string, number>();
|
||||||
|
for (const file of openFiles) {
|
||||||
|
const basename = path.basename(file.path);
|
||||||
|
basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileList = openFiles
|
||||||
|
.map((file: File) => {
|
||||||
|
const basename = path.basename(file.path);
|
||||||
|
const isDuplicate = (basenameCounts.get(basename) || 0) > 1;
|
||||||
|
const parentDir = path.basename(path.dirname(file.path));
|
||||||
|
const displayName = isDuplicate
|
||||||
|
? `${basename} (/${parentDir})`
|
||||||
|
: basename;
|
||||||
|
|
||||||
|
return ` - ${displayName}${file.isActive ? ' (active)' : ''}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const infoMessage = `
|
||||||
|
(Note: The file list is limited to a number of recently accessed files within your workspace and only includes local files on disk)`;
|
||||||
|
|
||||||
|
return `\n\nOpen files:\n${fileList}\n${infoMessage}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
|
||||||
|
messageType: 'info' | 'error';
|
||||||
|
content: string;
|
||||||
|
}> {
|
||||||
|
const connection = ideClient.getConnectionStatus();
|
||||||
|
switch (connection.status) {
|
||||||
|
case IDEConnectionStatus.Connected: {
|
||||||
|
let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`;
|
||||||
|
const context = ideContext.getIdeContext();
|
||||||
|
const openFiles = context?.workspaceState?.openFiles;
|
||||||
|
if (openFiles && openFiles.length > 0) {
|
||||||
|
content += formatFileList(openFiles);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messageType: 'info',
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case IDEConnectionStatus.Connecting:
|
||||||
|
return {
|
||||||
|
messageType: 'info',
|
||||||
|
content: `🟡 Connecting...`,
|
||||||
|
};
|
||||||
|
default: {
|
||||||
|
let content = `🔴 Disconnected`;
|
||||||
|
if (connection?.details) {
|
||||||
|
content += `: ${connection.details}`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messageType: 'error',
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const ideCommand = (config: Config | null): SlashCommand | null => {
|
export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||||
if (!config || !config.getIdeModeFeature()) {
|
if (!config || !config.getIdeModeFeature()) {
|
||||||
return null;
|
return null;
|
||||||
@@ -84,8 +149,9 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
|||||||
name: 'status',
|
name: 'status',
|
||||||
description: 'check status of IDE integration',
|
description: 'check status of IDE integration',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (): SlashCommandActionReturn => {
|
action: async (): Promise<SlashCommandActionReturn> => {
|
||||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
const { messageType, content } =
|
||||||
|
await getIdeStatusMessageWithFiles(ideClient);
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType,
|
messageType,
|
||||||
|
|||||||
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
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
|
||||||
import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest';
|
import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest';
|
||||||
import * as child_process from 'child_process';
|
import * as gitUtils from '../../utils/gitUtils.js';
|
||||||
import { setupGithubCommand } from './setupGithubCommand.js';
|
import { setupGithubCommand } from './setupGithubCommand.js';
|
||||||
import { CommandContext, ToolActionReturn } from './types.js';
|
import { CommandContext, ToolActionReturn } from './types.js';
|
||||||
|
import * as commandUtils from '../utils/commandUtils.js';
|
||||||
|
|
||||||
vi.mock('child_process');
|
vi.mock('child_process');
|
||||||
|
|
||||||
describe('setupGithubCommand', () => {
|
// Mock fetch globally
|
||||||
beforeEach(() => {
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../utils/gitUtils.js', () => ({
|
||||||
|
isGitHubRepository: vi.fn(),
|
||||||
|
getGitRepoRoot: vi.fn(),
|
||||||
|
getLatestGitHubRelease: vi.fn(),
|
||||||
|
getGitHubRepoInfo: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/commandUtils.js', () => ({
|
||||||
|
getUrlOpenCommand: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('setupGithubCommand', async () => {
|
||||||
|
let scratchDir = '';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
scratchDir = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), 'setup-github-command-'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
if (scratchDir) await fs.rm(scratchDir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a tool action to download github workflows and handles paths', () => {
|
it('returns a tool action to download github workflows and handles paths', async () => {
|
||||||
const fakeRepoRoot = '/github.com/fake/repo/root';
|
const fakeRepoOwner = 'fake';
|
||||||
vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot);
|
const fakeRepoName = 'repo';
|
||||||
|
const fakeRepoRoot = scratchDir;
|
||||||
|
const fakeReleaseVersion = 'v1.2.3';
|
||||||
|
|
||||||
const result = setupGithubCommand.action?.(
|
const workflows = [
|
||||||
|
'gemini-cli.yml',
|
||||||
|
'gemini-issue-automated-triage.yml',
|
||||||
|
'gemini-issue-scheduled-triage.yml',
|
||||||
|
'gemini-pr-review.yml',
|
||||||
|
];
|
||||||
|
for (const workflow of workflows) {
|
||||||
|
vi.mocked(global.fetch).mockReturnValueOnce(
|
||||||
|
Promise.resolve(new Response(workflow)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true);
|
||||||
|
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
|
||||||
|
vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce(
|
||||||
|
fakeReleaseVersion,
|
||||||
|
);
|
||||||
|
vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({
|
||||||
|
owner: fakeRepoOwner,
|
||||||
|
repo: fakeRepoName,
|
||||||
|
});
|
||||||
|
vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValueOnce(
|
||||||
|
'fakeOpenCommand',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (await setupGithubCommand.action?.(
|
||||||
{} as CommandContext,
|
{} as CommandContext,
|
||||||
'',
|
'',
|
||||||
) as ToolActionReturn;
|
)) as ToolActionReturn;
|
||||||
|
|
||||||
expect(result.type).toBe('tool');
|
|
||||||
expect(result.toolName).toBe('run_shell_command');
|
|
||||||
expect(child_process.execSync).toHaveBeenCalledWith(
|
|
||||||
'git rev-parse --show-toplevel',
|
|
||||||
{
|
|
||||||
encoding: 'utf-8',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(child_process.execSync).toHaveBeenCalledWith('git remote -v', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { command } = result.toolArgs;
|
const { command } = result.toolArgs;
|
||||||
|
|
||||||
const expectedSubstrings = [
|
const expectedSubstrings = [
|
||||||
`mkdir -p "${fakeRepoRoot}/.github/workflows"`,
|
`set -eEuo pipefail`,
|
||||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-cli.yml"`,
|
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
|
||||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`,
|
|
||||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`,
|
|
||||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`,
|
|
||||||
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/v0/examples/workflows/',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const substring of expectedSubstrings) {
|
for (const substring of expectedSubstrings) {
|
||||||
expect(command).toContain(substring);
|
expect(command).toContain(substring);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
it('throws an error if git root cannot be determined', () => {
|
for (const workflow of workflows) {
|
||||||
vi.mocked(child_process.execSync).mockReturnValue('');
|
const workflowFile = path.join(
|
||||||
expect(() => {
|
scratchDir,
|
||||||
setupGithubCommand.action?.({} as CommandContext, '');
|
'.github',
|
||||||
}).toThrow('Unable to determine the Git root directory.');
|
'workflows',
|
||||||
|
workflow,
|
||||||
|
);
|
||||||
|
const contents = await fs.readFile(workflowFile, 'utf8');
|
||||||
|
expect(contents).toContain(workflow);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,32 +4,93 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'node:path';
|
||||||
import { execSync } from 'child_process';
|
import * as fs from 'node:fs';
|
||||||
import { isGitHubRepository } from '../../utils/gitUtils.js';
|
import { Writable } from 'node:stream';
|
||||||
|
import { ProxyAgent } from 'undici';
|
||||||
|
|
||||||
|
import { CommandContext } from '../../ui/commands/types.js';
|
||||||
|
import {
|
||||||
|
getGitRepoRoot,
|
||||||
|
getLatestGitHubRelease,
|
||||||
|
isGitHubRepository,
|
||||||
|
getGitHubRepoInfo,
|
||||||
|
} from '../../utils/gitUtils.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CommandKind,
|
CommandKind,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
SlashCommandActionReturn,
|
SlashCommandActionReturn,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
||||||
|
|
||||||
|
// Generate OS-specific commands to open the GitHub pages needed for setup.
|
||||||
|
function getOpenUrlsCommands(readmeUrl: string): string[] {
|
||||||
|
// Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc
|
||||||
|
const openCmd = getUrlOpenCommand();
|
||||||
|
|
||||||
|
// Build a list of URLs to open
|
||||||
|
const urlsToOpen = [readmeUrl];
|
||||||
|
|
||||||
|
const repoInfo = getGitHubRepoInfo();
|
||||||
|
if (repoInfo) {
|
||||||
|
urlsToOpen.push(
|
||||||
|
`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and join the individual commands
|
||||||
|
const commands = urlsToOpen.map((url) => `${openCmd} "${url}"`);
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
export const setupGithubCommand: SlashCommand = {
|
export const setupGithubCommand: SlashCommand = {
|
||||||
name: 'setup-github',
|
name: 'setup-github',
|
||||||
description: 'Set up GitHub Actions',
|
description: 'Set up GitHub Actions',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (): SlashCommandActionReturn => {
|
action: async (
|
||||||
const gitRootRepo = execSync('git rev-parse --show-toplevel', {
|
context: CommandContext,
|
||||||
encoding: 'utf-8',
|
): Promise<SlashCommandActionReturn> => {
|
||||||
}).trim();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
if (!isGitHubRepository()) {
|
if (!isGitHubRepository()) {
|
||||||
throw new Error('Unable to determine the Git root directory.');
|
throw new Error(
|
||||||
|
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = 'v0';
|
// Find the root directory of the repo
|
||||||
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`;
|
let gitRepoRoot: string;
|
||||||
|
try {
|
||||||
|
gitRepoRoot = getGitRepoRoot();
|
||||||
|
} catch (_error) {
|
||||||
|
console.debug(`Failed to get git repo root:`, _error);
|
||||||
|
throw new Error(
|
||||||
|
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the latest release tag from GitHub
|
||||||
|
const proxy = context?.services?.config?.getProxy();
|
||||||
|
const releaseTag = await getLatestGitHubRelease(proxy);
|
||||||
|
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
|
||||||
|
|
||||||
|
// Create the .github/workflows directory to download the files into
|
||||||
|
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(githubWorkflowsDir, { recursive: true });
|
||||||
|
} catch (_error) {
|
||||||
|
console.debug(
|
||||||
|
`Failed to create ${githubWorkflowsDir} directory:`,
|
||||||
|
_error,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Unable to create ${githubWorkflowsDir} directory. Do you have file permissions in the current directory?`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download each workflow in parallel - there aren't enough files to warrant
|
||||||
|
// a full workerpool model here.
|
||||||
const workflows = [
|
const workflows = [
|
||||||
'gemini-cli/gemini-cli.yml',
|
'gemini-cli/gemini-cli.yml',
|
||||||
'issue-triage/gemini-issue-automated-triage.yml',
|
'issue-triage/gemini-issue-automated-triage.yml',
|
||||||
@@ -37,15 +98,63 @@ export const setupGithubCommand: SlashCommand = {
|
|||||||
'pr-review/gemini-pr-review.yml',
|
'pr-review/gemini-pr-review.yml',
|
||||||
];
|
];
|
||||||
|
|
||||||
const command = [
|
const downloads = [];
|
||||||
'set -e',
|
for (const workflow of workflows) {
|
||||||
`mkdir -p "${gitRootRepo}/.github/workflows"`,
|
downloads.push(
|
||||||
...workflows.map((workflow) => {
|
(async () => {
|
||||||
const fileName = path.basename(workflow);
|
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||||
return `curl -fsSL -o "${gitRootRepo}/.github/workflows/${fileName}" "${workflowBaseUrl}/${workflow}"`;
|
const response = await fetch(endpoint, {
|
||||||
}),
|
method: 'GET',
|
||||||
'echo "Workflows downloaded successfully."',
|
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||||
].join(' && ');
|
signal: AbortSignal.any([
|
||||||
|
AbortSignal.timeout(30_000),
|
||||||
|
abortController.signal,
|
||||||
|
]),
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = response.body;
|
||||||
|
if (!body) {
|
||||||
|
throw new Error(
|
||||||
|
`Empty body while downloading ${endpoint}: ${response.status} - ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = path.resolve(
|
||||||
|
githubWorkflowsDir,
|
||||||
|
path.basename(workflow),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileStream = fs.createWriteStream(destination, {
|
||||||
|
mode: 0o644, // -rw-r--r--, user(rw), group(r), other(r)
|
||||||
|
flags: 'w', // write and overwrite
|
||||||
|
flush: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await body.pipeTo(Writable.toWeb(fileStream));
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all downloads to complete
|
||||||
|
await Promise.all(downloads).finally(() => {
|
||||||
|
// Stop existing downloads
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Print out a message
|
||||||
|
const commands = [];
|
||||||
|
commands.push('set -eEuo pipefail');
|
||||||
|
commands.push(
|
||||||
|
`echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
|
||||||
|
);
|
||||||
|
commands.push(...getOpenUrlsCommands(readmeUrl));
|
||||||
|
|
||||||
|
const command = `(${commands.join(' && ')})`;
|
||||||
return {
|
return {
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
toolName: 'run_shell_command',
|
toolName: 'run_shell_command',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
import { Content } from '@google/genai';
|
import { Content } from '@google/genai';
|
||||||
import { HistoryItemWithoutId } from '../types.js';
|
import { HistoryItemWithoutId } from '../types.js';
|
||||||
import { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
|
import { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
|
||||||
@@ -68,6 +69,8 @@ export interface CommandContext {
|
|||||||
/** A transient list of shell commands the user has approved for this session. */
|
/** A transient list of shell commands the user has approved for this session. */
|
||||||
sessionShellAllowlist: Set<string>;
|
sessionShellAllowlist: Set<string>;
|
||||||
};
|
};
|
||||||
|
// Flag to indicate if an overwrite has been confirmed
|
||||||
|
overwriteConfirmed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,7 +103,8 @@ export interface MessageActionReturn {
|
|||||||
*/
|
*/
|
||||||
export interface OpenDialogActionReturn {
|
export interface OpenDialogActionReturn {
|
||||||
type: 'dialog';
|
type: 'dialog';
|
||||||
dialog: 'auth' | 'theme' | 'editor' | 'privacy';
|
|
||||||
|
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +140,16 @@ export interface ConfirmShellCommandsActionReturn {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConfirmActionReturn {
|
||||||
|
type: 'confirm_action';
|
||||||
|
/** The React node to display as the confirmation prompt. */
|
||||||
|
prompt: ReactNode;
|
||||||
|
/** The original invocation context to be re-run after confirmation. */
|
||||||
|
originalInvocation: {
|
||||||
|
raw: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type SlashCommandActionReturn =
|
export type SlashCommandActionReturn =
|
||||||
| ToolActionReturn
|
| ToolActionReturn
|
||||||
| MessageActionReturn
|
| MessageActionReturn
|
||||||
@@ -143,7 +157,8 @@ export type SlashCommandActionReturn =
|
|||||||
| OpenDialogActionReturn
|
| OpenDialogActionReturn
|
||||||
| LoadHistoryActionReturn
|
| LoadHistoryActionReturn
|
||||||
| SubmitPromptActionReturn
|
| SubmitPromptActionReturn
|
||||||
| ConfirmShellCommandsActionReturn;
|
| ConfirmShellCommandsActionReturn
|
||||||
|
| ConfirmActionReturn;
|
||||||
|
|
||||||
export enum CommandKind {
|
export enum CommandKind {
|
||||||
BUILT_IN = 'built-in',
|
BUILT_IN = 'built-in',
|
||||||
|
|||||||
@@ -20,3 +20,14 @@ export const longAsciiLogo = `
|
|||||||
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||||
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const tinyAsciiLogo = `
|
||||||
|
███ █████████
|
||||||
|
░░░███ ███░░░░░███
|
||||||
|
░░░███ ███ ░░░
|
||||||
|
░░░███░███
|
||||||
|
███░ ░███ █████
|
||||||
|
███░ ░░███ ░░███
|
||||||
|
███░ ░░█████████
|
||||||
|
░░░ ░░░░░░░░░
|
||||||
|
`;
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export function AuthInProgress({
|
|||||||
}: AuthInProgressProps): React.JSX.Element {
|
}: AuthInProgressProps): React.JSX.Element {
|
||||||
const [timedOut, setTimedOut] = useState(false);
|
const [timedOut, setTimedOut] = useState(false);
|
||||||
|
|
||||||
useInput((_, key) => {
|
useInput((input, key) => {
|
||||||
if (key.escape) {
|
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
|
||||||
onTimeout();
|
onTimeout();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,8 @@ export function AuthInProgress({
|
|||||||
) : (
|
) : (
|
||||||
<Box>
|
<Box>
|
||||||
<Text>
|
<Text>
|
||||||
<Spinner type="dots" /> Waiting for auth... (Press ESC to cancel)
|
<Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
|
||||||
|
cancel)
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 React from 'react';
|
||||||
import { Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import {
|
import {
|
||||||
type IdeContext,
|
type IdeContext,
|
||||||
type MCPServerConfig,
|
type MCPServerConfig,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
|
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||||
|
|
||||||
interface ContextSummaryDisplayProps {
|
interface ContextSummaryDisplayProps {
|
||||||
geminiMdFileCount: number;
|
geminiMdFileCount: number;
|
||||||
@@ -29,6 +31,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||||||
showToolDescriptions,
|
showToolDescriptions,
|
||||||
ideContext,
|
ideContext,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
|
const isNarrow = isNarrowWidth(terminalWidth);
|
||||||
const mcpServerCount = Object.keys(mcpServers || {}).length;
|
const mcpServerCount = Object.keys(mcpServers || {}).length;
|
||||||
const blockedMcpServerCount = blockedMcpServers?.length || 0;
|
const blockedMcpServerCount = blockedMcpServers?.length || 0;
|
||||||
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
|
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
|
||||||
@@ -81,30 +85,36 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||||||
}
|
}
|
||||||
parts.push(blockedText);
|
parts.push(blockedText);
|
||||||
}
|
}
|
||||||
return parts.join(', ');
|
let text = parts.join(', ');
|
||||||
|
// Add ctrl+t hint when MCP servers are available
|
||||||
|
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||||
|
if (showToolDescriptions) {
|
||||||
|
text += ' (ctrl+t to toggle)';
|
||||||
|
} else {
|
||||||
|
text += ' (ctrl+t to view)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
let summaryText = 'Using: ';
|
const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean);
|
||||||
const summaryParts = [];
|
|
||||||
if (openFilesText) {
|
|
||||||
summaryParts.push(openFilesText);
|
|
||||||
}
|
|
||||||
if (geminiMdText) {
|
|
||||||
summaryParts.push(geminiMdText);
|
|
||||||
}
|
|
||||||
if (mcpText) {
|
|
||||||
summaryParts.push(mcpText);
|
|
||||||
}
|
|
||||||
summaryText += summaryParts.join(' | ');
|
|
||||||
|
|
||||||
// Add ctrl+t hint when MCP servers are available
|
if (isNarrow) {
|
||||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
return (
|
||||||
if (showToolDescriptions) {
|
<Box flexDirection="column">
|
||||||
summaryText += ' (ctrl+t to toggle)';
|
<Text color={Colors.Gray}>Using:</Text>
|
||||||
} else {
|
{summaryParts.map((part, index) => (
|
||||||
summaryText += ' (ctrl+t to view)';
|
<Text key={index} color={Colors.Gray}>
|
||||||
}
|
{' '}- {part}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Text color={Colors.Gray}>{summaryText}</Text>;
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color={Colors.Gray}>Using: {summaryParts.join(' | ')}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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 React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import {
|
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||||
shortenPath,
|
|
||||||
tildeifyPath,
|
|
||||||
tokenLimit,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import path from 'node:path';
|
||||||
import Gradient from 'ink-gradient';
|
import Gradient from 'ink-gradient';
|
||||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||||
|
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||||
import { DebugProfiler } from './DebugProfiler.js';
|
import { DebugProfiler } from './DebugProfiler.js';
|
||||||
|
|
||||||
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
|
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
model: string;
|
model: string;
|
||||||
targetDir: string;
|
targetDir: string;
|
||||||
@@ -48,29 +48,43 @@ export const Footer: React.FC<FooterProps> = ({
|
|||||||
nightly,
|
nightly,
|
||||||
vimMode,
|
vimMode,
|
||||||
}) => {
|
}) => {
|
||||||
const limit = tokenLimit(model);
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
const percentage = promptTokenCount / limit;
|
|
||||||
|
const isNarrow = isNarrowWidth(terminalWidth);
|
||||||
|
|
||||||
|
// Adjust path length based on terminal width
|
||||||
|
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.4));
|
||||||
|
const displayPath = isNarrow
|
||||||
|
? path.basename(tildeifyPath(targetDir))
|
||||||
|
: shortenPath(tildeifyPath(targetDir), pathLength);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box justifyContent="space-between" width="100%">
|
<Box
|
||||||
|
justifyContent="space-between"
|
||||||
|
width="100%"
|
||||||
|
flexDirection={isNarrow ? 'column' : 'row'}
|
||||||
|
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||||
|
>
|
||||||
<Box>
|
<Box>
|
||||||
{debugMode && <DebugProfiler />}
|
{debugMode && <DebugProfiler />}
|
||||||
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
|
{vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
|
||||||
{nightly ? (
|
{nightly ? (
|
||||||
<Gradient colors={Colors.GradientColors}>
|
<Gradient colors={theme.ui.gradient}>
|
||||||
<Text>
|
<Text>
|
||||||
{shortenPath(tildeifyPath(targetDir), 70)}
|
{displayPath}
|
||||||
{branchName && <Text> ({branchName}*)</Text>}
|
{branchName && <Text> ({branchName}*)</Text>}
|
||||||
</Text>
|
</Text>
|
||||||
</Gradient>
|
</Gradient>
|
||||||
) : (
|
) : (
|
||||||
<Text color={Colors.LightBlue}>
|
<Text color={theme.text.link}>
|
||||||
{shortenPath(tildeifyPath(targetDir), 70)}
|
{displayPath}
|
||||||
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
|
{branchName && (
|
||||||
|
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{debugMode && (
|
{debugMode && (
|
||||||
<Text color={Colors.AccentRed}>
|
<Text color={theme.status.error}>
|
||||||
{' ' + (debugMessage || '--debug')}
|
{' ' + (debugMessage || '--debug')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -78,49 +92,54 @@ export const Footer: React.FC<FooterProps> = ({
|
|||||||
|
|
||||||
{/* Middle Section: Centered Sandbox Info */}
|
{/* Middle Section: Centered Sandbox Info */}
|
||||||
<Box
|
<Box
|
||||||
flexGrow={1}
|
flexGrow={isNarrow ? 0 : 1}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent={isNarrow ? 'flex-start' : 'center'}
|
||||||
display="flex"
|
display="flex"
|
||||||
|
paddingX={isNarrow ? 0 : 1}
|
||||||
|
paddingTop={isNarrow ? 1 : 0}
|
||||||
>
|
>
|
||||||
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
|
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
|
||||||
<Text color="green">
|
<Text color="green">
|
||||||
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
|
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
|
||||||
</Text>
|
</Text>
|
||||||
) : process.env.SANDBOX === 'sandbox-exec' ? (
|
) : process.env.SANDBOX === 'sandbox-exec' ? (
|
||||||
<Text color={Colors.AccentYellow}>
|
<Text color={theme.status.warning}>
|
||||||
macOS Seatbelt{' '}
|
macOS Seatbelt{' '}
|
||||||
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
|
<Text color={theme.text.secondary}>
|
||||||
|
({process.env.SEATBELT_PROFILE})
|
||||||
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text color={Colors.AccentRed}>
|
<Text color={theme.status.error}>
|
||||||
no sandbox <Text color={Colors.Gray}>(see /docs)</Text>
|
no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right Section: Gemini Label and Console Summary */}
|
{/* Right Section: Gemini Label and Console Summary */}
|
||||||
<Box alignItems="center">
|
<Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
|
||||||
<Text color={Colors.AccentBlue}>
|
<Text color={theme.text.accent}>
|
||||||
{' '}
|
{isNarrow ? '' : ' '}
|
||||||
{model}{' '}
|
{model}{' '}
|
||||||
<Text color={Colors.Gray}>
|
<ContextUsageDisplay
|
||||||
({((1 - percentage) * 100).toFixed(0)}% context left)
|
promptTokenCount={promptTokenCount}
|
||||||
</Text>
|
model={model}
|
||||||
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
{corgiMode && (
|
{corgiMode && (
|
||||||
<Text>
|
<Text>
|
||||||
<Text color={Colors.Gray}>| </Text>
|
<Text color={theme.ui.symbol}>| </Text>
|
||||||
<Text color={Colors.AccentRed}>▼</Text>
|
<Text color={theme.status.error}>▼</Text>
|
||||||
<Text color={Colors.Foreground}>(´</Text>
|
<Text color={theme.text.primary}>(´</Text>
|
||||||
<Text color={Colors.AccentRed}>ᴥ</Text>
|
<Text color={theme.status.error}>ᴥ</Text>
|
||||||
<Text color={Colors.Foreground}>`)</Text>
|
<Text color={theme.text.primary}>`)</Text>
|
||||||
<Text color={Colors.AccentRed}>▼ </Text>
|
<Text color={theme.status.error}>▼ </Text>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!showErrorDetails && errorCount > 0 && (
|
{!showErrorDetails && errorCount > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={Colors.Gray}>| </Text>
|
<Text color={theme.ui.symbol}>| </Text>
|
||||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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 { Box, Text } from 'ink';
|
||||||
import Gradient from 'ink-gradient';
|
import Gradient from 'ink-gradient';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { shortAsciiLogo, longAsciiLogo } from './AsciiArt.js';
|
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
|
||||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||||
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
customAsciiArt?: string; // For user-defined ASCII art
|
customAsciiArt?: string; // For user-defined ASCII art
|
||||||
terminalWidth: number; // For responsive logo
|
|
||||||
version: string;
|
version: string;
|
||||||
nightly: boolean;
|
nightly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({
|
export const Header: React.FC<HeaderProps> = ({
|
||||||
customAsciiArt,
|
customAsciiArt,
|
||||||
terminalWidth,
|
|
||||||
version,
|
version,
|
||||||
nightly,
|
nightly,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
let displayTitle;
|
let displayTitle;
|
||||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||||
|
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||||
|
|
||||||
if (customAsciiArt) {
|
if (customAsciiArt) {
|
||||||
displayTitle = customAsciiArt;
|
displayTitle = customAsciiArt;
|
||||||
|
} else if (terminalWidth >= widthOfLongLogo) {
|
||||||
|
displayTitle = longAsciiLogo;
|
||||||
|
} else if (terminalWidth >= widthOfShortLogo) {
|
||||||
|
displayTitle = shortAsciiLogo;
|
||||||
} else {
|
} else {
|
||||||
displayTitle =
|
displayTitle = tinyAsciiLogo;
|
||||||
terminalWidth >= widthOfLongLogo ? longAsciiLogo : shortAsciiLogo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const artWidth = getAsciiArtWidth(displayTitle);
|
const artWidth = getAsciiArtWidth(displayTitle);
|
||||||
@@ -52,9 +56,13 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
{nightly && (
|
{nightly && (
|
||||||
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
||||||
<Gradient colors={Colors.GradientColors}>
|
{Colors.GradientColors ? (
|
||||||
|
<Gradient colors={Colors.GradientColors}>
|
||||||
|
<Text>v{version}</Text>
|
||||||
|
</Gradient>
|
||||||
|
) : (
|
||||||
<Text>v{version}</Text>
|
<Text>v{version}</Text>
|
||||||
</Gradient>
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type File, type IdeContext } from '@qwen-code/qwen-code-core';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { Colors } from '../colors.js';
|
|
||||||
|
|
||||||
interface IDEContextDetailDisplayProps {
|
|
||||||
ideContext: IdeContext | undefined;
|
|
||||||
detectedIdeDisplay: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IDEContextDetailDisplay({
|
|
||||||
ideContext,
|
|
||||||
detectedIdeDisplay,
|
|
||||||
}: IDEContextDetailDisplayProps) {
|
|
||||||
const openFiles = ideContext?.workspaceState?.openFiles;
|
|
||||||
if (!openFiles || openFiles.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
flexDirection="column"
|
|
||||||
marginTop={1}
|
|
||||||
borderStyle="round"
|
|
||||||
borderColor={Colors.AccentCyan}
|
|
||||||
paddingX={1}
|
|
||||||
>
|
|
||||||
<Text color={Colors.AccentCyan} bold>
|
|
||||||
{detectedIdeDisplay ? detectedIdeDisplay : 'IDE'} Context (ctrl+e to
|
|
||||||
toggle)
|
|
||||||
</Text>
|
|
||||||
{openFiles.length > 0 && (
|
|
||||||
<Box flexDirection="column" marginTop={1}>
|
|
||||||
<Text bold>Open files:</Text>
|
|
||||||
{openFiles.map((file: File) => (
|
|
||||||
<Text key={file.path}>
|
|
||||||
- {path.basename(file.path)}
|
|
||||||
{file.isActive ? ' (active)' : ''}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1191,6 +1191,106 @@ describe('InputPrompt', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('enhanced input UX - double ESC clear functionality', () => {
|
||||||
|
it('should clear buffer on second ESC press', async () => {
|
||||||
|
const onEscapePromptChange = vi.fn();
|
||||||
|
props.onEscapePromptChange = onEscapePromptChange;
|
||||||
|
props.buffer.setText('text to clear');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x1B');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x1B');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||||
|
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset escape state on any non-ESC key', async () => {
|
||||||
|
const onEscapePromptChange = vi.fn();
|
||||||
|
props.onEscapePromptChange = onEscapePromptChange;
|
||||||
|
props.buffer.setText('some text');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x1B');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(onEscapePromptChange).toHaveBeenCalledWith(true);
|
||||||
|
|
||||||
|
stdin.write('a');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ESC in shell mode by disabling shell mode', async () => {
|
||||||
|
props.shellModeActive = true;
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x1B');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.setShellModeActive).toHaveBeenCalledWith(false);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ESC when completion suggestions are showing', async () => {
|
||||||
|
mockedUseCommandCompletion.mockReturnValue({
|
||||||
|
...mockCommandCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [{ label: 'suggestion', value: 'suggestion' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x1B');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call onEscapePromptChange when not provided', async () => {
|
||||||
|
props.onEscapePromptChange = undefined;
|
||||||
|
props.buffer.setText('some text');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x1B');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not interfere with existing keyboard shortcuts', async () => {
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x0C');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.onClearScreen).toHaveBeenCalled();
|
||||||
|
|
||||||
|
stdin.write('\x01');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.buffer.move).toHaveBeenCalledWith('home');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('reverse search', () => {
|
describe('reverse search', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
props.shellModeActive = true;
|
props.shellModeActive = true;
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||||
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
|
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
|
||||||
@@ -17,6 +17,7 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
|
|||||||
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
||||||
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
||||||
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
||||||
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import { Config } from '@qwen-code/qwen-code-core';
|
import { Config } from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +41,7 @@ export interface InputPromptProps {
|
|||||||
suggestionsWidth: number;
|
suggestionsWidth: number;
|
||||||
shellModeActive: boolean;
|
shellModeActive: boolean;
|
||||||
setShellModeActive: (value: boolean) => void;
|
setShellModeActive: (value: boolean) => void;
|
||||||
|
onEscapePromptChange?: (showPrompt: boolean) => void;
|
||||||
vimHandleInput?: (key: Key) => boolean;
|
vimHandleInput?: (key: Key) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +59,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
suggestionsWidth,
|
suggestionsWidth,
|
||||||
shellModeActive,
|
shellModeActive,
|
||||||
setShellModeActive,
|
setShellModeActive,
|
||||||
|
onEscapePromptChange,
|
||||||
vimHandleInput,
|
vimHandleInput,
|
||||||
}) => {
|
}) => {
|
||||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||||
|
const [escPressCount, setEscPressCount] = useState(0);
|
||||||
|
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||||
|
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const [dirs, setDirs] = useState<readonly string[]>(
|
const [dirs, setDirs] = useState<readonly string[]>(
|
||||||
config.getWorkspaceContext().getDirectories(),
|
config.getWorkspaceContext().getDirectories(),
|
||||||
@@ -97,6 +103,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
const resetReverseSearchCompletionState =
|
const resetReverseSearchCompletionState =
|
||||||
reverseSearchCompletion.resetCompletionState;
|
reverseSearchCompletion.resetCompletionState;
|
||||||
|
|
||||||
|
const resetEscapeState = useCallback(() => {
|
||||||
|
if (escapeTimerRef.current) {
|
||||||
|
clearTimeout(escapeTimerRef.current);
|
||||||
|
escapeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setEscPressCount(0);
|
||||||
|
setShowEscapePrompt(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Notify parent component about escape prompt state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (onEscapePromptChange) {
|
||||||
|
onEscapePromptChange(showEscapePrompt);
|
||||||
|
}
|
||||||
|
}, [showEscapePrompt, onEscapePromptChange]);
|
||||||
|
|
||||||
|
// Clear escape prompt timer on unmount
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (escapeTimerRef.current) {
|
||||||
|
clearTimeout(escapeTimerRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmitAndClear = useCallback(
|
const handleSubmitAndClear = useCallback(
|
||||||
(submittedValue: string) => {
|
(submittedValue: string) => {
|
||||||
if (shellModeActive) {
|
if (shellModeActive) {
|
||||||
@@ -211,6 +243,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset ESC count and hide prompt on any non-ESC key
|
||||||
|
if (key.name !== 'escape') {
|
||||||
|
if (escPressCount > 0 || showEscapePrompt) {
|
||||||
|
resetEscapeState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
key.sequence === '!' &&
|
key.sequence === '!' &&
|
||||||
buffer.text === '' &&
|
buffer.text === '' &&
|
||||||
@@ -221,7 +260,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.name === 'escape') {
|
if (keyMatchers[Command.ESCAPE](key)) {
|
||||||
if (reverseSearchActive) {
|
if (reverseSearchActive) {
|
||||||
setReverseSearchActive(false);
|
setReverseSearchActive(false);
|
||||||
reverseSearchCompletion.resetCompletionState();
|
reverseSearchCompletion.resetCompletionState();
|
||||||
@@ -234,26 +273,48 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
buffer.moveToOffset(offset);
|
buffer.moveToOffset(offset);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shellModeActive) {
|
if (shellModeActive) {
|
||||||
setShellModeActive(false);
|
setShellModeActive(false);
|
||||||
|
resetEscapeState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.showSuggestions) {
|
if (completion.showSuggestions) {
|
||||||
completion.resetCompletionState();
|
completion.resetCompletionState();
|
||||||
|
resetEscapeState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle double ESC for clearing input
|
||||||
|
if (escPressCount === 0) {
|
||||||
|
if (buffer.text === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEscPressCount(1);
|
||||||
|
setShowEscapePrompt(true);
|
||||||
|
if (escapeTimerRef.current) {
|
||||||
|
clearTimeout(escapeTimerRef.current);
|
||||||
|
}
|
||||||
|
escapeTimerRef.current = setTimeout(() => {
|
||||||
|
resetEscapeState();
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
// clear input and immediately reset state
|
||||||
|
buffer.setText('');
|
||||||
|
resetCompletionState();
|
||||||
|
resetEscapeState();
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shellModeActive && key.ctrl && key.name === 'r') {
|
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||||
setReverseSearchActive(true);
|
setReverseSearchActive(true);
|
||||||
setTextBeforeReverseSearch(buffer.text);
|
setTextBeforeReverseSearch(buffer.text);
|
||||||
setCursorPosition(buffer.cursor);
|
setCursorPosition(buffer.cursor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.ctrl && key.name === 'l') {
|
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||||
onClearScreen();
|
onClearScreen();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -268,15 +329,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
} = reverseSearchCompletion;
|
} = reverseSearchCompletion;
|
||||||
|
|
||||||
if (showSuggestions) {
|
if (showSuggestions) {
|
||||||
if (key.name === 'up') {
|
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||||
navigateUp();
|
navigateUp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'down') {
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||||
navigateDown();
|
navigateDown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'tab') {
|
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
|
||||||
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
|
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
|
||||||
reverseSearchCompletion.resetCompletionState();
|
reverseSearchCompletion.resetCompletionState();
|
||||||
setReverseSearchActive(false);
|
setReverseSearchActive(false);
|
||||||
@@ -284,7 +345,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.name === 'return' && !key.ctrl) {
|
if (keyMatchers[Command.SUBMIT_REVERSE_SEARCH](key)) {
|
||||||
const textToSubmit =
|
const textToSubmit =
|
||||||
showSuggestions && activeSuggestionIndex > -1
|
showSuggestions && activeSuggestionIndex > -1
|
||||||
? suggestions[activeSuggestionIndex].value
|
? suggestions[activeSuggestionIndex].value
|
||||||
@@ -296,30 +357,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prevent up/down from falling through to regular history navigation
|
// Prevent up/down from falling through to regular history navigation
|
||||||
if (key.name === 'up' || key.name === 'down') {
|
if (
|
||||||
|
keyMatchers[Command.NAVIGATION_UP](key) ||
|
||||||
|
keyMatchers[Command.NAVIGATION_DOWN](key)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the command is a perfect match, pressing enter should execute it.
|
// If the command is a perfect match, pressing enter should execute it.
|
||||||
if (completion.isPerfectMatch && key.name === 'return') {
|
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
|
||||||
handleSubmitAndClear(buffer.text);
|
handleSubmitAndClear(buffer.text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.showSuggestions) {
|
if (completion.showSuggestions) {
|
||||||
if (completion.suggestions.length > 1) {
|
if (completion.suggestions.length > 1) {
|
||||||
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
|
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||||
completion.navigateUp();
|
completion.navigateUp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
|
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
||||||
completion.navigateDown();
|
completion.navigateDown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
|
if (keyMatchers[Command.ACCEPT_SUGGESTION](key)) {
|
||||||
if (completion.suggestions.length > 0) {
|
if (completion.suggestions.length > 0) {
|
||||||
const targetIndex =
|
const targetIndex =
|
||||||
completion.activeSuggestionIndex === -1
|
completion.activeSuggestionIndex === -1
|
||||||
@@ -334,17 +398,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!shellModeActive) {
|
if (!shellModeActive) {
|
||||||
if (key.ctrl && key.name === 'p') {
|
if (keyMatchers[Command.HISTORY_UP](key)) {
|
||||||
inputHistory.navigateUp();
|
inputHistory.navigateUp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.ctrl && key.name === 'n') {
|
if (keyMatchers[Command.HISTORY_DOWN](key)) {
|
||||||
inputHistory.navigateDown();
|
inputHistory.navigateDown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Handle arrow-up/down for history on single-line or at edges
|
// Handle arrow-up/down for history on single-line or at edges
|
||||||
if (
|
if (
|
||||||
key.name === 'up' &&
|
keyMatchers[Command.NAVIGATION_UP](key) &&
|
||||||
(buffer.allVisualLines.length === 1 ||
|
(buffer.allVisualLines.length === 1 ||
|
||||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
||||||
) {
|
) {
|
||||||
@@ -352,7 +416,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
key.name === 'down' &&
|
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||||||
(buffer.allVisualLines.length === 1 ||
|
(buffer.allVisualLines.length === 1 ||
|
||||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||||
) {
|
) {
|
||||||
@@ -360,18 +424,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (key.name === 'up') {
|
// Shell History Navigation
|
||||||
|
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||||
const prevCommand = shellHistory.getPreviousCommand();
|
const prevCommand = shellHistory.getPreviousCommand();
|
||||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'down') {
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||||
const nextCommand = shellHistory.getNextCommand();
|
const nextCommand = shellHistory.getNextCommand();
|
||||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
|
|
||||||
|
if (keyMatchers[Command.SUBMIT](key)) {
|
||||||
if (buffer.text.trim()) {
|
if (buffer.text.trim()) {
|
||||||
const [row, col] = buffer.cursor;
|
const [row, col] = buffer.cursor;
|
||||||
const line = buffer.lines[row];
|
const line = buffer.lines[row];
|
||||||
@@ -387,50 +453,48 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Newline insertion
|
// Newline insertion
|
||||||
if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
|
if (keyMatchers[Command.NEWLINE](key)) {
|
||||||
buffer.newline();
|
buffer.newline();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+A (Home) / Ctrl+E (End)
|
// Ctrl+A (Home) / Ctrl+E (End)
|
||||||
if (key.ctrl && key.name === 'a') {
|
if (keyMatchers[Command.HOME](key)) {
|
||||||
buffer.move('home');
|
buffer.move('home');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.ctrl && key.name === 'e') {
|
if (keyMatchers[Command.END](key)) {
|
||||||
buffer.move('end');
|
buffer.move('end');
|
||||||
buffer.moveToOffset(cpLen(buffer.text));
|
buffer.moveToOffset(cpLen(buffer.text));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Ctrl+C (Clear input)
|
// Ctrl+C (Clear input)
|
||||||
if (key.ctrl && key.name === 'c') {
|
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||||
if (buffer.text.length > 0) {
|
if (buffer.text.length > 0) {
|
||||||
buffer.setText('');
|
buffer.setText('');
|
||||||
resetCompletionState();
|
resetCompletionState();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill line commands
|
// Kill line commands
|
||||||
if (key.ctrl && key.name === 'k') {
|
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||||
buffer.killLineRight();
|
buffer.killLineRight();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.ctrl && key.name === 'u') {
|
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
||||||
buffer.killLineLeft();
|
buffer.killLineLeft();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// External editor
|
// External editor
|
||||||
const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
|
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
||||||
if (isCtrlX) {
|
|
||||||
buffer.openInExternalEditor();
|
buffer.openInExternalEditor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+V for clipboard image paste
|
// Ctrl+V for clipboard image paste
|
||||||
if (key.ctrl && key.name === 'v') {
|
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
|
||||||
handleClipboardImage();
|
handleClipboardImage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -451,6 +515,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
reverseSearchCompletion,
|
reverseSearchCompletion,
|
||||||
handleClipboardImage,
|
handleClipboardImage,
|
||||||
resetCompletionState,
|
resetCompletionState,
|
||||||
|
escPressCount,
|
||||||
|
showEscapePrompt,
|
||||||
|
resetEscapeState,
|
||||||
vimHandleInput,
|
vimHandleInput,
|
||||||
reverseSearchActive,
|
reverseSearchActive,
|
||||||
textBeforeReverseSearch,
|
textBeforeReverseSearch,
|
||||||
@@ -469,15 +536,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
borderColor={shellModeActive ? Colors.AccentYellow : Colors.AccentBlue}
|
borderColor={
|
||||||
|
shellModeActive ? theme.status.warning : theme.border.focused
|
||||||
|
}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
|
color={shellModeActive ? theme.status.warning : theme.text.accent}
|
||||||
>
|
>
|
||||||
{shellModeActive ? (
|
{shellModeActive ? (
|
||||||
reverseSearchActive ? (
|
reverseSearchActive ? (
|
||||||
<Text color={Colors.AccentCyan}>(r:) </Text>
|
<Text color={theme.text.link}>(r:) </Text>
|
||||||
) : (
|
) : (
|
||||||
'! '
|
'! '
|
||||||
)
|
)
|
||||||
@@ -490,10 +559,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
focus ? (
|
focus ? (
|
||||||
<Text>
|
<Text>
|
||||||
{chalk.inverse(placeholder.slice(0, 1))}
|
{chalk.inverse(placeholder.slice(0, 1))}
|
||||||
<Text color={Colors.Gray}>{placeholder.slice(1)}</Text>
|
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text color={Colors.Gray}>{placeholder}</Text>
|
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
||||||
@@ -536,7 +605,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{completion.showSuggestions && (
|
{completion.showSuggestions && (
|
||||||
<Box>
|
<Box paddingRight={2}>
|
||||||
<SuggestionsDisplay
|
<SuggestionsDisplay
|
||||||
suggestions={completion.suggestions}
|
suggestions={completion.suggestions}
|
||||||
activeIndex={completion.activeSuggestionIndex}
|
activeIndex={completion.activeSuggestionIndex}
|
||||||
@@ -548,7 +617,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{reverseSearchActive && (
|
{reverseSearchActive && (
|
||||||
<Box>
|
<Box paddingRight={2}>
|
||||||
<SuggestionsDisplay
|
<SuggestionsDisplay
|
||||||
suggestions={reverseSearchCompletion.suggestions}
|
suggestions={reverseSearchCompletion.suggestions}
|
||||||
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
|
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { LoadingIndicator } from './LoadingIndicator.js';
|
|||||||
import { StreamingContext } from '../contexts/StreamingContext.js';
|
import { StreamingContext } from '../contexts/StreamingContext.js';
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||||
|
|
||||||
// Mock GeminiRespondingSpinner
|
// Mock GeminiRespondingSpinner
|
||||||
vi.mock('./GeminiRespondingSpinner.js', () => ({
|
vi.mock('./GeminiRespondingSpinner.js', () => ({
|
||||||
@@ -29,10 +30,18 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||||
|
useTerminalSize: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||||
|
|
||||||
const renderWithContext = (
|
const renderWithContext = (
|
||||||
ui: React.ReactElement,
|
ui: React.ReactElement,
|
||||||
streamingStateValue: StreamingState,
|
streamingStateValue: StreamingState,
|
||||||
|
width = 120,
|
||||||
) => {
|
) => {
|
||||||
|
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||||
const contextValue: StreamingState = streamingStateValue;
|
const contextValue: StreamingState = streamingStateValue;
|
||||||
return render(
|
return render(
|
||||||
<StreamingContext.Provider value={contextValue}>
|
<StreamingContext.Provider value={contextValue}>
|
||||||
@@ -223,4 +232,65 @@ describe('<LoadingIndicator />', () => {
|
|||||||
expect(output).toContain('This should be displayed');
|
expect(output).toContain('This should be displayed');
|
||||||
expect(output).not.toContain('This should not be displayed');
|
expect(output).not.toContain('This should not be displayed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('responsive layout', () => {
|
||||||
|
it('should render on a single line on a wide terminal', () => {
|
||||||
|
const { lastFrame } = renderWithContext(
|
||||||
|
<LoadingIndicator
|
||||||
|
{...defaultProps}
|
||||||
|
rightContent={<Text>Right</Text>}
|
||||||
|
/>,
|
||||||
|
StreamingState.Responding,
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
const output = lastFrame();
|
||||||
|
// Check for single line output
|
||||||
|
expect(output?.includes('\n')).toBe(false);
|
||||||
|
expect(output).toContain('Loading...');
|
||||||
|
expect(output).toContain('(esc to cancel, 5s)');
|
||||||
|
expect(output).toContain('Right');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render on multiple lines on a narrow terminal', () => {
|
||||||
|
const { lastFrame } = renderWithContext(
|
||||||
|
<LoadingIndicator
|
||||||
|
{...defaultProps}
|
||||||
|
rightContent={<Text>Right</Text>}
|
||||||
|
/>,
|
||||||
|
StreamingState.Responding,
|
||||||
|
79,
|
||||||
|
);
|
||||||
|
const output = lastFrame();
|
||||||
|
const lines = output?.split('\n');
|
||||||
|
// Expecting 3 lines:
|
||||||
|
// 1. Spinner + Primary Text
|
||||||
|
// 2. Cancel + Timer
|
||||||
|
// 3. Right Content
|
||||||
|
expect(lines).toHaveLength(3);
|
||||||
|
if (lines) {
|
||||||
|
expect(lines[0]).toContain('Loading...');
|
||||||
|
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
|
||||||
|
expect(lines[1]).toContain('(esc to cancel, 5s)');
|
||||||
|
expect(lines[2]).toContain('Right');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use wide layout at 80 columns', () => {
|
||||||
|
const { lastFrame } = renderWithContext(
|
||||||
|
<LoadingIndicator {...defaultProps} />,
|
||||||
|
StreamingState.Responding,
|
||||||
|
80,
|
||||||
|
);
|
||||||
|
expect(lastFrame()?.includes('\n')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use narrow layout at 79 columns', () => {
|
||||||
|
const { lastFrame } = renderWithContext(
|
||||||
|
<LoadingIndicator {...defaultProps} />,
|
||||||
|
StreamingState.Responding,
|
||||||
|
79,
|
||||||
|
);
|
||||||
|
expect(lastFrame()?.includes('\n')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useStreamingContext } from '../contexts/StreamingContext.js';
|
|||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
||||||
import { formatDuration } from '../utils/formatters.js';
|
import { formatDuration } from '../utils/formatters.js';
|
||||||
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
|
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||||
|
|
||||||
interface LoadingIndicatorProps {
|
interface LoadingIndicatorProps {
|
||||||
currentLoadingPhrase?: string;
|
currentLoadingPhrase?: string;
|
||||||
@@ -27,6 +29,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
|||||||
thought,
|
thought,
|
||||||
}) => {
|
}) => {
|
||||||
const streamingState = useStreamingContext();
|
const streamingState = useStreamingContext();
|
||||||
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
|
const isNarrow = isNarrowWidth(terminalWidth);
|
||||||
|
|
||||||
if (streamingState === StreamingState.Idle) {
|
if (streamingState === StreamingState.Idle) {
|
||||||
return null;
|
return null;
|
||||||
@@ -34,28 +38,45 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
|||||||
|
|
||||||
const primaryText = thought?.subject || currentLoadingPhrase;
|
const primaryText = thought?.subject || currentLoadingPhrase;
|
||||||
|
|
||||||
|
const cancelAndTimerContent =
|
||||||
|
streamingState !== StreamingState.WaitingForConfirmation
|
||||||
|
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box marginTop={1} paddingLeft={0} flexDirection="column">
|
<Box paddingLeft={0} flexDirection="column">
|
||||||
{/* Main loading line */}
|
{/* Main loading line */}
|
||||||
<Box>
|
<Box
|
||||||
<Box marginRight={1}>
|
width="100%"
|
||||||
<GeminiRespondingSpinner
|
flexDirection={isNarrow ? 'column' : 'row'}
|
||||||
nonRespondingDisplay={
|
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||||
streamingState === StreamingState.WaitingForConfirmation
|
>
|
||||||
? '⠏'
|
<Box>
|
||||||
: ''
|
<Box marginRight={1}>
|
||||||
}
|
<GeminiRespondingSpinner
|
||||||
/>
|
nonRespondingDisplay={
|
||||||
|
streamingState === StreamingState.WaitingForConfirmation
|
||||||
|
? '⠏'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{primaryText && (
|
||||||
|
<Text color={Colors.AccentPurple}>{primaryText}</Text>
|
||||||
|
)}
|
||||||
|
{!isNarrow && cancelAndTimerContent && (
|
||||||
|
<Text color={Colors.Gray}> {cancelAndTimerContent}</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>}
|
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
|
||||||
<Text color={Colors.Gray}>
|
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
|
||||||
{streamingState === StreamingState.WaitingForConfirmation
|
|
||||||
? ''
|
|
||||||
: ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`}
|
|
||||||
</Text>
|
|
||||||
<Box flexGrow={1}>{/* Spacer */}</Box>
|
|
||||||
{rightContent && <Box>{rightContent}</Box>}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
{isNarrow && cancelAndTimerContent && (
|
||||||
|
<Box>
|
||||||
|
<Text color={Colors.Gray}>{cancelAndTimerContent}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{isNarrow && rightContent && <Box>{rightContent}</Box>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
831
packages/cli/src/ui/components/SettingsDialog.test.tsx
Normal file
831
packages/cli/src/ui/components/SettingsDialog.test.tsx
Normal file
@@ -0,0 +1,831 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This test suite covers:
|
||||||
|
* - Initial rendering and display state
|
||||||
|
* - Keyboard navigation (arrows, vim keys, Tab)
|
||||||
|
* - Settings toggling (Enter, Space)
|
||||||
|
* - Focus section switching between settings and scope selector
|
||||||
|
* - Scope selection and settings persistence across scopes
|
||||||
|
* - Restart-required vs immediate settings behavior
|
||||||
|
* - VimModeContext integration
|
||||||
|
* - Complex user interaction workflows
|
||||||
|
* - Error handling and edge cases
|
||||||
|
* - Display values for inherited and overridden settings
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render } from 'ink-testing-library';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { SettingsDialog } from './SettingsDialog.js';
|
||||||
|
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
|
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||||
|
|
||||||
|
// Mock the VimModeContext
|
||||||
|
const mockToggleVimEnabled = vi.fn();
|
||||||
|
const mockSetVimMode = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../contexts/VimModeContext.js', async () => {
|
||||||
|
const actual = await vi.importActual('../contexts/VimModeContext.js');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useVimMode: () => ({
|
||||||
|
vimEnabled: false,
|
||||||
|
vimMode: 'INSERT' as const,
|
||||||
|
toggleVimEnabled: mockToggleVimEnabled,
|
||||||
|
setVimMode: mockSetVimMode,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../utils/settingsUtils.js', async () => {
|
||||||
|
const actual = await vi.importActual('../../utils/settingsUtils.js');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
saveModifiedSettings: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock console.log to avoid noise in tests
|
||||||
|
const originalConsoleLog = console.log;
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
|
||||||
|
describe('SettingsDialog', () => {
|
||||||
|
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
console.log = vi.fn();
|
||||||
|
console.error = vi.fn();
|
||||||
|
mockToggleVimEnabled.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockSettings = (
|
||||||
|
userSettings = {},
|
||||||
|
systemSettings = {},
|
||||||
|
workspaceSettings = {},
|
||||||
|
) =>
|
||||||
|
new LoadedSettings(
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {}, ...systemSettings },
|
||||||
|
path: '/system/settings.json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: {
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
|
...userSettings,
|
||||||
|
},
|
||||||
|
path: '/user/settings.json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings },
|
||||||
|
path: '/workspace/settings.json',
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('Initial Rendering', () => {
|
||||||
|
it('should render the settings dialog with default state', () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('Settings');
|
||||||
|
expect(output).toContain('Apply To');
|
||||||
|
expect(output).toContain('Use Enter to select, Tab to change focus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show settings list with default values', () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
// Should show some default settings
|
||||||
|
expect(output).toContain('●'); // Active indicator
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should highlight first setting by default', () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
// First item should be highlighted with green color and active indicator
|
||||||
|
expect(output).toContain('●');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings Navigation', () => {
|
||||||
|
it('should navigate down with arrow key', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press down arrow
|
||||||
|
stdin.write('\u001B[B'); // Down arrow
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// The active index should have changed (tested indirectly through behavior)
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate up with arrow key', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First go down, then up
|
||||||
|
stdin.write('\u001B[B'); // Down arrow
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u001B[A'); // Up arrow
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate with vim keys (j/k)', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate with vim keys
|
||||||
|
stdin.write('j'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('k'); // Up
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not navigate beyond bounds', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to go up from first item
|
||||||
|
stdin.write('\u001B[A'); // Up arrow
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should still be on first item
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings Toggling', () => {
|
||||||
|
it('should toggle setting with Enter key', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Enter to toggle current setting
|
||||||
|
stdin.write('\u000D'); // Enter key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle setting with Space key', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Space to toggle current setting
|
||||||
|
stdin.write(' '); // Space key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle vim mode setting specially', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to vim mode setting and toggle it
|
||||||
|
// This would require knowing the exact position, so we'll just test that the mock is called
|
||||||
|
stdin.write('\u000D'); // Enter key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// The mock should potentially be called if vim mode was toggled
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Scope Selection', () => {
|
||||||
|
it('should switch between scopes', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch to scope focus
|
||||||
|
stdin.write('\t'); // Tab key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Select different scope (numbers 1-3 typically available)
|
||||||
|
stdin.write('2'); // Select second scope option
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset to settings focus when scope is selected', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame, stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch to scope focus
|
||||||
|
stdin.write('\t'); // Tab key
|
||||||
|
await wait();
|
||||||
|
expect(lastFrame()).toContain('> Apply To');
|
||||||
|
|
||||||
|
// Select a scope
|
||||||
|
stdin.write('1'); // Select first scope option
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should be back to settings focus
|
||||||
|
expect(lastFrame()).toContain(' Apply To');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Restart Prompt', () => {
|
||||||
|
it('should show restart prompt for restart-required settings', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onRestartRequest = vi.fn();
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onRestartRequest={onRestartRequest}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// This test would need to trigger a restart-required setting change
|
||||||
|
// The exact steps depend on which settings require restart
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle restart request when r is pressed', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onRestartRequest = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onRestartRequest={onRestartRequest}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press 'r' key (this would only work if restart prompt is showing)
|
||||||
|
stdin.write('r');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// If restart prompt was showing, onRestartRequest should be called
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Escape Key Behavior', () => {
|
||||||
|
it('should call onSelect with undefined when Escape is pressed', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Escape key
|
||||||
|
stdin.write('\u001B'); // ESC key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings Persistence', () => {
|
||||||
|
it('should persist settings across scope changes', async () => {
|
||||||
|
const settings = createMockSettings({ vimMode: true });
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch to scope selector
|
||||||
|
stdin.write('\t'); // Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Change scope
|
||||||
|
stdin.write('2'); // Select workspace scope
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Settings should be reloaded for new scope
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show different values for different scopes', () => {
|
||||||
|
const settings = createMockSettings(
|
||||||
|
{ vimMode: true }, // User settings
|
||||||
|
{ vimMode: false }, // System settings
|
||||||
|
{ autoUpdate: false }, // Workspace settings
|
||||||
|
);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show user scope values initially
|
||||||
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('Settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle vim mode toggle errors gracefully', async () => {
|
||||||
|
mockToggleVimEnabled.mockRejectedValue(new Error('Toggle failed'));
|
||||||
|
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to toggle a setting (this might trigger vim mode toggle)
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should not crash
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex State Management', () => {
|
||||||
|
it('should track modified settings correctly', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle a setting
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Toggle another setting
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should track multiple modified settings
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle scrolling when there are many settings', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate down many times to test scrolling
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
stdin.write('\u001B[B'); // Down arrow
|
||||||
|
await wait(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VimMode Integration', () => {
|
||||||
|
it('should sync with VimModeContext when vim mode is toggled', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<VimModeProvider settings={settings}>
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||||
|
</VimModeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to and toggle vim mode setting
|
||||||
|
// This would require knowing the exact position of vim mode setting
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Specific Settings Behavior', () => {
|
||||||
|
it('should show correct display values for settings with different states', () => {
|
||||||
|
const settings = createMockSettings(
|
||||||
|
{ vimMode: true, hideTips: false }, // User settings
|
||||||
|
{ hideWindowTitle: true }, // System settings
|
||||||
|
{ ideMode: false }, // Workspace settings
|
||||||
|
);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
// Should contain settings labels
|
||||||
|
expect(output).toContain('Settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle immediate settings save for non-restart-required settings', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle a non-restart-required setting (like hideTips)
|
||||||
|
stdin.write('\u000D'); // Enter - toggle current setting
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should save immediately without showing restart prompt
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show restart prompt for restart-required settings', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// This test would need to navigate to a specific restart-required setting
|
||||||
|
// Since we can't easily target specific settings, we test the general behavior
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should not show restart prompt initially
|
||||||
|
expect(lastFrame()).not.toContain(
|
||||||
|
'To see changes, Gemini CLI must be restarted',
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear restart prompt when switching scopes', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restart prompt should be cleared when switching scopes
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings Display Values', () => {
|
||||||
|
it('should show correct values for inherited settings', () => {
|
||||||
|
const settings = createMockSettings(
|
||||||
|
{}, // No user settings
|
||||||
|
{ vimMode: true, hideWindowTitle: false }, // System settings
|
||||||
|
{}, // No workspace settings
|
||||||
|
);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
// Settings should show inherited values
|
||||||
|
expect(output).toContain('Settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show override indicator for overridden settings', () => {
|
||||||
|
const settings = createMockSettings(
|
||||||
|
{ vimMode: false }, // User overrides
|
||||||
|
{ vimMode: true }, // System default
|
||||||
|
{}, // No workspace settings
|
||||||
|
);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
// Should show settings with override indicators
|
||||||
|
expect(output).toContain('Settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Keyboard Shortcuts Edge Cases', () => {
|
||||||
|
it('should handle rapid key presses gracefully', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rapid navigation
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
stdin.write('\u001B[B'); // Down arrow
|
||||||
|
stdin.write('\u001B[A'); // Up arrow
|
||||||
|
}
|
||||||
|
await wait(100);
|
||||||
|
|
||||||
|
// Should not crash
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Ctrl+C to reset current setting to default', async () => {
|
||||||
|
const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Ctrl+C to reset current setting to default
|
||||||
|
stdin.write('\u0003'); // Ctrl+C
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should reset the current setting to its default value
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Ctrl+L to reset current setting to default', async () => {
|
||||||
|
const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Ctrl+L to reset current setting to default
|
||||||
|
stdin.write('\u000C'); // Ctrl+L
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should reset the current setting to its default value
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle navigation when only one setting exists', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to navigate when potentially at bounds
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u001B[A'); // Up
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly handle Tab navigation between sections', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame, stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start in settings section
|
||||||
|
expect(lastFrame()).toContain(' Apply To');
|
||||||
|
|
||||||
|
// Tab to scope section
|
||||||
|
stdin.write('\t');
|
||||||
|
await wait();
|
||||||
|
expect(lastFrame()).toContain('> Apply To');
|
||||||
|
|
||||||
|
// Tab back to settings section
|
||||||
|
stdin.write('\t');
|
||||||
|
await wait();
|
||||||
|
expect(lastFrame()).toContain(' Apply To');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Recovery', () => {
|
||||||
|
it('should handle malformed settings gracefully', () => {
|
||||||
|
// Create settings with potentially problematic values
|
||||||
|
const settings = createMockSettings(
|
||||||
|
{ vimMode: null as unknown as boolean }, // Invalid value
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should still render without crashing
|
||||||
|
expect(lastFrame()).toContain('Settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing setting definitions gracefully', () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
// Should not crash even if some settings are missing definitions
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastFrame()).toContain('Settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex User Interactions', () => {
|
||||||
|
it('should handle complete user workflow: navigate, toggle, change scope, exit', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate down a few settings
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Toggle a setting
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Switch to scope selector
|
||||||
|
stdin.write('\t'); // Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Change scope
|
||||||
|
stdin.write('2'); // Select workspace
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Go back to settings
|
||||||
|
stdin.write('\t'); // Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Navigate and toggle another setting
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write(' '); // Space to toggle
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Exit
|
||||||
|
stdin.write('\u001B'); // Escape
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(undefined, expect.any(String));
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow changing multiple settings without losing pending changes', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle first setting (should require restart)
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Navigate to next setting and toggle it (should not require restart - e.g., vimMode)
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Navigate to another setting and toggle it (should also require restart)
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// The test verifies that all changes are preserved and the dialog still works
|
||||||
|
// This tests the fix for the bug where changing one setting would reset all pending changes
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain state consistency during complex interactions', async () => {
|
||||||
|
const settings = createMockSettings({ vimMode: true });
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Multiple scope changes
|
||||||
|
stdin.write('\t'); // Tab to scope
|
||||||
|
await wait();
|
||||||
|
stdin.write('2'); // Workspace
|
||||||
|
await wait();
|
||||||
|
stdin.write('\t'); // Tab to settings
|
||||||
|
await wait();
|
||||||
|
stdin.write('\t'); // Tab to scope
|
||||||
|
await wait();
|
||||||
|
stdin.write('1'); // User
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should maintain consistent state
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle restart workflow correctly', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onRestartRequest = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onRestartRequest={onRestartRequest}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// This would test the restart workflow if we could trigger it
|
||||||
|
stdin.write('r'); // Try restart key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Without restart prompt showing, this should have no effect
|
||||||
|
expect(onRestartRequest).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
465
packages/cli/src/ui/components/SettingsDialog.tsx
Normal file
465
packages/cli/src/ui/components/SettingsDialog.tsx
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { Colors } from '../colors.js';
|
||||||
|
import {
|
||||||
|
LoadedSettings,
|
||||||
|
SettingScope,
|
||||||
|
Settings,
|
||||||
|
} from '../../config/settings.js';
|
||||||
|
import {
|
||||||
|
getScopeItems,
|
||||||
|
getScopeMessageForSetting,
|
||||||
|
} from '../../utils/dialogScopeUtils.js';
|
||||||
|
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||||
|
import {
|
||||||
|
getDialogSettingKeys,
|
||||||
|
getSettingValue,
|
||||||
|
setPendingSettingValue,
|
||||||
|
getDisplayValue,
|
||||||
|
hasRestartRequiredSettings,
|
||||||
|
saveModifiedSettings,
|
||||||
|
getSettingDefinition,
|
||||||
|
isDefaultValue,
|
||||||
|
requiresRestart,
|
||||||
|
getRestartRequiredFromModified,
|
||||||
|
getDefaultValue,
|
||||||
|
} from '../../utils/settingsUtils.js';
|
||||||
|
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||||
|
|
||||||
|
interface SettingsDialogProps {
|
||||||
|
settings: LoadedSettings;
|
||||||
|
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
|
||||||
|
onRestartRequest?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxItemsToShow = 8;
|
||||||
|
|
||||||
|
export function SettingsDialog({
|
||||||
|
settings,
|
||||||
|
onSelect,
|
||||||
|
onRestartRequest,
|
||||||
|
}: SettingsDialogProps): React.JSX.Element {
|
||||||
|
// Get vim mode context to sync vim mode changes
|
||||||
|
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
||||||
|
|
||||||
|
// Focus state: 'settings' or 'scope'
|
||||||
|
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
|
||||||
|
'settings',
|
||||||
|
);
|
||||||
|
// Scope selector state (User by default)
|
||||||
|
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||||
|
SettingScope.User,
|
||||||
|
);
|
||||||
|
// Active indices
|
||||||
|
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
|
||||||
|
// Scroll offset for settings
|
||||||
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
|
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||||
|
|
||||||
|
// Local pending settings state for the selected scope
|
||||||
|
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
||||||
|
// Deep clone to avoid mutation
|
||||||
|
structuredClone(settings.forScope(selectedScope).settings),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track which settings have been modified by the user
|
||||||
|
const [modifiedSettings, setModifiedSettings] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track the intended values for modified settings
|
||||||
|
const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track restart-required settings across scope changes
|
||||||
|
const [restartRequiredSettings, setRestartRequiredSettings] = useState<
|
||||||
|
Set<string>
|
||||||
|
>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPendingSettings(
|
||||||
|
structuredClone(settings.forScope(selectedScope).settings),
|
||||||
|
);
|
||||||
|
// Don't reset modifiedSettings when scope changes - preserve user's pending changes
|
||||||
|
if (restartRequiredSettings.size === 0) {
|
||||||
|
setShowRestartPrompt(false);
|
||||||
|
}
|
||||||
|
}, [selectedScope, settings, restartRequiredSettings]);
|
||||||
|
|
||||||
|
// Preserve pending changes when scope changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (modifiedSettings.size > 0) {
|
||||||
|
setPendingSettings((prevPending) => {
|
||||||
|
let updatedPending = { ...prevPending };
|
||||||
|
|
||||||
|
// Reapply all modified settings to the new pending settings using stored values
|
||||||
|
modifiedSettings.forEach((key) => {
|
||||||
|
const storedValue = modifiedValues.get(key);
|
||||||
|
if (storedValue !== undefined) {
|
||||||
|
updatedPending = setPendingSettingValue(
|
||||||
|
key,
|
||||||
|
storedValue,
|
||||||
|
updatedPending,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedPending;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedScope, modifiedSettings, modifiedValues, settings]);
|
||||||
|
|
||||||
|
const generateSettingsItems = () => {
|
||||||
|
const settingKeys = getDialogSettingKeys();
|
||||||
|
|
||||||
|
return settingKeys.map((key: string) => {
|
||||||
|
const currentValue = getSettingValue(key, pendingSettings, {});
|
||||||
|
const definition = getSettingDefinition(key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: definition?.label || key,
|
||||||
|
value: key,
|
||||||
|
checked: currentValue,
|
||||||
|
toggle: () => {
|
||||||
|
const newValue = !currentValue;
|
||||||
|
|
||||||
|
setPendingSettings((prev) =>
|
||||||
|
setPendingSettingValue(key, newValue, prev),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiresRestart(key)) {
|
||||||
|
const immediateSettings = new Set([key]);
|
||||||
|
const immediateSettingsObject = setPendingSettingValue(
|
||||||
|
key,
|
||||||
|
newValue,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
|
||||||
|
newValue,
|
||||||
|
);
|
||||||
|
saveModifiedSettings(
|
||||||
|
immediateSettings,
|
||||||
|
immediateSettingsObject,
|
||||||
|
settings,
|
||||||
|
selectedScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Special handling for vim mode to sync with VimModeContext
|
||||||
|
if (key === 'vimMode' && newValue !== vimEnabled) {
|
||||||
|
// Call toggleVimEnabled to sync the VimModeContext local state
|
||||||
|
toggleVimEnabled().catch((error) => {
|
||||||
|
console.error('Failed to toggle vim mode:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the current modified settings before updating state
|
||||||
|
const currentModifiedSettings = new Set(modifiedSettings);
|
||||||
|
|
||||||
|
// Remove the saved setting from modifiedSettings since it's now saved
|
||||||
|
setModifiedSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(key);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from modifiedValues as well
|
||||||
|
setModifiedValues((prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.delete(key);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also remove from restart-required settings if it was there
|
||||||
|
setRestartRequiredSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(key);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingSettings((_prevPending) => {
|
||||||
|
let updatedPending = structuredClone(
|
||||||
|
settings.forScope(selectedScope).settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
currentModifiedSettings.forEach((modifiedKey) => {
|
||||||
|
if (modifiedKey !== key) {
|
||||||
|
const modifiedValue = modifiedValues.get(modifiedKey);
|
||||||
|
if (modifiedValue !== undefined) {
|
||||||
|
updatedPending = setPendingSettingValue(
|
||||||
|
modifiedKey,
|
||||||
|
modifiedValue,
|
||||||
|
updatedPending,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedPending;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For restart-required settings, store the actual value
|
||||||
|
setModifiedValues((prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.set(key, newValue);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
setModifiedSettings((prev) => {
|
||||||
|
const updated = new Set(prev).add(key);
|
||||||
|
const needsRestart = hasRestartRequiredSettings(updated);
|
||||||
|
console.log(
|
||||||
|
`[DEBUG SettingsDialog] Modified settings:`,
|
||||||
|
Array.from(updated),
|
||||||
|
'Needs restart:',
|
||||||
|
needsRestart,
|
||||||
|
);
|
||||||
|
if (needsRestart) {
|
||||||
|
setShowRestartPrompt(true);
|
||||||
|
setRestartRequiredSettings((prevRestart) =>
|
||||||
|
new Set(prevRestart).add(key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = generateSettingsItems();
|
||||||
|
|
||||||
|
// Scope selector items
|
||||||
|
const scopeItems = getScopeItems();
|
||||||
|
|
||||||
|
const handleScopeHighlight = (scope: SettingScope) => {
|
||||||
|
setSelectedScope(scope);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScopeSelect = (scope: SettingScope) => {
|
||||||
|
handleScopeHighlight(scope);
|
||||||
|
setFocusSection('settings');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll logic for settings
|
||||||
|
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||||
|
// Always show arrows for consistent UI and to indicate circular navigation
|
||||||
|
const showScrollUp = true;
|
||||||
|
const showScrollDown = true;
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (key.tab) {
|
||||||
|
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
|
||||||
|
}
|
||||||
|
if (focusSection === 'settings') {
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
|
const newIndex =
|
||||||
|
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
|
||||||
|
setActiveSettingIndex(newIndex);
|
||||||
|
// Adjust scroll offset for wrap-around
|
||||||
|
if (newIndex === items.length - 1) {
|
||||||
|
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
|
||||||
|
} else if (newIndex < scrollOffset) {
|
||||||
|
setScrollOffset(newIndex);
|
||||||
|
}
|
||||||
|
} else if (key.downArrow || input === 'j') {
|
||||||
|
const newIndex =
|
||||||
|
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
|
||||||
|
setActiveSettingIndex(newIndex);
|
||||||
|
// Adjust scroll offset for wrap-around
|
||||||
|
if (newIndex === 0) {
|
||||||
|
setScrollOffset(0);
|
||||||
|
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||||
|
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||||
|
}
|
||||||
|
} else if (key.return || input === ' ') {
|
||||||
|
items[activeSettingIndex]?.toggle();
|
||||||
|
} else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) {
|
||||||
|
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
|
||||||
|
const currentSetting = items[activeSettingIndex];
|
||||||
|
if (currentSetting) {
|
||||||
|
const defaultValue = getDefaultValue(currentSetting.value);
|
||||||
|
// Ensure defaultValue is a boolean for setPendingSettingValue
|
||||||
|
const booleanDefaultValue =
|
||||||
|
typeof defaultValue === 'boolean' ? defaultValue : false;
|
||||||
|
|
||||||
|
// Update pending settings to default value
|
||||||
|
setPendingSettings((prev) =>
|
||||||
|
setPendingSettingValue(
|
||||||
|
currentSetting.value,
|
||||||
|
booleanDefaultValue,
|
||||||
|
prev,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from modified settings since it's now at default
|
||||||
|
setModifiedSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(currentSetting.value);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from restart-required settings if it was there
|
||||||
|
setRestartRequiredSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(currentSetting.value);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this setting doesn't require restart, save it immediately
|
||||||
|
if (!requiresRestart(currentSetting.value)) {
|
||||||
|
const immediateSettings = new Set([currentSetting.value]);
|
||||||
|
const immediateSettingsObject = setPendingSettingValue(
|
||||||
|
currentSetting.value,
|
||||||
|
booleanDefaultValue,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
saveModifiedSettings(
|
||||||
|
immediateSettings,
|
||||||
|
immediateSettingsObject,
|
||||||
|
settings,
|
||||||
|
selectedScope,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showRestartPrompt && input === 'r') {
|
||||||
|
// Only save settings that require restart (non-restart settings were already saved immediately)
|
||||||
|
const restartRequiredSettings =
|
||||||
|
getRestartRequiredFromModified(modifiedSettings);
|
||||||
|
const restartRequiredSet = new Set(restartRequiredSettings);
|
||||||
|
|
||||||
|
if (restartRequiredSet.size > 0) {
|
||||||
|
saveModifiedSettings(
|
||||||
|
restartRequiredSet,
|
||||||
|
pendingSettings,
|
||||||
|
settings,
|
||||||
|
selectedScope,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowRestartPrompt(false);
|
||||||
|
setRestartRequiredSettings(new Set()); // Clear restart-required settings
|
||||||
|
if (onRestartRequest) onRestartRequest();
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
onSelect(undefined, selectedScope);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={Colors.Gray}
|
||||||
|
flexDirection="row"
|
||||||
|
padding={1}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
<Text bold color={Colors.AccentBlue}>
|
||||||
|
Settings
|
||||||
|
</Text>
|
||||||
|
<Box height={1} />
|
||||||
|
{showScrollUp && <Text color={Colors.Gray}>▲</Text>}
|
||||||
|
{visibleItems.map((item, idx) => {
|
||||||
|
const isActive =
|
||||||
|
focusSection === 'settings' &&
|
||||||
|
activeSettingIndex === idx + scrollOffset;
|
||||||
|
|
||||||
|
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||||
|
const mergedSettings = settings.merged;
|
||||||
|
const displayValue = getDisplayValue(
|
||||||
|
item.value,
|
||||||
|
scopeSettings,
|
||||||
|
mergedSettings,
|
||||||
|
modifiedSettings,
|
||||||
|
pendingSettings,
|
||||||
|
);
|
||||||
|
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
|
||||||
|
|
||||||
|
// Generate scope message for this setting
|
||||||
|
const scopeMessage = getScopeMessageForSetting(
|
||||||
|
item.value,
|
||||||
|
selectedScope,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.value}>
|
||||||
|
<Box flexDirection="row" alignItems="center">
|
||||||
|
<Box minWidth={2} flexShrink={0}>
|
||||||
|
<Text color={isActive ? Colors.AccentGreen : Colors.Gray}>
|
||||||
|
{isActive ? '●' : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box minWidth={50}>
|
||||||
|
<Text
|
||||||
|
color={isActive ? Colors.AccentGreen : Colors.Foreground}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{scopeMessage && (
|
||||||
|
<Text color={Colors.Gray}> {scopeMessage}</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box minWidth={3} />
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
isActive
|
||||||
|
? Colors.AccentGreen
|
||||||
|
: shouldBeGreyedOut
|
||||||
|
? Colors.Gray
|
||||||
|
: Colors.Foreground
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box height={1} />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showScrollDown && <Text color={Colors.Gray}>▼</Text>}
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text bold={focusSection === 'scope'} wrap="truncate">
|
||||||
|
{focusSection === 'scope' ? '> ' : ' '}Apply To
|
||||||
|
</Text>
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={scopeItems}
|
||||||
|
initialIndex={0}
|
||||||
|
onSelect={handleScopeSelect}
|
||||||
|
onHighlight={handleScopeHighlight}
|
||||||
|
isFocused={focusSection === 'scope'}
|
||||||
|
showNumbers={focusSection === 'scope'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
(Use Enter to select, Tab to change focus)
|
||||||
|
</Text>
|
||||||
|
{showRestartPrompt && (
|
||||||
|
<Text color={Colors.AccentYellow}>
|
||||||
|
To see changes, Gemini CLI must be restarted. Press r to exit and
|
||||||
|
apply changes now.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ export function SuggestionsDisplay({
|
|||||||
)}
|
)}
|
||||||
{suggestion.description ? (
|
{suggestion.description ? (
|
||||||
<Box flexGrow={1}>
|
<Box flexGrow={1}>
|
||||||
<Text color={textColor} wrap="wrap">
|
<Text color={textColor} wrap="truncate">
|
||||||
{suggestion.description}
|
{suggestion.description}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
|||||||
import { DiffRenderer } from './messages/DiffRenderer.js';
|
import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||||
import { colorizeCode } from '../utils/CodeColorizer.js';
|
import { colorizeCode } from '../utils/CodeColorizer.js';
|
||||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
|
import {
|
||||||
|
getScopeItems,
|
||||||
|
getScopeMessageForSetting,
|
||||||
|
} from '../../utils/dialogScopeUtils.js';
|
||||||
|
|
||||||
interface ThemeDialogProps {
|
interface ThemeDialogProps {
|
||||||
/** Callback function when a theme is selected */
|
/** Callback function when a theme is selected */
|
||||||
@@ -76,11 +80,7 @@ export function ThemeDialog({
|
|||||||
// If not found, fall back to the first theme
|
// If not found, fall back to the first theme
|
||||||
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
|
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
|
||||||
|
|
||||||
const scopeItems = [
|
const scopeItems = getScopeItems();
|
||||||
{ label: 'User Settings', value: SettingScope.User },
|
|
||||||
{ label: 'Workspace Settings', value: SettingScope.Workspace },
|
|
||||||
{ label: 'System Settings', value: SettingScope.System },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleThemeSelect = useCallback(
|
const handleThemeSelect = useCallback(
|
||||||
(themeName: string) => {
|
(themeName: string) => {
|
||||||
@@ -120,23 +120,13 @@ export function ThemeDialog({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const otherScopes = Object.values(SettingScope).filter(
|
// Generate scope message for theme setting
|
||||||
(scope) => scope !== selectedScope,
|
const otherScopeModifiedMessage = getScopeMessageForSetting(
|
||||||
|
'theme',
|
||||||
|
selectedScope,
|
||||||
|
settings,
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiedInOtherScopes = otherScopes.filter(
|
|
||||||
(scope) => settings.forScope(scope).settings.theme !== undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
let otherScopeModifiedMessage = '';
|
|
||||||
if (modifiedInOtherScopes.length > 0) {
|
|
||||||
const modifiedScopesStr = modifiedInOtherScopes.join(', ');
|
|
||||||
otherScopeModifiedMessage =
|
|
||||||
settings.forScope(selectedScope).settings.theme !== undefined
|
|
||||||
? `(Also modified in ${modifiedScopesStr})`
|
|
||||||
: `(Modified in ${modifiedScopesStr})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constants for calculating preview pane layout.
|
// Constants for calculating preview pane layout.
|
||||||
// These values are based on the JSX structure below.
|
// These values are based on the JSX structure below.
|
||||||
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
|
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`IDEContextDetailDisplay > handles duplicate basenames by showing path hints 1`] = `
|
||||||
|
"
|
||||||
|
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ VS Code Context (ctrl+e to toggle) │
|
||||||
|
│ │
|
||||||
|
│ Open files: │
|
||||||
|
│ - bar.txt (/foo) (active) │
|
||||||
|
│ - bar.txt (/qux) │
|
||||||
|
│ - unique.txt │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`IDEContextDetailDisplay > renders a list of open files with active status 1`] = `
|
||||||
|
"
|
||||||
|
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ VS Code Context (ctrl+e to toggle) │
|
||||||
|
│ │
|
||||||
|
│ Open files: │
|
||||||
|
│ - bar.txt (active) │
|
||||||
|
│ - baz.txt │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
|
`;
|
||||||
@@ -33,6 +33,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
ToolConfirmationMessageProps
|
ToolConfirmationMessageProps
|
||||||
> = ({
|
> = ({
|
||||||
confirmationDetails,
|
confirmationDetails,
|
||||||
|
config,
|
||||||
isFocused = true,
|
isFocused = true,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
terminalWidth,
|
terminalWidth,
|
||||||
@@ -40,14 +41,29 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
const { onConfirm } = confirmationDetails;
|
const { onConfirm } = confirmationDetails;
|
||||||
const childWidth = terminalWidth - 2; // 2 for padding
|
const childWidth = terminalWidth - 2; // 2 for padding
|
||||||
|
|
||||||
useInput((_, key) => {
|
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
|
||||||
|
if (confirmationDetails.type === 'edit') {
|
||||||
|
const ideClient = config?.getIdeClient();
|
||||||
|
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
|
||||||
|
const cliOutcome =
|
||||||
|
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
|
||||||
|
await ideClient?.resolveDiffFromCli(
|
||||||
|
confirmationDetails.filePath,
|
||||||
|
cliOutcome,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onConfirm(outcome);
|
||||||
|
};
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
if (!isFocused) return;
|
if (!isFocused) return;
|
||||||
if (key.escape) {
|
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
|
||||||
onConfirm(ToolConfirmationOutcome.Cancel);
|
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSelect = (item: ToolConfirmationOutcome) => onConfirm(item);
|
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
|
||||||
|
|
||||||
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
|
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
|
||||||
let question: string;
|
let question: string;
|
||||||
@@ -85,6 +101,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
HEIGHT_OPTIONS;
|
HEIGHT_OPTIONS;
|
||||||
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirmationDetails.type === 'edit') {
|
if (confirmationDetails.type === 'edit') {
|
||||||
if (confirmationDetails.isModifying) {
|
if (confirmationDetails.isModifying) {
|
||||||
return (
|
return (
|
||||||
@@ -114,15 +131,23 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
label: 'Yes, allow always',
|
label: 'Yes, allow always',
|
||||||
value: ToolConfirmationOutcome.ProceedAlways,
|
value: ToolConfirmationOutcome.ProceedAlways,
|
||||||
},
|
},
|
||||||
{
|
);
|
||||||
|
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
|
||||||
|
options.push({
|
||||||
|
label: 'No (esc)',
|
||||||
|
value: ToolConfirmationOutcome.Cancel,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
options.push({
|
||||||
label: 'Modify with external editor',
|
label: 'Modify with external editor',
|
||||||
value: ToolConfirmationOutcome.ModifyWithEditor,
|
value: ToolConfirmationOutcome.ModifyWithEditor,
|
||||||
},
|
});
|
||||||
{
|
options.push({
|
||||||
label: 'No, suggest changes (esc)',
|
label: 'No, suggest changes (esc)',
|
||||||
value: ToolConfirmationOutcome.Cancel,
|
value: ToolConfirmationOutcome.Cancel,
|
||||||
},
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
bodyContent = (
|
bodyContent = (
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={confirmationDetails.fileDiff}
|
diffContent={confirmationDetails.fileDiff}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
textBufferReducer,
|
textBufferReducer,
|
||||||
TextBufferState,
|
TextBufferState,
|
||||||
TextBufferAction,
|
TextBufferAction,
|
||||||
|
findWordEndInLine,
|
||||||
|
findNextWordStartInLine,
|
||||||
|
isWordCharStrict,
|
||||||
} from './text-buffer.js';
|
} from './text-buffer.js';
|
||||||
|
import { cpLen } from '../../utils/textUtils.js';
|
||||||
|
|
||||||
const initialState: TextBufferState = {
|
const initialState: TextBufferState = {
|
||||||
lines: [''],
|
lines: [''],
|
||||||
@@ -1591,3 +1595,94 @@ describe('textBufferReducer vim operations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Unicode helper functions', () => {
|
||||||
|
describe('findWordEndInLine with Unicode', () => {
|
||||||
|
it('should handle combining characters', () => {
|
||||||
|
// café with combining accent
|
||||||
|
const cafeWithCombining = 'cafe\u0301';
|
||||||
|
const result = findWordEndInLine(cafeWithCombining + ' test', 0);
|
||||||
|
expect(result).toBe(3); // End of 'café' at base character 'e', not combining accent
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle precomposed characters with diacritics', () => {
|
||||||
|
// café with precomposed é (U+00E9)
|
||||||
|
const cafePrecomposed = 'café';
|
||||||
|
const result = findWordEndInLine(cafePrecomposed + ' test', 0);
|
||||||
|
expect(result).toBe(3); // End of 'café' at precomposed character 'é'
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when no word end found', () => {
|
||||||
|
const result = findWordEndInLine(' ', 0);
|
||||||
|
expect(result).toBeNull(); // No word end found in whitespace-only string string
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findNextWordStartInLine with Unicode', () => {
|
||||||
|
it('should handle right-to-left text', () => {
|
||||||
|
const result = findNextWordStartInLine('hello مرحبا world', 0);
|
||||||
|
expect(result).toBe(6); // Start of Arabic word
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Chinese characters', () => {
|
||||||
|
const result = findNextWordStartInLine('hello 你好 world', 0);
|
||||||
|
expect(result).toBe(6); // Start of Chinese word
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null at end of line', () => {
|
||||||
|
const result = findNextWordStartInLine('hello', 10);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle combining characters', () => {
|
||||||
|
// café with combining accent + next word
|
||||||
|
const textWithCombining = 'cafe\u0301 test';
|
||||||
|
const result = findNextWordStartInLine(textWithCombining, 0);
|
||||||
|
expect(result).toBe(6); // Start of 'test' after 'café ' (combining char makes string longer)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle precomposed characters with diacritics', () => {
|
||||||
|
// café with precomposed é + next word
|
||||||
|
const textPrecomposed = 'café test';
|
||||||
|
const result = findNextWordStartInLine(textPrecomposed, 0);
|
||||||
|
expect(result).toBe(5); // Start of 'test' after 'café '
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isWordCharStrict with Unicode', () => {
|
||||||
|
it('should return true for ASCII word characters', () => {
|
||||||
|
expect(isWordCharStrict('a')).toBe(true);
|
||||||
|
expect(isWordCharStrict('Z')).toBe(true);
|
||||||
|
expect(isWordCharStrict('0')).toBe(true);
|
||||||
|
expect(isWordCharStrict('_')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for punctuation', () => {
|
||||||
|
expect(isWordCharStrict('.')).toBe(false);
|
||||||
|
expect(isWordCharStrict(',')).toBe(false);
|
||||||
|
expect(isWordCharStrict('!')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for non-Latin scripts', () => {
|
||||||
|
expect(isWordCharStrict('你')).toBe(true); // Chinese character
|
||||||
|
expect(isWordCharStrict('م')).toBe(true); // Arabic character
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for whitespace', () => {
|
||||||
|
expect(isWordCharStrict(' ')).toBe(false);
|
||||||
|
expect(isWordCharStrict('\t')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cpLen with Unicode', () => {
|
||||||
|
it('should handle combining characters', () => {
|
||||||
|
expect(cpLen('é')).toBe(1); // Precomposed
|
||||||
|
expect(cpLen('e\u0301')).toBe(2); // e + combining acute
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Chinese and Arabic text', () => {
|
||||||
|
expect(cpLen('hello 你好 world')).toBe(14); // 5 + 1 + 2 + 1 + 5 = 14
|
||||||
|
expect(cpLen('hello مرحبا world')).toBe(17);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -33,143 +33,329 @@ function isWordChar(ch: string | undefined): boolean {
|
|||||||
return !/[\s,.;!?]/.test(ch);
|
return !/[\s,.;!?]/.test(ch);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vim-specific word boundary functions
|
// Helper functions for line-based word navigation
|
||||||
export const findNextWordStart = (
|
export const isWordCharStrict = (char: string): boolean =>
|
||||||
text: string,
|
/[\w\p{L}\p{N}]/u.test(char); // Matches a single character that is any Unicode letter, any Unicode number, or an underscore
|
||||||
currentOffset: number,
|
|
||||||
): number => {
|
|
||||||
let i = currentOffset;
|
|
||||||
|
|
||||||
if (i >= text.length) return i;
|
export const isWhitespace = (char: string): boolean => /\s/.test(char);
|
||||||
|
|
||||||
const currentChar = text[i];
|
// Check if a character is a combining mark (only diacritics for now)
|
||||||
|
export const isCombiningMark = (char: string): boolean => /\p{M}/u.test(char);
|
||||||
|
|
||||||
|
// Check if a character should be considered part of a word (including combining marks)
|
||||||
|
export const isWordCharWithCombining = (char: string): boolean =>
|
||||||
|
isWordCharStrict(char) || isCombiningMark(char);
|
||||||
|
|
||||||
|
// Get the script of a character (simplified for common scripts)
|
||||||
|
export const getCharScript = (char: string): string => {
|
||||||
|
if (/[\p{Script=Latin}]/u.test(char)) return 'latin'; // All Latin script chars including diacritics
|
||||||
|
if (/[\p{Script=Han}]/u.test(char)) return 'han'; // Chinese
|
||||||
|
if (/[\p{Script=Arabic}]/u.test(char)) return 'arabic';
|
||||||
|
if (/[\p{Script=Hiragana}]/u.test(char)) return 'hiragana';
|
||||||
|
if (/[\p{Script=Katakana}]/u.test(char)) return 'katakana';
|
||||||
|
if (/[\p{Script=Cyrillic}]/u.test(char)) return 'cyrillic';
|
||||||
|
return 'other';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if two characters are from different scripts (indicating word boundary)
|
||||||
|
export const isDifferentScript = (char1: string, char2: string): boolean => {
|
||||||
|
if (!isWordCharStrict(char1) || !isWordCharStrict(char2)) return false;
|
||||||
|
return getCharScript(char1) !== getCharScript(char2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find next word start within a line, starting from col
|
||||||
|
export const findNextWordStartInLine = (
|
||||||
|
line: string,
|
||||||
|
col: number,
|
||||||
|
): number | null => {
|
||||||
|
const chars = toCodePoints(line);
|
||||||
|
let i = col;
|
||||||
|
|
||||||
|
if (i >= chars.length) return null;
|
||||||
|
|
||||||
|
const currentChar = chars[i];
|
||||||
|
|
||||||
// Skip current word/sequence based on character type
|
// Skip current word/sequence based on character type
|
||||||
if (/\w/.test(currentChar)) {
|
if (isWordCharStrict(currentChar)) {
|
||||||
// Skip current word characters
|
while (i < chars.length && isWordCharWithCombining(chars[i])) {
|
||||||
while (i < text.length && /\w/.test(text[i])) {
|
// Check for script boundary - if next character is from different script, stop here
|
||||||
|
if (
|
||||||
|
i + 1 < chars.length &&
|
||||||
|
isWordCharStrict(chars[i + 1]) &&
|
||||||
|
isDifferentScript(chars[i], chars[i + 1])
|
||||||
|
) {
|
||||||
|
i++; // Include current character
|
||||||
|
break; // Stop at script boundary
|
||||||
|
}
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
} else if (!/\s/.test(currentChar)) {
|
} else if (!isWhitespace(currentChar)) {
|
||||||
// Skip current non-word, non-whitespace characters (like "/", ".", etc.)
|
while (
|
||||||
while (i < text.length && !/\w/.test(text[i]) && !/\s/.test(text[i])) {
|
i < chars.length &&
|
||||||
|
!isWordCharStrict(chars[i]) &&
|
||||||
|
!isWhitespace(chars[i])
|
||||||
|
) {
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip whitespace
|
// Skip whitespace
|
||||||
while (i < text.length && /\s/.test(text[i])) {
|
while (i < chars.length && isWhitespace(chars[i])) {
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we reached the end of text and there's no next word,
|
return i < chars.length ? i : null;
|
||||||
// vim behavior for dw is to delete to the end of the current word
|
|
||||||
if (i >= text.length) {
|
|
||||||
// Go back to find the end of the last word
|
|
||||||
let endOfLastWord = text.length - 1;
|
|
||||||
while (endOfLastWord >= 0 && /\s/.test(text[endOfLastWord])) {
|
|
||||||
endOfLastWord--;
|
|
||||||
}
|
|
||||||
// For dw on last word, return position AFTER the last character to delete entire word
|
|
||||||
return Math.max(currentOffset + 1, endOfLastWord + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return i;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const findPrevWordStart = (
|
// Find previous word start within a line
|
||||||
text: string,
|
export const findPrevWordStartInLine = (
|
||||||
currentOffset: number,
|
line: string,
|
||||||
): number => {
|
col: number,
|
||||||
let i = currentOffset;
|
): number | null => {
|
||||||
|
const chars = toCodePoints(line);
|
||||||
|
let i = col;
|
||||||
|
|
||||||
// If at beginning of text, return current position
|
if (i <= 0) return null;
|
||||||
if (i <= 0) {
|
|
||||||
return currentOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move back one character to start searching
|
|
||||||
i--;
|
i--;
|
||||||
|
|
||||||
// Skip whitespace moving backwards
|
// Skip whitespace moving backwards
|
||||||
while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n')) {
|
while (i >= 0 && isWhitespace(chars[i])) {
|
||||||
i--;
|
i--;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i < 0) {
|
if (i < 0) return null;
|
||||||
return 0; // Reached beginning of text
|
|
||||||
}
|
|
||||||
|
|
||||||
const charAtI = text[i];
|
if (isWordCharStrict(chars[i])) {
|
||||||
|
|
||||||
if (/\w/.test(charAtI)) {
|
|
||||||
// We're in a word, move to its beginning
|
// We're in a word, move to its beginning
|
||||||
while (i >= 0 && /\w/.test(text[i])) {
|
while (i >= 0 && isWordCharStrict(chars[i])) {
|
||||||
|
// Check for script boundary - if previous character is from different script, stop here
|
||||||
|
if (
|
||||||
|
i - 1 >= 0 &&
|
||||||
|
isWordCharStrict(chars[i - 1]) &&
|
||||||
|
isDifferentScript(chars[i], chars[i - 1])
|
||||||
|
) {
|
||||||
|
return i; // Return current position at script boundary
|
||||||
|
}
|
||||||
i--;
|
i--;
|
||||||
}
|
}
|
||||||
return i + 1; // Return first character of word
|
return i + 1;
|
||||||
} else {
|
} else {
|
||||||
// We're in punctuation, move to its beginning
|
// We're in punctuation, move to its beginning
|
||||||
while (
|
while (i >= 0 && !isWordCharStrict(chars[i]) && !isWhitespace(chars[i])) {
|
||||||
i >= 0 &&
|
|
||||||
!/\w/.test(text[i]) &&
|
|
||||||
text[i] !== ' ' &&
|
|
||||||
text[i] !== '\t' &&
|
|
||||||
text[i] !== '\n'
|
|
||||||
) {
|
|
||||||
i--;
|
i--;
|
||||||
}
|
}
|
||||||
return i + 1; // Return first character of punctuation sequence
|
return i + 1;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const findWordEnd = (text: string, currentOffset: number): number => {
|
// Find word end within a line
|
||||||
let i = currentOffset;
|
export const findWordEndInLine = (line: string, col: number): number | null => {
|
||||||
|
const chars = toCodePoints(line);
|
||||||
|
let i = col;
|
||||||
|
|
||||||
// If we're already at the end of a word, advance to next word
|
// If we're already at the end of a word (including punctuation sequences), advance to next word
|
||||||
if (
|
// This includes both regular word endings and script boundaries
|
||||||
i < text.length &&
|
const atEndOfWordChar =
|
||||||
/\w/.test(text[i]) &&
|
i < chars.length &&
|
||||||
(i + 1 >= text.length || !/\w/.test(text[i + 1]))
|
isWordCharWithCombining(chars[i]) &&
|
||||||
) {
|
(i + 1 >= chars.length ||
|
||||||
// We're at the end of a word, move forward to find next word
|
!isWordCharWithCombining(chars[i + 1]) ||
|
||||||
|
(isWordCharStrict(chars[i]) &&
|
||||||
|
i + 1 < chars.length &&
|
||||||
|
isWordCharStrict(chars[i + 1]) &&
|
||||||
|
isDifferentScript(chars[i], chars[i + 1])));
|
||||||
|
|
||||||
|
const atEndOfPunctuation =
|
||||||
|
i < chars.length &&
|
||||||
|
!isWordCharWithCombining(chars[i]) &&
|
||||||
|
!isWhitespace(chars[i]) &&
|
||||||
|
(i + 1 >= chars.length ||
|
||||||
|
isWhitespace(chars[i + 1]) ||
|
||||||
|
isWordCharWithCombining(chars[i + 1]));
|
||||||
|
|
||||||
|
if (atEndOfWordChar || atEndOfPunctuation) {
|
||||||
|
// We're at the end of a word or punctuation sequence, move forward to find next word
|
||||||
i++;
|
i++;
|
||||||
// Skip whitespace/punctuation to find next word
|
// Skip whitespace to find next word or punctuation
|
||||||
while (i < text.length && !/\w/.test(text[i])) {
|
while (i < chars.length && isWhitespace(chars[i])) {
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're not on a word character, find the next word
|
// If we're not on a word character, find the next word or punctuation sequence
|
||||||
if (i < text.length && !/\w/.test(text[i])) {
|
if (i < chars.length && !isWordCharWithCombining(chars[i])) {
|
||||||
while (i < text.length && !/\w/.test(text[i])) {
|
// Skip whitespace to find next word or punctuation
|
||||||
|
while (i < chars.length && isWhitespace(chars[i])) {
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to end of current word
|
// Move to end of current word (including combining marks, but stop at script boundaries)
|
||||||
while (i < text.length && /\w/.test(text[i])) {
|
let foundWord = false;
|
||||||
i++;
|
let lastBaseCharPos = -1;
|
||||||
|
|
||||||
|
if (i < chars.length && isWordCharWithCombining(chars[i])) {
|
||||||
|
// Handle word characters
|
||||||
|
while (i < chars.length && isWordCharWithCombining(chars[i])) {
|
||||||
|
foundWord = true;
|
||||||
|
|
||||||
|
// Track the position of the last base character (not combining mark)
|
||||||
|
if (isWordCharStrict(chars[i])) {
|
||||||
|
lastBaseCharPos = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if next character is from a different script (word boundary)
|
||||||
|
if (
|
||||||
|
i + 1 < chars.length &&
|
||||||
|
isWordCharStrict(chars[i + 1]) &&
|
||||||
|
isDifferentScript(chars[i], chars[i + 1])
|
||||||
|
) {
|
||||||
|
i++; // Include current character
|
||||||
|
if (isWordCharStrict(chars[i - 1])) {
|
||||||
|
lastBaseCharPos = i - 1;
|
||||||
|
}
|
||||||
|
break; // Stop at script boundary
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
} else if (i < chars.length && !isWhitespace(chars[i])) {
|
||||||
|
// Handle punctuation sequences (like ████)
|
||||||
|
while (
|
||||||
|
i < chars.length &&
|
||||||
|
!isWordCharStrict(chars[i]) &&
|
||||||
|
!isWhitespace(chars[i])
|
||||||
|
) {
|
||||||
|
foundWord = true;
|
||||||
|
lastBaseCharPos = i;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move back one to be on the last character of the word
|
// Only return a position if we actually found a word
|
||||||
return Math.max(currentOffset, i - 1);
|
// Return the position of the last base character, not combining marks
|
||||||
|
if (foundWord && lastBaseCharPos >= col) {
|
||||||
|
return lastBaseCharPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper functions for vim operations
|
// Find next word across lines
|
||||||
export const getOffsetFromPosition = (
|
export const findNextWordAcrossLines = (
|
||||||
row: number,
|
|
||||||
col: number,
|
|
||||||
lines: string[],
|
lines: string[],
|
||||||
): number => {
|
cursorRow: number,
|
||||||
let offset = 0;
|
cursorCol: number,
|
||||||
for (let i = 0; i < row; i++) {
|
searchForWordStart: boolean,
|
||||||
offset += lines[i].length + 1; // +1 for newline
|
): { row: number; col: number } | null => {
|
||||||
|
// First try current line
|
||||||
|
const currentLine = lines[cursorRow] || '';
|
||||||
|
const colInCurrentLine = searchForWordStart
|
||||||
|
? findNextWordStartInLine(currentLine, cursorCol)
|
||||||
|
: findWordEndInLine(currentLine, cursorCol);
|
||||||
|
|
||||||
|
if (colInCurrentLine !== null) {
|
||||||
|
return { row: cursorRow, col: colInCurrentLine };
|
||||||
}
|
}
|
||||||
offset += col;
|
|
||||||
return offset;
|
// Search subsequent lines
|
||||||
|
for (let row = cursorRow + 1; row < lines.length; row++) {
|
||||||
|
const line = lines[row] || '';
|
||||||
|
const chars = toCodePoints(line);
|
||||||
|
|
||||||
|
// For empty lines, if we haven't found any words yet, return the empty line
|
||||||
|
if (chars.length === 0) {
|
||||||
|
// Check if there are any words in remaining lines
|
||||||
|
let hasWordsInLaterLines = false;
|
||||||
|
for (let laterRow = row + 1; laterRow < lines.length; laterRow++) {
|
||||||
|
const laterLine = lines[laterRow] || '';
|
||||||
|
const laterChars = toCodePoints(laterLine);
|
||||||
|
let firstNonWhitespace = 0;
|
||||||
|
while (
|
||||||
|
firstNonWhitespace < laterChars.length &&
|
||||||
|
isWhitespace(laterChars[firstNonWhitespace])
|
||||||
|
) {
|
||||||
|
firstNonWhitespace++;
|
||||||
|
}
|
||||||
|
if (firstNonWhitespace < laterChars.length) {
|
||||||
|
hasWordsInLaterLines = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no words in later lines, return the empty line
|
||||||
|
if (!hasWordsInLaterLines) {
|
||||||
|
return { row, col: 0 };
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first non-whitespace
|
||||||
|
let firstNonWhitespace = 0;
|
||||||
|
while (
|
||||||
|
firstNonWhitespace < chars.length &&
|
||||||
|
isWhitespace(chars[firstNonWhitespace])
|
||||||
|
) {
|
||||||
|
firstNonWhitespace++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstNonWhitespace < chars.length) {
|
||||||
|
if (searchForWordStart) {
|
||||||
|
return { row, col: firstNonWhitespace };
|
||||||
|
} else {
|
||||||
|
// For word end, find the end of the first word
|
||||||
|
const endCol = findWordEndInLine(line, firstNonWhitespace);
|
||||||
|
if (endCol !== null) {
|
||||||
|
return { row, col: endCol };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Find previous word across lines
|
||||||
|
export const findPrevWordAcrossLines = (
|
||||||
|
lines: string[],
|
||||||
|
cursorRow: number,
|
||||||
|
cursorCol: number,
|
||||||
|
): { row: number; col: number } | null => {
|
||||||
|
// First try current line
|
||||||
|
const currentLine = lines[cursorRow] || '';
|
||||||
|
const colInCurrentLine = findPrevWordStartInLine(currentLine, cursorCol);
|
||||||
|
|
||||||
|
if (colInCurrentLine !== null) {
|
||||||
|
return { row: cursorRow, col: colInCurrentLine };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search previous lines
|
||||||
|
for (let row = cursorRow - 1; row >= 0; row--) {
|
||||||
|
const line = lines[row] || '';
|
||||||
|
const chars = toCodePoints(line);
|
||||||
|
|
||||||
|
if (chars.length === 0) continue;
|
||||||
|
|
||||||
|
// Find last word start
|
||||||
|
let lastWordStart = chars.length;
|
||||||
|
while (lastWordStart > 0 && isWhitespace(chars[lastWordStart - 1])) {
|
||||||
|
lastWordStart--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastWordStart > 0) {
|
||||||
|
// Find start of this word
|
||||||
|
const wordStart = findPrevWordStartInLine(line, lastWordStart);
|
||||||
|
if (wordStart !== null) {
|
||||||
|
return { row, col: wordStart };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions for vim line operations
|
||||||
export const getPositionFromOffsets = (
|
export const getPositionFromOffsets = (
|
||||||
startOffset: number,
|
startOffset: number,
|
||||||
endOffset: number,
|
endOffset: number,
|
||||||
|
|||||||
@@ -140,6 +140,25 @@ describe('vim-buffer-actions', () => {
|
|||||||
expect(result.cursorRow).toBe(1);
|
expect(result.cursorRow).toBe(1);
|
||||||
expect(result.cursorCol).toBe(0);
|
expect(result.cursorCol).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should skip over combining marks to avoid cursor disappearing', () => {
|
||||||
|
// Test case for combining character cursor disappearing bug
|
||||||
|
// "café test" where é is represented as e + combining acute accent
|
||||||
|
const state = createTestState(['cafe\u0301 test'], 0, 2); // Start at 'f'
|
||||||
|
const action = {
|
||||||
|
type: 'vim_move_right' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = handleVimAction(state, action);
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(3); // Should be on 'e' of 'café'
|
||||||
|
|
||||||
|
// Move right again - should skip combining mark and land on space
|
||||||
|
const result2 = handleVimAction(result, action);
|
||||||
|
expect(result2).toHaveOnlyValidCharacters();
|
||||||
|
expect(result2.cursorCol).toBe(5); // Should be on space after 'café'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('vim_move_up', () => {
|
describe('vim_move_up', () => {
|
||||||
@@ -169,7 +188,7 @@ describe('vim-buffer-actions', () => {
|
|||||||
const result = handleVimAction(state, action);
|
const result = handleVimAction(state, action);
|
||||||
expect(result).toHaveOnlyValidCharacters();
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
expect(result.cursorRow).toBe(0);
|
expect(result.cursorRow).toBe(0);
|
||||||
expect(result.cursorCol).toBe(5); // End of 'short'
|
expect(result.cursorCol).toBe(4); // Last character 't' of 'short', not past it
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,6 +255,20 @@ describe('vim-buffer-actions', () => {
|
|||||||
expect(result).toHaveOnlyValidCharacters();
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
expect(result.cursorCol).toBe(5); // Start of ','
|
expect(result.cursorCol).toBe(5); // Start of ','
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should move across empty lines when starting from within a word', () => {
|
||||||
|
// Testing the exact scenario: cursor on 'w' of 'hello world', w should move to next line
|
||||||
|
const state = createTestState(['hello world', ''], 0, 6); // At 'w' of 'world'
|
||||||
|
const action = {
|
||||||
|
type: 'vim_move_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = handleVimAction(state, action);
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorRow).toBe(1); // Should move to empty line
|
||||||
|
expect(result.cursorCol).toBe(0); // Beginning of empty line
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('vim_move_word_backward', () => {
|
describe('vim_move_word_backward', () => {
|
||||||
@@ -288,6 +321,85 @@ describe('vim-buffer-actions', () => {
|
|||||||
expect(result).toHaveOnlyValidCharacters();
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
expect(result.cursorCol).toBe(10); // End of 'world'
|
expect(result.cursorCol).toBe(10); // End of 'world'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should move across empty lines when at word end', () => {
|
||||||
|
const state = createTestState(['hello world', '', 'test'], 0, 10); // At 'd' of 'world'
|
||||||
|
const action = {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = handleVimAction(state, action);
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorRow).toBe(2); // Should move to line with 'test'
|
||||||
|
expect(result.cursorCol).toBe(3); // Should be at 't' (end of 'test')
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle consecutive word-end movements across empty lines', () => {
|
||||||
|
// Testing the exact scenario: cursor on 'w' of world, press 'e' twice
|
||||||
|
const state = createTestState(['hello world', ''], 0, 6); // At 'w' of 'world'
|
||||||
|
|
||||||
|
// First 'e' should move to 'd' of 'world'
|
||||||
|
let result = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorRow).toBe(0);
|
||||||
|
expect(result.cursorCol).toBe(10); // At 'd' of 'world'
|
||||||
|
|
||||||
|
// Second 'e' should move to the empty line (end of file in this case)
|
||||||
|
result = handleVimAction(result, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorRow).toBe(1); // Should move to empty line
|
||||||
|
expect(result.cursorCol).toBe(0); // Empty line has col 0
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle combining characters - advance from end of base character', () => {
|
||||||
|
// Test case for combining character word end bug
|
||||||
|
// "café test" where é is represented as e + combining acute accent
|
||||||
|
const state = createTestState(['cafe\u0301 test'], 0, 0); // Start at 'c'
|
||||||
|
|
||||||
|
// First 'e' command should move to the 'e' (position 3)
|
||||||
|
let result = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(3); // At 'e' of café
|
||||||
|
|
||||||
|
// Second 'e' command should advance to end of "test" (position 9), not stay stuck
|
||||||
|
result = handleVimAction(result, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(9); // At 't' of "test"
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle precomposed characters with diacritics', () => {
|
||||||
|
// Test case with precomposed é for comparison
|
||||||
|
const state = createTestState(['café test'], 0, 0); // Start at 'c'
|
||||||
|
|
||||||
|
// First 'e' command should move to the 'é' (position 3)
|
||||||
|
let result = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(3); // At 'é' of café
|
||||||
|
|
||||||
|
// Second 'e' command should advance to end of "test" (position 8)
|
||||||
|
result = handleVimAction(result, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(8); // At 't' of "test"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Position commands', () => {
|
describe('Position commands', () => {
|
||||||
@@ -793,4 +905,215 @@ describe('vim-buffer-actions', () => {
|
|||||||
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
|
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('UTF-32 character handling in word/line operations', () => {
|
||||||
|
describe('Right-to-left text handling', () => {
|
||||||
|
it('should handle Arabic text in word movements', () => {
|
||||||
|
const state = createTestState(['hello مرحبا world'], 0, 0);
|
||||||
|
|
||||||
|
// Move to end of 'hello'
|
||||||
|
let result = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(4); // End of 'hello'
|
||||||
|
|
||||||
|
// Move to end of Arabic word
|
||||||
|
result = handleVimAction(result, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(10); // End of Arabic word 'مرحبا'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Chinese character handling', () => {
|
||||||
|
it('should handle Chinese characters in word movements', () => {
|
||||||
|
const state = createTestState(['hello 你好 world'], 0, 0);
|
||||||
|
|
||||||
|
// Move to end of 'hello'
|
||||||
|
let result = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(4); // End of 'hello'
|
||||||
|
|
||||||
|
// Move forward to start of 'world'
|
||||||
|
result = handleVimAction(result, {
|
||||||
|
type: 'vim_move_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(6); // Start of '你好'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Mixed script handling', () => {
|
||||||
|
it('should handle mixed Latin and non-Latin scripts with word end commands', () => {
|
||||||
|
const state = createTestState(['test中文test'], 0, 0);
|
||||||
|
|
||||||
|
let result = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(3); // End of 'test'
|
||||||
|
|
||||||
|
// Second word end command should move to end of '中文'
|
||||||
|
result = handleVimAction(result, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(5); // End of '中文'
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed Latin and non-Latin scripts with word forward commands', () => {
|
||||||
|
const state = createTestState(['test中文test'], 0, 0);
|
||||||
|
|
||||||
|
let result = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(4); // Start of '中'
|
||||||
|
|
||||||
|
// Second word forward command should move to start of final 'test'
|
||||||
|
result = handleVimAction(result, {
|
||||||
|
type: 'vim_move_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(6); // Start of final 'test'
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed Latin and non-Latin scripts with word backward commands', () => {
|
||||||
|
const state = createTestState(['test中文test'], 0, 9); // Start at end of final 'test'
|
||||||
|
|
||||||
|
let result = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_backward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(6); // Start of final 'test'
|
||||||
|
|
||||||
|
// Second word backward command should move to start of '中文'
|
||||||
|
result = handleVimAction(result, {
|
||||||
|
type: 'vim_move_word_backward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(4); // Start of '中'
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Unicode block characters consistently with w and e commands', () => {
|
||||||
|
const state = createTestState(['██ █████ ██'], 0, 0);
|
||||||
|
|
||||||
|
// Test w command progression
|
||||||
|
let wResult = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(wResult).toHaveOnlyValidCharacters();
|
||||||
|
expect(wResult.cursorCol).toBe(3); // Start of second block sequence
|
||||||
|
|
||||||
|
wResult = handleVimAction(wResult, {
|
||||||
|
type: 'vim_move_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(wResult).toHaveOnlyValidCharacters();
|
||||||
|
expect(wResult.cursorCol).toBe(9); // Start of third block sequence
|
||||||
|
|
||||||
|
// Test e command progression from beginning
|
||||||
|
let eResult = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(eResult).toHaveOnlyValidCharacters();
|
||||||
|
expect(eResult.cursorCol).toBe(1); // End of first block sequence
|
||||||
|
|
||||||
|
eResult = handleVimAction(eResult, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(eResult).toHaveOnlyValidCharacters();
|
||||||
|
expect(eResult.cursorCol).toBe(7); // End of second block sequence
|
||||||
|
|
||||||
|
eResult = handleVimAction(eResult, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(eResult).toHaveOnlyValidCharacters();
|
||||||
|
expect(eResult.cursorCol).toBe(10); // End of third block sequence
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle strings starting with Chinese characters', () => {
|
||||||
|
const state = createTestState(['中文test英文word'], 0, 0);
|
||||||
|
|
||||||
|
// Test 'w' command - when at start of non-Latin word, w moves to next word
|
||||||
|
let wResult = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(wResult).toHaveOnlyValidCharacters();
|
||||||
|
expect(wResult.cursorCol).toBe(2); // Start of 'test'
|
||||||
|
|
||||||
|
wResult = handleVimAction(wResult, {
|
||||||
|
type: 'vim_move_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(wResult.cursorCol).toBe(6); // Start of '英文'
|
||||||
|
|
||||||
|
// Test 'e' command
|
||||||
|
let eResult = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(eResult).toHaveOnlyValidCharacters();
|
||||||
|
expect(eResult.cursorCol).toBe(1); // End of 中文
|
||||||
|
|
||||||
|
eResult = handleVimAction(eResult, {
|
||||||
|
type: 'vim_move_word_end' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(eResult.cursorCol).toBe(5); // End of test
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle strings starting with Arabic characters', () => {
|
||||||
|
const state = createTestState(['مرحباhelloسلام'], 0, 0);
|
||||||
|
|
||||||
|
// Test 'w' command - when at start of non-Latin word, w moves to next word
|
||||||
|
let wResult = handleVimAction(state, {
|
||||||
|
type: 'vim_move_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(wResult).toHaveOnlyValidCharacters();
|
||||||
|
expect(wResult.cursorCol).toBe(5); // Start of 'hello'
|
||||||
|
|
||||||
|
wResult = handleVimAction(wResult, {
|
||||||
|
type: 'vim_move_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(wResult.cursorCol).toBe(10); // Start of 'سلام'
|
||||||
|
|
||||||
|
// Test 'b' command from end
|
||||||
|
const bState = createTestState(['مرحباhelloسلام'], 0, 13);
|
||||||
|
let bResult = handleVimAction(bState, {
|
||||||
|
type: 'vim_move_word_backward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(bResult).toHaveOnlyValidCharacters();
|
||||||
|
expect(bResult.cursorCol).toBe(10); // Start of سلام
|
||||||
|
|
||||||
|
bResult = handleVimAction(bResult, {
|
||||||
|
type: 'vim_move_word_backward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
});
|
||||||
|
expect(bResult.cursorCol).toBe(5); // Start of hello
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,16 +7,35 @@
|
|||||||
import {
|
import {
|
||||||
TextBufferState,
|
TextBufferState,
|
||||||
TextBufferAction,
|
TextBufferAction,
|
||||||
findNextWordStart,
|
|
||||||
findPrevWordStart,
|
|
||||||
findWordEnd,
|
|
||||||
getOffsetFromPosition,
|
|
||||||
getPositionFromOffsets,
|
|
||||||
getLineRangeOffsets,
|
getLineRangeOffsets,
|
||||||
|
getPositionFromOffsets,
|
||||||
replaceRangeInternal,
|
replaceRangeInternal,
|
||||||
pushUndo,
|
pushUndo,
|
||||||
|
isWordCharStrict,
|
||||||
|
isWordCharWithCombining,
|
||||||
|
isCombiningMark,
|
||||||
|
findNextWordAcrossLines,
|
||||||
|
findPrevWordAcrossLines,
|
||||||
|
findWordEndInLine,
|
||||||
} from './text-buffer.js';
|
} from './text-buffer.js';
|
||||||
import { cpLen } from '../../utils/textUtils.js';
|
import { cpLen, toCodePoints } from '../../utils/textUtils.js';
|
||||||
|
|
||||||
|
// Check if we're at the end of a base word (on the last base character)
|
||||||
|
// Returns true if current position has a base character followed only by combining marks until non-word
|
||||||
|
function isAtEndOfBaseWord(lineCodePoints: string[], col: number): boolean {
|
||||||
|
if (!isWordCharStrict(lineCodePoints[col])) return false;
|
||||||
|
|
||||||
|
// Look ahead to see if we have only combining marks followed by non-word
|
||||||
|
let i = col + 1;
|
||||||
|
|
||||||
|
// Skip any combining marks
|
||||||
|
while (i < lineCodePoints.length && isCombiningMark(lineCodePoints[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we hit end of line or non-word character, we were at end of base word
|
||||||
|
return i >= lineCodePoints.length || !isWordCharStrict(lineCodePoints[i]);
|
||||||
|
}
|
||||||
|
|
||||||
export type VimAction = Extract<
|
export type VimAction = Extract<
|
||||||
TextBufferAction,
|
TextBufferAction,
|
||||||
@@ -59,167 +78,38 @@ export function handleVimAction(
|
|||||||
action: VimAction,
|
action: VimAction,
|
||||||
): TextBufferState {
|
): TextBufferState {
|
||||||
const { lines, cursorRow, cursorCol } = state;
|
const { lines, cursorRow, cursorCol } = state;
|
||||||
// Cache text join to avoid repeated calculations for word operations
|
|
||||||
let text: string | null = null;
|
|
||||||
const getText = () => text ?? (text = lines.join('\n'));
|
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'vim_delete_word_forward': {
|
case 'vim_delete_word_forward':
|
||||||
const { count } = action.payload;
|
|
||||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
|
||||||
|
|
||||||
let endOffset = currentOffset;
|
|
||||||
let searchOffset = currentOffset;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const nextWordOffset = findNextWordStart(getText(), searchOffset);
|
|
||||||
if (nextWordOffset > searchOffset) {
|
|
||||||
searchOffset = nextWordOffset;
|
|
||||||
endOffset = nextWordOffset;
|
|
||||||
} else {
|
|
||||||
// If no next word, delete to end of current word
|
|
||||||
const wordEndOffset = findWordEnd(getText(), searchOffset);
|
|
||||||
endOffset = Math.min(wordEndOffset + 1, getText().length);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endOffset > currentOffset) {
|
|
||||||
const nextState = pushUndo(state);
|
|
||||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
|
||||||
currentOffset,
|
|
||||||
endOffset,
|
|
||||||
nextState.lines,
|
|
||||||
);
|
|
||||||
return replaceRangeInternal(
|
|
||||||
nextState,
|
|
||||||
startRow,
|
|
||||||
startCol,
|
|
||||||
endRow,
|
|
||||||
endCol,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'vim_delete_word_backward': {
|
|
||||||
const { count } = action.payload;
|
|
||||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
|
||||||
|
|
||||||
let startOffset = currentOffset;
|
|
||||||
let searchOffset = currentOffset;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
|
|
||||||
if (prevWordOffset < searchOffset) {
|
|
||||||
searchOffset = prevWordOffset;
|
|
||||||
startOffset = prevWordOffset;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startOffset < currentOffset) {
|
|
||||||
const nextState = pushUndo(state);
|
|
||||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
|
||||||
startOffset,
|
|
||||||
currentOffset,
|
|
||||||
nextState.lines,
|
|
||||||
);
|
|
||||||
const newState = replaceRangeInternal(
|
|
||||||
nextState,
|
|
||||||
startRow,
|
|
||||||
startCol,
|
|
||||||
endRow,
|
|
||||||
endCol,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
// Cursor is already at the correct position after deletion
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'vim_delete_word_end': {
|
|
||||||
const { count } = action.payload;
|
|
||||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
|
||||||
|
|
||||||
let offset = currentOffset;
|
|
||||||
let endOffset = currentOffset;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const wordEndOffset = findWordEnd(getText(), offset);
|
|
||||||
if (wordEndOffset >= offset) {
|
|
||||||
endOffset = wordEndOffset + 1; // Include the character at word end
|
|
||||||
// For next iteration, move to start of next word
|
|
||||||
if (i < count - 1) {
|
|
||||||
const nextWordStart = findNextWordStart(
|
|
||||||
getText(),
|
|
||||||
wordEndOffset + 1,
|
|
||||||
);
|
|
||||||
offset = nextWordStart;
|
|
||||||
if (nextWordStart <= wordEndOffset) {
|
|
||||||
break; // No more words
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endOffset = Math.min(endOffset, getText().length);
|
|
||||||
|
|
||||||
if (endOffset > currentOffset) {
|
|
||||||
const nextState = pushUndo(state);
|
|
||||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
|
||||||
currentOffset,
|
|
||||||
endOffset,
|
|
||||||
nextState.lines,
|
|
||||||
);
|
|
||||||
return replaceRangeInternal(
|
|
||||||
nextState,
|
|
||||||
startRow,
|
|
||||||
startCol,
|
|
||||||
endRow,
|
|
||||||
endCol,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'vim_change_word_forward': {
|
case 'vim_change_word_forward': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
let endRow = cursorRow;
|
||||||
|
let endCol = cursorCol;
|
||||||
let searchOffset = currentOffset;
|
|
||||||
let endOffset = currentOffset;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const nextWordOffset = findNextWordStart(getText(), searchOffset);
|
const nextWord = findNextWordAcrossLines(lines, endRow, endCol, true);
|
||||||
if (nextWordOffset > searchOffset) {
|
if (nextWord) {
|
||||||
searchOffset = nextWordOffset;
|
endRow = nextWord.row;
|
||||||
endOffset = nextWordOffset;
|
endCol = nextWord.col;
|
||||||
} else {
|
} else {
|
||||||
// If no next word, change to end of current word
|
// No more words, delete/change to end of current word or line
|
||||||
const wordEndOffset = findWordEnd(getText(), searchOffset);
|
const currentLine = lines[endRow] || '';
|
||||||
endOffset = Math.min(wordEndOffset + 1, getText().length);
|
const wordEnd = findWordEndInLine(currentLine, endCol);
|
||||||
|
if (wordEnd !== null) {
|
||||||
|
endCol = wordEnd + 1; // Include the character at word end
|
||||||
|
} else {
|
||||||
|
endCol = cpLen(currentLine);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endOffset > currentOffset) {
|
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||||
const nextState = pushUndo(state);
|
const nextState = pushUndo(state);
|
||||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
|
||||||
currentOffset,
|
|
||||||
endOffset,
|
|
||||||
nextState.lines,
|
|
||||||
);
|
|
||||||
return replaceRangeInternal(
|
return replaceRangeInternal(
|
||||||
nextState,
|
nextState,
|
||||||
startRow,
|
cursorRow,
|
||||||
startCol,
|
cursorCol,
|
||||||
endRow,
|
endRow,
|
||||||
endCol,
|
endCol,
|
||||||
'',
|
'',
|
||||||
@@ -228,61 +118,61 @@ export function handleVimAction(
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'vim_delete_word_backward':
|
||||||
case 'vim_change_word_backward': {
|
case 'vim_change_word_backward': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
let startRow = cursorRow;
|
||||||
|
let startCol = cursorCol;
|
||||||
let startOffset = currentOffset;
|
|
||||||
let searchOffset = currentOffset;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
|
const prevWord = findPrevWordAcrossLines(lines, startRow, startCol);
|
||||||
if (prevWordOffset < searchOffset) {
|
if (prevWord) {
|
||||||
searchOffset = prevWordOffset;
|
startRow = prevWord.row;
|
||||||
startOffset = prevWordOffset;
|
startCol = prevWord.col;
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startOffset < currentOffset) {
|
if (startRow !== cursorRow || startCol !== cursorCol) {
|
||||||
const nextState = pushUndo(state);
|
const nextState = pushUndo(state);
|
||||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
|
||||||
startOffset,
|
|
||||||
currentOffset,
|
|
||||||
nextState.lines,
|
|
||||||
);
|
|
||||||
return replaceRangeInternal(
|
return replaceRangeInternal(
|
||||||
nextState,
|
nextState,
|
||||||
startRow,
|
startRow,
|
||||||
startCol,
|
startCol,
|
||||||
endRow,
|
cursorRow,
|
||||||
endCol,
|
cursorCol,
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'vim_delete_word_end':
|
||||||
case 'vim_change_word_end': {
|
case 'vim_change_word_end': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
let row = cursorRow;
|
||||||
|
let col = cursorCol;
|
||||||
let offset = currentOffset;
|
let endRow = cursorRow;
|
||||||
let endOffset = currentOffset;
|
let endCol = cursorCol;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const wordEndOffset = findWordEnd(getText(), offset);
|
const wordEnd = findNextWordAcrossLines(lines, row, col, false);
|
||||||
if (wordEndOffset >= offset) {
|
if (wordEnd) {
|
||||||
endOffset = wordEndOffset + 1; // Include the character at word end
|
endRow = wordEnd.row;
|
||||||
|
endCol = wordEnd.col + 1; // Include the character at word end
|
||||||
// For next iteration, move to start of next word
|
// For next iteration, move to start of next word
|
||||||
if (i < count - 1) {
|
if (i < count - 1) {
|
||||||
const nextWordStart = findNextWordStart(
|
const nextWord = findNextWordAcrossLines(
|
||||||
getText(),
|
lines,
|
||||||
wordEndOffset + 1,
|
wordEnd.row,
|
||||||
|
wordEnd.col + 1,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
offset = nextWordStart;
|
if (nextWord) {
|
||||||
if (nextWordStart <= wordEndOffset) {
|
row = nextWord.row;
|
||||||
|
col = nextWord.col;
|
||||||
|
} else {
|
||||||
break; // No more words
|
break; // No more words
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,19 +181,18 @@ export function handleVimAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
endOffset = Math.min(endOffset, getText().length);
|
// Ensure we don't go past the end of the last line
|
||||||
|
if (endRow < lines.length) {
|
||||||
|
const lineLen = cpLen(lines[endRow] || '');
|
||||||
|
endCol = Math.min(endCol, lineLen);
|
||||||
|
}
|
||||||
|
|
||||||
if (endOffset !== currentOffset) {
|
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||||
const nextState = pushUndo(state);
|
const nextState = pushUndo(state);
|
||||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
|
||||||
Math.min(currentOffset, endOffset),
|
|
||||||
Math.max(currentOffset, endOffset),
|
|
||||||
nextState.lines,
|
|
||||||
);
|
|
||||||
return replaceRangeInternal(
|
return replaceRangeInternal(
|
||||||
nextState,
|
nextState,
|
||||||
startRow,
|
cursorRow,
|
||||||
startCol,
|
cursorCol,
|
||||||
endRow,
|
endRow,
|
||||||
endCol,
|
endCol,
|
||||||
'',
|
'',
|
||||||
@@ -376,32 +265,17 @@ export function handleVimAction(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'vim_delete_to_end_of_line': {
|
case 'vim_delete_to_end_of_line':
|
||||||
const currentLine = lines[cursorRow] || '';
|
|
||||||
if (cursorCol < currentLine.length) {
|
|
||||||
const nextState = pushUndo(state);
|
|
||||||
return replaceRangeInternal(
|
|
||||||
nextState,
|
|
||||||
cursorRow,
|
|
||||||
cursorCol,
|
|
||||||
cursorRow,
|
|
||||||
currentLine.length,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'vim_change_to_end_of_line': {
|
case 'vim_change_to_end_of_line': {
|
||||||
const currentLine = lines[cursorRow] || '';
|
const currentLine = lines[cursorRow] || '';
|
||||||
if (cursorCol < currentLine.length) {
|
if (cursorCol < cpLen(currentLine)) {
|
||||||
const nextState = pushUndo(state);
|
const nextState = pushUndo(state);
|
||||||
return replaceRangeInternal(
|
return replaceRangeInternal(
|
||||||
nextState,
|
nextState,
|
||||||
cursorRow,
|
cursorRow,
|
||||||
cursorCol,
|
cursorCol,
|
||||||
cursorRow,
|
cursorRow,
|
||||||
currentLine.length,
|
cpLen(currentLine),
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -578,6 +452,16 @@ export function handleVimAction(
|
|||||||
}
|
}
|
||||||
} else if (newCol < lineLength - 1) {
|
} else if (newCol < lineLength - 1) {
|
||||||
newCol++;
|
newCol++;
|
||||||
|
|
||||||
|
// Skip over combining marks - don't let cursor land on them
|
||||||
|
const currentLinePoints = toCodePoints(currentLine);
|
||||||
|
while (
|
||||||
|
newCol < currentLinePoints.length &&
|
||||||
|
isCombiningMark(currentLinePoints[newCol]) &&
|
||||||
|
newCol < lineLength - 1
|
||||||
|
) {
|
||||||
|
newCol++;
|
||||||
|
}
|
||||||
} else if (newRow < lines.length - 1) {
|
} else if (newRow < lines.length - 1) {
|
||||||
// At end of line - move to beginning of next line
|
// At end of line - move to beginning of next line
|
||||||
newRow++;
|
newRow++;
|
||||||
@@ -597,7 +481,12 @@ export function handleVimAction(
|
|||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
const { cursorRow, cursorCol, lines } = state;
|
const { cursorRow, cursorCol, lines } = state;
|
||||||
const newRow = Math.max(0, cursorRow - count);
|
const newRow = Math.max(0, cursorRow - count);
|
||||||
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
|
const targetLine = lines[newRow] || '';
|
||||||
|
const targetLineLength = cpLen(targetLine);
|
||||||
|
const newCol = Math.min(
|
||||||
|
cursorCol,
|
||||||
|
targetLineLength > 0 ? targetLineLength - 1 : 0,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -611,7 +500,12 @@ export function handleVimAction(
|
|||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
const { cursorRow, cursorCol, lines } = state;
|
const { cursorRow, cursorCol, lines } = state;
|
||||||
const newRow = Math.min(lines.length - 1, cursorRow + count);
|
const newRow = Math.min(lines.length - 1, cursorRow + count);
|
||||||
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
|
const targetLine = lines[newRow] || '';
|
||||||
|
const targetLineLength = cpLen(targetLine);
|
||||||
|
const newCol = Math.min(
|
||||||
|
cursorCol,
|
||||||
|
targetLineLength > 0 ? targetLineLength - 1 : 0,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -623,69 +517,101 @@ export function handleVimAction(
|
|||||||
|
|
||||||
case 'vim_move_word_forward': {
|
case 'vim_move_word_forward': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
let row = cursorRow;
|
||||||
|
let col = cursorCol;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const nextWordOffset = findNextWordStart(getText(), offset);
|
const nextWord = findNextWordAcrossLines(lines, row, col, true);
|
||||||
if (nextWordOffset > offset) {
|
if (nextWord) {
|
||||||
offset = nextWordOffset;
|
row = nextWord.row;
|
||||||
|
col = nextWord.col;
|
||||||
} else {
|
} else {
|
||||||
// No more words to move to
|
// No more words to move to
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { startRow, startCol } = getPositionFromOffsets(
|
|
||||||
offset,
|
|
||||||
offset,
|
|
||||||
lines,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
cursorRow: startRow,
|
cursorRow: row,
|
||||||
cursorCol: startCol,
|
cursorCol: col,
|
||||||
preferredCol: null,
|
preferredCol: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'vim_move_word_backward': {
|
case 'vim_move_word_backward': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
let row = cursorRow;
|
||||||
|
let col = cursorCol;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
offset = findPrevWordStart(getText(), offset);
|
const prevWord = findPrevWordAcrossLines(lines, row, col);
|
||||||
|
if (prevWord) {
|
||||||
|
row = prevWord.row;
|
||||||
|
col = prevWord.col;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { startRow, startCol } = getPositionFromOffsets(
|
|
||||||
offset,
|
|
||||||
offset,
|
|
||||||
lines,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
cursorRow: startRow,
|
cursorRow: row,
|
||||||
cursorCol: startCol,
|
cursorCol: col,
|
||||||
preferredCol: null,
|
preferredCol: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'vim_move_word_end': {
|
case 'vim_move_word_end': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
let row = cursorRow;
|
||||||
|
let col = cursorCol;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
offset = findWordEnd(getText(), offset);
|
// Special handling for the first iteration when we're at end of word
|
||||||
|
if (i === 0) {
|
||||||
|
const currentLine = lines[row] || '';
|
||||||
|
const lineCodePoints = toCodePoints(currentLine);
|
||||||
|
|
||||||
|
// Check if we're at the end of a word (on the last base character)
|
||||||
|
const atEndOfWord =
|
||||||
|
col < lineCodePoints.length &&
|
||||||
|
isWordCharStrict(lineCodePoints[col]) &&
|
||||||
|
(col + 1 >= lineCodePoints.length ||
|
||||||
|
!isWordCharWithCombining(lineCodePoints[col + 1]) ||
|
||||||
|
// Or if we're on a base char followed only by combining marks until non-word
|
||||||
|
(isWordCharStrict(lineCodePoints[col]) &&
|
||||||
|
isAtEndOfBaseWord(lineCodePoints, col)));
|
||||||
|
|
||||||
|
if (atEndOfWord) {
|
||||||
|
// We're already at end of word, find next word end
|
||||||
|
const nextWord = findNextWordAcrossLines(
|
||||||
|
lines,
|
||||||
|
row,
|
||||||
|
col + 1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (nextWord) {
|
||||||
|
row = nextWord.row;
|
||||||
|
col = nextWord.col;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordEnd = findNextWordAcrossLines(lines, row, col, false);
|
||||||
|
if (wordEnd) {
|
||||||
|
row = wordEnd.row;
|
||||||
|
col = wordEnd.col;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { startRow, startCol } = getPositionFromOffsets(
|
|
||||||
offset,
|
|
||||||
offset,
|
|
||||||
lines,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
cursorRow: startRow,
|
cursorRow: row,
|
||||||
cursorCol: startCol,
|
cursorCol: col,
|
||||||
preferredCol: null,
|
preferredCol: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -783,7 +709,7 @@ export function handleVimAction(
|
|||||||
let col = 0;
|
let col = 0;
|
||||||
|
|
||||||
// Find first non-whitespace character using proper Unicode handling
|
// Find first non-whitespace character using proper Unicode handling
|
||||||
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
|
const lineCodePoints = toCodePoints(currentLine);
|
||||||
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
||||||
col++;
|
col++;
|
||||||
}
|
}
|
||||||
@@ -820,7 +746,7 @@ export function handleVimAction(
|
|||||||
let col = 0;
|
let col = 0;
|
||||||
|
|
||||||
// Find first non-whitespace character using proper Unicode handling
|
// Find first non-whitespace character using proper Unicode handling
|
||||||
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
|
const lineCodePoints = toCodePoints(currentLine);
|
||||||
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
||||||
col++;
|
col++;
|
||||||
}
|
}
|
||||||
|
|||||||
20
packages/cli/src/ui/contexts/SettingsContext.tsx
Normal file
20
packages/cli/src/ui/contexts/SettingsContext.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { LoadedSettings } from '../../config/settings.js';
|
||||||
|
|
||||||
|
export const SettingsContext = React.createContext<LoadedSettings | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useSettings = () => {
|
||||||
|
const context = useContext(SettingsContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSettings must be used within a SettingsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import * as fs from 'fs/promises';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { PartListUnion, PartUnion } from '@google/genai';
|
import { PartListUnion, PartUnion } from '@google/genai';
|
||||||
import {
|
import {
|
||||||
|
AnyToolInvocation,
|
||||||
Config,
|
Config,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
isNodeError,
|
isNodeError,
|
||||||
@@ -254,7 +255,7 @@ export async function handleAtCommand({
|
|||||||
`Path ${pathName} not found directly, attempting glob search.`,
|
`Path ${pathName} not found directly, attempting glob search.`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const globResult = await globTool.execute(
|
const globResult = await globTool.buildAndExecute(
|
||||||
{
|
{
|
||||||
pattern: `**/*${pathName}*`,
|
pattern: `**/*${pathName}*`,
|
||||||
path: dir,
|
path: dir,
|
||||||
@@ -411,12 +412,14 @@ export async function handleAtCommand({
|
|||||||
};
|
};
|
||||||
let toolCallDisplay: IndividualToolCallDisplay;
|
let toolCallDisplay: IndividualToolCallDisplay;
|
||||||
|
|
||||||
|
let invocation: AnyToolInvocation | undefined = undefined;
|
||||||
try {
|
try {
|
||||||
const result = await readManyFilesTool.execute(toolArgs, signal);
|
invocation = readManyFilesTool.build(toolArgs);
|
||||||
|
const result = await invocation.execute(signal);
|
||||||
toolCallDisplay = {
|
toolCallDisplay = {
|
||||||
callId: `client-read-${userMessageTimestamp}`,
|
callId: `client-read-${userMessageTimestamp}`,
|
||||||
name: readManyFilesTool.displayName,
|
name: readManyFilesTool.displayName,
|
||||||
description: readManyFilesTool.getDescription(toolArgs),
|
description: invocation.getDescription(),
|
||||||
status: ToolCallStatus.Success,
|
status: ToolCallStatus.Success,
|
||||||
resultDisplay:
|
resultDisplay:
|
||||||
result.returnDisplay ||
|
result.returnDisplay ||
|
||||||
@@ -466,7 +469,9 @@ export async function handleAtCommand({
|
|||||||
toolCallDisplay = {
|
toolCallDisplay = {
|
||||||
callId: `client-read-${userMessageTimestamp}`,
|
callId: `client-read-${userMessageTimestamp}`,
|
||||||
name: readManyFilesTool.displayName,
|
name: readManyFilesTool.displayName,
|
||||||
description: readManyFilesTool.getDescription(toolArgs),
|
description:
|
||||||
|
invocation?.getDescription() ??
|
||||||
|
'Error attempting to execute tool to read files',
|
||||||
status: ToolCallStatus.Error,
|
status: ToolCallStatus.Error,
|
||||||
resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ vi.mock('../contexts/SessionContext.js', () => ({
|
|||||||
useSessionStats: vi.fn(() => ({ stats: {} })),
|
useSessionStats: vi.fn(() => ({ stats: {} })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { mockRunExitCleanup } = vi.hoisted(() => ({
|
||||||
|
mockRunExitCleanup: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../utils/cleanup.js', () => ({
|
||||||
|
runExitCleanup: mockRunExitCleanup,
|
||||||
|
}));
|
||||||
|
|
||||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
||||||
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
|
||||||
@@ -139,6 +147,7 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
vi.fn(), // toggleCorgiMode
|
vi.fn(), // toggleCorgiMode
|
||||||
mockSetQuittingMessages,
|
mockSetQuittingMessages,
|
||||||
vi.fn(), // openPrivacyNotice
|
vi.fn(), // openPrivacyNotice
|
||||||
|
vi.fn(), // openSettingsDialog
|
||||||
vi.fn(), // toggleVimEnabled
|
vi.fn(), // toggleVimEnabled
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
),
|
),
|
||||||
@@ -405,6 +414,37 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call runExitCleanup when handling a "quit" action', async () => {
|
||||||
|
const quitAction = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ type: 'quit', messages: [] });
|
||||||
|
const command = createTestCommand({
|
||||||
|
name: 'exit',
|
||||||
|
action: quitAction,
|
||||||
|
});
|
||||||
|
const result = setupProcessorHook([command]);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.slashCommands).toHaveLength(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleSlashCommand('/exit');
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRunExitCleanup).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle "submit_prompt" action returned from a file-based command', async () => {
|
it('should handle "submit_prompt" action returned from a file-based command', async () => {
|
||||||
@@ -825,6 +865,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
vi.fn(), // toggleCorgiMode
|
vi.fn(), // toggleCorgiMode
|
||||||
mockSetQuittingMessages,
|
mockSetQuittingMessages,
|
||||||
vi.fn(), // openPrivacyNotice
|
vi.fn(), // openPrivacyNotice
|
||||||
|
|
||||||
|
vi.fn(), // openSettingsDialog
|
||||||
|
vi.fn(), // toggleVimEnabled
|
||||||
vi.fn().mockResolvedValue(false), // toggleVimEnabled
|
vi.fn().mockResolvedValue(false), // toggleVimEnabled
|
||||||
vi.fn(), // setIsProcessing
|
vi.fn(), // setIsProcessing
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||||
|
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||||
import {
|
import {
|
||||||
Message,
|
Message,
|
||||||
MessageType,
|
MessageType,
|
||||||
@@ -49,6 +50,7 @@ export const useSlashCommandProcessor = (
|
|||||||
toggleCorgiMode: () => void,
|
toggleCorgiMode: () => void,
|
||||||
setQuittingMessages: (message: HistoryItem[]) => void,
|
setQuittingMessages: (message: HistoryItem[]) => void,
|
||||||
openPrivacyNotice: () => void,
|
openPrivacyNotice: () => void,
|
||||||
|
openSettingsDialog: () => void,
|
||||||
toggleVimEnabled: () => Promise<boolean>,
|
toggleVimEnabled: () => Promise<boolean>,
|
||||||
setIsProcessing: (isProcessing: boolean) => void,
|
setIsProcessing: (isProcessing: boolean) => void,
|
||||||
setGeminiMdFileCount: (count: number) => void,
|
setGeminiMdFileCount: (count: number) => void,
|
||||||
@@ -63,6 +65,11 @@ export const useSlashCommandProcessor = (
|
|||||||
approvedCommands?: string[],
|
approvedCommands?: string[],
|
||||||
) => void;
|
) => void;
|
||||||
}>(null);
|
}>(null);
|
||||||
|
const [confirmationRequest, setConfirmationRequest] = useState<null | {
|
||||||
|
prompt: React.ReactNode;
|
||||||
|
onConfirm: (confirmed: boolean) => void;
|
||||||
|
}>(null);
|
||||||
|
|
||||||
const [sessionShellAllowlist, setSessionShellAllowlist] = useState(
|
const [sessionShellAllowlist, setSessionShellAllowlist] = useState(
|
||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
@@ -221,6 +228,7 @@ export const useSlashCommandProcessor = (
|
|||||||
async (
|
async (
|
||||||
rawQuery: PartListUnion,
|
rawQuery: PartListUnion,
|
||||||
oneTimeShellAllowlist?: Set<string>,
|
oneTimeShellAllowlist?: Set<string>,
|
||||||
|
overwriteConfirmed?: boolean,
|
||||||
): Promise<SlashCommandProcessorResult | false> => {
|
): Promise<SlashCommandProcessorResult | false> => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
@@ -300,6 +308,7 @@ export const useSlashCommandProcessor = (
|
|||||||
name: commandToExecute.name,
|
name: commandToExecute.name,
|
||||||
args,
|
args,
|
||||||
},
|
},
|
||||||
|
overwriteConfirmed,
|
||||||
};
|
};
|
||||||
|
|
||||||
// If a one-time list is provided for a "Proceed" action, temporarily
|
// If a one-time list is provided for a "Proceed" action, temporarily
|
||||||
@@ -353,6 +362,11 @@ export const useSlashCommandProcessor = (
|
|||||||
case 'privacy':
|
case 'privacy':
|
||||||
openPrivacyNotice();
|
openPrivacyNotice();
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
case 'settings':
|
||||||
|
openSettingsDialog();
|
||||||
|
return { type: 'handled' };
|
||||||
|
case 'help':
|
||||||
|
return { type: 'handled' };
|
||||||
default: {
|
default: {
|
||||||
const unhandled: never = result.dialog;
|
const unhandled: never = result.dialog;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -372,7 +386,8 @@ export const useSlashCommandProcessor = (
|
|||||||
}
|
}
|
||||||
case 'quit':
|
case 'quit':
|
||||||
setQuittingMessages(result.messages);
|
setQuittingMessages(result.messages);
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
|
await runExitCleanup();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}, 100);
|
}, 100);
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
@@ -422,6 +437,36 @@ export const useSlashCommandProcessor = (
|
|||||||
new Set(approvedCommands),
|
new Set(approvedCommands),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'confirm_action': {
|
||||||
|
const { confirmed } = await new Promise<{
|
||||||
|
confirmed: boolean;
|
||||||
|
}>((resolve) => {
|
||||||
|
setConfirmationRequest({
|
||||||
|
prompt: result.prompt,
|
||||||
|
onConfirm: (resolvedConfirmed) => {
|
||||||
|
setConfirmationRequest(null);
|
||||||
|
resolve({ confirmed: resolvedConfirmed });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Operation cancelled.',
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return { type: 'handled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleSlashCommand(
|
||||||
|
result.originalInvocation.raw,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
const unhandled: never = result;
|
const unhandled: never = result;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -475,9 +520,11 @@ export const useSlashCommandProcessor = (
|
|||||||
openPrivacyNotice,
|
openPrivacyNotice,
|
||||||
openEditorDialog,
|
openEditorDialog,
|
||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
|
openSettingsDialog,
|
||||||
setShellConfirmationRequest,
|
setShellConfirmationRequest,
|
||||||
setSessionShellAllowlist,
|
setSessionShellAllowlist,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
|
setConfirmationRequest,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -487,5 +534,6 @@ export const useSlashCommandProcessor = (
|
|||||||
pendingHistoryItems,
|
pendingHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
shellConfirmationRequest,
|
shellConfirmationRequest,
|
||||||
|
confirmationRequest,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ describe('useAtCompletion', () => {
|
|||||||
respectGitIgnore: true,
|
respectGitIgnore: true,
|
||||||
respectGeminiIgnore: true,
|
respectGeminiIgnore: true,
|
||||||
})),
|
})),
|
||||||
|
getEnableRecursiveFileSearch: () => true,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -113,8 +114,8 @@ describe('useAtCompletion', () => {
|
|||||||
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||||
'src/',
|
'src/',
|
||||||
'src/components/',
|
'src/components/',
|
||||||
'src/components/Button.tsx',
|
|
||||||
'src/index.js',
|
'src/index.js',
|
||||||
|
'src/components/Button.tsx',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +157,7 @@ describe('useAtCompletion', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT show a loading indicator for subsequent searches that complete under 100ms', async () => {
|
it('should NOT show a loading indicator for subsequent searches that complete under 200ms', async () => {
|
||||||
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
|
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
|
||||||
testRootDir = await createTmpDir(structure);
|
testRootDir = await createTmpDir(structure);
|
||||||
|
|
||||||
@@ -185,7 +186,7 @@ describe('useAtCompletion', () => {
|
|||||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 100ms', async () => {
|
it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 200ms', async () => {
|
||||||
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
|
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
|
||||||
testRootDir = await createTmpDir(structure);
|
testRootDir = await createTmpDir(structure);
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ describe('useAtCompletion', () => {
|
|||||||
const originalSearch = FileSearch.prototype.search;
|
const originalSearch = FileSearch.prototype.search;
|
||||||
vi.spyOn(FileSearch.prototype, 'search').mockImplementation(
|
vi.spyOn(FileSearch.prototype, 'search').mockImplementation(
|
||||||
async function (...args) {
|
async function (...args) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
return originalSearch.apply(this, args);
|
return originalSearch.apply(this, args);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -283,6 +284,61 @@ describe('useAtCompletion', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('State Management', () => {
|
||||||
|
it('should reset the state when disabled after being in a READY state', async () => {
|
||||||
|
const structure: FileSystemStructure = { 'a.txt': '' };
|
||||||
|
testRootDir = await createTmpDir(structure);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ enabled }) =>
|
||||||
|
useTestHarnessForAtCompletion(enabled, 'a', mockConfig, testRootDir),
|
||||||
|
{ initialProps: { enabled: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the hook to be ready and have suggestions
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||||
|
'a.txt',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now, disable the hook
|
||||||
|
rerender({ enabled: false });
|
||||||
|
|
||||||
|
// The suggestions should be cleared immediately because of the RESET action
|
||||||
|
expect(result.current.suggestions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset the state when disabled after being in an ERROR state', async () => {
|
||||||
|
testRootDir = await createTmpDir({});
|
||||||
|
|
||||||
|
// Force an error during initialization
|
||||||
|
vi.spyOn(FileSearch.prototype, 'initialize').mockRejectedValueOnce(
|
||||||
|
new Error('Initialization failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ enabled }) =>
|
||||||
|
useTestHarnessForAtCompletion(enabled, '', mockConfig, testRootDir),
|
||||||
|
{ initialProps: { enabled: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the hook to enter the error state
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||||
|
});
|
||||||
|
expect(result.current.suggestions).toEqual([]); // No suggestions on error
|
||||||
|
|
||||||
|
// Now, disable the hook
|
||||||
|
rerender({ enabled: false });
|
||||||
|
|
||||||
|
// The state should still be reset (though visually it's the same)
|
||||||
|
// We can't directly inspect the internal state, but we can ensure it doesn't crash
|
||||||
|
// and the suggestions remain empty.
|
||||||
|
expect(result.current.suggestions).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Filtering and Configuration', () => {
|
describe('Filtering and Configuration', () => {
|
||||||
it('should respect .gitignore files', async () => {
|
it('should respect .gitignore files', async () => {
|
||||||
const gitignoreContent = ['dist/', '*.log'].join('\n');
|
const gitignoreContent = ['dist/', '*.log'].join('\n');
|
||||||
@@ -376,5 +432,42 @@ describe('useAtCompletion', () => {
|
|||||||
await cleanupTmpDir(rootDir1);
|
await cleanupTmpDir(rootDir1);
|
||||||
await cleanupTmpDir(rootDir2);
|
await cleanupTmpDir(rootDir2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should perform a non-recursive search when enableRecursiveFileSearch is false', async () => {
|
||||||
|
const structure: FileSystemStructure = {
|
||||||
|
'file.txt': '',
|
||||||
|
src: {
|
||||||
|
'index.js': '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
testRootDir = await createTmpDir(structure);
|
||||||
|
|
||||||
|
const nonRecursiveConfig = {
|
||||||
|
getEnableRecursiveFileSearch: () => false,
|
||||||
|
getFileFilteringOptions: vi.fn(() => ({
|
||||||
|
respectGitIgnore: true,
|
||||||
|
respectGeminiIgnore: true,
|
||||||
|
})),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTestHarnessForAtCompletion(
|
||||||
|
true,
|
||||||
|
'',
|
||||||
|
nonRecursiveConfig,
|
||||||
|
testRootDir,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should only contain top-level items
|
||||||
|
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
||||||
|
'src/',
|
||||||
|
'file.txt',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
|
|||||||
config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true,
|
config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true,
|
||||||
cache: true,
|
cache: true,
|
||||||
cacheTtl: 30, // 30 seconds
|
cacheTtl: 30, // 30 seconds
|
||||||
|
maxDepth: !(config?.getEnableRecursiveFileSearch() ?? true)
|
||||||
|
? 0
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
await searcher.initialize();
|
await searcher.initialize();
|
||||||
fileSearch.current = searcher;
|
fileSearch.current = searcher;
|
||||||
@@ -191,7 +194,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
|
|||||||
|
|
||||||
slowSearchTimer.current = setTimeout(() => {
|
slowSearchTimer.current = setTimeout(() => {
|
||||||
dispatch({ type: 'SET_LOADING', payload: true });
|
dispatch({ type: 'SET_LOADING', payload: true });
|
||||||
}, 100);
|
}, 200);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await fileSearch.current.search(state.pattern, {
|
const results = await fileSearch.current.search(state.pattern, {
|
||||||
|
|||||||
78
packages/cli/src/ui/hooks/useFolderTrust.test.ts
Normal file
78
packages/cli/src/ui/hooks/useFolderTrust.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { useFolderTrust } from './useFolderTrust.js';
|
||||||
|
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
|
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
||||||
|
|
||||||
|
describe('useFolderTrust', () => {
|
||||||
|
it('should set isFolderTrustDialogOpen to true when folderTrustFeature is true and folderTrust is undefined', () => {
|
||||||
|
const settings = {
|
||||||
|
merged: {
|
||||||
|
folderTrustFeature: true,
|
||||||
|
folderTrust: undefined,
|
||||||
|
},
|
||||||
|
setValue: vi.fn(),
|
||||||
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFolderTrust(settings));
|
||||||
|
|
||||||
|
expect(result.current.isFolderTrustDialogOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isFolderTrustDialogOpen to false when folderTrustFeature is false', () => {
|
||||||
|
const settings = {
|
||||||
|
merged: {
|
||||||
|
folderTrustFeature: false,
|
||||||
|
folderTrust: undefined,
|
||||||
|
},
|
||||||
|
setValue: vi.fn(),
|
||||||
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFolderTrust(settings));
|
||||||
|
|
||||||
|
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isFolderTrustDialogOpen to false when folderTrust is defined', () => {
|
||||||
|
const settings = {
|
||||||
|
merged: {
|
||||||
|
folderTrustFeature: true,
|
||||||
|
folderTrust: true,
|
||||||
|
},
|
||||||
|
setValue: vi.fn(),
|
||||||
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFolderTrust(settings));
|
||||||
|
|
||||||
|
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setValue and set isFolderTrustDialogOpen to false on handleFolderTrustSelect', () => {
|
||||||
|
const settings = {
|
||||||
|
merged: {
|
||||||
|
folderTrustFeature: true,
|
||||||
|
folderTrust: undefined,
|
||||||
|
},
|
||||||
|
setValue: vi.fn(),
|
||||||
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFolderTrust(settings));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(settings.setValue).toHaveBeenCalledWith(
|
||||||
|
SettingScope.User,
|
||||||
|
'folderTrust',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
packages/cli/src/ui/hooks/useFolderTrust.ts
Normal file
31
packages/cli/src/ui/hooks/useFolderTrust.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
|
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
||||||
|
|
||||||
|
export const useFolderTrust = (settings: LoadedSettings) => {
|
||||||
|
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(
|
||||||
|
!!settings.merged.folderTrustFeature &&
|
||||||
|
// TODO: Update to avoid showing dialog for folders that are trusted.
|
||||||
|
settings.merged.folderTrust === undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderTrustSelect = useCallback(
|
||||||
|
(_choice: FolderTrustChoice) => {
|
||||||
|
// TODO: Store folderPath in the trusted folders config file based on the choice.
|
||||||
|
settings.setValue(SettingScope.User, 'folderTrust', true);
|
||||||
|
setIsFolderTrustDialogOpen(false);
|
||||||
|
},
|
||||||
|
[settings],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFolderTrustDialogOpen,
|
||||||
|
handleFolderTrustSelect,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
EditorType,
|
EditorType,
|
||||||
AuthType,
|
AuthType,
|
||||||
GeminiEventType as ServerGeminiEventType,
|
GeminiEventType as ServerGeminiEventType,
|
||||||
|
AnyToolInvocation,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { Part, PartListUnion } from '@google/genai';
|
import { Part, PartListUnion } from '@google/genai';
|
||||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
@@ -405,6 +406,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -453,9 +456,13 @@ describe('useGeminiStream', () => {
|
|||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
name: 'tool1',
|
name: 'tool1',
|
||||||
|
displayName: 'tool1',
|
||||||
description: 'desc1',
|
description: 'desc1',
|
||||||
getDescription: vi.fn(),
|
build: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
|
invocation: {
|
||||||
|
getDescription: () => `Mock description`,
|
||||||
|
} as unknown as AnyToolInvocation,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
endTime: Date.now(),
|
endTime: Date.now(),
|
||||||
} as TrackedCompletedToolCall,
|
} as TrackedCompletedToolCall,
|
||||||
@@ -470,9 +477,13 @@ describe('useGeminiStream', () => {
|
|||||||
responseSubmittedToGemini: false,
|
responseSubmittedToGemini: false,
|
||||||
tool: {
|
tool: {
|
||||||
name: 'tool2',
|
name: 'tool2',
|
||||||
|
displayName: 'tool2',
|
||||||
description: 'desc2',
|
description: 'desc2',
|
||||||
getDescription: vi.fn(),
|
build: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
|
invocation: {
|
||||||
|
getDescription: () => `Mock description`,
|
||||||
|
} as unknown as AnyToolInvocation,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
liveOutput: '...',
|
liveOutput: '...',
|
||||||
} as TrackedExecutingToolCall,
|
} as TrackedExecutingToolCall,
|
||||||
@@ -507,6 +518,12 @@ describe('useGeminiStream', () => {
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
responseSubmittedToGemini: false,
|
responseSubmittedToGemini: false,
|
||||||
response: { callId: 'call1', responseParts: toolCall1ResponseParts },
|
response: { callId: 'call1', responseParts: toolCall1ResponseParts },
|
||||||
|
tool: {
|
||||||
|
displayName: 'MockTool',
|
||||||
|
},
|
||||||
|
invocation: {
|
||||||
|
getDescription: () => `Mock description`,
|
||||||
|
} as unknown as AnyToolInvocation,
|
||||||
} as TrackedCompletedToolCall,
|
} as TrackedCompletedToolCall,
|
||||||
{
|
{
|
||||||
request: {
|
request: {
|
||||||
@@ -546,6 +563,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -585,6 +604,12 @@ describe('useGeminiStream', () => {
|
|||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
response: { callId: '1', responseParts: [{ text: 'cancelled' }] },
|
response: { callId: '1', responseParts: [{ text: 'cancelled' }] },
|
||||||
responseSubmittedToGemini: false,
|
responseSubmittedToGemini: false,
|
||||||
|
tool: {
|
||||||
|
displayName: 'mock tool',
|
||||||
|
},
|
||||||
|
invocation: {
|
||||||
|
getDescription: () => `Mock description`,
|
||||||
|
} as unknown as AnyToolInvocation,
|
||||||
} as TrackedCancelledToolCall,
|
} as TrackedCancelledToolCall,
|
||||||
];
|
];
|
||||||
const client = new MockedGeminiClientClass(mockConfig);
|
const client = new MockedGeminiClientClass(mockConfig);
|
||||||
@@ -613,6 +638,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -645,9 +672,13 @@ describe('useGeminiStream', () => {
|
|||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
name: 'toolA',
|
name: 'toolA',
|
||||||
|
displayName: 'toolA',
|
||||||
description: 'descA',
|
description: 'descA',
|
||||||
getDescription: vi.fn(),
|
build: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
|
invocation: {
|
||||||
|
getDescription: () => `Mock description`,
|
||||||
|
} as unknown as AnyToolInvocation,
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
response: {
|
response: {
|
||||||
callId: 'cancel-1',
|
callId: 'cancel-1',
|
||||||
@@ -670,9 +701,13 @@ describe('useGeminiStream', () => {
|
|||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
name: 'toolB',
|
name: 'toolB',
|
||||||
|
displayName: 'toolB',
|
||||||
description: 'descB',
|
description: 'descB',
|
||||||
getDescription: vi.fn(),
|
build: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
|
invocation: {
|
||||||
|
getDescription: () => `Mock description`,
|
||||||
|
} as unknown as AnyToolInvocation,
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
response: {
|
response: {
|
||||||
callId: 'cancel-2',
|
callId: 'cancel-2',
|
||||||
@@ -711,6 +746,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -763,9 +800,13 @@ describe('useGeminiStream', () => {
|
|||||||
responseSubmittedToGemini: false,
|
responseSubmittedToGemini: false,
|
||||||
tool: {
|
tool: {
|
||||||
name: 'tool1',
|
name: 'tool1',
|
||||||
|
displayName: 'tool1',
|
||||||
description: 'desc',
|
description: 'desc',
|
||||||
getDescription: vi.fn(),
|
build: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
|
invocation: {
|
||||||
|
getDescription: () => `Mock description`,
|
||||||
|
} as unknown as AnyToolInvocation,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
} as TrackedExecutingToolCall,
|
} as TrackedExecutingToolCall,
|
||||||
];
|
];
|
||||||
@@ -814,6 +855,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -914,6 +957,44 @@ describe('useGeminiStream', () => {
|
|||||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call onCancelSubmit handler when escape is pressed', async () => {
|
||||||
|
const cancelSubmitSpy = vi.fn();
|
||||||
|
const mockStream = (async function* () {
|
||||||
|
yield { type: 'content', value: 'Part 1' };
|
||||||
|
// Keep the stream open
|
||||||
|
await new Promise(() => {});
|
||||||
|
})();
|
||||||
|
mockSendMessageStream.mockReturnValue(mockStream);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useGeminiStream(
|
||||||
|
mockConfig.getGeminiClient(),
|
||||||
|
[],
|
||||||
|
mockAddItem,
|
||||||
|
mockConfig,
|
||||||
|
mockOnDebugMessage,
|
||||||
|
mockHandleSlashCommand,
|
||||||
|
false,
|
||||||
|
() => 'vscode' as EditorType,
|
||||||
|
() => {},
|
||||||
|
() => Promise.resolve(),
|
||||||
|
false,
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
cancelSubmitSpy,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start a query
|
||||||
|
await act(async () => {
|
||||||
|
result.current.submitQuery('test query');
|
||||||
|
});
|
||||||
|
|
||||||
|
simulateEscapeKeyPress();
|
||||||
|
|
||||||
|
expect(cancelSubmitSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should not do anything if escape is pressed when not responding', () => {
|
it('should not do anything if escape is pressed when not responding', () => {
|
||||||
const { result } = renderTestHook();
|
const { result } = renderTestHook();
|
||||||
|
|
||||||
@@ -984,8 +1065,13 @@ describe('useGeminiStream', () => {
|
|||||||
tool: {
|
tool: {
|
||||||
name: 'tool1',
|
name: 'tool1',
|
||||||
description: 'desc1',
|
description: 'desc1',
|
||||||
getDescription: vi.fn(),
|
build: vi.fn().mockImplementation((_) => ({
|
||||||
|
getDescription: () => `Mock description`,
|
||||||
|
})),
|
||||||
} as any,
|
} as any,
|
||||||
|
invocation: {
|
||||||
|
getDescription: () => `Mock description`,
|
||||||
|
},
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
liveOutput: '...',
|
liveOutput: '...',
|
||||||
} as TrackedExecutingToolCall,
|
} as TrackedExecutingToolCall,
|
||||||
@@ -1136,9 +1222,13 @@ describe('useGeminiStream', () => {
|
|||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
name: 'save_memory',
|
name: 'save_memory',
|
||||||
|
displayName: 'save_memory',
|
||||||
description: 'Saves memory',
|
description: 'Saves memory',
|
||||||
getDescription: vi.fn(),
|
build: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
|
invocation: {
|
||||||
|
getDescription: () => `Mock description`,
|
||||||
|
} as unknown as AnyToolInvocation,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture the onComplete callback
|
// Capture the onComplete callback
|
||||||
@@ -1165,6 +1255,8 @@ describe('useGeminiStream', () => {
|
|||||||
mockPerformMemoryRefresh,
|
mockPerformMemoryRefresh,
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1216,6 +1308,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1264,6 +1358,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1310,6 +1406,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1357,6 +1455,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1444,6 +1544,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1498,6 +1600,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1574,6 +1678,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1626,6 +1732,8 @@ describe('useGeminiStream', () => {
|
|||||||
() => Promise.resolve(),
|
() => Promise.resolve(),
|
||||||
false,
|
false,
|
||||||
() => {},
|
() => {},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export const useGeminiStream = (
|
|||||||
modelSwitchedFromQuotaError: boolean,
|
modelSwitchedFromQuotaError: boolean,
|
||||||
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
|
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
onEditorClose: () => void,
|
onEditorClose: () => void,
|
||||||
|
onCancelSubmit: () => void,
|
||||||
) => {
|
) => {
|
||||||
const [initError, setInitError] = useState<string | null>(null);
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
@@ -183,25 +184,39 @@ export const useGeminiStream = (
|
|||||||
return StreamingState.Idle;
|
return StreamingState.Idle;
|
||||||
}, [isResponding, toolCalls]);
|
}, [isResponding, toolCalls]);
|
||||||
|
|
||||||
|
const cancelOngoingRequest = useCallback(() => {
|
||||||
|
if (streamingState !== StreamingState.Responding) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (turnCancelledRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
turnCancelledRef.current = true;
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
if (pendingHistoryItemRef.current) {
|
||||||
|
addItem(pendingHistoryItemRef.current, Date.now());
|
||||||
|
}
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Request cancelled.',
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
setPendingHistoryItem(null);
|
||||||
|
onCancelSubmit();
|
||||||
|
setIsResponding(false);
|
||||||
|
}, [
|
||||||
|
streamingState,
|
||||||
|
addItem,
|
||||||
|
setPendingHistoryItem,
|
||||||
|
onCancelSubmit,
|
||||||
|
pendingHistoryItemRef,
|
||||||
|
]);
|
||||||
|
|
||||||
useInput((_input, key) => {
|
useInput((_input, key) => {
|
||||||
if (streamingState === StreamingState.Responding && key.escape) {
|
if (key.escape) {
|
||||||
if (turnCancelledRef.current) {
|
cancelOngoingRequest();
|
||||||
return;
|
|
||||||
}
|
|
||||||
turnCancelledRef.current = true;
|
|
||||||
abortControllerRef.current?.abort();
|
|
||||||
if (pendingHistoryItemRef.current) {
|
|
||||||
addItem(pendingHistoryItemRef.current, Date.now());
|
|
||||||
}
|
|
||||||
addItem(
|
|
||||||
{
|
|
||||||
type: MessageType.INFO,
|
|
||||||
text: 'Request cancelled.',
|
|
||||||
},
|
|
||||||
Date.now(),
|
|
||||||
);
|
|
||||||
setPendingHistoryItem(null);
|
|
||||||
setIsResponding(false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -985,5 +1000,6 @@ export const useGeminiStream = (
|
|||||||
initError,
|
initError,
|
||||||
pendingHistoryItems,
|
pendingHistoryItems,
|
||||||
thought,
|
thought,
|
||||||
|
cancelOngoingRequest,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export const WITTY_LOADING_PHRASES = [
|
|||||||
'Garbage collecting... be right back...',
|
'Garbage collecting... be right back...',
|
||||||
'Assembling the interwebs...',
|
'Assembling the interwebs...',
|
||||||
'Converting coffee into code...',
|
'Converting coffee into code...',
|
||||||
'Pushing to production (and hoping for the best)...',
|
|
||||||
'Updating the syntax for reality...',
|
'Updating the syntax for reality...',
|
||||||
'Rewiring the synapses...',
|
'Rewiring the synapses...',
|
||||||
'Looking for a misplaced semicolon...',
|
'Looking for a misplaced semicolon...',
|
||||||
@@ -99,7 +98,7 @@ export const WITTY_LOADING_PHRASES = [
|
|||||||
'Why did the computer go to therapy? It had too many bytes...',
|
'Why did the computer go to therapy? It had too many bytes...',
|
||||||
"Why don't programmers like nature? It has too many bugs...",
|
"Why don't programmers like nature? It has too many bugs...",
|
||||||
'Why do programmers prefer dark mode? Because light attracts bugs...',
|
'Why do programmers prefer dark mode? Because light attracts bugs...',
|
||||||
'Why did the developer go broke? Because he used up all his cache...',
|
'Why did the developer go broke? Because they used up all their cache...',
|
||||||
"What can you do with a broken pencil? Nothing, it's pointless...",
|
"What can you do with a broken pencil? Nothing, it's pointless...",
|
||||||
'Applying percussive maintenance...',
|
'Applying percussive maintenance...',
|
||||||
'Searching for the correct USB orientation...',
|
'Searching for the correct USB orientation...',
|
||||||
@@ -136,6 +135,7 @@ export const WITTY_LOADING_PHRASES = [
|
|||||||
"It's not a bug, it's a feature... of this loading screen.",
|
"It's not a bug, it's a feature... of this loading screen.",
|
||||||
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
||||||
'Constructing additional pylons...',
|
'Constructing additional pylons...',
|
||||||
|
'New line? That’s Ctrl+J.',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
|
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
OutputUpdateHandler,
|
OutputUpdateHandler,
|
||||||
AllToolCallsCompleteHandler,
|
AllToolCallsCompleteHandler,
|
||||||
ToolCallsUpdateHandler,
|
ToolCallsUpdateHandler,
|
||||||
Tool,
|
|
||||||
ToolCall,
|
ToolCall,
|
||||||
Status as CoreStatus,
|
Status as CoreStatus,
|
||||||
EditorType,
|
EditorType,
|
||||||
@@ -64,7 +63,7 @@ export type TrackedToolCall =
|
|||||||
| TrackedCancelledToolCall;
|
| TrackedCancelledToolCall;
|
||||||
|
|
||||||
export function useReactToolScheduler(
|
export function useReactToolScheduler(
|
||||||
onComplete: (tools: CompletedToolCall[]) => void,
|
onComplete: (tools: CompletedToolCall[]) => Promise<void>,
|
||||||
config: Config,
|
config: Config,
|
||||||
setPendingHistoryItem: React.Dispatch<
|
setPendingHistoryItem: React.Dispatch<
|
||||||
React.SetStateAction<HistoryItemWithoutId | null>
|
React.SetStateAction<HistoryItemWithoutId | null>
|
||||||
@@ -107,8 +106,8 @@ export function useReactToolScheduler(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allToolCallsCompleteHandler: AllToolCallsCompleteHandler = useCallback(
|
const allToolCallsCompleteHandler: AllToolCallsCompleteHandler = useCallback(
|
||||||
(completedToolCalls) => {
|
async (completedToolCalls) => {
|
||||||
onComplete(completedToolCalls);
|
await onComplete(completedToolCalls);
|
||||||
},
|
},
|
||||||
[onComplete],
|
[onComplete],
|
||||||
);
|
);
|
||||||
@@ -158,7 +157,7 @@ export function useReactToolScheduler(
|
|||||||
request: ToolCallRequestInfo | ToolCallRequestInfo[],
|
request: ToolCallRequestInfo | ToolCallRequestInfo[],
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
) => {
|
) => {
|
||||||
scheduler.schedule(request, signal);
|
void scheduler.schedule(request, signal);
|
||||||
},
|
},
|
||||||
[scheduler],
|
[scheduler],
|
||||||
);
|
);
|
||||||
@@ -216,23 +215,20 @@ export function mapToDisplay(
|
|||||||
|
|
||||||
const toolDisplays = toolCalls.map(
|
const toolDisplays = toolCalls.map(
|
||||||
(trackedCall): IndividualToolCallDisplay => {
|
(trackedCall): IndividualToolCallDisplay => {
|
||||||
let displayName = trackedCall.request.name;
|
let displayName: string;
|
||||||
let description = '';
|
let description: string;
|
||||||
let renderOutputAsMarkdown = false;
|
let renderOutputAsMarkdown = false;
|
||||||
|
|
||||||
const currentToolInstance =
|
if (trackedCall.status === 'error') {
|
||||||
'tool' in trackedCall && trackedCall.tool
|
displayName =
|
||||||
? (trackedCall as { tool: Tool }).tool
|
trackedCall.tool === undefined
|
||||||
: undefined;
|
? trackedCall.request.name
|
||||||
|
: trackedCall.tool.displayName;
|
||||||
if (currentToolInstance) {
|
|
||||||
displayName = currentToolInstance.displayName;
|
|
||||||
description = currentToolInstance.getDescription(
|
|
||||||
trackedCall.request.args,
|
|
||||||
);
|
|
||||||
renderOutputAsMarkdown = currentToolInstance.isOutputMarkdown;
|
|
||||||
} else if ('request' in trackedCall && 'args' in trackedCall.request) {
|
|
||||||
description = JSON.stringify(trackedCall.request.args);
|
description = JSON.stringify(trackedCall.request.args);
|
||||||
|
} else {
|
||||||
|
displayName = trackedCall.tool.displayName;
|
||||||
|
description = trackedCall.invocation.getDescription();
|
||||||
|
renderOutputAsMarkdown = trackedCall.tool.isOutputMarkdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseDisplayProperties: Omit<
|
const baseDisplayProperties: Omit<
|
||||||
@@ -256,7 +252,6 @@ export function mapToDisplay(
|
|||||||
case 'error':
|
case 'error':
|
||||||
return {
|
return {
|
||||||
...baseDisplayProperties,
|
...baseDisplayProperties,
|
||||||
name: currentToolInstance?.displayName ?? trackedCall.request.name,
|
|
||||||
status: mapCoreStatusToDisplayStatus(trackedCall.status),
|
status: mapCoreStatusToDisplayStatus(trackedCall.status),
|
||||||
resultDisplay: trackedCall.response.resultDisplay,
|
resultDisplay: trackedCall.response.resultDisplay,
|
||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
|
|||||||
25
packages/cli/src/ui/hooks/useSettingsCommand.ts
Normal file
25
packages/cli/src/ui/hooks/useSettingsCommand.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useSettingsCommand() {
|
||||||
|
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const openSettingsDialog = useCallback(() => {
|
||||||
|
setIsSettingsDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSettingsDialog = useCallback(() => {
|
||||||
|
setIsSettingsDialogOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSettingsDialogOpen,
|
||||||
|
openSettingsDialog,
|
||||||
|
closeSettingsDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import { PartUnion, FunctionResponse } from '@google/genai';
|
|||||||
import {
|
import {
|
||||||
Config,
|
Config,
|
||||||
ToolCallRequestInfo,
|
ToolCallRequestInfo,
|
||||||
Tool,
|
|
||||||
ToolRegistry,
|
ToolRegistry,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
@@ -25,6 +24,9 @@ import {
|
|||||||
Status as ToolCallStatusType,
|
Status as ToolCallStatusType,
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
Icon,
|
Icon,
|
||||||
|
BaseTool,
|
||||||
|
AnyDeclarativeTool,
|
||||||
|
AnyToolInvocation,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
HistoryItemWithoutId,
|
HistoryItemWithoutId,
|
||||||
@@ -53,46 +55,55 @@ const mockConfig = {
|
|||||||
getDebugMode: () => false,
|
getDebugMode: () => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockTool: Tool = {
|
class MockTool extends BaseTool<object, ToolResult> {
|
||||||
name: 'mockTool',
|
constructor(
|
||||||
displayName: 'Mock Tool',
|
name: string,
|
||||||
description: 'A mock tool for testing',
|
displayName: string,
|
||||||
icon: Icon.Hammer,
|
canUpdateOutput = false,
|
||||||
toolLocations: vi.fn(),
|
shouldConfirm = false,
|
||||||
isOutputMarkdown: false,
|
isOutputMarkdown = false,
|
||||||
canUpdateOutput: false,
|
) {
|
||||||
schema: {},
|
super(
|
||||||
validateToolParams: vi.fn(),
|
name,
|
||||||
execute: vi.fn(),
|
displayName,
|
||||||
shouldConfirmExecute: vi.fn(),
|
'A mock tool for testing',
|
||||||
getDescription: vi.fn((args) => `Description for ${JSON.stringify(args)}`),
|
Icon.Hammer,
|
||||||
};
|
{},
|
||||||
|
isOutputMarkdown,
|
||||||
|
canUpdateOutput,
|
||||||
|
);
|
||||||
|
if (shouldConfirm) {
|
||||||
|
this.shouldConfirmExecute = vi.fn(
|
||||||
|
async (): Promise<ToolCallConfirmationDetails | false> => ({
|
||||||
|
type: 'edit',
|
||||||
|
title: 'Mock Tool Requires Confirmation',
|
||||||
|
onConfirm: mockOnUserConfirmForToolConfirmation,
|
||||||
|
fileName: 'mockToolRequiresConfirmation.ts',
|
||||||
|
fileDiff: 'Mock tool requires confirmation',
|
||||||
|
originalContent: 'Original content',
|
||||||
|
newContent: 'New content',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mockToolWithLiveOutput: Tool = {
|
execute = vi.fn();
|
||||||
...mockTool,
|
shouldConfirmExecute = vi.fn();
|
||||||
name: 'mockToolWithLiveOutput',
|
}
|
||||||
displayName: 'Mock Tool With Live Output',
|
|
||||||
canUpdateOutput: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const mockTool = new MockTool('mockTool', 'Mock Tool');
|
||||||
|
const mockToolWithLiveOutput = new MockTool(
|
||||||
|
'mockToolWithLiveOutput',
|
||||||
|
'Mock Tool With Live Output',
|
||||||
|
true,
|
||||||
|
);
|
||||||
let mockOnUserConfirmForToolConfirmation: Mock;
|
let mockOnUserConfirmForToolConfirmation: Mock;
|
||||||
|
const mockToolRequiresConfirmation = new MockTool(
|
||||||
const mockToolRequiresConfirmation: Tool = {
|
'mockToolRequiresConfirmation',
|
||||||
...mockTool,
|
'Mock Tool Requires Confirmation',
|
||||||
name: 'mockToolRequiresConfirmation',
|
false,
|
||||||
displayName: 'Mock Tool Requires Confirmation',
|
true,
|
||||||
shouldConfirmExecute: vi.fn(
|
);
|
||||||
async (): Promise<ToolCallConfirmationDetails | false> => ({
|
|
||||||
type: 'edit',
|
|
||||||
title: 'Mock Tool Requires Confirmation',
|
|
||||||
onConfirm: mockOnUserConfirmForToolConfirmation,
|
|
||||||
fileName: 'mockToolRequiresConfirmation.ts',
|
|
||||||
fileDiff: 'Mock tool requires confirmation',
|
|
||||||
originalContent: 'Original content',
|
|
||||||
newContent: 'New content',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('useReactToolScheduler in YOLO Mode', () => {
|
describe('useReactToolScheduler in YOLO Mode', () => {
|
||||||
let onComplete: Mock;
|
let onComplete: Mock;
|
||||||
@@ -646,28 +657,21 @@ describe('useReactToolScheduler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should schedule and execute multiple tool calls', async () => {
|
it('should schedule and execute multiple tool calls', async () => {
|
||||||
const tool1 = {
|
const tool1 = new MockTool('tool1', 'Tool 1');
|
||||||
...mockTool,
|
tool1.execute.mockResolvedValue({
|
||||||
name: 'tool1',
|
llmContent: 'Output 1',
|
||||||
displayName: 'Tool 1',
|
returnDisplay: 'Display 1',
|
||||||
execute: vi.fn().mockResolvedValue({
|
summary: 'Summary 1',
|
||||||
llmContent: 'Output 1',
|
} as ToolResult);
|
||||||
returnDisplay: 'Display 1',
|
tool1.shouldConfirmExecute.mockResolvedValue(null);
|
||||||
summary: 'Summary 1',
|
|
||||||
} as ToolResult),
|
const tool2 = new MockTool('tool2', 'Tool 2');
|
||||||
shouldConfirmExecute: vi.fn().mockResolvedValue(null),
|
tool2.execute.mockResolvedValue({
|
||||||
};
|
llmContent: 'Output 2',
|
||||||
const tool2 = {
|
returnDisplay: 'Display 2',
|
||||||
...mockTool,
|
summary: 'Summary 2',
|
||||||
name: 'tool2',
|
} as ToolResult);
|
||||||
displayName: 'Tool 2',
|
tool2.shouldConfirmExecute.mockResolvedValue(null);
|
||||||
execute: vi.fn().mockResolvedValue({
|
|
||||||
llmContent: 'Output 2',
|
|
||||||
returnDisplay: 'Display 2',
|
|
||||||
summary: 'Summary 2',
|
|
||||||
} as ToolResult),
|
|
||||||
shouldConfirmExecute: vi.fn().mockResolvedValue(null),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockToolRegistry.getTool.mockImplementation((name) => {
|
mockToolRegistry.getTool.mockImplementation((name) => {
|
||||||
if (name === 'tool1') return tool1;
|
if (name === 'tool1') return tool1;
|
||||||
@@ -805,20 +809,7 @@ describe('mapToDisplay', () => {
|
|||||||
args: { foo: 'bar' },
|
args: { foo: 'bar' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseTool: Tool = {
|
const baseTool = new MockTool('testTool', 'Test Tool Display');
|
||||||
name: 'testTool',
|
|
||||||
displayName: 'Test Tool Display',
|
|
||||||
description: 'Test Description',
|
|
||||||
isOutputMarkdown: false,
|
|
||||||
canUpdateOutput: false,
|
|
||||||
schema: {},
|
|
||||||
icon: Icon.Hammer,
|
|
||||||
toolLocations: vi.fn(),
|
|
||||||
validateToolParams: vi.fn(),
|
|
||||||
execute: vi.fn(),
|
|
||||||
shouldConfirmExecute: vi.fn(),
|
|
||||||
getDescription: vi.fn((args) => `Desc: ${JSON.stringify(args)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseResponse: ToolCallResponseInfo = {
|
const baseResponse: ToolCallResponseInfo = {
|
||||||
callId: 'testCallId',
|
callId: 'testCallId',
|
||||||
@@ -840,13 +831,15 @@ describe('mapToDisplay', () => {
|
|||||||
// This helps ensure that tool and confirmationDetails are only accessed when they are expected to exist.
|
// This helps ensure that tool and confirmationDetails are only accessed when they are expected to exist.
|
||||||
type MapToDisplayExtraProps =
|
type MapToDisplayExtraProps =
|
||||||
| {
|
| {
|
||||||
tool?: Tool;
|
tool?: AnyDeclarativeTool;
|
||||||
|
invocation?: AnyToolInvocation;
|
||||||
liveOutput?: string;
|
liveOutput?: string;
|
||||||
response?: ToolCallResponseInfo;
|
response?: ToolCallResponseInfo;
|
||||||
confirmationDetails?: ToolCallConfirmationDetails;
|
confirmationDetails?: ToolCallConfirmationDetails;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
tool: Tool;
|
tool: AnyDeclarativeTool;
|
||||||
|
invocation?: AnyToolInvocation;
|
||||||
response?: ToolCallResponseInfo;
|
response?: ToolCallResponseInfo;
|
||||||
confirmationDetails?: ToolCallConfirmationDetails;
|
confirmationDetails?: ToolCallConfirmationDetails;
|
||||||
}
|
}
|
||||||
@@ -857,10 +850,12 @@ describe('mapToDisplay', () => {
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
confirmationDetails: ToolCallConfirmationDetails;
|
confirmationDetails: ToolCallConfirmationDetails;
|
||||||
tool?: Tool;
|
tool?: AnyDeclarativeTool;
|
||||||
|
invocation?: AnyToolInvocation;
|
||||||
response?: ToolCallResponseInfo;
|
response?: ToolCallResponseInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const baseInvocation = baseTool.build(baseRequest.args);
|
||||||
const testCases: Array<{
|
const testCases: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
status: ToolCallStatusType;
|
status: ToolCallStatusType;
|
||||||
@@ -873,7 +868,7 @@ describe('mapToDisplay', () => {
|
|||||||
{
|
{
|
||||||
name: 'validating',
|
name: 'validating',
|
||||||
status: 'validating',
|
status: 'validating',
|
||||||
extraProps: { tool: baseTool },
|
extraProps: { tool: baseTool, invocation: baseInvocation },
|
||||||
expectedStatus: ToolCallStatus.Executing,
|
expectedStatus: ToolCallStatus.Executing,
|
||||||
expectedName: baseTool.displayName,
|
expectedName: baseTool.displayName,
|
||||||
expectedDescription: baseTool.getDescription(baseRequest.args),
|
expectedDescription: baseTool.getDescription(baseRequest.args),
|
||||||
@@ -883,6 +878,7 @@ describe('mapToDisplay', () => {
|
|||||||
status: 'awaiting_approval',
|
status: 'awaiting_approval',
|
||||||
extraProps: {
|
extraProps: {
|
||||||
tool: baseTool,
|
tool: baseTool,
|
||||||
|
invocation: baseInvocation,
|
||||||
confirmationDetails: {
|
confirmationDetails: {
|
||||||
onConfirm: vi.fn(),
|
onConfirm: vi.fn(),
|
||||||
type: 'edit',
|
type: 'edit',
|
||||||
@@ -903,7 +899,7 @@ describe('mapToDisplay', () => {
|
|||||||
{
|
{
|
||||||
name: 'scheduled',
|
name: 'scheduled',
|
||||||
status: 'scheduled',
|
status: 'scheduled',
|
||||||
extraProps: { tool: baseTool },
|
extraProps: { tool: baseTool, invocation: baseInvocation },
|
||||||
expectedStatus: ToolCallStatus.Pending,
|
expectedStatus: ToolCallStatus.Pending,
|
||||||
expectedName: baseTool.displayName,
|
expectedName: baseTool.displayName,
|
||||||
expectedDescription: baseTool.getDescription(baseRequest.args),
|
expectedDescription: baseTool.getDescription(baseRequest.args),
|
||||||
@@ -911,7 +907,7 @@ describe('mapToDisplay', () => {
|
|||||||
{
|
{
|
||||||
name: 'executing no live output',
|
name: 'executing no live output',
|
||||||
status: 'executing',
|
status: 'executing',
|
||||||
extraProps: { tool: baseTool },
|
extraProps: { tool: baseTool, invocation: baseInvocation },
|
||||||
expectedStatus: ToolCallStatus.Executing,
|
expectedStatus: ToolCallStatus.Executing,
|
||||||
expectedName: baseTool.displayName,
|
expectedName: baseTool.displayName,
|
||||||
expectedDescription: baseTool.getDescription(baseRequest.args),
|
expectedDescription: baseTool.getDescription(baseRequest.args),
|
||||||
@@ -919,7 +915,11 @@ describe('mapToDisplay', () => {
|
|||||||
{
|
{
|
||||||
name: 'executing with live output',
|
name: 'executing with live output',
|
||||||
status: 'executing',
|
status: 'executing',
|
||||||
extraProps: { tool: baseTool, liveOutput: 'Live test output' },
|
extraProps: {
|
||||||
|
tool: baseTool,
|
||||||
|
invocation: baseInvocation,
|
||||||
|
liveOutput: 'Live test output',
|
||||||
|
},
|
||||||
expectedStatus: ToolCallStatus.Executing,
|
expectedStatus: ToolCallStatus.Executing,
|
||||||
expectedResultDisplay: 'Live test output',
|
expectedResultDisplay: 'Live test output',
|
||||||
expectedName: baseTool.displayName,
|
expectedName: baseTool.displayName,
|
||||||
@@ -928,7 +928,11 @@ describe('mapToDisplay', () => {
|
|||||||
{
|
{
|
||||||
name: 'success',
|
name: 'success',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
extraProps: { tool: baseTool, response: baseResponse },
|
extraProps: {
|
||||||
|
tool: baseTool,
|
||||||
|
invocation: baseInvocation,
|
||||||
|
response: baseResponse,
|
||||||
|
},
|
||||||
expectedStatus: ToolCallStatus.Success,
|
expectedStatus: ToolCallStatus.Success,
|
||||||
expectedResultDisplay: baseResponse.resultDisplay as any,
|
expectedResultDisplay: baseResponse.resultDisplay as any,
|
||||||
expectedName: baseTool.displayName,
|
expectedName: baseTool.displayName,
|
||||||
@@ -970,6 +974,7 @@ describe('mapToDisplay', () => {
|
|||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
extraProps: {
|
extraProps: {
|
||||||
tool: baseTool,
|
tool: baseTool,
|
||||||
|
invocation: baseInvocation,
|
||||||
response: {
|
response: {
|
||||||
...baseResponse,
|
...baseResponse,
|
||||||
resultDisplay: 'Cancelled display',
|
resultDisplay: 'Cancelled display',
|
||||||
@@ -1030,12 +1035,21 @@ describe('mapToDisplay', () => {
|
|||||||
request: { ...baseRequest, callId: 'call1' },
|
request: { ...baseRequest, callId: 'call1' },
|
||||||
status: 'success',
|
status: 'success',
|
||||||
tool: baseTool,
|
tool: baseTool,
|
||||||
|
invocation: baseTool.build(baseRequest.args),
|
||||||
response: { ...baseResponse, callId: 'call1' },
|
response: { ...baseResponse, callId: 'call1' },
|
||||||
} as ToolCall;
|
} as ToolCall;
|
||||||
|
const toolForCall2 = new MockTool(
|
||||||
|
baseTool.name,
|
||||||
|
baseTool.displayName,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
const toolCall2: ToolCall = {
|
const toolCall2: ToolCall = {
|
||||||
request: { ...baseRequest, callId: 'call2' },
|
request: { ...baseRequest, callId: 'call2' },
|
||||||
status: 'executing',
|
status: 'executing',
|
||||||
tool: { ...baseTool, isOutputMarkdown: true },
|
tool: toolForCall2,
|
||||||
|
invocation: toolForCall2.build(baseRequest.args),
|
||||||
liveOutput: 'markdown output',
|
liveOutput: 'markdown output',
|
||||||
} as ToolCall;
|
} as ToolCall;
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user