mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-26 11:39:22 +00:00
Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17eb20c134 | ||
|
|
5d59ceb6f3 | ||
|
|
7f645b9726 | ||
|
|
8c109be48c | ||
|
|
e9a1d9a927 | ||
|
|
8aceddffa2 | ||
|
|
cebe0448d0 | ||
|
|
919560e3a4 | ||
|
|
26bd4f882d | ||
|
|
3787e95572 | ||
|
|
7233d37bd1 | ||
|
|
93dcca5147 | ||
|
|
f7d04323f3 | ||
|
|
9a27857f10 | ||
|
|
452f4f3c0e | ||
|
|
5cc01e5e09 | ||
|
|
ac0be9fb84 | ||
|
|
257c6705e1 | ||
|
|
27e7438b75 | ||
|
|
8a3ff8db12 | ||
|
|
26f8b67d4f | ||
|
|
b64d636280 | ||
|
|
781c57b438 | ||
|
|
c81c24d45d | ||
|
|
c53bdde747 | ||
|
|
99db18069d | ||
|
|
a0a5b831d4 | ||
|
|
8f74dd224c | ||
|
|
b931d28f35 | ||
|
|
4407597794 | ||
|
|
9f65bd3b39 | ||
|
|
2b3830cf83 | ||
|
|
90bf101040 | ||
|
|
2b9140940d | ||
|
|
4efdea0981 | ||
|
|
05791d4200 | ||
|
|
add35d2904 | ||
|
|
660901e1fd | ||
|
|
8e64c5acaf | ||
|
|
bc2a7efcb3 | ||
|
|
1dfd880e17 | ||
|
|
4f970c9987 | ||
|
|
251031cfc5 | ||
|
|
10a0c843c1 | ||
|
|
77c257d9d0 | ||
|
|
955547d523 | ||
|
|
3bc862df89 | ||
|
|
4311af96eb | ||
|
|
b49c11e9a2 | ||
|
|
642dda0315 | ||
|
|
bbbdeb280d | ||
|
|
0d43ddee2a | ||
|
|
50e03f2dd6 | ||
|
|
f440ff2f7f | ||
|
|
9a6b0abc37 | ||
|
|
9cdd85c62a | ||
|
|
00547ba439 | ||
|
|
fc1dac9dc7 | ||
|
|
338eb9038d | ||
|
|
87d8d82be7 | ||
|
|
e0b9044833 | ||
|
|
f33f43e2f7 | ||
|
|
fefc138485 | ||
|
|
4e7929850c | ||
|
|
9cc5c3ed8f | ||
|
|
18e9b2340b | ||
|
|
ad427da340 | ||
|
|
484e0fd943 | ||
|
|
a92be72e88 | ||
|
|
52cd1da4a7 | ||
|
|
c5c556a326 | ||
|
|
a8a863581b | ||
|
|
e4468cfcbc | ||
|
|
3bf30ead67 | ||
|
|
a786f61e49 | ||
|
|
b8a16d362a | ||
|
|
17129024f4 | ||
|
|
fa7d857945 | ||
|
|
90489933fd | ||
|
|
3354b56a05 | ||
|
|
d40447cee4 | ||
|
|
ba87cf63f6 | ||
|
|
00a8c6a924 | ||
|
|
156134d3d4 | ||
|
|
b720209888 | ||
|
|
dfe667c364 | ||
|
|
1386fba278 | ||
|
|
d942250905 | ||
|
|
ec32a24508 | ||
|
|
c2b59038ae | ||
|
|
27bf42b4f5 | ||
|
|
d07ae35c90 | ||
|
|
cb59b5a9dc | ||
|
|
01e62a2120 | ||
|
|
d464f61b72 | ||
|
|
f866f7f071 | ||
|
|
7eabf543b4 | ||
|
|
8106a6b0f4 | ||
|
|
c0839dceac | ||
|
|
12f84fb730 | ||
|
|
e274b4469a | ||
|
|
0a39c91264 | ||
|
|
8fd7490d8f | ||
|
|
4f1766e19a | ||
|
|
bf52c6db0f | ||
|
|
9267677d38 | ||
|
|
fb8412a96a | ||
|
|
2837aa6b7c | ||
|
|
49b3e0dc92 | ||
|
|
25d9c4f1a7 | ||
|
|
9942b2b877 | ||
|
|
850c52dc79 | ||
|
|
61e378644e | ||
|
|
bd3bdd82ea | ||
|
|
fc58291c5c | ||
|
|
633148b257 | ||
|
|
f9da1b819e | ||
|
|
f47c762620 | ||
|
|
573c33f68a | ||
|
|
32c085cf7d | ||
|
|
725843f9b3 | ||
|
|
07fb6faf5f | ||
|
|
1956507d90 | ||
|
|
54fd63f04b | ||
|
|
59c3d3d0f9 | ||
|
|
5d94763581 | ||
|
|
5bd1822b7d | ||
|
|
65392a057d | ||
|
|
3b9d38a325 | ||
|
|
177fc42f04 | ||
|
|
4930a24d07 | ||
|
|
7a97fcd5f1 | ||
|
|
4504c7a0ac | ||
|
|
56a62bcb2a | ||
|
|
1098c23b26 | ||
|
|
e76f47512c | ||
|
|
f5c868702b | ||
|
|
0d40cf2213 | ||
|
|
12877ac849 | ||
|
|
2de50ae436 | ||
|
|
a761be80a5 | ||
|
|
6c77303172 | ||
|
|
4f2b2d0a3e | ||
|
|
44794121a8 | ||
|
|
bf905dcc17 | ||
|
|
95d3e5b744 | ||
|
|
6d3cf4cd98 | ||
|
|
68295d0bbf | ||
|
|
2560c2d1a2 | ||
|
|
84cccfe99a | ||
|
|
b6a3ab11e0 | ||
|
|
bd6e16d41b | ||
|
|
2f0fa267c8 | ||
|
|
fa6ae0a324 | ||
|
|
387be44866 | ||
|
|
51b82771da | ||
|
|
629cd14fad |
9
.github/workflows/e2e.yml
vendored
9
.github/workflows/e2e.yml
vendored
@@ -18,8 +18,6 @@ jobs:
|
||||
- 'sandbox:docker'
|
||||
node-version:
|
||||
- '20.x'
|
||||
- '22.x'
|
||||
- '24.x'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
@@ -67,10 +65,13 @@ jobs:
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: '${{ matrix.sandbox }}'
|
||||
VERBOSE: 'true'
|
||||
run: |-
|
||||
npm run "test:integration:${SANDBOX}"
|
||||
if [[ "${{ matrix.sandbox }}" == "sandbox:docker" ]]; then
|
||||
npm run test:integration:sandbox:docker
|
||||
else
|
||||
npm run test:integration:sandbox:none
|
||||
fi
|
||||
|
||||
e2e-test-macos:
|
||||
name: 'E2E Test - macOS'
|
||||
|
||||
143
.github/workflows/release-sdk.yml
vendored
143
.github/workflows/release-sdk.yml
vendored
@@ -33,6 +33,10 @@ on:
|
||||
type: 'boolean'
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-sdk:
|
||||
runs-on: 'ubuntu-latest'
|
||||
@@ -46,6 +50,7 @@ jobs:
|
||||
packages: 'write'
|
||||
id-token: 'write'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
outputs:
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
|
||||
@@ -86,6 +91,8 @@ jobs:
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@qwen-code'
|
||||
|
||||
- name: 'Install Dependencies'
|
||||
run: |-
|
||||
@@ -121,6 +128,19 @@ jobs:
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
MANUAL_VERSION: '${{ inputs.version }}'
|
||||
|
||||
- name: 'Set SDK package version (local only)'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||
run: |-
|
||||
# Ensure the package version matches the computed release version.
|
||||
# This is required for nightly/preview because npm does not allow re-publishing the same version.
|
||||
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: 'Build CLI Bundle'
|
||||
run: |
|
||||
npm run build
|
||||
npm run bundle
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
@@ -132,13 +152,6 @@ jobs:
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Build CLI for Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
run: |
|
||||
npm run build
|
||||
npm run bundle
|
||||
|
||||
- name: 'Run SDK Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
@@ -155,7 +168,21 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: 'Build SDK'
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
run: |-
|
||||
npm run build
|
||||
|
||||
- name: 'Publish @qwen-code/sdk'
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
run: |-
|
||||
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
|
||||
|
||||
- name: 'Create and switch to a release branch'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
id: 'release_branch'
|
||||
env:
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
@@ -164,50 +191,22 @@ jobs:
|
||||
git switch -c "${BRANCH_NAME}"
|
||||
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Update package version'
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||
run: |-
|
||||
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: 'Commit and Conditionally Push package version'
|
||||
- name: 'Commit and Push package version (stable only)'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
git add packages/sdk-typescript/package.json
|
||||
# Only persist version bumps after a successful publish.
|
||||
git add packages/sdk-typescript/package.json package-lock.json
|
||||
if git diff --staged --quiet; then
|
||||
echo "No version changes to commit"
|
||||
else
|
||||
git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}"
|
||||
fi
|
||||
if [[ "${IS_DRY_RUN}" == "false" ]]; then
|
||||
echo "Pushing release branch to remote..."
|
||||
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
|
||||
else
|
||||
echo "Dry run enabled. Skipping push."
|
||||
fi
|
||||
|
||||
- name: 'Build SDK'
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
run: |-
|
||||
npm run build
|
||||
|
||||
- name: 'Configure npm for publishing'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@qwen-code'
|
||||
|
||||
- name: 'Publish @qwen-code/sdk'
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
run: |-
|
||||
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
|
||||
echo "Pushing release branch to remote..."
|
||||
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
|
||||
|
||||
- name: 'Create GitHub Release and Tag'
|
||||
if: |-
|
||||
@@ -217,12 +216,68 @@ jobs:
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
|
||||
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
REF: '${{ github.event.inputs.ref || github.sha }}'
|
||||
run: |-
|
||||
# For stable releases, use the release branch; for nightly/preview, use the current ref
|
||||
if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then
|
||||
TARGET="${REF}"
|
||||
PRERELEASE_FLAG="--prerelease"
|
||||
else
|
||||
TARGET="${RELEASE_BRANCH}"
|
||||
PRERELEASE_FLAG=""
|
||||
fi
|
||||
|
||||
gh release create "sdk-typescript-${RELEASE_TAG}" \
|
||||
--target "$RELEASE_BRANCH" \
|
||||
--target "${TARGET}" \
|
||||
--title "SDK TypeScript Release ${RELEASE_TAG}" \
|
||||
--notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \
|
||||
--generate-notes
|
||||
--generate-notes \
|
||||
${PRERELEASE_FLAG}
|
||||
|
||||
- name: 'Create PR to merge release branch into main'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
id: 'pr'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
|
||||
pr_url="$(gh pr list --head "${RELEASE_BRANCH}" --base main --json url --jq '.[0].url')"
|
||||
if [[ -z "${pr_url}" ]]; then
|
||||
pr_url="$(gh pr create \
|
||||
--base main \
|
||||
--head "${RELEASE_BRANCH}" \
|
||||
--title "chore(release): sdk-typescript ${RELEASE_TAG}" \
|
||||
--body "Automated release PR for sdk-typescript ${RELEASE_TAG}.")"
|
||||
fi
|
||||
|
||||
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Wait for CI checks to complete'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
echo "Waiting for CI checks to complete..."
|
||||
gh pr checks "${PR_URL}" --watch --interval 30
|
||||
|
||||
- name: 'Enable auto-merge for release PR'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
gh pr merge "${PR_URL}" --merge --auto
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -133,8 +133,8 @@ jobs:
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
run: |
|
||||
npm run preflight
|
||||
npm run test:integration:sandbox:none
|
||||
npm run test:integration:sandbox:docker
|
||||
npm run test:integration:cli:sandbox:none
|
||||
npm run test:integration:cli:sandbox:docker
|
||||
env:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
|
||||
110
CONTRIBUTING.md
110
CONTRIBUTING.md
@@ -2,27 +2,6 @@
|
||||
|
||||
We would love to accept your patches and contributions to this project.
|
||||
|
||||
## Before you begin
|
||||
|
||||
### Sign our Contributor License Agreement
|
||||
|
||||
Contributions to this project must be accompanied by a
|
||||
[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
|
||||
You (or your employer) retain the copyright to your contribution; this simply
|
||||
gives us permission to use and redistribute your contributions as part of the
|
||||
project.
|
||||
|
||||
If you or your current employer have already signed the Google CLA (even if it
|
||||
was for a different project), you probably don't need to do it again.
|
||||
|
||||
Visit <https://cla.developers.google.com/> to see your current agreements or to
|
||||
sign a new one.
|
||||
|
||||
### Review our Community Guidelines
|
||||
|
||||
This project follows [Google's Open Source Community
|
||||
Guidelines](https://opensource.google/conduct/).
|
||||
|
||||
## Contribution Process
|
||||
|
||||
### Code Reviews
|
||||
@@ -74,12 +53,6 @@ Your PR should have a clear, descriptive title and a detailed description of the
|
||||
|
||||
In the PR description, explain the "why" behind your changes and link to the relevant issue (e.g., `Fixes #123`).
|
||||
|
||||
## Forking
|
||||
|
||||
If you are forking the repository you will be able to run the Build, Test and Integration test workflows. However in order to make the integration tests run you'll need to add a [GitHub Repository Secret](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository) with a value of `GEMINI_API_KEY` and set that to a valid API key that you have available. Your key and secret are private to your repo; no one without access can see your key and you cannot see any secrets related to this repo.
|
||||
|
||||
Additionally you will need to click on the `Actions` tab and enable workflows for your repository, you'll find it's the large blue button in the center of the screen.
|
||||
|
||||
## Development Setup and Workflow
|
||||
|
||||
This section guides contributors on how to build, modify, and understand the development setup of this project.
|
||||
@@ -98,8 +71,8 @@ This section guides contributors on how to build, modify, and understand the dev
|
||||
To clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/google-gemini/gemini-cli.git # Or your fork's URL
|
||||
cd gemini-cli
|
||||
git clone https://github.com/QwenLM/qwen-code.git # Or your fork's URL
|
||||
cd qwen-code
|
||||
```
|
||||
|
||||
To install dependencies defined in `package.json` as well as root dependencies:
|
||||
@@ -118,9 +91,9 @@ This command typically compiles TypeScript to JavaScript, bundles assets, and pr
|
||||
|
||||
### Enabling Sandboxing
|
||||
|
||||
[Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `GEMINI_SANDBOX=true` in your `~/.env` and ensuring a sandboxing provider (e.g. `macOS Seatbelt`, `docker`, or `podman`) is available. See [Sandboxing](#sandboxing) for details.
|
||||
[Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `QWEN_SANDBOX=true` in your `~/.env` and ensuring a sandboxing provider (e.g. `macOS Seatbelt`, `docker`, or `podman`) is available. See [Sandboxing](#sandboxing) for details.
|
||||
|
||||
To build both the `gemini` CLI utility and the sandbox container, run `build:all` from the root directory:
|
||||
To build both the `qwen-code` CLI utility and the sandbox container, run `build:all` from the root directory:
|
||||
|
||||
```bash
|
||||
npm run build:all
|
||||
@@ -130,13 +103,13 @@ To skip building the sandbox container, you can use `npm run build` instead.
|
||||
|
||||
### Running
|
||||
|
||||
To start the Gemini CLI from the source code (after building), run the following command from the root directory:
|
||||
To start the Qwen Code application from the source code (after building), run the following command from the root directory:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
If you'd like to run the source build outside of the gemini-cli folder, you can utilize `npm link path/to/gemini-cli/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias gemini="node path/to/gemini-cli/packages/cli"` to run with `gemini`
|
||||
If you'd like to run the source build outside of the qwen-code folder, you can utilize `npm link path/to/qwen-code/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) to run with `qwen-code`
|
||||
|
||||
### Running Tests
|
||||
|
||||
@@ -154,7 +127,7 @@ This will run tests located in the `packages/core` and `packages/cli` directorie
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
The integration tests are designed to validate the end-to-end functionality of the Gemini CLI. They are not run as part of the default `npm run test` command.
|
||||
The integration tests are designed to validate the end-to-end functionality of Qwen Code. They are not run as part of the default `npm run test` command.
|
||||
|
||||
To run the integration tests, use the following command:
|
||||
|
||||
@@ -209,19 +182,61 @@ npm run lint
|
||||
### Coding Conventions
|
||||
|
||||
- Please adhere to the coding style, patterns, and conventions used throughout the existing codebase.
|
||||
- Consult [QWEN.md](https://github.com/QwenLM/qwen-code/blob/main/QWEN.md) (typically found in the project root) for specific instructions related to AI-assisted development, including conventions for React, comments, and Git usage.
|
||||
- **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages.
|
||||
|
||||
### Project Structure
|
||||
|
||||
- `packages/`: Contains the individual sub-packages of the project.
|
||||
- `cli/`: The command-line interface.
|
||||
- `core/`: The core backend logic for the Gemini CLI.
|
||||
- `core/`: The core backend logic for Qwen Code.
|
||||
- `docs/`: Contains all project documentation.
|
||||
- `scripts/`: Utility scripts for building, testing, and development tasks.
|
||||
|
||||
For more detailed architecture, see `docs/architecture.md`.
|
||||
|
||||
## Documentation Development
|
||||
|
||||
This section describes how to develop and preview the documentation locally.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Ensure you have Node.js (version 18+) installed
|
||||
2. Have npm or yarn available
|
||||
|
||||
### Setup Documentation Site Locally
|
||||
|
||||
To work on the documentation and preview changes locally:
|
||||
|
||||
1. Navigate to the `docs-site` directory:
|
||||
|
||||
```bash
|
||||
cd docs-site
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Link the documentation content from the main `docs` directory:
|
||||
|
||||
```bash
|
||||
npm run link
|
||||
```
|
||||
|
||||
This creates a symbolic link from `../docs` to `content` in the docs-site project, allowing the documentation content to be served by the Next.js site.
|
||||
|
||||
4. Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the documentation site with live updates as you make changes.
|
||||
|
||||
Any changes made to the documentation files in the main `docs` directory will be reflected immediately in the documentation site.
|
||||
|
||||
## Debugging
|
||||
|
||||
### VS Code:
|
||||
@@ -231,7 +246,7 @@ For more detailed architecture, see `docs/architecture.md`.
|
||||
```bash
|
||||
npm run debug
|
||||
```
|
||||
This command runs `node --inspect-brk dist/gemini.js` within the `packages/cli` directory, pausing execution until a debugger attaches. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger.
|
||||
This command runs `node --inspect-brk dist/index.js` within the `packages/cli` directory, pausing execution until a debugger attaches. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger.
|
||||
2. In VS Code, use the "Attach" launch configuration (found in `.vscode/launch.json`).
|
||||
|
||||
Alternatively, you can use the "Launch Program" configuration in VS Code if you prefer to launch the currently open file directly, but 'F5' is generally recommended.
|
||||
@@ -239,16 +254,16 @@ Alternatively, you can use the "Launch Program" configuration in VS Code if you
|
||||
To hit a breakpoint inside the sandbox container run:
|
||||
|
||||
```bash
|
||||
DEBUG=1 gemini
|
||||
DEBUG=1 qwen-code
|
||||
```
|
||||
|
||||
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
|
||||
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect qwen-code due to automatic exclusion. Use `.qwen-code/.env` files for qwen-code specific debug settings.
|
||||
|
||||
### React DevTools
|
||||
|
||||
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x.
|
||||
|
||||
1. **Start the Gemini CLI in development mode:**
|
||||
1. **Start the Qwen Code application in development mode:**
|
||||
|
||||
```bash
|
||||
DEV=true npm start
|
||||
@@ -270,23 +285,10 @@ To debug the CLI's React-based UI, you can use React DevTools. Ink, the library
|
||||
```
|
||||
|
||||
Your running CLI application should then connect to React DevTools.
|
||||

|
||||
|
||||
## Sandboxing
|
||||
|
||||
### macOS Seatbelt
|
||||
|
||||
On macOS, `qwen` uses Seatbelt (`sandbox-exec`) under a `permissive-open` profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that restricts writes to the project folder but otherwise allows all other operations and outbound network traffic ("open") by default. You can switch to a `restrictive-closed` profile (see `packages/cli/src/utils/sandbox-macos-restrictive-closed.sb`) that declines all operations and outbound network traffic ("closed") by default by setting `SEATBELT_PROFILE=restrictive-closed` in your environment or `.env` file. Available built-in profiles are `{permissive,restrictive}-{open,closed,proxied}` (see below for proxied networking). You can also switch to a custom profile `SEATBELT_PROFILE=<profile>` if you also create a file `.qwen/sandbox-macos-<profile>.sb` under your project settings directory `.qwen`.
|
||||
|
||||
### Container-based Sandboxing (All Platforms)
|
||||
|
||||
For stronger container-based sandboxing on macOS or other platforms, you can set `GEMINI_SANDBOX=true|docker|podman|<command>` in your environment or `.env` file. The specified command (or if `true` then either `docker` or `podman`) must be installed on the host machine. Once enabled, `npm run build:all` will build a minimal container ("sandbox") image and `npm start` will launch inside a fresh instance of that container. The first build can take 20-30s (mostly due to downloading of the base image) but after that both build and start overhead should be minimal. Default builds (`npm run build`) will not rebuild the sandbox.
|
||||
|
||||
Container-based sandboxing mounts the project directory (and system temp directory) with read-write access and is started/stopped/removed automatically as you start/stop Gemini CLI. Files created within the sandbox should be automatically mapped to your user/group on host machine. You can easily specify additional mounts, ports, or environment variables by setting `SANDBOX_{MOUNTS,PORTS,ENV}` as needed. You can also fully customize the sandbox for your projects by creating the files `.qwen/sandbox.Dockerfile` and/or `.qwen/sandbox.bashrc` under your project settings directory (`.qwen`) and running `qwen` with `BUILD_SANDBOX=1` to trigger building of your custom sandbox.
|
||||
|
||||
#### Proxied Networking
|
||||
|
||||
All sandboxing methods, including macOS Seatbelt using `*-proxied` profiles, support restricting outbound network traffic through a custom proxy server that can be specified as `GEMINI_SANDBOX_PROXY_COMMAND=<command>`, where `<command>` must start a proxy server that listens on `:::8877` for relevant requests. See `docs/examples/proxy-script.md` for a minimal proxy that only allows `HTTPS` connections to `example.com:443` (e.g. `curl https://example.com`) and declines all other requests. The proxy is started and stopped automatically alongside the sandbox.
|
||||
> TBD
|
||||
|
||||
## Manual Publish
|
||||
|
||||
|
||||
10
Makefile
10
Makefile
@@ -1,9 +1,9 @@
|
||||
# Makefile for gemini-cli
|
||||
# Makefile for qwen-code
|
||||
|
||||
.PHONY: help install build build-sandbox build-all test lint format preflight clean start debug release run-npx create-alias
|
||||
|
||||
help:
|
||||
@echo "Makefile for gemini-cli"
|
||||
@echo "Makefile for qwen-code"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make install - Install npm dependencies"
|
||||
@@ -14,11 +14,11 @@ help:
|
||||
@echo " make format - Format the code"
|
||||
@echo " make preflight - Run formatting, linting, and tests"
|
||||
@echo " make clean - Remove generated files"
|
||||
@echo " make start - Start the Gemini CLI"
|
||||
@echo " make debug - Start the Gemini CLI in debug mode"
|
||||
@echo " make start - Start the Qwen Code CLI"
|
||||
@echo " make debug - Start the Qwen Code CLI in debug mode"
|
||||
@echo ""
|
||||
@echo " make run-npx - Run the CLI using npx (for testing the published package)"
|
||||
@echo " make create-alias - Create a 'gemini' alias for your shell"
|
||||
@echo " make create-alias - Create a 'qwen' alias for your shell"
|
||||
|
||||
install:
|
||||
npm install
|
||||
|
||||
181
README.md
181
README.md
@@ -1,108 +1,150 @@
|
||||
# Qwen Code
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
[](./LICENSE)
|
||||
[](https://nodejs.org/)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
**AI-powered command-line workflow tool for developers**
|
||||
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/zh/">中文</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/de/">Deutsch</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/fr">français</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ja/">日本語</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ru">Русский</a>
|
||||
|
||||
[Installation](#install-from-npm) • [Quick Start](#-quick-start) • [Features](#-why-qwen-code) • [Documentation](https://qwenlm.github.io/qwen-code-docs/en/users/overview/) • [Contributing](https://qwenlm.github.io/qwen-code-docs/en/developers/contributing/)
|
||||
**An open-source AI agent that lives in your terminal.**
|
||||
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/zh/users/overview">中文</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/de/users/overview">Deutsch</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/fr/users/overview">français</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ja/users/overview">日本語</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ru/users/overview">Русский</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/pt-BR/users/overview">Português (Brasil)</a>
|
||||
|
||||
</div>
|
||||
|
||||
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
|
||||
Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps you understand large codebases, automate tedious work, and ship faster.
|
||||
|
||||

|
||||
|
||||
## Why Qwen Code?
|
||||
|
||||
## 📌 Why Qwen Code?
|
||||
- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 2,000 free requests/day.
|
||||
- **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together.
|
||||
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience.
|
||||
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code and Zed.
|
||||
|
||||
- 🎯 Free Access Available: Get started with 2,000 free requests per day via Qwen OAuth.
|
||||
- 🧠 Code Understanding & Editing - Query and edit large codebases beyond traditional context window limits
|
||||
- 🤖 Workflow Automation - Automate operational tasks like handling pull requests and complex rebases
|
||||
- 💻 Terminal-first: Designed for developers who live in the command line.
|
||||
- 🧰 VS Code: Install the VS Code extension to seamlessly integrate into your existing workflow.
|
||||
- 📦 Simple Setup: Easy installation with npm, Homebrew, or source for quick deployment.
|
||||
## Installation
|
||||
|
||||
>👉 Know more [workflows](https://qwenlm.github.io/qwen-code-docs/en/users/common-workflow/)
|
||||
>
|
||||
> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md).
|
||||
|
||||
|
||||
## ❓ How to use Qwen Code?
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Ensure you have [Node.js version 20](https://nodejs.org/en/download) or higher installed.
|
||||
#### Prerequisites
|
||||
|
||||
```bash
|
||||
# Node.js 20+
|
||||
curl -qL https://www.npmjs.com/install.sh | sh
|
||||
```
|
||||
|
||||
### Install from npm
|
||||
#### NPM (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code@latest
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
#### Homebrew (macOS, Linux)
|
||||
|
||||
```bash
|
||||
# Start Qwen Code
|
||||
qwen
|
||||
|
||||
# Example commands
|
||||
> What does this project do?
|
||||
> Explain this codebase structure
|
||||
> Help me refactor this function
|
||||
> Generate unit tests for this module
|
||||
brew install qwen-code
|
||||
```
|
||||
|
||||
👇 Click to play video
|
||||
## Quick Start
|
||||
|
||||
[](https://cloud.video.taobao.com/vod/HLfyppnCHplRV9Qhz2xSqeazHeRzYtG-EYJnHAqtzkQ.mp4)
|
||||
```bash
|
||||
# Start Qwen Code (interactive)
|
||||
qwen
|
||||
|
||||
# Then, in the session:
|
||||
/help
|
||||
/auth
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
On first use, you'll be prompted to sign in. You can run `/auth` anytime to switch authentication methods.
|
||||
|
||||
### 1️⃣ Interactive Mode
|
||||
Example prompts:
|
||||
|
||||
```text
|
||||
What does this project do?
|
||||
Explain the codebase structure.
|
||||
Help me refactor this function.
|
||||
Generate unit tests for this module.
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Click to watch a demo video</summary>
|
||||
|
||||
<video src="https://cloud.video.taobao.com/vod/HLfyppnCHplRV9Qhz2xSqeazHeRzYtG-EYJnHAqtzkQ.mp4" controls>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
</details>
|
||||
|
||||
## Authentication
|
||||
|
||||
Qwen Code supports two authentication methods:
|
||||
|
||||
- **Qwen OAuth (recommended & free)**: sign in with your `qwen.ai` account in a browser.
|
||||
- **OpenAI-compatible API**: use `OPENAI_API_KEY` (and optionally a custom base URL / model).
|
||||
|
||||
#### Qwen OAuth (recommended)
|
||||
|
||||
Start `qwen`, then run:
|
||||
|
||||
```bash
|
||||
/auth
|
||||
```
|
||||
|
||||
Choose **Qwen OAuth** and complete the browser flow. Your credentials are cached locally so you usually won't need to log in again.
|
||||
|
||||
#### OpenAI-compatible API (API key)
|
||||
|
||||
Environment variables (recommended for CI / headless environments):
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
export OPENAI_BASE_URL="https://api.openai.com/v1" # optional
|
||||
export OPENAI_MODEL="gpt-4o" # optional
|
||||
```
|
||||
|
||||
For details (including `.qwen/.env` loading and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/).
|
||||
|
||||
## Usage
|
||||
|
||||
As an open-source terminal agent, you can use Qwen Code in four primary ways:
|
||||
|
||||
1. Interactive mode (terminal UI)
|
||||
2. Headless mode (scripts, CI)
|
||||
3. IDE integration (VS Code, Zed)
|
||||
4. TypeScript SDK
|
||||
|
||||
#### Interactive mode
|
||||
|
||||
```bash
|
||||
cd your-project/
|
||||
qwen
|
||||
```
|
||||
|
||||
Navigate to your project folder and type `qwen` to launch Qwen Code. Start a conversation and use `@` to reference files within the folder.
|
||||
Run `qwen` in your project folder to launch the interactive terminal UI. Use `@` to reference local files (for example `@src/main.ts`).
|
||||
|
||||
If you want to learn more about common workflows, click [Common Workflows](https://qwenlm.github.io/qwen-code-docs/en/users/common-workflow/) to view.
|
||||
|
||||
### 2️⃣ Headless Mode
|
||||
#### Headless mode
|
||||
|
||||
```bash
|
||||
cd your-project/
|
||||
qwen -p "your question"
|
||||
```
|
||||
[Headless mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/headless) allows you to run Qwen Code programmatically from command line scripts and automation tools without any interactive UI. This is ideal for scripting, automation, CI/CD pipelines, and building AI-powered tools.
|
||||
|
||||
### 3️⃣ Use in IDE
|
||||
If you prefer to integrate Qwen Code into your current editor, we now support VS Code and Zed. For details, please refer to:
|
||||
Use `-p` to run Qwen Code without the interactive UI—ideal for scripts, automation, and CI/CD. Learn more: [Headless mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/headless).
|
||||
|
||||
#### IDE integration
|
||||
|
||||
Use Qwen Code inside your editor (VS Code and Zed):
|
||||
|
||||
- [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/)
|
||||
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
|
||||
|
||||
### 4️⃣ SDK
|
||||
Qwen Code now supports an SDK designed to simplify integration with the Qwen Code platform. It provides a set of easy-to-use APIs and tools enabling developers to efficiently build, test, and deploy applications. For details, please refer to:
|
||||
#### TypeScript SDK
|
||||
|
||||
Build on top of Qwen Code with the TypeScript SDK:
|
||||
|
||||
- [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
|
||||
|
||||
@@ -114,6 +156,7 @@ Qwen Code now supports an SDK designed to simplify integration with the Qwen Cod
|
||||
- `/clear` - Clear conversation history
|
||||
- `/compress` - Compress history to save tokens
|
||||
- `/stats` - Show current session information
|
||||
- `/bug` - Submit a bug report
|
||||
- `/exit` or `/quit` - Exit Qwen Code
|
||||
|
||||
### Keyboard Shortcuts
|
||||
@@ -122,10 +165,18 @@ Qwen Code now supports an SDK designed to simplify integration with the Qwen Cod
|
||||
- `Ctrl+D` - Exit (on empty line)
|
||||
- `Up/Down` - Navigate command history
|
||||
|
||||
|
||||
> 👉 Know more about [Commands](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/)
|
||||
> Learn more about [Commands](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/)
|
||||
>
|
||||
> 💡 **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected. Know more about [Approval Mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/approval-mode/)
|
||||
> **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected. Learn more about [Approval Mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/approval-mode/)
|
||||
|
||||
## Configuration
|
||||
|
||||
Qwen Code can be configured via `settings.json`, environment variables, and CLI flags.
|
||||
|
||||
- **User settings**: `~/.qwen/settings.json`
|
||||
- **Project settings**: `.qwen/settings.json`
|
||||
|
||||
See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/) for available options and precedence.
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
@@ -136,24 +187,18 @@ Qwen Code now supports an SDK designed to simplify integration with the Qwen Cod
|
||||
| Qwen Code | Qwen3-Coder-480A35 | 37.5% |
|
||||
| Qwen Code | Qwen3-Coder-30BA3B | 31.3% |
|
||||
|
||||
## Development & Contributing
|
||||
## Ecosystem
|
||||
|
||||
See [CONTRIBUTING.md](https://qwenlm.github.io/qwen-code-docs/en/developers/contributing/) to learn how to contribute to the project.
|
||||
Looking for a graphical interface?
|
||||
|
||||
For detailed authentication setup, see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/).
|
||||
- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues, check the [troubleshooting guide](https://qwenlm.github.io/qwen-code-docs/en/users/support/troubleshooting/).
|
||||
|
||||
To report a bug from within the CLI, run `/bug` and include a short title and repro steps.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli). We acknowledge and appreciate the excellent work of the Gemini CLI team. Our main contribution focuses on parser-level adaptations to better support Qwen-Coder models.
|
||||
|
||||
## License
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#QwenLM/qwen-code&Date)
|
||||
|
||||
@@ -627,7 +627,12 @@ The MCP integration tracks several states:
|
||||
|
||||
### Schema Compatibility
|
||||
|
||||
- **Property stripping:** The system automatically removes certain schema properties (`$schema`, `additionalProperties`) for Qwen API compatibility
|
||||
- **Schema compliance mode:** By default (`schemaCompliance: "auto"`), tool schemas are passed through as-is. Set `"model": { "generationConfig": { "schemaCompliance": "openapi_30" } }` in your `settings.json` to convert models to Strict OpenAPI 3.0 format.
|
||||
- **OpenAPI 3.0 transformations:** When `openapi_30` mode is enabled, the system handles:
|
||||
- Nullable types: `["string", "null"]` -> `type: "string", nullable: true`
|
||||
- Const values: `const: "foo"` -> `enum: ["foo"]`
|
||||
- Exclusive limits: numeric `exclusiveMinimum` -> boolean form with `minimum`
|
||||
- Keyword removal: `$schema`, `$id`, `dependencies`, `patternProperties`
|
||||
- **Name sanitization:** Tool names are automatically sanitized to meet API requirements
|
||||
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that
|
||||
## Documentation Sections
|
||||
|
||||
### [User Guide](./users/overview)
|
||||
|
||||
Learn how to use Qwen Code as an end user. This section covers:
|
||||
|
||||
- Basic installation and setup
|
||||
|
||||
@@ -20,9 +20,9 @@ You can update your `.qwenignore` file at any time. To apply the changes, you mu
|
||||
|
||||
## How to use `.qwenignore`
|
||||
|
||||
| Step | Description |
|
||||
| ---------------------- | ------------------------------------------------------------ |
|
||||
| **Enable .qwenignore** | Create a file named `.qwenignore` in your project root directory |
|
||||
| Step | Description |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------- |
|
||||
| **Enable .qwenignore** | Create a file named `.qwenignore` in your project root directory |
|
||||
| **Add ignore rules** | Open `.qwenignore` file and add paths to ignore, example: `/archive/` or `apikeys.txt` |
|
||||
|
||||
### `.qwenignore` examples
|
||||
|
||||
@@ -43,6 +43,7 @@ Qwen Code uses JSON settings files for persistent configuration. There are four
|
||||
In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as:
|
||||
|
||||
- [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
|
||||
- [Agent Skills](../features/skills) (experimental) under `.qwen/skills/` (each Skill is a directory containing a `SKILL.md`).
|
||||
|
||||
### Available settings in `settings.json`
|
||||
|
||||
@@ -50,13 +51,14 @@ Settings are organized into categories. All settings should be placed within the
|
||||
|
||||
#### general
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------------------- | ------- | ------------------------------------------ | ----------- |
|
||||
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
|
||||
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
|
||||
| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` |
|
||||
| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` |
|
||||
| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` |
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
|
||||
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
|
||||
| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` |
|
||||
| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` |
|
||||
| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` |
|
||||
| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` |
|
||||
|
||||
#### output
|
||||
|
||||
@@ -68,7 +70,7 @@ Settings are organized into categories. All settings should be placed within the
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` |
|
||||
| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` |
|
||||
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
|
||||
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
|
||||
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
|
||||
@@ -356,38 +358,40 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
|
||||
### Command-Line Arguments Table
|
||||
|
||||
| Argument | Alias | Description | Possible Values | Notes |
|
||||
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
|
||||
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
|
||||
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
|
||||
| Argument | Alias | Description | Possible Values | Notes |
|
||||
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
|
||||
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
|
||||
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
|
||||
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
|
||||
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
|
||||
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
|
||||
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
|
||||
| `--sandbox-image` | | Sets the sandbox image URI. | | |
|
||||
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
|
||||
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
|
||||
| `--help` | `-h` | Displays help information about command-line arguments. | | |
|
||||
| `--show-memory-usage` | | Displays the current memory usage. | | |
|
||||
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
|
||||
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
|
||||
| `--sandbox-image` | | Sets the sandbox image URI. | | |
|
||||
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
|
||||
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
|
||||
| `--help` | `-h` | Displays help information about command-line arguments. | | |
|
||||
| `--show-memory-usage` | | Displays the current memory usage. | | |
|
||||
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
|
||||
| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`<br>See more about [Approval Mode](../features/approval-mode). |
|
||||
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
|
||||
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
|
||||
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
|
||||
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
|
||||
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
|
||||
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
|
||||
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
|
||||
| `--version` | | Displays the version of the CLI. | | |
|
||||
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
|
||||
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
|
||||
| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` |
|
||||
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
|
||||
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
|
||||
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
|
||||
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||
| `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. |
|
||||
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
|
||||
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
|
||||
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
|
||||
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
|
||||
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
|
||||
| `--version` | | Displays the version of the CLI. | | |
|
||||
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
|
||||
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
|
||||
| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` |
|
||||
|
||||
## Context Files (Hierarchical Instructional Context)
|
||||
|
||||
|
||||
@@ -140,8 +140,6 @@ The theme file must be a valid JSON file that follows the same structure as a cu
|
||||
|
||||
### Example Custom Theme
|
||||
|
||||
|
||||
|
||||
<img src="https://gw.alicdn.com/imgextra/i1/O1CN01Em30Hc1jYXAdIgls3_!!6000000004560-2-tps-1009-629.png" alt=" " style="zoom:100%;text-align:center;margin: 0 auto;" />
|
||||
|
||||
### Using Your Custom Theme
|
||||
@@ -150,15 +148,13 @@ The theme file must be a valid JSON file that follows the same structure as a cu
|
||||
- Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` object in your `settings.json`.
|
||||
- Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](../configuration/settings) as other settings.
|
||||
|
||||
|
||||
|
||||
## Themes Preview
|
||||
|
||||
| Dark Theme | Preview | Light Theme | Preview |
|
||||
| :-: | :-: | :-: | :-: |
|
||||
| ANSI | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01ZInJiq1GdSZc9gHsI_!!6000000000645-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | ANSI Light | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01IiJQFC1h9E3MXQj6W_!!6000000004234-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| Atom OneDark | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01Zlx1SO1Sw21SkTKV3_!!6000000002310-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Ayu Light | <img src="https://gw.alicdn.com/imgextra/i3/O1CN01zEUc1V1jeUJsnCgQb_!!6000000004573-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| Ayu | <img src="https://gw.alicdn.com/imgextra/i3/O1CN019upo6v1SmPhmRjzfN_!!6000000002289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Default Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01RHjrEs1u7TXq3M6l3_!!6000000005990-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| Default | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016pIeXz1pFC8owmR4Q_!!6000000005330-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | GitHub Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01US2b0g1VETCPAVWLA_!!6000000002621-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| Dracula | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016htnWH20c3gd2LpUR_!!6000000006869-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Google Code | <img src="https://gw.alicdn.com/imgextra/i1/O1CN01Ng29ab23iQ2BuYKz8_!!6000000007289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| GitHub | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01fFCRda1IQIQ9qDNqv_!!6000000000887-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Xcode | <img src="https://gw.alicdn.com/imgextra/i1/O1CN010E3QAi1Huh5o1E9LN_!!6000000000818-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| Dark Theme | Preview | Light Theme | Preview |
|
||||
| :----------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| ANSI | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01ZInJiq1GdSZc9gHsI_!!6000000000645-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | ANSI Light | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01IiJQFC1h9E3MXQj6W_!!6000000004234-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| Atom OneDark | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01Zlx1SO1Sw21SkTKV3_!!6000000002310-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Ayu Light | <img src="https://gw.alicdn.com/imgextra/i3/O1CN01zEUc1V1jeUJsnCgQb_!!6000000004573-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| Ayu | <img src="https://gw.alicdn.com/imgextra/i3/O1CN019upo6v1SmPhmRjzfN_!!6000000002289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Default Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01RHjrEs1u7TXq3M6l3_!!6000000005990-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| Default | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016pIeXz1pFC8owmR4Q_!!6000000005330-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | GitHub Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01US2b0g1VETCPAVWLA_!!6000000002621-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| Dracula | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016htnWH20c3gd2LpUR_!!6000000006869-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Google Code | <img src="https://gw.alicdn.com/imgextra/i1/O1CN01Ng29ab23iQ2BuYKz8_!!6000000007289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
| GitHub | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01fFCRda1IQIQ9qDNqv_!!6000000000887-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Xcode | <img src="https://gw.alicdn.com/imgextra/i1/O1CN010E3QAi1Huh5o1E9LN_!!6000000000818-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export default {
|
||||
commands: 'Commands',
|
||||
'sub-agents': 'SubAgents',
|
||||
skills: 'Skills (Experimental)',
|
||||
headless: 'Headless Mode',
|
||||
checkpointing: {
|
||||
display: 'hidden',
|
||||
|
||||
@@ -20,10 +20,11 @@ These commands help you save, restore, and summarize work progress.
|
||||
|
||||
| Command | Description | Usage Examples |
|
||||
| ----------- | --------------------------------------------------------- | ------------------------------------ |
|
||||
| `/init` | Analyze current directory and create initial context file | `/init` |
|
||||
| `/summary` | Generate project summary based on conversation history | `/summary` |
|
||||
| `/compress` | Replace chat history with summary to save Tokens | `/compress` |
|
||||
| `/resume` | Resume a previous conversation session | `/resume` |
|
||||
| `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore <ID>` |
|
||||
| `/init` | Analyze current directory and create initial context file | `/init` |
|
||||
|
||||
### 1.2 Interface and Workspace Control
|
||||
|
||||
|
||||
@@ -189,19 +189,20 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq
|
||||
|
||||
Key command-line options for headless usage:
|
||||
|
||||
| Option | Description | Example |
|
||||
| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
||||
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||
| Option | Description | Example |
|
||||
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
||||
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||
| `--experimental-skills` | Enable experimental Skills (registers the `skill` tool) | `qwen --experimental-skills -p "What Skills are available?"` |
|
||||
|
||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings).
|
||||
|
||||
|
||||
282
docs/users/features/skills.md
Normal file
282
docs/users/features/skills.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# Agent Skills (Experimental)
|
||||
|
||||
> Create, manage, and share Skills to extend Qwen Code’s capabilities.
|
||||
|
||||
This guide shows you how to create, use, and manage Agent Skills in **Qwen Code**. Skills are modular capabilities that extend the model’s effectiveness through organized folders containing instructions (and optionally scripts/resources).
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> Skills are currently **experimental** and must be enabled with `--experimental-skills`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Qwen Code (recent version)
|
||||
- Run with the experimental flag enabled:
|
||||
|
||||
```bash
|
||||
qwen --experimental-skills
|
||||
```
|
||||
|
||||
- Basic familiarity with Qwen Code ([Quickstart](../quickstart.md))
|
||||
|
||||
## What are Agent Skills?
|
||||
|
||||
Agent Skills package expertise into discoverable capabilities. Each Skill consists of a `SKILL.md` file with instructions that the model can load when relevant, plus optional supporting files like scripts and templates.
|
||||
|
||||
### How Skills are invoked
|
||||
|
||||
Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skill’s description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`).
|
||||
|
||||
### Benefits
|
||||
|
||||
- Extend Qwen Code for your workflows
|
||||
- Share expertise across your team via git
|
||||
- Reduce repetitive prompting
|
||||
- Compose multiple Skills for complex tasks
|
||||
|
||||
## Create a Skill
|
||||
|
||||
Skills are stored as directories containing a `SKILL.md` file.
|
||||
|
||||
### Personal Skills
|
||||
|
||||
Personal Skills are available across all your projects. Store them in `~/.qwen/skills/`:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.qwen/skills/my-skill-name
|
||||
```
|
||||
|
||||
Use personal Skills for:
|
||||
|
||||
- Your individual workflows and preferences
|
||||
- Experimental Skills you’re developing
|
||||
- Personal productivity helpers
|
||||
|
||||
### Project Skills
|
||||
|
||||
Project Skills are shared with your team. Store them in `.qwen/skills/` within your project:
|
||||
|
||||
```bash
|
||||
mkdir -p .qwen/skills/my-skill-name
|
||||
```
|
||||
|
||||
Use project Skills for:
|
||||
|
||||
- Team workflows and conventions
|
||||
- Project-specific expertise
|
||||
- Shared utilities and scripts
|
||||
|
||||
Project Skills can be checked into git and automatically become available to teammates.
|
||||
|
||||
## Write `SKILL.md`
|
||||
|
||||
Create a `SKILL.md` file with YAML frontmatter and Markdown content:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: your-skill-name
|
||||
description: Brief description of what this Skill does and when to use it
|
||||
---
|
||||
|
||||
# Your Skill Name
|
||||
|
||||
## Instructions
|
||||
Provide clear, step-by-step guidance for Qwen Code.
|
||||
|
||||
## Examples
|
||||
Show concrete examples of using this Skill.
|
||||
```
|
||||
|
||||
### Field requirements
|
||||
|
||||
Qwen Code currently validates that:
|
||||
|
||||
- `name` is a non-empty string
|
||||
- `description` is a non-empty string
|
||||
|
||||
Recommended conventions (not strictly enforced yet):
|
||||
|
||||
- Use lowercase letters, numbers, and hyphens in `name`
|
||||
- Make `description` specific: include both **what** the Skill does and **when** to use it (key words users will naturally mention)
|
||||
|
||||
## Add supporting files
|
||||
|
||||
Create additional files alongside `SKILL.md`:
|
||||
|
||||
```text
|
||||
my-skill/
|
||||
├── SKILL.md (required)
|
||||
├── reference.md (optional documentation)
|
||||
├── examples.md (optional examples)
|
||||
├── scripts/
|
||||
│ └── helper.py (optional utility)
|
||||
└── templates/
|
||||
└── template.txt (optional template)
|
||||
```
|
||||
|
||||
Reference these files from `SKILL.md`:
|
||||
|
||||
````markdown
|
||||
For advanced usage, see [reference.md](reference.md).
|
||||
|
||||
Run the helper script:
|
||||
|
||||
```bash
|
||||
python scripts/helper.py input.txt
|
||||
```
|
||||
````
|
||||
|
||||
## View available Skills
|
||||
|
||||
When `--experimental-skills` is enabled, Qwen Code discovers Skills from:
|
||||
|
||||
- Personal Skills: `~/.qwen/skills/`
|
||||
- Project Skills: `.qwen/skills/`
|
||||
|
||||
To view available Skills, ask Qwen Code directly:
|
||||
|
||||
```text
|
||||
What Skills are available?
|
||||
```
|
||||
|
||||
Or inspect the filesystem:
|
||||
|
||||
```bash
|
||||
# List personal Skills
|
||||
ls ~/.qwen/skills/
|
||||
|
||||
# List project Skills (if in a project directory)
|
||||
ls .qwen/skills/
|
||||
|
||||
# View a specific Skill’s content
|
||||
cat ~/.qwen/skills/my-skill/SKILL.md
|
||||
```
|
||||
|
||||
## Test a Skill
|
||||
|
||||
After creating a Skill, test it by asking questions that match your description.
|
||||
|
||||
Example: if your description mentions “PDF files”:
|
||||
|
||||
```text
|
||||
Can you help me extract text from this PDF?
|
||||
```
|
||||
|
||||
The model autonomously decides to use your Skill if it matches the request — you don’t need to explicitly invoke it.
|
||||
|
||||
## Debug a Skill
|
||||
|
||||
If Qwen Code doesn’t use your Skill, check these common issues:
|
||||
|
||||
### Make the description specific
|
||||
|
||||
Too vague:
|
||||
|
||||
```yaml
|
||||
description: Helps with documents
|
||||
```
|
||||
|
||||
Specific:
|
||||
|
||||
```yaml
|
||||
description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDFs, forms, or document extraction.
|
||||
```
|
||||
|
||||
### Verify file path
|
||||
|
||||
- Personal Skills: `~/.qwen/skills/<skill-name>/SKILL.md`
|
||||
- Project Skills: `.qwen/skills/<skill-name>/SKILL.md`
|
||||
|
||||
```bash
|
||||
# Personal
|
||||
ls ~/.qwen/skills/my-skill/SKILL.md
|
||||
|
||||
# Project
|
||||
ls .qwen/skills/my-skill/SKILL.md
|
||||
```
|
||||
|
||||
### Check YAML syntax
|
||||
|
||||
Invalid YAML prevents the Skill metadata from loading correctly.
|
||||
|
||||
```bash
|
||||
cat SKILL.md | head -n 15
|
||||
```
|
||||
|
||||
Ensure:
|
||||
|
||||
- Opening `---` on line 1
|
||||
- Closing `---` before Markdown content
|
||||
- Valid YAML syntax (no tabs, correct indentation)
|
||||
|
||||
### View errors
|
||||
|
||||
Run Qwen Code with debug mode to see Skill loading errors:
|
||||
|
||||
```bash
|
||||
qwen --experimental-skills --debug
|
||||
```
|
||||
|
||||
## Share Skills with your team
|
||||
|
||||
You can share Skills through project repositories:
|
||||
|
||||
1. Add the Skill under `.qwen/skills/`
|
||||
2. Commit and push
|
||||
3. Teammates pull the changes and run with `--experimental-skills`
|
||||
|
||||
```bash
|
||||
git add .qwen/skills/
|
||||
git commit -m "Add team Skill for PDF processing"
|
||||
git push
|
||||
```
|
||||
|
||||
## Update a Skill
|
||||
|
||||
Edit `SKILL.md` directly:
|
||||
|
||||
```bash
|
||||
# Personal Skill
|
||||
code ~/.qwen/skills/my-skill/SKILL.md
|
||||
|
||||
# Project Skill
|
||||
code .qwen/skills/my-skill/SKILL.md
|
||||
```
|
||||
|
||||
Changes take effect the next time you start Qwen Code. If Qwen Code is already running, restart it to load the updates.
|
||||
|
||||
## Remove a Skill
|
||||
|
||||
Delete the Skill directory:
|
||||
|
||||
```bash
|
||||
# Personal
|
||||
rm -rf ~/.qwen/skills/my-skill
|
||||
|
||||
# Project
|
||||
rm -rf .qwen/skills/my-skill
|
||||
git commit -m "Remove unused Skill"
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
### Keep Skills focused
|
||||
|
||||
One Skill should address one capability:
|
||||
|
||||
- Focused: “PDF form filling”, “Excel analysis”, “Git commit messages”
|
||||
- Too broad: “Document processing” (split into smaller Skills)
|
||||
|
||||
### Write clear descriptions
|
||||
|
||||
Help the model discover when to use Skills by including specific triggers:
|
||||
|
||||
```yaml
|
||||
description: Analyze Excel spreadsheets, create pivot tables, and generate charts. Use when working with Excel files, spreadsheets, or .xlsx data.
|
||||
```
|
||||
|
||||
### Test with your team
|
||||
|
||||
- Does the Skill activate when expected?
|
||||
- Are the instructions clear?
|
||||
- Are there missing examples or edge cases?
|
||||
@@ -16,16 +16,15 @@ The plugin **MUST** run a local HTTP server that implements the **Model Context
|
||||
- **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication.
|
||||
- **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`).
|
||||
|
||||
### 2. Discovery Mechanism: The Port File
|
||||
### 2. Discovery Mechanism: The Lock File
|
||||
|
||||
For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file."
|
||||
For Qwen Code to connect, it needs to discover what port your server is using. The plugin **MUST** facilitate this by creating a "lock file" and setting the port environment variable.
|
||||
|
||||
- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name.
|
||||
- **File Location:** The file must be created in a specific directory: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist.
|
||||
- **How the CLI Finds the File:** The CLI reads the port from `QWEN_CODE_IDE_SERVER_PORT`, then reads `~/.qwen/ide/<PORT>.lock`. (Legacy fallbacks exist for older extensions; see note below.)
|
||||
- **File Location:** The file must be created in a specific directory: `~/.qwen/ide/`. Your plugin must create this directory if it doesn't exist.
|
||||
- **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
|
||||
`qwen-code-ide-server-${PID}-${PORT}.json`
|
||||
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename.
|
||||
- `${PORT}`: The port your MCP server is listening on.
|
||||
`<PORT>.lock`
|
||||
- `<PORT>`: The port your MCP server is listening on.
|
||||
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
|
||||
|
||||
```json
|
||||
@@ -33,21 +32,20 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i
|
||||
"port": 12345,
|
||||
"workspacePath": "/path/to/project1:/path/to/project2",
|
||||
"authToken": "a-very-secret-token",
|
||||
"ideInfo": {
|
||||
"name": "vscode",
|
||||
"displayName": "VS Code"
|
||||
}
|
||||
"ppid": 1234,
|
||||
"ideName": "VS Code"
|
||||
}
|
||||
```
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s).
|
||||
- `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests.
|
||||
- `ideInfo` (object, required): Information about the IDE.
|
||||
- `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`).
|
||||
- `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
|
||||
- `ppid` (number, required): The parent process ID of the IDE process.
|
||||
- `ideName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
|
||||
|
||||
- **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized.
|
||||
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server.
|
||||
- **Environment Variables (Required):** Your plugin **MUST** set `QWEN_CODE_IDE_SERVER_PORT` in the integrated terminal so the CLI can locate the correct `<PORT>.lock` file.
|
||||
|
||||
**Legacy note:** For extensions older than v0.5.1, Qwen Code may fall back to reading JSON files in the system temp directory named `qwen-code-ide-server-<PID>.json` or `qwen-code-ide-server-<PORT>.json`. New integrations should not rely on these legacy files.
|
||||
|
||||
## II. The Context Interface
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# Qwen Code overview
|
||||
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
|
||||
|
||||
@@ -46,7 +48,7 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. You can search for **Qwen Code** in the VS Code Marketplace and download it.
|
||||
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. Download and install the [Qwen Code Companion](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) now.
|
||||
|
||||
## What Qwen Code does for you
|
||||
|
||||
|
||||
@@ -84,12 +84,12 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
|
||||
The Qwen Code uses specific exit codes to indicate the reason for termination. This is especially useful for scripting and automation.
|
||||
|
||||
| Exit Code | Error Type | Description |
|
||||
| --------- | -------------------------- | ------------------------------------------------------------ |
|
||||
| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. |
|
||||
| 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) |
|
||||
| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g. Docker, Podman, or Seatbelt). |
|
||||
| 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. |
|
||||
| Exit Code | Error Type | Description |
|
||||
| --------- | -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. |
|
||||
| 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) |
|
||||
| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g. Docker, Podman, or Seatbelt). |
|
||||
| 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. |
|
||||
| 53 | `FatalTurnLimitedError` | The maximum number of conversational turns for the session was reached. (non-interactive mode only) |
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
describe('file-system', () => {
|
||||
@@ -202,8 +200,8 @@ describe('file-system', () => {
|
||||
const readAttempt = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'read_file',
|
||||
);
|
||||
const writeAttempt = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'write_file',
|
||||
const editAttempt = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'edit_file',
|
||||
);
|
||||
const successfulReplace = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'replace' && log.toolRequest.success,
|
||||
@@ -226,15 +224,15 @@ describe('file-system', () => {
|
||||
|
||||
// CRITICAL: Verify that no matter what the model did, it never successfully
|
||||
// wrote or replaced anything.
|
||||
if (writeAttempt) {
|
||||
if (editAttempt) {
|
||||
console.error(
|
||||
'A write_file attempt was made when no file should be written.',
|
||||
'A edit_file attempt was made when no file should be written.',
|
||||
);
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(
|
||||
writeAttempt,
|
||||
'write_file should not have been called',
|
||||
editAttempt,
|
||||
'edit_file should not have been called',
|
||||
).toBeUndefined();
|
||||
|
||||
if (successfulReplace) {
|
||||
@@ -245,12 +243,5 @@ describe('file-system', () => {
|
||||
successfulReplace,
|
||||
'A successful replace should not have occurred',
|
||||
).toBeUndefined();
|
||||
|
||||
// Final verification: ensure the file was not created.
|
||||
const filePath = path.join(rig.testDir!, fileName);
|
||||
const fileExists = existsSync(filePath);
|
||||
expect(fileExists, 'The non-existent file should not be created').toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -952,7 +952,8 @@ describe('Permission Control (E2E)', () => {
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
// FIXME: This test is flaky and sometimes fails with no tool calls.
|
||||
it.skip(
|
||||
'should allow read-only tools without restrictions',
|
||||
async () => {
|
||||
// Create test files for the model to read
|
||||
|
||||
@@ -314,4 +314,88 @@ describe('System Control (E2E)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportedCommands API', () => {
|
||||
it('should return list of supported slash commands', async () => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const generator = (async function* () {
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: { role: 'user', content: 'Hello' },
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
})();
|
||||
|
||||
const q = query({
|
||||
prompt: generator,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
model: 'qwen3-max',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await q.supportedCommands();
|
||||
// Start consuming messages to trigger initialization
|
||||
const messageConsumer = (async () => {
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Just consume messages
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors from query being closed
|
||||
if (error instanceof Error && error.message !== 'Query is closed') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Verify result structure
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('commands');
|
||||
expect(Array.isArray(result?.['commands'])).toBe(true);
|
||||
|
||||
const commands = result?.['commands'] as string[];
|
||||
|
||||
// Verify default allowed built-in commands are present
|
||||
expect(commands).toContain('init');
|
||||
expect(commands).toContain('summary');
|
||||
expect(commands).toContain('compress');
|
||||
|
||||
// Verify commands are sorted
|
||||
const sortedCommands = [...commands].sort();
|
||||
expect(commands).toEqual(sortedCommands);
|
||||
|
||||
// Verify all commands are strings
|
||||
commands.forEach((cmd) => {
|
||||
expect(typeof cmd).toBe('string');
|
||||
expect(cmd.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await q.close();
|
||||
await messageConsumer;
|
||||
} catch (error) {
|
||||
await q.close();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when supportedCommands is called on closed query', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
model: 'qwen3-max',
|
||||
},
|
||||
});
|
||||
|
||||
await q.close();
|
||||
|
||||
await expect(q.supportedCommands()).rejects.toThrow('Query is closed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
// Windows skip (Option A: avoid infra scope)
|
||||
@@ -121,21 +121,4 @@ d('BOM end-to-end integration', () => {
|
||||
'BOM_OK UTF-32BE',
|
||||
);
|
||||
});
|
||||
|
||||
it('Can describe a PNG file', async () => {
|
||||
const imagePath = resolve(
|
||||
process.cwd(),
|
||||
'docs/assets/gemini-screenshot.png',
|
||||
);
|
||||
const imageContent = readFileSync(imagePath);
|
||||
const filename = 'gemini-screenshot.png';
|
||||
writeFileSync(join(dir, filename), imageContent);
|
||||
const prompt = `What is shown in the image ${filename}?`;
|
||||
const output = await rig.run(prompt);
|
||||
await rig.waitForToolCall('read_file');
|
||||
const lower = output.toLowerCase();
|
||||
// The response is non-deterministic, so we just check for some
|
||||
// keywords that are very likely to be in the response.
|
||||
expect(lower.includes('gemini')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
2155
package-lock.json
generated
2155
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,14 +13,11 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
"debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js",
|
||||
"auth:npm": "npx google-artifactregistry-auth",
|
||||
"auth:docker": "gcloud auth configure-docker us-west1-docker.pkg.dev",
|
||||
"auth": "npm run auth:npm && npm run auth:docker",
|
||||
"generate": "node scripts/generate-git-commit-info.js",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start",
|
||||
@@ -95,7 +92,6 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"glob": "^10.5.0",
|
||||
"globals": "^16.0.0",
|
||||
"google-artifactregistry-auth": "^3.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"json": "^11.0.0",
|
||||
"lint-staged": "^16.1.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,13 +33,13 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"command-exists": "^1.2.9",
|
||||
@@ -64,7 +64,7 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^7.10.0",
|
||||
"undici": "^6.22.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
|
||||
@@ -98,6 +98,14 @@ export class AgentSideConnection implements Client {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a custom notification to the client.
|
||||
* Used for extension-specific notifications that are not part of the core ACP protocol.
|
||||
*/
|
||||
async sendCustomNotification<T>(method: string, params: T): Promise<void> {
|
||||
return await this.#connection.sendNotification(method, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permission before running a tool
|
||||
*
|
||||
@@ -374,6 +382,7 @@ export interface Client {
|
||||
): Promise<schema.RequestPermissionResponse>;
|
||||
sessionUpdate(params: schema.SessionNotification): Promise<void>;
|
||||
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
|
||||
sendCustomNotification<T>(method: string, params: T): Promise<void>;
|
||||
writeTextFile(
|
||||
params: schema.WriteTextFileRequest,
|
||||
): Promise<schema.WriteTextFileResponse>;
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
qwenOAuth2Events,
|
||||
MCPServerConfig,
|
||||
SessionService,
|
||||
buildApiHistoryFromConversation,
|
||||
type Config,
|
||||
type ConversationRecord,
|
||||
type DeviceAuthorizationData,
|
||||
tokenLimit,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ApprovalModeValue } from './schema.js';
|
||||
import * as acp from './acp.js';
|
||||
@@ -165,9 +165,30 @@ class GeminiAgent {
|
||||
this.setupFileSystem(config);
|
||||
|
||||
const session = await this.createAndStoreSession(config);
|
||||
const configuredModel = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
''
|
||||
).trim();
|
||||
const modelId = configuredModel || 'default';
|
||||
const modelName = configuredModel || modelId;
|
||||
|
||||
return {
|
||||
sessionId: session.getId(),
|
||||
models: {
|
||||
currentModelId: modelId,
|
||||
availableModels: [
|
||||
{
|
||||
modelId,
|
||||
name: modelName,
|
||||
description: null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(modelId),
|
||||
},
|
||||
},
|
||||
],
|
||||
_meta: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -327,12 +348,20 @@ class GeminiAgent {
|
||||
const sessionId = config.getSessionId();
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
const history = conversation
|
||||
? buildApiHistoryFromConversation(conversation)
|
||||
: undefined;
|
||||
const chat = history
|
||||
? await geminiClient.startChat(history)
|
||||
: await geminiClient.startChat();
|
||||
// Use GeminiClient to manage chat lifecycle properly
|
||||
// This ensures geminiClient.chat is in sync with the session's chat
|
||||
//
|
||||
// Note: When loading a session, config.initialize() has already been called
|
||||
// in newSessionConfig(), which in turn calls geminiClient.initialize().
|
||||
// The GeminiClient.initialize() method checks config.getResumedSessionData()
|
||||
// and automatically loads the conversation history into the chat instance.
|
||||
// So we only need to initialize if it hasn't been done yet.
|
||||
if (!geminiClient.isInitialized()) {
|
||||
await geminiClient.initialize();
|
||||
}
|
||||
|
||||
// Now get the chat instance that's managed by GeminiClient
|
||||
const chat = geminiClient.getChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
|
||||
@@ -93,6 +93,7 @@ export type ModeInfo = z.infer<typeof modeInfoSchema>;
|
||||
export type ModesData = z.infer<typeof modesDataSchema>;
|
||||
|
||||
export type AgentInfo = z.infer<typeof agentInfoSchema>;
|
||||
export type ModelInfo = z.infer<typeof modelInfoSchema>;
|
||||
|
||||
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
|
||||
|
||||
@@ -254,8 +255,26 @@ export const authenticateUpdateSchema = z.object({
|
||||
|
||||
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
|
||||
|
||||
export const acpMetaSchema = z.record(z.unknown()).nullable().optional();
|
||||
|
||||
export const modelIdSchema = z.string();
|
||||
|
||||
export const modelInfoSchema = z.object({
|
||||
_meta: acpMetaSchema,
|
||||
description: z.string().nullable().optional(),
|
||||
modelId: modelIdSchema,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const sessionModelStateSchema = z.object({
|
||||
_meta: acpMetaSchema,
|
||||
availableModels: z.array(modelInfoSchema),
|
||||
currentModelId: modelIdSchema,
|
||||
});
|
||||
|
||||
export const newSessionResponseSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
models: sessionModelStateSchema,
|
||||
});
|
||||
|
||||
export const loadSessionResponseSchema = z.null();
|
||||
@@ -514,6 +533,13 @@ export const currentModeUpdateSchema = z.object({
|
||||
|
||||
export type CurrentModeUpdate = z.infer<typeof currentModeUpdateSchema>;
|
||||
|
||||
export const currentModelUpdateSchema = z.object({
|
||||
sessionUpdate: z.literal('current_model_update'),
|
||||
model: modelInfoSchema,
|
||||
});
|
||||
|
||||
export type CurrentModelUpdate = z.infer<typeof currentModelUpdateSchema>;
|
||||
|
||||
export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
@@ -555,6 +581,7 @@ export const sessionUpdateSchema = z.union([
|
||||
sessionUpdate: z.literal('plan'),
|
||||
}),
|
||||
currentModeUpdateSchema,
|
||||
currentModelUpdateSchema,
|
||||
availableCommandsUpdateSchema,
|
||||
]);
|
||||
|
||||
|
||||
@@ -41,9 +41,11 @@ import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
|
||||
import {
|
||||
handleSlashCommand,
|
||||
getAvailableCommands,
|
||||
type NonInteractiveSlashCommandResult,
|
||||
} from '../../nonInteractiveCliCommands.js';
|
||||
import type {
|
||||
AvailableCommand,
|
||||
@@ -63,12 +65,6 @@ import { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||
|
||||
/**
|
||||
* Built-in commands that are allowed in ACP integration mode.
|
||||
* Only safe, read-only commands that don't require interactive UI.
|
||||
*/
|
||||
export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
|
||||
|
||||
/**
|
||||
* Session represents an active conversation session with the AI model.
|
||||
* It uses modular components for consistent event emission:
|
||||
@@ -167,24 +163,26 @@ export class Session implements SessionContext {
|
||||
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
|
||||
const inputText = firstTextBlock?.text || '';
|
||||
|
||||
let parts: Part[];
|
||||
let parts: Part[] | null;
|
||||
|
||||
if (isSlashCommand(inputText)) {
|
||||
// Handle slash command - allow specific built-in commands for ACP integration
|
||||
// Handle slash command - uses default allowed commands (init, summary, compress)
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
inputText,
|
||||
pendingSend,
|
||||
this.config,
|
||||
this.settings,
|
||||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||
);
|
||||
|
||||
if (slashCommandResult) {
|
||||
// Use the result from the slash command
|
||||
parts = slashCommandResult as Part[];
|
||||
} else {
|
||||
// Slash command didn't return a prompt, continue with normal processing
|
||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
parts = await this.#processSlashCommandResult(
|
||||
slashCommandResult,
|
||||
params.prompt,
|
||||
);
|
||||
|
||||
// If parts is null, the command was fully handled (e.g., /summary completed)
|
||||
// Return early without sending to the model
|
||||
if (parts === null) {
|
||||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
} else {
|
||||
// Normal processing for non-slash commands
|
||||
@@ -295,11 +293,10 @@ export class Session implements SessionContext {
|
||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
// Use default allowed commands from getAvailableCommands
|
||||
const slashCommands = await getAvailableCommands(
|
||||
this.config,
|
||||
this.settings,
|
||||
abortController.signal,
|
||||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||
);
|
||||
|
||||
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
|
||||
@@ -647,6 +644,103 @@ export class Session implements SessionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the result of a slash command execution.
|
||||
*
|
||||
* Supported result types in ACP mode:
|
||||
* - submit_prompt: Submits content to the model
|
||||
* - stream_messages: Streams multiple messages to the client (ACP-specific)
|
||||
* - unsupported: Command cannot be executed in ACP mode
|
||||
* - no_command: No command was found, use original prompt
|
||||
*
|
||||
* Note: 'message' type is not supported in ACP mode - commands should use
|
||||
* 'stream_messages' instead for consistent async handling.
|
||||
*
|
||||
* @param result The result from handleSlashCommand
|
||||
* @param originalPrompt The original prompt blocks
|
||||
* @returns Parts to use for the prompt, or null if command was handled without needing model interaction
|
||||
*/
|
||||
async #processSlashCommandResult(
|
||||
result: NonInteractiveSlashCommandResult,
|
||||
originalPrompt: acp.ContentBlock[],
|
||||
): Promise<Part[] | null> {
|
||||
switch (result.type) {
|
||||
case 'submit_prompt':
|
||||
// Command wants to submit a prompt to the model
|
||||
// Convert PartListUnion to Part[]
|
||||
return normalizePartList(result.content);
|
||||
|
||||
case 'message': {
|
||||
// 'message' type is not ideal for ACP mode, but we handle it for compatibility
|
||||
// by converting it to a stream_messages-like notification
|
||||
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
||||
sessionId: this.sessionId,
|
||||
command: originalPrompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' '),
|
||||
messageType: result.messageType,
|
||||
message: result.content || '',
|
||||
});
|
||||
|
||||
if (result.messageType === 'error') {
|
||||
// Throw error to stop execution
|
||||
throw new Error(result.content || 'Slash command failed.');
|
||||
}
|
||||
// For info messages, return null to indicate command was handled
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'stream_messages': {
|
||||
// Command returns multiple messages via async generator (ACP-preferred)
|
||||
const command = originalPrompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' ');
|
||||
|
||||
// Stream all messages to the client
|
||||
for await (const msg of result.messages) {
|
||||
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
||||
sessionId: this.sessionId,
|
||||
command,
|
||||
messageType: msg.messageType,
|
||||
message: msg.content,
|
||||
});
|
||||
|
||||
// If we encounter an error message, throw after sending
|
||||
if (msg.messageType === 'error') {
|
||||
throw new Error(msg.content || 'Slash command failed.');
|
||||
}
|
||||
}
|
||||
|
||||
// All messages sent successfully, return null to indicate command was handled
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'unsupported': {
|
||||
// Command returned an unsupported result type
|
||||
const unsupportedError = `Slash command not supported in ACP integration: ${result.reason}`;
|
||||
throw new Error(unsupportedError);
|
||||
}
|
||||
|
||||
case 'no_command':
|
||||
// No command was found or executed, use original prompt
|
||||
return originalPrompt.map((block) => {
|
||||
if (block.type === 'text') {
|
||||
return { text: block.text };
|
||||
}
|
||||
throw new Error(`Unsupported block type: ${block.type}`);
|
||||
});
|
||||
|
||||
default: {
|
||||
// Exhaustiveness check
|
||||
const _exhaustive: never = result;
|
||||
const unknownError = `Unknown slash command result type: ${(_exhaustive as NonInteractiveSlashCommandResult).type}`;
|
||||
throw new Error(unknownError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #resolvePrompt(
|
||||
message: acp.ContentBlock[],
|
||||
abortSignal: AbortSignal,
|
||||
|
||||
@@ -26,5 +26,37 @@ export function validateAuthMethod(authMethod: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_ANTHROPIC) {
|
||||
const hasApiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'ANTHROPIC_API_KEY environment variable not found.';
|
||||
}
|
||||
|
||||
const hasBaseUrl = process.env['ANTHROPIC_BASE_URL'];
|
||||
if (!hasBaseUrl) {
|
||||
return 'ANTHROPIC_BASE_URL environment variable not found.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_GEMINI) {
|
||||
const hasApiKey = process.env['GEMINI_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_VERTEX_AI) {
|
||||
const hasApiKey = process.env['GOOGLE_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||
}
|
||||
|
||||
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Invalid auth method selected.';
|
||||
}
|
||||
|
||||
@@ -206,6 +206,18 @@ describe('parseArguments', () => {
|
||||
expect(argv.prompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow -r flag as alias for --resume', async () => {
|
||||
process.argv = ['node', 'script.js', '-r', 'session-123'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.resume).toBe('session-123');
|
||||
});
|
||||
|
||||
it('should allow -c flag as alias for --continue', async () => {
|
||||
process.argv = ['node', 'script.js', '-c'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert positional query argument to prompt by default', async () => {
|
||||
process.argv = ['node', 'script.js', 'Hi Gemini'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
@@ -2102,7 +2114,14 @@ describe('loadCliConfig model selection', () => {
|
||||
});
|
||||
|
||||
it('always prefers model from argvs', async () => {
|
||||
process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus'];
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--auth-type',
|
||||
'openai',
|
||||
'--model',
|
||||
'qwen3-coder-plus',
|
||||
];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
@@ -2122,7 +2141,14 @@ describe('loadCliConfig model selection', () => {
|
||||
});
|
||||
|
||||
it('selects the model from argvs if provided', async () => {
|
||||
process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus'];
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--auth-type',
|
||||
'openai',
|
||||
'--model',
|
||||
'qwen3-coder-plus',
|
||||
];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface CliArgs {
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
allowedTools: string[] | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
experimentalSkills: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
openaiLogging: boolean | undefined;
|
||||
@@ -299,7 +300,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), yolo (auto-approve all tools)',
|
||||
})
|
||||
.option('checkpointing', {
|
||||
alias: 'c',
|
||||
type: 'boolean',
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
@@ -308,6 +308,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('experimental-skills', {
|
||||
type: 'boolean',
|
||||
description: 'Enable experimental Skills feature',
|
||||
default: false,
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||
@@ -422,12 +427,14 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
default: false,
|
||||
})
|
||||
.option('continue', {
|
||||
alias: 'c',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Resume the most recent session for the current project.',
|
||||
default: false,
|
||||
})
|
||||
.option('resume', {
|
||||
alias: 'r',
|
||||
type: 'string',
|
||||
description:
|
||||
'Resume a specific session by its ID. Use without an ID to show session picker.',
|
||||
@@ -459,7 +466,13 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
})
|
||||
.option('auth-type', {
|
||||
type: 'string',
|
||||
choices: [AuthType.USE_OPENAI, AuthType.QWEN_OAUTH],
|
||||
choices: [
|
||||
AuthType.USE_OPENAI,
|
||||
AuthType.USE_ANTHROPIC,
|
||||
AuthType.QWEN_OAUTH,
|
||||
AuthType.USE_GEMINI,
|
||||
AuthType.USE_VERTEX_AI,
|
||||
],
|
||||
description: 'Authentication type',
|
||||
})
|
||||
.deprecateOption(
|
||||
@@ -864,11 +877,30 @@ export async function loadCliConfig(
|
||||
);
|
||||
}
|
||||
|
||||
const selectedAuthType =
|
||||
(argv.authType as AuthType | undefined) ||
|
||||
settings.security?.auth?.selectedType;
|
||||
|
||||
const apiKey =
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? argv.openaiApiKey ||
|
||||
process.env['OPENAI_API_KEY'] ||
|
||||
settings.security?.auth?.apiKey
|
||||
: '') || '';
|
||||
const baseUrl =
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? argv.openaiBaseUrl ||
|
||||
process.env['OPENAI_BASE_URL'] ||
|
||||
settings.security?.auth?.baseUrl
|
||||
: '') || '';
|
||||
const resolvedModel =
|
||||
argv.model ||
|
||||
process.env['OPENAI_MODEL'] ||
|
||||
process.env['QWEN_MODEL'] ||
|
||||
settings.model?.name;
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? process.env['OPENAI_MODEL'] ||
|
||||
process.env['QWEN_MODEL'] ||
|
||||
settings.model?.name
|
||||
: '') ||
|
||||
'';
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
const screenReader =
|
||||
@@ -950,27 +982,20 @@ export async function loadCliConfig(
|
||||
maxSessionTurns:
|
||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
experimentalSkills: argv.experimentalSkills || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
extensions: allExtensions,
|
||||
blockedMcpServers,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
authType:
|
||||
(argv.authType as AuthType | undefined) ||
|
||||
settings.security?.auth?.selectedType,
|
||||
authType: selectedAuthType,
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
generationConfig: {
|
||||
...(settings.model?.generationConfig || {}),
|
||||
model: resolvedModel,
|
||||
apiKey:
|
||||
argv.openaiApiKey ||
|
||||
process.env['OPENAI_API_KEY'] ||
|
||||
settings.security?.auth?.apiKey,
|
||||
baseUrl:
|
||||
argv.openaiBaseUrl ||
|
||||
process.env['OPENAI_BASE_URL'] ||
|
||||
settings.security?.auth?.baseUrl,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
enableOpenAILogging:
|
||||
(typeof argv.openaiLogging === 'undefined'
|
||||
? settings.model?.enableOpenAILogging
|
||||
@@ -1002,6 +1027,7 @@ export async function loadCliConfig(
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: appEvents,
|
||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||
gitCoAuthor: settings.general?.gitCoAuthor,
|
||||
output: {
|
||||
format: outputSettingsFormat,
|
||||
},
|
||||
|
||||
@@ -56,6 +56,17 @@ vi.mock('simple-git', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./extensions/github.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./extensions/github.js')>();
|
||||
return {
|
||||
...actual,
|
||||
downloadFromGitHubRelease: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Mocked GitHub release download failure')),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
|
||||
@@ -581,7 +581,7 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string {
|
||||
}
|
||||
if (extensionConfig.contextFileName) {
|
||||
output.push(
|
||||
`This extension will append info to your gemini.md context using ${extensionConfig.contextFileName}`,
|
||||
`This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`,
|
||||
);
|
||||
}
|
||||
if (extensionConfig.excludeTools) {
|
||||
|
||||
@@ -41,6 +41,17 @@ vi.mock('simple-git', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../extensions/github.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../extensions/github.js')>();
|
||||
return {
|
||||
...actual,
|
||||
downloadFromGitHubRelease: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Mocked GitHub release download failure')),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
|
||||
@@ -147,6 +147,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable update notification prompts.',
|
||||
showInDialog: false,
|
||||
},
|
||||
gitCoAuthor: {
|
||||
type: 'boolean',
|
||||
label: 'Git Co-Author',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.',
|
||||
showInDialog: false,
|
||||
},
|
||||
checkpointing: {
|
||||
type: 'object',
|
||||
label: 'Checkpointing',
|
||||
@@ -284,7 +294,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Show Gemini CLI status and thoughts in the terminal window title',
|
||||
'Show Qwen Code status and thoughts in the terminal window title',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideTips: {
|
||||
@@ -312,7 +322,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
|
||||
'Hide the context summary (QWEN.md, MCP servers) above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
footer: {
|
||||
@@ -518,7 +528,7 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The Gemini model to use for conversations.',
|
||||
description: 'The model to use for conversations.',
|
||||
showInDialog: false,
|
||||
},
|
||||
maxSessionTurns: {
|
||||
@@ -649,6 +659,22 @@ const SETTINGS_SCHEMA = {
|
||||
childKey: 'disableCacheControl',
|
||||
showInDialog: true,
|
||||
},
|
||||
schemaCompliance: {
|
||||
type: 'enum',
|
||||
label: 'Tool Schema Compliance',
|
||||
category: 'Generation Configuration',
|
||||
requiresRestart: false,
|
||||
default: 'auto',
|
||||
description:
|
||||
'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'schemaCompliance',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'auto', label: 'Auto (Default)' },
|
||||
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -379,8 +379,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Set no relaunch in tests since process spawning causing issues in tests
|
||||
originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(process.stdin as any).setRawMode) {
|
||||
@@ -402,9 +402,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
afterEach(() => {
|
||||
// Restore original env variables
|
||||
if (originalEnvNoRelaunch !== undefined) {
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch;
|
||||
process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch;
|
||||
} else {
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
delete process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -461,6 +461,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
experimentalAcp: undefined,
|
||||
experimentalSkills: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: undefined,
|
||||
openaiLogging: undefined,
|
||||
|
||||
@@ -4,13 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
getOauthClient,
|
||||
InputFormat,
|
||||
logUserPrompt,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Config, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core';
|
||||
import { render } from 'ink';
|
||||
import dns from 'node:dns';
|
||||
import os from 'node:os';
|
||||
@@ -58,7 +53,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js';
|
||||
import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js';
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
order: string | undefined,
|
||||
@@ -92,7 +87,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
|
||||
if (process.env['QWEN_CODE_NO_RELAUNCH']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -399,15 +394,6 @@ export async function main() {
|
||||
initializationResult = await initializeApp(config, settings);
|
||||
}
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.selectedType ===
|
||||
AuthType.LOGIN_WITH_GOOGLE &&
|
||||
config.isBrowserLaunchSuppressed()
|
||||
) {
|
||||
// Do oauth before app renders to make copying the link possible.
|
||||
await getOauthClient(settings.merged.security.auth.selectedType, config);
|
||||
}
|
||||
|
||||
if (config.getExperimentalZedIntegration()) {
|
||||
return runAcpAgent(config, settings, extensions, argv);
|
||||
}
|
||||
|
||||
@@ -258,6 +258,8 @@ export default {
|
||||
', Tab to change focus': ', Tab to change focus',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.',
|
||||
// ============================================================================
|
||||
// Settings Labels
|
||||
// ============================================================================
|
||||
@@ -310,6 +312,7 @@ export default {
|
||||
'Tool Output Truncation Lines': 'Tool Output Truncation Lines',
|
||||
'Folder Trust': 'Folder Trust',
|
||||
'Vision Model Preview': 'Vision Model Preview',
|
||||
'Tool Schema Compliance': 'Tool Schema Compliance',
|
||||
// Settings enum options
|
||||
'Auto (detect from system)': 'Auto (detect from system)',
|
||||
Text: 'Text',
|
||||
@@ -589,6 +592,12 @@ export default {
|
||||
'No conversation found to summarize.': 'No conversation found to summarize.',
|
||||
'Failed to generate project context summary: {{error}}':
|
||||
'Failed to generate project context summary: {{error}}',
|
||||
'Saved project summary to {{filePathForDisplay}}.':
|
||||
'Saved project summary to {{filePathForDisplay}}.',
|
||||
'Saving project summary...': 'Saving project summary...',
|
||||
'Generating project summary...': 'Generating project summary...',
|
||||
'Failed to generate summary - no text content received from LLM response':
|
||||
'Failed to generate summary - no text content received from LLM response',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Model
|
||||
@@ -635,8 +644,8 @@ export default {
|
||||
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.':
|
||||
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
|
||||
"Error adding '{{path}}': {{error}}": "Error adding '{{path}}': {{error}}",
|
||||
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}':
|
||||
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
|
||||
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
|
||||
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}',
|
||||
'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}',
|
||||
'Successfully added directories:\n- {{directories}}':
|
||||
'Successfully added directories:\n- {{directories}}',
|
||||
|
||||
@@ -260,7 +260,8 @@ export default {
|
||||
', Tab to change focus': ', Tab для смены фокуса',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
|
||||
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
'Команда "/{{command}}" не поддерживается в неинтерактивном режиме.',
|
||||
// ============================================================================
|
||||
// Метки настроек
|
||||
// ============================================================================
|
||||
@@ -604,6 +605,12 @@ export default {
|
||||
'Не найдено диалогов для создания сводки.',
|
||||
'Failed to generate project context summary: {{error}}':
|
||||
'Не удалось сгенерировать сводку контекста проекта: {{error}}',
|
||||
'Saved project summary to {{filePathForDisplay}}.':
|
||||
'Сводка проекта сохранена в {{filePathForDisplay}}',
|
||||
'Saving project summary...': 'Сохранение сводки проекта...',
|
||||
'Generating project summary...': 'Генерация сводки проекта...',
|
||||
'Failed to generate summary - no text content received from LLM response':
|
||||
'Не удалось сгенерировать сводку - не получен текстовый контент из ответа LLM',
|
||||
|
||||
// ============================================================================
|
||||
// Команды - Модель
|
||||
|
||||
@@ -249,6 +249,8 @@ export default {
|
||||
', Tab to change focus': ',Tab 切换焦点',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
'不支持在非交互模式下使用命令 "/{{command}}"。',
|
||||
// ============================================================================
|
||||
// Settings Labels
|
||||
// ============================================================================
|
||||
@@ -300,6 +302,7 @@ export default {
|
||||
'Tool Output Truncation Lines': '工具输出截断行数',
|
||||
'Folder Trust': '文件夹信任',
|
||||
'Vision Model Preview': '视觉模型预览',
|
||||
'Tool Schema Compliance': '工具 Schema 兼容性',
|
||||
// Settings enum options
|
||||
'Auto (detect from system)': '自动(从系统检测)',
|
||||
Text: '文本',
|
||||
@@ -559,6 +562,12 @@ export default {
|
||||
'No conversation found to summarize.': '未找到要总结的对话',
|
||||
'Failed to generate project context summary: {{error}}':
|
||||
'生成项目上下文摘要失败:{{error}}',
|
||||
'Saved project summary to {{filePathForDisplay}}.':
|
||||
'项目摘要已保存到 {{filePathForDisplay}}',
|
||||
'Saving project summary...': '正在保存项目摘要...',
|
||||
'Generating project summary...': '正在生成项目摘要...',
|
||||
'Failed to generate summary - no text content received from LLM response':
|
||||
'生成摘要失败 - 未从 LLM 响应中接收到文本内容',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Model
|
||||
@@ -601,8 +610,8 @@ export default {
|
||||
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.':
|
||||
'/directory add 命令在限制性沙箱配置文件中不受支持。请改为在启动会话时使用 --include-directories。',
|
||||
"Error adding '{{path}}': {{error}}": "添加 '{{path}}' 时出错:{{error}}",
|
||||
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}':
|
||||
'如果存在,已成功从以下目录添加 GEMINI.md 文件:\n- {{directories}}',
|
||||
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
|
||||
'如果存在,已成功从以下目录添加 QWEN.md 文件:\n- {{directories}}',
|
||||
'Error refreshing memory: {{error}}': '刷新内存时出错:{{error}}',
|
||||
'Successfully added directories:\n- {{directories}}':
|
||||
'成功添加目录:\n- {{directories}}',
|
||||
|
||||
@@ -20,8 +20,7 @@ import type {
|
||||
CLIControlSetModelRequest,
|
||||
CLIMcpServerConfig,
|
||||
} from '../../types.js';
|
||||
import { CommandService } from '../../../services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js';
|
||||
import { getAvailableCommands } from '../../../nonInteractiveCliCommands.js';
|
||||
import {
|
||||
MCPServerConfig,
|
||||
AuthProviderType,
|
||||
@@ -407,7 +406,7 @@ export class SystemController extends BaseController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load slash command names using CommandService
|
||||
* Load slash command names using getAvailableCommands
|
||||
*
|
||||
* @param signal - AbortSignal to respect for cancellation
|
||||
* @returns Promise resolving to array of slash command names
|
||||
@@ -418,21 +417,14 @@ export class SystemController extends BaseController {
|
||||
}
|
||||
|
||||
try {
|
||||
const service = await CommandService.create(
|
||||
[new BuiltinCommandLoader(this.context.config)],
|
||||
signal,
|
||||
);
|
||||
const commands = await getAvailableCommands(this.context.config, signal);
|
||||
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const names = new Set<string>();
|
||||
const commands = service.getCommands();
|
||||
for (const command of commands) {
|
||||
names.add(command.name);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
// Extract command names and sort
|
||||
return commands.map((cmd) => cmd.name).sort();
|
||||
} catch (error) {
|
||||
// Check if the error is due to abort
|
||||
if (signal.aborted) {
|
||||
|
||||
@@ -610,8 +610,6 @@ export abstract class BaseJsonOutputAdapter {
|
||||
const errorText = parseAndFormatApiError(
|
||||
event.value.error,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
undefined,
|
||||
this.config.getModel(),
|
||||
);
|
||||
this.appendText(state, errorText, null);
|
||||
break;
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('runNonInteractive', () => {
|
||||
let mockShutdownTelemetry: Mock;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
let processStdoutSpy: MockInstance;
|
||||
let processStderrSpy: MockInstance;
|
||||
let mockGeminiClient: {
|
||||
sendMessageStream: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
@@ -86,6 +87,9 @@ describe('runNonInteractive', () => {
|
||||
processStdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
processStderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code}) called`);
|
||||
});
|
||||
@@ -139,6 +143,8 @@ describe('runNonInteractive', () => {
|
||||
setModel: vi.fn(async (model: string) => {
|
||||
currentModel = model;
|
||||
}),
|
||||
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
@@ -852,7 +858,7 @@ describe('runNonInteractive', () => {
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||
});
|
||||
|
||||
it('should throw FatalInputError if a command requires confirmation', async () => {
|
||||
it('should handle command that requires confirmation by returning early', async () => {
|
||||
const mockCommand = {
|
||||
name: 'confirm',
|
||||
description: 'a command that needs confirmation',
|
||||
@@ -864,15 +870,16 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/confirm',
|
||||
'prompt-id-confirm',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Exiting due to a confirmation prompt requested by the command.',
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/confirm',
|
||||
'prompt-id-confirm',
|
||||
);
|
||||
|
||||
// Should write error message to stderr
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -909,7 +916,30 @@ describe('runNonInteractive', () => {
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||
});
|
||||
|
||||
it('should throw for unhandled command result types', async () => {
|
||||
it('should handle known but unsupported slash commands like /help by returning early', async () => {
|
||||
// Mock a built-in command that exists but is not in the allowed list
|
||||
const mockHelpCommand = {
|
||||
name: 'help',
|
||||
description: 'Show help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/help',
|
||||
'prompt-id-help',
|
||||
);
|
||||
|
||||
// Should write error message to stderr
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'The command "/help" is not supported in non-interactive mode.\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unhandled command result types by returning early with error', async () => {
|
||||
const mockCommand = {
|
||||
name: 'noaction',
|
||||
description: 'unhandled type',
|
||||
@@ -920,15 +950,16 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/noaction',
|
||||
'prompt-id-unhandled',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Exiting due to command result that is not supported in non-interactive mode.',
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/noaction',
|
||||
'prompt-id-unhandled',
|
||||
);
|
||||
|
||||
// Should write error message to stderr
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Unknown command result type: unhandled\n',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,55 @@ import {
|
||||
computeUsageFromMetrics,
|
||||
} from './utils/nonInteractiveHelpers.js';
|
||||
|
||||
/**
|
||||
* Emits a final message for slash command results.
|
||||
* Note: systemMessage should already be emitted before calling this function.
|
||||
*/
|
||||
async function emitNonInteractiveFinalMessage(params: {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
adapter?: JsonOutputAdapterInterface;
|
||||
config: Config;
|
||||
startTimeMs: number;
|
||||
}): Promise<void> {
|
||||
const { message, isError, adapter, config } = params;
|
||||
|
||||
if (!adapter) {
|
||||
// Text output mode: write directly to stdout/stderr
|
||||
const target = isError ? process.stderr : process.stdout;
|
||||
target.write(`${message}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON output mode: emit assistant message and result
|
||||
// (systemMessage should already be emitted by caller)
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: message,
|
||||
} as unknown as Parameters<JsonOutputAdapterInterface['processEvent']>[0]);
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const usage = computeUsageFromMetrics(metrics);
|
||||
const outputFormat = config.getOutputFormat();
|
||||
const stats =
|
||||
outputFormat === OutputFormat.JSON
|
||||
? uiTelemetryService.getMetrics()
|
||||
: undefined;
|
||||
|
||||
adapter.emitResult({
|
||||
isError,
|
||||
durationMs: Date.now() - params.startTimeMs,
|
||||
apiDurationMs: 0,
|
||||
numTurns: 0,
|
||||
errorMessage: isError ? message : undefined,
|
||||
usage,
|
||||
stats,
|
||||
summary: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides optional overrides for `runNonInteractive` execution.
|
||||
*
|
||||
@@ -115,6 +164,16 @@ export async function runNonInteractive(
|
||||
process.on('SIGINT', shutdownHandler);
|
||||
process.on('SIGTERM', shutdownHandler);
|
||||
|
||||
// Emit systemMessage first (always the first message in JSON mode)
|
||||
if (adapter) {
|
||||
const systemMessage = await buildSystemMessage(
|
||||
config,
|
||||
sessionId,
|
||||
permissionMode,
|
||||
);
|
||||
adapter.emitMessage(systemMessage);
|
||||
}
|
||||
|
||||
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
|
||||
options.userMessage,
|
||||
);
|
||||
@@ -128,10 +187,45 @@ export async function runNonInteractive(
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
if (slashCommandResult) {
|
||||
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
||||
initialPartList = slashCommandResult as PartListUnion;
|
||||
slashHandled = true;
|
||||
switch (slashCommandResult.type) {
|
||||
case 'submit_prompt':
|
||||
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
||||
initialPartList = slashCommandResult.content;
|
||||
slashHandled = true;
|
||||
break;
|
||||
case 'message': {
|
||||
// systemMessage already emitted above
|
||||
await emitNonInteractiveFinalMessage({
|
||||
message: slashCommandResult.content,
|
||||
isError: slashCommandResult.messageType === 'error',
|
||||
adapter,
|
||||
config,
|
||||
startTimeMs: startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'stream_messages':
|
||||
throw new FatalInputError(
|
||||
'Stream messages mode is not supported in non-interactive CLI',
|
||||
);
|
||||
case 'unsupported': {
|
||||
await emitNonInteractiveFinalMessage({
|
||||
message: slashCommandResult.reason,
|
||||
isError: true,
|
||||
adapter,
|
||||
config,
|
||||
startTimeMs: startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'no_command':
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = slashCommandResult;
|
||||
throw new FatalInputError(
|
||||
`Unhandled slash command result type: ${(_exhaustive as { type: string }).type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,15 +257,6 @@ export async function runNonInteractive(
|
||||
const initialParts = normalizePartList(initialPartList);
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
|
||||
|
||||
if (adapter) {
|
||||
const systemMessage = await buildSystemMessage(
|
||||
config,
|
||||
sessionId,
|
||||
permissionMode,
|
||||
);
|
||||
adapter.emitMessage(systemMessage);
|
||||
}
|
||||
|
||||
let isFirstTurn = true;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
@@ -221,8 +306,6 @@ export async function runNonInteractive(
|
||||
const errorText = parseAndFormatApiError(
|
||||
event.value.error,
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
undefined,
|
||||
config.getModel(),
|
||||
);
|
||||
process.stderr.write(`${errorText}\n`);
|
||||
}
|
||||
|
||||
242
packages/cli/src/nonInteractiveCliCommands.test.ts
Normal file
242
packages/cli/src/nonInteractiveCliCommands.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import { CommandKind } from './ui/commands/types.js';
|
||||
|
||||
// Mock the CommandService
|
||||
const mockGetCommands = vi.hoisted(() => vi.fn());
|
||||
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./services/CommandService.js', () => ({
|
||||
CommandService: {
|
||||
create: mockCommandServiceCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('handleSlashCommand', () => {
|
||||
let mockConfig: Config;
|
||||
let mockSettings: LoadedSettings;
|
||||
let abortController: AbortController;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandServiceCreate.mockResolvedValue({
|
||||
getCommands: mockGetCommands,
|
||||
});
|
||||
|
||||
mockConfig = {
|
||||
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session'),
|
||||
getFolderTrustFeature: vi.fn().mockReturnValue(false),
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
storage: {},
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
system: { path: '', settings: {} },
|
||||
systemDefaults: { path: '', settings: {} },
|
||||
user: { path: '', settings: {} },
|
||||
workspace: { path: '', settings: {} },
|
||||
} as LoadedSettings;
|
||||
|
||||
abortController = new AbortController();
|
||||
});
|
||||
|
||||
it('should return no_command for non-slash input', async () => {
|
||||
const result = await handleSlashCommand(
|
||||
'regular text',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('no_command');
|
||||
});
|
||||
|
||||
it('should return no_command for unknown slash commands', async () => {
|
||||
mockGetCommands.mockReturnValue([]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/unknowncommand',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('no_command');
|
||||
});
|
||||
|
||||
it('should return unsupported for known built-in commands not in allowed list', async () => {
|
||||
const mockHelpCommand = {
|
||||
name: 'help',
|
||||
description: 'Show help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/help',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
[], // Empty allowed list
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
if (result.type === 'unsupported') {
|
||||
expect(result.reason).toContain('/help');
|
||||
expect(result.reason).toContain('not supported');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unsupported for /help when using default allowed list', async () => {
|
||||
const mockHelpCommand = {
|
||||
name: 'help',
|
||||
description: 'Show help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/help',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
// Default allowed list: ['init', 'summary', 'compress']
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
if (result.type === 'unsupported') {
|
||||
expect(result.reason).toBe(
|
||||
'The command "/help" is not supported in non-interactive mode.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute allowed built-in commands', async () => {
|
||||
const mockInitCommand = {
|
||||
name: 'init',
|
||||
description: 'Initialize project',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Project initialized',
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockInitCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/init',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
['init'], // init is in the allowed list
|
||||
);
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
if (result.type === 'message') {
|
||||
expect(result.content).toBe('Project initialized');
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute file commands regardless of allowed list', async () => {
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
description: 'Custom file command',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Custom prompt' }],
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockFileCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/custom',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
[], // Empty allowed list, but FILE commands should still work
|
||||
);
|
||||
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
if (result.type === 'submit_prompt') {
|
||||
expect(result.content).toEqual([{ text: 'Custom prompt' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unsupported for other built-in commands like /quit', async () => {
|
||||
const mockQuitCommand = {
|
||||
name: 'quit',
|
||||
description: 'Quit application',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockQuitCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/quit',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
if (result.type === 'unsupported') {
|
||||
expect(result.reason).toContain('/quit');
|
||||
expect(result.reason).toContain('not supported');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle command with no action', async () => {
|
||||
const mockCommand = {
|
||||
name: 'noaction',
|
||||
description: 'Command without action',
|
||||
kind: CommandKind.FILE,
|
||||
// No action property
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/noaction',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('no_command');
|
||||
});
|
||||
|
||||
it('should return message when command returns void', async () => {
|
||||
const mockCommand = {
|
||||
name: 'voidcmd',
|
||||
description: 'Command that returns void',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/voidcmd',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
if (result.type === 'message') {
|
||||
expect(result.content).toBe('Command executed successfully.');
|
||||
expect(result.messageType).toBe('info');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { PartListUnion } from '@google/genai';
|
||||
import { parseSlashCommand } from './utils/commands.js';
|
||||
import {
|
||||
FatalInputError,
|
||||
Logger,
|
||||
uiTelemetryService,
|
||||
type Config,
|
||||
@@ -19,10 +18,164 @@ import {
|
||||
CommandKind,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
} from './ui/commands/types.js';
|
||||
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
/**
|
||||
* Built-in commands that are allowed in non-interactive modes (CLI and ACP).
|
||||
* Only safe, read-only commands that don't require interactive UI.
|
||||
*
|
||||
* These commands are:
|
||||
* - init: Initialize project configuration
|
||||
* - summary: Generate session summary
|
||||
* - compress: Compress conversation history
|
||||
*/
|
||||
export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
|
||||
'init',
|
||||
'summary',
|
||||
'compress',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Result of handling a slash command in non-interactive mode.
|
||||
*
|
||||
* Supported types:
|
||||
* - 'submit_prompt': Submits content to the model (supports all modes)
|
||||
* - 'message': Returns a single message (supports non-interactive JSON/text only)
|
||||
* - 'stream_messages': Streams multiple messages (supports ACP only)
|
||||
* - 'unsupported': Command cannot be executed in this mode
|
||||
* - 'no_command': No command was found or executed
|
||||
*/
|
||||
export type NonInteractiveSlashCommandResult =
|
||||
| {
|
||||
type: 'submit_prompt';
|
||||
content: PartListUnion;
|
||||
}
|
||||
| {
|
||||
type: 'message';
|
||||
messageType: 'info' | 'error';
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
type: 'stream_messages';
|
||||
messages: AsyncGenerator<
|
||||
{ messageType: 'info' | 'error'; content: string },
|
||||
void,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
| {
|
||||
type: 'unsupported';
|
||||
reason: string;
|
||||
originalType: string;
|
||||
}
|
||||
| {
|
||||
type: 'no_command';
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a SlashCommandActionReturn to a NonInteractiveSlashCommandResult.
|
||||
*
|
||||
* Only the following result types are supported in non-interactive mode:
|
||||
* - submit_prompt: Submits content to the model (all modes)
|
||||
* - message: Returns a single message (non-interactive JSON/text only)
|
||||
* - stream_messages: Streams multiple messages (ACP only)
|
||||
*
|
||||
* All other result types are converted to 'unsupported'.
|
||||
*
|
||||
* @param result The result from executing a slash command action
|
||||
* @returns A NonInteractiveSlashCommandResult describing the outcome
|
||||
*/
|
||||
function handleCommandResult(
|
||||
result: SlashCommandActionReturn,
|
||||
): NonInteractiveSlashCommandResult {
|
||||
switch (result.type) {
|
||||
case 'submit_prompt':
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: result.content,
|
||||
};
|
||||
|
||||
case 'message':
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: result.messageType,
|
||||
content: result.content,
|
||||
};
|
||||
|
||||
case 'stream_messages':
|
||||
return {
|
||||
type: 'stream_messages',
|
||||
messages: result.messages,
|
||||
};
|
||||
|
||||
/**
|
||||
* Currently return types below are never generated due to the
|
||||
* whitelist of allowed slash commands in ACP and non-interactive mode.
|
||||
* We'll try to add more supported return types in the future.
|
||||
*/
|
||||
case 'tool':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason:
|
||||
'Tool execution from slash commands is not supported in non-interactive mode.',
|
||||
originalType: 'tool',
|
||||
};
|
||||
|
||||
case 'quit':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason:
|
||||
'Quit command is not supported in non-interactive mode. The process will exit naturally after completion.',
|
||||
originalType: 'quit',
|
||||
};
|
||||
|
||||
case 'dialog':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason: `Dialog '${result.dialog}' cannot be opened in non-interactive mode.`,
|
||||
originalType: 'dialog',
|
||||
};
|
||||
|
||||
case 'load_history':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason:
|
||||
'Loading history is not supported in non-interactive mode. Each invocation starts with a fresh context.',
|
||||
originalType: 'load_history',
|
||||
};
|
||||
|
||||
case 'confirm_shell_commands':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason:
|
||||
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
|
||||
originalType: 'confirm_shell_commands',
|
||||
};
|
||||
|
||||
case 'confirm_action':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason:
|
||||
'Action confirmation is not supported in non-interactive mode. Commands requiring confirmation cannot be executed.',
|
||||
originalType: 'confirm_action',
|
||||
};
|
||||
|
||||
default: {
|
||||
// Exhaustiveness check
|
||||
const _exhaustive: never = result;
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason: `Unknown command result type: ${(_exhaustive as SlashCommandActionReturn).type}`,
|
||||
originalType: 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters commands based on the allowed built-in command names.
|
||||
@@ -62,122 +215,146 @@ function filterCommandsForNonInteractive(
|
||||
* @param config The configuration object
|
||||
* @param settings The loaded settings
|
||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||
* allowed. If not provided or empty, only file commands are available.
|
||||
* @returns A Promise that resolves to `PartListUnion` if a valid command is
|
||||
* found and results in a prompt, or `undefined` otherwise.
|
||||
* @throws {FatalInputError} if the command result is not supported in
|
||||
* non-interactive mode.
|
||||
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
|
||||
* Pass an empty array to only allow file commands.
|
||||
* @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing
|
||||
* the outcome of the command execution.
|
||||
*/
|
||||
export const handleSlashCommand = async (
|
||||
rawQuery: string,
|
||||
abortController: AbortController,
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
): Promise<PartListUnion | undefined> => {
|
||||
allowedBuiltinCommandNames: string[] = [
|
||||
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||
],
|
||||
): Promise<NonInteractiveSlashCommandResult> => {
|
||||
const trimmed = rawQuery.trim();
|
||||
if (!trimmed.startsWith('/')) {
|
||||
return;
|
||||
return { type: 'no_command' };
|
||||
}
|
||||
|
||||
const isAcpMode = config.getExperimentalZedIntegration();
|
||||
const isInteractive = config.isInteractive();
|
||||
|
||||
const executionMode = isAcpMode
|
||||
? 'acp'
|
||||
: isInteractive
|
||||
? 'interactive'
|
||||
: 'non_interactive';
|
||||
|
||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
|
||||
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
||||
const loaders =
|
||||
allowedBuiltinSet.size > 0
|
||||
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
|
||||
: [new FileCommandLoader(config)];
|
||||
// Load all commands to check if the command exists but is not allowed
|
||||
const allLoaders = [
|
||||
new BuiltinCommandLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
];
|
||||
|
||||
const commandService = await CommandService.create(
|
||||
loaders,
|
||||
allLoaders,
|
||||
abortController.signal,
|
||||
);
|
||||
const commands = commandService.getCommands();
|
||||
const allCommands = commandService.getCommands();
|
||||
const filteredCommands = filterCommandsForNonInteractive(
|
||||
commands,
|
||||
allCommands,
|
||||
allowedBuiltinSet,
|
||||
);
|
||||
|
||||
// First, try to parse with filtered commands
|
||||
const { commandToExecute, args } = parseSlashCommand(
|
||||
rawQuery,
|
||||
filteredCommands,
|
||||
);
|
||||
|
||||
if (commandToExecute) {
|
||||
if (commandToExecute.action) {
|
||||
// Not used by custom commands but may be in the future.
|
||||
const sessionStats: SessionStatsState = {
|
||||
sessionId: config?.getSessionId(),
|
||||
sessionStartTime: new Date(),
|
||||
metrics: uiTelemetryService.getMetrics(),
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 1,
|
||||
if (!commandToExecute) {
|
||||
// Check if this is a known command that's just not allowed
|
||||
const { commandToExecute: knownCommand } = parseSlashCommand(
|
||||
rawQuery,
|
||||
allCommands,
|
||||
);
|
||||
|
||||
if (knownCommand) {
|
||||
// Command exists but is not allowed in non-interactive mode
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason: t(
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.',
|
||||
{ command: knownCommand.name },
|
||||
),
|
||||
originalType: 'filtered_command',
|
||||
};
|
||||
|
||||
const logger = new Logger(config?.getSessionId() || '', config?.storage);
|
||||
|
||||
const context: CommandContext = {
|
||||
services: {
|
||||
config,
|
||||
settings,
|
||||
git: undefined,
|
||||
logger,
|
||||
},
|
||||
ui: createNonInteractiveUI(),
|
||||
session: {
|
||||
stats: sessionStats,
|
||||
sessionShellAllowlist: new Set(),
|
||||
},
|
||||
invocation: {
|
||||
raw: trimmed,
|
||||
name: commandToExecute.name,
|
||||
args,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await commandToExecute.action(context, args);
|
||||
|
||||
if (result) {
|
||||
switch (result.type) {
|
||||
case 'submit_prompt':
|
||||
return result.content;
|
||||
case 'confirm_shell_commands':
|
||||
// This result indicates a command attempted to confirm shell commands.
|
||||
// However note that currently, ShellTool is excluded in non-interactive
|
||||
// mode unless 'YOLO mode' is active, so confirmation actually won't
|
||||
// occur because of YOLO mode.
|
||||
// This ensures that if a command *does* request confirmation (e.g.
|
||||
// in the future with more granular permissions), it's handled appropriately.
|
||||
throw new FatalInputError(
|
||||
'Exiting due to a confirmation prompt requested by the command.',
|
||||
);
|
||||
default:
|
||||
throw new FatalInputError(
|
||||
'Exiting due to command result that is not supported in non-interactive mode.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'no_command' };
|
||||
}
|
||||
|
||||
return;
|
||||
if (!commandToExecute.action) {
|
||||
return { type: 'no_command' };
|
||||
}
|
||||
|
||||
// Not used by custom commands but may be in the future.
|
||||
const sessionStats: SessionStatsState = {
|
||||
sessionId: config?.getSessionId(),
|
||||
sessionStartTime: new Date(),
|
||||
metrics: uiTelemetryService.getMetrics(),
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 1,
|
||||
};
|
||||
|
||||
const logger = new Logger(config?.getSessionId() || '', config?.storage);
|
||||
|
||||
const context: CommandContext = {
|
||||
executionMode,
|
||||
services: {
|
||||
config,
|
||||
settings,
|
||||
git: undefined,
|
||||
logger,
|
||||
},
|
||||
ui: createNonInteractiveUI(),
|
||||
session: {
|
||||
stats: sessionStats,
|
||||
sessionShellAllowlist: new Set(),
|
||||
},
|
||||
invocation: {
|
||||
raw: trimmed,
|
||||
name: commandToExecute.name,
|
||||
args,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await commandToExecute.action(context, args);
|
||||
|
||||
if (!result) {
|
||||
// Command executed but returned no result (e.g., void return)
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Command executed successfully.',
|
||||
};
|
||||
}
|
||||
|
||||
// Handle different result types
|
||||
return handleCommandResult(result);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves all available slash commands for the current configuration.
|
||||
*
|
||||
* @param config The configuration object
|
||||
* @param settings The loaded settings
|
||||
* @param abortSignal Signal to cancel the loading process
|
||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||
* allowed. If not provided or empty, only file commands are available.
|
||||
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
|
||||
* Pass an empty array to only include file commands.
|
||||
* @returns A Promise that resolves to an array of SlashCommand objects
|
||||
*/
|
||||
export const getAvailableCommands = async (
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
abortSignal: AbortSignal,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
allowedBuiltinCommandNames: string[] = [
|
||||
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||
],
|
||||
): Promise<SlashCommand[]> => {
|
||||
try {
|
||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
||||
@@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
resumeCommand,
|
||||
statsCommand,
|
||||
summaryCommand,
|
||||
themeCommand,
|
||||
|
||||
@@ -28,7 +28,7 @@ const mockPrompt = {
|
||||
{ name: 'trail', required: false, description: "The animal's trail." },
|
||||
],
|
||||
invoke: vi.fn().mockResolvedValue({
|
||||
messages: [{ content: { text: 'Hello, world!' } }],
|
||||
messages: [{ content: { type: 'text', text: 'Hello, world!' } }],
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -123,7 +123,10 @@ export class McpPromptLoader implements ICommandLoader {
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.messages?.[0]?.content?.['text']) {
|
||||
const firstMessage = result.messages?.[0];
|
||||
const content = firstMessage?.content;
|
||||
|
||||
if (content?.type !== 'text') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
@@ -134,7 +137,7 @@ export class McpPromptLoader implements ICommandLoader {
|
||||
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: JSON.stringify(result.messages[0].content.text),
|
||||
content: JSON.stringify(content.text),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import type { InitializationResult } from '../core/initializer.js';
|
||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
import {
|
||||
UIActionsContext,
|
||||
@@ -56,7 +55,6 @@ vi.mock('./App.js', () => ({
|
||||
App: TestContextConsumer,
|
||||
}));
|
||||
|
||||
vi.mock('./hooks/useQuotaAndFallback.js');
|
||||
vi.mock('./hooks/useHistoryManager.js');
|
||||
vi.mock('./hooks/useThemeCommand.js');
|
||||
vi.mock('./auth/useAuth.js');
|
||||
@@ -122,7 +120,6 @@ describe('AppContainer State Management', () => {
|
||||
let mockInitResult: InitializationResult;
|
||||
|
||||
// Create typed mocks for all hooks
|
||||
const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock;
|
||||
const mockedUseHistory = useHistory as Mock;
|
||||
const mockedUseThemeCommand = useThemeCommand as Mock;
|
||||
const mockedUseAuthCommand = useAuthCommand as Mock;
|
||||
@@ -164,10 +161,6 @@ describe('AppContainer State Management', () => {
|
||||
capturedUIActions = null!;
|
||||
|
||||
// **Provide a default return value for EVERY mocked hook.**
|
||||
mockedUseQuotaAndFallback.mockReturnValue({
|
||||
proQuotaRequest: null,
|
||||
handleProQuotaChoice: vi.fn(),
|
||||
});
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [],
|
||||
addItem: vi.fn(),
|
||||
@@ -567,75 +560,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quota and Fallback Integration', () => {
|
||||
it('passes a null proQuotaRequest to UIStateContext by default', () => {
|
||||
// The default mock from beforeEach already sets proQuotaRequest to null
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert that the context value is as expected
|
||||
expect(capturedUIState.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', () => {
|
||||
// Arrange: Create a mock request object that a UI dialog would receive
|
||||
const mockRequest = {
|
||||
failedModel: 'gemini-pro',
|
||||
fallbackModel: 'gemini-flash',
|
||||
resolve: vi.fn(),
|
||||
};
|
||||
mockedUseQuotaAndFallback.mockReturnValue({
|
||||
proQuotaRequest: mockRequest,
|
||||
handleProQuotaChoice: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert: The mock request is correctly passed through the context
|
||||
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
|
||||
});
|
||||
|
||||
it('passes the handleProQuotaChoice function to UIActionsContext', () => {
|
||||
// Arrange: Create a mock handler function
|
||||
const mockHandler = vi.fn();
|
||||
mockedUseQuotaAndFallback.mockReturnValue({
|
||||
proQuotaRequest: null,
|
||||
handleProQuotaChoice: mockHandler,
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert: The action in the context is the mock handler we provided
|
||||
expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler);
|
||||
|
||||
// You can even verify that the plumbed function is callable
|
||||
capturedUIActions.handleProQuotaChoice('auth');
|
||||
expect(mockHandler).toHaveBeenCalledWith('auth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Terminal Title Update Feature', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mock stdout for each test
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
type Config,
|
||||
type IdeInfo,
|
||||
type IdeContext,
|
||||
type UserTierId,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
IdeClient,
|
||||
ideContextStore,
|
||||
@@ -48,11 +47,11 @@ import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useAuthCommand } from './auth/useAuth.js';
|
||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
||||
import { useResumeCommand } from './hooks/useResumeCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
@@ -191,8 +190,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
|
||||
|
||||
const [userTier] = useState<UserTierId | undefined>(undefined);
|
||||
|
||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||
|
||||
const [userMessages, setUserMessages] = useState<string[]>([]);
|
||||
@@ -203,7 +200,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { stdout } = useStdout();
|
||||
|
||||
// Additional hooks moved from App.tsx
|
||||
const { stats: sessionStats } = useSessionStats();
|
||||
const { stats: sessionStats, startNewSession } = useSessionStats();
|
||||
const logger = useLogger(config.storage, sessionStats.sessionId);
|
||||
const branchName = useGitBranchName(config.getTargetDir());
|
||||
|
||||
@@ -366,14 +363,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
cancelAuthentication,
|
||||
} = useAuthCommand(settings, config, historyManager.addItem);
|
||||
|
||||
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
|
||||
config,
|
||||
historyManager,
|
||||
userTier,
|
||||
setAuthState,
|
||||
setModelSwitchedFromQuotaError,
|
||||
});
|
||||
|
||||
useInitializationAuthError(initializationResult.authError, onAuthError);
|
||||
|
||||
// Sync user tier from config when authentication changes
|
||||
@@ -435,6 +424,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||
useModelCommand();
|
||||
|
||||
const {
|
||||
isResumeDialogOpen,
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
} = useResumeCommand({
|
||||
config,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
remount: refreshStatic,
|
||||
});
|
||||
|
||||
const {
|
||||
showWorkspaceMigrationDialog,
|
||||
workspaceExtensions,
|
||||
@@ -488,6 +489,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openResumeDialog,
|
||||
}),
|
||||
[
|
||||
openAuthDialog,
|
||||
@@ -502,6 +504,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openResumeDialog,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -737,8 +740,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
!initError &&
|
||||
!isProcessing &&
|
||||
(streamingState === StreamingState.Idle ||
|
||||
streamingState === StreamingState.Responding) &&
|
||||
!proQuotaRequest;
|
||||
streamingState === StreamingState.Responding);
|
||||
|
||||
const [controlsHeight, setControlsHeight] = useState(0);
|
||||
|
||||
@@ -1191,10 +1193,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isAuthenticating ||
|
||||
isEditorDialogOpen ||
|
||||
showIdeRestartPrompt ||
|
||||
!!proQuotaRequest ||
|
||||
isSubagentCreateDialogOpen ||
|
||||
isAgentsManagerDialogOpen ||
|
||||
isApprovalModeDialogOpen;
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen;
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
@@ -1222,6 +1224,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isModelDialogOpen,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
isResumeDialogOpen,
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
@@ -1260,8 +1263,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
showWorkspaceMigrationDialog,
|
||||
workspaceExtensions,
|
||||
currentModel,
|
||||
userTier,
|
||||
proQuotaRequest,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -1312,6 +1313,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isModelDialogOpen,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
isResumeDialogOpen,
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
@@ -1349,8 +1351,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
showAutoAcceptIndicator,
|
||||
showWorkspaceMigrationDialog,
|
||||
workspaceExtensions,
|
||||
userTier,
|
||||
proQuotaRequest,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -1412,7 +1412,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
handleClearScreen,
|
||||
onWorkspaceMigrationDialogOpen,
|
||||
onWorkspaceMigrationDialogClose,
|
||||
handleProQuotaChoice,
|
||||
// Vision switch dialog
|
||||
handleVisionSwitchSelect,
|
||||
// Welcome back dialog
|
||||
@@ -1421,6 +1420,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -1446,13 +1449,16 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
handleClearScreen,
|
||||
onWorkspaceMigrationDialogOpen,
|
||||
onWorkspaceMigrationDialogClose,
|
||||
handleProQuotaChoice,
|
||||
handleVisionSwitchSelect,
|
||||
handleWelcomeBackSelection,
|
||||
handleWelcomeBackClose,
|
||||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'foobar';
|
||||
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
|
||||
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
@@ -212,7 +212,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('should show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to use api key', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'foobar';
|
||||
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
|
||||
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
@@ -504,12 +504,12 @@ describe('AuthDialog', () => {
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
||||
security: { auth: { selectedType: AuthType.USE_OPENAI } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
originalSettings: {
|
||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
||||
security: { auth: { selectedType: AuthType.USE_OPENAI } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
|
||||
@@ -225,16 +225,26 @@ export const useAuthCommand = (
|
||||
const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE'];
|
||||
if (
|
||||
defaultAuthType &&
|
||||
![AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].includes(
|
||||
defaultAuthType as AuthType,
|
||||
)
|
||||
![
|
||||
AuthType.QWEN_OAUTH,
|
||||
AuthType.USE_OPENAI,
|
||||
AuthType.USE_ANTHROPIC,
|
||||
AuthType.USE_GEMINI,
|
||||
AuthType.USE_VERTEX_AI,
|
||||
].includes(defaultAuthType as AuthType)
|
||||
) {
|
||||
onAuthError(
|
||||
t(
|
||||
'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}',
|
||||
{
|
||||
value: defaultAuthType,
|
||||
validValues: [AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', '),
|
||||
validValues: [
|
||||
AuthType.QWEN_OAUTH,
|
||||
AuthType.USE_OPENAI,
|
||||
AuthType.USE_ANTHROPIC,
|
||||
AuthType.USE_GEMINI,
|
||||
AuthType.USE_VERTEX_AI,
|
||||
].join(', '),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -19,7 +19,9 @@ export const compressCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const { ui } = context;
|
||||
if (ui.pendingItem) {
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
|
||||
if (executionMode === 'interactive' && ui.pendingItem) {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
@@ -40,13 +42,80 @@ export const compressCommand: SlashCommand = {
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
ui.setPendingItem(pendingMessage);
|
||||
const config = context.services.config;
|
||||
const geminiClient = config?.getGeminiClient();
|
||||
if (!config || !geminiClient) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const doCompress = async () => {
|
||||
const promptId = `compress-${Date.now()}`;
|
||||
const compressed = await context.services.config
|
||||
?.getGeminiClient()
|
||||
?.tryCompressChat(promptId, true);
|
||||
if (compressed) {
|
||||
return await geminiClient.tryCompressChat(promptId, true);
|
||||
};
|
||||
|
||||
if (executionMode === 'acp') {
|
||||
const messages = async function* () {
|
||||
try {
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: 'Compressing context...',
|
||||
};
|
||||
const compressed = await doCompress();
|
||||
if (!compressed) {
|
||||
yield {
|
||||
messageType: 'error' as const,
|
||||
content: t('Failed to compress chat history.'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
|
||||
};
|
||||
} catch (e) {
|
||||
yield {
|
||||
messageType: 'error' as const,
|
||||
content: t('Failed to compress chat history: {{error}}', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return { type: 'stream_messages', messages: messages() };
|
||||
}
|
||||
|
||||
try {
|
||||
if (executionMode === 'interactive') {
|
||||
ui.setPendingItem(pendingMessage);
|
||||
}
|
||||
|
||||
const compressed = await doCompress();
|
||||
|
||||
if (!compressed) {
|
||||
if (executionMode === 'interactive') {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Failed to compress chat history.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Failed to compress chat history.'),
|
||||
};
|
||||
}
|
||||
|
||||
if (executionMode === 'interactive') {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.COMPRESSION,
|
||||
@@ -59,27 +128,39 @@ export const compressCommand: SlashCommand = {
|
||||
} as HistoryItemCompression,
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
|
||||
};
|
||||
} catch (e) {
|
||||
if (executionMode === 'interactive') {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Failed to compress chat history.'),
|
||||
text: t('Failed to compress chat history: {{error}}', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Failed to compress chat history: {{error}}', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Failed to compress chat history: {{error}}', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
ui.setPendingItem(null);
|
||||
if (executionMode === 'interactive') {
|
||||
ui.setPendingItem(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -130,7 +130,7 @@ export const directoryCommand: SlashCommand = {
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
|
||||
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}',
|
||||
{
|
||||
directories: added.join('\n- '),
|
||||
},
|
||||
|
||||
@@ -15,7 +15,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof core>();
|
||||
return {
|
||||
...original,
|
||||
getOauthClient: vi.fn(original.getOauthClient),
|
||||
getIdeInstaller: vi.fn(original.getIdeInstaller),
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('restoreCommand', () => {
|
||||
).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine the .gemini directory path.',
|
||||
content: 'Could not determine the .qwen directory path.',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ async function restoreAction(
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine the .gemini directory path.',
|
||||
content: 'Could not determine the .qwen directory path.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
38
packages/cli/src/ui/commands/resumeCommand.test.ts
Normal file
38
packages/cli/src/ui/commands/resumeCommand.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { resumeCommand } from './resumeCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('resumeCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should return a dialog action to open the resume dialog', async () => {
|
||||
// Ensure the command has an action to test.
|
||||
if (!resumeCommand.action) {
|
||||
throw new Error('The resume command must have an action.');
|
||||
}
|
||||
|
||||
const result = await resumeCommand.action(mockContext, '');
|
||||
|
||||
// Assert that the action returns the correct object to trigger the resume dialog.
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'resume',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(resumeCommand.name).toBe('resume');
|
||||
expect(resumeCommand.description).toBe('Resume a previous session');
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/ui/commands/resumeCommand.ts
Normal file
21
packages/cli/src/ui/commands/resumeCommand.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const resumeCommand: SlashCommand = {
|
||||
name: 'resume',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
get description() {
|
||||
return t('Resume a previous session');
|
||||
},
|
||||
action: async (): Promise<SlashCommandActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'resume',
|
||||
}),
|
||||
};
|
||||
@@ -26,6 +26,8 @@ export const summaryCommand: SlashCommand = {
|
||||
action: async (context): Promise<SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
const { ui } = context;
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -43,8 +45,8 @@ export const summaryCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
// Check if already generating summary
|
||||
if (ui.pendingItem) {
|
||||
// Check if already generating summary (interactive UI only)
|
||||
if (executionMode === 'interactive' && ui.pendingItem) {
|
||||
ui.addItem(
|
||||
{
|
||||
type: 'error' as const,
|
||||
@@ -63,29 +65,22 @@ export const summaryCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the current chat history
|
||||
const getChatHistory = () => {
|
||||
const chat = geminiClient.getChat();
|
||||
const history = chat.getHistory();
|
||||
return chat.getHistory();
|
||||
};
|
||||
|
||||
const validateChatHistory = (
|
||||
history: ReturnType<typeof getChatHistory>,
|
||||
) => {
|
||||
if (history.length <= 2) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('No conversation found to summarize.'),
|
||||
};
|
||||
throw new Error(t('No conversation found to summarize.'));
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state
|
||||
const pendingMessage: HistoryItemSummary = {
|
||||
type: 'summary',
|
||||
summary: {
|
||||
isPending: true,
|
||||
stage: 'generating',
|
||||
},
|
||||
};
|
||||
ui.setPendingItem(pendingMessage);
|
||||
|
||||
const generateSummaryMarkdown = async (
|
||||
history: ReturnType<typeof getChatHistory>,
|
||||
): Promise<string> => {
|
||||
// Build the conversation context for summary generation
|
||||
const conversationContext = history.map((message) => ({
|
||||
role: message.role,
|
||||
@@ -121,19 +116,21 @@ export const summaryCommand: SlashCommand = {
|
||||
|
||||
if (!markdownSummary) {
|
||||
throw new Error(
|
||||
'Failed to generate summary - no text content received from LLM response',
|
||||
t(
|
||||
'Failed to generate summary - no text content received from LLM response',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update loading message to show saving progress
|
||||
ui.setPendingItem({
|
||||
type: 'summary',
|
||||
summary: {
|
||||
isPending: true,
|
||||
stage: 'saving',
|
||||
},
|
||||
});
|
||||
return markdownSummary;
|
||||
};
|
||||
|
||||
const saveSummaryToDisk = async (
|
||||
markdownSummary: string,
|
||||
): Promise<{
|
||||
filePathForDisplay: string;
|
||||
fullPath: string;
|
||||
}> => {
|
||||
// Ensure .qwen directory exists
|
||||
const projectRoot = config.getProjectRoot();
|
||||
const qwenDir = path.join(projectRoot, '.qwen');
|
||||
@@ -155,45 +152,163 @@ export const summaryCommand: SlashCommand = {
|
||||
|
||||
await fsPromises.writeFile(summaryPath, summaryContent, 'utf8');
|
||||
|
||||
// Clear pending item and show success message
|
||||
return {
|
||||
filePathForDisplay: '.qwen/PROJECT_SUMMARY.md',
|
||||
fullPath: summaryPath,
|
||||
};
|
||||
};
|
||||
|
||||
const emitInteractivePending = (stage: 'generating' | 'saving') => {
|
||||
if (executionMode !== 'interactive') {
|
||||
return;
|
||||
}
|
||||
const pendingMessage: HistoryItemSummary = {
|
||||
type: 'summary',
|
||||
summary: {
|
||||
isPending: true,
|
||||
stage,
|
||||
},
|
||||
};
|
||||
ui.setPendingItem(pendingMessage);
|
||||
};
|
||||
|
||||
const completeInteractive = (filePathForDisplay: string) => {
|
||||
if (executionMode !== 'interactive') {
|
||||
return;
|
||||
}
|
||||
ui.setPendingItem(null);
|
||||
const completedSummaryItem: HistoryItemSummary = {
|
||||
type: 'summary',
|
||||
summary: {
|
||||
isPending: false,
|
||||
stage: 'completed',
|
||||
filePath: '.qwen/PROJECT_SUMMARY.md',
|
||||
filePath: filePathForDisplay,
|
||||
},
|
||||
};
|
||||
ui.addItem(completedSummaryItem, Date.now());
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: '', // Empty content since we show the message in UI component
|
||||
};
|
||||
} catch (error) {
|
||||
// Clear pending item on error
|
||||
const formatErrorMessage = (error: unknown): string =>
|
||||
t('Failed to generate project context summary: {{error}}', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
const failInteractive = (error: unknown) => {
|
||||
if (executionMode !== 'interactive') {
|
||||
return;
|
||||
}
|
||||
ui.setPendingItem(null);
|
||||
ui.addItem(
|
||||
{
|
||||
type: 'error' as const,
|
||||
text: `❌ ${t(
|
||||
'Failed to generate project context summary: {{error}}',
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
)}`,
|
||||
text: `❌ ${formatErrorMessage(error)}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
};
|
||||
|
||||
const formatSuccessMessage = (filePathForDisplay: string): string =>
|
||||
t('Saved project summary to {{filePathForDisplay}}.', {
|
||||
filePathForDisplay,
|
||||
});
|
||||
|
||||
const returnNoConversationMessage = (): SlashCommandActionReturn => {
|
||||
const msg = t('No conversation found to summarize.');
|
||||
if (executionMode === 'acp') {
|
||||
const messages = async function* () {
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: msg,
|
||||
};
|
||||
};
|
||||
return {
|
||||
type: 'stream_messages',
|
||||
messages: messages(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: msg,
|
||||
};
|
||||
};
|
||||
|
||||
const executeSummaryGeneration = async (
|
||||
history: ReturnType<typeof getChatHistory>,
|
||||
): Promise<{
|
||||
markdownSummary: string;
|
||||
filePathForDisplay: string;
|
||||
}> => {
|
||||
emitInteractivePending('generating');
|
||||
const markdownSummary = await generateSummaryMarkdown(history);
|
||||
emitInteractivePending('saving');
|
||||
const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary);
|
||||
completeInteractive(filePathForDisplay);
|
||||
return { markdownSummary, filePathForDisplay };
|
||||
};
|
||||
|
||||
// Validate chat history once at the beginning
|
||||
const history = getChatHistory();
|
||||
try {
|
||||
validateChatHistory(history);
|
||||
} catch (_error) {
|
||||
return returnNoConversationMessage();
|
||||
}
|
||||
|
||||
if (executionMode === 'acp') {
|
||||
const messages = async function* () {
|
||||
try {
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: t('Generating project summary...'),
|
||||
};
|
||||
|
||||
const { filePathForDisplay } =
|
||||
await executeSummaryGeneration(history);
|
||||
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: formatSuccessMessage(filePathForDisplay),
|
||||
};
|
||||
} catch (error) {
|
||||
failInteractive(error);
|
||||
yield {
|
||||
messageType: 'error' as const,
|
||||
content: formatErrorMessage(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'stream_messages',
|
||||
messages: messages(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { filePathForDisplay } = await executeSummaryGeneration(history);
|
||||
|
||||
if (executionMode === 'non_interactive') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: formatSuccessMessage(filePathForDisplay),
|
||||
};
|
||||
}
|
||||
|
||||
// Interactive mode: UI components already display progress and completion.
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: '',
|
||||
};
|
||||
} catch (error) {
|
||||
failInteractive(error);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Failed to generate project context summary: {{error}}', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
content: formatErrorMessage(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -22,6 +22,14 @@ import type {
|
||||
|
||||
// Grouped dependencies for clarity and easier mocking
|
||||
export interface CommandContext {
|
||||
/**
|
||||
* Execution mode for the current invocation.
|
||||
*
|
||||
* - interactive: React/Ink UI mode
|
||||
* - non_interactive: non-interactive CLI mode (text/json)
|
||||
* - acp: ACP/Zed integration mode
|
||||
*/
|
||||
executionMode?: 'interactive' | 'non_interactive' | 'acp';
|
||||
// Invocation properties for when commands are called.
|
||||
invocation?: {
|
||||
/** The raw, untrimmed input string from the user. */
|
||||
@@ -108,6 +116,19 @@ export interface MessageActionReturn {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that streams multiple messages.
|
||||
* Used for long-running operations that need to send progress updates.
|
||||
*/
|
||||
export interface StreamMessagesActionReturn {
|
||||
type: 'stream_messages';
|
||||
messages: AsyncGenerator<
|
||||
{ messageType: 'info' | 'error'; content: string },
|
||||
void,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that needs to open a dialog.
|
||||
*/
|
||||
@@ -124,7 +145,8 @@ export interface OpenDialogActionReturn {
|
||||
| 'subagent_create'
|
||||
| 'subagent_list'
|
||||
| 'permissions'
|
||||
| 'approval-mode';
|
||||
| 'approval-mode'
|
||||
| 'resume';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +195,7 @@ export interface ConfirmActionReturn {
|
||||
export type SlashCommandActionReturn =
|
||||
| ToolActionReturn
|
||||
| MessageActionReturn
|
||||
| StreamMessagesActionReturn
|
||||
| QuitActionReturn
|
||||
| OpenDialogActionReturn
|
||||
| LoadHistoryActionReturn
|
||||
|
||||
@@ -17,7 +17,6 @@ import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
|
||||
@@ -36,6 +35,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
||||
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -86,15 +86,6 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.proQuotaRequest) {
|
||||
return (
|
||||
<ProQuotaDialog
|
||||
failedModel={uiState.proQuotaRequest.failedModel}
|
||||
fallbackModel={uiState.proQuotaRequest.fallbackModel}
|
||||
onChoice={uiActions.handleProQuotaChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowIdePrompt) {
|
||||
return (
|
||||
<IdeIntegrationNudge
|
||||
@@ -290,5 +281,16 @@ export const DialogManager = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isResumeDialogOpen) {
|
||||
return (
|
||||
<SessionPicker
|
||||
sessionService={config.getSessionService()}
|
||||
currentBranch={uiState.branchName}
|
||||
onSelect={uiActions.handleResume}
|
||||
onCancel={uiActions.closeResumeDialog}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -115,7 +115,7 @@ export function PermissionsModifyTrustDialog({
|
||||
{needsRestart && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
To apply the trust changes, Gemini CLI must be restarted. Press
|
||||
To apply the trust changes, Qwen Code must be restarted. Press
|
||||
'r' to restart CLI now.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
|
||||
// Mock the child component to make it easier to test the parent
|
||||
vi.mock('./shared/RadioButtonSelect.js', () => ({
|
||||
RadioButtonSelect: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ProQuotaDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with correct title and options', () => {
|
||||
const { lastFrame } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
onChoice={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Pro quota limit reached for gemini-2.5-pro.');
|
||||
|
||||
// Check that RadioButtonSelect was called with the correct items
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Change auth (executes the /auth command)',
|
||||
value: 'auth',
|
||||
key: 'auth',
|
||||
},
|
||||
{
|
||||
label: `Continue with gemini-2.5-flash`,
|
||||
value: 'continue',
|
||||
key: 'continue',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onChoice with "auth" when "Change auth" is selected', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Get the onSelect function passed to RadioButtonSelect
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
|
||||
// Simulate the selection
|
||||
onSelect('auth');
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('auth');
|
||||
});
|
||||
|
||||
it('should call onChoice with "continue" when "Continue with flash" is selected', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Get the onSelect function passed to RadioButtonSelect
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
|
||||
// Simulate the selection
|
||||
onSelect('continue');
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('continue');
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface ProQuotaDialogProps {
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
onChoice: (choice: 'auth' | 'continue') => void;
|
||||
}
|
||||
|
||||
export function ProQuotaDialog({
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
onChoice,
|
||||
}: ProQuotaDialogProps): React.JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: t('Change auth (executes the /auth command)'),
|
||||
value: 'auth' as const,
|
||||
key: 'auth',
|
||||
},
|
||||
{
|
||||
label: t('Continue with {{model}}', { model: fallbackModel }),
|
||||
value: 'continue' as const,
|
||||
key: 'continue',
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelect = (choice: 'auth' | 'continue') => {
|
||||
onChoice(choice);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
<Text bold color={theme.status.warning}>
|
||||
{t('Pro quota limit reached for {{model}}.', { model: failedModel })}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={1}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { render, Box, Text, useInput, useApp } from 'ink';
|
||||
import {
|
||||
SessionService,
|
||||
type SessionListItem,
|
||||
type ListSessionsResult,
|
||||
getGitBranch,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatRelativeTime } from '../utils/formatters.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface SessionPickerProps {
|
||||
sessionService: SessionService;
|
||||
currentBranch?: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates text to fit within a given width, adding ellipsis if needed.
|
||||
*/
|
||||
function truncateText(text: string, maxWidth: number): string {
|
||||
if (text.length <= maxWidth) return text;
|
||||
if (maxWidth <= 3) return text.slice(0, maxWidth);
|
||||
return text.slice(0, maxWidth - 3) + '...';
|
||||
}
|
||||
|
||||
function SessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}: SessionPickerProps): React.JSX.Element {
|
||||
const { exit } = useApp();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [sessionState, setSessionState] = useState<{
|
||||
sessions: SessionListItem[];
|
||||
hasMore: boolean;
|
||||
nextCursor?: number;
|
||||
}>({
|
||||
sessions: [],
|
||||
hasMore: true,
|
||||
nextCursor: undefined,
|
||||
});
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
const [filterByBranch, setFilterByBranch] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const [terminalSize, setTerminalSize] = useState({
|
||||
width: process.stdout.columns || 80,
|
||||
height: process.stdout.rows || 24,
|
||||
});
|
||||
|
||||
// Update terminal size on resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setTerminalSize({
|
||||
width: process.stdout.columns || 80,
|
||||
height: process.stdout.rows || 24,
|
||||
});
|
||||
};
|
||||
process.stdout.on('resize', handleResize);
|
||||
return () => {
|
||||
process.stdout.off('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Filter sessions by current branch if filter is enabled
|
||||
const filteredSessions =
|
||||
filterByBranch && currentBranch
|
||||
? sessionState.sessions.filter(
|
||||
(session) => session.gitBranch === currentBranch,
|
||||
)
|
||||
: sessionState.sessions;
|
||||
|
||||
const hasSentinel = sessionState.hasMore;
|
||||
|
||||
// Reset selection when filter changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filterByBranch]);
|
||||
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
|
||||
isLoadingMoreRef.current = true;
|
||||
try {
|
||||
const result: ListSessionsResult = await sessionService.listSessions({
|
||||
size: PAGE_SIZE,
|
||||
cursor: sessionState.nextCursor,
|
||||
});
|
||||
|
||||
setSessionState((prev) => ({
|
||||
sessions: [...prev.sessions, ...result.items],
|
||||
hasMore: result.hasMore && result.nextCursor !== undefined,
|
||||
nextCursor: result.nextCursor,
|
||||
}));
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
|
||||
|
||||
// Calculate visible items
|
||||
// Reserved space: header (1), footer (1), separators (2), borders (2)
|
||||
const reservedLines = 6;
|
||||
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
|
||||
// On average, this is ~3 lines per item, but the last item has no margin
|
||||
const itemHeight = 3;
|
||||
const maxVisibleItems = Math.max(
|
||||
1,
|
||||
Math.floor((terminalSize.height - reservedLines) / itemHeight),
|
||||
);
|
||||
|
||||
// Calculate scroll offset
|
||||
const scrollOffset = (() => {
|
||||
if (filteredSessions.length <= maxVisibleItems) return 0;
|
||||
const halfVisible = Math.floor(maxVisibleItems / 2);
|
||||
let offset = selectedIndex - halfVisible;
|
||||
offset = Math.max(0, offset);
|
||||
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
|
||||
return offset;
|
||||
})();
|
||||
|
||||
const visibleSessions = filteredSessions.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxVisibleItems,
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown =
|
||||
scrollOffset + maxVisibleItems < filteredSessions.length;
|
||||
|
||||
// Sentinel (invisible) sits after the last session item; consider it visible
|
||||
// once the viewport reaches the final real item.
|
||||
const sentinelVisible =
|
||||
hasSentinel && scrollOffset + maxVisibleItems >= filteredSessions.length;
|
||||
|
||||
// Load more when sentinel enters view or when filtered list is empty.
|
||||
useEffect(() => {
|
||||
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
|
||||
|
||||
const shouldLoadMore =
|
||||
filteredSessions.length === 0 ||
|
||||
sentinelVisible ||
|
||||
isLoadingMoreRef.current;
|
||||
|
||||
if (shouldLoadMore) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
}, [
|
||||
filteredSessions.length,
|
||||
loadMoreSessions,
|
||||
sessionState.hasMore,
|
||||
sentinelVisible,
|
||||
]);
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
// Ignore input if already exiting
|
||||
if (isExiting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape or Ctrl+C to cancel
|
||||
if (key.escape || (key.ctrl && input === 'c')) {
|
||||
setIsExiting(true);
|
||||
onCancel();
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
const session = filteredSessions[selectedIndex];
|
||||
if (session) {
|
||||
setIsExiting(true);
|
||||
onSelect(session.sessionId);
|
||||
exit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow || input === 'k') {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow || input === 'j') {
|
||||
if (filteredSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
setSelectedIndex((prev) =>
|
||||
Math.min(filteredSessions.length - 1, prev + 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === 'b' || input === 'B') {
|
||||
if (currentBranch) {
|
||||
setFilterByBranch((prev) => !prev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Filtered sessions may have changed, ensure selectedIndex is valid
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedIndex >= filteredSessions.length &&
|
||||
filteredSessions.length > 0
|
||||
) {
|
||||
setSelectedIndex(filteredSessions.length - 1);
|
||||
}
|
||||
}, [filteredSessions.length, selectedIndex]);
|
||||
|
||||
// Calculate content width (terminal width minus border padding)
|
||||
const contentWidth = terminalSize.width - 4;
|
||||
const promptMaxWidth = contentWidth - 4; // Account for "› " prefix
|
||||
|
||||
// Return empty while exiting to prevent visual glitches
|
||||
if (isExiting) {
|
||||
return <Box />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={terminalSize.width}
|
||||
height={terminalSize.height - 1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Main container with single border */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
width={terminalSize.width}
|
||||
height={terminalSize.height - 1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header row */}
|
||||
<Box paddingX={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Resume Session
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Separator line */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>
|
||||
{'─'.repeat(terminalSize.width - 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session list with auto-scrolling */}
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
||||
{filteredSessions.length === 0 ? (
|
||||
<Box paddingY={1} justifyContent="center">
|
||||
<Text color={theme.text.secondary}>
|
||||
{filterByBranch
|
||||
? `No sessions found for branch "${currentBranch}"`
|
||||
: 'No sessions found'}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
visibleSessions.map((session, visibleIndex) => {
|
||||
const actualIndex = scrollOffset + visibleIndex;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
const isFirst = visibleIndex === 0;
|
||||
const isLast = visibleIndex === visibleSessions.length - 1;
|
||||
const timeAgo = formatRelativeTime(session.mtime);
|
||||
const messageText =
|
||||
session.messageCount === 1
|
||||
? '1 message'
|
||||
: `${session.messageCount} messages`;
|
||||
|
||||
// Show scroll indicator on first/last visible items
|
||||
const showUpIndicator = isFirst && showScrollUp;
|
||||
const showDownIndicator = isLast && showScrollDown;
|
||||
|
||||
// Determine the prefix: selector takes priority over scroll indicator
|
||||
const prefix = isSelected
|
||||
? '› '
|
||||
: showUpIndicator
|
||||
? '↑ '
|
||||
: showDownIndicator
|
||||
? '↓ '
|
||||
: ' ';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={session.sessionId}
|
||||
flexDirection="column"
|
||||
marginBottom={isLast ? 0 : 1}
|
||||
>
|
||||
{/* First line: prefix (selector or scroll indicator) + prompt text */}
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
isSelected
|
||||
? theme.text.accent
|
||||
: showUpIndicator || showDownIndicator
|
||||
? theme.text.secondary
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{prefix}
|
||||
</Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
>
|
||||
{truncateText(
|
||||
session.prompt || '(empty prompt)',
|
||||
promptMaxWidth,
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Second line: metadata (aligned with prompt text) */}
|
||||
<Box>
|
||||
<Text>{' '}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{timeAgo} · {messageText}
|
||||
{session.gitBranch && ` · ${session.gitBranch}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Separator line */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>
|
||||
{'─'.repeat(terminalSize.width - 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer with keyboard shortcuts */}
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentBranch && (
|
||||
<>
|
||||
<Text
|
||||
bold={filterByBranch}
|
||||
color={filterByBranch ? theme.text.accent : undefined}
|
||||
>
|
||||
B
|
||||
</Text>
|
||||
{' to toggle branch · '}
|
||||
</>
|
||||
)}
|
||||
{'↑↓ to navigate · Esc to cancel'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the terminal screen.
|
||||
*/
|
||||
function clearScreen(): void {
|
||||
// Move cursor to home position and clear screen
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an interactive session picker and returns the selected session ID.
|
||||
* Returns undefined if the user cancels or no sessions are available.
|
||||
*/
|
||||
export async function showResumeSessionPicker(
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<string | undefined> {
|
||||
const sessionService = new SessionService(cwd);
|
||||
const hasSession = await sessionService.loadLastSession();
|
||||
if (!hasSession) {
|
||||
console.log('No sessions found. Start a new session with `qwen`.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currentBranch = getGitBranch(cwd);
|
||||
|
||||
// Clear the screen before showing the picker for a clean fullscreen experience
|
||||
clearScreen();
|
||||
|
||||
// Enable raw mode for keyboard input if not already enabled
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY && !wasRaw) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
let selectedId: string | undefined;
|
||||
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<SessionPicker
|
||||
sessionService={sessionService}
|
||||
currentBranch={currentBranch}
|
||||
onSelect={(id) => {
|
||||
selectedId = id;
|
||||
}}
|
||||
onCancel={() => {
|
||||
selectedId = undefined;
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
exitOnCtrlC: false,
|
||||
},
|
||||
);
|
||||
|
||||
waitUntilExit().then(() => {
|
||||
unmount();
|
||||
|
||||
// Clear the screen after the picker closes for a clean fullscreen experience
|
||||
clearScreen();
|
||||
|
||||
// Restore raw mode state only if we changed it and user cancelled
|
||||
// (if user selected a session, main app will handle raw mode)
|
||||
if (process.stdin.isTTY && !wasRaw && !selectedId) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
|
||||
resolve(selectedId);
|
||||
});
|
||||
});
|
||||
}
|
||||
251
packages/cli/src/ui/components/SessionPicker.tsx
Normal file
251
packages/cli/src/ui/components/SessionPicker.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
SessionListItem as SessionData,
|
||||
SessionService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useSessionPicker } from '../hooks/useSessionPicker.js';
|
||||
import { formatRelativeTime } from '../utils/formatters.js';
|
||||
import {
|
||||
formatMessageCount,
|
||||
truncateText,
|
||||
} from '../utils/sessionPickerUtils.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export interface SessionPickerProps {
|
||||
sessionService: SessionService | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
currentBranch?: string;
|
||||
|
||||
/**
|
||||
* Scroll mode. When true, keep selection centered (fullscreen-style).
|
||||
* Defaults to true so dialog + standalone behave identically.
|
||||
*/
|
||||
centerSelection?: boolean;
|
||||
}
|
||||
|
||||
const PREFIX_CHARS = {
|
||||
selected: '› ',
|
||||
scrollUp: '↑ ',
|
||||
scrollDown: '↓ ',
|
||||
normal: ' ',
|
||||
};
|
||||
|
||||
interface SessionListItemViewProps {
|
||||
session: SessionData;
|
||||
isSelected: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
showScrollUp: boolean;
|
||||
showScrollDown: boolean;
|
||||
maxPromptWidth: number;
|
||||
prefixChars?: {
|
||||
selected: string;
|
||||
scrollUp: string;
|
||||
scrollDown: string;
|
||||
normal: string;
|
||||
};
|
||||
boldSelectedPrefix?: boolean;
|
||||
}
|
||||
|
||||
function SessionListItemView({
|
||||
session,
|
||||
isSelected,
|
||||
isFirst,
|
||||
isLast,
|
||||
showScrollUp,
|
||||
showScrollDown,
|
||||
maxPromptWidth,
|
||||
prefixChars = PREFIX_CHARS,
|
||||
boldSelectedPrefix = true,
|
||||
}: SessionListItemViewProps): React.JSX.Element {
|
||||
const timeAgo = formatRelativeTime(session.mtime);
|
||||
const messageText = formatMessageCount(session.messageCount);
|
||||
|
||||
const showUpIndicator = isFirst && showScrollUp;
|
||||
const showDownIndicator = isLast && showScrollDown;
|
||||
|
||||
const prefix = isSelected
|
||||
? prefixChars.selected
|
||||
: showUpIndicator
|
||||
? prefixChars.scrollUp
|
||||
: showDownIndicator
|
||||
? prefixChars.scrollDown
|
||||
: prefixChars.normal;
|
||||
|
||||
const promptText = session.prompt || '(empty prompt)';
|
||||
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={isLast ? 0 : 1}>
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
isSelected
|
||||
? theme.text.accent
|
||||
: showUpIndicator || showDownIndicator
|
||||
? theme.text.secondary
|
||||
: undefined
|
||||
}
|
||||
bold={isSelected && boldSelectedPrefix}
|
||||
>
|
||||
{prefix}
|
||||
</Text>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
bold={isSelected}
|
||||
>
|
||||
{truncatedPrompt}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{timeAgo} · {messageText}
|
||||
{session.gitBranch && ` · ${session.gitBranch}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionPicker(props: SessionPickerProps) {
|
||||
const {
|
||||
sessionService,
|
||||
onSelect,
|
||||
onCancel,
|
||||
currentBranch,
|
||||
centerSelection = true,
|
||||
} = props;
|
||||
|
||||
const { columns: width, rows: height } = useTerminalSize();
|
||||
|
||||
// Calculate box width (width + 6 for border padding)
|
||||
const boxWidth = width + 6;
|
||||
// Calculate visible items (same heuristic as before)
|
||||
// Reserved space: header (1), footer (1), separators (2), borders (2)
|
||||
const reservedLines = 6;
|
||||
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
|
||||
const itemHeight = 3;
|
||||
const maxVisibleItems = Math.max(
|
||||
1,
|
||||
Math.floor((height - reservedLines) / itemHeight),
|
||||
);
|
||||
|
||||
const picker = useSessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
maxVisibleItems,
|
||||
centerSelection,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={boxWidth}
|
||||
height={height - 1}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
width={boxWidth}
|
||||
height={height - 1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header row */}
|
||||
<Box paddingX={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Resume Session')}
|
||||
</Text>
|
||||
{picker.filterByBranch && currentBranch && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
{t('(branch: {{branch}})', { branch: currentBranch })}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Separator */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session list */}
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
||||
{!sessionService || picker.isLoading ? (
|
||||
<Box paddingY={1} justifyContent="center">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Loading sessions...')}
|
||||
</Text>
|
||||
</Box>
|
||||
) : picker.filteredSessions.length === 0 ? (
|
||||
<Box paddingY={1} justifyContent="center">
|
||||
<Text color={theme.text.secondary}>
|
||||
{picker.filterByBranch
|
||||
? t('No sessions found for branch "{{branch}}"', {
|
||||
branch: currentBranch ?? '',
|
||||
})
|
||||
: t('No sessions found')}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
picker.visibleSessions.map((session, visibleIndex) => {
|
||||
const actualIndex = picker.scrollOffset + visibleIndex;
|
||||
return (
|
||||
<SessionListItemView
|
||||
key={session.sessionId}
|
||||
session={session}
|
||||
isSelected={actualIndex === picker.selectedIndex}
|
||||
isFirst={visibleIndex === 0}
|
||||
isLast={visibleIndex === picker.visibleSessions.length - 1}
|
||||
showScrollUp={picker.showScrollUp}
|
||||
showScrollDown={picker.showScrollDown}
|
||||
maxPromptWidth={width}
|
||||
prefixChars={PREFIX_CHARS}
|
||||
boldSelectedPrefix={false}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Separator */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box paddingX={1}>
|
||||
<Box flexDirection="row">
|
||||
{currentBranch && (
|
||||
<Text color={theme.text.secondary}>
|
||||
<Text
|
||||
bold={picker.filterByBranch}
|
||||
color={picker.filterByBranch ? theme.text.accent : undefined}
|
||||
>
|
||||
B
|
||||
</Text>
|
||||
{t(' to toggle branch')} ·
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('↑↓ to navigate · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
624
packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
Normal file
624
packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
Normal file
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
import type {
|
||||
SessionListItem,
|
||||
ListSessionsResult,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actual = await vi.importActual('@qwen-code/qwen-code-core');
|
||||
return {
|
||||
...actual,
|
||||
getGitBranch: vi.fn().mockReturnValue('main'),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock terminal size
|
||||
const mockTerminalSize = { columns: 80, rows: 24 };
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process.stdout, 'columns', {
|
||||
value: mockTerminalSize.columns,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, 'rows', {
|
||||
value: mockTerminalSize.rows,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create mock sessions
|
||||
function createMockSession(
|
||||
overrides: Partial<SessionListItem> = {},
|
||||
): SessionListItem {
|
||||
return {
|
||||
sessionId: 'test-session-id',
|
||||
cwd: '/test/path',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
mtime: Date.now(),
|
||||
prompt: 'Test prompt',
|
||||
gitBranch: 'main',
|
||||
filePath: '/test/path/sessions/test-session-id.jsonl',
|
||||
messageCount: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create mock session service
|
||||
function createMockSessionService(
|
||||
sessions: SessionListItem[] = [],
|
||||
hasMore = false,
|
||||
) {
|
||||
return {
|
||||
listSessions: vi.fn().mockResolvedValue({
|
||||
items: sessions,
|
||||
hasMore,
|
||||
nextCursor: hasMore ? Date.now() : undefined,
|
||||
} as ListSessionsResult),
|
||||
loadSession: vi.fn(),
|
||||
loadLastSession: vi
|
||||
.fn()
|
||||
.mockResolvedValue(sessions.length > 0 ? {} : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionPicker', () => {
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Empty Sessions', () => {
|
||||
it('should show sessions with 0 messages', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 'empty-1',
|
||||
messageCount: 0,
|
||||
prompt: '',
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 'with-messages',
|
||||
messageCount: 5,
|
||||
prompt: 'Hello',
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 'empty-2',
|
||||
messageCount: 0,
|
||||
prompt: '(empty prompt)',
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Hello');
|
||||
// Should show empty sessions too (rendered as "(empty prompt)" + "0 messages")
|
||||
expect(output).toContain('0 messages');
|
||||
});
|
||||
|
||||
it('should show sessions even when all sessions are empty', async () => {
|
||||
const sessions = [
|
||||
createMockSession({ sessionId: 'empty-1', messageCount: 0 }),
|
||||
createMockSession({ sessionId: 'empty-2', messageCount: 0 }),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('0 messages');
|
||||
});
|
||||
|
||||
it('should show sessions with 1 or more messages', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 'one-msg',
|
||||
messageCount: 1,
|
||||
prompt: 'Single message',
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 'many-msg',
|
||||
messageCount: 10,
|
||||
prompt: 'Many messages',
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Single message');
|
||||
expect(output).toContain('Many messages');
|
||||
expect(output).toContain('1 message');
|
||||
expect(output).toContain('10 messages');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Branch Filtering', () => {
|
||||
it('should filter by branch when B is pressed', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 's1',
|
||||
gitBranch: 'main',
|
||||
prompt: 'Main branch',
|
||||
messageCount: 1,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's2',
|
||||
gitBranch: 'feature',
|
||||
prompt: 'Feature branch',
|
||||
messageCount: 1,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's3',
|
||||
gitBranch: 'main',
|
||||
prompt: 'Also main',
|
||||
messageCount: 1,
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame, stdin } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
currentBranch="main"
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// All sessions should be visible initially
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('Main branch');
|
||||
expect(output).toContain('Feature branch');
|
||||
|
||||
// Press B to filter by branch
|
||||
stdin.write('B');
|
||||
await wait(50);
|
||||
|
||||
output = lastFrame();
|
||||
// Only main branch sessions should be visible
|
||||
expect(output).toContain('Main branch');
|
||||
expect(output).toContain('Also main');
|
||||
expect(output).not.toContain('Feature branch');
|
||||
});
|
||||
|
||||
it('should combine empty session filter with branch filter', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 's1',
|
||||
gitBranch: 'main',
|
||||
messageCount: 0,
|
||||
prompt: 'Empty main',
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's2',
|
||||
gitBranch: 'main',
|
||||
messageCount: 5,
|
||||
prompt: 'Valid main',
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's3',
|
||||
gitBranch: 'feature',
|
||||
messageCount: 5,
|
||||
prompt: 'Valid feature',
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame, stdin } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
currentBranch="main"
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// Press B to filter by branch
|
||||
stdin.write('B');
|
||||
await wait(50);
|
||||
|
||||
const output = lastFrame();
|
||||
// Should only show sessions from main branch (including 0-message sessions)
|
||||
expect(output).toContain('Valid main');
|
||||
expect(output).toContain('Empty main');
|
||||
expect(output).not.toContain('Valid feature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Navigation', () => {
|
||||
it('should navigate with arrow keys', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 's1',
|
||||
prompt: 'First session',
|
||||
messageCount: 1,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's2',
|
||||
prompt: 'Second session',
|
||||
messageCount: 1,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's3',
|
||||
prompt: 'Third session',
|
||||
messageCount: 1,
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame, stdin } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// First session should be selected initially (indicated by >)
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('First session');
|
||||
|
||||
// Navigate down
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait(50);
|
||||
|
||||
output = lastFrame();
|
||||
// Selection indicator should move
|
||||
expect(output).toBeDefined();
|
||||
});
|
||||
|
||||
it('should navigate with vim keys (j/k)', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 's1',
|
||||
prompt: 'First',
|
||||
messageCount: 1,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's2',
|
||||
prompt: 'Second',
|
||||
messageCount: 1,
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// Navigate with j (down)
|
||||
stdin.write('j');
|
||||
await wait(50);
|
||||
|
||||
// Navigate with k (up)
|
||||
stdin.write('k');
|
||||
await wait(50);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should select session on Enter', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 'selected-session',
|
||||
prompt: 'Select me',
|
||||
messageCount: 1,
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// Press Enter to select
|
||||
stdin.write('\r');
|
||||
await wait(50);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('selected-session');
|
||||
});
|
||||
|
||||
it('should cancel on Escape', async () => {
|
||||
const sessions = [
|
||||
createMockSession({ sessionId: 's1', messageCount: 1 }),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// Press Escape to cancel
|
||||
stdin.write('\u001B');
|
||||
await wait(50);
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Display', () => {
|
||||
it('should show session metadata', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 's1',
|
||||
prompt: 'Test prompt text',
|
||||
messageCount: 5,
|
||||
gitBranch: 'feature-branch',
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Test prompt text');
|
||||
expect(output).toContain('5 messages');
|
||||
expect(output).toContain('feature-branch');
|
||||
});
|
||||
|
||||
it('should show header and footer', async () => {
|
||||
const sessions = [createMockSession({ messageCount: 1 })];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Resume Session');
|
||||
expect(output).toContain('↑↓ to navigate');
|
||||
expect(output).toContain('Esc to cancel');
|
||||
});
|
||||
|
||||
it('should show branch toggle hint when currentBranch is provided', async () => {
|
||||
const sessions = [createMockSession({ messageCount: 1 })];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
currentBranch="main"
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('B');
|
||||
expect(output).toContain('toggle branch');
|
||||
});
|
||||
|
||||
it('should truncate long prompts', async () => {
|
||||
const longPrompt = 'A'.repeat(300);
|
||||
const sessions = [
|
||||
createMockSession({ prompt: longPrompt, messageCount: 1 }),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
// Should contain ellipsis for truncated text
|
||||
expect(output).toContain('...');
|
||||
// Should NOT contain the full untruncated prompt (300 A's in a row)
|
||||
expect(output).not.toContain(longPrompt);
|
||||
});
|
||||
|
||||
it('should show "(empty prompt)" for sessions without prompt text', async () => {
|
||||
const sessions = [createMockSession({ prompt: '', messageCount: 1 })];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('(empty prompt)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('should load more sessions when scrolling to bottom', async () => {
|
||||
const firstPage = Array.from({ length: 5 }, (_, i) =>
|
||||
createMockSession({
|
||||
sessionId: `session-${i}`,
|
||||
prompt: `Session ${i}`,
|
||||
messageCount: 1,
|
||||
mtime: Date.now() - i * 1000,
|
||||
}),
|
||||
);
|
||||
const secondPage = Array.from({ length: 3 }, (_, i) =>
|
||||
createMockSession({
|
||||
sessionId: `session-${i + 5}`,
|
||||
prompt: `Session ${i + 5}`,
|
||||
messageCount: 1,
|
||||
mtime: Date.now() - (i + 5) * 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockService = {
|
||||
listSessions: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: firstPage,
|
||||
hasMore: true,
|
||||
nextCursor: Date.now() - 5000,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: secondPage,
|
||||
hasMore: false,
|
||||
nextCursor: undefined,
|
||||
}),
|
||||
loadSession: vi.fn(),
|
||||
loadLastSession: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(200);
|
||||
|
||||
// First page should be loaded
|
||||
expect(mockService.listSessions).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
122
packages/cli/src/ui/components/StandaloneSessionPicker.tsx
Normal file
122
packages/cli/src/ui/components/StandaloneSessionPicker.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { render, Box, useApp } from 'ink';
|
||||
import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface StandalonePickerScreenProps {
|
||||
sessionService: SessionService;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
currentBranch?: string;
|
||||
}
|
||||
|
||||
function StandalonePickerScreen({
|
||||
sessionService,
|
||||
onSelect,
|
||||
onCancel,
|
||||
currentBranch,
|
||||
}: StandalonePickerScreenProps): React.JSX.Element {
|
||||
const { exit } = useApp();
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const handleExit = () => {
|
||||
setIsExiting(true);
|
||||
exit();
|
||||
};
|
||||
|
||||
// Return empty while exiting to prevent visual glitches
|
||||
if (isExiting) {
|
||||
return <Box />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionPicker
|
||||
sessionService={sessionService}
|
||||
onSelect={(id) => {
|
||||
onSelect(id);
|
||||
handleExit();
|
||||
}}
|
||||
onCancel={() => {
|
||||
onCancel();
|
||||
handleExit();
|
||||
}}
|
||||
currentBranch={currentBranch}
|
||||
centerSelection={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the terminal screen.
|
||||
*/
|
||||
function clearScreen(): void {
|
||||
// Move cursor to home position and clear screen
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an interactive session picker and returns the selected session ID.
|
||||
* Returns undefined if the user cancels or no sessions are available.
|
||||
*/
|
||||
export async function showResumeSessionPicker(
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<string | undefined> {
|
||||
const sessionService = new SessionService(cwd);
|
||||
const hasSession = await sessionService.loadLastSession();
|
||||
if (!hasSession) {
|
||||
console.log('No sessions found. Start a new session with `qwen`.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Clear the screen before showing the picker for a clean fullscreen experience
|
||||
clearScreen();
|
||||
|
||||
// Enable raw mode for keyboard input if not already enabled
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY && !wasRaw) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
let selectedId: string | undefined;
|
||||
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<StandalonePickerScreen
|
||||
sessionService={sessionService}
|
||||
onSelect={(id) => {
|
||||
selectedId = id;
|
||||
}}
|
||||
onCancel={() => {
|
||||
selectedId = undefined;
|
||||
}}
|
||||
currentBranch={getGitBranch(cwd)}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
{
|
||||
exitOnCtrlC: false,
|
||||
},
|
||||
);
|
||||
|
||||
waitUntilExit().then(() => {
|
||||
unmount();
|
||||
|
||||
// Clear the screen after the picker closes for a clean fullscreen experience
|
||||
clearScreen();
|
||||
|
||||
// Restore raw mode state only if we changed it and user cancelled
|
||||
// (if user selected a session, main app will handle raw mode)
|
||||
if (process.stdin.isTTY && !wasRaw && !selectedId) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
|
||||
resolve(selectedId);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -55,7 +55,6 @@ export interface UIActions {
|
||||
handleClearScreen: () => void;
|
||||
onWorkspaceMigrationDialogOpen: () => void;
|
||||
onWorkspaceMigrationDialogClose: () => void;
|
||||
handleProQuotaChoice: (choice: 'auth' | 'continue') => void;
|
||||
// Vision switch dialog
|
||||
handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void;
|
||||
// Welcome back dialog
|
||||
@@ -64,6 +63,10 @@ export interface UIActions {
|
||||
// Subagent dialogs
|
||||
closeSubagentCreateDialog: () => void;
|
||||
closeAgentsManagerDialog: () => void;
|
||||
// Resume session dialog
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
handleResume: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -22,21 +22,13 @@ import type {
|
||||
AuthType,
|
||||
IdeContext,
|
||||
ApprovalMode,
|
||||
UserTierId,
|
||||
IdeInfo,
|
||||
FallbackIntent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { DOMElement } from 'ink';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import type { ExtensionUpdateState } from '../state/extensions.js';
|
||||
import type { UpdateObject } from '../utils/updateCheck.js';
|
||||
|
||||
export interface ProQuotaDialogRequest {
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
resolve: (intent: FallbackIntent) => void;
|
||||
}
|
||||
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
|
||||
@@ -60,6 +52,7 @@ export interface UIState {
|
||||
isModelDialogOpen: boolean;
|
||||
isPermissionsDialogOpen: boolean;
|
||||
isApprovalModeDialogOpen: boolean;
|
||||
isResumeDialogOpen: boolean;
|
||||
slashCommands: readonly SlashCommand[];
|
||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||
commandContext: CommandContext;
|
||||
@@ -98,8 +91,6 @@ export interface UIState {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
workspaceExtensions: any[]; // Extension[]
|
||||
// Quota-related state
|
||||
userTier: UserTierId | undefined;
|
||||
proQuotaRequest: ProQuotaDialogRequest | null;
|
||||
currentModel: string;
|
||||
contextFileNames: string[];
|
||||
errorCount: number;
|
||||
|
||||
@@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
||||
'clear',
|
||||
'reset',
|
||||
'new',
|
||||
'resume',
|
||||
]);
|
||||
|
||||
interface SlashCommandProcessorActions {
|
||||
@@ -66,6 +67,7 @@ interface SlashCommandProcessorActions {
|
||||
openModelDialog: () => void;
|
||||
openPermissionsDialog: () => void;
|
||||
openApprovalModeDialog: () => void;
|
||||
openResumeDialog: () => void;
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
setDebugMessage: (message: string) => void;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
@@ -417,6 +419,9 @@ export const useSlashCommandProcessor = (
|
||||
case 'approval-mode':
|
||||
actions.openApprovalModeDialog();
|
||||
return { type: 'handled' };
|
||||
case 'resume':
|
||||
actions.openResumeDialog();
|
||||
return { type: 'handled' };
|
||||
case 'help':
|
||||
return { type: 'handled' };
|
||||
default: {
|
||||
@@ -515,6 +520,13 @@ export const useSlashCommandProcessor = (
|
||||
true,
|
||||
);
|
||||
}
|
||||
case 'stream_messages': {
|
||||
// stream_messages is only used in ACP/Zed integration mode
|
||||
// and should not be returned in interactive UI mode
|
||||
throw new Error(
|
||||
'stream_messages result type is not supported in interactive mode',
|
||||
);
|
||||
}
|
||||
default: {
|
||||
const unhandled: never = result;
|
||||
throw new Error(
|
||||
|
||||
@@ -1323,7 +1323,7 @@ describe('useGeminiStream', () => {
|
||||
it('should call parseAndFormatApiError with the correct authType on stream initialization failure', async () => {
|
||||
// 1. Setup
|
||||
const mockError = new Error('Rate limit exceeded');
|
||||
const mockAuthType = AuthType.LOGIN_WITH_GOOGLE;
|
||||
const mockAuthType = AuthType.USE_VERTEX_AI;
|
||||
mockParseAndFormatApiError.mockClear();
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
@@ -1374,9 +1374,6 @@ describe('useGeminiStream', () => {
|
||||
expect(mockParseAndFormatApiError).toHaveBeenCalledWith(
|
||||
'Rate limit exceeded',
|
||||
mockAuthType,
|
||||
undefined,
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2493,9 +2490,6 @@ describe('useGeminiStream', () => {
|
||||
expect(mockParseAndFormatApiError).toHaveBeenCalledWith(
|
||||
{ message: 'Test error' },
|
||||
expect.any(String),
|
||||
undefined,
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
GitService,
|
||||
UnauthorizedError,
|
||||
UserPromptEvent,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
logConversationFinishedEvent,
|
||||
ConversationFinishedEvent,
|
||||
ApprovalMode,
|
||||
@@ -527,10 +526,15 @@ export const useGeminiStream = (
|
||||
return currentThoughtBuffer;
|
||||
}
|
||||
|
||||
const newThoughtBuffer = currentThoughtBuffer + thoughtText;
|
||||
let newThoughtBuffer = currentThoughtBuffer + thoughtText;
|
||||
|
||||
const pendingType = pendingHistoryItemRef.current?.type;
|
||||
const isPendingThought =
|
||||
pendingType === 'gemini_thought' ||
|
||||
pendingType === 'gemini_thought_content';
|
||||
|
||||
// If we're not already showing a thought, start a new one
|
||||
if (pendingHistoryItemRef.current?.type !== 'gemini_thought') {
|
||||
if (!isPendingThought) {
|
||||
// If there's a pending non-thought item, finalize it first
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
@@ -538,11 +542,37 @@ export const useGeminiStream = (
|
||||
setPendingHistoryItem({ type: 'gemini_thought', text: '' });
|
||||
}
|
||||
|
||||
// Update the existing thought message with accumulated content
|
||||
setPendingHistoryItem({
|
||||
type: 'gemini_thought',
|
||||
text: newThoughtBuffer,
|
||||
});
|
||||
// Split large thought messages for better rendering performance (same rationale
|
||||
// as regular content streaming). This helps avoid terminal flicker caused by
|
||||
// constantly re-rendering an ever-growing "pending" block.
|
||||
const splitPoint = findLastSafeSplitPoint(newThoughtBuffer);
|
||||
const nextPendingType: 'gemini_thought' | 'gemini_thought_content' =
|
||||
isPendingThought && pendingType === 'gemini_thought_content'
|
||||
? 'gemini_thought_content'
|
||||
: 'gemini_thought';
|
||||
|
||||
if (splitPoint === newThoughtBuffer.length) {
|
||||
// Update the existing thought message with accumulated content
|
||||
setPendingHistoryItem({
|
||||
type: nextPendingType,
|
||||
text: newThoughtBuffer,
|
||||
});
|
||||
} else {
|
||||
const beforeText = newThoughtBuffer.substring(0, splitPoint);
|
||||
const afterText = newThoughtBuffer.substring(splitPoint);
|
||||
addItem(
|
||||
{
|
||||
type: nextPendingType,
|
||||
text: beforeText,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
setPendingHistoryItem({
|
||||
type: 'gemini_thought_content',
|
||||
text: afterText,
|
||||
});
|
||||
newThoughtBuffer = afterText;
|
||||
}
|
||||
|
||||
// Also update the thought state for the loading indicator
|
||||
mergeThought(eventValue);
|
||||
@@ -600,9 +630,6 @@ export const useGeminiStream = (
|
||||
text: parseAndFormatApiError(
|
||||
eventValue.error,
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
undefined,
|
||||
config.getModel(),
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
),
|
||||
},
|
||||
userMessageTimestamp,
|
||||
@@ -654,6 +681,9 @@ export const useGeminiStream = (
|
||||
'Response stopped due to image safety violations.',
|
||||
[FinishReason.UNEXPECTED_TOOL_CALL]:
|
||||
'Response stopped due to unexpected tool call.',
|
||||
[FinishReason.IMAGE_PROHIBITED_CONTENT]:
|
||||
'Response stopped due to image prohibited content.',
|
||||
[FinishReason.NO_IMAGE]: 'Response stopped due to no image.',
|
||||
};
|
||||
|
||||
const message = finishReasonMessages[finishReason];
|
||||
@@ -770,11 +800,17 @@ export const useGeminiStream = (
|
||||
for await (const event of stream) {
|
||||
switch (event.type) {
|
||||
case ServerGeminiEventType.Thought:
|
||||
thoughtBuffer = handleThoughtEvent(
|
||||
event.value,
|
||||
thoughtBuffer,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
// If the thought has a subject, it's a discrete status update rather than
|
||||
// a streamed textual thought, so we update the thought state directly.
|
||||
if (event.value.subject) {
|
||||
setThought(event.value);
|
||||
} else {
|
||||
thoughtBuffer = handleThoughtEvent(
|
||||
event.value,
|
||||
thoughtBuffer,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case ServerGeminiEventType.Content:
|
||||
geminiMessageBuffer = handleContentEvent(
|
||||
@@ -845,6 +881,7 @@ export const useGeminiStream = (
|
||||
handleMaxSessionTurnsEvent,
|
||||
handleSessionTokenLimitExceededEvent,
|
||||
handleCitationEvent,
|
||||
setThought,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -987,9 +1024,6 @@ export const useGeminiStream = (
|
||||
text: parseAndFormatApiError(
|
||||
getErrorMessage(error) || 'Unknown error',
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
undefined,
|
||||
config.getModel(),
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
),
|
||||
},
|
||||
userMessageTimestamp,
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
type Config,
|
||||
type FallbackModelHandler,
|
||||
UserTierId,
|
||||
AuthType,
|
||||
isGenericQuotaExceededError,
|
||||
isProQuotaExceededError,
|
||||
makeFakeConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useQuotaAndFallback } from './useQuotaAndFallback.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { AuthState, MessageType } from '../types.js';
|
||||
|
||||
// Mock the error checking functions from the core package to control test scenarios
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
isGenericQuotaExceededError: vi.fn(),
|
||||
isProQuotaExceededError: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Use a type alias for SpyInstance as it's not directly exported
|
||||
type SpyInstance = ReturnType<typeof vi.spyOn>;
|
||||
|
||||
describe('useQuotaAndFallback', () => {
|
||||
let mockConfig: Config;
|
||||
let mockHistoryManager: UseHistoryManagerReturn;
|
||||
let mockSetAuthState: Mock;
|
||||
let mockSetModelSwitchedFromQuotaError: Mock;
|
||||
let setFallbackHandlerSpy: SpyInstance;
|
||||
|
||||
const mockedIsGenericQuotaExceededError = isGenericQuotaExceededError as Mock;
|
||||
const mockedIsProQuotaExceededError = isProQuotaExceededError as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = makeFakeConfig();
|
||||
|
||||
// Spy on the method that requires the private field and mock its return.
|
||||
// This is cleaner than modifying the config class for tests.
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
model: 'test-model',
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
});
|
||||
|
||||
mockHistoryManager = {
|
||||
addItem: vi.fn(),
|
||||
history: [],
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
};
|
||||
mockSetAuthState = vi.fn();
|
||||
mockSetModelSwitchedFromQuotaError = vi.fn();
|
||||
|
||||
setFallbackHandlerSpy = vi.spyOn(mockConfig, 'setFallbackModelHandler');
|
||||
vi.spyOn(mockConfig, 'setQuotaErrorOccurred');
|
||||
|
||||
mockedIsGenericQuotaExceededError.mockReturnValue(false);
|
||||
mockedIsProQuotaExceededError.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should register a fallback handler on initialization', () => {
|
||||
renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(setFallbackHandlerSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setFallbackHandlerSpy.mock.calls[0][0]).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
describe('Fallback Handler Logic', () => {
|
||||
// Helper function to render the hook and extract the registered handler
|
||||
const getRegisteredHandler = (
|
||||
userTier: UserTierId = UserTierId.FREE,
|
||||
): FallbackModelHandler => {
|
||||
renderHook(
|
||||
(props) =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: props.userTier,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
{ initialProps: { userTier } },
|
||||
);
|
||||
return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler;
|
||||
};
|
||||
|
||||
it('should return null and take no action if already in fallback mode', async () => {
|
||||
vi.spyOn(mockConfig, 'isInFallbackMode').mockReturnValue(true);
|
||||
const handler = getRegisteredHandler();
|
||||
const result = await handler('gemini-pro', 'gemini-flash', new Error());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null and take no action if authType is not LOGIN_WITH_GOOGLE', async () => {
|
||||
// Override the default mock from beforeEach for this specific test
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
model: 'test-model',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
});
|
||||
|
||||
const handler = getRegisteredHandler();
|
||||
const result = await handler('gemini-pro', 'gemini-flash', new Error());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Automatic Fallback Scenarios', () => {
|
||||
const testCases = [
|
||||
{
|
||||
errorType: 'generic',
|
||||
tier: UserTierId.FREE,
|
||||
expectedMessageSnippets: [
|
||||
'Automatically switching from model-A to model-B',
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorType: 'generic',
|
||||
tier: UserTierId.STANDARD, // Paid tier
|
||||
expectedMessageSnippets: [
|
||||
'Automatically switching from model-A to model-B',
|
||||
'switch to using a paid API key from AI Studio',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorType: 'other',
|
||||
tier: UserTierId.FREE,
|
||||
expectedMessageSnippets: [
|
||||
'Automatically switching from model-A to model-B for faster responses',
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorType: 'other',
|
||||
tier: UserTierId.LEGACY, // Paid tier
|
||||
expectedMessageSnippets: [
|
||||
'Automatically switching from model-A to model-B for faster responses',
|
||||
'switch to using a paid API key from AI Studio',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { errorType, tier, expectedMessageSnippets } of testCases) {
|
||||
it(`should handle ${errorType} error for ${tier} tier correctly`, async () => {
|
||||
mockedIsGenericQuotaExceededError.mockReturnValue(
|
||||
errorType === 'generic',
|
||||
);
|
||||
|
||||
const handler = getRegisteredHandler(tier);
|
||||
const result = await handler(
|
||||
'model-A',
|
||||
'model-B',
|
||||
new Error('quota exceeded'),
|
||||
);
|
||||
|
||||
// Automatic fallbacks should return 'stop'
|
||||
expect(result).toBe('stop');
|
||||
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: MessageType.INFO }),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
||||
.text;
|
||||
for (const snippet of expectedMessageSnippets) {
|
||||
expect(message).toContain(snippet);
|
||||
}
|
||||
|
||||
expect(mockSetModelSwitchedFromQuotaError).toHaveBeenCalledWith(true);
|
||||
expect(mockConfig.setQuotaErrorOccurred).toHaveBeenCalledWith(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Interactive Fallback (Pro Quota Error)', () => {
|
||||
beforeEach(() => {
|
||||
mockedIsProQuotaExceededError.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should set an interactive request and wait for user choice', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
// Call the handler but do not await it, to check the intermediate state
|
||||
const promise = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('pro quota'),
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
// The hook should now have a pending request for the UI to handle
|
||||
expect(result.current.proQuotaRequest).not.toBeNull();
|
||||
expect(result.current.proQuotaRequest?.failedModel).toBe('gemini-pro');
|
||||
|
||||
// Simulate the user choosing to continue with the fallback model
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('continue');
|
||||
});
|
||||
|
||||
// The original promise from the handler should now resolve
|
||||
const intent = await promise;
|
||||
expect(intent).toBe('retry');
|
||||
|
||||
// The pending request should be cleared from the state
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle race conditions by stopping subsequent requests', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
const promise1 = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('pro quota 1'),
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
const firstRequest = result.current.proQuotaRequest;
|
||||
expect(firstRequest).not.toBeNull();
|
||||
|
||||
const result2 = await handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('pro quota 2'),
|
||||
);
|
||||
|
||||
// The lock should have stopped the second request
|
||||
expect(result2).toBe('stop');
|
||||
expect(result.current.proQuotaRequest).toBe(firstRequest);
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('continue');
|
||||
});
|
||||
|
||||
const intent1 = await promise1;
|
||||
expect(intent1).toBe('retry');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleProQuotaChoice', () => {
|
||||
beforeEach(() => {
|
||||
mockedIsProQuotaExceededError.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should do nothing if there is no pending pro quota request', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('auth');
|
||||
});
|
||||
|
||||
expect(mockSetAuthState).not.toHaveBeenCalled();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve intent to "auth" and trigger auth state update', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
const promise = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('pro quota'),
|
||||
);
|
||||
await act(async () => {}); // Allow state to update
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('auth');
|
||||
});
|
||||
|
||||
const intent = await promise;
|
||||
expect(intent).toBe('auth');
|
||||
expect(mockSetAuthState).toHaveBeenCalledWith(AuthState.Updating);
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should resolve intent to "retry" and add info message on continue', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
// The first `addItem` call is for the initial quota error message
|
||||
const promise = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('pro quota'),
|
||||
);
|
||||
await act(async () => {}); // Allow state to update
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('continue');
|
||||
});
|
||||
|
||||
const intent = await promise;
|
||||
expect(intent).toBe('retry');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
|
||||
// Check for the second "Switched to fallback model" message
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[1][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain('Switched to fallback model.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
type Config,
|
||||
type FallbackModelHandler,
|
||||
type FallbackIntent,
|
||||
isGenericQuotaExceededError,
|
||||
isProQuotaExceededError,
|
||||
UserTierId,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { AuthState, MessageType } from '../types.js';
|
||||
import { type ProQuotaDialogRequest } from '../contexts/UIStateContext.js';
|
||||
|
||||
interface UseQuotaAndFallbackArgs {
|
||||
config: Config;
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
userTier: UserTierId | undefined;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
setModelSwitchedFromQuotaError: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function useQuotaAndFallback({
|
||||
config,
|
||||
historyManager,
|
||||
userTier,
|
||||
setAuthState,
|
||||
setModelSwitchedFromQuotaError,
|
||||
}: UseQuotaAndFallbackArgs) {
|
||||
const [proQuotaRequest, setProQuotaRequest] =
|
||||
useState<ProQuotaDialogRequest | null>(null);
|
||||
const isDialogPending = useRef(false);
|
||||
|
||||
// Set up Flash fallback handler
|
||||
useEffect(() => {
|
||||
const fallbackHandler: FallbackModelHandler = async (
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
error,
|
||||
): Promise<FallbackIntent | null> => {
|
||||
if (config.isInFallbackMode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fallbacks are currently only handled for OAuth users.
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
if (
|
||||
!contentGeneratorConfig ||
|
||||
contentGeneratorConfig.authType !== AuthType.LOGIN_WITH_GOOGLE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
|
||||
let message: string;
|
||||
|
||||
if (error && isProQuotaExceededError(error)) {
|
||||
// Pro Quota specific messages (Interactive)
|
||||
if (isPaidTier) {
|
||||
message = `⚡ You have reached your daily ${failedModel} quota limit.
|
||||
⚡ You can choose to authenticate with a paid API key or continue with the fallback model.
|
||||
⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
message = `⚡ You have reached your daily ${failedModel} quota limit.
|
||||
⚡ You can choose to authenticate with a paid API key or continue with the fallback model.
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
} else if (error && isGenericQuotaExceededError(error)) {
|
||||
// Generic Quota (Automatic fallback)
|
||||
const actionMessage = `⚡ You have reached your daily quota limit.\n⚡ Automatically switching from ${failedModel} to ${fallbackModel} for the remainder of this session.`;
|
||||
|
||||
if (isPaidTier) {
|
||||
message = `${actionMessage}
|
||||
⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
message = `${actionMessage}
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
} else {
|
||||
// Consecutive 429s or other errors (Automatic fallback)
|
||||
const actionMessage = `⚡ Automatically switching from ${failedModel} to ${fallbackModel} for faster responses for the remainder of this session.`;
|
||||
|
||||
if (isPaidTier) {
|
||||
message = `${actionMessage}
|
||||
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${failedModel} quota limit
|
||||
⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
message = `${actionMessage}
|
||||
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${failedModel} quota limit
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add message to UI history
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
config.setQuotaErrorOccurred(true);
|
||||
|
||||
// Interactive Fallback for Pro quota
|
||||
if (error && isProQuotaExceededError(error)) {
|
||||
if (isDialogPending.current) {
|
||||
return 'stop'; // A dialog is already active, so just stop this request.
|
||||
}
|
||||
isDialogPending.current = true;
|
||||
|
||||
const intent: FallbackIntent = await new Promise<FallbackIntent>(
|
||||
(resolve) => {
|
||||
setProQuotaRequest({
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
resolve,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
return 'stop';
|
||||
};
|
||||
|
||||
config.setFallbackModelHandler(fallbackHandler);
|
||||
}, [config, historyManager, userTier, setModelSwitchedFromQuotaError]);
|
||||
|
||||
const handleProQuotaChoice = useCallback(
|
||||
(choice: 'auth' | 'continue') => {
|
||||
if (!proQuotaRequest) return;
|
||||
|
||||
const intent: FallbackIntent = choice === 'auth' ? 'auth' : 'retry';
|
||||
proQuotaRequest.resolve(intent);
|
||||
setProQuotaRequest(null);
|
||||
isDialogPending.current = false; // Reset the flag here
|
||||
|
||||
if (choice === 'auth') {
|
||||
setAuthState(AuthState.Updating);
|
||||
} else {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Switched to fallback model. Tip: Press Ctrl+P (or Up Arrow) to recall your previous prompt and submit it again if you wish.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
},
|
||||
[proQuotaRequest, setAuthState, historyManager],
|
||||
);
|
||||
|
||||
return {
|
||||
proQuotaRequest,
|
||||
handleProQuotaChoice,
|
||||
};
|
||||
}
|
||||
@@ -411,7 +411,7 @@ describe('useQwenAuth', () => {
|
||||
expect(geminiResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||
|
||||
const { result: oauthResult } = renderHook(() =>
|
||||
useQwenAuth(AuthType.LOGIN_WITH_GOOGLE, true),
|
||||
useQwenAuth(AuthType.USE_OPENAI, true),
|
||||
);
|
||||
expect(oauthResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||
});
|
||||
|
||||
190
packages/cli/src/ui/hooks/useResumeCommand.test.ts
Normal file
190
packages/cli/src/ui/hooks/useResumeCommand.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useResumeCommand } from './useResumeCommand.js';
|
||||
|
||||
const resumeMocks = vi.hoisted(() => {
|
||||
let resolveLoadSession:
|
||||
| ((value: { conversation: unknown } | undefined) => void)
|
||||
| undefined;
|
||||
let pendingLoadSession:
|
||||
| Promise<{ conversation: unknown } | undefined>
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
createPendingLoadSession() {
|
||||
pendingLoadSession = new Promise((resolve) => {
|
||||
resolveLoadSession = resolve;
|
||||
});
|
||||
return pendingLoadSession;
|
||||
},
|
||||
resolvePendingLoadSession(value: { conversation: unknown } | undefined) {
|
||||
resolveLoadSession?.(value);
|
||||
},
|
||||
getPendingLoadSession() {
|
||||
return pendingLoadSession;
|
||||
},
|
||||
reset() {
|
||||
resolveLoadSession = undefined;
|
||||
pendingLoadSession = undefined;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/resumeHistoryUtils.js', () => ({
|
||||
buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => {
|
||||
class SessionService {
|
||||
constructor(_cwd: string) {}
|
||||
async loadSession(_sessionId: string) {
|
||||
return (
|
||||
resumeMocks.getPendingLoadSession() ??
|
||||
Promise.resolve({
|
||||
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SessionService,
|
||||
};
|
||||
});
|
||||
|
||||
describe('useResumeCommand', () => {
|
||||
it('should initialize with dialog closed', () => {
|
||||
const { result } = renderHook(() => useResumeCommand());
|
||||
|
||||
expect(result.current.isResumeDialogOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should open the dialog when openResumeDialog is called', () => {
|
||||
const { result } = renderHook(() => useResumeCommand());
|
||||
|
||||
act(() => {
|
||||
result.current.openResumeDialog();
|
||||
});
|
||||
|
||||
expect(result.current.isResumeDialogOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should close the dialog when closeResumeDialog is called', () => {
|
||||
const { result } = renderHook(() => useResumeCommand());
|
||||
|
||||
// Open the dialog first
|
||||
act(() => {
|
||||
result.current.openResumeDialog();
|
||||
});
|
||||
|
||||
expect(result.current.isResumeDialogOpen).toBe(true);
|
||||
|
||||
// Close the dialog
|
||||
act(() => {
|
||||
result.current.closeResumeDialog();
|
||||
});
|
||||
|
||||
expect(result.current.isResumeDialogOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should maintain stable function references across renders', () => {
|
||||
const { result, rerender } = renderHook(() => useResumeCommand());
|
||||
|
||||
const initialOpenFn = result.current.openResumeDialog;
|
||||
const initialCloseFn = result.current.closeResumeDialog;
|
||||
const initialHandleResume = result.current.handleResume;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.openResumeDialog).toBe(initialOpenFn);
|
||||
expect(result.current.closeResumeDialog).toBe(initialCloseFn);
|
||||
expect(result.current.handleResume).toBe(initialHandleResume);
|
||||
});
|
||||
|
||||
it('handleResume no-ops when config is null', async () => {
|
||||
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
|
||||
const startNewSession = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResumeCommand({
|
||||
config: null,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleResume('session-1');
|
||||
});
|
||||
|
||||
expect(startNewSession).not.toHaveBeenCalled();
|
||||
expect(historyManager.clearItems).not.toHaveBeenCalled();
|
||||
expect(historyManager.loadHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handleResume closes the dialog immediately and restores session state', async () => {
|
||||
resumeMocks.reset();
|
||||
resumeMocks.createPendingLoadSession();
|
||||
|
||||
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
|
||||
const startNewSession = vi.fn();
|
||||
const geminiClient = {
|
||||
initialize: vi.fn(),
|
||||
};
|
||||
|
||||
const config = {
|
||||
getTargetDir: () => '/tmp',
|
||||
getGeminiClient: () => geminiClient,
|
||||
startNewSession: vi.fn(),
|
||||
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResumeCommand({
|
||||
config,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
}),
|
||||
);
|
||||
|
||||
// Open first so we can verify the dialog closes immediately.
|
||||
act(() => {
|
||||
result.current.openResumeDialog();
|
||||
});
|
||||
expect(result.current.isResumeDialogOpen).toBe(true);
|
||||
|
||||
let resumePromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
// Start resume but do not await it yet — we want to assert the dialog
|
||||
// closes immediately before the async session load completes.
|
||||
resumePromise = result.current.handleResume('session-2') as unknown as
|
||||
| Promise<void>
|
||||
| undefined;
|
||||
});
|
||||
expect(result.current.isResumeDialogOpen).toBe(false);
|
||||
|
||||
// Now finish the async load and let the handler complete.
|
||||
resumeMocks.resolvePendingLoadSession({
|
||||
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
});
|
||||
await act(async () => {
|
||||
await resumePromise;
|
||||
});
|
||||
|
||||
expect(config.startNewSession).toHaveBeenCalledWith(
|
||||
'session-2',
|
||||
expect.objectContaining({
|
||||
conversation: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(startNewSession).toHaveBeenCalledWith('session-2');
|
||||
expect(geminiClient.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(historyManager.clearItems).toHaveBeenCalledTimes(1);
|
||||
expect(historyManager.loadHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
82
packages/cli/src/ui/hooks/useResumeCommand.ts
Normal file
82
packages/cli/src/ui/hooks/useResumeCommand.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
|
||||
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
|
||||
export interface UseResumeCommandOptions {
|
||||
config: Config | null;
|
||||
historyManager: Pick<UseHistoryManagerReturn, 'clearItems' | 'loadHistory'>;
|
||||
startNewSession: (sessionId: string) => void;
|
||||
remount?: () => void;
|
||||
}
|
||||
|
||||
export interface UseResumeCommandResult {
|
||||
isResumeDialogOpen: boolean;
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
handleResume: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export function useResumeCommand(
|
||||
options?: UseResumeCommandOptions,
|
||||
): UseResumeCommandResult {
|
||||
const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false);
|
||||
|
||||
const openResumeDialog = useCallback(() => {
|
||||
setIsResumeDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeResumeDialog = useCallback(() => {
|
||||
setIsResumeDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const { config, historyManager, startNewSession, remount } = options ?? {};
|
||||
|
||||
const handleResume = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!config || !historyManager || !startNewSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dialog immediately to prevent input capture during async operations.
|
||||
closeResumeDialog();
|
||||
|
||||
const cwd = config.getTargetDir();
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadSession(sessionId);
|
||||
|
||||
if (!sessionData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start new session in UI context.
|
||||
startNewSession(sessionId);
|
||||
|
||||
// Reset UI history.
|
||||
const uiHistoryItems = buildResumedHistoryItems(sessionData, config);
|
||||
historyManager.clearItems();
|
||||
historyManager.loadHistory(uiHistoryItems);
|
||||
|
||||
// Update session history core.
|
||||
config.startNewSession(sessionId, sessionData);
|
||||
await config.getGeminiClient()?.initialize?.();
|
||||
|
||||
// Refresh terminal UI.
|
||||
remount?.();
|
||||
},
|
||||
[closeResumeDialog, config, historyManager, startNewSession, remount],
|
||||
);
|
||||
|
||||
return {
|
||||
isResumeDialogOpen,
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
};
|
||||
}
|
||||
279
packages/cli/src/ui/hooks/useSessionPicker.ts
Normal file
279
packages/cli/src/ui/hooks/useSessionPicker.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Unified session picker hook for both dialog and standalone modes.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - Uses KeypressContext (`useKeypress`) so it behaves correctly inside the main app.
|
||||
* - Standalone mode should wrap the picker in `<KeypressProvider>` when rendered
|
||||
* outside the main app.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
ListSessionsResult,
|
||||
SessionListItem,
|
||||
SessionService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
filterSessions,
|
||||
SESSION_PAGE_SIZE,
|
||||
type SessionState,
|
||||
} from '../utils/sessionPickerUtils.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
|
||||
export interface UseSessionPickerOptions {
|
||||
sessionService: SessionService | null;
|
||||
currentBranch?: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
maxVisibleItems: number;
|
||||
/**
|
||||
* If true, computes centered scroll offset (keeps selection near middle).
|
||||
* If false, uses follow mode (scrolls when selection reaches edge).
|
||||
*/
|
||||
centerSelection?: boolean;
|
||||
/**
|
||||
* Enable/disable input handling.
|
||||
*/
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UseSessionPickerResult {
|
||||
selectedIndex: number;
|
||||
sessionState: SessionState;
|
||||
filteredSessions: SessionListItem[];
|
||||
filterByBranch: boolean;
|
||||
isLoading: boolean;
|
||||
scrollOffset: number;
|
||||
visibleSessions: SessionListItem[];
|
||||
showScrollUp: boolean;
|
||||
showScrollDown: boolean;
|
||||
loadMoreSessions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
maxVisibleItems,
|
||||
centerSelection = false,
|
||||
isActive = true,
|
||||
}: UseSessionPickerOptions): UseSessionPickerResult {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [sessionState, setSessionState] = useState<SessionState>({
|
||||
sessions: [],
|
||||
hasMore: true,
|
||||
nextCursor: undefined,
|
||||
});
|
||||
const [filterByBranch, setFilterByBranch] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// For follow mode (non-centered)
|
||||
const [followScrollOffset, setFollowScrollOffset] = useState(0);
|
||||
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
|
||||
const filteredSessions = useMemo(
|
||||
() => filterSessions(sessionState.sessions, filterByBranch, currentBranch),
|
||||
[sessionState.sessions, filterByBranch, currentBranch],
|
||||
);
|
||||
|
||||
const scrollOffset = useMemo(() => {
|
||||
if (centerSelection) {
|
||||
if (filteredSessions.length <= maxVisibleItems) {
|
||||
return 0;
|
||||
}
|
||||
const halfVisible = Math.floor(maxVisibleItems / 2);
|
||||
let offset = selectedIndex - halfVisible;
|
||||
offset = Math.max(0, offset);
|
||||
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
|
||||
return offset;
|
||||
}
|
||||
return followScrollOffset;
|
||||
}, [
|
||||
centerSelection,
|
||||
filteredSessions.length,
|
||||
followScrollOffset,
|
||||
maxVisibleItems,
|
||||
selectedIndex,
|
||||
]);
|
||||
|
||||
const visibleSessions = useMemo(
|
||||
() => filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleItems),
|
||||
[filteredSessions, maxVisibleItems, scrollOffset],
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown =
|
||||
scrollOffset + maxVisibleItems < filteredSessions.length;
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (!sessionService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadInitialSessions = async () => {
|
||||
try {
|
||||
const result: ListSessionsResult = await sessionService.listSessions({
|
||||
size: SESSION_PAGE_SIZE,
|
||||
});
|
||||
setSessionState({
|
||||
sessions: result.items,
|
||||
hasMore: result.hasMore,
|
||||
nextCursor: result.nextCursor,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadInitialSessions();
|
||||
}, [sessionService]);
|
||||
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingMoreRef.current = true;
|
||||
try {
|
||||
const result: ListSessionsResult = await sessionService.listSessions({
|
||||
size: SESSION_PAGE_SIZE,
|
||||
cursor: sessionState.nextCursor,
|
||||
});
|
||||
setSessionState((prev) => ({
|
||||
sessions: [...prev.sessions, ...result.items],
|
||||
hasMore: result.hasMore && result.nextCursor !== undefined,
|
||||
nextCursor: result.nextCursor,
|
||||
}));
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
|
||||
|
||||
// Reset selection when filter changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setFollowScrollOffset(0);
|
||||
}, [filterByBranch]);
|
||||
|
||||
// Ensure selectedIndex is valid when filtered sessions change
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedIndex >= filteredSessions.length &&
|
||||
filteredSessions.length > 0
|
||||
) {
|
||||
setSelectedIndex(filteredSessions.length - 1);
|
||||
}
|
||||
}, [filteredSessions.length, selectedIndex]);
|
||||
|
||||
// Auto-load more when centered mode hits the sentinel or list is empty.
|
||||
useEffect(() => {
|
||||
if (
|
||||
isLoading ||
|
||||
!sessionState.hasMore ||
|
||||
isLoadingMoreRef.current ||
|
||||
!centerSelection
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sentinelVisible =
|
||||
scrollOffset + maxVisibleItems >= filteredSessions.length;
|
||||
const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible;
|
||||
|
||||
if (shouldLoadMore) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
}, [
|
||||
centerSelection,
|
||||
filteredSessions.length,
|
||||
isLoading,
|
||||
loadMoreSessions,
|
||||
maxVisibleItems,
|
||||
scrollOffset,
|
||||
sessionState.hasMore,
|
||||
]);
|
||||
|
||||
// Key handling (KeypressContext)
|
||||
useKeypress(
|
||||
(key) => {
|
||||
const { name, sequence, ctrl } = key;
|
||||
|
||||
if (name === 'escape' || (ctrl && name === 'c')) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'return') {
|
||||
const session = filteredSessions[selectedIndex];
|
||||
if (session) {
|
||||
onSelect(session.sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'up' || name === 'k') {
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.max(0, prev - 1);
|
||||
if (!centerSelection && newIndex < followScrollOffset) {
|
||||
setFollowScrollOffset(newIndex);
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'down' || name === 'j') {
|
||||
if (filteredSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
|
||||
|
||||
if (
|
||||
!centerSelection &&
|
||||
newIndex >= followScrollOffset + maxVisibleItems
|
||||
) {
|
||||
setFollowScrollOffset(newIndex - maxVisibleItems + 1);
|
||||
}
|
||||
|
||||
// Follow mode: load more when near the end.
|
||||
if (!centerSelection && newIndex >= filteredSessions.length - 3) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
|
||||
return newIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sequence === 'b' || sequence === 'B') {
|
||||
if (currentBranch) {
|
||||
setFilterByBranch((prev) => !prev);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
);
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
sessionState,
|
||||
filteredSessions,
|
||||
filterByBranch,
|
||||
isLoading,
|
||||
scrollOffset,
|
||||
visibleSessions,
|
||||
showScrollUp,
|
||||
showScrollDown,
|
||||
loadMoreSessions,
|
||||
};
|
||||
}
|
||||
@@ -62,7 +62,7 @@ const mockConfig = {
|
||||
getAllowedTools: vi.fn(() => []),
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
authType: 'gemini-api-key',
|
||||
}),
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
|
||||
@@ -60,6 +60,11 @@ export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
|
||||
return id ? { id, label: id } : null;
|
||||
}
|
||||
|
||||
export function getAnthropicAvailableModelFromEnv(): AvailableModel | null {
|
||||
const id = process.env['ANTHROPIC_MODEL']?.trim();
|
||||
return id ? { id, label: id } : null;
|
||||
}
|
||||
|
||||
export function getAvailableModelsForAuthType(
|
||||
authType: AuthType,
|
||||
): AvailableModel[] {
|
||||
@@ -70,6 +75,10 @@ export function getAvailableModelsForAuthType(
|
||||
const openAIModel = getOpenAIAvailableModelFromEnv();
|
||||
return openAIModel ? [openAIModel] : [];
|
||||
}
|
||||
case AuthType.USE_ANTHROPIC: {
|
||||
const anthropicModel = getAnthropicAvailableModelFromEnv();
|
||||
return anthropicModel ? [anthropicModel] : [];
|
||||
}
|
||||
default:
|
||||
// For other auth types, return empty array for now
|
||||
// This can be expanded later according to the design doc
|
||||
|
||||
@@ -20,6 +20,11 @@ const makeConfig = (tools: Record<string, AnyDeclarativeTool>) =>
|
||||
getToolRegistry: () => ({
|
||||
getTool: (name: string) => tools[name],
|
||||
}),
|
||||
getContentGenerator: () => ({
|
||||
// Default to showing full thinking content during resume unless explicitly
|
||||
// summarized; tests don't care about summarized thinking behavior.
|
||||
useSummarizedThinking: () => false,
|
||||
}),
|
||||
}) as unknown as Config;
|
||||
|
||||
describe('resumeHistoryUtils', () => {
|
||||
|
||||
@@ -204,7 +204,11 @@ function convertToHistoryItems(
|
||||
const parts = record.message?.parts as Part[] | undefined;
|
||||
|
||||
// Extract thought content
|
||||
const thoughtText = extractThoughtTextFromParts(parts);
|
||||
const thoughtText = !config
|
||||
.getContentGenerator()
|
||||
.useSummarizedThinking()
|
||||
? extractThoughtTextFromParts(parts)
|
||||
: '';
|
||||
|
||||
// Extract text content (non-function-call, non-thought)
|
||||
const text = extractTextFromParts(parts);
|
||||
|
||||
45
packages/cli/src/ui/utils/sessionPickerUtils.test.ts
Normal file
45
packages/cli/src/ui/utils/sessionPickerUtils.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { truncateText } from './sessionPickerUtils.js';
|
||||
|
||||
describe('sessionPickerUtils', () => {
|
||||
describe('truncateText', () => {
|
||||
it('returns the original text when it fits and has no newline', () => {
|
||||
expect(truncateText('hello', 10)).toBe('hello');
|
||||
});
|
||||
|
||||
it('truncates long text with ellipsis', () => {
|
||||
expect(truncateText('hello world', 5)).toBe('he...');
|
||||
});
|
||||
|
||||
it('truncates without ellipsis when maxWidth <= 3', () => {
|
||||
expect(truncateText('hello', 3)).toBe('hel');
|
||||
expect(truncateText('hello', 2)).toBe('he');
|
||||
});
|
||||
|
||||
it('breaks at newline and returns only the first line', () => {
|
||||
expect(truncateText('hello\nworld', 20)).toBe('hello');
|
||||
expect(truncateText('hello\r\nworld', 20)).toBe('hello');
|
||||
});
|
||||
|
||||
it('breaks at newline and still truncates the first line when needed', () => {
|
||||
expect(truncateText('hello\nworld', 2)).toBe('he');
|
||||
expect(truncateText('hello\nworld', 3)).toBe('hel');
|
||||
expect(truncateText('hello\nworld', 4)).toBe('h...');
|
||||
});
|
||||
|
||||
it('does not add ellipsis when the string ends at a newline', () => {
|
||||
expect(truncateText('hello\n', 20)).toBe('hello');
|
||||
expect(truncateText('hello\r\n', 20)).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns only the first line even if there are multiple line breaks', () => {
|
||||
expect(truncateText('hello\n\nworld', 20)).toBe('hello');
|
||||
});
|
||||
});
|
||||
});
|
||||
59
packages/cli/src/ui/utils/sessionPickerUtils.ts
Normal file
59
packages/cli/src/ui/utils/sessionPickerUtils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SessionListItem } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* State for managing loaded sessions in the session picker.
|
||||
*/
|
||||
export interface SessionState {
|
||||
sessions: SessionListItem[];
|
||||
hasMore: boolean;
|
||||
nextCursor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page size for loading sessions.
|
||||
*/
|
||||
export const SESSION_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Truncates text to fit within a given width, adding ellipsis if needed.
|
||||
*/
|
||||
export function truncateText(text: string, maxWidth: number): string {
|
||||
const firstLine = text.split(/\r?\n/, 1)[0];
|
||||
if (firstLine.length <= maxWidth) {
|
||||
return firstLine;
|
||||
}
|
||||
if (maxWidth <= 3) {
|
||||
return firstLine.slice(0, maxWidth);
|
||||
}
|
||||
return firstLine.slice(0, maxWidth - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters sessions optionally by branch.
|
||||
*/
|
||||
export function filterSessions(
|
||||
sessions: SessionListItem[],
|
||||
filterByBranch: boolean,
|
||||
currentBranch?: string,
|
||||
): SessionListItem[] {
|
||||
return sessions.filter((session) => {
|
||||
// Apply branch filter if enabled
|
||||
if (filterByBranch && currentBranch) {
|
||||
return session.gitBranch === currentBranch;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats message count for display with proper pluralization.
|
||||
*/
|
||||
export function formatMessageCount(count: number): string {
|
||||
return count === 1 ? '1 message' : `${count} messages`;
|
||||
}
|
||||
@@ -76,6 +76,105 @@ describe('getGitHubRepoInfo', async () => {
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
// Tests for credential formats
|
||||
|
||||
it('returns the owner and repo for URL with classic PAT token (ghp_)', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('returns the owner and repo for URL with fine-grained PAT token (github_pat_)', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://github_pat_xxxxxxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('returns the owner and repo for URL with username:password format', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://username:password@github.com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('returns the owner and repo for URL with OAuth token (oauth2:token)', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://oauth2:gho_xxxxxxxxxxxx@github.com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('returns the owner and repo for URL with GitHub Actions token (x-access-token)', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://x-access-token:ghs_xxxxxxxxxxxx@github.com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
// Tests for case insensitivity
|
||||
|
||||
it('returns the owner and repo for URL with uppercase GITHUB.COM', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://GITHUB.COM/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('returns the owner and repo for URL with mixed case GitHub.Com', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://GitHub.Com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
// Tests for SSH format
|
||||
|
||||
it('returns the owner and repo for SSH URL', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'git@github.com:owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('throws for non-GitHub SSH URL', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'git@gitlab.com:owner/repo.git',
|
||||
);
|
||||
expect(() => {
|
||||
getGitHubRepoInfo();
|
||||
}).toThrowError(/Owner & repo could not be extracted from remote URL/);
|
||||
});
|
||||
|
||||
// Tests for edge cases
|
||||
|
||||
it('returns the owner and repo for URL without .git suffix', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://github.com/owner/repo',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('throws for non-GitHub HTTPS URL', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://gitlab.com/owner/repo.git',
|
||||
);
|
||||
expect(() => {
|
||||
getGitHubRepoInfo();
|
||||
}).toThrowError(/Owner & repo could not be extracted from remote URL/);
|
||||
});
|
||||
|
||||
it('handles repo names containing .git substring', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://github.com/owner/my.git.repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({
|
||||
owner: 'owner',
|
||||
repo: 'my.git.repo',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitRepoRoot', async () => {
|
||||
|
||||
@@ -103,17 +103,38 @@ export function getGitHubRepoInfo(): { owner: string; repo: string } {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
// Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git
|
||||
const match = remoteUrl.match(
|
||||
/(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/,
|
||||
);
|
||||
|
||||
// If the regex fails match, throw an error.
|
||||
if (!match || !match[1] || !match[2]) {
|
||||
// Handle SCP-style SSH URLs (git@github.com:owner/repo.git)
|
||||
let urlToParse = remoteUrl;
|
||||
if (remoteUrl.startsWith('git@github.com:')) {
|
||||
urlToParse = remoteUrl.replace('git@github.com:', '');
|
||||
} else if (remoteUrl.startsWith('git@')) {
|
||||
// SSH URL for a different provider (GitLab, Bitbucket, etc.)
|
||||
throw new Error(
|
||||
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { owner: match[1], repo: match[2] };
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(urlToParse, 'https://github.com');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedUrl.host !== 'github.com') {
|
||||
throw new Error(
|
||||
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
const parts = parsedUrl.pathname.split('/').filter((part) => part !== '');
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
throw new Error(
|
||||
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { owner: parts[0], repo: parts[1].replace(/\.git$/, '') };
|
||||
}
|
||||
|
||||
@@ -35,22 +35,33 @@ import {
|
||||
} from './nonInteractiveHelpers.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../services/CommandService.js', () => ({
|
||||
CommandService: {
|
||||
create: vi.fn().mockResolvedValue({
|
||||
getCommands: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: 'help' },
|
||||
{ name: 'commit' },
|
||||
{ name: 'memory' },
|
||||
]),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../nonInteractiveCliCommands.js', () => ({
|
||||
getAvailableCommands: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (
|
||||
_config: unknown,
|
||||
_signal: AbortSignal,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
) => {
|
||||
const allowedSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
const allCommands = [
|
||||
{ name: 'help', kind: 'built-in' },
|
||||
{ name: 'commit', kind: 'file' },
|
||||
{ name: 'memory', kind: 'built-in' },
|
||||
{ name: 'init', kind: 'built-in' },
|
||||
{ name: 'summary', kind: 'built-in' },
|
||||
{ name: 'compress', kind: 'built-in' },
|
||||
];
|
||||
|
||||
vi.mock('../services/BuiltinCommandLoader.js', () => ({
|
||||
BuiltinCommandLoader: vi.fn().mockImplementation(() => ({})),
|
||||
// Filter commands: always include file commands, only include allowed built-in commands
|
||||
return allCommands.filter(
|
||||
(cmd) =>
|
||||
cmd.kind === 'file' ||
|
||||
(cmd.kind === 'built-in' && allowedSet.has(cmd.name)),
|
||||
);
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/utils/computeStats.js', () => ({
|
||||
@@ -511,10 +522,12 @@ describe('buildSystemMessage', () => {
|
||||
});
|
||||
|
||||
it('should build system message with all fields', async () => {
|
||||
const allowedBuiltinCommands = ['init', 'summary', 'compress'];
|
||||
const result = await buildSystemMessage(
|
||||
mockConfig,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
allowedBuiltinCommands,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -530,7 +543,7 @@ describe('buildSystemMessage', () => {
|
||||
],
|
||||
model: 'test-model',
|
||||
permission_mode: 'auto',
|
||||
slash_commands: ['commit', 'help', 'memory'],
|
||||
slash_commands: ['commit', 'compress', 'init', 'summary'],
|
||||
qwen_code_version: '1.0.0',
|
||||
agents: [],
|
||||
});
|
||||
@@ -546,6 +559,7 @@ describe('buildSystemMessage', () => {
|
||||
config,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
['init', 'summary'],
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([]);
|
||||
@@ -561,6 +575,7 @@ describe('buildSystemMessage', () => {
|
||||
config,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
['init', 'summary'],
|
||||
);
|
||||
|
||||
expect(result.mcp_servers).toEqual([]);
|
||||
@@ -576,10 +591,37 @@ describe('buildSystemMessage', () => {
|
||||
config,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
['init', 'summary'],
|
||||
);
|
||||
|
||||
expect(result.qwen_code_version).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should only include allowed built-in commands and all file commands', async () => {
|
||||
const allowedBuiltinCommands = ['init', 'summary'];
|
||||
const result = await buildSystemMessage(
|
||||
mockConfig,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
allowedBuiltinCommands,
|
||||
);
|
||||
|
||||
// Should include: 'commit' (FILE), 'init' (BUILT_IN, allowed), 'summary' (BUILT_IN, allowed)
|
||||
// Should NOT include: 'help', 'memory', 'compress' (BUILT_IN but not in allowed set)
|
||||
expect(result.slash_commands).toEqual(['commit', 'init', 'summary']);
|
||||
});
|
||||
|
||||
it('should include only file commands when no built-in commands are allowed', async () => {
|
||||
const result = await buildSystemMessage(
|
||||
mockConfig,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
[], // Empty array - no built-in commands allowed
|
||||
);
|
||||
|
||||
// Should only include 'commit' (FILE command)
|
||||
expect(result.slash_commands).toEqual(['commit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTaskToolProgressHandler', () => {
|
||||
|
||||
@@ -25,10 +25,9 @@ import type {
|
||||
PermissionMode,
|
||||
CLISystemMessage,
|
||||
} from '../nonInteractive/types.js';
|
||||
import { CommandService } from '../services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
|
||||
import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js';
|
||||
import { computeSessionStats } from '../ui/utils/computeStats.js';
|
||||
import { getAvailableCommands } from '../nonInteractiveCliCommands.js';
|
||||
|
||||
/**
|
||||
* Normalizes various part list formats into a consistent Part[] array.
|
||||
@@ -187,24 +186,27 @@ export function computeUsageFromMetrics(metrics: SessionMetrics): Usage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load slash command names using CommandService
|
||||
* Load slash command names using getAvailableCommands
|
||||
*
|
||||
* @param config - Config instance
|
||||
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
|
||||
* If not provided, uses the default from getAvailableCommands.
|
||||
* @returns Promise resolving to array of slash command names
|
||||
*/
|
||||
async function loadSlashCommandNames(config: Config): Promise<string[]> {
|
||||
async function loadSlashCommandNames(
|
||||
config: Config,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
): Promise<string[]> {
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const service = await CommandService.create(
|
||||
[new BuiltinCommandLoader(config)],
|
||||
const commands = await getAvailableCommands(
|
||||
config,
|
||||
controller.signal,
|
||||
allowedBuiltinCommandNames,
|
||||
);
|
||||
const names = new Set<string>();
|
||||
const commands = service.getCommands();
|
||||
for (const command of commands) {
|
||||
names.add(command.name);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
|
||||
// Extract command names and sort
|
||||
return commands.map((cmd) => cmd.name).sort();
|
||||
} catch (error) {
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
@@ -233,12 +235,15 @@ async function loadSlashCommandNames(config: Config): Promise<string[]> {
|
||||
* @param config - Config instance
|
||||
* @param sessionId - Session identifier
|
||||
* @param permissionMode - Current permission/approval mode
|
||||
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
|
||||
* If not provided, defaults to empty array (only file commands will be included).
|
||||
* @returns Promise resolving to CLISystemMessage
|
||||
*/
|
||||
export async function buildSystemMessage(
|
||||
config: Config,
|
||||
sessionId: string,
|
||||
permissionMode: PermissionMode,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
): Promise<CLISystemMessage> {
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const tools = toolRegistry ? toolRegistry.getAllToolNames() : [];
|
||||
@@ -251,8 +256,11 @@ export async function buildSystemMessage(
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Load slash commands
|
||||
const slashCommands = await loadSlashCommandNames(config);
|
||||
// Load slash commands with filtering based on allowed built-in commands
|
||||
const slashCommands = await loadSlashCommandNames(
|
||||
config,
|
||||
allowedBuiltinCommandNames,
|
||||
);
|
||||
|
||||
// Load subagent names from config
|
||||
let agentNames: string[] = [];
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('relaunchAppInChildProcess', () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
delete process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||
|
||||
process.execArgv = [...originalExecArgv];
|
||||
process.argv = [...originalArgv];
|
||||
@@ -145,9 +145,9 @@ describe('relaunchAppInChildProcess', () => {
|
||||
stdinResumeSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('when GEMINI_CLI_NO_RELAUNCH is set', () => {
|
||||
describe('when QWEN_CODE_NO_RELAUNCH is set', () => {
|
||||
it('should return early without spawning a child process', async () => {
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
|
||||
|
||||
await relaunchAppInChildProcess(['--test'], ['--verbose']);
|
||||
|
||||
@@ -156,9 +156,9 @@ describe('relaunchAppInChildProcess', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => {
|
||||
describe('when QWEN_CODE_NO_RELAUNCH is not set', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
delete process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||
});
|
||||
|
||||
it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function relaunchAppInChildProcess(
|
||||
additionalNodeArgs: string[],
|
||||
additionalScriptArgs: string[],
|
||||
) {
|
||||
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
|
||||
if (process.env['QWEN_CODE_NO_RELAUNCH']) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function relaunchAppInChildProcess(
|
||||
...additionalScriptArgs,
|
||||
...scriptArgs,
|
||||
];
|
||||
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
|
||||
const newEnv = { ...process.env, QWEN_CODE_NO_RELAUNCH: 'true' };
|
||||
|
||||
// The parent process should not be reading from stdin while the child is running.
|
||||
process.stdin.pause();
|
||||
|
||||
@@ -153,7 +153,8 @@ export async function getExtendedSystemInfo(
|
||||
|
||||
// Get base URL if using OpenAI auth
|
||||
const baseUrl =
|
||||
baseInfo.selectedAuthType === AuthType.USE_OPENAI
|
||||
baseInfo.selectedAuthType === AuthType.USE_OPENAI ||
|
||||
baseInfo.selectedAuthType === AuthType.USE_ANTHROPIC
|
||||
? context.services.config?.getContentGeneratorConfig()?.baseUrl
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ describe('validateNonInterActiveAuth', () => {
|
||||
let originalEnvVertexAi: string | undefined;
|
||||
let originalEnvGcp: string | undefined;
|
||||
let originalEnvOpenAiApiKey: string | undefined;
|
||||
let originalEnvQwenOauth: string | undefined;
|
||||
let originalEnvGoogleApiKey: string | undefined;
|
||||
let originalEnvAnthropicApiKey: string | undefined;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processExitSpy: ReturnType<typeof vi.spyOn<[code?: number], never>>;
|
||||
let refreshAuthMock: ReturnType<typeof vi.fn>;
|
||||
@@ -29,10 +32,16 @@ describe('validateNonInterActiveAuth', () => {
|
||||
originalEnvVertexAi = process.env['GOOGLE_GENAI_USE_VERTEXAI'];
|
||||
originalEnvGcp = process.env['GOOGLE_GENAI_USE_GCA'];
|
||||
originalEnvOpenAiApiKey = process.env['OPENAI_API_KEY'];
|
||||
originalEnvQwenOauth = process.env['QWEN_OAUTH'];
|
||||
originalEnvGoogleApiKey = process.env['GOOGLE_API_KEY'];
|
||||
originalEnvAnthropicApiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['GOOGLE_GENAI_USE_VERTEXAI'];
|
||||
delete process.env['GOOGLE_GENAI_USE_GCA'];
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
delete process.env['QWEN_OAUTH'];
|
||||
delete process.env['GOOGLE_API_KEY'];
|
||||
delete process.env['ANTHROPIC_API_KEY'];
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code}) called`);
|
||||
@@ -80,6 +89,21 @@ describe('validateNonInterActiveAuth', () => {
|
||||
} else {
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
}
|
||||
if (originalEnvQwenOauth !== undefined) {
|
||||
process.env['QWEN_OAUTH'] = originalEnvQwenOauth;
|
||||
} else {
|
||||
delete process.env['QWEN_OAUTH'];
|
||||
}
|
||||
if (originalEnvGoogleApiKey !== undefined) {
|
||||
process.env['GOOGLE_API_KEY'] = originalEnvGoogleApiKey;
|
||||
} else {
|
||||
delete process.env['GOOGLE_API_KEY'];
|
||||
}
|
||||
if (originalEnvAnthropicApiKey !== undefined) {
|
||||
process.env['ANTHROPIC_API_KEY'] = originalEnvAnthropicApiKey;
|
||||
} else {
|
||||
delete process.env['ANTHROPIC_API_KEY'];
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,16 @@ function getAuthTypeFromEnv(): AuthType | undefined {
|
||||
return AuthType.QWEN_OAUTH;
|
||||
}
|
||||
|
||||
if (process.env['GEMINI_API_KEY']) {
|
||||
return AuthType.USE_GEMINI;
|
||||
}
|
||||
if (process.env['GOOGLE_API_KEY']) {
|
||||
return AuthType.USE_VERTEX_AI;
|
||||
}
|
||||
if (process.env['ANTHROPIC_API_KEY']) {
|
||||
return AuthType.USE_ANTHROPIC;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -23,8 +23,9 @@
|
||||
"scripts/postinstall.js"
|
||||
],
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
"@google/genai": "1.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
|
||||
@@ -34,7 +35,6 @@
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.203.0",
|
||||
"@opentelemetry/resource-detector-gcp": "^0.40.0",
|
||||
"@opentelemetry/sdk-node": "^0.203.0",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@xterm/headless": "5.5.0",
|
||||
@@ -48,7 +48,7 @@
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.5.0",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ignore": "^7.0.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tiktoken": "^1.0.21",
|
||||
"undici": "^7.10.0",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import { getOauthClient } from './oauth2.js';
|
||||
import { setupUser } from './setup.js';
|
||||
import type { HttpOptions } from './server.js';
|
||||
import { CodeAssistServer } from './server.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { LoggingContentGenerator } from '../core/loggingContentGenerator.js';
|
||||
|
||||
export async function createCodeAssistContentGenerator(
|
||||
httpOptions: HttpOptions,
|
||||
authType: AuthType,
|
||||
config: Config,
|
||||
sessionId?: string,
|
||||
): Promise<ContentGenerator> {
|
||||
if (
|
||||
authType === AuthType.LOGIN_WITH_GOOGLE ||
|
||||
authType === AuthType.CLOUD_SHELL
|
||||
) {
|
||||
const authClient = await getOauthClient(authType, config);
|
||||
const userData = await setupUser(authClient);
|
||||
return new CodeAssistServer(
|
||||
authClient,
|
||||
userData.projectId,
|
||||
httpOptions,
|
||||
sessionId,
|
||||
userData.userTier,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported authType: ${authType}`);
|
||||
}
|
||||
|
||||
export function getCodeAssistServer(
|
||||
config: Config,
|
||||
): CodeAssistServer | undefined {
|
||||
let server = config.getContentGenerator();
|
||||
|
||||
// Unwrap LoggingContentGenerator if present
|
||||
if (server instanceof LoggingContentGenerator) {
|
||||
server = server.getWrapped();
|
||||
}
|
||||
|
||||
if (!(server instanceof CodeAssistServer)) {
|
||||
return undefined;
|
||||
}
|
||||
return server;
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { CaGenerateContentResponse } from './converter.js';
|
||||
import {
|
||||
toGenerateContentRequest,
|
||||
fromGenerateContentResponse,
|
||||
toContents,
|
||||
} from './converter.js';
|
||||
import type {
|
||||
ContentListUnion,
|
||||
GenerateContentParameters,
|
||||
} from '@google/genai';
|
||||
import {
|
||||
GenerateContentResponse,
|
||||
FinishReason,
|
||||
BlockedReason,
|
||||
type Part,
|
||||
} from '@google/genai';
|
||||
|
||||
describe('converter', () => {
|
||||
describe('toCodeAssistRequest', () => {
|
||||
it('should convert a simple request with project', () => {
|
||||
const genaiReq: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
const codeAssistReq = toGenerateContentRequest(
|
||||
genaiReq,
|
||||
'my-prompt',
|
||||
'my-project',
|
||||
'my-session',
|
||||
);
|
||||
expect(codeAssistReq).toEqual({
|
||||
model: 'gemini-pro',
|
||||
project: 'my-project',
|
||||
request: {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
systemInstruction: undefined,
|
||||
cachedContent: undefined,
|
||||
tools: undefined,
|
||||
toolConfig: undefined,
|
||||
labels: undefined,
|
||||
safetySettings: undefined,
|
||||
generationConfig: undefined,
|
||||
session_id: 'my-session',
|
||||
},
|
||||
user_prompt_id: 'my-prompt',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert a request without a project', () => {
|
||||
const genaiReq: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
const codeAssistReq = toGenerateContentRequest(
|
||||
genaiReq,
|
||||
'my-prompt',
|
||||
undefined,
|
||||
'my-session',
|
||||
);
|
||||
expect(codeAssistReq).toEqual({
|
||||
model: 'gemini-pro',
|
||||
project: undefined,
|
||||
request: {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
systemInstruction: undefined,
|
||||
cachedContent: undefined,
|
||||
tools: undefined,
|
||||
toolConfig: undefined,
|
||||
labels: undefined,
|
||||
safetySettings: undefined,
|
||||
generationConfig: undefined,
|
||||
session_id: 'my-session',
|
||||
},
|
||||
user_prompt_id: 'my-prompt',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert a request with sessionId', () => {
|
||||
const genaiReq: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
const codeAssistReq = toGenerateContentRequest(
|
||||
genaiReq,
|
||||
'my-prompt',
|
||||
'my-project',
|
||||
'session-123',
|
||||
);
|
||||
expect(codeAssistReq).toEqual({
|
||||
model: 'gemini-pro',
|
||||
project: 'my-project',
|
||||
request: {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
systemInstruction: undefined,
|
||||
cachedContent: undefined,
|
||||
tools: undefined,
|
||||
toolConfig: undefined,
|
||||
labels: undefined,
|
||||
safetySettings: undefined,
|
||||
generationConfig: undefined,
|
||||
session_id: 'session-123',
|
||||
},
|
||||
user_prompt_id: 'my-prompt',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle string content', () => {
|
||||
const genaiReq: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: 'Hello',
|
||||
};
|
||||
const codeAssistReq = toGenerateContentRequest(
|
||||
genaiReq,
|
||||
'my-prompt',
|
||||
'my-project',
|
||||
'my-session',
|
||||
);
|
||||
expect(codeAssistReq.request.contents).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle Part[] content', () => {
|
||||
const genaiReq: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: [{ text: 'Hello' }, { text: 'World' }],
|
||||
};
|
||||
const codeAssistReq = toGenerateContentRequest(
|
||||
genaiReq,
|
||||
'my-prompt',
|
||||
'my-project',
|
||||
'my-session',
|
||||
);
|
||||
expect(codeAssistReq.request.contents).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'user', parts: [{ text: 'World' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle system instructions', () => {
|
||||
const genaiReq: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: 'Hello',
|
||||
config: {
|
||||
systemInstruction: 'You are a helpful assistant.',
|
||||
},
|
||||
};
|
||||
const codeAssistReq = toGenerateContentRequest(
|
||||
genaiReq,
|
||||
'my-prompt',
|
||||
'my-project',
|
||||
'my-session',
|
||||
);
|
||||
expect(codeAssistReq.request.systemInstruction).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'You are a helpful assistant.' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle generation config', () => {
|
||||
const genaiReq: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: 'Hello',
|
||||
config: {
|
||||
temperature: 0.8,
|
||||
topK: 40,
|
||||
},
|
||||
};
|
||||
const codeAssistReq = toGenerateContentRequest(
|
||||
genaiReq,
|
||||
'my-prompt',
|
||||
'my-project',
|
||||
'my-session',
|
||||
);
|
||||
expect(codeAssistReq.request.generationConfig).toEqual({
|
||||
temperature: 0.8,
|
||||
topK: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle all generation config fields', () => {
|
||||
const genaiReq: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: 'Hello',
|
||||
config: {
|
||||
temperature: 0.1,
|
||||
topP: 0.2,
|
||||
topK: 3,
|
||||
candidateCount: 4,
|
||||
maxOutputTokens: 5,
|
||||
stopSequences: ['a'],
|
||||
responseLogprobs: true,
|
||||
logprobs: 6,
|
||||
presencePenalty: 0.7,
|
||||
frequencyPenalty: 0.8,
|
||||
seed: 9,
|
||||
responseMimeType: 'application/json',
|
||||
},
|
||||
};
|
||||
const codeAssistReq = toGenerateContentRequest(
|
||||
genaiReq,
|
||||
'my-prompt',
|
||||
'my-project',
|
||||
'my-session',
|
||||
);
|
||||
expect(codeAssistReq.request.generationConfig).toEqual({
|
||||
temperature: 0.1,
|
||||
topP: 0.2,
|
||||
topK: 3,
|
||||
candidateCount: 4,
|
||||
maxOutputTokens: 5,
|
||||
stopSequences: ['a'],
|
||||
responseLogprobs: true,
|
||||
logprobs: 6,
|
||||
presencePenalty: 0.7,
|
||||
frequencyPenalty: 0.8,
|
||||
seed: 9,
|
||||
responseMimeType: 'application/json',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromCodeAssistResponse', () => {
|
||||
it('should convert a simple response', () => {
|
||||
const codeAssistRes: CaGenerateContentResponse = {
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hi there!' }],
|
||||
},
|
||||
finishReason: FinishReason.STOP,
|
||||
safetyRatings: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const genaiRes = fromGenerateContentResponse(codeAssistRes);
|
||||
expect(genaiRes).toBeInstanceOf(GenerateContentResponse);
|
||||
expect(genaiRes.candidates).toEqual(codeAssistRes.response.candidates);
|
||||
});
|
||||
|
||||
it('should handle prompt feedback and usage metadata', () => {
|
||||
const codeAssistRes: CaGenerateContentResponse = {
|
||||
response: {
|
||||
candidates: [],
|
||||
promptFeedback: {
|
||||
blockReason: BlockedReason.SAFETY,
|
||||
safetyRatings: [],
|
||||
},
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
},
|
||||
},
|
||||
};
|
||||
const genaiRes = fromGenerateContentResponse(codeAssistRes);
|
||||
expect(genaiRes.promptFeedback).toEqual(
|
||||
codeAssistRes.response.promptFeedback,
|
||||
);
|
||||
expect(genaiRes.usageMetadata).toEqual(
|
||||
codeAssistRes.response.usageMetadata,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle automatic function calling history', () => {
|
||||
const codeAssistRes: CaGenerateContentResponse = {
|
||||
response: {
|
||||
candidates: [],
|
||||
automaticFunctionCallingHistory: [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'test_function',
|
||||
args: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const genaiRes = fromGenerateContentResponse(codeAssistRes);
|
||||
expect(genaiRes.automaticFunctionCallingHistory).toEqual(
|
||||
codeAssistRes.response.automaticFunctionCallingHistory,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle modelVersion', () => {
|
||||
const codeAssistRes: CaGenerateContentResponse = {
|
||||
response: {
|
||||
candidates: [],
|
||||
modelVersion: 'qwen3-coder-plus',
|
||||
},
|
||||
};
|
||||
const genaiRes = fromGenerateContentResponse(codeAssistRes);
|
||||
expect(genaiRes.modelVersion).toEqual('qwen3-coder-plus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toContents', () => {
|
||||
it('should handle Content', () => {
|
||||
const content: ContentListUnion = {
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
};
|
||||
expect(toContents(content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'hello' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle array of Contents', () => {
|
||||
const contents: ContentListUnion = [
|
||||
{ role: 'user', parts: [{ text: 'hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'hi' }] },
|
||||
];
|
||||
expect(toContents(contents)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'hi' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle Part', () => {
|
||||
const part: ContentListUnion = { text: 'a part' };
|
||||
expect(toContents(part)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'a part' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle array of Parts', () => {
|
||||
const parts = [{ text: 'part 1' }, 'part 2'];
|
||||
expect(toContents(parts)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'part 1' }] },
|
||||
{ role: 'user', parts: [{ text: 'part 2' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle string', () => {
|
||||
const str: ContentListUnion = 'a string';
|
||||
expect(toContents(str)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'a string' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle array of strings', () => {
|
||||
const strings: ContentListUnion = ['string 1', 'string 2'];
|
||||
expect(toContents(strings)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'string 1' }] },
|
||||
{ role: 'user', parts: [{ text: 'string 2' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should convert thought parts to text parts for API compatibility', () => {
|
||||
const contentWithThought: ContentListUnion = {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'regular text' },
|
||||
{ thought: 'thinking about the problem' } as Part & {
|
||||
thought: string;
|
||||
},
|
||||
{ text: 'more text' },
|
||||
],
|
||||
};
|
||||
expect(toContents(contentWithThought)).toEqual([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'regular text' },
|
||||
{ text: '[Thought: thinking about the problem]' },
|
||||
{ text: 'more text' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should combine text and thought for text parts with thoughts', () => {
|
||||
const contentWithTextAndThought: ContentListUnion = {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: 'Here is my response',
|
||||
thought: 'I need to be careful here',
|
||||
} as Part & { thought: string },
|
||||
],
|
||||
};
|
||||
expect(toContents(contentWithTextAndThought)).toEqual([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: 'Here is my response\n[Thought: I need to be careful here]',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve non-thought properties while removing thought', () => {
|
||||
const contentWithComplexPart: ContentListUnion = {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: { name: 'calculate', args: { x: 5, y: 10 } },
|
||||
thought: 'Performing calculation',
|
||||
} as Part & { thought: string },
|
||||
],
|
||||
};
|
||||
expect(toContents(contentWithComplexPart)).toEqual([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: { name: 'calculate', args: { x: 5, y: 10 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should convert invalid text content to valid text part with thought', () => {
|
||||
const contentWithInvalidText: ContentListUnion = {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: 123, // Invalid - should be string
|
||||
thought: 'Processing number',
|
||||
} as Part & { thought: string; text: number },
|
||||
],
|
||||
};
|
||||
expect(toContents(contentWithInvalidText)).toEqual([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: '123\n[Thought: Processing number]',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user