diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..88c9891937 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# Dependencies (npm ci installs fresh inside the container) +node_modules +**/node_modules + +# Build artifacts (rebuilt from scratch inside the container) +dist +**/dist + +# Version control +.git diff --git a/.github/workflows/build-and-publish-image.yml b/.github/workflows/build-and-publish-image.yml deleted file mode 100644 index 0051ccebf4..0000000000 --- a/.github/workflows/build-and-publish-image.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: 'Build and Publish Docker Image' - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - version: - description: 'Docker image version/tag (e.g., 0.9.1, 0.9.2-rc.1)' - type: 'string' - required: false - publish: - description: 'Publish to GHCR' - type: 'boolean' - default: false - -env: - REGISTRY: 'ghcr.io' - IMAGE_NAME: '${{ github.repository }}' - -jobs: - build-and-push-to-ghcr: - runs-on: 'ubuntu-latest' - permissions: - contents: 'read' - packages: 'write' - - steps: - - name: 'Checkout repository' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - ref: '${{ github.ref }}' - - - name: 'Process version' - id: 'version' - run: | - INPUT_VERSION="${{ github.event.inputs.version }}" - - # For tag pushes, extract version from the tag - if [[ -z "$INPUT_VERSION" && "${{ github.ref_type }}" == "tag" ]]; then - INPUT_VERSION="${{ github.ref_name }}" - fi - - # Strip 'v' prefix if present - CLEAN_VERSION="${INPUT_VERSION#v}" - - # Extract major.minor for floating tag (e.g., 1.0.0 -> 1.0) - MAJOR_MINOR=$(echo "$CLEAN_VERSION" | grep -oE '^[0-9]+\.[0-9]+' || true) - - echo "raw=${INPUT_VERSION}" >> "$GITHUB_OUTPUT" - echo "clean=${CLEAN_VERSION}" >> "$GITHUB_OUTPUT" - echo "major_minor=${MAJOR_MINOR}" >> "$GITHUB_OUTPUT" - echo "Input version: ${INPUT_VERSION}" - echo "Clean version: ${CLEAN_VERSION}" - echo "Major.minor: ${MAJOR_MINOR}" - - - name: 'Debug inputs' - if: |- - ${{ runner.debug == '1' }} - run: | - echo "Event name: ${{ github.event_name }}" - echo "Version input (raw): ${{ steps.version.outputs.raw }}" - echo "Version (clean): ${{ steps.version.outputs.clean }}" - echo "Major.minor: ${{ steps.version.outputs.major_minor }}" - echo "Publish input: ${{ github.event.inputs.publish }}" - echo "GitHub ref: ${{ github.ref }}" - - - name: 'Set up QEMU' - uses: 'docker/setup-qemu-action@v3' # ratchet:exclude - - - name: 'Set up Docker Buildx' - uses: 'docker/setup-buildx-action@v3' # ratchet:exclude - - - name: 'Extract metadata (tags, labels) for Docker' - id: 'meta' - uses: 'docker/metadata-action@v5' # ratchet:exclude - with: - images: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}' - tags: | - type=raw,value=${{ steps.version.outputs.clean }},enable=${{ steps.version.outputs.clean != '' }} - type=raw,value=${{ steps.version.outputs.major_minor }},enable=${{ steps.version.outputs.major_minor != '' }} - type=ref,event=branch,enable=${{ steps.version.outputs.clean == '' }} - type=ref,event=pr,enable=${{ steps.version.outputs.clean == '' }} - type=semver,pattern={{version}},enable=${{ steps.version.outputs.clean == '' }} - type=semver,pattern={{major}}.{{minor}},enable=${{ steps.version.outputs.clean == '' }} - type=sha,prefix=sha-,format=short,enable=${{ steps.version.outputs.clean == '' }} - - - name: 'Log in to the Container registry' - if: |- - ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') }} - uses: 'docker/login-action@v3' # ratchet:exclude - with: - registry: '${{ env.REGISTRY }}' - username: '${{ github.actor }}' - password: '${{ secrets.GITHUB_TOKEN }}' - - - name: 'Build and push Docker image' - id: 'build-and-push' - uses: 'docker/build-push-action@v6' # ratchet:exclude - with: - context: '.' - platforms: 'linux/amd64,linux/arm64' - push: |- - ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') }} - tags: '${{ steps.meta.outputs.tags }}' - labels: '${{ steps.meta.outputs.labels }}' - build-args: | - CLI_VERSION_ARG=${{ steps.version.outputs.clean || github.sha }} diff --git a/.github/workflows/check-issue-completeness.yml b/.github/workflows/check-issue-completeness.yml deleted file mode 100644 index bf8630bc44..0000000000 --- a/.github/workflows/check-issue-completeness.yml +++ /dev/null @@ -1,201 +0,0 @@ -name: 'Check Issue Completeness' - -on: - issues: - types: - - 'opened' - - 'edited' - -permissions: - contents: 'read' - issues: 'write' - -jobs: - check-issue-info: - timeout-minutes: 2 - if: |- - ${{ github.repository == 'QwenLM/qwen-code' && contains(github.event.issue.labels.*.name, 'type/bug') }} - runs-on: 'ubuntu-latest' - steps: - - name: 'Check for Client Information' - id: 'check_info' - env: - ISSUE_BODY: '${{ github.event.issue.body }}' - run: |- - echo "Checking issue body for required information..." - - # Convert issue body to lowercase for case-insensitive matching - ISSUE_BODY_LOWER=$(echo "$ISSUE_BODY" | tr '[:upper:]' '[:lower:]') - - # Initialize flags - HAS_VERSION=false - HAS_OS_INFO=false - HAS_AUTH_METHOD=false - HAS_ABOUT_OUTPUT=false - MISSING_INFO=() - - # Check for /about command output by looking for its characteristic fields - # The /about output contains: CLI Version, Git Commit, Model, Sandbox, OS, Auth Method - if echo "$ISSUE_BODY_LOWER" | grep -qE 'cli version.*[0-9]+\.[0-9]+\.[0-9]+'; then - HAS_ABOUT_OUTPUT=true - HAS_VERSION=true - fi - - # If full /about output is not detected, check individual components - if [ "$HAS_ABOUT_OUTPUT" = false ]; then - # Check for version information (various formats) - if echo "$ISSUE_BODY_LOWER" | grep -qE '(cli version|version|v)[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+'; then - HAS_VERSION=true - fi - - # Check for OS information - if echo "$ISSUE_BODY_LOWER" | grep -qE '(^os[[:space:]]|macos|windows|linux|ubuntu|debian|fedora|arch|darwin|win32|platform)'; then - HAS_OS_INFO=true - fi - - # Check for Auth Method information - if echo "$ISSUE_BODY_LOWER" | grep -qE '(auth method|authentication|login|qwen-oauth|api.?config|oauth)'; then - HAS_AUTH_METHOD=true - fi - else - # If /about output is present, assume it contains OS and auth info - HAS_OS_INFO=true - HAS_AUTH_METHOD=true - fi - - # Determine what's missing - if [ "$HAS_ABOUT_OUTPUT" = false ]; then - if [ "$HAS_VERSION" = false ]; then - MISSING_INFO+=("Qwen Code version") - fi - if [ "$HAS_OS_INFO" = false ]; then - MISSING_INFO+=("operating system information") - fi - if [ "$HAS_AUTH_METHOD" = false ]; then - MISSING_INFO+=("authentication/login method") - fi - # Suggest providing /about output for completeness - if [ "$HAS_VERSION" = false ] || [ "$HAS_OS_INFO" = false ] || [ "$HAS_AUTH_METHOD" = false ]; then - MISSING_INFO+=("full output of the \`/about\` command (recommended)") - fi - fi - - # Set output variables - if [ ${#MISSING_INFO[@]} -eq 0 ]; then - echo "info_complete=true" >> "$GITHUB_OUTPUT" - echo "All required information is present." - else - echo "info_complete=false" >> "$GITHUB_OUTPUT" - # Join array elements with comma - MISSING_LIST=$(IFS=','; echo "${MISSING_INFO[*]}") - echo "missing_info=$MISSING_LIST" >> "$GITHUB_OUTPUT" - echo "Missing information: $MISSING_LIST" - fi - - - name: 'Comment on Issue if Information is Missing' - if: |- - ${{ steps.check_info.outputs.info_complete == 'false' }} - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7 - env: - MISSING_INFO: '${{ steps.check_info.outputs.missing_info }}' - with: - github-token: '${{ secrets.GITHUB_TOKEN }}' - script: | - const missingInfo = process.env.MISSING_INFO.split(','); - const missingList = missingInfo.map(item => `- ${item}`).join('\n'); - - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const botComment = comments.data.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('Missing Required Information') - ); - - const commentBody = `### âš ī¸ Missing Required Information - - Thank you for reporting this issue! To help us investigate and resolve this problem more effectively, we need some additional information: - - ${missingList} - - ### How to provide this information: - - Please run the following command and paste the complete output: - - \`\`\`bash - qwen - # Then in the interactive CLI, run: - /about - \`\`\` - - The output should look like: - \`\`\` - CLI Version 0.0.14 - Git Commit 9a0cb64a - Model coder-model - Sandbox no sandbox - OS darwin - Auth Method qwen-oauth - \`\`\` - - Once you provide this information, we'll be able to assist you better. Thank you! 🙏`; - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: commentBody - }); - console.log('Updated existing comment about missing information.'); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: commentBody - }); - console.log('Created new comment about missing information.'); - } - - - name: 'Add status/need-information Label' - if: |- - ${{ steps.check_info.outputs.info_complete == 'false' }} - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7 - with: - github-token: '${{ secrets.GITHUB_TOKEN }}' - script: | - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: ['status/need-information'] - }); - console.log('Added status/need-information label.'); - - - name: 'Remove status/need-information Label if Complete' - if: |- - ${{ steps.check_info.outputs.info_complete == 'true' }} - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7 - continue-on-error: true - with: - github-token: '${{ secrets.GITHUB_TOKEN }}' - script: | - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - name: 'status/need-information' - }); - console.log('Removed status/need-information label as information is now complete.'); - } catch (error) { - if (error.status === 404) { - console.log('Label not found on issue, nothing to remove.'); - } else { - throw error; - } - } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index c410b6cddd..0000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,222 +0,0 @@ -# .github/workflows/ci.yml - -name: 'Qwen Code CI' - -on: - push: - branches: - - 'main' - - 'release/**' - pull_request: - branches: - - 'main' - - 'release/**' - merge_group: - workflow_dispatch: - inputs: - branch_ref: - description: 'Branch to run on' - required: true - default: 'main' - type: 'string' - -concurrency: - group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' - cancel-in-progress: |- - ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }} - -permissions: - checks: 'write' - contents: 'read' - statuses: 'write' - -defaults: - run: - shell: 'bash' - -env: - ACTIONLINT_VERSION: '1.7.7' - SHELLCHECK_VERSION: '0.11.0' - YAMLLINT_VERSION: '1.35.1' - -jobs: - lint: - name: 'Lint' - runs-on: 'ubuntu-latest' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - ref: '${{ github.event.inputs.branch_ref || github.ref }}' - fetch-depth: 0 - - - name: 'Set up Node.js' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: 'Install dependencies' - run: 'npm ci' - - - name: 'Check lockfile' - run: 'npm run check:lockfile' - - - name: 'Install linters' - run: 'node scripts/lint.js --setup' - - - name: 'Run ESLint' - run: 'node scripts/lint.js --eslint' - - - name: 'Run actionlint' - run: 'node scripts/lint.js --actionlint' - - - name: 'Run shellcheck' - run: 'node scripts/lint.js --shellcheck' - - - name: 'Run yamllint' - run: 'node scripts/lint.js --yamllint' - - - name: 'Run Prettier' - run: 'node scripts/lint.js --prettier' - - - name: 'Run sensitive keyword linter' - run: 'node scripts/lint.js --sensitive-keywords' - - # - # Test: Node - # - test: - name: 'Test' - runs-on: '${{ matrix.os }}' - needs: - - 'lint' - permissions: - contents: 'read' - checks: 'write' - pull-requests: 'write' - strategy: - fail-fast: false # So we can see all test failures - matrix: - os: - - 'macos-latest' - - 'ubuntu-latest' - - 'windows-latest' - node-version: - - '20.x' - - '22.x' - - '24.x' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Set up Node.js ${{ matrix.node-version }}' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version: '${{ matrix.node-version }}' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - registry-url: 'https://registry.npmjs.org/' - - - name: 'Configure npm for rate limiting' - run: |- - npm config set fetch-retry-mintimeout 20000 - npm config set fetch-retry-maxtimeout 120000 - npm config set fetch-retries 5 - npm config set fetch-timeout 300000 - - - name: 'Install dependencies' - run: |- - npm ci --prefer-offline --no-audit --progress=false - - - name: 'Build project' - run: |- - npm run build - - - name: 'Run tests and generate reports' - env: - NO_COLOR: true - run: 'npm run test:ci' - - - name: 'Publish Test Report (for non-forks)' - if: |- - ${{ always() && (github.event.pull_request.head.repo.full_name == github.repository) }} - uses: 'dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3' # ratchet:dorny/test-reporter@v2 - with: - name: 'Test Results (Node ${{ matrix.node-version }})' - path: 'packages/*/junit.xml' - reporter: 'java-junit' - fail-on-error: 'false' - - - name: 'Upload Test Results Artifact (for forks)' - if: |- - ${{ always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 - with: - name: 'test-results-fork-${{ matrix.node-version }}-${{ matrix.os }}' - path: 'packages/*/junit.xml' - - - name: 'Upload coverage reports' - if: |- - ${{ always() }} - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 - with: - name: 'coverage-reports-${{ matrix.node-version }}-${{ matrix.os }}' - path: 'packages/*/coverage' - - post_coverage_comment: - name: 'Post Coverage Comment' - runs-on: 'ubuntu-latest' - needs: 'test' - if: |- - ${{ always() && github.event_name == 'pull_request' && (github.event.pull_request.head.repo.full_name == github.repository) }} - continue-on-error: true - permissions: - contents: 'read' # For checkout - pull-requests: 'write' # For commenting - strategy: - matrix: - # Reduce noise by only posting the comment once - os: - - 'ubuntu-latest' - node-version: - - '22.x' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Download coverage reports artifact' - uses: 'actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0' # ratchet:actions/download-artifact@v5 - with: - name: 'coverage-reports-${{ matrix.node-version }}-${{ matrix.os }}' - path: 'coverage_artifact' # Download to a specific directory - - - name: 'Post Coverage Comment using Composite Action' - uses: './.github/actions/post-coverage-comment' # Path to the composite action directory - with: - cli_json_file: 'coverage_artifact/cli/coverage/coverage-summary.json' - core_json_file: 'coverage_artifact/core/coverage/coverage-summary.json' - cli_full_text_summary_file: 'coverage_artifact/cli/coverage/full-text-summary.txt' - core_full_text_summary_file: 'coverage_artifact/core/coverage/full-text-summary.txt' - node_version: '${{ matrix.node-version }}' - os: '${{ matrix.os }}' - github_token: '${{ secrets.GITHUB_TOKEN }}' - - codeql: - name: 'CodeQL' - runs-on: 'ubuntu-latest' - permissions: - actions: 'read' - contents: 'read' - security-events: 'write' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Initialize CodeQL' - uses: 'github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/init@v3 - with: - languages: 'javascript' - - - name: 'Perform CodeQL Analysis' - uses: 'github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/analyze@v3 diff --git a/.github/workflows/community-report.yml b/.github/workflows/community-report.yml deleted file mode 100644 index e0aaf90dbf..0000000000 --- a/.github/workflows/community-report.yml +++ /dev/null @@ -1,197 +0,0 @@ -name: 'Generate Weekly Community Report 📊' - -on: - schedule: - - cron: '0 12 * * 1' # Run at 12:00 UTC on Monday - workflow_dispatch: - inputs: - days: - description: 'Number of days to look back for the report' - required: true - default: '7' - -jobs: - generate-report: - name: 'Generate Report 📝' - if: |- - ${{ github.repository == 'google-gemini/gemini-cli' }} - runs-on: 'ubuntu-latest' - permissions: - issues: 'write' - pull-requests: 'read' - discussions: 'read' - contents: 'read' - id-token: 'write' - - steps: - - name: 'Generate GitHub App Token 🔑' - id: 'generate_token' - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 - with: - app-id: '${{ secrets.APP_ID }}' - private-key: '${{ secrets.PRIVATE_KEY }}' - permission-issues: 'write' - permission-pull-requests: 'read' - permission-discussions: 'read' - permission-contents: 'read' - - - name: 'Generate Report 📜' - id: 'report' - env: - GH_TOKEN: '${{ steps.generate_token.outputs.token }}' - REPO: '${{ github.repository }}' - DAYS: '${{ github.event.inputs.days || 7 }}' - run: |- - set -e - - START_DATE="$(date -u -d "$DAYS days ago" +'%Y-%m-%d')" - END_DATE="$(date -u +'%Y-%m-%d')" - echo "âŗ Generating report for contributions from ${START_DATE} to ${END_DATE}..." - - declare -A author_is_googler - check_googler_status() { - local author="$1" - if [[ "${author}" == *"[bot]" ]]; then - author_is_googler[${author}]=1 - return 1 - fi - if [[ -v "author_is_googler[${author}]" ]]; then - return "${author_is_googler[${author}]}" - fi - - if gh api "orgs/googlers/members/${author}" --silent 2>/dev/null; then - echo "🧑‍đŸ’ģ ${author} is a Googler." - author_is_googler[${author}]=0 - else - echo "🌍 ${author} is a community contributor." - author_is_googler[${author}]=1 - fi - return "${author_is_googler[${author}]}" - } - - googler_issues=0 - non_googler_issues=0 - googler_prs=0 - non_googler_prs=0 - - echo "🔎 Fetching issues and pull requests..." - ITEMS_JSON="$(gh search issues --repo "${REPO}" "created:>${START_DATE}" --json author,isPullRequest --limit 1000)" - - for row in $(echo "${ITEMS_JSON}" | jq -r '.[] | @base64'); do - _jq() { - echo "${row}" | base64 --decode | jq -r "${1}" - } - author="$(_jq '.author.login')" - is_pr="$(_jq '.isPullRequest')" - - if [[ -z "${author}" || "${author}" == "null" ]]; then - continue - fi - - if check_googler_status "${author}"; then - if [[ "${is_pr}" == "true" ]]; then - ((googler_prs++)) - else - ((googler_issues++)) - fi - else - if [[ "${is_pr}" == "true" ]]; then - ((non_googler_prs++)) - else - ((non_googler_issues++)) - fi - fi - done - - googler_discussions=0 - non_googler_discussions=0 - - echo "đŸ—Ŗī¸ Fetching discussions..." - DISCUSSION_QUERY=''' - query($q: String!) { - search(query: $q, type: DISCUSSION, first: 100) { - nodes { - ... on Discussion { - author { - login - } - } - } - } - }''' - DISCUSSIONS_JSON="$(gh api graphql -f q="repo:${REPO} created:>${START_DATE}" -f query="${DISCUSSION_QUERY}")" - - for row in $(echo "${DISCUSSIONS_JSON}" | jq -r '.data.search.nodes[] | @base64'); do - _jq() { - echo "${row}" | base64 --decode | jq -r "${1}" - } - author="$(_jq '.author.login')" - - if [[ -z "${author}" || "${author}" == "null" ]]; then - continue - fi - - if check_googler_status "${author}"; then - ((googler_discussions++)) - else - ((non_googler_discussions++)) - fi - done - - echo "âœī¸ Generating report content..." - TOTAL_ISSUES=$((googler_issues + non_googler_issues)) - TOTAL_PRS=$((googler_prs + non_googler_prs)) - TOTAL_DISCUSSIONS=$((googler_discussions + non_googler_discussions)) - - REPORT_BODY=$(cat <> "${GITHUB_OUTPUT}" - echo "${REPORT_BODY}" >> "${GITHUB_OUTPUT}" - echo "EOF" >> "${GITHUB_OUTPUT}" - - echo "📊 Community Contribution Report:" - echo "${REPORT_BODY}" - - - name: '🤖 Get Insights from Report' - if: |- - ${{ steps.report.outputs.report_body != '' }} - uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 - env: - GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' - REPOSITORY: '${{ github.repository }}' - with: - gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' - gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' - gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' - gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' - gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' - use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' - use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' - settings: |- - { - "coreTools": [ - "run_shell_command(gh issue list)", - "run_shell_command(gh pr list)", - "run_shell_command(gh search issues)", - "run_shell_command(gh search prs)" - ] - } - prompt: |- - You are a helpful assistant that analyzes community contribution reports. - Based on the following report, please provide a brief summary and highlight any interesting trends or potential areas for improvement. - - Report: - ${{ steps.report.outputs.report_body }} diff --git a/.github/workflows/docs-page-action.yml b/.github/workflows/docs-page-action.yml deleted file mode 100644 index 2d485278ce..0000000000 --- a/.github/workflows/docs-page-action.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: 'Deploy GitHub Pages' - -on: - push: - tags: 'v*' - workflow_dispatch: - -permissions: - contents: 'read' - pages: 'write' - id-token: 'write' - -# Allow only one concurrent deployment, skipping runs queued between the run -# in-progress and latest queued. However, do NOT cancel in-progress runs as we -# want to allow these production deployments to complete. -concurrency: - group: '${{ github.workflow }}' - cancel-in-progress: false - -jobs: - build: - if: |- - ${{ !contains(github.ref_name, 'nightly') }} - runs-on: 'ubuntu-latest' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Setup Pages' - uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5 - - - name: 'Build with Jekyll' - uses: 'actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697' # ratchet:actions/jekyll-build-pages@v1 - with: - source: './' - destination: './_site' - - - name: 'Upload artifact' - uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3 - - deploy: - environment: - name: 'github-pages' - url: '${{ steps.deployment.outputs.page_url }}' - runs-on: 'ubuntu-latest' - needs: 'build' - steps: - - name: 'Deploy to GitHub Pages' - id: 'deployment' - uses: 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' # ratchet:actions/deploy-pages@v4 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 13b71ffa3f..0000000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: 'E2E Tests' - -on: - push: - branches: - - 'main' - - 'feat/e2e/**' - merge_group: - -jobs: - e2e-test-linux: - name: 'E2E Test (Linux) - ${{ matrix.sandbox }}' - runs-on: 'ubuntu-latest' - strategy: - matrix: - sandbox: - - 'sandbox:none' - - 'sandbox:docker' - node-version: - - '20.x' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Set up Node.js ${{ matrix.node-version }}' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version: '${{ matrix.node-version }}' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - registry-url: 'https://registry.npmjs.org/' - - - name: 'Configure npm for rate limiting' - run: |- - npm config set fetch-retry-mintimeout 20000 - npm config set fetch-retry-maxtimeout 120000 - npm config set fetch-retries 5 - npm config set fetch-timeout 300000 - - - name: 'Install dependencies' - run: |- - npm ci --prefer-offline --no-audit --progress=false - - - name: 'Build project' - run: |- - npm run build - - - name: 'Set up Docker' - if: |- - ${{ matrix.sandbox == 'sandbox:docker' }} - uses: 'docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435' # ratchet:docker/setup-buildx-action@v3 - - - name: 'Set up Podman' - if: |- - ${{ matrix.sandbox == 'sandbox:podman' }} - uses: 'redhat-actions/podman-login@4934294ad0449894bcd1e9f191899d7292469603' # ratchet:redhat-actions/podman-login@v1 - with: - registry: 'docker.io' - username: '${{ secrets.DOCKERHUB_USERNAME }}' - password: '${{ secrets.DOCKERHUB_TOKEN }}' - - - name: 'Run E2E tests' - env: - OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' - OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - KEEP_OUTPUT: 'true' - VERBOSE: 'true' - run: |- - 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' - runs-on: 'macos-latest' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Set up Node.js' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - registry-url: 'https://registry.npmjs.org/' - - - name: 'Configure npm for rate limiting' - run: |- - npm config set fetch-retry-mintimeout 20000 - npm config set fetch-retry-maxtimeout 120000 - npm config set fetch-retries 5 - npm config set fetch-timeout 300000 - - - name: 'Install dependencies' - run: |- - npm ci --prefer-offline --no-audit --progress=false - - - name: 'Build project' - run: |- - npm run build - - - name: 'Run E2E tests' - env: - OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' - OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - run: 'npm run test:e2e' diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml deleted file mode 100644 index c8a4c6523f..0000000000 --- a/.github/workflows/eval.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: 'Eval' - -on: - workflow_dispatch: - -jobs: - eval: - name: 'Eval' - runs-on: 'ubuntu-latest' - strategy: - matrix: - node-version: - - '20.x' - - '22.x' - - '24.x' - steps: - - name: 'Set up Node.js ${{ matrix.node-version }}' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version: '${{ matrix.node-version }}' - cache: 'npm' - - - name: 'Set up Python' - uses: 'actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065' # ratchet:actions/setup-python@v5 - with: - python-version: '3.11' - - - name: 'Install and configure Poetry' - uses: 'snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a' # ratchet:snok/install-poetry@v1 diff --git a/.github/workflows/gemini-automated-issue-dedup.yml b/.github/workflows/gemini-automated-issue-dedup.yml deleted file mode 100644 index b84b5aa94d..0000000000 --- a/.github/workflows/gemini-automated-issue-dedup.yml +++ /dev/null @@ -1,262 +0,0 @@ -name: 'đŸˇī¸ Gemini Automated Issue Deduplication' - -on: - issues: - types: - - 'opened' - - 'reopened' - issue_comment: - types: - - 'created' - workflow_dispatch: - inputs: - issue_number: - description: 'issue number to dedup' - required: true - type: 'number' - -concurrency: - group: '${{ github.workflow }}-${{ github.event.issue.number }}' - cancel-in-progress: true - -defaults: - run: - shell: 'bash' - -jobs: - find-duplicates: - if: |- - github.repository == 'google-gemini/gemini-cli' && - vars.TRIAGE_DEDUPLICATE_ISSUES != '' && - (github.event_name == 'issues' || - github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' && - contains(github.event.comment.body, '@gemini-cli /deduplicate') && - (github.event.comment.author_association == 'OWNER' || - github.event.comment.author_association == 'MEMBER' || - github.event.comment.author_association == 'COLLABORATOR'))) - permissions: - contents: 'read' - id-token: 'write' # Required for WIF, see https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-google-cloud-platform#adding-permissions-settings - issues: 'read' - statuses: 'read' - packages: 'read' - timeout-minutes: 20 - runs-on: 'ubuntu-latest' - outputs: - duplicate_issues_csv: '${{ env.DUPLICATE_ISSUES_CSV }}' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Log in to GitHub Container Registry' - uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3 - with: - registry: 'ghcr.io' - username: '${{ github.actor }}' - password: '${{ secrets.GITHUB_TOKEN }}' - - - name: 'Find Duplicate Issues' - uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 - id: 'gemini_issue_deduplication' - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - ISSUE_TITLE: '${{ github.event.issue.title }}' - ISSUE_BODY: '${{ github.event.issue.body }}' - ISSUE_NUMBER: '${{ github.event.issue.number }}' - REPOSITORY: '${{ github.repository }}' - FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}' - with: - gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' - gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' - gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' - gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' - gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' - use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' - use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' - settings: |- - { - "mcpServers": { - "issue_deduplication": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "--network", "host", - "-e", "GITHUB_TOKEN", - "-e", "GEMINI_API_KEY", - "-e", "DATABASE_TYPE", - "-e", "FIRESTORE_DATABASE_ID", - "-e", "GCP_PROJECT", - "-e", "GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json", - "-v", "${GOOGLE_APPLICATION_CREDENTIALS}:/app/gcp-credentials.json", - "ghcr.io/google-gemini/gemini-cli-issue-triage@sha256:e3de1523f6c83aabb3c54b76d08940a2bf42febcb789dd2da6f95169641f94d3" - ], - "env": { - "GITHUB_TOKEN": "${GITHUB_TOKEN}", - "GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}", - "DATABASE_TYPE":"firestore", - "GCP_PROJECT": "${FIRESTORE_PROJECT}", - "FIRESTORE_DATABASE_ID": "(default)", - "GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}" - }, - "enabled": true, - "timeout": 600000 - } - }, - "maxSessionTurns": 25, - "coreTools": [ - "run_shell_command(echo)", - "run_shell_command(gh issue view)" - ], - "telemetry": { - "enabled": true, - "target": "gcp" - } - } - prompt: |- - ## Role - You are an issue de-duplication assistant. Your goal is to find - duplicate issues for a given issue. - ## Steps - 1. **Find Potential Duplicates:** - - The repository is ${{ github.repository }} and the issue number is ${{ github.event.issue.number }}. - - Use the `duplicates` tool with the `repo` and `issue_number` to find potential duplicates for the current issue. Do not use the `threshold` parameter. - - If no duplicates are found, you are done. - - Print the JSON output from the `duplicates` tool to the logs. - 2. **Refine Duplicates List (if necessary):** - - If the `duplicates` tool returns between 1 and 14 results, you must refine the list. - - For each potential duplicate issue, run `gh issue view --json title,body,comments` to fetch its content. - - Also fetch the content of the original issue: `gh issue view "${ISSUE_NUMBER}" --json title,body,comments`. - - Carefully analyze the content (title, body, comments) of the original issue and all potential duplicates. - - It is very important if the comments on either issue mention that they are not duplicates of each other, to treat them as not duplicates. - - Based on your analysis, create a final list containing only the issues you are highly confident are actual duplicates. - - If your final list is empty, you are done. - - Print to the logs if you omitted any potential duplicates based on your analysis. - - If the `duplicates` tool returned 15+ results, use the top 15 matches (based on descending similarity score value) to perform this step. - 3. **Output final duplicates list as CSV:** - - Convert the list of appropriate duplicate issue numbers into a comma-separated list (CSV). If there are no appropriate duplicates, use the empty string. - - Use the "echo" shell command to append the CSV of issue numbers into the filepath referenced by the environment variable "${GITHUB_ENV}": - echo "DUPLICATE_ISSUES_CSV=[DUPLICATE_ISSUES_AS_CSV]" >> "${GITHUB_ENV}" - ## Guidelines - - Only use the `duplicates` and `run_shell_command` tools. - - The `run_shell_command` tool can be used with `gh issue view`. - - Do not download or read media files like images, videos, or links. The `--json` flag for `gh issue view` will prevent this. - - Do not modify the issue content or status. - - Do not add comments or labels. - - Reference all shell variables as "${VAR}" (with quotes and braces). - - add-comment-and-label: - needs: 'find-duplicates' - if: |- - github.repository == 'google-gemini/gemini-cli' && - vars.TRIAGE_DEDUPLICATE_ISSUES != '' && - needs.find-duplicates.outputs.duplicate_issues_csv != '' && - ( - github.event_name == 'issues' || - github.event_name == 'workflow_dispatch' || - ( - github.event_name == 'issue_comment' && - contains(github.event.comment.body, '@gemini-cli /deduplicate') && - ( - github.event.comment.author_association == 'OWNER' || - github.event.comment.author_association == 'MEMBER' || - github.event.comment.author_association == 'COLLABORATOR' - ) - ) - ) - permissions: - issues: 'write' - timeout-minutes: 5 - runs-on: 'ubuntu-latest' - steps: - - name: 'Generate GitHub App Token' - id: 'generate_token' - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 - with: - app-id: '${{ secrets.APP_ID }}' - private-key: '${{ secrets.PRIVATE_KEY }}' - permission-issues: 'write' - - - name: 'Comment and Label Duplicate Issue' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' - env: - DUPLICATES_OUTPUT: '${{ needs.find-duplicates.outputs.duplicate_issues_csv }}' - with: - github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' - script: |- - const rawCsv = process.env.DUPLICATES_OUTPUT; - core.info(`Raw duplicates CSV: ${rawCsv}`); - const duplicateIssues = rawCsv.split(',').map(s => s.trim()).filter(s => s); - - if (duplicateIssues.length === 0) { - core.info('No duplicate issues found. Nothing to do.'); - return; - } - - const issueNumber = ${{ github.event.issue.number }}; - - function formatCommentBody(issues, updated = false) { - const header = updated - ? 'Found possible duplicate issues (updated):' - : 'Found possible duplicate issues:'; - const issuesList = issues.map(num => `- #${num}`).join('\n'); - const footer = 'If you believe this is not a duplicate, please remove the `status/possible-duplicate` label.'; - const magicComment = ''; - return `${header}\n\n${issuesList}\n\n${footer}\n${magicComment}`; - } - - const newCommentBody = formatCommentBody(duplicateIssues); - const newUpdatedCommentBody = formatCommentBody(duplicateIssues, true); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - }); - - const magicComment = ''; - const existingComment = comments.find(comment => - comment.user.type === 'Bot' && comment.body.includes(magicComment) - ); - - let commentMade = false; - - if (existingComment) { - // To check if lists are same, just compare the formatted bodies without headers. - const existingBodyForCompare = existingComment.body.substring(existingComment.body.indexOf('- #')); - const newBodyForCompare = newCommentBody.substring(newCommentBody.indexOf('- #')); - - if (existingBodyForCompare.trim() !== newBodyForCompare.trim()) { - core.info(`Updating existing comment ${existingComment.id}`); - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existingComment.id, - body: newUpdatedCommentBody, - }); - commentMade = true; - } else { - core.info('Existing comment is up-to-date. Nothing to do.'); - } - } else { - core.info('Creating new comment.'); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: newCommentBody, - }); - commentMade = true; - } - - if (commentMade) { - core.info('Adding "status/possible-duplicate" label.'); - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: ['status/possible-duplicate'], - }); - } diff --git a/.github/workflows/gemini-scheduled-issue-dedup.yml b/.github/workflows/gemini-scheduled-issue-dedup.yml deleted file mode 100644 index 9eea5e0aa0..0000000000 --- a/.github/workflows/gemini-scheduled-issue-dedup.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: '📋 Gemini Scheduled Issue Deduplication' - -on: - schedule: - - cron: '0 * * * *' # Runs every hour - workflow_dispatch: - -concurrency: - group: '${{ github.workflow }}' - cancel-in-progress: true - -defaults: - run: - shell: 'bash' - -jobs: - refresh-embeddings: - if: |- - ${{ vars.TRIAGE_DEDUPLICATE_ISSUES != '' && github.repository == 'google-gemini/gemini-cli' }} - permissions: - contents: 'read' - id-token: 'write' # Required for WIF, see https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-google-cloud-platform#adding-permissions-settings - issues: 'read' - statuses: 'read' - packages: 'read' - timeout-minutes: 20 - runs-on: 'ubuntu-latest' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Log in to GitHub Container Registry' - uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3 - with: - registry: 'ghcr.io' - username: '${{ github.actor }}' - password: '${{ secrets.GITHUB_TOKEN }}' - - - name: 'Run Gemini Issue Deduplication Refresh' - uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 - id: 'gemini_refresh_embeddings' - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - ISSUE_TITLE: '${{ github.event.issue.title }}' - ISSUE_BODY: '${{ github.event.issue.body }}' - ISSUE_NUMBER: '${{ github.event.issue.number }}' - REPOSITORY: '${{ github.repository }}' - FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}' - with: - gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' - gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' - gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' - gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' - gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' - use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' - use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' - settings: |- - { - "mcpServers": { - "issue_deduplication": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "--network", "host", - "-e", "GITHUB_TOKEN", - "-e", "GEMINI_API_KEY", - "-e", "DATABASE_TYPE", - "-e", "FIRESTORE_DATABASE_ID", - "-e", "GCP_PROJECT", - "-e", "GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json", - "-v", "${GOOGLE_APPLICATION_CREDENTIALS}:/app/gcp-credentials.json", - "ghcr.io/google-gemini/gemini-cli-issue-triage@sha256:e3de1523f6c83aabb3c54b76d08940a2bf42febcb789dd2da6f95169641f94d3" - ], - "env": { - "GITHUB_TOKEN": "${GITHUB_TOKEN}", - "GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}", - "DATABASE_TYPE":"firestore", - "GCP_PROJECT": "${FIRESTORE_PROJECT}", - "FIRESTORE_DATABASE_ID": "(default)", - "GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}" - }, - "enabled": true, - "timeout": 600000 - } - }, - "maxSessionTurns": 25, - "coreTools": [ - "run_shell_command(echo)" - ], - "telemetry": { - "enabled": true, - "target": "gcp" - } - } - prompt: |- - ## Role - - You are a database maintenance assistant for a GitHub issue deduplication system. - - ## Goal - - Your sole responsibility is to refresh the embeddings for all open issues in the repository to ensure the deduplication database is up-to-date. - - ## Steps - - 1. **Extract Repository Information:** The repository is ${{ github.repository }}. - 2. **Refresh Embeddings:** Call the `refresh` tool with the correct `repo`. Do not use the `force` parameter. - 3. **Log Output:** Print the JSON output from the `refresh` tool to the logs. - - ## Guidelines - - - Only use the `refresh` tool. - - Do not attempt to find duplicates or modify any issues. - - Your only task is to call the `refresh` tool and log its output. diff --git a/.github/workflows/gemini-scheduled-pr-triage.yml b/.github/workflows/gemini-scheduled-pr-triage.yml deleted file mode 100644 index 41c2d7cb73..0000000000 --- a/.github/workflows/gemini-scheduled-pr-triage.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: 'Qwen Scheduled PR Triage 🚀' - -on: - schedule: - - cron: '*/15 * * * *' # Runs every 15 minutes - workflow_dispatch: - -jobs: - audit-prs: - timeout-minutes: 15 - if: |- - ${{ github.repository == 'QwenLM/qwen-code' }} - permissions: - contents: 'read' - id-token: 'write' - issues: 'write' - pull-requests: 'write' - runs-on: 'ubuntu-latest' - outputs: - prs_needing_comment: '${{ steps.run_triage.outputs.prs_needing_comment }}' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Run PR Triage Script' - id: 'run_triage' - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - GITHUB_REPOSITORY: '${{ github.repository }}' - run: './.github/scripts/pr-triage.sh' diff --git a/.github/workflows/gemini-self-assign-issue.yml b/.github/workflows/gemini-self-assign-issue.yml deleted file mode 100644 index 40e6353f8d..0000000000 --- a/.github/workflows/gemini-self-assign-issue.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: 'Assign Issue on Comment' - -on: - issue_comment: - types: - - 'created' - -concurrency: - group: '${{ github.workflow }}-${{ github.event.issue.number }}' - cancel-in-progress: true - -defaults: - run: - shell: 'bash' - -permissions: - contents: 'read' - id-token: 'write' - issues: 'write' - statuses: 'write' - packages: 'read' - -jobs: - self-assign-issue: - if: |- - github.repository == 'google-gemini/gemini-cli' && - github.event_name == 'issue_comment' && - contains(github.event.comment.body, '/assign') - runs-on: 'ubuntu-latest' - steps: - - name: 'Generate GitHub App Token' - id: 'generate_token' - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' - with: - app-id: '${{ secrets.APP_ID }}' - private-key: '${{ secrets.PRIVATE_KEY }}' - # Add 'assignments' write permission - permission-issues: 'write' - - - name: 'Assign issue to user' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' - with: - github-token: '${{ steps.generate_token.outputs.token }}' - script: | - const issueNumber = context.issue.number; - const commenter = context.actor; - const owner = context.repo.owner; - const repo = context.repo.repo; - const MAX_ISSUES_ASSIGNED = 3; - - // Search for open issues already assigned to the commenter in this repo - const { data: assignedIssues } = await github.rest.search.issuesAndPullRequests({ - q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`, - advanced_search: true - }); - - if (assignedIssues.total_count >= MAX_ISSUES_ASSIGNED) { - await github.rest.issues.createComment({ - owner: owner, - repo: repo, - issue_number: issueNumber, - body: `👋 @${commenter}! You currently have ${assignedIssues.total_count} issues assigned to you. We have a ${MAX_ISSUES_ASSIGNED} max issues assigned at once policy. Once you close out an existing issue it will open up space to take another. You can also unassign yourself from an existing issue but please work on a hand-off if someone is expecting work on that issue.` - }); - return; // exit - } - - // Check if the issue is already assigned - const issue = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - }); - - if (issue.data.assignees.length > 0) { - // Comment that it's already assigned - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: `@${commenter} Thanks for taking interest but this issue is already assigned. We'd still love to have you contribute. Check out our [Help Wanted](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22) list for issues where we need some extra attention.` - }); - return; - } - - // If not taken, assign the user who commented - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - assignees: [commenter] - }); - - // Post a comment to confirm assignment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: `👋 @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).` - }); diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml deleted file mode 100644 index abaad9dbbf..0000000000 --- a/.github/workflows/no-response.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: 'No Response' - -# Run as a daily cron at 1:45 AM -on: - schedule: - - cron: '45 1 * * *' - workflow_dispatch: - -jobs: - no-response: - runs-on: 'ubuntu-latest' - if: |- - ${{ github.repository == 'google-gemini/gemini-cli' }} - permissions: - issues: 'write' - pull-requests: 'write' - concurrency: - group: '${{ github.workflow }}-no-response' - cancel-in-progress: true - steps: - - uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9 - with: - repo-token: '${{ secrets.GITHUB_TOKEN }}' - days-before-stale: -1 - days-before-close: 14 - stale-issue-label: 'status/need-information' - close-issue-message: >- - This issue was marked as needing more information and has not received a response in 14 days. - Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you! - stale-pr-label: 'status/need-information' - close-pr-message: >- - This pull request was marked as needing more information and has had no updates in 14 days. - Closing it for now. You are welcome to reopen with the required info. Thanks for contributing! diff --git a/.github/workflows/qwen-automated-issue-triage.yml b/.github/workflows/qwen-automated-issue-triage.yml deleted file mode 100644 index 6f91efb646..0000000000 --- a/.github/workflows/qwen-automated-issue-triage.yml +++ /dev/null @@ -1,203 +0,0 @@ -name: 'Qwen Automated Issue Triage' - -on: - issues: - types: - - 'opened' - - 'reopened' - issue_comment: - types: - - 'created' - workflow_dispatch: - inputs: - issue_number: - description: 'issue number to triage' - required: true - type: 'number' - -concurrency: - group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number }}' - cancel-in-progress: true - -defaults: - run: - shell: 'bash' - -permissions: - contents: 'read' - id-token: 'write' - issues: 'write' - statuses: 'write' - packages: 'read' - actions: 'write' # Required for cancelling a workflow run - -jobs: - triage-issue: - timeout-minutes: 5 - if: |- - ${{ github.repository == 'QwenLM/qwen-code' }} - runs-on: 'ubuntu-latest' - steps: - - name: 'Run Qwen Issue Analysis' - uses: 'QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2' - id: 'qwen_issue_analysis' - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - ISSUE_TITLE: '${{ github.event.issue.title }}' - ISSUE_BODY: '${{ github.event.issue.body }}' - ISSUE_NUMBER: '${{ github.event.issue.number }}' - REPOSITORY: '${{ github.repository }}' - with: - OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' - OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - settings_json: |- - { - "maxSessionTurns": 25, - "coreTools": [ - "run_shell_command" - ], - "sandbox": false - } - prompt: |- - ## Role - - You are an issue triage assistant. Analyze the current GitHub issue - and identify the most appropriate existing labels by only using the provided data. Use the available - tools to gather information; do not ask for information to be - provided. Do not remove the following labels titled maintainer, help wanted or good first issue. - - ## Steps - - 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels. - 2. Use shell command `echo` to check the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}". - 3. Ignore any existing priorities or tags on the issue. Just report your findings. - 4. Select the most relevant labels from the existing labels, focusing on type/*, category/*, scope/*, status/* and priority/*. For category/* and type/* limit yourself to only the single most applicable label in each case. - 6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`. - 7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label. - 8. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label. - 9. Use Category and Scope definitions mentioned below to help you narrow down issues. - - ## Guidelines - - - Only use labels that already exist in the repository - - Do not add comments or modify the issue content - - Triage only the current issue - - Identify only one category/ label - - Identify only one type/ label - - Identify all applicable scope/*, status/* and priority/* labels based on the issue content. It's ok to have multiple of these - - Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario - - Reference all shell variables as "${VAR}" (with quotes and braces) - - Output only valid JSON format - - Do not include any explanation or additional text, just the JSON - - Categorization Guidelines: - P0: Critical / Blocker - - A P0 bug is a catastrophic failure that demands immediate attention. - - To be a P0 it means almost all users are running into this issue and it is blocking users from being able to use the product. - - You would see this in the form of many comments from different developers on the bug. - - It represents a complete showstopper for a significant portion of users or for the development process itself. - Impact: - - Blocks development or testing for the entire team. - - Major security vulnerability that could compromise user data or system integrity. - - Causes data loss or corruption with no workaround. - - Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? Is it preventing contributors from contributing to the repository or is it a release blocker? - Qualifier: Is the main function of the software broken? - Example: The qwen auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI. - P1: High - - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. - - While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1. - - Once again this would be affecting many users. - - You would see this in the form of comments from different developers on the bug. - Impact: - - A core feature is broken or behaving incorrectly for a large number of users or large number of use cases. - - Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases. - - Severe performance degradation making the application frustratingly slow. - - No straightforward workaround exists, or the workaround is difficult and non-obvious. - Qualifier: Is a key feature unusable or giving very wrong results? - Example: Qwen Code enters a loop when making read-many-files tool call. I am unable to break out of the loop and qwen doesn't follow instructions subsequently. - P2: Medium - - A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality. - Impact: - - Affects a non-critical feature or a smaller, specific subset of users. - - An inconvenient but functional workaround is available and easy to execute. - - Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping). - Qualifier: Is it an annoying but non-blocking problem? - Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow. - P3: Low - - A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application. - Impact: - - Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page. - - An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users. - Qualifier: Is it a "nice-to-fix" issue? - Example: Spelling mistakes etc. - Things you should know: - - If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue - - This product is designed to use different models eg.. using pro, downgrading to flash etc. when users report that they dont expect the model to change those would be categorized as feature requests. - Definition of Categories and Scopes - - category/cli: Command line interface and interaction - - Issues with interactive CLI features, command parsing, keyboard shortcuts - - Related scopes: scope/commands, scope/interactive, scope/non-interactive, scope/keybindings - - category/core: Core engine and logic - - Issues with fundamental components, content generation, session management - - Related scopes: scope/content-generation, scope/token-management, scope/session-management, scope/model-switching - - category/ui: User interface and display - - Issues with themes, UI components, rendering, markdown display - - Related scopes: scope/themes, scope/components, scope/rendering, scope/markdown - - category/authentication: Authentication and authorization - - Issues with login flows, API keys, OAuth, credential storage - - Related scopes: scope/oauth, scope/api-keys, scope/token-storage - - category/tools: Tool integration and execution - - Issues with MCP, shell execution, file operations, web search, memory, git integration - - Related scopes: scope/mcp, scope/shell, scope/file-operations, scope/web-search, scope/memory, scope/git - - category/configuration: Configuration management - - Issues with settings, extensions, trusted folders, sandbox configuration - - Related scopes: scope/settings, scope/extensions, scope/trusted-folders, scope/sandbox - - category/integration: External integrations - - Issues with IDE integration, VSCode extension, Zed integration, GitHub Actions - - Related scopes: scope/ide, scope/vscode, scope/zed, scope/github-actions - - category/platform: Platform compatibility - - Issues with installation, OS compatibility, packaging - - Related scopes: scope/installation, scope/macos, scope/windows, scope/linux, scope/packaging - - category/performance: Performance and optimization - - Issues with latency, memory usage, model performance, caching - - Related scopes: scope/latency, scope/memory-usage, scope/model-performance, scope/caching - - category/security: Security and privacy - - Issues with data privacy, credential security, vulnerabilities - - Related scopes: scope/data-privacy, scope/credential-security, scope/vulnerability - - category/telemetry: Telemetry and analytics - - Issues with metrics collection, logging, analytics - - Related scopes: scope/metrics, scope/logging, scope/analytics - - category/development: Development experience - - Issues with build system, testing, CI/CD, documentation - - Related scopes: scope/build-system, scope/testing, scope/ci-cd, scope/documentation - - - name: 'Post Issue Analysis Failure Comment' - if: |- - ${{ failure() && steps.qwen_issue_analysis.outcome == 'failure' }} - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7 - env: - ISSUE_NUMBER: '${{ github.event.issue.number }}' - REPOSITORY: '${{ github.repository }}' - RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - with: - github-token: '${{ secrets.GITHUB_TOKEN }}' - script: |- - github.rest.issues.createComment({ - owner: process.env.REPOSITORY.split('/')[0], - repo: process.env.REPOSITORY.split('/')[1], - issue_number: parseInt(process.env.ISSUE_NUMBER), - body: 'There is a problem with the Qwen Code issue analysis. Please check the [action logs](${process.env.RUN_URL}) for details.' - }) diff --git a/.github/workflows/qwen-code-pr-review.yml b/.github/workflows/qwen-code-pr-review.yml deleted file mode 100644 index 6d7f0934fc..0000000000 --- a/.github/workflows/qwen-code-pr-review.yml +++ /dev/null @@ -1,190 +0,0 @@ -name: '🧐 Qwen Pull Request Review' - -on: - pull_request_target: - types: ['opened'] - pull_request_review_comment: - types: ['created'] - pull_request_review: - types: ['submitted'] - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to review' - required: true - type: 'number' - -jobs: - review-pr: - if: |- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request_target' && - github.event.action == 'opened' && - (github.event.pull_request.author_association == 'OWNER' || - github.event.pull_request.author_association == 'MEMBER' || - github.event.pull_request.author_association == 'COLLABORATOR')) || - (github.event_name == 'issue_comment' && - github.event.issue.pull_request && - contains(github.event.comment.body, '@qwen /review') && - (github.event.comment.author_association == 'OWNER' || - github.event.comment.author_association == 'MEMBER' || - github.event.comment.author_association == 'COLLABORATOR')) || - (github.event_name == 'pull_request_review_comment' && - contains(github.event.comment.body, '@qwen /review') && - (github.event.comment.author_association == 'OWNER' || - github.event.comment.author_association == 'MEMBER' || - github.event.comment.author_association == 'COLLABORATOR')) || - (github.event_name == 'pull_request_review' && - contains(github.event.review.body, '@qwen /review') && - (github.event.review.author_association == 'OWNER' || - github.event.review.author_association == 'MEMBER' || - github.event.review.author_association == 'COLLABORATOR')) - timeout-minutes: 15 - runs-on: 'ubuntu-latest' - permissions: - contents: 'read' - id-token: 'write' - pull-requests: 'write' - issues: 'write' - steps: - - name: 'Checkout PR code' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - fetch-depth: 0 - - - name: 'Get PR details (pull_request_target & workflow_dispatch)' - id: 'get_pr' - if: |- - ${{ github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' }} - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - run: |- - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - PR_NUMBER=${{ github.event.inputs.pr_number }} - else - PR_NUMBER=${{ github.event.pull_request.number }} - fi - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - # Get PR details - PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName) - echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT" - # Get file changes - CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only) - echo "changed_files<> "$GITHUB_OUTPUT" - echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: 'Get PR details (issue_comment)' - id: 'get_pr_comment' - if: |- - ${{ github.event_name == 'issue_comment' }} - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - COMMENT_BODY: '${{ github.event.comment.body }}' - run: |- - PR_NUMBER=${{ github.event.issue.number }} - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - # Extract additional instructions from comment - ADDITIONAL_INSTRUCTIONS=$(echo "$COMMENT_BODY" | sed 's/.*@qwen \/review//' | xargs) - echo "additional_instructions=$ADDITIONAL_INSTRUCTIONS" >> "$GITHUB_OUTPUT" - # Get PR details - PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName) - echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT" - # Get file changes - CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only) - echo "changed_files<> "$GITHUB_OUTPUT" - echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: 'Run Qwen PR Review' - uses: 'QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2' - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - PR_NUMBER: '${{ steps.get_pr.outputs.pr_number || steps.get_pr_comment.outputs.pr_number }}' - PR_DATA: '${{ steps.get_pr.outputs.pr_data || steps.get_pr_comment.outputs.pr_data }}' - CHANGED_FILES: '${{ steps.get_pr.outputs.changed_files || steps.get_pr_comment.outputs.changed_files }}' - ADDITIONAL_INSTRUCTIONS: '${{ steps.get_pr.outputs.additional_instructions || steps.get_pr_comment.outputs.additional_instructions }}' - REPOSITORY: '${{ github.repository }}' - with: - OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' - OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - settings_json: |- - { - "coreTools": [ - "run_shell_command", - "write_file" - ], - "sandbox": false - } - prompt: |- - You are an expert code reviewer. You have access to shell commands to gather PR information and perform the review. - - IMPORTANT: Use the available shell commands to gather information. Do not ask for information to be provided. - - Start by running these commands to gather the required data: - 1. Run: echo "$PR_DATA" to get PR details (JSON format) - 2. Run: echo "$CHANGED_FILES" to get the list of changed files - 3. Run: echo "$PR_NUMBER" to get the PR number - 4. Run: echo "$ADDITIONAL_INSTRUCTIONS" to see any specific review instructions from the user - 5. Run: gh pr diff $PR_NUMBER to see the full diff - 6. For any specific files, use: cat filename, head -50 filename, or tail -50 filename - - Additional Review Instructions: - If ADDITIONAL_INSTRUCTIONS contains text, prioritize those specific areas or focus points in your review. - Common instruction examples: "focus on security", "check performance", "review error handling", "check for breaking changes" - - Once you have the information, provide a comprehensive code review by: - 1. Writing your review to a file: write_file("review.md", "") - 2. Posting the review: gh pr comment $PR_NUMBER --body-file review.md --repo $REPOSITORY - - Review Areas: - - **Security**: Authentication, authorization, input validation, data sanitization - - **Performance**: Algorithms, database queries, caching, resource usage - - **Reliability**: Error handling, logging, testing coverage, edge cases - - **Maintainability**: Code structure, documentation, naming conventions - - **Functionality**: Logic correctness, requirements fulfillment - - Output Format: - Structure your review using this exact format with markdown: - - ## 📋 Review Summary - Provide a brief 2-3 sentence overview of the PR and overall assessment. - - ## 🔍 General Feedback - - List general observations about code quality - - Mention overall patterns or architectural decisions - - Highlight positive aspects of the implementation - - Note any recurring themes across files - - ## đŸŽ¯ Specific Feedback - Only include sections below that have actual issues. If there are no issues in a priority category, omit that entire section. - - ### 🔴 Critical - (Only include this section if there are critical issues) - Issues that must be addressed before merging (security vulnerabilities, breaking changes, major bugs): - - **File: `filename:line`** - Description of critical issue with specific recommendation - - ### 🟡 High - (Only include this section if there are high priority issues) - Important issues that should be addressed (performance problems, design flaws, significant bugs): - - **File: `filename:line`** - Description of high priority issue with suggested fix - - ### đŸŸĸ Medium - (Only include this section if there are medium priority issues) - Improvements that would enhance code quality (style issues, minor optimizations, better practices): - - **File: `filename:line`** - Description of medium priority improvement - - ### đŸ”ĩ Low - (Only include this section if there are suggestions) - Nice-to-have improvements and suggestions (documentation, naming, minor refactoring): - - **File: `filename:line`** - Description of suggestion or enhancement - - **Note**: If no specific issues are found in any category, simply state "No specific issues identified in this review." - - ## ✅ Highlights - (Only include this section if there are positive aspects to highlight) - - Mention specific good practices or implementations - - Acknowledge well-written code sections - - Note improvements from previous versions diff --git a/.github/workflows/qwen-scheduled-issue-triage.yml b/.github/workflows/qwen-scheduled-issue-triage.yml deleted file mode 100644 index 4281211833..0000000000 --- a/.github/workflows/qwen-scheduled-issue-triage.yml +++ /dev/null @@ -1,213 +0,0 @@ -name: 'Qwen Scheduled Issue Triage' - -on: - schedule: - - cron: '0 * * * *' # Runs every hour - workflow_dispatch: - -concurrency: - group: '${{ github.workflow }}' - cancel-in-progress: true - -defaults: - run: - shell: 'bash' - -permissions: - id-token: 'write' - issues: 'write' - -jobs: - triage-issues: - timeout-minutes: 10 - if: |- - ${{ github.repository == 'QwenLM/qwen-code' }} - runs-on: 'ubuntu-latest' - permissions: - contents: 'read' - id-token: 'write' - issues: 'write' - steps: - - name: 'Find untriaged issues' - id: 'find_issues' - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - GITHUB_REPOSITORY: '${{ github.repository }}' - run: |- - echo "🔍 Finding issues without labels..." - NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body) - - echo '🔍 Finding issues without labels...' - NO_LABEL_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ - --search 'is:open is:issue no:label' --json number,title,body)" - - echo 'đŸˇī¸ Finding issues that need triage...' - NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ - --search "is:open is:issue label:\"status/need-triage\"" --limit 1000 --json number,title,body)" - - echo '🔄 Merging and deduplicating issues...' - ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')" - - echo '📝 Setting output for GitHub Actions...' - echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}" - - - name: 'Run Qwen Issue Triage' - if: |- - ${{ steps.find_issues.outputs.issues_to_triage != '[]' }} - uses: 'QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2' - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - ISSUES_TO_TRIAGE: '${{ steps.find_issues.outputs.issues_to_triage }}' - REPOSITORY: '${{ github.repository }}' - with: - OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' - OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - settings_json: |- - { - "maxSessionTurns": 25, - "coreTools": [ - "run_shell_command(echo)" - ], - "sandbox": false - } - prompt: |- - ## Role - - You are an issue triage assistant. Analyze issues and identify - appropriate labels. Use the available tools to gather information; - do not ask for information to be provided. - - ## Steps - - 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels. - 2. Use shell command `echo` to check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues) - 3. Review the issue title, body and any comments provided in the environment variables. - 4. Ignore any existing priorities or tags on the issue. - 5. Select the most relevant labels from the existing labels, focusing on type/*, category/*, scope/*, status/* and priority/*. - 6. Get the list of labels already on the issue using `gh issue view ISSUE_NUMBER --repo ${{ github.repository }} --json labels -t '{{range .labels}}{{.name}}{{"\n"}}{{end}}' - 7. For category/* and type/* limit yourself to only the single most applicable label in each case. - 8. Give me a single short paragraph about why you are selecting each label in the process. use the format Issue ID: , Title, Label applied:, Label removed, ovearll explanation - 9. Parse the JSON array from step 2 and for EACH INDIVIDUAL issue, apply appropriate labels using separate commands: - - `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label1"` - - `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label2"` - - Continue for each label separately - - IMPORTANT: Label each issue individually, one command per issue, one label at a time if needed. - - Make sure after you apply labels there is only one category/* and one type/* label per issue. - - To do this look for labels found in step 6 that no longer apply remove them one at a time using - - `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name1"` - - `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name2"` - - IMPORTANT: Remove each label one at a time, one command per issue if needed. - 10. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 - - Anything more than 6 versions older than the most recent should add the status/need-retesting label - 11. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label - - After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"` - - Execute one `gh issue edit` command per issue, wait for success before proceeding to the next - Process each issue sequentially and confirm each labeling operation before moving to the next issue. - - ## Guidelines - - - Output only valid JSON format - - Do not include any explanation or additional text, just the JSON - - Only use labels that already exist in the repository. - - Do not add comments or modify the issue content. - - Do not remove the following labels maintainer, help wanted or good first issue. - - Triage only the current issue. - - Identify only one category/ label - - Identify only one type/ label (Do not apply type/duplicate or type/parent-issue) - - Identify all applicable scope/*, status/* and priority/* labels based on the issue content. It's ok to have multiple of these. - - Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario. - Categorization Guidelines: - P0: Critical / Blocker - - A P0 bug is a catastrophic failure that demands immediate attention. - - To be a P0 it means almost all users are running into this issue and it is blocking users from being able to use the product. - - You would see this in the form of many comments from different developers on the bug. - - It represents a complete showstopper for a significant portion of users or for the development process itself. - Impact: - - Blocks development or testing for the entire team. - - Major security vulnerability that could compromise user data or system integrity. - - Causes data loss or corruption with no workaround. - - Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? - - Is it preventing contributors from contributing to the repository or is it a release blocker? - Qualifier: Is the main function of the software broken? - Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI. - P1: High - - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. - - While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1. - - Once again this would be affecting many users. - - You would see this in the form of comments from different developers on the bug. - Impact: - - A core feature is broken or behaving incorrectly for a large number of users or large number of use cases. - - Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases. - - Severe performance degradation making the application frustratingly slow. - - No straightforward workaround exists, or the workaround is difficult and non-obvious. - Qualifier: Is a key feature unusable or giving very wrong results? - Example: Gemini CLI enters a loop when making read-many-files tool call. I am unable to break out of the loop and gemini doesn't follow instructions subsequently. - P2: Medium - - A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality. - Impact: - - Affects a non-critical feature or a smaller, specific subset of users. - - An inconvenient but functional workaround is available and easy to execute. - - Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping). - Qualifier: Is it an annoying but non-blocking problem? - Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow. - P3: Low - - A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application. - Impact: - - Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page. - - An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users. - Qualifier: Is it a "nice-to-fix" issue? - Example: Spelling mistakes etc. - Additional Context: - - If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue - - This product is designed to use different models eg.. using pro, downgrading to flash etc. - - When users report that they dont expect the model to change those would be categorized as feature requests. - Definition of Categories and Scopes - - category/cli: Command line interface and interaction - - Issues with interactive CLI features, command parsing, keyboard shortcuts - - Related scopes: scope/commands, scope/interactive, scope/non-interactive, scope/keybindings - - category/core: Core engine and logic - - Issues with fundamental components, content generation, session management - - Related scopes: scope/content-generation, scope/token-management, scope/session-management, scope/model-switching - - category/ui: User interface and display - - Issues with themes, UI components, rendering, markdown display - - Related scopes: scope/themes, scope/components, scope/rendering, scope/markdown - - category/authentication: Authentication and authorization - - Issues with login flows, API keys, OAuth, credential storage - - Related scopes: scope/oauth, scope/api-keys, scope/token-storage - - category/tools: Tool integration and execution - - Issues with MCP, shell execution, file operations, web search, memory, git integration - - Related scopes: scope/mcp, scope/shell, scope/file-operations, scope/web-search, scope/memory, scope/git - - category/configuration: Configuration management - - Issues with settings, extensions, trusted folders, sandbox configuration - - Related scopes: scope/settings, scope/extensions, scope/trusted-folders, scope/sandbox - - category/integration: External integrations - - Issues with IDE integration, VSCode extension, Zed integration, GitHub Actions - - Related scopes: scope/ide, scope/vscode, scope/zed, scope/github-actions - - category/platform: Platform compatibility - - Issues with installation, OS compatibility, packaging - - Related scopes: scope/installation, scope/macos, scope/windows, scope/linux, scope/packaging - - category/performance: Performance and optimization - - Issues with latency, memory usage, model performance, caching - - Related scopes: scope/latency, scope/memory-usage, scope/model-performance, scope/caching - - category/security: Security and privacy - - Issues with data privacy, credential security, vulnerabilities - - Related scopes: scope/data-privacy, scope/credential-security, scope/vulnerability - - category/telemetry: Telemetry and analytics - - Issues with metrics collection, logging, analytics - - Related scopes: scope/metrics, scope/logging, scope/analytics - - category/development: Development experience - - Issues with build system, testing, CI/CD, documentation - - Related scopes: scope/build-system, scope/testing, scope/ci-cd, scope/documentation diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml deleted file mode 100644 index ccdc24b77b..0000000000 --- a/.github/workflows/release-sdk.yml +++ /dev/null @@ -1,403 +0,0 @@ -name: 'Release SDK' - -on: - workflow_dispatch: - inputs: - version: - description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.' - required: false - type: 'string' - ref: - description: 'The branch or ref (full git sha) to release SDK from.' - required: true - type: 'string' - default: 'main' - cli_source: - description: 'CLI source to bundle. "build_from_source" builds CLI from the current branch/ref (recommended when releasing CLI and SDK together). "npm_latest" uses the latest stable CLI from npm (recommended for standalone SDK releases).' - required: true - type: 'choice' - options: - - 'build_from_source' - - 'npm_latest' - default: 'npm_latest' - dry_run: - description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' - required: true - type: 'boolean' - default: true - create_nightly_release: - description: 'Auto apply the nightly release tag, input version is ignored.' - required: false - type: 'boolean' - default: false - create_preview_release: - description: 'Auto apply the preview release tag, input version is ignored.' - required: false - type: 'boolean' - default: false - force_skip_tests: - description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' - required: false - type: 'boolean' - default: false - -concurrency: - group: '${{ github.workflow }}' - cancel-in-progress: false - -jobs: - release-sdk: - runs-on: 'ubuntu-latest' - environment: - name: 'production-release' - url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}' - if: |- - ${{ github.repository == 'QwenLM/qwen-code' }} - permissions: - contents: 'write' - packages: 'write' - id-token: 'write' - issues: 'write' - pull-requests: 'write' - outputs: - RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' - - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - ref: '${{ github.event.inputs.ref || github.sha }}' - fetch-depth: 0 - - - name: 'Set booleans for simplified logic' - env: - CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' - CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' - DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' - id: 'vars' - run: |- - is_nightly="false" - if [[ "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then - is_nightly="true" - fi - echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}" - - is_preview="false" - if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then - is_preview="true" - fi - echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}" - - is_dry_run="false" - if [[ "${DRY_RUN_INPUT}" == "true" ]]; then - is_dry_run="true" - fi - echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" - - - name: 'Setup Node.js' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'npm' - registry-url: 'https://registry.npmjs.org' - scope: '@qwen-code' - - - name: 'Install Dependencies' - run: |- - npm ci - - - name: 'Get the version' - id: 'version' - run: | - VERSION_ARGS=() - if [[ "${IS_NIGHTLY}" == "true" ]]; then - VERSION_ARGS+=(--type=nightly) - elif [[ "${IS_PREVIEW}" == "true" ]]; then - VERSION_ARGS+=(--type=preview) - if [[ -n "${MANUAL_VERSION}" ]]; then - VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}") - fi - else - VERSION_ARGS+=(--type=stable) - if [[ -n "${MANUAL_VERSION}" ]]; then - VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}") - fi - fi - - VERSION_JSON=$(node packages/sdk-typescript/scripts/get-release-version.js "${VERSION_ARGS[@]}") - RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag) - RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion) - NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag) - PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag) - - # 输å‡ē到 GITHUB_OUTPUT - echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" - echo "NPM_TAG=${NPM_TAG}" >> "$GITHUB_OUTPUT" - echo "PREVIOUS_RELEASE_TAG=${PREVIOUS_RELEASE_TAG}" >> "$GITHUB_OUTPUT" - - # æ‰“å°į‰ˆæœŦäŋĄæ¯åˆ°æ—Ĩåŋ— - echo "========================================" - echo "SDK Release Version Info" - echo "========================================" - echo "Release Tag: ${RELEASE_TAG}" - echo "Release Version: ${RELEASE_VERSION}" - echo "NPM Tag: ${NPM_TAG}" - echo "Previous Release: ${PREVIOUS_RELEASE_TAG}" - echo "========================================" - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' - 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: 'Determine CLI source and version' - id: 'cli_source' - env: - CLI_SOURCE_INPUT: '${{ github.event.inputs.cli_source }}' - run: | - # Determine CLI source mode - if [[ "${CLI_SOURCE_INPUT}" == "npm_latest" ]]; then - echo "mode=npm_latest" >> "$GITHUB_OUTPUT" - echo "Building SDK with latest stable CLI from npm" - else - echo "mode=build_from_source" >> "$GITHUB_OUTPUT" - echo "Building SDK with CLI built from current branch/ref" - fi - - - name: 'Get CLI version from npm (for npm_latest mode)' - id: 'cli_version_npm' - if: "steps.cli_source.outputs.mode == 'npm_latest'" - run: | - CLI_VERSION=$(npm view @qwen-code/qwen-code version --tag=latest) - if [[ -z "${CLI_VERSION}" ]]; then - echo '::error::Could not get latest stable CLI version from npm' - exit 1 - fi - echo "CLI_VERSION=${CLI_VERSION}" >> "$GITHUB_OUTPUT" - echo "Using latest stable CLI version from npm: ${CLI_VERSION}" - - - name: 'Download CLI package from npm' - id: 'cli_download' - if: "steps.cli_source.outputs.mode == 'npm_latest'" - env: - CLI_VERSION: '${{ steps.cli_version_npm.outputs.CLI_VERSION }}' - run: | - # Create temp directory for CLI package - CLI_TMP_DIR=$(mktemp -d) - echo "CLI_TMP_DIR=${CLI_TMP_DIR}" >> "$GITHUB_OUTPUT" - - # Download CLI package - echo "Downloading @qwen-code/qwen-code@${CLI_VERSION}..." - npm pack "@qwen-code/qwen-code@${CLI_VERSION}" --pack-destination "${CLI_TMP_DIR}" - - # Extract package - cd "${CLI_TMP_DIR}" - tar -xzf qwen-code-qwen-code-*.tgz - - echo "CLI package extracted to: ${CLI_TMP_DIR}/package" - echo "CLI package contents:" - ls -la "${CLI_TMP_DIR}/package/" - - - name: 'Build CLI from source' - id: 'cli_build' - if: "steps.cli_source.outputs.mode == 'build_from_source'" - run: | - # Build the CLI bundle from source - echo "Building CLI from source..." - npm run bundle - - # Get the CLI version from the built package - CLI_VERSION=$(node -p "require('./packages/cli/package.json').version") - echo "CLI_VERSION=${CLI_VERSION}" >> "$GITHUB_OUTPUT" - echo "Built CLI version: ${CLI_VERSION}" - - # Verify dist exists - if [[ ! -f "./dist/cli.js" ]]; then - echo "::error::CLI bundle not found at ./dist/cli.js" - exit 1 - fi - - echo "CLI bundle built successfully at ./dist/" - ls -la ./dist/ - - - name: 'Configure Git User' - run: | - 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: 'Bundle CLI into SDK (from npm)' - if: "steps.cli_source.outputs.mode == 'npm_latest'" - working-directory: 'packages/sdk-typescript' - env: - CLI_PACKAGE_PATH: '${{ steps.cli_download.outputs.CLI_TMP_DIR }}/package' - run: | - node scripts/bundle-cli-from-npm.js - - - name: 'Bundle CLI into SDK (from source)' - if: "steps.cli_source.outputs.mode == 'build_from_source'" - working-directory: 'packages/sdk-typescript' - run: | - node scripts/bundle-cli.js - - - name: 'Run Tests' - if: |- - ${{ github.event.inputs.force_skip_tests != 'true' }} - working-directory: 'packages/sdk-typescript' - run: | - npm run test:ci - env: - OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' - OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - - - name: 'Run SDK Integration Tests' - if: |- - ${{ github.event.inputs.force_skip_tests != 'true' }} - run: | - npm run test:integration:sdk:sandbox:none - npm run test:integration:sdk:sandbox:docker - env: - OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' - OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - - - name: 'Record bundled CLI version' - env: - CLI_VERSION: "${{ steps.cli_source.outputs.mode == 'npm_latest' && steps.cli_version_npm.outputs.CLI_VERSION || steps.cli_build.outputs.CLI_VERSION }}" - run: | - # Create a metadata file to record which CLI version was bundled - echo "${CLI_VERSION}" > packages/sdk-typescript/dist/BUNDLED_CLI_VERSION - echo "Bundled CLI version: ${CLI_VERSION}" - - - 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 }}' - run: |- - BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}" - git switch -c "${BRANCH_NAME}" - echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" - - - 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 }}' - RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' - run: |- - # 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 - echo "Pushing release branch to remote..." - git push --set-upstream origin "${BRANCH_NAME}" --follow-tags - - - name: 'Create GitHub Release and Tag' - if: |- - ${{ steps.vars.outputs.is_dry_run == 'false' }} - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - 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 }}' - CLI_VERSION: "${{ steps.cli_source.outputs.mode == 'npm_latest' && steps.cli_version_npm.outputs.CLI_VERSION || steps.cli_build.outputs.CLI_VERSION }}" - CLI_SOURCE_MODE: '${{ steps.cli_source.outputs.mode }}' - 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 - - # Determine CLI source description - if [[ "${CLI_SOURCE_MODE}" == "npm_latest" ]]; then - CLI_SOURCE_DESC="latest stable CLI from npm" - else - CLI_SOURCE_DESC="CLI built from source (same branch/ref as SDK)" - fi - - # Create release notes with CLI version info - NOTES="## Bundled CLI Version\n\nThis SDK release bundles CLI version: \`${CLI_VERSION}\`\n\nSource: ${CLI_SOURCE_DESC}\n\n---\n\n" - - gh release create "sdk-typescript-${RELEASE_TAG}" \ - --target "${TARGET}" \ - --title "SDK TypeScript Release ${RELEASE_TAG}" \ - --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ - --notes "${NOTES}$(gh release view "sdk-typescript-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>/dev/null || echo 'See commit history for changes.')" \ - "${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.CI_BOT_PAT }}' - 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: '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.CI_BOT_PAT }}' - PR_URL: '${{ steps.pr.outputs.PR_URL }}' - run: |- - set -euo pipefail - gh pr merge "${PR_URL}" --merge --auto --delete-branch - - - name: 'Create Issue on Failure' - if: |- - ${{ failure() && steps.vars.outputs.is_dry_run == 'false' }} - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}" - DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - run: |- - gh issue create \ - --title "SDK Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \ - --body "The SDK release workflow failed. See the full run for details: ${DETAILS_URL}" diff --git a/.github/workflows/release-vscode-companion.yml b/.github/workflows/release-vscode-companion.yml deleted file mode 100644 index ea02b01fb9..0000000000 --- a/.github/workflows/release-vscode-companion.yml +++ /dev/null @@ -1,359 +0,0 @@ -name: 'Release VSCode IDE Companion' - -on: - workflow_dispatch: - inputs: - version: - description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.' - required: false - type: 'string' - ref: - description: 'The branch or ref (full git sha) to release from.' - required: true - type: 'string' - default: 'main' - dry_run: - description: 'Run a dry-run of the release process; no branches, vsix packages or GitHub releases will be created.' - required: true - type: 'boolean' - default: true - create_preview_release: - description: 'Create a preview release. If version includes -preview., it is used as-is; otherwise a timestamp is appended.' - required: false - type: 'boolean' - default: false - force_skip_tests: - description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' - required: false - type: 'boolean' - default: false - -concurrency: - group: '${{ github.workflow }}' - cancel-in-progress: false - -jobs: - # First job: Determine version and run tests once - prepare: - runs-on: 'ubuntu-latest' - if: |- - ${{ github.repository == 'QwenLM/qwen-code' }} - permissions: - contents: 'read' - outputs: - release_version: '${{ steps.version.outputs.RELEASE_VERSION }}' - release_tag: '${{ steps.version.outputs.RELEASE_TAG }}' - vscode_tag: '${{ steps.version.outputs.VSCODE_TAG }}' - is_preview: '${{ steps.vars.outputs.is_preview }}' - is_dry_run: '${{ steps.vars.outputs.is_dry_run }}' - - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - ref: '${{ github.event.inputs.ref || github.sha }}' - fetch-depth: 0 - - - name: 'Set booleans for simplified logic' - env: - CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' - DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' - id: 'vars' - run: |- - is_preview="false" - if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then - is_preview="true" - fi - echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}" - - is_dry_run="false" - if [[ "${DRY_RUN_INPUT}" == "true" ]]; then - is_dry_run="true" - fi - echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" - - - name: 'Setup Node.js' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - - - name: 'Install Dependencies' - env: - NPM_CONFIG_PREFER_OFFLINE: 'true' - run: |- - npm ci - - - name: 'Get the version' - id: 'version' - working-directory: 'packages/vscode-ide-companion' - run: | - # Get the base version from package.json regardless of scenario - BASE_VERSION=$(node -p "require('./package.json').version") - - if [[ "${IS_PREVIEW}" == "true" ]]; then - # Generate preview version. If a manual version is provided and already - # contains -preview., use it as-is (no timestamp). Otherwise, append - # a timestamp for uniqueness. - if [[ -n "${MANUAL_VERSION}" ]]; then - MANUAL_CLEAN="${MANUAL_VERSION#v}" # Remove 'v' prefix if present - if [[ "${MANUAL_CLEAN}" == *"-preview."* ]]; then - PREVIEW_VERSION="${MANUAL_CLEAN}" - else - PREVIEW_BASE="${MANUAL_CLEAN%%-*}" # Strip any prerelease/build - TIMESTAMP=$(date +%Y%m%d%H%M%S) - PREVIEW_VERSION="${PREVIEW_BASE}-preview.${TIMESTAMP}" - fi - else - TIMESTAMP=$(date +%Y%m%d%H%M%S) - PREVIEW_VERSION="${BASE_VERSION}-preview.${TIMESTAMP}" - fi - - RELEASE_TAG="${PREVIEW_VERSION}" - - echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - echo "RELEASE_VERSION=${PREVIEW_VERSION}" >> "$GITHUB_OUTPUT" - echo "VSCODE_TAG=preview" >> "$GITHUB_OUTPUT" - else - # Use specified version or get from package.json - if [[ -n "${MANUAL_VERSION}" ]]; then - RELEASE_VERSION="${MANUAL_VERSION#v}" # Remove 'v' prefix if present - RELEASE_TAG="${MANUAL_VERSION#v}" # Remove 'v' prefix if present - else - RELEASE_VERSION="${BASE_VERSION}" - RELEASE_TAG="${BASE_VERSION}" - fi - - echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" - echo "VSCODE_TAG=latest" >> "$GITHUB_OUTPUT" - fi - env: - IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' - MANUAL_VERSION: '${{ inputs.version }}' - - - name: 'Build webui dependency' - if: |- - ${{ github.event.inputs.force_skip_tests != 'true' }} - run: | - npm run build --workspace=@qwen-code/webui - - - name: 'Run Tests' - if: |- - ${{ github.event.inputs.force_skip_tests != 'true' }} - working-directory: 'packages/vscode-ide-companion' - run: | - npm run test:ci - env: - OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' - OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - - # Second job: Build platform-specific VSIXes in parallel - build: - needs: 'prepare' - strategy: - fail-fast: false - matrix: - include: - # Platform-specific builds (with node-pty native binaries) - - os: 'ubuntu-latest' - target: 'linux-x64' - universal: false - # macOS 15 (x64): use macos-15-intel - # Endpoint Badge: macos-latest-large, macos-15-large, or macos-15-intel - - os: 'macos-15-intel' - target: 'darwin-x64' - universal: false - # macOS 15 Arm64: use macos-latest - # Endpoint Badge: macos-latest, macos-15, or macos-15-xlarge - - os: 'macos-latest' - target: 'darwin-arm64' - universal: false - - os: 'windows-latest' - target: 'win32-x64' - universal: false - # Universal fallback (without node-pty, uses child_process) - - os: 'ubuntu-latest' - target: '' - universal: true - - runs-on: '${{ matrix.os }}' - permissions: - contents: 'read' - - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - ref: '${{ github.event.inputs.ref || github.sha }}' - fetch-depth: 0 - - - name: 'Setup Node.js' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - - - name: 'Install Dependencies' - env: - NPM_CONFIG_PREFER_OFFLINE: 'true' - run: |- - npm ci - - - name: 'Install VSCE' - run: |- - npm install -g @vscode/vsce - - - name: 'Update package version' - env: - RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' - shell: 'bash' - run: |- - npm run release:version -- "${RELEASE_VERSION}" - - - name: 'Prepare VSCode Extension' - env: - UNIVERSAL_BUILD: '${{ matrix.universal }}' - VSCODE_TARGET: '${{ matrix.target }}' - run: | - # Build and stage the extension + bundled CLI - npm --workspace=qwen-code-vscode-ide-companion run prepackage - - - name: 'Package VSIX (platform-specific)' - if: '${{ matrix.target != '''' }}' - working-directory: 'packages/vscode-ide-companion' - run: |- - if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then - vsce package --no-dependencies --pre-release --target ${{ matrix.target }} \ - --out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.vsix - else - vsce package --no-dependencies --target ${{ matrix.target }} \ - --out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.vsix - fi - shell: 'bash' - - - name: 'Package VSIX (universal)' - if: '${{ matrix.target == '''' }}' - working-directory: 'packages/vscode-ide-companion' - run: |- - if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then - vsce package --no-dependencies --pre-release \ - --out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-universal.vsix - else - vsce package --no-dependencies \ - --out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-universal.vsix - fi - shell: 'bash' - - - name: 'Upload VSIX Artifact' - uses: 'actions/upload-artifact@v4' - with: - name: 'vsix-${{ matrix.target || ''universal'' }}' - path: 'qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-*.vsix' - if-no-files-found: 'error' - - # Third job: Publish all VSIXes to marketplaces - publish: - needs: - - 'prepare' - - 'build' - runs-on: 'ubuntu-latest' - environment: - name: 'production-release' - url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ needs.prepare.outputs.release_tag }}' - permissions: - contents: 'read' - issues: 'write' - - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - ref: '${{ github.event.inputs.ref || github.sha }}' - - - name: 'Download all VSIX artifacts' - uses: 'actions/download-artifact@v4' - with: - pattern: 'vsix-*' - path: 'vsix-artifacts' - merge-multiple: true - - - name: 'List downloaded artifacts' - run: |- - echo "Downloaded VSIX files:" - ls -la vsix-artifacts/ - - - name: 'Install VSCE and OVSX' - run: |- - npm install -g @vscode/vsce - npm install -g ovsx - - - name: 'Publish to Microsoft Marketplace' - if: '${{ needs.prepare.outputs.is_dry_run == ''false'' && needs.prepare.outputs.is_preview != ''true'' }}' - env: - VSCE_PAT: '${{ secrets.VSCE_PAT }}' - run: |- - echo "Publishing to Microsoft Marketplace..." - for vsix in vsix-artifacts/*.vsix; do - echo "Publishing: ${vsix}" - vsce publish --packagePath "${vsix}" --pat "${VSCE_PAT}" --skip-duplicate - done - - - name: 'Publish to OpenVSX' - if: '${{ needs.prepare.outputs.is_dry_run == ''false'' }}' - env: - OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}' - run: |- - echo "Publishing to OpenVSX..." - for vsix in vsix-artifacts/*.vsix; do - echo "Publishing: ${vsix}" - if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then - ovsx publish "${vsix}" --pat "${OVSX_TOKEN}" --pre-release - else - ovsx publish "${vsix}" --pat "${OVSX_TOKEN}" - fi - done - - - name: 'Upload all VSIXes as release artifacts (dry run)' - if: '${{ needs.prepare.outputs.is_dry_run == ''true'' }}' - uses: 'actions/upload-artifact@v4' - with: - name: 'all-vsix-packages-${{ needs.prepare.outputs.release_version }}' - path: 'vsix-artifacts/*.vsix' - if-no-files-found: 'error' - - report-failure: - name: 'Create Issue on Failure' - needs: - - 'prepare' - - 'build' - - 'publish' - if: |- - ${{ - always() && - ( - needs.build.result == 'failure' || - needs.build.result == 'cancelled' || - needs.publish.result == 'failure' || - needs.publish.result == 'cancelled' - ) - }} - runs-on: 'ubuntu-latest' - permissions: - contents: 'read' - issues: 'write' - steps: - - name: 'Create failure issue' - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' - DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - GH_REPO: '${{ github.repository }}' - run: |- - gh issue create \ - --repo "${GH_REPO}" \ - --title "VSCode IDE Companion Release Failed for ${RELEASE_VERSION} on $(date +'%Y-%m-%d')" \ - --body "The VSCode IDE Companion release workflow failed. See the full run for details: ${DETAILS_URL}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index ffcda3dc0f..0000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,227 +0,0 @@ -name: 'Release' - -on: - schedule: - # Runs every day at midnight UTC for the nightly release. - - cron: '0 0 * * *' - # Runs every Tuesday at 23:59 UTC for the preview release. - - cron: '59 23 * * 2' - workflow_dispatch: - inputs: - version: - description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.' - required: false # Not required for scheduled runs - type: 'string' - ref: - description: 'The branch or ref (full git sha) to release from.' - required: true - type: 'string' - default: 'main' - dry_run: - description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' - required: true - type: 'boolean' - default: true - create_nightly_release: - description: 'Auto apply the nightly release tag, input version is ignored.' - required: false - type: 'boolean' - default: false - create_preview_release: - description: 'Auto apply the preview release tag, input version is ignored.' - required: false - type: 'boolean' - default: false - force_skip_tests: - description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' - required: false - type: 'boolean' - default: false - -jobs: - release: - runs-on: 'ubuntu-latest' - environment: - name: 'production-release' - url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ steps.version.outputs.RELEASE_TAG }}' - if: |- - ${{ github.repository == 'QwenLM/qwen-code' }} - permissions: - contents: 'write' - packages: 'write' - id-token: 'write' - issues: 'write' # For creating issues on failure - outputs: - RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' - - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - ref: '${{ github.event.inputs.ref || github.sha }}' - fetch-depth: 0 - - - name: 'Set booleans for simplified logic' - env: - CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' - CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' - EVENT_NAME: '${{ github.event_name }}' - CRON: '${{ github.event.schedule }}' - DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' - id: 'vars' - run: |- - is_nightly="false" - if [[ "${CRON}" == "0 0 * * *" || "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then - is_nightly="true" - fi - echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}" - - is_preview="false" - if [[ "${CRON}" == "59 23 * * 2" || "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then - is_preview="true" - fi - echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}" - - is_dry_run="false" - if [[ "${DRY_RUN_INPUT}" == "true" ]]; then - is_dry_run="true" - fi - echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" - - - name: 'Setup Node.js' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: 'Install Dependencies' - run: |- - npm ci - - - name: 'Get the version' - id: 'version' - run: | - VERSION_ARGS=() - if [[ "${IS_NIGHTLY}" == "true" ]]; then - VERSION_ARGS+=(--type=nightly) - elif [[ "${IS_PREVIEW}" == "true" ]]; then - VERSION_ARGS+=(--type=preview) - if [[ -n "${MANUAL_VERSION}" ]]; then - VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}") - fi - else - VERSION_ARGS+=(--type=stable) - if [[ -n "${MANUAL_VERSION}" ]]; then - VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}") - fi - fi - - VERSION_JSON=$(node scripts/get-release-version.js "${VERSION_ARGS[@]}") - echo "RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)" >> "$GITHUB_OUTPUT" - echo "RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion)" >> "$GITHUB_OUTPUT" - echo "NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag)" >> "$GITHUB_OUTPUT" - - echo "PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag)" >> "$GITHUB_OUTPUT" - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' - IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' - MANUAL_VERSION: '${{ inputs.version }}' - - - name: 'Run Tests' - if: |- - ${{ github.event.inputs.force_skip_tests != 'true' }} - run: | - npm run preflight - 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 }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - - - name: 'Configure Git User' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: 'Create and switch to a release branch' - id: 'release_branch' - env: - RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' - run: |- - BRANCH_NAME="release/${RELEASE_TAG}" - git switch -c "${BRANCH_NAME}" - echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" - - - name: 'Update package versions' - env: - RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}' - run: |- - npm run release:version "${RELEASE_VERSION}" - - - name: 'Commit and Conditionally Push package versions' - 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 package.json package-lock.json packages/*/package.json - if git diff --staged --quiet; then - echo "No version changes to commit" - else - git commit -m "chore(release): ${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 Bundle and Prepare Package' - run: |- - npm run bundle - npm run prepare:package - - - name: 'Configure npm for publishing' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version: '20' - registry-url: 'https://registry.npmjs.org' - scope: '@qwen-code' - - - name: 'Publish @qwen-code/qwen-code' - working-directory: 'dist' - 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 GitHub Release and Tag' - if: |- - ${{ steps.vars.outputs.is_dry_run == 'false' }} - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' - RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' - PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}' - run: |- - gh release create "${RELEASE_TAG}" \ - dist/cli.js \ - --target "$RELEASE_BRANCH" \ - --title "Release ${RELEASE_TAG}" \ - --notes-start-tag "$PREVIOUS_RELEASE_TAG" \ - --generate-notes - - - name: 'Create Issue on Failure' - if: |- - ${{ failure() }} - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }} || "N/A"' - DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - run: |- - gh issue create \ - --title "Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \ - --body "The release workflow failed. See the full run for details: ${DETAILS_URL}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 87354b5706..0000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: 'Mark stale issues and pull requests' - -# Run as a daily cron at 1:30 AM -on: - schedule: - - cron: '30 1 * * *' - workflow_dispatch: - -jobs: - stale: - runs-on: 'ubuntu-latest' - if: |- - ${{ github.repository == 'google-gemini/gemini-cli' }} - permissions: - issues: 'write' - pull-requests: 'write' - concurrency: - group: '${{ github.workflow }}-stale' - cancel-in-progress: true - steps: - - uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9 - with: - repo-token: '${{ secrets.GITHUB_TOKEN }}' - stale-issue-message: >- - This issue has been automatically marked as stale due to 60 days of inactivity. - It will be closed in 14 days if no further activity occurs. - stale-pr-message: >- - This pull request has been automatically marked as stale due to 60 days of inactivity. - It will be closed in 14 days if no further activity occurs. - close-issue-message: >- - This issue has been closed due to 14 additional days of inactivity after being marked as stale. - If you believe this is still relevant, feel free to comment or reopen the issue. Thank you! - close-pr-message: >- - This pull request has been closed due to 14 additional days of inactivity after being marked as stale. - If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing! - days-before-stale: 60 - days-before-close: 14 - exempt-issue-labels: 'pinned,security' - exempt-pr-labels: 'pinned,security' diff --git a/.github/workflows/terminal-bench.yml b/.github/workflows/terminal-bench.yml deleted file mode 100644 index 0a46768889..0000000000 --- a/.github/workflows/terminal-bench.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: 'Terminal Bench Tests' - -on: - push: - branches: - - 'feat/tbench*' - workflow_dispatch: - inputs: - version: - description: 'The version to test.' - required: true - type: 'string' - default: 'latest' - release: - types: ['published'] - -jobs: - terminal-bench: - name: 'Terminal Bench (Task: ${{ matrix.task_id }})' - runs-on: 'ubuntu-latest' - strategy: - fail-fast: false - matrix: - task_id: - - 'hello-world' - - 'swe-bench-astropy-1' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - submodules: 'recursive' - - name: 'Install uv and set the python version' - uses: 'astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6' # v6 - with: - python-version: '3.12' - - - name: 'Set up Node.js 20.x' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version: '20.x' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - registry-url: 'https://registry.npmjs.org/' - - - name: 'Configure npm for rate limiting' - run: |- - npm config set fetch-retry-mintimeout 20000 - npm config set fetch-retry-maxtimeout 120000 - npm config set fetch-retries 5 - npm config set fetch-timeout 300000 - - - name: 'Install dependencies' - run: |- - npm ci --prefer-offline --no-audit --progress=false - - - name: 'Build project' - run: |- - npm run build - - - name: 'Run Terminal Bench Oracle (task: ${{ matrix.task_id }})' - run: 'npm run test:terminal-bench:oracle' - timeout-minutes: 30 - env: - CI: 'true' - NODE_ENV: 'test' - VERBOSE: 'true' - KEEP_OUTPUT: 'true' - TB_TASK_ID: '${{ matrix.task_id }}' - TB_TIMEOUT_MINUTES: '30' - - - name: 'Run Terminal Bench Qwen (task: ${{ matrix.task_id }})' - run: 'npm run test:terminal-bench:qwen' - timeout-minutes: 30 - env: - OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' - OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - CI: 'true' - NODE_ENV: 'test' - VERBOSE: 'true' - KEEP_OUTPUT: 'true' - TB_TASK_ID: '${{ matrix.task_id }}' - TB_TIMEOUT_MINUTES: '30' - - - name: 'Upload test artifacts' - if: 'always()' - uses: 'actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b' # ratchet:actions/upload-artifact@v4 - with: - name: 'terminal-bench-${{ matrix.task_id }}-output' - path: | - .integration-tests/** - !.integration-tests/**/*.lock - !.integration-tests/**/tb.lock - integration-tests/**/*.log - if-no-files-found: 'warn' - retention-days: 7 diff --git a/.gitignore b/.gitignore index a923e9bc15..27e0ab904c 100644 --- a/.gitignore +++ b/.gitignore @@ -47,11 +47,15 @@ packages/*/coverage/ # Generated files packages/cli/src/generated/ packages/core/src/generated/ +packages/web-templates/src/generated/ .integration-tests/ packages/vscode-ide-companion/*.vsix # Qwen Code Configs + .qwen/ +!.qwen/commands/ +!.qwen/skills/ logs/ # GHA credentials gha-creds-*.json @@ -70,6 +74,8 @@ __pycache__/ integration-tests/concurrent-runner/output/ integration-tests/concurrent-runner/task-* +integration-tests/terminal-capture/scenarios/screenshots/ + # storybook *storybook.log storybook-static diff --git a/.prettierignore b/.prettierignore index f4330b7e68..c9ae7e56ab 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,3 +18,4 @@ eslint.config.js gha-creds-*.json junit.xml Thumbs.db +packages/cli/src/services/insight/templates/insightTemplate.ts diff --git a/.qwen/skills/pr-review/SKILL.md b/.qwen/skills/pr-review/SKILL.md new file mode 100644 index 0000000000..52bf75427f --- /dev/null +++ b/.qwen/skills/pr-review/SKILL.md @@ -0,0 +1,104 @@ +--- +name: pr-review +description: Reviews pull requests with code analysis and terminal smoke testing. Applies when examining code changes, running CLI tests, or when 'PR review', 'code review', 'terminal screenshot', 'visual test' is mentioned. +--- + +# PR Review — Code Review + Terminal Smoke Testing + +## Workflow + +### 1. Fetch PR Information + +```bash +# List open PRs +gh pr list + +# View PR details +gh pr view + +# Get diff +gh pr diff +``` + +### 2. Code Review + +Analyze changes across the following dimensions: + +- **Correctness** — Is the logic correct? Are edge cases handled? +- **Code Style** — Does it follow existing code style and conventions? +- **Performance** — Are there any performance concerns? +- **Test Coverage** — Are there corresponding tests for the changes? +- **Security** — Does it introduce any security risks? + +Output format: + +- 🔴 **Critical** — Must fix +- 🟡 **Suggestion** — Suggested improvement +- đŸŸĸ **Nice to have** — Optional optimization + +### 3. Terminal Smoke Testing (Run for Every PR) + +**Run terminal-capture for every PR review**, not just UI changes. Reasons: + +- **Smoke Test** — Verify the CLI starts correctly and responds to user input, ensuring the PR didn't break anything +- **Visual Verification** — If there are UI changes, screenshots provide the most intuitive review evidence +- **Documentation** — Attach screenshots to the PR comments so reviewers can see the results without building locally + +```bash +# Checkout branch & build +gh pr checkout +npm run build +``` + +#### Scenario Selection Strategy + +Choose appropriate scenarios based on the PR's scope of changes: + +| PR Type | Recommended Scenarios | Description | +| ------------------------------------- | ------------------------------------------------------------ | --------------------------------- | +| **Any PR** (default) | smoke test: send `hi`, verify startup & response | Minimal-cost smoke validation | +| Slash command changes | Corresponding command scenarios (`/about`, `/context`, etc.) | Verify command output correctness | +| Ink component / layout changes | Multiple scenarios + full-flow long screenshot | Verify visual effects | +| Large refactors / dependency upgrades | Run `scenarios/all.ts` fully | Full regression | + +#### Running Screenshots + +```bash +# Write scenario config to integration-tests/terminal-capture/scenarios/ +# See terminal-capture skill for FlowStep API reference + +# Single scenario +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/.ts + + +# Check output in screenshots/ directory +``` + +#### Minimal Smoke Test Example + +No need to write a new scenario file — just use the existing `about.ts`. It sends "hi" then runs `/about`, covering startup + input + command response: + +```bash +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts +``` + +### 4. Upload Screenshots to PR + +Use Playwright MCP browser to upload screenshots to the PR comments (images hosted at `github.com/user-attachments/assets/`, zero side effects): + +1. Open the PR page with Playwright: `https://github.com//pull/` +2. Click the comment text box and enter a comment title (e.g., `## 📷 Terminal Smoke Test Screenshots`) +3. Click the "Paste, drop, or click to add files" button to trigger the file picker +4. Upload screenshot PNG files via `browser_file_upload` (can upload multiple one by one) +5. Wait for GitHub to process (about 2-3 seconds) — image links auto-insert into the comment box +6. Click the "Comment" button to submit + +> **Prerequisite**: Playwright MCP needs `--user-data-dir` configured to persist GitHub login session. First time use requires manually logging into GitHub in the Playwright browser. + +### 5. Submit Review + +Submit code review comments via `gh pr review`: + +```bash +gh pr review --comment --body "review content" +``` diff --git a/.qwen/skills/terminal-capture/SKILL.md b/.qwen/skills/terminal-capture/SKILL.md new file mode 100644 index 0000000000..adf8fff13a --- /dev/null +++ b/.qwen/skills/terminal-capture/SKILL.md @@ -0,0 +1,197 @@ +--- +name: terminal-capture +description: Automates terminal UI screenshot testing for CLI commands. Applies when reviewing PRs that affect CLI output, testing slash commands (/about, /context, /auth, /export), generating visual documentation, or when 'terminal screenshot', 'CLI test', 'visual test', or 'terminal-capture' is mentioned. +--- + +# Terminal Capture — CLI Terminal Screenshot Automation + +Drive terminal interactions and screenshots via TypeScript configuration, used for visual verification during PR reviews. + +## Prerequisites + +Ensure the following dependencies are installed before running: + +```bash +npm install # Install project dependencies (including node-pty, xterm, playwright, etc.) +npx playwright install chromium # Install Playwright browser +``` + +## Architecture + +``` +node-pty (pseudo-terminal) → ANSI byte stream → xterm.js (Playwright headless) → Screenshot +``` + +Core files: + +| File | Purpose | +| -------------------------------------------------------- | ------------------------------------------------------------------------ | +| `integration-tests/terminal-capture/terminal-capture.ts` | Low-level engine (PTY + xterm.js + Playwright) | +| `integration-tests/terminal-capture/scenario-runner.ts` | Scenario executor (parses config, drives interactions, auto-screenshots) | +| `integration-tests/terminal-capture/run.ts` | CLI entry point (batch run scenarios) | +| `integration-tests/terminal-capture/scenarios/*.ts` | Scenario configuration files | + +## Quick Start + +### 1. Write Scenario Configuration + +Create a `.ts` file under `integration-tests/terminal-capture/scenarios/`: + +```typescript +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: '/about', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, // Relative to this config file's location + flow: [ + { type: 'Hi, can you help me understand this codebase?' }, + { type: '/about' }, + ], +} satisfies ScenarioConfig; +``` + +### 2. Run + +```bash +# Single scenario +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + +# Batch (entire directory) +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ +``` + +### 3. Output + +Screenshots are saved to `integration-tests/terminal-capture/scenarios/screenshots/{name}/`: + +| File | Description | +| --------------- | ---------------------------------- | +| `01-01.png` | Step 1 input state | +| `01-02.png` | Step 1 execution result | +| `02-01.png` | Step 2 input state | +| `02-02.png` | Step 2 execution result | +| `full-flow.png` | Final state full-length screenshot | + +## FlowStep API + +Each flow step can contain the following fields: + +### `type: string` — Input Text + +Automatic behavior: Input text → Screenshot (01) → Press Enter → Wait for output to stabilize → Screenshot (02). + +```typescript +{ + type: 'Hello'; +} // Plain text +{ + type: '/about'; +} // Slash command (auto-completion handled automatically) +``` + +**Special rule**: If the next step is `key`, do not auto-press Enter (hand over control to the key sequence). + +### `key: string | string[]` — Send Key Press + +Used for menu selection, Tab completion, and other interactions. Does not auto-press Enter or auto-screenshot. + +Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`, `Tab`, `Escape`, `Backspace`, `Space`, `Home`, `End`, `PageUp`, `PageDown`, `Delete` + +```typescript +{ + key: 'ArrowDown'; +} // Single key +{ + key: ['ArrowDown', 'ArrowDown', 'Enter']; +} // Multiple keys +``` + +Auto-screenshot is triggered after the key sequence ends (when the next step is not a `key`). + +### `capture` / `captureFull` — Explicit Screenshot + +Use as a standalone step, or override automatic naming: + +```typescript +{ + capture: 'initial.png'; +} // Screenshot current viewport only +{ + captureFull: 'all-output.png'; +} // Screenshot full scrollback buffer +``` + +## Scenario Examples + +### Basic: Input + Command + +```typescript +flow: [{ type: 'explain this project' }, { type: '/about' }]; +``` + +### Secondary Menu Selection (/auth) + +```typescript +flow: [ + { type: '/auth' }, + { key: 'ArrowDown' }, // Select API Key option + { key: 'Enter' }, // Confirm + { type: 'sk-xxx' }, // Input API key +]; +``` + +### Tab Completion Selection (/export) + +```typescript +flow: [ + { type: 'Tell me about yourself' }, + { type: '/export' }, // No auto-Enter (next step is key) + { key: 'Tab' }, // Pop format selection + { key: 'ArrowDown' }, // Select format + { key: 'Enter' }, // Confirm → auto-screenshot +]; +``` + +### Array Batch (Multiple Scenarios in One File) + +```typescript +export default [ + { name: '/about', spawn: [...], flow: [...] }, + { name: '/context', spawn: [...], flow: [...] }, +] satisfies ScenarioConfig[]; +``` + +## Integration with PR Review + +This tool is commonly used for visual verification during PR reviews. For the complete code review + screenshot workflow, see the [pr-review](../pr-review/SKILL.md) skill. + +## Troubleshooting + +| Issue | Cause | Solution | +| ------------------------------------ | ------------------------------------- | ---------------------------------------------------- | +| Playwright error `browser not found` | Browser not installed | `npx playwright install chromium` | +| Blank screenshot | Process starts slowly or build failed | Ensure `npm run build` succeeds, check spawn command | +| PTY-related errors | node-pty native module not compiled | `npm rebuild node-pty` | +| Unstable screenshot output | Terminal output not fully rendered | Check if the scenario needs additional wait time | + +## Full ScenarioConfig Type + +```typescript +interface ScenarioConfig { + name: string; // Scenario name (also used as screenshot subdirectory name) + spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"] + flow: FlowStep[]; // Interaction steps + terminal?: { + // Terminal configuration (all optional) + cols?: number; // Number of columns, default 100 + rows?: number; // Number of rows, default 28 + theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl + chrome?: boolean; // macOS window decorations, default true + title?: string; // Window title, default "Terminal" + fontSize?: number; // Font size + cwd?: string; // Working directory (relative to config file) + }; + outputDir?: string; // Screenshot output directory (relative to config file) +} +``` diff --git a/Dockerfile b/Dockerfile index 52a4d4416c..6d30d6ad3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,12 +19,12 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin COPY . /home/node/app WORKDIR /home/node/app -# Install dependencies and build packages -# Use scripts/build.js which handles workspace dependencies in correct order +# Install dependencies, build workspaces, bundle into a single distributable, and pack RUN npm ci \ && npm run build \ - && npm pack -w @qwen-code/qwen-code --pack-destination ./packages/cli/dist \ - && npm pack -w @qwen-code/qwen-code-core --pack-destination ./packages/core/dist + && npm run bundle \ + && npm run prepare:package \ + && cd dist && npm pack # Runtime stage FROM docker.io/library/node:20-slim @@ -61,9 +61,8 @@ RUN mkdir -p /usr/local/share/npm-global ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global ENV PATH=$PATH:/usr/local/share/npm-global/bin -# Copy built packages from builder stage -COPY --from=builder /home/node/app/packages/cli/dist/*.tgz /tmp/ -COPY --from=builder /home/node/app/packages/core/dist/*.tgz /tmp/ +# Copy bundled package from builder stage +COPY --from=builder /home/node/app/dist/*.tgz /tmp/ # Install built packages globally RUN npm install -g /tmp/*.tgz \ diff --git a/README.md b/README.md index b1b99e94c0..ab598666c9 100644 --- a/README.md +++ b/README.md @@ -189,12 +189,60 @@ Use the `/model` command at any time to switch between all configured models. { "modelProviders": { "openai": [ + { + "id": "qwen3.5-plus", + "name": "qwen3.5-plus (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3.5-plus with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + }, { "id": "qwen3-coder-plus", "name": "qwen3-coder-plus (Coding Plan)", "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", "description": "qwen3-coder-plus from Bailian Coding Plan", "envKey": "BAILIAN_CODING_PLAN_API_KEY" + }, + { + "id": "qwen3-coder-next", + "name": "qwen3-coder-next (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3-coder-next with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + }, + { + "id": "glm-4.7", + "name": "glm-4.7 (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "glm-4.7 with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + }, + { + "id": "kimi-k2.5", + "name": "kimi-k2.5 (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "kimi-k2.5 with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } } ] }, @@ -212,7 +260,7 @@ Use the `/model` command at any time to switch between all configured models. } ``` -> Subscribe to the Coding Plan at [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan). +> Subscribe to the Coding Plan and get your API key at [Alibaba Cloud Bailian](https://modelstudio.console.aliyun.com/?tab=dashboard#/efm/coding_plan). diff --git a/SECURITY.md b/SECURITY.md index 4e7d8ce79e..d4ae9df9ee 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,9 @@ -# Reporting Security Issues +# Security Policy -Please report any security issue or Higress crash report to [ASRC](https://security.alibaba.com/) (Alibaba Security Response Center) where the issue will be triaged appropriately. +## Reporting a Vulnerability -Thank you for helping keep our project secure. +If you believe you have discovered a security vulnerability, please report it to us through the following portal: [Report Security Issue](https://yundun.console.aliyun.com/?p=xznew#/taskmanagement/tasks/detail/151) + +> **Note:** This channel is strictly for reporting security-related issues. Non-security vulnerabilities or general bug reports will not be addressed here. + +We sincerely appreciate your responsible disclosure and your contribution to helping us keep our project secure. diff --git a/docs/developers/roadmap.md b/docs/developers/roadmap.md index 125a4d36ee..83cd42355a 100644 --- a/docs/developers/roadmap.md +++ b/docs/developers/roadmap.md @@ -2,13 +2,13 @@ > **Objective**: Catch up with Claude Code's product functionality, continuously refine details, and enhance user experience. -| Category | Phase 1 | Phase 2 | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| User Experience | ✅ Terminal UI
✅ Support OpenAI Protocol
✅ Settings
✅ OAuth
✅ Cache Control
✅ Memory
✅ Compress
✅ Theme | Better UI
OnBoarding
LogView
✅ Session
Permission
🔄 Cross-platform Compatibility | -| Coding Workflow | ✅ Slash Commands
✅ MCP
✅ PlanMode
✅ TodoWrite
✅ SubAgent
✅ Multi Model
✅ Chat Management
✅ Tools (WebFetch, Bash, TextSearch, FileReadFile, EditFile) | 🔄 Hooks
SubAgent (enhanced)
✅ Skill
✅ Headless Mode
✅ Tools (WebSearch) | -| Building Open Capabilities | ✅ Custom Commands | ✅ QwenCode SDK
Extension | -| Integrating Community Ecosystem | | ✅ VSCode Plugin
🔄 ACP/Zed
✅ GHA | -| Administrative Capabilities | ✅ Stats
✅ Feedback | Costs
Dashboard | +| Category | Phase 1 | Phase 2 | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| User Experience | ✅ Terminal UI
✅ Support OpenAI Protocol
✅ Settings
✅ OAuth
✅ Cache Control
✅ Memory
✅ Compress
✅ Theme | Better UI
OnBoarding
LogView
✅ Session
Permission
🔄 Cross-platform Compatibility
✅ Coding Plan
✅ Anthropic Provider
✅ Multimodal Input
✅ Unified WebUI | +| Coding Workflow | ✅ Slash Commands
✅ MCP
✅ PlanMode
✅ TodoWrite
✅ SubAgent
✅ Multi Model
✅ Chat Management
✅ Tools (WebFetch, Bash, TextSearch, FileReadFile, EditFile) | 🔄 Hooks
✅ Skill
✅ Headless Mode
✅ Tools (WebSearch)
✅ LSP Support
✅ Concurrent Runner | +| Building Open Capabilities | ✅ Custom Commands | ✅ QwenCode SDK
✅ Extension System | +| Integrating Community Ecosystem | | ✅ VSCode Plugin
✅ ACP/Zed
✅ GHA | +| Administrative Capabilities | ✅ Stats
✅ Feedback | Costs
Dashboard
✅ User Feedback Dialog | > For more details, please see the list below. @@ -16,39 +16,48 @@ #### Completed Features -| Feature | Version | Description | Category | -| ----------------------- | --------- | ------------------------------------------------------- | ------------------------------- | -| Skill | `V0.6.0` | Extensible custom AI skills | Coding Workflow | -| Github Actions | `V0.5.0` | qwen-code-action and automation | Integrating Community Ecosystem | -| VSCode Plugin | `V0.5.0` | VSCode extension plugin | Integrating Community Ecosystem | -| QwenCode SDK | `V0.4.0` | Open SDK for third-party integration | Building Open Capabilities | -| Session | `V0.4.0` | Enhanced session management | User Experience | -| i18n | `V0.3.0` | Internationalization and multilingual support | User Experience | -| Headless Mode | `V0.3.0` | Headless mode (non-interactive) | Coding Workflow | -| ACP/Zed | `V0.2.0` | ACP and Zed editor integration | Integrating Community Ecosystem | -| Terminal UI | `V0.1.0+` | Interactive terminal user interface | User Experience | -| Settings | `V0.1.0+` | Configuration management system | User Experience | -| Theme | `V0.1.0+` | Multi-theme support | User Experience | -| Support OpenAI Protocol | `V0.1.0+` | Support for OpenAI API protocol | User Experience | -| Chat Management | `V0.1.0+` | Session management (save, restore, browse) | Coding Workflow | -| MCP | `V0.1.0+` | Model Context Protocol integration | Coding Workflow | -| Multi Model | `V0.1.0+` | Multi-model support and switching | Coding Workflow | -| Slash Commands | `V0.1.0+` | Slash command system | Coding Workflow | -| Tool: Bash | `V0.1.0+` | Shell command execution tool (with is_background param) | Coding Workflow | -| Tool: FileRead/EditFile | `V0.1.0+` | File read/write and edit tools | Coding Workflow | -| Custom Commands | `V0.1.0+` | Custom command loading | Building Open Capabilities | -| Feedback | `V0.1.0+` | Feedback mechanism (/bug command) | Administrative Capabilities | -| Stats | `V0.1.0+` | Usage statistics and quota display | Administrative Capabilities | -| Memory | `V0.0.9+` | Project-level and global memory management | User Experience | -| Cache Control | `V0.0.9+` | Prompt caching control (Anthropic, DashScope) | User Experience | -| PlanMode | `V0.0.14` | Task planning mode | Coding Workflow | -| Compress | `V0.0.11` | Chat compression mechanism | User Experience | -| SubAgent | `V0.0.11` | Dedicated sub-agent system | Coding Workflow | -| TodoWrite | `V0.0.10` | Task management and progress tracking | Coding Workflow | -| Tool: TextSearch | `V0.0.8+` | Text search tool (grep, supports .qwenignore) | Coding Workflow | -| Tool: WebFetch | `V0.0.7+` | Web content fetching tool | Coding Workflow | -| Tool: WebSearch | `V0.0.7+` | Web search tool (using Tavily API) | Coding Workflow | -| OAuth | `V0.0.5+` | OAuth login authentication (Qwen OAuth) | User Experience | +| Feature | Version | Description | Category | Phase | +| ----------------------- | --------- | ------------------------------------------------------- | ------------------------------- | ----- | +| **Coding Plan** | `V0.10.0` | Bailian Coding Plan authentication & models | User Experience | 2 | +| Unified WebUI | `V0.9.0` | Shared WebUI component library for VSCode/CLI | User Experience | 2 | +| Export Chat | `V0.8.0` | Export sessions to Markdown/HTML/JSON/JSONL | User Experience | 2 | +| Extension System | `V0.8.0` | Full extension management with slash commands | Building Open Capabilities | 2 | +| LSP Support | `V0.7.0` | Experimental LSP service (`--experimental-lsp`) | Coding Workflow | 2 | +| Anthropic Provider | `V0.7.0` | Anthropic API provider support | User Experience | 2 | +| User Feedback Dialog | `V0.7.0` | In-app feedback collection with fatigue mechanism | Administrative Capabilities | 2 | +| Concurrent Runner | `V0.6.0` | Batch CLI execution with Git integration | Coding Workflow | 2 | +| Multimodal Input | `V0.6.0` | Image, PDF, audio, video input support | User Experience | 2 | +| Skill | `V0.6.0` | Extensible custom AI skills (experimental) | Coding Workflow | 2 | +| Github Actions | `V0.5.0` | qwen-code-action and automation | Integrating Community Ecosystem | 1 | +| VSCode Plugin | `V0.5.0` | VSCode extension plugin | Integrating Community Ecosystem | 1 | +| QwenCode SDK | `V0.4.0` | Open SDK for third-party integration | Building Open Capabilities | 1 | +| Session | `V0.4.0` | Enhanced session management | User Experience | 1 | +| i18n | `V0.3.0` | Internationalization and multilingual support | User Experience | 1 | +| Headless Mode | `V0.3.0` | Headless mode (non-interactive) | Coding Workflow | 1 | +| ACP/Zed | `V0.2.0` | ACP and Zed editor integration | Integrating Community Ecosystem | 1 | +| Terminal UI | `V0.1.0+` | Interactive terminal user interface | User Experience | 1 | +| Settings | `V0.1.0+` | Configuration management system | User Experience | 1 | +| Theme | `V0.1.0+` | Multi-theme support | User Experience | 1 | +| Support OpenAI Protocol | `V0.1.0+` | Support for OpenAI API protocol | User Experience | 1 | +| Chat Management | `V0.1.0+` | Session management (save, restore, browse) | Coding Workflow | 1 | +| MCP | `V0.1.0+` | Model Context Protocol integration | Coding Workflow | 1 | +| Multi Model | `V0.1.0+` | Multi-model support and switching | Coding Workflow | 1 | +| Slash Commands | `V0.1.0+` | Slash command system | Coding Workflow | 1 | +| Tool: Bash | `V0.1.0+` | Shell command execution tool (with is_background param) | Coding Workflow | 1 | +| Tool: FileRead/EditFile | `V0.1.0+` | File read/write and edit tools | Coding Workflow | 1 | +| Custom Commands | `V0.1.0+` | Custom command loading | Building Open Capabilities | 1 | +| Feedback | `V0.1.0+` | Feedback mechanism (/bug command) | Administrative Capabilities | 1 | +| Stats | `V0.1.0+` | Usage statistics and quota display | Administrative Capabilities | 1 | +| Memory | `V0.0.9+` | Project-level and global memory management | User Experience | 1 | +| Cache Control | `V0.0.9+` | Prompt caching control (Anthropic, DashScope) | User Experience | 1 | +| PlanMode | `V0.0.14` | Task planning mode | Coding Workflow | 1 | +| Compress | `V0.0.11` | Chat compression mechanism | User Experience | 1 | +| SubAgent | `V0.0.11` | Dedicated sub-agent system | Coding Workflow | 1 | +| TodoWrite | `V0.0.10` | Task management and progress tracking | Coding Workflow | 1 | +| Tool: TextSearch | `V0.0.8+` | Text search tool (grep, supports .qwenignore) | Coding Workflow | 1 | +| Tool: WebFetch | `V0.0.7+` | Web content fetching tool | Coding Workflow | 1 | +| Tool: WebSearch | `V0.0.7+` | Web search tool (using Tavily API) | Coding Workflow | 1 | +| OAuth | `V0.0.5+` | OAuth login authentication (Qwen OAuth) | User Experience | 1 | #### Features to Develop @@ -60,7 +69,6 @@ | Cross-platform Compatibility | P1 | In Progress | Windows/Linux/macOS compatibility | User Experience | | LogView | P2 | Planned | Log viewing and debugging feature | User Experience | | Hooks | P2 | In Progress | Extension hooks system | Coding Workflow | -| Extension | P2 | Planned | Extension system | Building Open Capabilities | | Costs | P2 | Planned | Cost tracking and analysis | Administrative Capabilities | | Dashboard | P2 | Planned | Management dashboard | Administrative Capabilities | diff --git a/docs/developers/tools/sandbox.md b/docs/developers/tools/sandbox.md index 92550f1647..de3e237168 100644 --- a/docs/developers/tools/sandbox.md +++ b/docs/developers/tools/sandbox.md @@ -38,6 +38,7 @@ ls -la $(dirname $(which qwen))/../lib/node_modules/@qwen-code/qwen-code # 7.Test the version of qwen qwen -v # npm link will overwrite the global qwen. To avoid being unable to distinguish the same version number, you can uninstall the global CLI first + ``` #### 3、Create your sandbox Dockerfile under the root directory of your own project diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 2b56c1fb6a..bce47dd3f8 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -94,7 +94,7 @@ After entering, select `Coding Plan`: ![](https://gw.alicdn.com/imgextra/i4/O1CN01Irk0AD1ebfop69o0r_!!6000000003890-2-tps-2308-830.png) -Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch between all Bailian `Coding Plan` supported models: +Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch between all Bailian `Coding Plan` supported models (including qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, qwen3-max, glm-4.7, and kimi-k2.5): ![](https://gw.alicdn.com/imgextra/i4/O1CN01fWArmf1kaCEgSmPln_!!6000000004699-2-tps-2304-1374.png) @@ -205,7 +205,7 @@ Edit `~/.qwen/settings.json` (create it if it doesn't exist). You can mix multip > > When using the `env` field in `settings.json`, credentials are stored in plain text. For better security, prefer `.env` files or shell `export` — see [Step 2](#step-2-set-environment-variables). -For the full `modelProviders` schema and advanced options like `generationConfig`, `customHeaders`, and `extra_body`, see [Settings Reference → modelProviders](settings.md#modelproviders). +For the full `modelProviders` schema and advanced options like `generationConfig`, `customHeaders`, and `extra_body`, see [Model Providers Reference](model-providers.md). #### Step 2: Set environment variables @@ -266,12 +266,12 @@ This is the approach used in the [one-file setup example](#recommended-one-file- **Priority summary:** -| Priority | Source | Override behavior | -| ----------- | ------------------------------ | ---------------------------------------- | -| 1 (highest) | CLI flags (`--openai-api-key`) | Always wins | -| 2 | System env (`export`, inline) | Overrides `.env` and `settings.env` | -| 3 | `.env` file | Only sets if not in system env | -| 4 (lowest) | `settings.json` → `env` | Only sets if not in system env or `.env` | +| Priority | Source | Override behavior | +| ----------- | ------------------------------ | -------------------------------------------- | +| 1 (highest) | CLI flags (`--openai-api-key`) | Always wins | +| 2 | System env (`export`, inline) | Overrides `.env` and `settings.json` → `env` | +| 3 | `.env` file | Only sets if not in system env | +| 4 (lowest) | `settings.json` → `env` | Only sets if not in system env or `.env` | #### Step 3: Switch models with `/model` @@ -292,7 +292,7 @@ qwen --model "qwen3-coder-plus" # In another terminal -qwen --model "qwen3-coder-next" +qwen --model "qwen3.5-plus" ``` ## Security notes diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md new file mode 100644 index 0000000000..023f5d4e4a --- /dev/null +++ b/docs/users/configuration/model-providers.md @@ -0,0 +1,532 @@ +# Model Providers + +Qwen Code allows you to configure multiple model providers through the `modelProviders` setting in your `settings.json`. This enables you to switch between different AI models and providers using the `/model` command. + +## Overview + +Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. + +> [!note] +> +> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows. + +> [!warning] +> +> **Duplicate model IDs within the same authType:** Defining multiple models with the same `id` under a single `authType` (e.g., two entries with `"id": "gpt-4o"` in `openai`) is currently not supported. If duplicates exist, **the first occurrence wins** and subsequent duplicates are skipped with a warning. Note that the `id` field is used both as the configuration identifier and as the actual model name sent to the API, so using unique IDs (e.g., `gpt-4o-creative`, `gpt-4o-balanced`) is not a viable workaround. This is a known limitation that we plan to address in a future release. + +## Configuration Examples by Auth Type + +Below are comprehensive configuration examples for different authentication types, showing the available parameters and their combinations. + +### Supported Auth Types + +The `modelProviders` object keys must be valid `authType` values. Currently supported auth types are: + +| Auth Type | Description | +| ------------ | --------------------------------------------------------------------------------------- | +| `openai` | OpenAI-compatible APIs (OpenAI, Azure OpenAI, local inference servers like vLLM/Ollama) | +| `anthropic` | Anthropic Claude API | +| `gemini` | Google Gemini API | +| `vertex-ai` | Google Vertex AI | +| `qwen-oauth` | Qwen OAuth (hard-coded, cannot be overridden in `modelProviders`) | + +> [!warning] +> If an invalid auth type key is used (e.g., a typo like `"openai-custom"`), the configuration will be **silently skipped** and the models will not appear in the `/model` picker. Always use one of the supported auth type values listed above. + +### SDKs Used for API Requests + +Qwen Code uses the following official SDKs to send requests to each provider: + +| Auth Type | SDK Package | +| ---------------------- | ----------------------------------------------------------------------------------------------- | +| `openai` | [`openai`](https://www.npmjs.com/package/openai) - Official OpenAI Node.js SDK | +| `anthropic` | [`@anthropic-ai/sdk`](https://www.npmjs.com/package/@anthropic-ai/sdk) - Official Anthropic SDK | +| `gemini` / `vertex-ai` | [`@google/genai`](https://www.npmjs.com/package/@google/genai) - Official Google GenAI SDK | +| `qwen-oauth` | [`openai`](https://www.npmjs.com/package/openai) with custom provider (DashScope-compatible) | + +This means the `baseUrl` you configure should be compatible with the corresponding SDK's expected API format. For example, when using `openai` auth type, the endpoint must accept OpenAI API format requests. + +### OpenAI-compatible providers (`openai`) + +This auth type supports not only OpenAI's official API but also any OpenAI-compatible endpoint, including aggregated model providers like OpenRouter. + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "gpt-4o", + "name": "GPT-4o", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1", + "generationConfig": { + "timeout": 60000, + "maxRetries": 3, + "enableCacheControl": true, + "contextWindowSize": 128000, + "modalities": { + "image": true + }, + "customHeaders": { + "X-Client-Request-ID": "req-123" + }, + "extra_body": { + "enable_thinking": true, + "service_tier": "priority" + }, + "samplingParams": { + "temperature": 0.2, + "top_p": 0.8, + "max_tokens": 4096, + "presence_penalty": 0.1, + "frequency_penalty": 0.1 + } + } + }, + { + "id": "gpt-4o-mini", + "name": "GPT-4o Mini", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1", + "generationConfig": { + "timeout": 30000, + "samplingParams": { + "temperature": 0.5, + "max_tokens": 2048 + } + } + }, + { + "id": "openai/gpt-4o", + "name": "GPT-4o (via OpenRouter)", + "envKey": "OPENROUTER_API_KEY", + "baseUrl": "https://openrouter.ai/api/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 3, + "samplingParams": { + "temperature": 0.7 + } + } + } + ] + } +} +``` + +### Anthropic (`anthropic`) + +```json +{ + "modelProviders": { + "anthropic": [ + { + "id": "claude-3-5-sonnet", + "name": "Claude 3.5 Sonnet", + "envKey": "ANTHROPIC_API_KEY", + "baseUrl": "https://api.anthropic.com/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 3, + "contextWindowSize": 200000, + "samplingParams": { + "temperature": 0.7, + "max_tokens": 8192, + "top_p": 0.9 + } + } + }, + { + "id": "claude-3-opus", + "name": "Claude 3 Opus", + "envKey": "ANTHROPIC_API_KEY", + "baseUrl": "https://api.anthropic.com/v1", + "generationConfig": { + "timeout": 180000, + "samplingParams": { + "temperature": 0.3, + "max_tokens": 4096 + } + } + } + ] + } +} +``` + +### Google Gemini (`gemini`) + +```json +{ + "modelProviders": { + "gemini": [ + { + "id": "gemini-2.0-flash", + "name": "Gemini 2.0 Flash", + "envKey": "GEMINI_API_KEY", + "baseUrl": "https://generativelanguage.googleapis.com", + "capabilities": { + "vision": true + }, + "generationConfig": { + "timeout": 60000, + "maxRetries": 2, + "contextWindowSize": 1000000, + "schemaCompliance": "auto", + "samplingParams": { + "temperature": 0.4, + "top_p": 0.95, + "max_tokens": 8192, + "top_k": 40 + } + } + } + ] + } +} +``` + +### Google Vertex AI (`vertex-ai`) + +```json +{ + "modelProviders": { + "vertex-ai": [ + { + "id": "gemini-1.5-pro-vertex", + "name": "Gemini 1.5 Pro (Vertex AI)", + "envKey": "GOOGLE_API_KEY", + "baseUrl": "https://generativelanguage.googleapis.com", + "generationConfig": { + "timeout": 90000, + "contextWindowSize": 2000000, + "samplingParams": { + "temperature": 0.2, + "max_tokens": 8192 + } + } + } + ] + } +} +``` + +### Local Self-Hosted Models (via OpenAI-compatible API) + +Most local inference servers (vLLM, Ollama, LM Studio, etc.) provide an OpenAI-compatible API endpoint. Configure them using the `openai` auth type with a local `baseUrl`: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen2.5-7b", + "name": "Qwen2.5 7B (Ollama)", + "envKey": "OLLAMA_API_KEY", + "baseUrl": "http://localhost:11434/v1", + "generationConfig": { + "timeout": 300000, + "maxRetries": 1, + "contextWindowSize": 32768, + "samplingParams": { + "temperature": 0.7, + "top_p": 0.9, + "max_tokens": 4096 + } + } + }, + { + "id": "llama-3.1-8b", + "name": "Llama 3.1 8B (vLLM)", + "envKey": "VLLM_API_KEY", + "baseUrl": "http://localhost:8000/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 2, + "contextWindowSize": 128000, + "samplingParams": { + "temperature": 0.6, + "max_tokens": 8192 + } + } + }, + { + "id": "local-model", + "name": "Local Model (LM Studio)", + "envKey": "LMSTUDIO_API_KEY", + "baseUrl": "http://localhost:1234/v1", + "generationConfig": { + "timeout": 60000, + "samplingParams": { + "temperature": 0.5 + } + } + } + ] + } +} +``` + +For local servers that don't require authentication, you can use any placeholder value for the API key: + +```bash +# For Ollama (no auth required) +export OLLAMA_API_KEY="ollama" + +# For vLLM (if no auth is configured) +export VLLM_API_KEY="not-needed" +``` + +> [!note] +> +> The `extra_body` parameter is **only supported for OpenAI-compatible providers** (`openai`, `qwen-oauth`). It is ignored for Anthropic, Gemini, and Vertex AI providers. + +## Bailian Coding Plan + +Bailian Coding Plan provides a pre-configured set of Qwen models optimized for coding tasks. This feature is available for users with Bailian API access and offers a simplified setup experience with automatic model configuration updates. + +### Overview + +When you authenticate with a Bailian Coding Plan API key using the `/auth` command, Qwen Code automatically configures the following models: + +| Model ID | Name | Description | +| ---------------------- | -------------------- | -------------------------------------- | +| `qwen3.5-plus` | qwen3.5-plus | Advanced model with thinking enabled | +| `qwen3-coder-plus` | qwen3-coder-plus | Optimized for coding tasks | +| `qwen3-max-2026-01-23` | qwen3-max-2026-01-23 | Latest max model with thinking enabled | + +### Setup + +1. Obtain a Bailian Coding Plan API key: + - **China**: + - **International**: +2. Run the `/auth` command in Qwen Code +3. Select the API-KEY authentication method +4. Select your region (China or Global/International) +5. Enter your API key when prompted + +The models will be automatically configured and added to your `/model` picker. + +### Regions + +Bailian Coding Plan supports two regions: + +| Region | Endpoint | Description | +| -------------------- | ----------------------------------------------- | ----------------------- | +| China | `https://coding.dashscope.aliyuncs.com/v1` | Mainland China endpoint | +| Global/International | `https://coding-intl.dashscope.aliyuncs.com/v1` | International endpoint | + +The region is selected during authentication and stored in `settings.json` under `codingPlan.region`. To switch regions, re-run the `/auth` command and select a different region. + +### API Key Storage + +When you configure Coding Plan through the `/auth` command, the API key is stored using the reserved environment variable name `BAILIAN_CODING_PLAN_API_KEY`. By default, it is stored in the `env` field of your `settings.json` file. + +> [!warning] +> +> **Security Recommendation**: For better security, it is recommended to move the API key from `settings.json` to a separate `.env` file and load it as an environment variable. For example: +> +> ```bash +> # ~/.qwen/.env +> BAILIAN_CODING_PLAN_API_KEY=your-api-key-here +> ``` +> +> Then ensure this file is added to your `.gitignore` if you're using project-level settings. + +### Automatic Updates + +Coding Plan model configurations are versioned. When Qwen Code detects a newer version of the model template, you will be prompted to update. Accepting the update will: + +- Replace the existing Coding Plan model configurations with the latest versions +- Preserve any custom model configurations you've added manually +- Automatically switch to the first model in the updated configuration + +The update process ensures you always have access to the latest model configurations and features without manual intervention. + +### Manual Configuration (Advanced) + +If you prefer to manually configure Coding Plan models, you can add them to your `settings.json` like any OpenAI-compatible provider: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus", + "description": "Qwen3-Coder via Bailian Coding Plan", + "envKey": "YOUR_CUSTOM_ENV_KEY", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1" + } + ] + } +} +``` + +> [!note] +> +> When using manual configuration: +> +> - You can use any environment variable name for `envKey` +> - You do not need to configure `codingPlan.*` +> - **Automatic updates will not apply** to manually configured Coding Plan models + +> [!warning] +> +> If you also use automatic Coding Plan configuration, automatic updates may overwrite your manual configurations if they use the same `envKey` and `baseUrl` as the automatic configuration. To avoid this, ensure your manual configuration uses a different `envKey` if possible. + +## Resolution Layers and Atomicity + +The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers. + +| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy | +| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- | +| Programmatic overrides | `/auth` | `/auth` input | `/auth` input | `/auth` input | — | — | +| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — | +| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — | +| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — | +| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — | +| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured | + +\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. + +> [!warning] +> +> **Deprecation of `security.auth.apiKey` and `security.auth.baseUrl`:** Directly configuring API credentials via `security.auth.apiKey` and `security.auth.baseUrl` in `settings.json` is deprecated. These settings were used in historical versions for credentials entered through the UI, but the credential input flow was removed in version 0.10.1. These fields will be fully removed in a future release. **It is strongly recommended to migrate to `modelProviders`** for all model and credential configurations. Use `envKey` in `modelProviders` to reference environment variables for secure credential management instead of hardcoding credentials in settings files. + +## Generation Config Layering: The Impermeable Provider Layer + +The configuration resolution follows a strict layering model with one crucial rule: **the modelProvider layer is impermeable**. + +### How it works + +1. **When a modelProvider model IS selected** (e.g., via `/model` command choosing a provider-configured model): + - The entire `generationConfig` from the provider is applied **atomically** + - **The provider layer is completely impermeable** — lower layers (CLI, env, settings) do not participate in generationConfig resolution at all + - All fields defined in `modelProviders[].generationConfig` use the provider's values + - All fields **not defined** by the provider are set to `undefined` (not inherited from settings) + - This ensures provider configurations act as a complete, self-contained "sealed package" + +2. **When NO modelProvider model is selected** (e.g., using `--model` with a raw model ID, or using CLI/env/settings directly): + - The resolution falls through to lower layers + - Fields are populated from CLI → env → settings → defaults + - This creates a **Runtime Model** (see next section) + +### Per-field precedence for `generationConfig` + +| Priority | Source | Behavior | +| -------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| 1 | Programmatic overrides | Runtime `/model`, `/auth` changes | +| 2 | `modelProviders[authType][].generationConfig` | **Impermeable layer** - completely replaces all generationConfig fields; lower layers do not participate | +| 3 | `settings.model.generationConfig` | Only used for **Runtime Models** (when no provider model is selected) | +| 4 | Content-generator defaults | Provider-specific defaults (e.g., OpenAI vs Gemini) - only for Runtime Models | + +### Atomic field treatment + +The following fields are treated as atomic objects - provider values completely replace the entire object, no merging occurs: + +- `samplingParams` - Temperature, top_p, max_tokens, etc. +- `customHeaders` - Custom HTTP headers +- `extra_body` - Extra request body parameters + +### Example + +```json +// User settings (~/.qwen/settings.json) +{ + "model": { + "generationConfig": { + "timeout": 30000, + "samplingParams": { "temperature": 0.5, "max_tokens": 1000 } + } + } +} + +// modelProviders configuration +{ + "modelProviders": { + "openai": [{ + "id": "gpt-4o", + "envKey": "OPENAI_API_KEY", + "generationConfig": { + "timeout": 60000, + "samplingParams": { "temperature": 0.2 } + } + }] + } +} +``` + +When `gpt-4o` is selected from modelProviders: + +- `timeout` = 60000 (from provider, overrides settings) +- `samplingParams.temperature` = 0.2 (from provider, completely replaces settings object) +- `samplingParams.max_tokens` = **undefined** (not defined in provider, and provider layer does not inherit from settings — fields are explicitly set to undefined if not provided) + +When using a raw model via `--model gpt-4` (not from modelProviders, creates a Runtime Model): + +- `timeout` = 30000 (from settings) +- `samplingParams.temperature` = 0.5 (from settings) +- `samplingParams.max_tokens` = 1000 (from settings) + +The merge strategy for `modelProviders` itself is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two. + +## Provider Models vs Runtime Models + +Qwen Code distinguishes between two types of model configurations: + +### Provider Model + +- Defined in `modelProviders` configuration +- Has a complete, atomic configuration package +- When selected, its configuration is applied as an impermeable layer +- Appears in `/model` command list with full metadata (name, description, capabilities) +- Recommended for multi-model workflows and team consistency + +### Runtime Model + +- Created dynamically when using raw model IDs via CLI (`--model`), environment variables, or settings +- Not defined in `modelProviders` +- Configuration is built by "projecting" through resolution layers (CLI → env → settings → defaults) +- Automatically captured as a **RuntimeModelSnapshot** when a complete configuration is detected +- Allows reuse without re-entering credentials + +### RuntimeModelSnapshot lifecycle + +When you configure a model without using `modelProviders`, Qwen Code automatically creates a RuntimeModelSnapshot to preserve your configuration: + +```bash +# This creates a RuntimeModelSnapshot with ID: $runtime|openai|my-custom-model +qwen --auth-type openai --model my-custom-model --openaiApiKey $KEY --openaiBaseUrl https://api.example.com/v1 +``` + +The snapshot: + +- Captures model ID, API key, base URL, and generation config +- Persists across sessions (stored in memory during runtime) +- Appears in the `/model` command list as a runtime option +- Can be switched to using `/model $runtime|openai|my-custom-model` + +### Key differences + +| Aspect | Provider Model | Runtime Model | +| ----------------------- | --------------------------------- | ------------------------------------------ | +| Configuration source | `modelProviders` in settings | CLI, env, settings layers | +| Configuration atomicity | Complete, impermeable package | Layered, each field resolved independently | +| Reusability | Always available in `/model` list | Captured as snapshot, appears if complete | +| Team sharing | Yes (via committed settings) | No (user-local) | +| Credential storage | Reference via `envKey` only | May capture actual key in snapshot | + +### When to use each + +- **Use Provider Models** when: You have standard models shared across a team, need consistent configurations, or want to prevent accidental overrides +- **Use Runtime Models** when: Quickly testing a new model, using temporary credentials, or working with ad-hoc endpoints + +## Selection Persistence and Recommendations + +> [!important] +> +> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope. + +- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog. +- Without `modelProviders`, the resolver mixes CLI/env/settings layers, creating Runtime Models. This is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 0094f411d1..53f6a11c4c 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -125,18 +125,18 @@ Settings are organized into categories. All settings should be placed within the #### model -| Setting | Type | Description | Default | -| -------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | -| `model.name` | string | The Qwen model to use for conversations. | `undefined` | -| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | -| `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` | -| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `contextWindowSize` (override model's context window size), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | -| `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` | -| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` | -| `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` | -| `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` | -| `model.enableOpenAILogging` | boolean | Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. | `false` | -| `model.openAILoggingDir` | string | Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory). | `undefined` | +| Setting | Type | Description | Default | +| -------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `model.name` | string | The Qwen model to use for conversations. | `undefined` | +| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | +| `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` | +| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | +| `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` | +| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` | +| `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` | +| `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` | +| `model.enableOpenAILogging` | boolean | Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. | `false` | +| `model.openAILoggingDir` | string | Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory). | `undefined` | **Example model.generationConfig:** @@ -146,10 +146,12 @@ Settings are organized into categories. All settings should be placed within the "generationConfig": { "timeout": 60000, "contextWindowSize": 128000, + "modalities": { + "image": true + }, "enableCacheControl": true, "customHeaders": { - "X-Request-ID": "req-123", - "X-User-ID": "user-456" + "X-Client-Request-ID": "req-123" }, "extra_body": { "enable_thinking": true @@ -168,6 +170,10 @@ Settings are organized into categories. All settings should be placed within the Overrides the default context window size for the selected model. Qwen Code determines the context window using built-in defaults based on model name matching, with a constant fallback value. Use this setting when a provider's effective context limit differs from Qwen Code's default. This value defines the model's assumed maximum context capacity, not a per-request token limit. +**modalities:** + +Overrides the auto-detected input modalities for the selected model. Qwen Code automatically detects supported modalities (image, PDF, audio, video) based on model name pattern matching. Use this setting when the auto-detection is incorrect — for example, to enable `pdf` for a model that supports it but isn't recognized. Format: `{ "image": true, "pdf": true, "audio": true, "video": true }`. Omit a key or set it to `false` for unsupported types. + **customHeaders:** Allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. If `customHeaders` is defined in `modelProviders[].generationConfig.customHeaders`, it will be used directly; otherwise, headers from `model.generationConfig.customHeaders` will be used. No merging occurs between the two levels. @@ -180,102 +186,6 @@ The `extra_body` field allows you to add custom parameters to the request body s - `"./custom-logs"` - Logs to `./custom-logs` relative to current directory - `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs` -#### modelProviders - -Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. - -##### Example - -```json -{ - "modelProviders": { - "openai": [ - { - "id": "gpt-4o", - "name": "GPT-4o", - "envKey": "OPENAI_API_KEY", - "baseUrl": "https://api.openai.com/v1", - "generationConfig": { - "timeout": 60000, - "maxRetries": 3, - "customHeaders": { - "X-Model-Version": "v1.0", - "X-Request-Priority": "high" - }, - "extra_body": { - "enable_thinking": true - }, - "samplingParams": { "temperature": 0.2 } - } - } - ], - "anthropic": [ - { - "id": "claude-3-5-sonnet", - "envKey": "ANTHROPIC_API_KEY", - "baseUrl": "https://api.anthropic.com/v1" - } - ], - "gemini": [ - { - "id": "gemini-2.0-flash", - "name": "Gemini 2.0 Flash", - "envKey": "GEMINI_API_KEY", - "baseUrl": "https://generativelanguage.googleapis.com" - } - ], - "vertex-ai": [ - { - "id": "gemini-1.5-pro-vertex", - "envKey": "GOOGLE_API_KEY", - "baseUrl": "https://generativelanguage.googleapis.com" - } - ] - } -} -``` - -> [!note] -> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows. - -##### Resolution layers and atomicity - -The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers. - -| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy | -| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- | -| Programmatic overrides | `/auth ` | `/auth` input | `/auth` input | `/auth` input | — | — | -| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — | -| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — | -| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — | -| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — | -| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured | - -\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. - -Model-provider sourced values are applied atomically: once a provider model is active, every field it defines is protected from lower layers until you manually clear credentials via `/auth`. The final `generationConfig` is the projection across all layers—lower layers only fill gaps left by higher ones, and the provider layer remains impenetrable. - -The merge strategy for `modelProviders` is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two. - -##### Generation config layering - -Per-field precedence for `generationConfig`: - -1. Programmatic overrides (e.g. runtime `/model`, `/auth` changes) -2. `modelProviders[authType][].generationConfig` -3. `settings.model.generationConfig` -4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.) - -`samplingParams`, `customHeaders`, and `extra_body` are all treated atomically; provider values replace the entire object. If `modelProviders[].generationConfig` defines these fields, they are used directly; otherwise, values from `model.generationConfig` are used. No merging occurs between provider and global configuration levels. Defaults from the content generator apply last so each provider retains its tuned baseline. - -##### Selection persistence and recommendations - -> [!important] -> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope. - -- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog. -- Without `modelProviders`, the resolver mixes CLI/env/settings layers, which is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable. - #### context | Setting | Type | Description | Default | diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 9a3b2c0510..ba980db802 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -121,7 +121,9 @@ Environment Variables: Commands executed via `!` will set the `QWEN_CODE=1` envi Save frequently used prompts as shortcut commands to improve work efficiency and ensure consistency. -> **Note:** Custom commands now use Markdown format with optional YAML frontmatter. TOML format is deprecated but still supported for backwards compatibility. When TOML files are detected, an automatic migration prompt will be displayed. +> [!note] +> +> Custom commands now use Markdown format with optional YAML frontmatter. TOML format is deprecated but still supported for backwards compatibility. When TOML files are detected, an automatic migration prompt will be displayed. ### Quick Overview @@ -137,10 +139,10 @@ Priority Rules: Project commands > User commands (project command used when name #### File Path to Command Name Mapping Table -| File Location | Generated Command | Example Call | -| -------------------------- | ----------------- | --------------------- | -| `~/.qwen/commands/test.md` | `/test` | `/test Parameter` | -| `/git/commit.md` | `/git:commit` | `/git:commit Message` | +| File Location | Generated Command | Example Call | +| ---------------------------------------- | ----------------- | --------------------- | +| `~/.qwen/commands/test.md` | `/test` | `/test Parameter` | +| `/.qwen/commands/git/commit.md` | `/git:commit` | `/git:commit Message` | Naming Rules: Path separator (`/` or `\`) converted to colon (`:`) @@ -164,6 +166,8 @@ Use {{args}} for parameter injection. ### TOML File Format (Deprecated) +> [!warning] +> > **Deprecated:** TOML format is still supported but will be removed in a future version. Please migrate to Markdown format. | Field | Required | Description | Example | @@ -225,8 +229,6 @@ Please generate a Commit message based on the following diff: ``` ```` -```` - #### 4. File Content Injection (`@{...}`) | File Type | Support Status | Processing Method | @@ -246,7 +248,7 @@ description: Code review based on best practices Review {{args}}, reference standards: @{docs/code-standards.md} -```` +``` ### Practical Creation Example diff --git a/docs/users/overview.md b/docs/users/overview.md index 3b45cc2f07..f3c52be912 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -7,25 +7,24 @@ ## Get started in 30 seconds -Prerequisites: - -- A [Qwen Code](https://chat.qwen.ai/auth?mode=register) account -- Requires [Node.js 20+](https://nodejs.org/zh-cn/download), you can use `node -v` to check the version. If it's not installed, use the following command to install it. - ### Install Qwen Code: -**NPM**(recommended) +**Linux / macOS** -```bash -npm install -g @qwen-code/qwen-code@latest +```sh +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash ``` -**Homebrew**(macOS, Linux) +**Windows (Run as Administrator CMD)** -```bash -brew install qwen-code +```sh +curl -fsSL -o %TEMP%\install-qwen.bat https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat && %TEMP%\install-qwen.bat ``` +> [!note] +> +> It's recommended to restart your terminal after installation to ensure environment variables take effect. If the installation fails, please refer to [Manual Installation](./quickstart#manual-installation) in the Quickstart guide. + ### Start using Qwen Code: ```bash diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index eac8f94742..3c4eafceaf 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -16,19 +16,39 @@ Make sure you have: To install Qwen Code, use one of the following methods: -### NPM (recommended) +### Quick Install (Recommended) -Requires [Node.js 20+](https://nodejs.org/download), you can use `node -v` check the version. If it's not installed, use the following command to install it. +**Linux / macOS** -If you have [Node.js or newer installed](https://nodejs.org/en/download/): +```sh +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash +``` + +**Windows (Run as Administrator CMD)** ```sh +curl -fsSL -o %TEMP%\install-qwen.bat https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat && %TEMP%\install-qwen.bat +``` + +> [!note] +> +> It's recommended to restart your terminal after installation to ensure environment variables take effect. + +### Manual Installation + +**Prerequisites** + +Make sure you have Node.js 20 or later installed. Download it from [nodejs.org](https://nodejs.org/en/download). + +**NPM** + +```bash npm install -g @qwen-code/qwen-code@latest ``` -### Homebrew (macOS, Linux) +**Homebrew (macOS, Linux)** -```sh +```bash brew install qwen-code ``` diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md index fc2f862865..f0cbd7b16b 100644 --- a/docs/users/reference/keyboard-shortcuts.md +++ b/docs/users/reference/keyboard-shortcuts.md @@ -42,7 +42,7 @@ This document lists the available keyboard shortcuts in Qwen Code. | `Ctrl+R` | Reverse search through input/shell history. | | `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. | | `Ctrl+U` | Delete from the cursor to the beginning of the line. | -| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. | +| `Ctrl+V` (Windows: `Alt+V`) | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. | | `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. | | `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. | diff --git a/esbuild.config.js b/esbuild.config.js index 12ab39d584..2b532b44ef 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -33,6 +33,13 @@ const external = [ '@lydell/node-pty-linux-x64', '@lydell/node-pty-win32-arm64', '@lydell/node-pty-win32-x64', + '@teddyzhu/clipboard', + '@teddyzhu/clipboard-darwin-arm64', + '@teddyzhu/clipboard-darwin-x64', + '@teddyzhu/clipboard-linux-x64-gnu', + '@teddyzhu/clipboard-linux-arm64-gnu', + '@teddyzhu/clipboard-win32-x64-msvc', + '@teddyzhu/clipboard-win32-arm64-msvc', ]; esbuild diff --git a/eslint.config.js b/eslint.config.js index 1d0ed2af97..d0963e876b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -254,9 +254,12 @@ export default tseslint.config( 'no-console': 'off', }, }, - // Settings for export-html assets + // Settings for web-templates assets { - files: ['packages/cli/assets/export-html/**/*.{js,jsx,ts,tsx}'], + files: [ + 'packages/web-templates/src/**/*.{js,jsx,ts,tsx}', + 'packages/web-templates/*.mjs', + ], languageOptions: { globals: { ...globals.browser, @@ -271,6 +274,8 @@ export default tseslint.config( rules: { 'react/react-in-jsx-scope': 'off', 'react/prop-types': 'off', + 'no-console': 'off', + 'no-undef': 'off', }, }, // Prettier config must be last diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 35397da263..07e53e9609 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -648,6 +648,107 @@ function setupAcpTest( } }); + it('blocks write tools in plan mode (issue #1806)', async () => { + const rig = new TestRig(); + rig.setup('acp plan mode enforcement'); + + const toolCallEvents: Array<{ + toolName: string; + status: string; + error?: string; + }> = []; + + const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig, { + permissionHandler: (request) => { + // Cancel exit_plan_mode to keep plan mode active + if (request.toolCall?.kind === 'switch_mode') { + return { outcome: 'cancelled' }; + } + return { optionId: 'proceed_once' }; + }, + }); + + try { + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + }); + await sendRequest('authenticate', { methodId: 'openai' }); + + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + + // Set mode to 'plan' + const setModeResult = (await sendRequest('session/set_mode', { + sessionId: newSession.sessionId, + modeId: 'plan', + })) as { modeId: string }; + expect(setModeResult.modeId).toBe('plan'); + + // Try to create a file - this should be blocked by plan mode + const promptResult = await sendRequest('session/prompt', { + sessionId: newSession.sessionId, + prompt: [ + { + type: 'text', + text: 'Create a file called test.txt with content "Hello World"', + }, + ], + }); + expect(promptResult).toBeDefined(); + + // Give time for tool calls to be processed + await delay(2000); + + // Collect tool call events from session updates + sessionUpdates.forEach((update) => { + if (update.update?.sessionUpdate === 'tool_call_update') { + const toolUpdate = update.update as { + sessionUpdate: string; + toolName?: string; + status?: string; + error?: { message?: string }; + }; + if (toolUpdate.toolName) { + toolCallEvents.push({ + toolName: toolUpdate.toolName, + status: toolUpdate.status ?? 'unknown', + error: toolUpdate.error?.message, + }); + } + } + }); + + // Verify that if write_file was attempted, it was blocked + const writeFileEvents = toolCallEvents.filter( + (e) => e.toolName === 'write_file', + ); + + // If the LLM tried to call write_file in plan mode, it should have been blocked + if (writeFileEvents.length > 0) { + const blockedEvent = writeFileEvents.find( + (e) => e.status === 'error' && e.error?.includes('Plan mode'), + ); + expect(blockedEvent).toBeDefined(); + expect(blockedEvent?.error).toContain('Plan mode is active'); + } + + // Verify the file was NOT created + const fs = await import('fs'); + const path = await import('path'); + const testFilePath = path.join(rig.testDir!, 'test.txt'); + const fileExists = fs.existsSync(testFilePath); + expect(fileExists).toBe(false); + } catch (e) { + if (stderr.length) console.error('Agent stderr:', stderr.join('')); + throw e; + } finally { + await cleanup(); + } + }); + it('receives usage metadata in agent_message_chunk updates', async () => { const rig = new TestRig(); rig.setup('acp usage metadata'); diff --git a/integration-tests/concurrent-runner/config.example.json b/integration-tests/concurrent-runner/config.example.json index 7042e7eb6f..f1937fe076 100644 --- a/integration-tests/concurrent-runner/config.example.json +++ b/integration-tests/concurrent-runner/config.example.json @@ -31,5 +31,9 @@ ] } ], - "models": ["claude-3-5-sonnet-20241022", "qwen3-coder-plus"] + "models": [ + "qwen3-coder-plus", + { "name": "glm-4.7", "auth_type": "anthropic" }, + { "name": "claude-4-5-sonnet-20260219", "auth_type": "anthropic" } + ] } diff --git a/integration-tests/concurrent-runner/runner.py b/integration-tests/concurrent-runner/runner.py index c27a221e0b..6eb2b8e0f5 100644 --- a/integration-tests/concurrent-runner/runner.py +++ b/integration-tests/concurrent-runner/runner.py @@ -50,11 +50,18 @@ class Task: prompts: List[str] +@dataclass +class ModelSpec: + """One model to run: name and optional auth_type (e.g. anthropic).""" + name: str + auth_type: Optional[str] = None + + @dataclass class RunConfig: """Configuration for the concurrent execution.""" tasks: List[Task] - models: List[str] + models: List[ModelSpec] # name + optional auth_type per model concurrency: int = 4 yolo: bool = True source_repo: Path = field(default_factory=lambda: Path.cwd()) @@ -84,6 +91,7 @@ class RunRecord: task_name: str model: str status: RunStatus + auth_type: Optional[str] = None # e.g. "anthropic" for qwen --auth-type worktree_path: Optional[str] = None output_dir: Optional[str] = None logs_dir: Optional[str] = None @@ -104,6 +112,7 @@ def to_dict(self) -> Dict[str, Any]: "task_name": self.task_name, "model": self.model, "status": self.status.value, + "auth_type": self.auth_type, "worktree_path": self.worktree_path, "output_dir": self.output_dir, "logs_dir": self.logs_dir, @@ -136,6 +145,7 @@ def from_dict(cls, data: Dict[str, Any]) -> RunRecord: task_name=data["task_name"], model=data["model"], status=RunStatus(data["status"]), + auth_type=data.get("auth_type"), worktree_path=data.get("worktree_path"), output_dir=data.get("output_dir"), logs_dir=data.get("logs_dir"), @@ -806,6 +816,10 @@ def _build_command(self, run: RunRecord, prompt_text: str, use_continue: bool = # Add model cmd.extend(["--model", run.model]) + # Add auth-type when model uses non-OpenAI protocol (e.g. anthropic for glm-4.7) + if run.auth_type: + cmd.extend(["--auth-type", run.auth_type]) + # Add yolo if enabled if self.config.yolo: cmd.append("--yolo") @@ -829,27 +843,41 @@ def generate_run_matrix(config: RunConfig) -> List[RunRecord]: runs = [] for task in config.tasks: for model in config.models: - run_id = str(uuid.uuid4())[:8] runs.append(RunRecord( - run_id=run_id, + run_id=str(uuid.uuid4())[:8], task_id=task.id, task_name=task.name, - model=model, + model=model.name, status=RunStatus.QUEUED, + auth_type=model.auth_type, )) return runs +def _parse_models(data_models: List[Any]) -> List[ModelSpec]: + """Parse models: string or {name, auth_type/authType}; returns list of ModelSpec.""" + specs: List[ModelSpec] = [] + for item in data_models or []: + if isinstance(item, str): + name, auth = item, None + elif isinstance(item, dict) and item.get("name"): + name = item["name"] + auth = item.get("auth_type") or item.get("authType") + else: + continue + specs.append(ModelSpec(name=name, auth_type=auth)) + return specs + + def load_config(config_path: Path) -> RunConfig: """Load configuration from JSON file.""" with open(config_path, 'r') as f: data = json.load(f) - tasks = [Task(**t) for t in data.get("tasks", [])] - + models = _parse_models(data.get("models", [])) return RunConfig( tasks=tasks, - models=data.get("models", []), + models=models, concurrency=data.get("concurrency", 4), yolo=data.get("yolo", True), source_repo=Path(data.get("source_repo", ".")).resolve(), diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index a8a9877fe4..02cea68598 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -94,7 +94,7 @@ export async function setup() { // Environment variables for CLI integration tests process.env['INTEGRATION_TEST_FILE_DIR'] = runDir; - process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true'; + process.env['QWEN_CODE_INTEGRATION_TEST'] = 'true'; process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log'); // Environment variables for SDK E2E tests diff --git a/integration-tests/sdk-typescript/session-id.test.ts b/integration-tests/sdk-typescript/session-id.test.ts index 6b91365039..7a2ab435da 100644 --- a/integration-tests/sdk-typescript/session-id.test.ts +++ b/integration-tests/sdk-typescript/session-id.test.ts @@ -377,8 +377,8 @@ describe('Session ID Support (E2E)', () => { describe('Session ID Duplicate Detection', () => { it('should reject duplicate sessionId with error', async () => { - // Valid UUID v4 - const customSessionId = 'dddddddd-eeee-4fff-aaaa-bbbbbbbbbbbb'; + // Generate a unique UUID for this test + const customSessionId = crypto.randomUUID(); // First query: create a session with the custom session ID const q1 = query({ @@ -387,7 +387,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); @@ -409,7 +411,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); @@ -426,8 +430,8 @@ describe('Session ID Support (E2E)', () => { }); it('should throw error when CLI exits with non-zero code', async () => { - // Valid UUID v4 - const customSessionId = 'eeeeeeee-ffff-4aaa-bbbb-cccccccccccc'; + // Generate a unique UUID for this test + const customSessionId = crypto.randomUUID(); // First query: create a session and properly close it after completion const q1 = query({ @@ -436,7 +440,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); @@ -456,7 +462,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); diff --git a/integration-tests/terminal-capture/motivation.md b/integration-tests/terminal-capture/motivation.md new file mode 100644 index 0000000000..388019369d --- /dev/null +++ b/integration-tests/terminal-capture/motivation.md @@ -0,0 +1,117 @@ +# terminal-capture — Motivation and Positioning + +## 1. Overview of Existing Testing System + +| Layer | Tools | Coverage | Status | +| ---------------------- | ----------------------------------------- | --------------------------------------- | --------------------------------------------------------- | +| Unit Tests | Vitest + ink-testing-library | Ink components, Core logic, utilities | Mature, extensive `.test.ts` / `.test.tsx` | +| Integration Tests | Vitest + TestRig / SDKTestHelper | CLI E2E, SDK multi-turn, MCP, auth | Mature, supports none/docker/podman sandboxes | +| Terminal UI Snapshots | `toMatchSnapshot()` + ink-testing-library | Ink component render output (ANSI) | Exists, covers Footer, InputPrompt, MarkdownDisplay, etc. | +| Web UI Regression | Chromatic + Storybook | `packages/webui` components | Exists, but only covers Web UI | +| **Terminal UI Visual** | **terminal-capture** | CLI terminal real rendering screenshots | ✅ Implemented | + +## 2. Problems Solved by terminal-capture + +### Limitations of Existing Ink Text Snapshots + +The project uses `toMatchSnapshot()` to compare Ink component ANSI text output, which validates **text content**, but cannot verify: + +- Whether colors are correct (red separators? green highlights? Logo gradients?) +- Whether layout is aligned (table borders? multi-column layout?) +- Overall visual feel (component spacing? blank areas? overflow?) + +These can only be seen by **actually rendering to a terminal emulator**. + +### Core Architecture + +``` +node-pty (pseudo-terminal) + ↓ raw ANSI byte stream +xterm.js (running inside Playwright headless Chromium) + ↓ perfect rendering: colors, bold, cursor, scrolling +Playwright element screenshot + ↓ pixel-perfect screenshots (optional macOS window decorations) +``` + +### Core Features + +| Feature | Description | +| -------------------- | ----------------------------------------------------------------------------------- | +| WYSIWYG | xterm.js fully renders ANSI, no manual output cleaning needed | +| Theme Support | Built-in 5 themes (Dracula, One Dark, GitHub Dark, Monokai, Night Owl) | +| Full-length | `captureFull()` supports capturing scrollback buffer content | +| Deterministic Naming | Screenshot filenames auto-generated by step sequence for easy regression comparison | +| Batch Execution | `run.ts` executes all scenarios in one command | + +## 3. Usage + +### TypeScript Configuration-Driven + +Scenario config files (`scenarios/*.ts`) only need to declare `type` (input) and `key` (keypress), Runner handles automatically: + +- Wait for CLI readiness +- Auto-complete interference handling (/ commands auto-send Escape) +- Auto-screenshot before/after input (01 = input state, 02 = result) +- Auto-capture full-length image at last step (full-flow.png) +- Special key interactions (Arrow keys / Tab / Enter, etc.) + +```typescript +// integration-tests/terminal-capture/scenarios/about.ts +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: '/about', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'Hi, can you help me understand this codebase?' }, + { type: '/about' }, + ], +} satisfies ScenarioConfig; +``` + +### Running + +```bash +# From project root +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ + +# Or inside terminal-capture directory +npm run capture +``` + +### Screenshot Output + +``` +scenarios/screenshots/ + about/ + 01-01.png # Step 1 input state + 01-02.png # Step 1 result + 02-01.png # Step 2 input state + 02-02.png # Step 2 result + full-flow.png # Final state full-length image + context/ + ... +``` + +## 4. Position in Testing System + +``` +┌─────────────────────────────────────┐ +│ Existing Testing System │ +├─────────────────────────────────────┤ +│ Unit Tests (Vitest) │ ← Function/Component level +│ Text Snapshots (ink-testing-lib) │ ← ANSI string comparison +│ Integration Tests (TestRig/SDK) │ ← E2E functionality +│ Web UI Regression (Chromatic) │ ← Only covers webui +├─────────────────────────────────────┤ +│ terminal-capture │ ← Terminal UI visual layer +│ (xterm.js + Playwright) │ Fills the gap +└─────────────────────────────────────┘ +``` + +## 5. Future Directions + +1. **Visual Regression** — Integrate Playwright `toHaveScreenshot()` for pixel-level baseline comparison, CI auto-detects terminal UI changes +2. **PR Workflow Integration** — Drive Agent via Cursor Skill to auto-checkout branch → build → screenshot → attach to review comment +3. **Complement to Chromatic** — Chromatic covers Web UI, terminal-capture covers CLI terminal UI diff --git a/integration-tests/terminal-capture/package.json b/integration-tests/terminal-capture/package.json new file mode 100644 index 0000000000..ef55e63ef3 --- /dev/null +++ b/integration-tests/terminal-capture/package.json @@ -0,0 +1,18 @@ +{ + "name": "@qwen-code/terminal-capture", + "version": "0.1.0", + "private": true, + "description": "Terminal UI screenshot automation for CLI visual testing", + "type": "module", + "scripts": { + "capture": "npx tsx run.ts scenarios/", + "capture:about": "npx tsx run.ts scenarios/about.ts", + "capture:all": "npx tsx run.ts scenarios/all.ts" + }, + "dependencies": { + "@lydell/node-pty": "1.1.0", + "@xterm/xterm": "^5.5.0", + "playwright": "^1.50.0", + "strip-ansi": "^7.1.2" + } +} diff --git a/integration-tests/terminal-capture/run.ts b/integration-tests/terminal-capture/run.ts new file mode 100644 index 0000000000..59b9ab547b --- /dev/null +++ b/integration-tests/terminal-capture/run.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env npx tsx +/** + * Batch run terminal screenshot scenarios + * + * Usage: + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ # batch + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/*.ts # glob + */ + +import { + loadScenarios, + runScenario, + type RunResult, +} from './scenario-runner.js'; +import { readdirSync, statSync } from 'node:fs'; +import { resolve, extname, join } from 'node:path'; + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log( + ` +Usage: npx tsx integration-tests/terminal-capture/run.ts ... + +Examples: + npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ + `.trim(), + ); + process.exit(1); + } + + // Collect all .ts scenario files from arguments + const scenarioFiles: string[] = []; + for (const arg of args) { + const abs = resolve(arg); + try { + const stat = statSync(abs); + if (stat.isDirectory()) { + const files = readdirSync(abs) + .filter((f) => extname(f) === '.ts') + .sort() + .map((f) => join(abs, f)); + scenarioFiles.push(...files); + } else { + scenarioFiles.push(abs); + } + } catch { + console.error(`❌ Not found: ${arg}`); + process.exit(1); + } + } + + if (scenarioFiles.length === 0) { + console.error('❌ No .ts scenario files found'); + process.exit(1); + } + + console.log(`đŸŽŦ Running ${scenarioFiles.length} scenario(s)...\n`); + + // Run scenarios sequentially (single file can export an array) + const results: RunResult[] = []; + for (const file of scenarioFiles) { + const { configs, basedir } = await loadScenarios(file); + for (const config of configs) { + const result = await runScenario(config, basedir); + results.push(result); + } + } + + // Summary + console.log(`\n${'═'.repeat(60)}`); + console.log('📊 Summary'); + console.log('═'.repeat(60)); + + const passed = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + const totalScreenshots = results.reduce( + (sum, r) => sum + r.screenshots.length, + 0, + ); + const totalTime = results.reduce((sum, r) => sum + r.durationMs, 0); + + for (const r of results) { + const icon = r.success ? '✅' : '❌'; + const time = (r.durationMs / 1000).toFixed(1); + console.log( + ` ${icon} ${r.name} — ${r.screenshots.length} screenshots, ${time}s`, + ); + if (r.error) console.log(` ${r.error}`); + } + + console.log( + `\n Total: ${passed.length} passed, ${failed.length} failed, ${totalScreenshots} screenshots, ${(totalTime / 1000).toFixed(1)}s`, + ); + + if (failed.length > 0) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/integration-tests/terminal-capture/scenario-runner.ts b/integration-tests/terminal-capture/scenario-runner.ts new file mode 100644 index 0000000000..4bd858fd4e --- /dev/null +++ b/integration-tests/terminal-capture/scenario-runner.ts @@ -0,0 +1,304 @@ +/** + * Scenario Runner v3 — TypeScript Configuration-Driven Terminal Screenshots + * + * Configuration has only two core concepts: type (input) and capture (screenshot). + * All intelligent waiting is handled automatically by the Runner. + * + * Usage: + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ + */ + +import { TerminalCapture, THEMES } from './terminal-capture.js'; +import { dirname, resolve, isAbsolute } from 'node:path'; + +// ───────────────────────────────────────────── +// Schema — Minimal +// ───────────────────────────────────────────── + +export interface FlowStep { + /** Input text (auto-press Enter, auto-wait for output to stabilize, auto-screenshot before/after) */ + type?: string; + /** + * Send special key presses (no auto-Enter, no auto-screenshot) + * Supported: ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Enter, Tab, Escape, Backspace, Space + * Can also pass ANSI escape sequence strings + */ + key?: string | string[]; + /** Explicit screenshot: current viewport (standalone capture when no type) */ + capture?: string; + /** Explicit screenshot: full scrollback buffer long image (standalone capture when no type) */ + captureFull?: string; +} + +export interface ScenarioConfig { + /** Scenario name */ + name: string; + /** Launch command, e.g., ["node", "dist/cli.js", "--yolo"] */ + spawn: string[]; + /** Execution flow: array, each item can contain type / capture / captureFull */ + flow: FlowStep[]; + /** Terminal configuration (all optional) */ + terminal?: { + cols?: number; + rows?: number; + theme?: string; + chrome?: boolean; + title?: string; + fontSize?: number; + cwd?: string; + }; + /** Screenshot output directory (relative to config file) */ + outputDir?: string; +} + +// ───────────────────────────────────────────── +// Runner +// ───────────────────────────────────────────── + +export interface RunResult { + name: string; + screenshots: string[]; + success: boolean; + error?: string; + durationMs: number; +} + +/** Dynamically load configuration from .ts file (supports single object or array) */ +export async function loadScenarios( + tsPath: string, +): Promise<{ configs: ScenarioConfig[]; basedir: string }> { + const absPath = isAbsolute(tsPath) ? tsPath : resolve(tsPath); + const mod = (await import(absPath)) as { + default: ScenarioConfig | ScenarioConfig[]; + }; + const raw = mod.default; + const configs = Array.isArray(raw) ? raw : [raw]; + + for (const config of configs) { + if (!config?.name) throw new Error(`Missing 'name': ${absPath}`); + if (!config.spawn?.length) throw new Error(`Missing 'spawn': ${absPath}`); + if (!config.flow?.length) throw new Error(`Missing 'flow': ${absPath}`); + } + + return { configs, basedir: dirname(absPath) }; +} + +/** Execute a single scenario */ +export async function runScenario( + config: ScenarioConfig, + basedir: string, +): Promise { + const startTime = Date.now(); + const screenshots: string[] = []; + const t = config.terminal ?? {}; + + const cwd = t.cwd ? resolve(basedir, t.cwd) : resolve(basedir, '..'); + // Use scenario name as subdirectory to isolate screenshot outputs from different scenarios + const scenarioDir = + config.name + .replace(/^\//, '') + .replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'unnamed'; + const outputDir = config.outputDir + ? resolve(basedir, config.outputDir, scenarioDir) + : resolve(basedir, 'screenshots', scenarioDir); + + console.log(`\n${'═'.repeat(60)}`); + console.log(`â–ļ ${config.name}`); + console.log('═'.repeat(60)); + + const terminal = await TerminalCapture.create({ + cols: t.cols ?? 100, + rows: t.rows ?? 28, + theme: (t.theme ?? 'dracula') as keyof typeof THEMES, + chrome: t.chrome ?? true, + title: t.title ?? 'Terminal', + fontSize: t.fontSize, + cwd, + outputDir, + }); + + try { + // ── Spawn ── + const [command, ...args] = config.spawn; + console.log(` spawn: ${config.spawn.join(' ')}`); + await terminal.spawn(command, args); + + // ── Auto-wait for CLI readiness ── + console.log(' âŗ waiting for ready...'); + await terminal.idle(1500, 30000); + console.log(' ✅ ready'); + + // ── Execute flow ── + let seq = 0; // Global screenshot sequence number + + for (let i = 0; i < config.flow.length; i++) { + const step = config.flow[i]; + const label = `[${i + 1}/${config.flow.length}]`; + + if (step.type) { + const display = + step.type.length > 60 ? step.type.slice(0, 60) + '...' : step.type; + + // If next step is key, there's more interaction to do, so don't auto-press Enter + const nextStep = config.flow[i + 1]; + const autoEnter = !nextStep?.key; + + console.log( + ` ${label} type: "${display}"${autoEnter ? '' : ' (no auto-enter)'}`, + ); + + const text = step.type.replace(/\n$/, ''); + await terminal.type(text); + await sleep(300); + + // Only send Escape for / commands to close auto-complete, not for regular text + if (text.startsWith('/') && autoEnter) { + await terminal.type('\x1b'); + await sleep(100); + } + + // ── 01: Text input complete ── + seq++; + const inputName = step.capture + ? step.capture.replace(/\.png$/, '-01.png') + : `${pad(seq)}-01.png`; + console.log(` ${label} 📸 input: ${inputName}`); + screenshots.push(await terminal.capture(inputName)); + + if (autoEnter) { + // ── Auto-press Enter → Wait for stabilization → 02 screenshot ── + await terminal.type('\n'); + console.log(` âŗ waiting for output to settle...`); + await terminal.idle(2000, 60000); + console.log(` ✅ settled`); + + const resultName = step.capture ?? `${pad(seq)}-02.png`; + console.log(` ${label} 📸 result: ${resultName}`); + screenshots.push(await terminal.capture(resultName)); + + // full-flow: Only the last type step auto-captures full-length image + const isLastType = !config.flow.slice(i + 1).some((s) => s.type); + if (isLastType || step.captureFull) { + const fullName = step.captureFull ?? 'full-flow.png'; + console.log(` ${label} 📸 full: ${fullName}`); + screenshots.push(await terminal.captureFull(fullName)); + } + } + // When not autoEnter, only captured before state, subsequent key steps take over interaction + } else if (step.key) { + // ── key: Send special key presses (arrow keys, Tab, Enter, etc.) ── + const keys = Array.isArray(step.key) ? step.key : [step.key]; + console.log(` ${label} key: ${keys.join(', ')}`); + + for (const k of keys) { + await terminal.type(resolveKey(k)); + await sleep(150); + } + // Wait for UI response to key press + await terminal.idle(500, 5000); + + // If key step has explicit capture/captureFull + if (step.capture || step.captureFull) { + seq++; + if (step.capture) { + console.log(` ${label} 📸 capture: ${step.capture}`); + screenshots.push(await terminal.capture(step.capture)); + } + if (step.captureFull) { + console.log(` ${label} 📸 captureFull: ${step.captureFull}`); + screenshots.push(await terminal.captureFull(step.captureFull)); + } + } + + // After key sequence ends (next step is not key), auto-add result + full screenshots + const nextStep = config.flow[i + 1]; + if (!nextStep?.key) { + console.log(` âŗ waiting for output to settle...`); + await terminal.idle(2000, 60000); + console.log(` ✅ settled`); + + const resultName = `${pad(seq)}-02.png`; + console.log(` ${label} 📸 result: ${resultName}`); + screenshots.push(await terminal.capture(resultName)); + + // If this is the last interaction step, add full-length image + const isLastType = !config.flow.slice(i + 1).some((s) => s.type); + if (isLastType) { + console.log(` ${label} 📸 full: full-flow.png`); + screenshots.push(await terminal.captureFull('full-flow.png')); + } + } + } else { + // ── Standalone screenshot step (no type/key) ── + seq++; + if (step.capture) { + console.log(` ${label} 📸 capture: ${step.capture}`); + screenshots.push(await terminal.capture(step.capture)); + } + if (step.captureFull) { + console.log(` ${label} 📸 captureFull: ${step.captureFull}`); + screenshots.push(await terminal.captureFull(step.captureFull)); + } + } + } + + const duration = Date.now() - startTime; + console.log( + `\n ✅ ${config.name} — ${screenshots.length} screenshots, ${(duration / 1000).toFixed(1)}s`, + ); + return { + name: config.name, + screenshots, + success: true, + durationMs: duration, + }; + } catch (err) { + const duration = Date.now() - startTime; + const msg = err instanceof Error ? err.message : String(err); + console.error(`\n ❌ ${config.name} — ${msg}`); + return { + name: config.name, + screenshots, + success: false, + error: msg, + durationMs: duration, + }; + } finally { + await terminal.close(); + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Pad sequence number with zero: 1 → "01" */ +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +/** Key name → PTY escape sequence */ +const KEY_MAP: Record = { + ArrowUp: '\x1b[A', + ArrowDown: '\x1b[B', + ArrowRight: '\x1b[C', + ArrowLeft: '\x1b[D', + Enter: '\r', + Tab: '\t', + Escape: '\x1b', + Backspace: '\x7f', + Space: ' ', + Home: '\x1b[H', + End: '\x1b[F', + PageUp: '\x1b[5~', + PageDown: '\x1b[6~', + Delete: '\x1b[3~', +}; + +/** Parse key name to PTY-recognizable character sequence */ +function resolveKey(key: string): string { + return KEY_MAP[key] ?? key; +} diff --git a/integration-tests/terminal-capture/scenarios/about.ts b/integration-tests/terminal-capture/scenarios/about.ts new file mode 100644 index 0000000000..6aae802ff7 --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/about.ts @@ -0,0 +1,8 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: '/about command', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [{ type: 'hi' }, { type: '/about' }], +} satisfies ScenarioConfig; diff --git a/integration-tests/terminal-capture/scenarios/all.ts b/integration-tests/terminal-capture/scenarios/all.ts new file mode 100644 index 0000000000..a8bb8db816 --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/all.ts @@ -0,0 +1,46 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default [ + { + name: '/about', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'Hi, can you help me understand this codebase?' }, + { type: '/about' }, + ], + }, + { + name: '/context', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'How do you understand this project?' }, + { type: '/context' }, + ], + }, + + { + name: '/export (tab select)', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'Please give me a brief introduction about yourself.' }, + { type: '/export' }, + { key: 'Tab' }, // Tab to open format selection + { key: 'ArrowDown' }, // Down arrow to switch options + { key: 'Enter' }, // Confirm selection + ], + }, + { + name: '/auth', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: '/auth' }, + { key: 'ArrowDown' }, // Select API Key + { key: 'Enter' }, // Confirm + { type: 'sk-test-key-123' }, + ], + }, +] satisfies ScenarioConfig[]; diff --git a/integration-tests/terminal-capture/terminal-capture.ts b/integration-tests/terminal-capture/terminal-capture.ts new file mode 100644 index 0000000000..ebfddd5231 --- /dev/null +++ b/integration-tests/terminal-capture/terminal-capture.ts @@ -0,0 +1,856 @@ +/** + * TerminalCapture - Terminal Screenshot Tool + * + * Terminal screenshot solution based on xterm.js + Playwright + node-pty. + * Core philosophy: WYSIWYG — let xterm.js complete terminal simulation and rendering + * inside the browser. Screenshots always capture the terminal's current real state, + * no manual output cleaning needed. + * + * Architecture: + * node-pty (pseudo-terminal) + * ↓ raw ANSI byte stream + * xterm.js (running inside Playwright headless Chromium) + * ↓ perfect rendering: colors, bold, cursor, scrolling + * Playwright element screenshot + * ↓ pixel-perfect screenshots (optional macOS window decorations) + */ + +import { chromium, type Browser, type Page } from 'playwright'; +import * as pty from '@lydell/node-pty'; +import stripAnsi from 'strip-ansi'; +import { mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { createRequire } from 'node:module'; + +const _require = createRequire(import.meta.url); + +// ───────────────────────────────────────────── +// Theme definitions +// ───────────────────────────────────────────── + +export interface XtermTheme { + background: string; + foreground: string; + cursor: string; + cursorAccent?: string; + selectionBackground?: string; + selectionForeground?: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; +} + +export const THEMES: Record = { + dracula: { + background: '#282a36', + foreground: '#f8f8f2', + cursor: '#f8f8f2', + selectionBackground: '#44475a', + black: '#21222c', + red: '#ff5555', + green: '#50fa7b', + yellow: '#f1fa8c', + blue: '#bd93f9', + magenta: '#ff79c6', + cyan: '#8be9fd', + white: '#f8f8f2', + brightBlack: '#6272a4', + brightRed: '#ff6e6e', + brightGreen: '#69ff94', + brightYellow: '#ffffa5', + brightBlue: '#d6acff', + brightMagenta: '#ff92df', + brightCyan: '#a4ffff', + brightWhite: '#ffffff', + }, + + 'one-dark': { + background: '#282c34', + foreground: '#abb2bf', + cursor: '#528bff', + selectionBackground: '#3e4451', + black: '#545862', + red: '#e06c75', + green: '#98c379', + yellow: '#e5c07b', + blue: '#61afef', + magenta: '#c678dd', + cyan: '#56b6c2', + white: '#abb2bf', + brightBlack: '#545862', + brightRed: '#e06c75', + brightGreen: '#98c379', + brightYellow: '#e5c07b', + brightBlue: '#61afef', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#c8ccd4', + }, + + 'github-dark': { + background: '#0d1117', + foreground: '#c9d1d9', + cursor: '#c9d1d9', + selectionBackground: '#264f78', + black: '#484f58', + red: '#ff7b72', + green: '#3fb950', + yellow: '#d29922', + blue: '#58a6ff', + magenta: '#bc8cff', + cyan: '#39c5cf', + white: '#b1bac4', + brightBlack: '#6e7681', + brightRed: '#ffa198', + brightGreen: '#56d364', + brightYellow: '#e3b341', + brightBlue: '#79c0ff', + brightMagenta: '#d2a8ff', + brightCyan: '#56d4dd', + brightWhite: '#f0f6fc', + }, + + monokai: { + background: '#272822', + foreground: '#f8f8f2', + cursor: '#f8f8f0', + selectionBackground: '#49483e', + black: '#272822', + red: '#f92672', + green: '#a6e22e', + yellow: '#f4bf75', + blue: '#66d9ef', + magenta: '#ae81ff', + cyan: '#a1efe4', + white: '#f8f8f2', + brightBlack: '#75715e', + brightRed: '#f92672', + brightGreen: '#a6e22e', + brightYellow: '#f4bf75', + brightBlue: '#66d9ef', + brightMagenta: '#ae81ff', + brightCyan: '#a1efe4', + brightWhite: '#f9f8f5', + }, + + 'night-owl': { + background: '#011627', + foreground: '#d6deeb', + cursor: '#80a4c2', + selectionBackground: '#1d3b53', + black: '#011627', + red: '#ef5350', + green: '#22da6e', + yellow: '#addb67', + blue: '#82aaff', + magenta: '#c792ea', + cyan: '#21c7a8', + white: '#d6deeb', + brightBlack: '#575656', + brightRed: '#ef5350', + brightGreen: '#22da6e', + brightYellow: '#ffeb95', + brightBlue: '#82aaff', + brightMagenta: '#c792ea', + brightCyan: '#7fdbca', + brightWhite: '#ffffff', + }, +}; + +// ───────────────────────────────────────────── +// Options +// ───────────────────────────────────────────── + +export interface TerminalCaptureOptions { + /** Number of terminal columns, default 120 */ + cols?: number; + /** Number of terminal rows, default 40 */ + rows?: number; + /** Working directory */ + cwd?: string; + /** Environment variables */ + env?: NodeJS.ProcessEnv; + /** Theme name or custom theme object, default 'dracula' */ + theme?: keyof typeof THEMES | XtermTheme; + /** Whether to show macOS window decorations (traffic lights + title bar), default true */ + chrome?: boolean; + /** Window title (only effective when chrome=true), default 'Terminal' */ + title?: string; + /** Font size, default 14 */ + fontSize?: number; + /** Font family, default system monospace font */ + fontFamily?: string; + /** Default screenshot output directory */ + outputDir?: string; +} + +// ───────────────────────────────────────────── +// Main class +// ───────────────────────────────────────────── + +export class TerminalCapture { + private browser: Browser | null = null; + private page: Page | null = null; + private ptyProcess: pty.IPty | null = null; + private rawOutput = ''; + private lastFlushedLength = 0; + + private readonly cols: number; + private readonly rows: number; + private readonly cwd: string; + private readonly env: NodeJS.ProcessEnv; + private readonly theme: XtermTheme; + private readonly showChrome: boolean; + private readonly windowTitle: string; + private readonly fontSize: number; + private readonly fontFamily: string; + private readonly outputDir: string; + + // ── Factory ────────────────────────────── + + /** + * Create and initialize a TerminalCapture instance + * + * @example + * ```ts + * const t = await TerminalCapture.create({ + * theme: 'dracula', + * chrome: true, + * title: 'qwen-code', + * }); + * ``` + */ + static async create( + options?: TerminalCaptureOptions, + ): Promise { + const instance = new TerminalCapture(options); + await instance.init(); + return instance; + } + + private constructor(options?: TerminalCaptureOptions) { + this.cols = options?.cols ?? 120; + this.rows = options?.rows ?? 40; + this.cwd = options?.cwd ?? process.cwd(); + // Build a clean env for optimal terminal rendering: + // - Remove NO_COLOR (conflicts with FORCE_COLOR, can crash gradient components) + // - Suppress Node.js warnings (noisy in screenshots) + // - Force color output and 256-color terminal + const baseEnv = { ...process.env }; + delete baseEnv['NO_COLOR']; + this.env = options?.env ?? { + ...baseEnv, + FORCE_COLOR: '1', + TERM: 'xterm-256color', + NODE_NO_WARNINGS: '1', + }; + this.showChrome = options?.chrome ?? true; + this.windowTitle = options?.title ?? 'Terminal'; + this.fontSize = options?.fontSize ?? 14; + this.fontFamily = + options?.fontFamily ?? + "'Menlo', 'Monaco', 'Consolas', 'Courier New', monospace"; + this.outputDir = options?.outputDir ?? join(process.cwd(), 'screenshots'); + + // Resolve theme + if (typeof options?.theme === 'string') { + this.theme = THEMES[options.theme] ?? THEMES['dracula']; + } else if (options?.theme && typeof options.theme === 'object') { + this.theme = options.theme; + } else { + this.theme = THEMES['dracula']; + } + } + + // ── Lifecycle ──────────────────────────── + + private async init(): Promise { + // 1. Launch browser + this.browser = await chromium.launch({ headless: true }); + this.page = await this.browser.newPage({ + viewport: { width: 1600, height: 1000 }, + }); + + // 2. Set base HTML (with chrome decoration, container, etc.) + await this.page.setContent(this.buildHTML()); + + // 3. Load xterm.js from node_modules + const xtermDir = this.resolveXtermDir(); + await this.page.addStyleTag({ path: join(xtermDir, 'css', 'xterm.css') }); + await this.page.addScriptTag({ path: join(xtermDir, 'lib', 'xterm.js') }); + + // 4. Create xterm Terminal instance inside the page + + await this.page.evaluate( + ({ cols, rows, theme, fontSize, fontFamily }) => { + const W = window as unknown as Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Terminal = W['Terminal'] as new (opts: unknown) => any; + const term = new Terminal({ + cols, + rows, + theme, + fontFamily, + fontSize, + lineHeight: 1.2, + cursorBlink: false, + allowProposedApi: true, + scrollback: 1000, + }); + + const container = document.getElementById('xterm-container')!; + + term.open(container); + + // Expose to outer scope + W['term'] = term; + W['termReady'] = true; + }, + { + cols: this.cols, + rows: this.rows, + theme: this.theme as unknown as Record, + fontSize: this.fontSize, + fontFamily: this.fontFamily, + }, + ); + + // 5. Wait until terminal is ready + await this.page.waitForFunction( + () => + (window as unknown as Record)['termReady'] === true, + ); + } + + /** + * Spawn a command (via pseudo-terminal) + * + * @example + * ```ts + * await terminal.spawn('node', ['dist/cli.js', '--yolo']); + * ``` + */ + async spawn(command: string, args: string[] = []): Promise { + if (!this.page) { + throw new Error( + 'Not initialized. Use TerminalCapture.create() factory method.', + ); + } + + this.ptyProcess = pty.spawn(command, args, { + name: 'xterm-256color', + cols: this.cols, + rows: this.rows, + cwd: this.cwd, + env: this.env, + }); + + this.ptyProcess.onData((data) => { + this.rawOutput += data; + }); + } + + // ── Input ──────────────────────────────── + + /** + * Input text. Supports `\n` as Enter. + * + * @param text Text to input + * @param options.delay Delay after input (ms), default 10 + * @param options.slow Type character by character (simulate real typing), default false + * + * @example + * ```ts + * await terminal.type('Hello world\n'); // Input + Enter + * await terminal.type('ls -la\n', { slow: true, delay: 80 }); + * ``` + */ + async type( + text: string, + options?: { delay?: number; slow?: boolean }, + ): Promise { + if (!this.ptyProcess) { + throw new Error('No process running. Call spawn() first.'); + } + + // Convert \n to \r for PTY + const translated = text.replace(/\n/g, '\r'); + + if (options?.slow) { + for (const char of translated) { + this.ptyProcess.write(char); + await this.sleep(options.delay ?? 50); + } + } else { + this.ptyProcess.write(translated); + await this.sleep(options?.delay ?? 10); + } + } + + // ── Wait ───────────────────────────────── + + /** + * Wait for specific text to appear in terminal output + * + * @throws Error on timeout + * + * @example + * ```ts + * await terminal.waitFor('Type your message'); + * await terminal.waitFor('tokens', { timeout: 30000 }); + * ``` + */ + async waitFor(text: string, options?: { timeout?: number }): Promise { + const timeout = options?.timeout ?? 15000; + const start = Date.now(); + + while (Date.now() - start < timeout) { + if ( + stripAnsi(this.rawOutput).toLowerCase().includes(text.toLowerCase()) + ) { + return; + } + await this.sleep(200); + } + + throw new Error( + `Timeout (${timeout}ms) waiting for text: "${text}"\n` + + `Last 500 chars of output: ${stripAnsi(this.rawOutput).slice(-500)}`, + ); + } + + /** + * Wait for output to stabilize (no new output within specified time) + * + * @param stableMs Stability detection duration (ms), default 500 + * @param timeout Maximum wait time (ms), default 30000 + * + * @example + * ```ts + * await terminal.idle(); // Default: 500ms with no new output considered stable + * await terminal.idle(2000); // 2s with no new output + * ``` + */ + async idle(stableMs: number = 500, timeout: number = 30000): Promise { + const start = Date.now(); + let lastLength = this.rawOutput.length; + let lastChangeTime = Date.now(); + + while (Date.now() - start < timeout) { + await this.sleep(100); + if (this.rawOutput.length !== lastLength) { + lastLength = this.rawOutput.length; + lastChangeTime = Date.now(); + } else if (Date.now() - lastChangeTime >= stableMs) { + return; + } + } + // Timeout for idle() is not an error — just means output kept coming + } + + /** + * Wait for text to appear, then wait for output to stabilize (common combination) + */ + async waitForAndIdle( + text: string, + options?: { timeout?: number; stableMs?: number }, + ): Promise { + await this.waitFor(text, { timeout: options?.timeout }); + await this.idle(options?.stableMs ?? 300, 5000); + } + + // ── Capture ────────────────────────────── + + /** + * Capture and save a screenshot. Filenames are deterministic (no timestamps) for easy regression comparison. + * + * @param filename Filename, e.g., 'initial.png' + * @param outputDir Output directory, defaults to the outputDir from construction + * @returns Full path to the screenshot file + * + * @example + * ```ts + * await terminal.capture('01-initial.png'); + * await terminal.capture('02-output.png', '/tmp/screenshots'); + * ``` + */ + async capture(filename: string, outputDir?: string): Promise { + if (!this.page) { + throw new Error('Not initialized'); + } + + // 1. Flush all accumulated PTY data to xterm.js + await this.flush(); + + // 2. Wait for xterm.js rendering to complete + await this.sleep(150); + + // 3. Prepare output directory + const dir = outputDir ?? this.outputDir; + mkdirSync(dir, { recursive: true }); + const filepath = join(dir, filename); + + // 4. Screenshot the capture root (terminal + optional chrome) + const element = await this.page.$('#capture-root'); + if (element) { + await element.screenshot({ path: filepath }); + } else { + await this.page.screenshot({ path: filepath }); + } + + console.log(`📸 Captured: ${filepath}`); + return filepath; + } + + /** + * Capture full terminal output (including scrollback buffer) as a long image. + * Suitable for scenarios where output exceeds the visible area, e.g., detailed token lists from /context. + * + * Principle: Temporarily expand xterm.js rows to show complete scrollback, then restore original dimensions after screenshot. + * Note: Only resizes xterm.js inside the browser, not the PTY dimensions, so it won't trigger CLI re-render. + * + * @param filename Filename + * @param outputDir Output directory + * @returns Full path to the screenshot file + * + * @example + * ```ts + * // Regular screenshot (only current viewport) + * await terminal.capture('output.png'); + * // Full-length image (including scrollback buffer) + * await terminal.captureFull('output-full.png'); + * ``` + */ + async captureFull(filename: string, outputDir?: string): Promise { + if (!this.page) { + throw new Error('Not initialized'); + } + + // 1. Flush all accumulated PTY data to xterm.js + await this.flush(); + await this.sleep(150); + + // 2. Query xterm.js for the actual content height (skip trailing empty lines) + const contentLines = await this.page.evaluate(() => { + const W = window as unknown as Record; + const term = W['term'] as { + buffer: { + active: { + length: number; + getLine: (i: number) => + | { + translateToString: (trimRight?: boolean) => string; + } + | undefined; + }; + }; + }; + const buf = term.buffer.active; + let lastNonEmpty = 0; + for (let i = buf.length - 1; i >= 0; i--) { + const line = buf.getLine(i); + if (line && line.translateToString(true).trim().length > 0) { + lastNonEmpty = i; + break; + } + } + return lastNonEmpty + 1; + }); + + const expandedRows = Math.max(contentLines + 2, this.rows); + + // 3. Temporarily resize xterm.js only (NOT the PTY) to show all content + // This avoids sending SIGWINCH to the child process, so the CLI won't re-render + await this.page.evaluate( + ({ cols, rows }: { cols: number; rows: number }) => { + const W = window as unknown as Record; + const term = W['term'] as { + resize: (c: number, r: number) => void; + scrollToTop: () => void; + }; + term.resize(cols, rows); + // Scroll to top to ensure rendering starts from scrollback beginning position + term.scrollToTop(); + }, + { cols: this.cols, rows: expandedRows }, + ); + + // 4. Expand viewport to accommodate the taller terminal + await this.page.setViewportSize({ + width: 1600, + height: Math.max(expandedRows * 22, 1000), // ~22px per row (fontSize 14 * lineHeight 1.2 + padding) + }); + + await this.sleep(300); + + // 5. Screenshot the full content + const dir = outputDir ?? this.outputDir; + mkdirSync(dir, { recursive: true }); + const filepath = join(dir, filename); + + const element = await this.page.$('#capture-root'); + if (element) { + await element.screenshot({ path: filepath }); + } else { + await this.page.screenshot({ path: filepath, fullPage: true }); + } + + // 6. Restore original xterm.js dimensions and viewport + await this.page.evaluate( + ({ cols, rows }: { cols: number; rows: number }) => { + const W = window as unknown as Record; + const term = W['term'] as { resize: (c: number, r: number) => void }; + term.resize(cols, rows); + }, + { cols: this.cols, rows: this.rows }, + ); + + await this.page.setViewportSize({ width: 1600, height: 1000 }); + + console.log(`📸 Captured (full): ${filepath}`); + return filepath; + } + + // ── Output access ──────────────────────── + + /** + * Get cleaned terminal output (without ANSI escape sequences) + */ + getOutput(): string { + return stripAnsi(this.rawOutput); + } + + /** + * Get raw terminal output (with ANSI escape sequences) + */ + getRawOutput(): string { + return this.rawOutput; + } + + // ── Cleanup ────────────────────────────── + + /** + * Release all resources (PTY process, browser) + */ + async close(): Promise { + if (this.ptyProcess) { + try { + this.ptyProcess.kill(); + } catch { + // Process may have already exited + } + this.ptyProcess = null; + } + + if (this.browser) { + await this.browser.close(); + this.browser = null; + this.page = null; + } + } + + // ── Internal: flush PTY → xterm.js ────── + + /** + * Flush accumulated PTY raw output to xterm.js inside the browser. + * Uses xterm.js's write callback to ensure data is fully parsed, + * then waits one requestAnimationFrame to ensure rendering is complete. + */ + private async flush(): Promise { + if (!this.page || this.rawOutput.length <= this.lastFlushedLength) { + return; + } + + const newData = this.rawOutput.slice(this.lastFlushedLength); + this.lastFlushedLength = this.rawOutput.length; + + // Send data in chunks to avoid hitting string size limits + const CHUNK_SIZE = 64 * 1024; + for (let i = 0; i < newData.length; i += CHUNK_SIZE) { + const chunk = newData.slice(i, i + CHUNK_SIZE); + await this.page.evaluate((data: string) => { + return new Promise((resolve) => { + const W = window as unknown as Record; + const term = W['term'] as { + write: (d: string, cb: () => void) => void; + }; + term.write(data, () => { + // Data parsed → wait one frame for rendering + requestAnimationFrame(() => resolve()); + }); + }); + }, chunk); + } + } + + // ── Internal: resolve xterm.js path ───── + + private resolveXtermDir(): string { + try { + const pkgJsonPath = _require.resolve('@xterm/xterm/package.json'); + return dirname(pkgJsonPath); + } catch { + throw new Error( + '@xterm/xterm is not installed.\n' + + 'Run: npm install --save-dev @xterm/xterm', + ); + } + } + + // ── Internal: build HTML ──────────────── + + private buildHTML(): string { + const bg = this.theme.background; + + // Title bar color: slightly lighter than background + // Use a manual approximation instead of color-mix for compatibility + const titleBarBg = this.lighten(bg, 0.08); + + const chromeHTML = this.showChrome + ? ` +
+
+ + + +
+ ${this.escapeHtml(this.windowTitle)} +
+
` + : ''; + + return ` + + + + + + +
+ ${chromeHTML} +
+
+ +`; + } + + // ── Internal: utils ───────────────────── + + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Lighten a hex color by a factor (0-1) + */ + private lighten(hex: string, factor: number): string { + const h = hex.replace('#', ''); + const r = Math.min( + 255, + parseInt(h.slice(0, 2), 16) + Math.round(255 * factor), + ); + const g = Math.min( + 255, + parseInt(h.slice(2, 4), 16) + Math.round(255 * factor), + ); + const b = Math.min( + 255, + parseInt(h.slice(4, 6), 16) + Math.round(255 * factor), + ); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/package-lock.json b/package-lock.json index df32644926..f26e50737e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.2", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.10.2", + "version": "0.11.1", "workspaces": [ "packages/*" ], @@ -2997,6 +2997,10 @@ "resolved": "packages/sdk-typescript", "link": true }, + "node_modules/@qwen-code/web-templates": { + "resolved": "packages/web-templates", + "link": true + }, "node_modules/@qwen-code/webui": { "resolved": "packages/webui", "link": true @@ -3834,6 +3838,120 @@ "node": ">=6" } }, + "node_modules/@teddyzhu/clipboard": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard/-/clipboard-0.0.5.tgz", + "integrity": "sha512-XA6MG7nLPZzj51agCwDYaVnVVrt0ByJ3G9rl3ar6N4GETAjUKKup6u76SLp2C5yHRWYV9hwMYDn04OGLar0MVg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + }, + "optionalDependencies": { + "@teddyzhu/clipboard-darwin-arm64": "0.0.5", + "@teddyzhu/clipboard-darwin-x64": "0.0.5", + "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5", + "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", + "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5", + "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5" + } + }, + "node_modules/@teddyzhu/clipboard-darwin-arm64": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.0.5.tgz", + "integrity": "sha512-FB3yykRAcw0VLmSjIGFddgew2t20UnLp80NZvi5e/lbsy/3mruHibMHkxHWqzCncuZsHdRsRXS/FmR/ggepW9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-darwin-x64": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-darwin-x64/-/clipboard-darwin-x64-0.0.5.tgz", + "integrity": "sha512-tiDazMpLf2dS7BZUif3da3DLJima8E/CnexB3CNgjQf12CFJ+D1cPcj/CgfvMYZgFQSsYyACpQNfXn4hmVbymA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-linux-arm64-gnu": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.0.5.tgz", + "integrity": "sha512-qcokM+BaXn4iG4o4nYGHdfC04pr54S2F7x2o5osFhG3hMVYHZLR/8NKcYDKELnebpH612nW2bNRoWWy14lM45g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-linux-x64-gnu": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.0.5.tgz", + "integrity": "sha512-Ogh4zYM9s537WJszSvKrPAoKQZ2grnY7Xy6szyJp2+84uQKWNbvZkATODAsRUn48zr9gqL3PZeUqkIBaz8sCpQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-win32-arm64-msvc": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.0.5.tgz", + "integrity": "sha512-TuU+7e8qYc0T++sIArHTmqr+nfqiTfJ6gdrb1e8yDJb6MM3EFxCd2VonTqLQL1YpUdfcH+/rdMarG2rvCwvEhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-win32-x64-msvc": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.0.5.tgz", + "integrity": "sha512-f1Br5bI+INNDifjkOI1woZsIxsoW0rRej/4kaaJvZcMxxkSG9TMT2LYOjTF2g+DtXw32lsGvWICN6c3JiHeG7Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -4403,6 +4521,13 @@ "kleur": "^3.0.3" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qrcode-terminal": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", @@ -18655,12 +18780,13 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.10.2", + "version": "0.11.1", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", + "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -18676,6 +18802,7 @@ "ink-spinner": "^5.0.0", "lowlight": "^3.3.0", "open": "^10.1.2", + "p-limit": "^7.3.0", "prompts": "^2.4.2", "qrcode-terminal": "^0.12.0", "react": "^19.1.0", @@ -18720,6 +18847,15 @@ }, "engines": { "node": ">=20" + }, + "optionalDependencies": { + "@teddyzhu/clipboard": "^0.0.5", + "@teddyzhu/clipboard-darwin-arm64": "0.0.5", + "@teddyzhu/clipboard-darwin-x64": "0.0.5", + "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5", + "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", + "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5", + "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5" } }, "packages/cli/node_modules/@google/genai": { @@ -19142,6 +19278,21 @@ "url": "https://opencollective.com/node-fetch" } }, + "packages/cli/node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/cli/node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -19272,9 +19423,21 @@ "node": ">=18.17" } }, + "packages/cli/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.10.2", + "version": "0.11.1", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22754,7 +22917,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.10.2", + "version": "0.11.1", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22766,7 +22929,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.10.2", + "version": "0.11.1", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -23011,9 +23174,537 @@ "node": ">= 0.6" } }, + "packages/web-templates": { + "name": "@qwen-code/web-templates", + "version": "0.11.1", + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.22", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", + "typescript": "^5.3.3", + "vite": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "packages/web-templates/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "packages/web-templates/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "packages/web-templates/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/web-templates/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.10.2", + "version": "0.11.1", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index 7063aee04a..5657d41299 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.2", + "version": "0.11.1", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.2" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.11.1" }, "scripts": { "start": "cross-env node scripts/start.js", @@ -50,7 +50,7 @@ "typecheck": "npm run typecheck --workspaces --if-present", "check-i18n": "npm run check-i18n --workspace=packages/cli", "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", - "prepare": "husky && npm run bundle", + "prepare": "husky && npm run build && npm run bundle", "prepare:package": "node scripts/prepare-package.js", "release:version": "node scripts/version.js", "telemetry": "node scripts/telemetry.js", diff --git a/packages/cli/assets/parallel-build.mjs b/packages/cli/assets/parallel-build.mjs deleted file mode 100644 index e070aa08f1..0000000000 --- a/packages/cli/assets/parallel-build.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import { access, readdir } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import process from 'node:process'; - -const assetsDir = dirname(fileURLToPath(import.meta.url)); -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - -const entries = await readdir(assetsDir, { withFileTypes: true }); -const assetBuilds = []; - -for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const assetPath = join(assetsDir, entry.name); - const buildPath = join(assetPath, 'build.mjs'); - const packageJsonPath = join(assetPath, 'package.json'); - let hasBuild = false; - let hasPackageJson = false; - - try { - await access(buildPath); - hasBuild = true; - } catch { - // ignore missing build.mjs - } - - try { - await access(packageJsonPath); - hasPackageJson = true; - } catch { - // ignore missing package.json - } - - if (hasBuild || hasPackageJson) { - assetBuilds.push({ - name: entry.name, - assetPath, - buildPath, - useNpm: hasPackageJson, - }); - } -} - -if (assetBuilds.length === 0) { - process.exit(0); -} - -const runCommand = ({ command, args, cwd, label }) => - new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - stdio: 'inherit', - shell: process.platform === 'win32', - }); - - child.on('error', reject); - child.on('exit', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`${label} failed for ${cwd}.`)); - } - }); - }); - -const runBuild = async (asset) => { - if (asset.useNpm) { - await runCommand({ - command: npmCommand, - args: ['install'], - cwd: asset.assetPath, - label: `npm install`, - }); - - await runCommand({ - command: npmCommand, - args: ['run', 'build'], - cwd: asset.assetPath, - label: `npm run build`, - }); - return; - } - - await runCommand({ - command: process.execPath, - args: [asset.buildPath], - cwd: asset.assetPath, - label: `Node build`, - }); -}; - -await Promise.all(assetBuilds.map((asset) => runBuild(asset))); diff --git a/packages/cli/package.json b/packages/cli/package.json index b30ed7e48d..2dc3d87d79 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.2", + "version": "0.11.1", "description": "Qwen Code", "repository": { "type": "git", @@ -19,8 +19,7 @@ } }, "scripts": { - "build:assets": "node ./assets/parallel-build.mjs", - "build": "npm run build:assets && node ../../scripts/build_package.js", + "build": "node ../../scripts/build_package.js", "start": "node dist/index.js", "debug": "node --inspect-brk dist/index.js", "lint": "eslint . --ext .ts,.tsx", @@ -34,13 +33,14 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.2" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.11.1" }, "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", + "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -56,6 +56,7 @@ "ink-spinner": "^5.0.0", "lowlight": "^3.3.0", "open": "^10.1.2", + "p-limit": "^7.3.0", "prompts": "^2.4.2", "qrcode-terminal": "^0.12.0", "react": "^19.1.0", @@ -81,12 +82,12 @@ "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/node": "^20.11.24", + "@types/prompts": "^2.4.9", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", "@types/yargs": "^17.0.32", - "@types/prompts": "^2.4.9", "archiver": "^7.0.1", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", @@ -95,6 +96,15 @@ "typescript": "^5.3.3", "vitest": "^3.1.1" }, + "optionalDependencies": { + "@teddyzhu/clipboard": "^0.0.5", + "@teddyzhu/clipboard-darwin-arm64": "0.0.5", + "@teddyzhu/clipboard-darwin-x64": "0.0.5", + "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", + "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5", + "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5", + "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5" + }, "engines": { "node": ">=20" } diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index 17a0cdbcfc..9dfbf35b39 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -84,7 +84,11 @@ export class AcpFileSystemService implements FileSystemService { limit: 1, }); // Check if content starts with BOM character (U+FEFF) - return response.content.charCodeAt(0) === 0xfeff; + // Use codePointAt for better Unicode support and check content length first + return ( + response.content.length > 0 && + response.content.codePointAt(0) === 0xfeff + ); } catch { // Fall through to fallback if ACP read fails } diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d7a5e73953..702f66a072 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -516,6 +516,18 @@ export class Session implements SessionContext { ? await invocation.shouldConfirmExecute(abortSignal) : false; + // Check for plan mode enforcement - block non-read-only tools + const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; + if (isPlanMode && !isExitPlanModeTool && confirmationDetails) { + // In plan mode, block any tool that requires confirmation (write operations) + return errorResponse( + new Error( + `Plan mode is active. The tool "${fc.name}" cannot be executed because it modifies the system. ` + + 'Please use the exit_plan_mode tool to present your plan and exit plan mode before making changes.', + ), + ); + } + if (confirmationDetails) { const content: acp.ToolCallContent[] = []; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c31ffa216c..48961cdcac 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -137,7 +137,6 @@ export interface CliArgs { googleSearchEngineId: string | undefined; webSearchDefault: string | undefined; screenReader: boolean | undefined; - vlmSwitchMode: string | undefined; inputFormat?: string | undefined; outputFormat: string | undefined; includePartialMessages?: boolean; @@ -426,13 +425,6 @@ export async function parseArguments(): Promise { type: 'boolean', description: 'Enable screen reader mode for accessibility.', }) - .option('vlm-switch-mode', { - type: 'string', - choices: ['once', 'session', 'persist'], - description: - 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.', - default: process.env['VLM_SWITCH_MODE'], - }) .option('input-format', { type: 'string', choices: ['text', 'stream-json'], @@ -701,14 +693,21 @@ export async function loadCliConfig( } // Automatically load output-language.md if it exists - let outputLanguageFilePath: string | undefined = path.join( + const projectStorage = new Storage(cwd); + const projectOutputLanguagePath = path.join( + projectStorage.getQwenDir(), + 'output-language.md', + ); + const globalOutputLanguagePath = path.join( Storage.getGlobalQwenDir(), 'output-language.md', ); - if (fs.existsSync(outputLanguageFilePath)) { - // output-language.md found - will be added to context files - } else { - outputLanguageFilePath = undefined; + + let outputLanguageFilePath: string | undefined; + if (fs.existsSync(projectOutputLanguagePath)) { + outputLanguageFilePath = projectOutputLanguagePath; + } else if (fs.existsSync(globalOutputLanguagePath)) { + outputLanguageFilePath = globalOutputLanguagePath; } const fileService = new FileDiscoveryService(cwd); @@ -903,9 +902,6 @@ export async function loadCliConfig( ? argv.screenReader : (settings.ui?.accessibility?.screenReader ?? false); - const vlmSwitchMode = - argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode; - let sessionId: string | undefined; let sessionData: ResumedSessionData | undefined; @@ -1002,6 +998,7 @@ export async function loadCliConfig( modelProvidersConfig, generationConfigSources: resolvedCliConfig.sources, generationConfig: resolvedCliConfig.generationConfig, + warnings: resolvedCliConfig.warnings, cliVersion: await getCliVersion(), webSearch: buildWebSearchConfig(argv, settings, selectedAuthType), summarizeToolOutput: settings.model?.summarizeToolOutput, @@ -1016,7 +1013,6 @@ export async function loadCliConfig( skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, skipLoopDetection: settings.model?.skipLoopDetection ?? false, skipStartupContext: settings.model?.skipStartupContext ?? false, - vlmSwitchMode, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, truncateToolOutputLines: settings.tools?.truncateToolOutputLines, enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 8737866ea8..226727c5bf 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -78,6 +78,7 @@ export interface KeyBinding { command?: boolean; /** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */ paste?: boolean; + meta?: boolean; } /** @@ -152,7 +153,16 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'x', ctrl: true }, { sequence: '\x18', ctrl: true }, ], - [Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }], + [Command.PASTE_CLIPBOARD_IMAGE]: + process.platform === 'win32' + ? [ + { key: 'v', command: true }, + { key: 'v', meta: true }, + ] + : [ + { key: 'v', ctrl: true }, + { key: 'v', command: true }, + ], // App level bindings [Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }], diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index fe2f63bd18..e261cc7234 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -122,8 +122,6 @@ const MIGRATION_MAP: Record = { skipStartupContext: 'model.skipStartupContext', enableOpenAILogging: 'model.enableOpenAILogging', tavilyApiKey: 'advanced.tavilyApiKey', - vlmSwitchMode: 'experimental.vlmSwitchMode', - visionModelPreview: 'experimental.visionModelPreview', }; // Settings that need boolean inversion during migration (V1 -> V3) diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index fc902234fc..cfde449ca0 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -28,7 +28,7 @@ describe('SettingsSchema', () => { 'mcp', 'security', 'advanced', - 'experimental', + 'webSearch', ]; expectedSettings.forEach((setting) => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 283baee26b..fd6c3e85b0 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -147,7 +147,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: {} as Record, description: - 'Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.env.', + 'Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.json env field.', showInDialog: false, mergeStrategy: MergeStrategy.SHALLOW_MERGE, }, @@ -1176,38 +1176,6 @@ const SETTINGS_SCHEMA = { description: 'Configuration for web search providers.', showInDialog: false, }, - - experimental: { - type: 'object', - label: 'Experimental', - category: 'Experimental', - requiresRestart: true, - default: {}, - description: 'Setting to enable experimental features', - showInDialog: false, - properties: { - visionModelPreview: { - type: 'boolean', - label: 'Vision Model Preview', - category: 'Experimental', - requiresRestart: false, - default: true, - description: - 'Enable vision model support and auto-switching functionality. When disabled, vision models like qwen-vl-max-latest will be hidden and auto-switching will not occur.', - showInDialog: false, - }, - vlmSwitchMode: { - type: 'string', - label: 'VLM Switch Mode', - category: 'Experimental', - requiresRestart: false, - default: undefined as string | undefined, - description: - 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). If not set, user will be prompted each time. This is a temporary experimental feature.', - showInDialog: false, - }, - }, - }, } as const satisfies SettingsSchema; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index e55aeb93d1..80c41f50bd 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -7,6 +7,14 @@ import { createHash } from 'node:crypto'; import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core'; +/** + * Coding plan regions + */ +export enum CodingPlanRegion { + CHINA = 'china', + GLOBAL = 'global', +} + /** * Coding plan template - array of model configurations * When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key @@ -14,48 +22,306 @@ import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-co export type CodingPlanTemplate = ModelConfig[]; /** - * Environment variable key for storing the coding plan API key + * Environment variable key for storing the coding plan API key. + * Unified key for both regions since they are mutually exclusive. */ export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; /** - * CODING_PLAN_MODELS defines the model configurations for coding-plan mode. + * Computes the version hash for the coding plan template. + * Uses SHA256 of the JSON-serialized template for deterministic versioning. + * @param template - The template to compute version for + * @returns Hexadecimal string representing the template version + */ +export function computeCodingPlanVersion(template: CodingPlanTemplate): string { + const templateString = JSON.stringify(template); + return createHash('sha256').update(templateString).digest('hex'); +} + +/** + * Generate the complete coding plan template for a specific region. + * China region uses legacy description to maintain backward compatibility. + * Global region uses new description with region indicator. + * @param region - The region to generate template for + * @returns Complete model configuration array for the region */ -export const CODING_PLAN_MODELS: CodingPlanTemplate = [ - { - id: 'qwen3-coder-plus', - name: 'qwen3-coder-plus', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - description: 'qwen3-coder-plus model from Bailian Coding Plan', - envKey: CODING_PLAN_ENV_KEY, - }, - { - id: 'qwen3-max-2026-01-23', - name: 'qwen3-max-2026-01-23', - description: - 'qwen3-max model with thinking enabled from Bailian Coding Plan', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, +export function generateCodingPlanTemplate( + region: CodingPlanRegion, +): CodingPlanTemplate { + if (region === CodingPlanRegion.CHINA) { + // China region uses legacy fields to maintain backward compatibility + // This ensures existing users don't get prompted for unnecessary updates + return [ + { + id: 'qwen3.5-plus', + name: '[Bailian Coding Plan] qwen3.5-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-plus', + name: '[Bailian Coding Plan] qwen3-coder-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-next', + name: '[Bailian Coding Plan] qwen3-coder-next', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, + }, + { + id: 'qwen3-max-2026-01-23', + name: '[Bailian Coding Plan] qwen3-max-2026-01-23', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 262144, + }, + }, + { + id: 'glm-4.7', + name: '[Bailian Coding Plan] glm-4.7', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 202752, + }, + }, + { + id: 'glm-5', + name: '[Bailian Coding Plan] glm-5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 202752, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[Bailian Coding Plan] MiniMax-M2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 1000000, + }, + }, + { + id: 'kimi-k2.5', + name: '[Bailian Coding Plan] kimi-k2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 262144, + }, + }, + ]; + } + + // Global region uses Bailian Coding Plan branding for Global/Intl + return [ + { + id: 'qwen3.5-plus', + name: '[Bailian Coding Plan for Global/Intl] qwen3.5-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 1000000, }, }, - }, -]; + { + id: 'qwen3-coder-plus', + name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-next', + name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-next', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, + }, + { + id: 'qwen3-max-2026-01-23', + name: '[Bailian Coding Plan for Global/Intl] qwen3-max-2026-01-23', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 262144, + }, + }, + { + id: 'glm-4.7', + name: '[Bailian Coding Plan for Global/Intl] glm-4.7', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 202752, + }, + }, + { + id: 'glm-5', + name: '[Bailian Coding Plan for Global/Intl] glm-5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 202752, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[Bailian Coding Plan for Global/Intl] MiniMax-M2.5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 1000000, + }, + }, + { + id: 'kimi-k2.5', + name: '[Bailian Coding Plan for Global/Intl] kimi-k2.5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 262144, + }, + }, + ]; +} /** - * Computes the version hash for the coding plan template. - * Uses SHA256 of the JSON-serialized template for deterministic versioning. - * @returns Hexadecimal string representing the template version + * Get the complete configuration for a specific region. + * @param region - The region to use + * @returns Object containing template, baseUrl, and version */ -export function computeCodingPlanVersion(): string { - const templateString = JSON.stringify(CODING_PLAN_MODELS); - return createHash('sha256').update(templateString).digest('hex'); +export function getCodingPlanConfig(region: CodingPlanRegion) { + const template = generateCodingPlanTemplate(region); + const baseUrl = + region === CodingPlanRegion.CHINA + ? 'https://coding.dashscope.aliyuncs.com/v1' + : 'https://coding-intl.dashscope.aliyuncs.com/v1'; + const regionName = + region === CodingPlanRegion.CHINA + ? 'Coding Plan (Bailian, China)' + : 'Coding Plan (Bailian, Global/Intl)'; + + return { + template, + baseUrl, + regionName, + version: computeCodingPlanVersion(template), + }; } /** - * Current version of the coding plan template. - * Computed at runtime from the template content. + * Get all unique base URLs for coding plan (used for filtering/config detection). + * @returns Array of base URLs */ -export const CODING_PLAN_VERSION = computeCodingPlanVersion(); +export function getCodingPlanBaseUrls(): string[] { + return [ + 'https://coding.dashscope.aliyuncs.com/v1', + 'https://coding-intl.dashscope.aliyuncs.com/v1', + ]; +} + +/** + * Check if a config belongs to Coding Plan (any region). + * Returns the region if matched, or false if not a Coding Plan config. + * @param baseUrl - The baseUrl to check + * @param envKey - The envKey to check + * @returns The region if matched, false otherwise + */ +export function isCodingPlanConfig( + baseUrl: string | undefined, + envKey: string | undefined, +): CodingPlanRegion | false { + if (!baseUrl || !envKey) { + return false; + } + + // Must use the unified envKey + if (envKey !== CODING_PLAN_ENV_KEY) { + return false; + } + + // Check which region's baseUrl matches + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + + return false; +} + +/** + * Get region from baseUrl. + * @param baseUrl - The baseUrl to check + * @returns The region if matched, null otherwise + */ +export function getRegionFromBaseUrl( + baseUrl: string | undefined, +): CodingPlanRegion | null { + if (!baseUrl) return null; + + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + + return null; +} diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 44c4c29d7f..6c48658ade 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -48,6 +48,7 @@ vi.mock('./config/config.js', () => ({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), isInteractive: () => false, + getWarnings: vi.fn(() => []), } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), @@ -177,6 +178,7 @@ describe('gemini.tsx main function', () => { getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', getOutputFormat: () => OutputFormat.TEXT, + getWarnings: () => [], } as unknown as Config; }); vi.mocked(loadSettings).mockReturnValue({ @@ -341,6 +343,7 @@ describe('gemini.tsx main function', () => { getProjectRoot: () => '/', getInputFormat: () => 'stream-json', getContentGeneratorConfig: () => ({ authType: 'test-auth' }), + getWarnings: () => [], } as unknown as Config; vi.mocked(loadCliConfig).mockResolvedValue(configStub); @@ -438,6 +441,7 @@ describe('gemini.tsx main function kitty protocol', () => { getExperimentalZedIntegration: () => false, getScreenReader: () => false, getGeminiMdFileCount: () => 0, + getWarnings: () => [], } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -483,7 +487,6 @@ describe('gemini.tsx main function kitty protocol', () => { googleSearchEngineId: undefined, webSearchDefault: undefined, screenReader: undefined, - vlmSwitchMode: undefined, inputFormat: undefined, outputFormat: undefined, includePartialMessages: undefined, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 08c0631a8b..c5e742ee66 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -411,6 +411,7 @@ export async function main() { useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true, })), ...getSettingsWarnings(settings), + ...config.getWarnings(), ]), ]; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index d000dc1f4a..39e54fefa6 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -11,6 +11,12 @@ export default { // ============================================================================ // Help / UI Components // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ Anhänge verwalten', + '← → select, Delete to remove, ↓ to exit': + '← → auswählen, Entf zum LÃļschen, ↓ beenden', + 'Attachments: ': 'Anhänge: ', + 'Basics:': 'Grundlagen:', 'Add context': 'Kontext hinzufÃŧgen', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': @@ -1028,6 +1034,17 @@ export default { '(default)': '(Standard)', '(set)': '(gesetzt)', '(not set)': '(nicht gesetzt)', + Modality: 'Modalität', + 'Context Window': 'Kontextfenster', + text: 'Text', + 'text-only': 'nur Text', + image: 'Bild', + pdf: 'PDF', + audio: 'Audio', + video: 'Video', + 'not set': 'nicht gesetzt', + none: 'keine', + unknown: 'unbekannt', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Modell konnte nicht auf '{{modelId}}' umgestellt werden.\n\n{{error}}", 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': @@ -1374,10 +1391,26 @@ export default { 'Erweiterungsseite wird im Browser geÃļffnet: {{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': 'Browser konnte nicht geÃļffnet werden. Besuchen Sie die Erweiterungsgalerie unter {{url}}', + 'Use /compress when the conversation gets long to summarize history and free up context.': + 'Verwenden Sie /compress, wenn die Unterhaltung lang wird, um den Verlauf zusammenzufassen und Kontext freizugeben.', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + 'Starten Sie eine neue Idee mit /clear oder /new; die vorherige Sitzung bleibt im Verlauf verfÃŧgbar.', + 'Use /bug to submit issues to the maintainers when something goes off.': + 'Verwenden Sie /bug, um Probleme an die Betreuer zu melden, wenn etwas schiefgeht.', + 'Switch auth type quickly with /auth.': + 'Wechseln Sie den Authentifizierungstyp schnell mit /auth.', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'Sie kÃļnnen beliebige Shell-Befehle in Qwen Code mit ! ausfÃŧhren (z. B. !ls).', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + 'Geben Sie / ein, um das BefehlsmenÃŧ zu Ãļffnen; Tab vervollständigt Slash-Befehle und gespeicherte Prompts.', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'Sie kÃļnnen eine frÃŧhere Unterhaltung mit qwen --continue oder qwen --resume fortsetzen.', 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': 'Sie kÃļnnen den Berechtigungsmodus schnell mit Shift+Tab oder /approval-mode wechseln.', 'You can switch permission mode quickly with Tab or /approval-mode.': 'Sie kÃļnnen den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.', + 'Try /insight to generate personalized insights from your chat history.': + 'Probieren Sie /insight, um personalisierte Erkenntnisse aus Ihrem Chatverlauf zu erstellen.', // ============================================================================ // Custom API-KEY Configuration @@ -1417,8 +1450,12 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": 'FÃŧgen Sie Ihren Bailian Coding Plan API-SchlÃŧssel ein und Sie sind bereit!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'FÃŧgen Sie Ihren Coding Plan (Bailian, Global/Intl) API-SchlÃŧssel ein und Sie sind bereit!', Custom: 'Benutzerdefiniert', 'More instructions about configuring `modelProviders` manually.': 'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.', @@ -1428,4 +1465,18 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Enter zum Absenden, Escape zum Abbrechen)', 'More instructions please check:': 'Weitere Anweisungen finden Sie unter:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Neue Modellkonfigurationen sind fÃŧr {{region}} verfÃŧgbar. Jetzt aktualisieren?', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Neue Modellkonfigurationen sind fÃŧr Bailian Coding Plan (China) verfÃŧgbar. Jetzt aktualisieren?', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Neue Modellkonfigurationen sind fÃŧr Coding Plan (Bailian, Global/Intl) verfÃŧgbar. Jetzt aktualisieren?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Erfolgreich mit {{region}} authentifiziert. API-SchlÃŧssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 88b376622d..b059a53613 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -11,6 +11,12 @@ export default { // ============================================================================ // Help / UI Components // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ to manage attachments', + '← → select, Delete to remove, ↓ to exit': + '← → select, Delete to remove, ↓ to exit', + 'Attachments: ': 'Attachments: ', + 'Basics:': 'Basics:', 'Add context': 'Add context', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': @@ -1015,6 +1021,17 @@ export default { '(default)': '(default)', '(set)': '(set)', '(not set)': '(not set)', + Modality: 'Modality', + 'Context Window': 'Context Window', + text: 'text', + 'text-only': 'text-only', + image: 'image', + pdf: 'pdf', + audio: 'audio', + video: 'video', + 'not set': 'not set', + none: 'none', + unknown: 'unknown', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Failed to switch model to '{{modelId}}'.\n\n{{error}}", 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': @@ -1109,6 +1126,8 @@ export default { 'You can switch permission mode quickly with Shift+Tab or /approval-mode.', 'You can switch permission mode quickly with Tab or /approval-mode.': 'You can switch permission mode quickly with Tab or /approval-mode.', + 'Try /insight to generate personalized insights from your chat history.': + 'Try /insight to generate personalized insights from your chat history.', // ============================================================================ // Exit Screen / Stats @@ -1418,8 +1437,12 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": "Paste your api key of Bailian Coding Plan and you're all set!", + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!", Custom: 'Custom', 'More instructions about configuring `modelProviders` manually.': 'More instructions about configuring `modelProviders` manually.', @@ -1427,4 +1450,18 @@ export default { '(Press Escape to go back)': '(Press Escape to go back)', '(Press Enter to submit, Escape to cancel)': '(Press Enter to submit, Escape to cancel)', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'New model configurations are available for {{region}}. Update now?', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'New model configurations are available for Bailian Coding Plan (China). Update now?', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} configuration updated successfully. Model switched to "{{model}}".', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 6f9ffe12d4..dd4e0a34c0 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -731,6 +731,17 @@ export default { // Dialogs - Model 'Select Model': 'ãƒĸデãƒĢを選択', '(Press Esc to close)': '(Esc で閉じる)', + Modality: 'ãƒĸダãƒĒãƒ†ã‚Ŗ', + 'Context Window': 'ã‚ŗãƒŗãƒ†ã‚­ã‚šãƒˆã‚Ļã‚Ŗãƒŗãƒ‰ã‚Ļ', + text: 'テキ゚ト', + 'text-only': 'テキ゚トぎãŋ', + image: 'į”ģ像', + pdf: 'PDF', + audio: '韺媰', + video: '動į”ģ', + 'not set': 'æœĒč¨­åŽš', + none: 'ãĒし', + unknown: '不明', 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': 'Qwen 3.5 Plus — åŠšįŽ‡įš„ãĒハイブãƒĒッドãƒĸデãƒĢ、æĨ­į•Œãƒˆãƒƒãƒ—ã‚¯ãƒŠã‚šãŽã‚ŗãƒŧãƒ‡ã‚Ŗãƒŗã‚°æ€§čƒŊ', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': @@ -783,6 +794,27 @@ export default { "Starting OAuth authentication for MCP server '{{name}}'...": "MCPã‚ĩãƒŧバãƒŧ '{{name}}' ぎOAuthčĒč¨ŧを開始中...", // Startup Tips + 'Tips:': 'ãƒ’ãƒŗãƒˆīŧš', + 'Use /compress when the conversation gets long to summarize history and free up context.': + 'äŧščŠąãŒé•ˇããĒãŖãŸã‚‰ /compress でåąĨ歴をčĻį´„ã—ã€ã‚ŗãƒŗãƒ†ã‚­ã‚šãƒˆã‚’č§Ŗæ”žã§ããžã™ã€‚', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + '/clear ぞたは /new で新しいã‚ĸイデã‚ĸを始められぞす。前ぎã‚ģãƒƒã‚ˇãƒ§ãƒŗã¯åąĨæ­´ãĢ掋りぞす。', + 'Use /bug to submit issues to the maintainers when something goes off.': + 'å•éĄŒãŒį™ēį”Ÿã—ãŸã‚‰ /bug ã§ãƒĄãƒŗãƒ†ãƒŠãƒŧãĢå ąå‘Šã§ããžã™ã€‚', + 'Switch auth type quickly with /auth.': + '/auth でčĒč¨ŧã‚ŋイプをすばやく切りæ›ŋえられぞす。', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'Qwen Code から ! をäŊŋãŖãĻäģģæ„ãŽã‚ˇã‚§ãƒĢã‚ŗãƒžãƒŗãƒ‰ã‚’åŽŸčĄŒã§ããžã™īŧˆäž‹: !lsīŧ‰ã€‚', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + '/ をå…Ĩ力しãĻã‚ŗãƒžãƒŗãƒ‰ãƒãƒƒãƒ—ã‚ĸップを開きぞす。Tab ã§ã‚šãƒŠãƒƒã‚ˇãƒĨã‚ŗãƒžãƒŗãƒ‰ã¨äŋå­˜æ¸ˆãŋãƒ—ãƒ­ãƒŗãƒ—ãƒˆã‚’čŖœåŽŒã§ããžã™ã€‚', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'qwen --continue ぞたは qwen --resume で前ぎäŧščŠąã‚’å†é–‹ã§ããžã™ã€‚', + 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': + 'Shift+Tab ぞたは /approval-mode で樊限ãƒĸãƒŧドをすばやく切りæ›ŋえられぞす。', + 'You can switch permission mode quickly with Tab or /approval-mode.': + 'Tab ぞたは /approval-mode で樊限ãƒĸãƒŧドをすばやく切りæ›ŋえられぞす。', + 'Try /insight to generate personalized insights from your chat history.': + '/insight ã§ãƒãƒŖãƒƒãƒˆåąĨ歴からパãƒŧã‚Ŋナナイã‚ēã•ã‚ŒãŸã‚¤ãƒŗã‚ĩã‚¤ãƒˆã‚’į”Ÿæˆã§ããžã™ã€‚', 'Tips for getting started:': 'å§‹ã‚ã‚‹ãŸã‚ãŽãƒ’ãƒŗãƒˆ:', '1. Ask questions, edit files, or run commands.': '1. čŗĒå•ã—ãŸã‚Šã€ãƒ•ã‚Ąã‚¤ãƒĢã‚’įˇ¨é›†ã—ãŸã‚Šã€ã‚ŗãƒžãƒŗãƒ‰ã‚’åŽŸčĄŒã—ãŸã‚Šã§ããžã™', @@ -928,8 +960,13 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, 中å›Ŋ)', + 'Coding Plan (Bailian, Global/Intl)': + 'Coding Plan (Bailian, グロãƒŧバãƒĢ/å›Ŋ際)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Bailian Coding PlanぎAPIキãƒŧをč˛ŧりäģ˜ã‘るだけでæē–å‚™åތäē†ã§ã™īŧ', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Coding Plan (Bailian, グロãƒŧバãƒĢ/å›Ŋ際) ぎAPIキãƒŧをč˛ŧりäģ˜ã‘るだけでæē–å‚™åތäē†ã§ã™īŧ', Custom: 'ã‚Ģ゚ã‚ŋム', 'More instructions about configuring `modelProviders` manually.': '`modelProviders`ã‚’æ‰‹å‹•ã§č¨­åŽšã™ã‚‹æ–šæŗ•ãŽčŠŗį´°ã¯ã“ãĄã‚‰ã€‚', @@ -938,4 +975,18 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Enterで送äŋĄã€Escapeã§ã‚­ãƒŖãƒŗã‚ģãƒĢ)', 'More instructions please check:': 'čŠŗį´°ãĒæ‰‹é †ã¯ã“ãĄã‚‰ã‚’ã”įĸēčĒãã ã•ã„īŧš', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + '{{region}} ぎ新しいãƒĸデãƒĢč¨­åŽšãŒåˆŠį”¨å¯čƒŊです。äģŠã™ãæ›´æ–°ã—ぞすかīŧŸ', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Bailian Coding Plan (中å›Ŋ) ぎ新しいãƒĸデãƒĢč¨­åŽšãŒåˆŠį”¨å¯čƒŊです。äģŠã™ãæ›´æ–°ã—ぞすかīŧŸ', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Coding Plan (Bailian, グロãƒŧバãƒĢ/å›Ŋ際) ぎ新しいãƒĸデãƒĢč¨­åŽšãŒåˆŠį”¨å¯čƒŊです。äģŠã™ãæ›´æ–°ã—ぞすかīŧŸ', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} ãŽč¨­åŽšãŒæ­Ŗå¸¸ãĢ更新されぞした。ãƒĸデãƒĢが "{{model}}" ãĢ切りæ›ŋわりぞした。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + '{{region}} でぎčĒč¨ŧãĢ成功しぞした。APIキãƒŧとãƒĸデãƒĢč¨­åŽšãŒ settings.json ãĢäŋå­˜ã•れぞしたīŧˆãƒãƒƒã‚¯ã‚ĸップ済ãŋīŧ‰ã€‚', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 08901262a8..7be4b9f8c5 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1037,6 +1037,17 @@ export default { '(default)': '(padrÃŖo)', '(set)': '(definido)', '(not set)': '(nÃŖo definido)', + Modality: 'Modalidade', + 'Context Window': 'Janela de Contexto', + text: 'texto', + 'text-only': 'somente texto', + image: 'imagem', + pdf: 'PDF', + audio: 'ÃĄudio', + video: 'vídeo', + 'not set': 'nÃŖo definido', + none: 'nenhum', + unknown: 'desconhecido', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Falha ao trocar o modelo para '{{modelId}}'.\n\n{{error}}", 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': @@ -1132,6 +1143,8 @@ export default { 'VocÃĒ pode retomar uma conversa anterior executando qwen --continue ou qwen --resume.', 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': 'VocÃĒ pode alternar o modo de permissÃŖo rapidamente com Shift+Tab ou /approval-mode.', + 'Try /insight to generate personalized insights from your chat history.': + 'Experimente /insight para gerar insights personalizados do seu histÃŗrico de conversas.', // ============================================================================ // Exit Screen / Stats @@ -1431,8 +1444,12 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Cole sua chave de API do Bailian Coding Plan e pronto!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Cole sua chave de API do Coding Plan (Bailian, Global/Intl) e pronto!', Custom: 'Personalizado', 'More instructions about configuring `modelProviders` manually.': 'Mais instruçÃĩes sobre como configurar `modelProviders` manualmente.', @@ -1442,4 +1459,18 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Pressione Enter para enviar, Escape para cancelar)', 'More instructions please check:': 'Mais instruçÃĩes, consulte:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Novas configuraçÃĩes de modelo estÃŖo disponíveis para o {{region}}. Atualizar agora?', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Novas configuraçÃĩes de modelo estÃŖo disponíveis para o Bailian Coding Plan (China). Atualizar agora?', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Novas configuraçÃĩes de modelo estÃŖo disponíveis para o Coding Plan (Bailian, Global/Intl). Atualizar agora?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + 'ConfiguraÃ§ÃŖo do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Autenticado com sucesso com {{region}}. Chave de API e configuraçÃĩes de modelo salvas em settings.json (com backup).', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 3806807d64..5768aac15b 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -11,6 +11,12 @@ export default { // ============================================================================ // ĐĄĐŋŅ€Đ°Đ˛Đēа / КоĐŧĐŋĐžĐŊĐĩĐŊ҂ҋ иĐŊŅ‚ĐĩҀ҄ĐĩĐšŅĐ° // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ ҃ĐŋŅ€Đ°Đ˛ĐģĐĩĐŊиĐĩ вĐģĐžĐļĐĩĐŊĐ¸ŅĐŧи', + '← → select, Delete to remove, ↓ to exit': + '← → Đ˛Ņ‹ĐąŅ€Đ°Ņ‚ŅŒ, Delete ŅƒĐ´Đ°ĐģĐ¸Ņ‚ŅŒ, ↓ Đ˛Ņ‹ĐšŅ‚Đ¸', + 'Attachments: ': 'ВĐģĐžĐļĐĩĐŊĐ¸Ņ: ', + 'Basics:': 'ĐžŅĐŊĐžĐ˛Ņ‹:', 'Add context': 'Đ”ĐžĐąĐ°Đ˛Đ¸Ņ‚ŅŒ ĐēĐžĐŊŅ‚ĐĩĐēҁ҂', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': @@ -1030,6 +1036,17 @@ export default { '(default)': '(ĐŋĐž ҃ĐŧĐžĐģŅ‡Đ°ĐŊĐ¸ŅŽ)', '(set)': '(ŅƒŅŅ‚Đ°ĐŊОвĐģĐĩĐŊĐž)', '(not set)': '(ĐŊĐĩ СадаĐŊĐž)', + Modality: 'МодаĐģҌĐŊĐžŅŅ‚ŅŒ', + 'Context Window': 'КоĐŊŅ‚ĐĩĐēҁ҂ĐŊĐžĐĩ ĐžĐēĐŊĐž', + text: 'Ņ‚ĐĩĐēҁ҂', + 'text-only': 'Ņ‚ĐžĐģҌĐēĐž Ņ‚ĐĩĐēҁ҂', + image: 'Đ¸ĐˇĐžĐąŅ€Đ°ĐļĐĩĐŊиĐĩ', + pdf: 'PDF', + audio: 'Đ°ŅƒĐ´Đ¸Đž', + video: 'видĐĩĐž', + 'not set': 'ĐŊĐĩ СадаĐŊĐž', + none: 'ĐŊĐĩŅ‚', + unknown: 'ĐŊĐĩиСвĐĩҁ҂ĐŊĐž', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐŋĐĩŅ€ĐĩĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ ĐŊа ĐŧОдĐĩĐģҌ '{{modelId}}'.\n\n{{error}}", 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': @@ -1378,10 +1395,26 @@ export default { 'ĐžŅ‚ĐēŅ€Ņ‹Đ˛Đ°ĐĩĐŧ ŅŅ‚Ņ€Đ°ĐŊĐ¸Ņ†Ņƒ Ņ€Đ°ŅŅˆĐ¸Ņ€ĐĩĐŊиК в ĐąŅ€Đ°ŅƒĐˇĐĩŅ€Đĩ: {{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': 'НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ ĐąŅ€Đ°ŅƒĐˇĐĩŅ€. ĐŸĐžŅĐĩŅ‚Đ¸Ņ‚Đĩ ĐŗĐ°ĐģĐĩŅ€ĐĩŅŽ Ņ€Đ°ŅŅˆĐ¸Ņ€ĐĩĐŊиК ĐŋĐž Đ°Đ´Ņ€Đĩҁ҃ {{url}}', + 'Use /compress when the conversation gets long to summarize history and free up context.': + 'Đ˜ŅĐŋĐžĐģŅŒĐˇŅƒĐšŅ‚Đĩ /compress, ĐēĐžĐŗĐ´Đ° Ņ€Đ°ĐˇĐŗĐžĐ˛ĐžŅ€ ŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŅ Đ´ĐģиĐŊĐŊŅ‹Đŧ, Ņ‡Ņ‚ĐžĐąŅ‹ ĐŋОдвĐĩŅŅ‚Đ¸ Đ¸Ņ‚ĐžĐŗ и ĐžŅĐ˛ĐžĐąĐžĐ´Đ¸Ņ‚ŅŒ ĐēĐžĐŊŅ‚ĐĩĐēҁ҂.', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + 'ĐĐ°Ņ‡ĐŊĐ¸Ņ‚Đĩ ĐŊĐžĐ˛ŅƒŅŽ идĐĩŅŽ ҁ /clear иĐģи /new; ĐŋŅ€ĐĩĐ´Ņ‹Đ´ŅƒŅ‰Đ°Ņ ҁĐĩŅŅĐ¸Ņ ĐžŅŅ‚Đ°ĐŊĐĩŅ‚ŅŅ в Đ¸ŅŅ‚ĐžŅ€Đ¸Đ¸.', + 'Use /bug to submit issues to the maintainers when something goes off.': + 'Đ˜ŅĐŋĐžĐģŅŒĐˇŅƒĐšŅ‚Đĩ /bug, Ņ‡Ņ‚ĐžĐąŅ‹ ŅĐžĐžĐąŅ‰Đ¸Ņ‚ŅŒ Đž ĐŋŅ€ĐžĐąĐģĐĩĐŧĐ°Ņ… Ņ€Đ°ĐˇŅ€Đ°ĐąĐžŅ‚Ņ‡Đ¸ĐēаĐŧ.', + 'Switch auth type quickly with /auth.': + 'Đ‘Ņ‹ŅŅ‚Ņ€Đž ĐŋĐĩŅ€ĐĩĐēĐģŅŽŅ‡Đ¸Ņ‚Đĩ Ņ‚Đ¸Đŋ Đ°ŅƒŅ‚ĐĩĐŊŅ‚Đ¸Ņ„Đ¸ĐēĐ°Ņ†Đ¸Đ¸ ҁ ĐŋĐžĐŧĐžŅ‰ŅŒŅŽ /auth.', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'Đ’Ņ‹ ĐŧĐžĐļĐĩŅ‚Đĩ Đ˛Ņ‹ĐŋĐžĐģĐŊŅŅ‚ŅŒ ĐģŅŽĐąŅ‹Đĩ shell-ĐēĐžĐŧаĐŊĐ´Ņ‹ в Qwen Code ҁ ĐŋĐžĐŧĐžŅ‰ŅŒŅŽ ! (ĐŊаĐŋŅ€Đ¸ĐŧĐĩŅ€, !ls).', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + 'ВвĐĩĐ´Đ¸Ņ‚Đĩ /, Ņ‡Ņ‚ĐžĐąŅ‹ ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ ĐŧĐĩĐŊŅŽ ĐēĐžĐŧаĐŊĐ´; Tab Đ°Đ˛Ņ‚ĐžĐ´ĐžĐŋĐžĐģĐŊŅĐĩŅ‚ ҁĐģŅŅˆ-ĐēĐžĐŧаĐŊĐ´Ņ‹ и ŅĐžŅ…Ņ€Đ°ĐŊŅ‘ĐŊĐŊŅ‹Đĩ ĐŋŅ€ĐžĐŧĐŋ҂ҋ.', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'Đ’Ņ‹ ĐŧĐžĐļĐĩŅ‚Đĩ ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ ĐŋŅ€ĐĩĐ´Ņ‹Đ´ŅƒŅ‰Đ¸Đš Ņ€Đ°ĐˇĐŗĐžĐ˛ĐžŅ€, СаĐŋŅƒŅŅ‚Đ¸Đ˛ qwen --continue иĐģи qwen --resume.', 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': 'Đ’Ņ‹ ĐŧĐžĐļĐĩŅ‚Đĩ ĐąŅ‹ŅŅ‚Ņ€Đž ĐŋĐĩŅ€ĐĩĐēĐģŅŽŅ‡Đ°Ņ‚ŅŒ Ņ€ĐĩĐļиĐŧ Ņ€Đ°ĐˇŅ€Đĩ҈ĐĩĐŊиК ҁ ĐŋĐžĐŧĐžŅ‰ŅŒŅŽ Shift+Tab иĐģи /approval-mode.', 'You can switch permission mode quickly with Tab or /approval-mode.': 'Đ’Ņ‹ ĐŧĐžĐļĐĩŅ‚Đĩ ĐąŅ‹ŅŅ‚Ņ€Đž ĐŋĐĩŅ€ĐĩĐēĐģŅŽŅ‡Đ°Ņ‚ŅŒ Ņ€ĐĩĐļиĐŧ Ņ€Đ°ĐˇŅ€Đĩ҈ĐĩĐŊиК ҁ ĐŋĐžĐŧĐžŅ‰ŅŒŅŽ Tab иĐģи /approval-mode.', + 'Try /insight to generate personalized insights from your chat history.': + 'ПоĐŋŅ€ĐžĐąŅƒĐšŅ‚Đĩ /insight, Ņ‡Ņ‚ĐžĐąŅ‹ ĐŋĐžĐģŅƒŅ‡Đ¸Ņ‚ŅŒ ĐŋĐĩŅ€ŅĐžĐŊаĐģĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°ĐŊĐŊŅ‹Đĩ Đ˛Ņ‹Đ˛ĐžĐ´Ņ‹ иС Đ¸ŅŅ‚ĐžŅ€Đ¸Đ¸ Ņ‡Đ°Ņ‚ĐžĐ˛.', // ============================================================================ // Custom API-KEY Configuration @@ -1421,8 +1454,13 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, ĐšĐ¸Ņ‚Đ°Đš)', + 'Coding Plan (Bailian, Global/Intl)': + 'Coding Plan (Bailian, ГĐģОйаĐģҌĐŊŅ‹Đš/МĐĩĐļĐ´ŅƒĐŊĐ°Ņ€ĐžĐ´ĐŊŅ‹Đš)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Đ’ŅŅ‚Đ°Đ˛ŅŒŅ‚Đĩ Đ˛Đ°Ņˆ API-ĐēĐģŅŽŅ‡ Bailian Coding Plan и Đ˛ŅŅ‘ ĐŗĐžŅ‚ĐžĐ˛Đž!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Đ’ŅŅ‚Đ°Đ˛ŅŒŅ‚Đĩ Đ˛Đ°Ņˆ API-ĐēĐģŅŽŅ‡ Coding Plan (Bailian, ГĐģОйаĐģҌĐŊŅ‹Đš/МĐĩĐļĐ´ŅƒĐŊĐ°Ņ€ĐžĐ´ĐŊŅ‹Đš) и Đ˛ŅŅ‘ ĐŗĐžŅ‚ĐžĐ˛Đž!', Custom: 'ПоĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģҌҁĐēиК', 'More instructions about configuring `modelProviders` manually.': 'ДоĐŋĐžĐģĐŊĐ¸Ņ‚ĐĩĐģҌĐŊŅ‹Đĩ иĐŊŅŅ‚Ņ€ŅƒĐēŅ†Đ¸Đ¸ ĐŋĐž Ņ€ŅƒŅ‡ĐŊОК ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐĩ `modelProviders`.', @@ -1431,4 +1469,18 @@ export default { '(Press Enter to submit, Escape to cancel)': '(НаĐļĐŧĐ¸Ņ‚Đĩ Enter Đ´ĐģŅ ĐžŅ‚ĐŋŅ€Đ°Đ˛Đēи, Escape Đ´ĐģŅ ĐžŅ‚ĐŧĐĩĐŊŅ‹)', 'More instructions please check:': 'ДоĐŋĐžĐģĐŊĐ¸Ņ‚ĐĩĐģҌĐŊŅ‹Đĩ иĐŊŅŅ‚Ņ€ŅƒĐēŅ†Đ¸Đ¸ ҁĐŧ.:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Đ”ĐžŅŅ‚ŅƒĐŋĐŊŅ‹ ĐŊĐžĐ˛Ņ‹Đĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐŧОдĐĩĐģĐĩĐš Đ´ĐģŅ {{region}}. ОбĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ҁĐĩĐšŅ‡Đ°Ņ?', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Đ”ĐžŅŅ‚ŅƒĐŋĐŊŅ‹ ĐŊĐžĐ˛Ņ‹Đĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐŧОдĐĩĐģĐĩĐš Đ´ĐģŅ Bailian Coding Plan (ĐšĐ¸Ņ‚Đ°Đš). ОбĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ҁĐĩĐšŅ‡Đ°Ņ?', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Đ”ĐžŅŅ‚ŅƒĐŋĐŊŅ‹ ĐŊĐžĐ˛Ņ‹Đĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐŧОдĐĩĐģĐĩĐš Đ´ĐģŅ Coding Plan (Bailian, ГĐģОйаĐģҌĐŊŅ‹Đš/МĐĩĐļĐ´ŅƒĐŊĐ°Ņ€ĐžĐ´ĐŊŅ‹Đš). ОбĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ҁĐĩĐšŅ‡Đ°Ņ?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + 'КоĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ {{region}} ҃ҁĐŋĐĩ҈ĐŊĐž ОйĐŊОвĐģĐĩĐŊа. МодĐĩĐģҌ ĐŋĐĩŅ€ĐĩĐēĐģŅŽŅ‡ĐĩĐŊа ĐŊа "{{model}}".', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'ĐŖŅĐŋĐĩ҈ĐŊĐ°Ņ Đ°ŅƒŅ‚ĐĩĐŊŅ‚Đ¸Ņ„Đ¸ĐēĐ°Ņ†Đ¸Ņ ҁ {{region}}. API-ĐēĐģŅŽŅ‡ и ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐŧОдĐĩĐģĐĩĐš ŅĐžŅ…Ņ€Đ°ĐŊĐĩĐŊŅ‹ в settings.json (Ņ€ĐĩСĐĩŅ€Đ˛ĐŊĐ°Ņ ĐēĐžĐŋĐ¸Ņ ŅĐžĐˇĐ´Đ°ĐŊа).', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 60c7551f2b..e4a42ad8fa 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -10,6 +10,11 @@ export default { // ============================================================================ // Help / UI Components // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ įŽĄį†é™„äģļ', + '← → select, Delete to remove, ↓ to exit': '← → 选拊īŧŒDelete 删除īŧŒâ†“ 退å‡ē', + 'Attachments: ': '附äģļīŧš', + 'Basics:': 'åŸēįĄ€åŠŸčƒŊīŧš', 'Add context': 'æˇģ加上下文', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': @@ -956,6 +961,17 @@ export default { '(default)': '(éģ˜čޤ)', '(set)': '(åˇ˛čŽžįŊŽ)', '(not set)': '(æœĒ莞įŊŽ)', + Modality: 'æ¨Ąæ€', + 'Context Window': '上下文įĒ—åŖ', + text: '文æœŦ', + 'text-only': 'į睿–‡æœŦ', + image: '回像', + pdf: 'PDF', + audio: '韺éĸ‘', + video: '视éĸ‘', + 'not set': 'æœĒ莞įŊŽ', + none: '无', + unknown: 'æœĒįŸĨ', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "æ— æŗ•åˆ‡æĸåˆ°æ¨Ąåž‹ '{{modelId}}'.\n\n{{error}}", 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': @@ -1047,6 +1063,8 @@ export default { '按 Shift+Tab æˆ–čž“å…Ĩ /approval-mode 可åŋĢ速切æĸæƒé™æ¨Ąåŧã€‚', 'You can switch permission mode quickly with Tab or /approval-mode.': '按 Tab æˆ–čž“å…Ĩ /approval-mode 可åŋĢ速切æĸæƒé™æ¨Ąåŧã€‚', + 'Try /insight to generate personalized insights from your chat history.': + '蝕蝕 /insightīŧŒäģŽčŠå¤ŠčްåŊ•ä¸­į”Ÿæˆä¸Ē性化洞察。', // ============================================================================ // Exit Screen / Stats @@ -1253,12 +1271,30 @@ export default { // ============================================================================ 'API-KEY': 'API-KEY', 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (į™žį‚ŧ, 中å›Ŋ)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (į™žį‚ŧ, å…¨įƒ/å›Ŋ际)', "Paste your api key of Bailian Coding Plan and you're all set!": 'į˛˜č´´æ‚¨įš„į™žį‚ŧ Coding Plan API KeyīŧŒåŗå¯åŽŒæˆčŽžįŊŽīŧ', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'į˛˜č´´æ‚¨įš„ Coding Plan (į™žį‚ŧ, å…¨įƒ/å›Ŋ际) API KeyīŧŒåŗå¯åŽŒæˆčŽžįŊŽīŧ', Custom: 'č‡Ē厚䚉', 'More instructions about configuring `modelProviders` manually.': 'å…ŗäēŽæ‰‹åŠ¨é…įŊŽ `modelProviders` įš„æ›´å¤šč¯´æ˜Žã€‚', 'Select API-KEY configuration mode:': '选拊 API-KEY 配įŊŽæ¨Ąåŧīŧš', '(Press Escape to go back)': '(按 Escape 键čŋ”回)', '(Press Enter to submit, Escape to cancel)': '(按 Enter 提äē¤īŧŒEscape 取æļˆ)', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + '{{region}} æœ‰æ–°įš„æ¨Ąåž‹é…įŊŽå¯į”¨ã€‚是åĻįĢ‹åŗæ›´æ–°īŧŸ', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'į™žį‚ŧ Coding Plan (中å›Ŋ) æœ‰æ–°įš„æ¨Ąåž‹é…įŊŽå¯į”¨ã€‚是åĻįĢ‹åŗæ›´æ–°īŧŸ', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Coding Plan (į™žį‚ŧ, å…¨įƒ/å›Ŋ际) æœ‰æ–°įš„æ¨Ąåž‹é…įŊŽå¯į”¨ã€‚是åĻįĢ‹åŗæ›´æ–°īŧŸ', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} 配įŊŽæ›´æ–°æˆåŠŸã€‚æ¨Ąåž‹åˇ˛åˆ‡æĸ臺 "{{model}}"。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + '成功通čŋ‡ {{region}} čŽ¤č¯ã€‚API Key å’Œæ¨Ąåž‹é…įŊŽåˇ˛äŋå­˜č‡ŗ settings.jsonīŧˆåˇ˛å¤‡äģŊīŧ‰ã€‚', }; diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index dc4c1f8d92..cda06daadc 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -40,6 +40,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; +import { insightCommand } from '../ui/commands/insightCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -90,6 +91,7 @@ export class BuiltinCommandLoader implements ICommandLoader { vimCommand, setupGithubCommand, terminalSetupCommand, + insightCommand, ]; return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/services/insight/generators/DataProcessor.test.ts b/packages/cli/src/services/insight/generators/DataProcessor.test.ts new file mode 100644 index 0000000000..1f90dbff57 --- /dev/null +++ b/packages/cli/src/services/insight/generators/DataProcessor.test.ts @@ -0,0 +1,1217 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { DataProcessor } from './DataProcessor.js'; +import type { Config, ChatRecord } from '@qwen-code/qwen-code-core'; +import type { + InsightData, + SessionFacets, +} from '../types/StaticInsightTypes.js'; + +// Mock dependencies +vi.mock('@qwen-code/qwen-code-core', async () => { + const actual = await vi.importActual< + typeof import('@qwen-code/qwen-code-core') + >('@qwen-code/qwen-code-core'); + return { + ...actual, + read: vi.fn(), + createDebugLogger: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + })), + }; +}); + +vi.mock('fs/promises', () => ({ + default: { + readdir: vi.fn(), + stat: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + }, +})); + +import fs from 'fs/promises'; +import { read as readJsonlFile } from '@qwen-code/qwen-code-core'; + +const mockedFs = vi.mocked(fs); +const mockedReadJsonlFile = vi.mocked(readJsonlFile); + +describe('DataProcessor', () => { + let mockConfig: Config; + let dataProcessor: DataProcessor; + let mockGenerateJson: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGenerateJson = vi.fn(); + mockConfig = { + getBaseLlmClient: vi.fn(() => ({ + generateJson: mockGenerateJson, + })), + getModel: vi.fn(() => 'test-model'), + } as unknown as Config; + + dataProcessor = new DataProcessor(mockConfig); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('formatDate', () => { + it('should format date as YYYY-MM-DD', () => { + const date = new Date('2025-01-15T10:30:00Z'); + // Access private method through any cast for testing + const result = ( + dataProcessor as unknown as { formatDate(date: Date): string } + ).formatDate(date); + expect(result).toBe('2025-01-15'); + }); + + it('should handle different timezones correctly', () => { + const date = new Date('2025-12-31T23:59:59Z'); + const result = ( + dataProcessor as unknown as { formatDate(date: Date): string } + ).formatDate(date); + // Result depends on local timezone, but should be a valid date string + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + }); + + describe('formatRecordsForAnalysis', () => { + it('should format empty records array', () => { + const records: ChatRecord[] = []; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).toContain('Session: unknown'); + expect(result).toContain('Duration: 0 turns'); + }); + + it('should format user messages correctly', () => { + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Hello, world!' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).toContain('Session: test-session'); + expect(result).toContain('[User]: Hello, world!'); + }); + + it('should format assistant text messages correctly', () => { + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'assistant', + message: { + role: 'assistant', + parts: [{ text: 'I can help you with that.' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).toContain('[Assistant]: I can help you with that.'); + }); + + it('should format function calls correctly', () => { + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'assistant', + message: { + role: 'assistant', + parts: [{ functionCall: { name: 'read_file', args: {} } }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).toContain('[Tool: read_file]'); + }); + + it('should handle multiple message parts', () => { + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'assistant', + message: { + role: 'assistant', + parts: [ + { text: 'Let me check that.' }, + { functionCall: { name: 'search', args: {} } }, + ], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).toContain('[Assistant]: Let me check that.'); + expect(result).toContain('[Tool: search]'); + }); + + it('should handle messages without parts', () => { + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'assistant', + message: { + role: 'assistant', + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).not.toContain('[Assistant]:'); + }); + }); + + describe('calculateStreaks', () => { + it('should return zero streaks for empty dates array', () => { + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks([]); + expect(result.currentStreak).toBe(0); + expect(result.longestStreak).toBe(0); + expect(result.dates).toEqual([]); + }); + + it('should calculate streak of 1 for single date', () => { + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks(['2025-01-15']); + expect(result.currentStreak).toBe(1); + expect(result.longestStreak).toBe(1); + }); + + it('should calculate consecutive day streak', () => { + const dates = ['2025-01-15', '2025-01-16', '2025-01-17']; + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks(dates); + expect(result.currentStreak).toBe(3); + expect(result.longestStreak).toBe(3); + }); + + it('should handle non-consecutive dates', () => { + const dates = ['2025-01-15', '2025-01-17', '2025-01-18']; + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks(dates); + expect(result.longestStreak).toBe(2); // Jan 17-18 + }); + + it('should sort dates before calculating streaks', () => { + const dates = ['2025-01-18', '2025-01-15', '2025-01-16', '2025-01-17']; + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks(dates); + expect(result.longestStreak).toBe(4); + }); + + it('should handle duplicate dates', () => { + const dates = ['2025-01-15', '2025-01-15', '2025-01-16']; + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks(dates); + expect(result.longestStreak).toBeGreaterThanOrEqual(1); + }); + }); + + describe('aggregateFacetsData', () => { + it('should return empty aggregates for empty facets array', () => { + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + satisfactionAgg: Record; + frictionAgg: Record; + primarySuccessAgg: Record; + outcomesAgg: Record; + goalsAgg: Record; + }; + } + ).aggregateFacetsData([]); + expect(result.satisfactionAgg).toEqual({}); + expect(result.frictionAgg).toEqual({}); + expect(result.primarySuccessAgg).toEqual({}); + expect(result.outcomesAgg).toEqual({}); + expect(result.goalsAgg).toEqual({}); + }); + + it('should aggregate satisfaction counts', () => { + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: { satisfied: 2, neutral: 1 }, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary', + }, + { + session_id: 's2', + underlying_goal: 'test2', + goal_categories: {}, + outcome: 'mostly_achieved', + user_satisfaction_counts: { satisfied: 1, frustrated: 2 }, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary 2', + }, + ]; + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + satisfactionAgg: Record; + }; + } + ).aggregateFacetsData(facets); + expect(result.satisfactionAgg).toEqual({ + satisfied: 3, + neutral: 1, + frustrated: 2, + }); + }); + + it('should aggregate friction counts', () => { + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: { slow_response: 1, unclear_answer: 2 }, + friction_detail: 'Some friction', + primary_success: 'none', + brief_summary: 'Test summary', + }, + { + session_id: 's2', + underlying_goal: 'test2', + goal_categories: {}, + outcome: 'mostly_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: { slow_response: 2 }, + friction_detail: 'More friction', + primary_success: 'none', + brief_summary: 'Test summary 2', + }, + ]; + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + frictionAgg: Record; + }; + } + ).aggregateFacetsData(facets); + expect(result.frictionAgg).toEqual({ + slow_response: 3, + unclear_answer: 2, + }); + }); + + it('should aggregate primary success excluding none', () => { + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'correct_code_edits', + brief_summary: 'Test summary', + }, + { + session_id: 's2', + underlying_goal: 'test2', + goal_categories: {}, + outcome: 'mostly_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary 2', + }, + { + session_id: 's3', + underlying_goal: 'test3', + goal_categories: {}, + outcome: 'partially_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'slightly_helpful', + session_type: 'exploration', + friction_counts: {}, + friction_detail: '', + primary_success: 'good_explanations', + brief_summary: 'Test summary 3', + }, + ]; + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + primarySuccessAgg: Record; + }; + } + ).aggregateFacetsData(facets); + expect(result.primarySuccessAgg).toEqual({ + correct_code_edits: 1, + good_explanations: 1, + }); + expect(result.primarySuccessAgg['none']).toBeUndefined(); + }); + + it('should aggregate outcomes', () => { + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary', + }, + { + session_id: 's2', + underlying_goal: 'test2', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary 2', + }, + { + session_id: 's3', + underlying_goal: 'test3', + goal_categories: {}, + outcome: 'partially_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'slightly_helpful', + session_type: 'exploration', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary 3', + }, + ]; + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + outcomesAgg: Record; + }; + } + ).aggregateFacetsData(facets); + expect(result.outcomesAgg).toEqual({ + fully_achieved: 2, + partially_achieved: 1, + }); + }); + + it('should aggregate goal categories', () => { + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: { coding: 2, debugging: 1 }, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary', + }, + { + session_id: 's2', + underlying_goal: 'test2', + goal_categories: { coding: 1, refactoring: 3 }, + outcome: 'mostly_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary 2', + }, + ]; + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + goalsAgg: Record; + }; + } + ).aggregateFacetsData(facets); + expect(result.goalsAgg).toEqual({ + coding: 3, + debugging: 1, + refactoring: 3, + }); + }); + }); + + describe('analyzeSession', () => { + it('should return null for empty records', async () => { + const result = await ( + dataProcessor as unknown as { + analyzeSession(records: ChatRecord[]): Promise; + } + ).analyzeSession([]); + expect(result).toBeNull(); + }); + + it('should analyze session and return facets', async () => { + const mockFacet = { + underlying_goal: 'Test goal', + goal_categories: { coding: 1 }, + outcome: 'fully_achieved', + user_satisfaction_counts: { satisfied: 1 }, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'correct_code_edits', + brief_summary: 'Test summary', + }; + + mockGenerateJson.mockResolvedValue(mockFacet); + + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Help me with code' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + const result = await ( + dataProcessor as unknown as { + analyzeSession(records: ChatRecord[]): Promise; + } + ).analyzeSession(records); + + expect(result).not.toBeNull(); + expect(result?.session_id).toBe('test-session'); + expect(result?.underlying_goal).toBe('Test goal'); + expect(mockGenerateJson).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'test-model', + schema: expect.any(Object), + }), + ); + }); + + it('should return null when LLM returns empty result', async () => { + mockGenerateJson.mockResolvedValue({}); + + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Help' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + const result = await ( + dataProcessor as unknown as { + analyzeSession(records: ChatRecord[]): Promise; + } + ).analyzeSession(records); + + expect(result).toBeNull(); + }); + + it('should handle LLM errors gracefully', async () => { + mockGenerateJson.mockRejectedValue(new Error('LLM Error')); + + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Help' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + const result = await ( + dataProcessor as unknown as { + analyzeSession(records: ChatRecord[]): Promise; + } + ).analyzeSession(records); + + expect(result).toBeNull(); + }); + }); + + describe('scanChatFiles', () => { + it('should return empty array when base directory does not exist', async () => { + const error = new Error('Directory not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + mockedFs.readdir.mockRejectedValue(error); + + const result = await ( + dataProcessor as unknown as { + scanChatFiles( + baseDir: string, + ): Promise>; + } + ).scanChatFiles('/nonexistent'); + + expect(result).toEqual([]); + }); + + it('should scan project directories and find chat files', async () => { + mockedFs.readdir.mockResolvedValueOnce([ + 'project1', + 'project2', + ] as unknown as Awaited>); + + mockedFs.stat.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.includes('project1') || pathStr.includes('project2')) { + return Promise.resolve({ + isDirectory: () => true, + mtimeMs: 1234567890, + } as Awaited>); + } + if (pathStr.endsWith('.jsonl')) { + return Promise.resolve({ + isDirectory: () => false, + mtimeMs: 1234567890, + } as Awaited>); + } + throw new Error('Unexpected path'); + }); + + mockedFs.readdir.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.endsWith('chats')) { + if (pathStr.includes('project1')) { + return Promise.resolve([ + 'chat1.jsonl', + 'chat2.jsonl', + ] as unknown as Awaited>); + } + if (pathStr.includes('project2')) { + return Promise.resolve(['chat3.jsonl'] as unknown as Awaited< + ReturnType + >); + } + } + return Promise.resolve( + [] as unknown as Awaited>, + ); + }); + + const result = await ( + dataProcessor as unknown as { + scanChatFiles( + baseDir: string, + ): Promise>; + } + ).scanChatFiles('/base'); + + expect(result).toHaveLength(3); + const paths = result.map((r) => r.path); + expect(paths.some((p) => p.includes('chat1.jsonl'))).toBe(true); + expect(paths.some((p) => p.includes('chat2.jsonl'))).toBe(true); + expect(paths.some((p) => p.includes('chat3.jsonl'))).toBe(true); + }); + + it('should skip projects without chats directory', async () => { + mockedFs.readdir.mockResolvedValueOnce([ + 'project1', + 'project2', + ] as unknown as Awaited>); + + mockedFs.stat.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.includes('project1') || pathStr.includes('project2')) { + return Promise.resolve({ isDirectory: () => true } as Awaited< + ReturnType + >); + } + if (pathStr.endsWith('.jsonl')) { + return Promise.resolve({ + isDirectory: () => false, + mtimeMs: 1234567890, + } as Awaited>); + } + throw new Error('Unexpected path'); + }); + + const error = new Error('No chats dir') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + + mockedFs.readdir.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.endsWith('chats')) { + if (pathStr.includes('project1')) { + return Promise.resolve(['chat1.jsonl'] as unknown as Awaited< + ReturnType + >); + } + if (pathStr.includes('project2')) { + return Promise.reject(error); + } + } + return Promise.resolve( + [] as unknown as Awaited>, + ); + }); + + const result = await ( + dataProcessor as unknown as { + scanChatFiles( + baseDir: string, + ): Promise>; + } + ).scanChatFiles('/base'); + + expect(result).toHaveLength(1); + expect(result[0].path).toContain('chat1.jsonl'); + }); + + it('should handle file stat errors gracefully', async () => { + mockedFs.readdir.mockResolvedValueOnce(['project1'] as unknown as Awaited< + ReturnType + >); + + mockedFs.stat.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.includes('project1') && !pathStr.includes('chats')) { + return Promise.resolve({ isDirectory: () => true } as Awaited< + ReturnType + >); + } + if (pathStr.endsWith('chat1.jsonl')) { + return Promise.reject(new Error('Stat failed')); + } + throw new Error('Unexpected path: ' + pathStr); + }); + + mockedFs.readdir.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.endsWith('chats')) { + return Promise.resolve(['chat1.jsonl'] as unknown as Awaited< + ReturnType + >); + } + return Promise.resolve( + [] as unknown as Awaited>, + ); + }); + + const result = await ( + dataProcessor as unknown as { + scanChatFiles( + baseDir: string, + ): Promise>; + } + ).scanChatFiles('/base'); + + // When stat fails for a file, it should be skipped but not crash + expect(result).toEqual([]); + }); + }); + + describe('generateMetrics', () => { + it('should generate metrics from chat files', async () => { + const mockRecords: ChatRecord[] = [ + { + sessionId: 'session1', + timestamp: '2025-01-15T10:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'session1', + timestamp: '2025-01-15T10:01:00Z', + type: 'system', + subtype: 'slash_command', + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'session1', + timestamp: '2025-01-15T10:05:00Z', + type: 'assistant', + message: { role: 'assistant', parts: [{ text: 'Hi' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'session1', + timestamp: '2025-01-15T10:06:00Z', + type: 'assistant', + message: { + role: 'assistant', + parts: [{ functionCall: { name: 'read_file', args: {} } }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + mockedReadJsonlFile.mockResolvedValue(mockRecords); + + const files = [{ path: '/test/chat.jsonl', mtime: 1234567890 }]; + const result = await ( + dataProcessor as unknown as { + generateMetrics( + files: Array<{ path: string; mtime: number }>, + ): Promise; + } + ).generateMetrics(files); + + expect(result).toMatchObject({ + totalMessages: 2, + totalSessions: 1, + heatmap: expect.any(Object), + activeHours: expect.any(Object), + topTools: expect.any(Array), + }); + }); + + it('should track tool usage correctly', async () => { + const mockRecords: ChatRecord[] = [ + { + sessionId: 'session1', + timestamp: '2025-01-15T10:00:00Z', + type: 'assistant', + message: { + role: 'assistant', + parts: [{ functionCall: { name: 'read_file', args: {} } }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'session1', + timestamp: '2025-01-15T10:01:00Z', + type: 'assistant', + message: { + role: 'assistant', + parts: [{ functionCall: { name: 'read_file', args: {} } }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'session1', + timestamp: '2025-01-15T10:02:00Z', + type: 'assistant', + message: { + role: 'assistant', + parts: [{ functionCall: { name: 'write_file', args: {} } }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + mockedReadJsonlFile.mockResolvedValue(mockRecords); + + const files = [{ path: '/test/chat.jsonl', mtime: 1234567890 }]; + const result = await ( + dataProcessor as unknown as { + generateMetrics( + files: Array<{ path: string; mtime: number }>, + ): Promise<{ topTools: Array<[string, number]> }>; + } + ).generateMetrics(files); + + expect(result.topTools).toContainEqual(['read_file', 2]); + expect(result.topTools).toContainEqual(['write_file', 1]); + }); + + it('should handle file read errors gracefully', async () => { + mockedReadJsonlFile.mockRejectedValue(new Error('Read failed')); + + const files = [{ path: '/test/chat.jsonl', mtime: 1234567890 }]; + const result = await ( + dataProcessor as unknown as { + generateMetrics( + files: Array<{ path: string; mtime: number }>, + ): Promise<{ totalMessages: number }>; + } + ).generateMetrics(files); + + expect(result.totalMessages).toBe(0); + }); + + it('should call progress callback during processing', async () => { + const mockRecords: ChatRecord[] = [ + { + sessionId: 'session1', + timestamp: '2025-01-15T10:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + mockedReadJsonlFile.mockResolvedValue(mockRecords); + + const files = [ + { path: '/test/chat1.jsonl', mtime: 1234567890 }, + { path: '/test/chat2.jsonl', mtime: 1234567891 }, + ]; + const onProgress = vi.fn(); + + await ( + dataProcessor as unknown as { + generateMetrics( + files: Array<{ path: string; mtime: number }>, + onProgress?: (stage: string, progress: number) => void, + ): Promise; + } + ).generateMetrics(files, onProgress); + + expect(onProgress).toHaveBeenCalled(); + }); + }); + + describe('prepareCommonPromptData', () => { + it('should prepare prompt data with all required sections', () => { + const metrics = { + heatmap: { '2025-01-15': 5, '2025-01-16': 3 }, + totalSessions: 10, + totalMessages: 100, + totalHours: 5, + topTools: [ + ['read_file', 20], + ['write_file', 10], + ], + } as unknown as Omit; + + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'Goal 1', + goal_categories: { coding: 2, debugging: 1 }, + outcome: 'fully_achieved', + user_satisfaction_counts: { satisfied: 2 }, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: { slow: 1 }, + friction_detail: 'Some friction detail', + primary_success: 'correct_code_edits', + brief_summary: 'Summary 1', + }, + ]; + + const result = ( + dataProcessor as unknown as { + prepareCommonPromptData( + metrics: Omit, + facets: SessionFacets[], + ): string; + } + ).prepareCommonPromptData(metrics, facets); + + expect(result).toContain('DATA:'); + expect(result).toContain('SESSION SUMMARIES:'); + expect(result).toContain('FRICTION DETAILS:'); + expect(result).toContain('Summary 1'); + expect(result).toContain('Some friction detail'); + }); + + it('should filter out empty friction details', () => { + const metrics = { + heatmap: {}, + totalSessions: 1, + totalMessages: 10, + totalHours: 1, + topTools: [], + } as unknown as Omit; + + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'Goal 1', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Summary 1', + }, + { + session_id: 's2', + underlying_goal: 'Goal 2', + goal_categories: {}, + outcome: 'mostly_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: ' ', + primary_success: 'none', + brief_summary: 'Summary 2', + }, + ]; + + const result = ( + dataProcessor as unknown as { + prepareCommonPromptData( + metrics: Omit, + facets: SessionFacets[], + ): string; + } + ).prepareCommonPromptData(metrics, facets); + + // Check that FRICTION DETAILS section is empty or only contains whitespace + const frictionSection = + result.split('FRICTION DETAILS:')[1]?.split('USER INSTRUCTIONS')[0] || + ''; + const hasNonEmptyFrictionDetail = + frictionSection.trim().length > 0 && frictionSection.includes('-'); + expect(hasNonEmptyFrictionDetail).toBe(false); + }); + }); + + describe('generateFacets', () => { + it('should skip non-conversational sessions', async () => { + const userOnlyRecords: ChatRecord[] = [ + { + sessionId: 'user-only', + timestamp: '2025-01-15T10:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + const conversationalRecords: ChatRecord[] = [ + { + sessionId: 'conversational', + timestamp: '2025-01-15T10:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'conversational', + timestamp: '2025-01-15T10:01:00Z', + type: 'assistant', + message: { role: 'assistant', parts: [{ text: 'Hi' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + // First file is user-only, second is conversational + mockedReadJsonlFile + .mockResolvedValueOnce(userOnlyRecords) + .mockResolvedValueOnce(conversationalRecords); + + const mockFacet = { + underlying_goal: 'Test', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test', + }; + mockGenerateJson.mockResolvedValue(mockFacet); + + const files = [ + { path: '/test/user-only.jsonl', mtime: 2000 }, + { path: '/test/conversational.jsonl', mtime: 1000 }, + ]; + + const result = await ( + dataProcessor as unknown as { + generateFacets( + files: Array<{ path: string; mtime: number }>, + facetsOutputDir?: string, + ): Promise; + } + ).generateFacets(files); + + // Only the conversational session should be analyzed + expect(mockGenerateJson).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect(result[0].session_id).toBe('conversational'); + }); + }); +}); diff --git a/packages/cli/src/services/insight/generators/DataProcessor.ts b/packages/cli/src/services/insight/generators/DataProcessor.ts new file mode 100644 index 0000000000..a3cda424e1 --- /dev/null +++ b/packages/cli/src/services/insight/generators/DataProcessor.ts @@ -0,0 +1,1131 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { + read as readJsonlFile, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; +import pLimit from 'p-limit'; +import type { + InsightData, + HeatMapData, + StreakData, + SessionFacets, + InsightProgressCallback, +} from '../types/StaticInsightTypes.js'; +import type { + QualitativeInsights, + InsightImpressiveWorkflows, + InsightProjectAreas, + InsightFutureOpportunities, + InsightFrictionPoints, + InsightMemorableMoment, + InsightImprovements, + InsightInteractionStyle, + InsightAtAGlance, +} from '../types/QualitativeInsightTypes.js'; +import { + getInsightPrompt, + type Config, + type ChatRecord, +} from '@qwen-code/qwen-code-core'; + +const logger = createDebugLogger('DataProcessor'); + +const CONCURRENCY_LIMIT = 4; + +export class DataProcessor { + constructor(private config: Config) {} + + // Helper function to format date as YYYY-MM-DD + private formatDate(date: Date): string { + return date.toISOString().split('T')[0]; + } + + // Format chat records for LLM analysis + private formatRecordsForAnalysis(records: ChatRecord[]): string { + let output = ''; + const sessionStart = + records.length > 0 ? new Date(records[0].timestamp) : new Date(); + + output += `Session: ${records[0]?.sessionId || 'unknown'}\n`; + output += `Date: ${sessionStart.toISOString()}\n`; + output += `Duration: ${records.length} turns\n\n`; + + for (const record of records) { + if (record.type === 'user') { + const text = + record.message?.parts + ?.map((p) => ('text' in p ? p.text : '')) + .join('') || ''; + output += `[User]: ${text}\n`; + } else if (record.type === 'assistant') { + if (record.message?.parts) { + for (const part of record.message.parts) { + if ('text' in part && part.text) { + output += `[Assistant]: ${part.text}\n`; + } else if ('functionCall' in part) { + const call = part.functionCall; + if (call) { + output += `[Tool: ${call.name}]\n`; + } + } + } + } + } + } + return output; + } + + // Only analyze conversational sessions for facets (skip system-only logs). + private hasUserAndAssistantRecords(records: ChatRecord[]): boolean { + let hasUser = false; + let hasAssistant = false; + + for (const record of records) { + if (record.type === 'user') { + hasUser = true; + } else if (record.type === 'assistant') { + hasAssistant = true; + } + + if (hasUser && hasAssistant) { + return true; + } + } + + return false; + } + + // Analyze a single session using LLM + private async analyzeSession( + records: ChatRecord[], + ): Promise { + if (records.length === 0) return null; + + const INSIGHT_SCHEMA = { + type: 'object', + properties: { + underlying_goal: { + type: 'string', + description: 'What the user fundamentally wanted to achieve', + }, + goal_categories: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + outcome: { + type: 'string', + enum: [ + 'fully_achieved', + 'mostly_achieved', + 'partially_achieved', + 'not_achieved', + 'unclear_from_transcript', + ], + }, + user_satisfaction_counts: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + Qwen_helpfulness: { + type: 'string', + enum: [ + 'unhelpful', + 'slightly_helpful', + 'moderately_helpful', + 'very_helpful', + 'essential', + ], + }, + session_type: { + type: 'string', + enum: [ + 'single_task', + 'multi_task', + 'iterative_refinement', + 'exploration', + 'quick_question', + ], + }, + friction_counts: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + friction_detail: { + type: 'string', + description: 'One sentence describing friction or empty', + }, + primary_success: { + type: 'string', + enum: [ + 'none', + 'fast_accurate_search', + 'correct_code_edits', + 'good_explanations', + 'proactive_help', + 'multi_file_changes', + 'good_debugging', + ], + }, + brief_summary: { + type: 'string', + description: 'One sentence: what user wanted and whether they got it', + }, + }, + required: [ + 'underlying_goal', + 'goal_categories', + 'outcome', + 'user_satisfaction_counts', + 'Qwen_helpfulness', + 'session_type', + 'friction_counts', + 'friction_detail', + 'primary_success', + 'brief_summary', + ], + }; + + const sessionText = this.formatRecordsForAnalysis(records); + const prompt = `${getInsightPrompt('analysis')}\n\nSESSION:\n${sessionText}`; + + try { + const result = await this.config.getBaseLlmClient().generateJson({ + // Use the configured model + model: this.config.getModel(), + contents: [{ role: 'user', parts: [{ text: prompt }] }], + schema: INSIGHT_SCHEMA, + abortSignal: AbortSignal.timeout(600000), // 10 minute timeout per session + }); + + if (!result || Object.keys(result).length === 0) { + return null; + } + + return { + ...(result as unknown as SessionFacets), + session_id: records[0].sessionId, + }; + } catch (error) { + logger.error( + `Failed to analyze session ${records[0]?.sessionId}:`, + error, + ); + return null; + } + } + + // Calculate streaks from activity dates + private calculateStreaks(dates: string[]): StreakData { + if (dates.length === 0) { + return { currentStreak: 0, longestStreak: 0, dates: [] }; + } + + // Convert string dates to Date objects and sort them + const dateObjects = dates.map((dateStr) => new Date(dateStr)); + dateObjects.sort((a, b) => a.getTime() - b.getTime()); + + let currentStreak = 1; + let maxStreak = 1; + let currentDate = new Date(dateObjects[0]); + currentDate.setHours(0, 0, 0, 0); // Normalize to start of day + + for (let i = 1; i < dateObjects.length; i++) { + const nextDate = new Date(dateObjects[i]); + nextDate.setHours(0, 0, 0, 0); // Normalize to start of day + + // Calculate difference in days + const diffDays = Math.floor( + (nextDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24), + ); + + if (diffDays === 1) { + // Consecutive day + currentStreak++; + maxStreak = Math.max(maxStreak, currentStreak); + } else if (diffDays > 1) { + // Gap in streak + currentStreak = 1; + } + // If diffDays === 0, same day, so streak continues + + currentDate = nextDate; + } + + // Check if the streak is still ongoing (if last activity was yesterday or today) + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if ( + currentDate.getTime() === today.getTime() || + currentDate.getTime() === yesterday.getTime() + ) { + // The streak might still be active, so we don't reset it + } + + return { + currentStreak, + longestStreak: maxStreak, + dates, + }; + } + + // Process chat files from all projects in the base directory and generate insights + async generateInsights( + baseDir: string, + facetsOutputDir?: string, + onProgress?: InsightProgressCallback, + ): Promise { + if (onProgress) onProgress('Scanning chat history...', 0); + const allChatFiles = await this.scanChatFiles(baseDir); + + if (onProgress) onProgress('Crunching the numbers', 10); + const metrics = await this.generateMetrics(allChatFiles, onProgress); + + if (onProgress) onProgress('Preparing sessions...', 20); + const facets = await this.generateFacets( + allChatFiles, + facetsOutputDir, + onProgress, + ); + + if (onProgress) onProgress('Generating personalized insights...', 80); + const qualitative = await this.generateQualitativeInsights(metrics, facets); + + // Aggregate satisfaction, friction, success and outcome data from facets + const { + satisfactionAgg, + frictionAgg, + primarySuccessAgg, + outcomesAgg, + goalsAgg, + } = this.aggregateFacetsData(facets); + + if (onProgress) onProgress('Assembling report...', 100); + + return { + ...metrics, + qualitative, + satisfaction: satisfactionAgg, + friction: frictionAgg, + primarySuccess: primarySuccessAgg, + outcomes: outcomesAgg, + topGoals: goalsAgg, + }; + } + + // Aggregate satisfaction and friction data from facets + private aggregateFacetsData(facets: SessionFacets[]): { + satisfactionAgg: Record; + frictionAgg: Record; + primarySuccessAgg: Record; + outcomesAgg: Record; + goalsAgg: Record; + } { + const satisfactionAgg: Record = {}; + const frictionAgg: Record = {}; + const primarySuccessAgg: Record = {}; + const outcomesAgg: Record = {}; + const goalsAgg: Record = {}; + + facets.forEach((facet) => { + // Aggregate satisfaction + Object.entries(facet.user_satisfaction_counts).forEach(([sat, count]) => { + satisfactionAgg[sat] = (satisfactionAgg[sat] || 0) + count; + }); + + // Aggregate friction + Object.entries(facet.friction_counts).forEach(([fric, count]) => { + frictionAgg[fric] = (frictionAgg[fric] || 0) + count; + }); + + // Aggregate primary success + if (facet.primary_success && facet.primary_success !== 'none') { + primarySuccessAgg[facet.primary_success] = + (primarySuccessAgg[facet.primary_success] || 0) + 1; + } + + // Aggregate outcomes + if (facet.outcome) { + outcomesAgg[facet.outcome] = (outcomesAgg[facet.outcome] || 0) + 1; + } + + // Aggregate goals + Object.entries(facet.goal_categories).forEach(([goal, count]) => { + goalsAgg[goal] = (goalsAgg[goal] || 0) + count; + }); + }); + + return { + satisfactionAgg, + frictionAgg, + primarySuccessAgg, + outcomesAgg, + goalsAgg, + }; + } + + private async generateQualitativeInsights( + metrics: Omit, + facets: SessionFacets[], + ): Promise { + if (facets.length === 0) { + return undefined; + } + + logger.info('Generating qualitative insights...'); + + const commonData = this.prepareCommonPromptData(metrics, facets); + + const generate = async ( + promptTemplate: string, + schema: Record, + ): Promise => { + const prompt = `${promptTemplate}\n\n${commonData}`; + try { + const result = await this.config.getBaseLlmClient().generateJson({ + model: this.config.getModel(), + contents: [{ role: 'user', parts: [{ text: prompt }] }], + schema, + abortSignal: AbortSignal.timeout(600000), + }); + return result as T; + } catch (error) { + logger.error('Failed to generate insight:', error); + throw error; + } + }; + + // Schemas for each insight type + // We define simplified schemas here to guide the LLM. + // The types are already defined in QualitativeInsightTypes.ts + + // 1. Impressive Workflows + const schemaImpressiveWorkflows = { + type: 'object', + properties: { + intro: { type: 'string' }, + impressive_workflows: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + }, + required: ['title', 'description'], + }, + }, + }, + required: ['intro', 'impressive_workflows'], + }; + + // 2. Project Areas + const schemaProjectAreas = { + type: 'object', + properties: { + areas: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + session_count: { type: 'number' }, + description: { type: 'string' }, + }, + required: ['name', 'session_count', 'description'], + }, + }, + }, + required: ['areas'], + }; + + // 3. Future Opportunities + const schemaFutureOpportunities = { + type: 'object', + properties: { + intro: { type: 'string' }, + opportunities: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + whats_possible: { type: 'string' }, + how_to_try: { type: 'string' }, + copyable_prompt: { type: 'string' }, + }, + required: [ + 'title', + 'whats_possible', + 'how_to_try', + 'copyable_prompt', + ], + }, + }, + }, + required: ['intro', 'opportunities'], + }; + + // 4. Friction Points + const schemaFrictionPoints = { + type: 'object', + properties: { + intro: { type: 'string' }, + categories: { + type: 'array', + items: { + type: 'object', + properties: { + category: { type: 'string' }, + description: { type: 'string' }, + examples: { type: 'array', items: { type: 'string' } }, + }, + required: ['category', 'description', 'examples'], + }, + }, + }, + required: ['intro', 'categories'], + }; + + // 5. Memorable Moment + const schemaMemorableMoment = { + type: 'object', + properties: { + headline: { type: 'string' }, + detail: { type: 'string' }, + }, + required: ['headline', 'detail'], + }; + + // 6. Improvements + const schemaImprovements = { + type: 'object', + properties: { + Qwen_md_additions: { + type: 'array', + items: { + type: 'object', + properties: { + addition: { type: 'string' }, + why: { type: 'string' }, + prompt_scaffold: { type: 'string' }, + }, + required: ['addition', 'why', 'prompt_scaffold'], + }, + }, + features_to_try: { + type: 'array', + items: { + type: 'object', + properties: { + feature: { type: 'string' }, + one_liner: { type: 'string' }, + why_for_you: { type: 'string' }, + example_code: { type: 'string' }, + }, + required: ['feature', 'one_liner', 'why_for_you', 'example_code'], + }, + }, + usage_patterns: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + suggestion: { type: 'string' }, + detail: { type: 'string' }, + copyable_prompt: { type: 'string' }, + }, + required: ['title', 'suggestion', 'detail', 'copyable_prompt'], + }, + }, + }, + required: ['Qwen_md_additions', 'features_to_try', 'usage_patterns'], + }; + + // 7. Interaction Style + const schemaInteractionStyle = { + type: 'object', + properties: { + narrative: { type: 'string' }, + key_pattern: { type: 'string' }, + }, + required: ['narrative', 'key_pattern'], + }; + + // 8. At A Glance + const schemaAtAGlance = { + type: 'object', + properties: { + whats_working: { type: 'string' }, + whats_hindering: { type: 'string' }, + quick_wins: { type: 'string' }, + ambitious_workflows: { type: 'string' }, + }, + required: [ + 'whats_working', + 'whats_hindering', + 'quick_wins', + 'ambitious_workflows', + ], + }; + + const limit = pLimit(CONCURRENCY_LIMIT); + + try { + const [ + impressiveWorkflows, + projectAreas, + futureOpportunities, + frictionPoints, + memorableMoment, + improvements, + interactionStyle, + atAGlance, + ] = await Promise.all([ + limit(() => + generate( + getInsightPrompt('impressive_workflows'), + schemaImpressiveWorkflows, + ), + ), + limit(() => + generate( + getInsightPrompt('project_areas'), + schemaProjectAreas, + ), + ), + limit(() => + generate( + getInsightPrompt('future_opportunities'), + schemaFutureOpportunities, + ), + ), + limit(() => + generate( + getInsightPrompt('friction_points'), + schemaFrictionPoints, + ), + ), + limit(() => + generate( + getInsightPrompt('memorable_moment'), + schemaMemorableMoment, + ), + ), + limit(() => + generate( + getInsightPrompt('improvements'), + schemaImprovements, + ), + ), + limit(() => + generate( + getInsightPrompt('interaction_style'), + schemaInteractionStyle, + ), + ), + limit(() => + generate( + getInsightPrompt('at_a_glance'), + schemaAtAGlance, + ), + ), + ]); + + logger.debug( + JSON.stringify( + { + impressiveWorkflows, + projectAreas, + futureOpportunities, + frictionPoints, + memorableMoment, + improvements, + interactionStyle, + atAGlance, + }, + null, + 2, + ), + ); + + return { + impressiveWorkflows, + projectAreas, + futureOpportunities, + frictionPoints, + memorableMoment, + improvements, + interactionStyle, + atAGlance, + }; + } catch (e) { + logger.error('Error generating qualitative insights:', e); + return undefined; + } + } + + private prepareCommonPromptData( + metrics: Omit, + facets: SessionFacets[], + ): string { + // 1. DATA section + const goalsAgg: Record = {}; + const outcomesAgg: Record = {}; + const satisfactionAgg: Record = {}; + const frictionAgg: Record = {}; + const successAgg: Record = {}; + + facets.forEach((facet) => { + // Aggregate goals + Object.entries(facet.goal_categories).forEach(([goal, count]) => { + goalsAgg[goal] = (goalsAgg[goal] || 0) + count; + }); + + // Aggregate outcomes + outcomesAgg[facet.outcome] = (outcomesAgg[facet.outcome] || 0) + 1; + + // Aggregate satisfaction + Object.entries(facet.user_satisfaction_counts).forEach(([sat, count]) => { + satisfactionAgg[sat] = (satisfactionAgg[sat] || 0) + count; + }); + + // Aggregate friction + Object.entries(facet.friction_counts).forEach(([fric, count]) => { + frictionAgg[fric] = (frictionAgg[fric] || 0) + count; + }); + + // Aggregate success (primary_success) + if (facet.primary_success && facet.primary_success !== 'none') { + successAgg[facet.primary_success] = + (successAgg[facet.primary_success] || 0) + 1; + } + }); + + const topGoals = Object.entries(goalsAgg) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); + + const dataObj = { + sessions: metrics.totalSessions || facets.length, + analyzed: facets.length, + date_range: { + start: Object.keys(metrics.heatmap).sort()[0] || 'N/A', + end: Object.keys(metrics.heatmap).sort().pop() || 'N/A', + }, + messages: metrics.totalMessages || 0, + hours: metrics.totalHours || 0, + commits: 0, // Not tracked yet + top_tools: metrics.topTools || [], + top_goals: topGoals, + outcomes: outcomesAgg, + satisfaction: satisfactionAgg, + friction: frictionAgg, + success: successAgg, + }; + + // 2. SESSION SUMMARIES section + const sessionSummaries = facets + .map((f) => `- ${f.brief_summary}`) + .join('\n'); + + // 3. FRICTION DETAILS section + const frictionDetails = facets + .filter((f) => f.friction_detail && f.friction_detail.trim().length > 0) + .map((f) => `- ${f.friction_detail}`) + .join('\n'); + + return `DATA: +${JSON.stringify(dataObj, null, 2)} + +SESSION SUMMARIES: +${sessionSummaries} + +FRICTION DETAILS: +${frictionDetails} + +USER INSTRUCTIONS TO Qwen: +None captured`; + } + + private async scanChatFiles( + baseDir: string, + ): Promise> { + const allChatFiles: Array<{ path: string; mtime: number }> = []; + + try { + // Get all project directories in the base directory + const projectDirs = await fs.readdir(baseDir); + + // Process each project directory + for (const projectDir of projectDirs) { + const projectPath = path.join(baseDir, projectDir); + const stats = await fs.stat(projectPath); + + // Only process if it's a directory + if (stats.isDirectory()) { + const chatsDir = path.join(projectPath, 'chats'); + + try { + // Get all chat files in the chats directory + const files = await fs.readdir(chatsDir); + const chatFiles = files.filter((file) => file.endsWith('.jsonl')); + + for (const file of chatFiles) { + const filePath = path.join(chatsDir, file); + + // Get file stats for sorting by recency + try { + const fileStats = await fs.stat(filePath); + allChatFiles.push({ path: filePath, mtime: fileStats.mtimeMs }); + } catch (e) { + logger.error(`Failed to stat file ${filePath}:`, e); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error( + `Error reading chats directory for project ${projectDir}: ${error}`, + ); + } + // Continue to next project if chats directory doesn't exist + continue; + } + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // Base directory doesn't exist, return empty + logger.info(`Base directory does not exist: ${baseDir}`); + } else { + logger.error(`Error reading base directory: ${error}`); + } + } + + return allChatFiles; + } + + private async generateMetrics( + files: Array<{ path: string; mtime: number }>, + onProgress?: InsightProgressCallback, + ): Promise> { + // Initialize data structures + const heatmap: HeatMapData = {}; + const activeHours: { [hour: number]: number } = {}; + const sessionStartTimes: { [sessionId: string]: Date } = {}; + const sessionEndTimes: { [sessionId: string]: Date } = {}; + let totalMessages = 0; + let totalLinesAdded = 0; + let totalLinesRemoved = 0; + const uniqueFiles = new Set(); + const toolUsage: Record = {}; + + // Process files in batches to avoid OOM and blocking the event loop + const BATCH_SIZE = 50; + const totalFiles = files.length; + + for (let i = 0; i < totalFiles; i += BATCH_SIZE) { + const batchEnd = Math.min(i + BATCH_SIZE, totalFiles); + const batch = files.slice(i, batchEnd); + + // Process batch sequentially to minimize memory usage + for (const fileInfo of batch) { + try { + const records = await readJsonlFile(fileInfo.path); + + // Process each record + for (const record of records) { + const timestamp = new Date(record.timestamp); + const dateKey = this.formatDate(timestamp); + const hour = timestamp.getHours(); + + // Count user messages and slash commands (actual user interactions) + const isUserMessage = record.type === 'user'; + const isSlashCommand = + record.type === 'system' && record.subtype === 'slash_command'; + if (isUserMessage || isSlashCommand) { + totalMessages++; + + // Update heatmap (count of user interactions per day) + heatmap[dateKey] = (heatmap[dateKey] || 0) + 1; + + // Update active hours + activeHours[hour] = (activeHours[hour] || 0) + 1; + } + + // Track session times + if (!sessionStartTimes[record.sessionId]) { + sessionStartTimes[record.sessionId] = timestamp; + } + sessionEndTimes[record.sessionId] = timestamp; + + // Track tool usage + if (record.type === 'assistant' && record.message?.parts) { + for (const part of record.message.parts) { + if ('functionCall' in part) { + const name = part.functionCall!.name!; + toolUsage[name] = (toolUsage[name] || 0) + 1; + } + } + } + + // Track lines and files from tool results + if ( + record.type === 'tool_result' && + record.toolCallResult?.resultDisplay + ) { + const display = record.toolCallResult.resultDisplay; + // Check if it matches FileDiff shape + if ( + typeof display === 'object' && + display !== null && + 'fileName' in display + ) { + // Cast to any to avoid importing FileDiff type which might not be available here + const diff = display as { + fileName: unknown; + diffStat?: { + model_added_lines?: number; + model_removed_lines?: number; + }; + }; + if (typeof diff.fileName === 'string') { + uniqueFiles.add(diff.fileName); + } + + if (diff.diffStat) { + totalLinesAdded += diff.diffStat.model_added_lines || 0; + totalLinesRemoved += diff.diffStat.model_removed_lines || 0; + } + } + } + } + } catch (error) { + logger.error( + `Failed to process metrics for file ${fileInfo.path}:`, + error, + ); + // Continue to next file + } + } + + // Update progress (mapped to 10-20% range of total progress) + if (onProgress) { + const percentComplete = batchEnd / totalFiles; + const overallProgress = 10 + Math.round(percentComplete * 10); + onProgress( + `Crunching the numbers (${batchEnd}/${totalFiles})`, + overallProgress, + ); + } + + // Yield to event loop to allow GC and UI updates + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + // Calculate streak data + const streakData = this.calculateStreaks(Object.keys(heatmap)); + + // Calculate longest work session and total hours + let longestWorkDuration = 0; + let longestWorkDate: string | null = null; + let totalDurationMs = 0; + + const sessionIds = Object.keys(sessionStartTimes); + const totalSessions = sessionIds.length; + + for (const sessionId of sessionIds) { + const start = sessionStartTimes[sessionId]; + const end = sessionEndTimes[sessionId]; + const durationMs = end.getTime() - start.getTime(); + const durationMinutes = Math.round(durationMs / (1000 * 60)); + + totalDurationMs += durationMs; + + if (durationMinutes > longestWorkDuration) { + longestWorkDuration = durationMinutes; + longestWorkDate = this.formatDate(start); + } + } + + const totalHours = Math.round(totalDurationMs / (1000 * 60 * 60)); + + // Calculate latest active time + let latestActiveTime: string | null = null; + let latestTimestamp = new Date(0); + for (const dateStr in heatmap) { + const date = new Date(dateStr); + if (date > latestTimestamp) { + latestTimestamp = date; + latestActiveTime = date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + } + } + + // Calculate top tools + const topTools = Object.entries(toolUsage) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + return { + heatmap, + currentStreak: streakData.currentStreak, + longestStreak: streakData.longestStreak, + longestWorkDate, + longestWorkDuration, + activeHours, + latestActiveTime, + totalSessions, + totalMessages, + totalHours, + topTools, + totalLinesAdded, + totalLinesRemoved, + totalFiles: uniqueFiles.size, + }; + } + + private async generateFacets( + allFiles: Array<{ path: string; mtime: number }>, + facetsOutputDir?: string, + onProgress?: InsightProgressCallback, + ): Promise { + const MAX_ELIGIBLE_SESSIONS = 50; + + // Sort files by recency (descending), then select up to 50 conversational + // sessions (must contain both user and assistant records). + const sortedFiles = [...allFiles].sort((a, b) => b.mtime - a.mtime); + const eligibleSessions: Array<{ + fileInfo: { path: string; mtime: number }; + records: ChatRecord[]; + }> = []; + + for (const fileInfo of sortedFiles) { + if (eligibleSessions.length >= MAX_ELIGIBLE_SESSIONS) { + break; + } + + try { + const records = await readJsonlFile(fileInfo.path); + if (!this.hasUserAndAssistantRecords(records)) { + continue; + } + eligibleSessions.push({ fileInfo, records }); + } catch (e) { + logger.error( + `Error reading session file ${fileInfo.path} for facet eligibility:`, + e, + ); + } + } + + logger.info( + `Analyzing ${eligibleSessions.length} eligible recent sessions with LLM...`, + ); + + // Create a limit function with concurrency of 4 to avoid 429 errors + const limit = pLimit(CONCURRENCY_LIMIT); + + let completed = 0; + const total = eligibleSessions.length; + + // Analyze sessions concurrently with limit + const analysisPromises = eligibleSessions.map(({ fileInfo, records }) => + limit(async () => { + try { + // Check if we already have this session analyzed + if (records.length > 0 && facetsOutputDir) { + const sessionId = records[0].sessionId; + if (sessionId) { + const existingFacetPath = path.join( + facetsOutputDir, + `${sessionId}.json`, + ); + try { + // Check if file exists and is readable + const existingData = await fs.readFile( + existingFacetPath, + 'utf-8', + ); + const existingFacet = JSON.parse(existingData); + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress( + 'Analyzing sessions', + percent, + `${completed}/${total}`, + ); + } + return existingFacet; + } catch (readError) { + // File doesn't exist or is invalid, proceed to analyze + if ((readError as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn( + `Failed to read existing facet for ${sessionId}, regenerating:`, + readError, + ); + } + } + } + } + + const facet = await this.analyzeSession(records); + + if (facet && facetsOutputDir) { + try { + const facetPath = path.join( + facetsOutputDir, + `${facet.session_id}.json`, + ); + await fs.writeFile( + facetPath, + JSON.stringify(facet, null, 2), + 'utf-8', + ); + } catch (writeError) { + logger.error( + `Failed to write facet file for session ${facet.session_id}:`, + writeError, + ); + } + } + + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress('Analyzing sessions', percent, `${completed}/${total}`); + } + + return facet; + } catch (e) { + logger.error(`Error analyzing session file ${fileInfo.path}:`, e); + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress('Analyzing sessions', percent, `${completed}/${total}`); + } + return null; + } + }), + ); + + const sessionFacetsWithNulls = await Promise.all(analysisPromises); + const facets = sessionFacetsWithNulls.filter( + (f): f is SessionFacets => f !== null, + ); + return facets; + } +} diff --git a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts new file mode 100644 index 0000000000..99bcb9e266 --- /dev/null +++ b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { DataProcessor } from './DataProcessor.js'; +import { TemplateRenderer } from './TemplateRenderer.js'; +import type { + InsightData, + InsightProgressCallback, +} from '../types/StaticInsightTypes.js'; + +import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core'; + +const logger = createDebugLogger('StaticInsightGenerator'); + +export class StaticInsightGenerator { + private dataProcessor: DataProcessor; + private templateRenderer: TemplateRenderer; + + constructor(config: Config) { + this.dataProcessor = new DataProcessor(config); + this.templateRenderer = new TemplateRenderer(); + } + + // Ensure the output directory exists + private async ensureOutputDirectory(): Promise { + const outputDir = path.join(os.homedir(), '.qwen', 'insights'); + await fs.mkdir(outputDir, { recursive: true }); + return outputDir; + } + + // Generate timestamped filename with collision detection + private async generateOutputPath(outputDir: string): Promise { + const now = new Date(); + const date = now.toISOString().split('T')[0]; // YYYY-MM-DD + const time = now.toTimeString().slice(0, 8).replace(/:/g, ''); // HHMMSS + + let outputPath = path.join(outputDir, `insight-${date}.html`); + + // Check if date-only file exists, if so, add timestamp + try { + await fs.access(outputPath); + // File exists, use timestamped version + outputPath = path.join(outputDir, `insight-${date}-${time}.html`); + } catch { + // File doesn't exist, use date-only name + } + + return outputPath; + } + + // Create or update the "latest" alias (symlink preferred, copy as fallback) + private async updateLatestAlias( + outputDir: string, + targetPath: string, + ): Promise { + const latestPath = path.join(outputDir, 'insight.html'); + const relativeTarget = path.relative(outputDir, targetPath); + + // Remove existing file/symlink if it exists + try { + await fs.unlink(latestPath); + } catch { + // File doesn't exist, ignore + } + + // Try symlink first (preferred - lightweight, always points to latest) + try { + await fs.symlink(relativeTarget, latestPath); + logger.debug('Created insight symlink:', relativeTarget); + return; + } catch (error) { + logger.debug( + 'Failed to create insight symlink, falling back to copy:', + error, + ); + } + + // Fallback: copy file (works everywhere, uses more disk space) + try { + await fs.copyFile(targetPath, latestPath); + logger.debug('Created insight copy:', targetPath); + } catch (error) { + logger.debug('Failed to create insight latest alias:', error); + } + } + + // Generate the static insight HTML file + async generateStaticInsight( + baseDir: string, + onProgress?: InsightProgressCallback, + ): Promise { + // Ensure output directory exists + const outputDir = await this.ensureOutputDirectory(); + const facetsDir = path.join(outputDir, 'facets'); + await fs.mkdir(facetsDir, { recursive: true }); + + // Process data + const insights: InsightData = await this.dataProcessor.generateInsights( + baseDir, + facetsDir, + onProgress, + ); + + // Render HTML + const html = await this.templateRenderer.renderInsightHTML(insights); + + // Generate timestamped output path + const outputPath = await this.generateOutputPath(outputDir); + + // Write the HTML file + await fs.writeFile(outputPath, html, 'utf-8'); + + // Update latest alias (symlink preferred, copy as fallback) + await this.updateLatestAlias(outputDir, outputPath); + + return outputPath; + } +} diff --git a/packages/cli/src/services/insight/generators/TemplateRenderer.ts b/packages/cli/src/services/insight/generators/TemplateRenderer.ts new file mode 100644 index 0000000000..8b6f779f7d --- /dev/null +++ b/packages/cli/src/services/insight/generators/TemplateRenderer.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { INSIGHT_JS, INSIGHT_CSS } from '@qwen-code/web-templates'; +import type { InsightData } from '../types/StaticInsightTypes.js'; + +export class TemplateRenderer { + // Render the complete HTML file + async renderInsightHTML(insights: InsightData): Promise { + const html = ` + + + + + Qwen Code Insights + + + +
+
+
+
+
+ + + + + + + + + + + + + + + +`; + + return html; + } +} diff --git a/packages/cli/src/services/insight/types/QualitativeInsightTypes.ts b/packages/cli/src/services/insight/types/QualitativeInsightTypes.ts new file mode 100644 index 0000000000..fc9546b98b --- /dev/null +++ b/packages/cli/src/services/insight/types/QualitativeInsightTypes.ts @@ -0,0 +1,82 @@ +export interface InsightImpressiveWorkflows { + intro: string; + impressive_workflows: Array<{ + title: string; + description: string; + }>; +} + +export interface InsightProjectAreas { + areas: Array<{ + name: string; + session_count: number; + description: string; + }>; +} + +export interface InsightFutureOpportunities { + intro: string; + opportunities: Array<{ + title: string; + whats_possible: string; + how_to_try: string; + copyable_prompt: string; + }>; +} + +export interface InsightFrictionPoints { + intro: string; + categories: Array<{ + category: string; + description: string; + examples: string[]; + }>; +} + +export interface InsightMemorableMoment { + headline: string; + detail: string; +} + +export interface InsightImprovements { + Qwen_md_additions: Array<{ + addition: string; + why: string; + prompt_scaffold: string; + }>; + features_to_try: Array<{ + feature: string; + one_liner: string; + why_for_you: string; + example_code: string; + }>; + usage_patterns: Array<{ + title: string; + suggestion: string; + detail: string; + copyable_prompt: string; + }>; +} + +export interface InsightInteractionStyle { + narrative: string; + key_pattern: string; +} + +export interface InsightAtAGlance { + whats_working: string; + whats_hindering: string; + quick_wins: string; + ambitious_workflows: string; +} + +export interface QualitativeInsights { + impressiveWorkflows: InsightImpressiveWorkflows; + projectAreas: InsightProjectAreas; + futureOpportunities: InsightFutureOpportunities; + frictionPoints: InsightFrictionPoints; + memorableMoment: InsightMemorableMoment; + improvements: InsightImprovements; + interactionStyle: InsightInteractionStyle; + atAGlance: InsightAtAGlance; +} diff --git a/packages/cli/src/services/insight/types/StaticInsightTypes.ts b/packages/cli/src/services/insight/types/StaticInsightTypes.ts new file mode 100644 index 0000000000..29ce39f164 --- /dev/null +++ b/packages/cli/src/services/insight/types/StaticInsightTypes.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { QualitativeInsights } from './QualitativeInsightTypes.js'; + +export interface HeatMapData { + [date: string]: number; +} + +export interface InsightData { + heatmap: HeatMapData; + currentStreak: number; + longestStreak: number; + longestWorkDate: string | null; + longestWorkDuration: number; // in minutes + activeHours: { [hour: number]: number }; + latestActiveTime: string | null; + totalSessions?: number; + totalMessages?: number; + totalHours?: number; + totalLinesAdded?: number; + totalLinesRemoved?: number; + totalFiles?: number; + topTools?: Array<[string, number]>; + qualitative?: QualitativeInsights; + satisfaction?: Record; + friction?: Record; + primarySuccess?: Record; + outcomes?: Record; + topGoals?: Record; +} + +export interface StreakData { + currentStreak: number; + longestStreak: number; + dates: string[]; +} + +export interface SessionFacets { + session_id: string; + underlying_goal: string; + goal_categories: Record; + outcome: + | 'fully_achieved' + | 'mostly_achieved' + | 'partially_achieved' + | 'not_achieved' + | 'unclear_from_transcript'; + user_satisfaction_counts: Record; + Qwen_helpfulness: + | 'unhelpful' + | 'slightly_helpful' + | 'moderately_helpful' + | 'very_helpful' + | 'essential'; + session_type: + | 'single_task' + | 'multi_task' + | 'iterative_refinement' + | 'exploration' + | 'quick_question'; + friction_counts: Record; + friction_detail: string; + primary_success: + | 'none' + | 'fast_accurate_search' + | 'correct_code_edits' + | 'good_explanations' + | 'proactive_help' + | 'multi_file_changes' + | 'good_debugging'; + brief_summary: string; +} + +export interface StaticInsightTemplateData { + styles: string; + content: string; + data: InsightData; + scripts: string; + generatedTime: string; +} + +export type InsightProgressCallback = ( + stage: string, + progress: number, + detail?: string, +) => void; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 53e1ea9e33..2ab8eeec4f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -100,8 +100,6 @@ import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; import { useInitializationAuthError } from './hooks/useInitializationAuthError.js'; -import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; -import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; @@ -496,18 +494,6 @@ export const AppContainer = (props: AppContainerProps) => { closeAgentsManagerDialog, } = useAgentsManagerDialog(); - // Vision model auto-switch dialog state (must be before slashCommandActions) - const [isVisionSwitchDialogOpen, setIsVisionSwitchDialogOpen] = - useState(false); - const [visionSwitchResolver, setVisionSwitchResolver] = useState<{ - resolve: (result: { - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }) => void; - reject: () => void; - } | null>(null); - const slashCommandActions = useMemo( () => ({ openAuthDialog, @@ -563,6 +549,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.loadHistory, refreshStatic, toggleVimEnabled, + isProcessing, setIsProcessing, setGeminiMdFileCount, slashCommandActions, @@ -571,32 +558,6 @@ export const AppContainer = (props: AppContainerProps) => { logger, ); - // Vision switch handlers - const handleVisionSwitchRequired = useCallback( - async (_query: unknown) => - new Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>((resolve, reject) => { - setVisionSwitchResolver({ resolve, reject }); - setIsVisionSwitchDialogOpen(true); - }), - [], - ); - - const handleVisionSwitchSelect = useCallback( - (outcome: VisionSwitchOutcome) => { - setIsVisionSwitchDialogOpen(false); - if (visionSwitchResolver) { - const result = processVisionSwitchOutcome(outcome); - visionSwitchResolver.resolve(result); - setVisionSwitchResolver(null); - } - }, - [visionSwitchResolver], - ); - // onDebugMessage should log to debug logfile, not update footer debugMessage const onDebugMessage = useCallback( (message: string) => { @@ -687,11 +648,9 @@ export const AppContainer = (props: AppContainerProps) => { setModelSwitchedFromQuotaError, refreshStatic, () => cancelHandlerRef.current(), - settings.merged.experimental?.visionModelPreview ?? false, // visionModelPreviewEnabled setEmbeddedShellFocused, terminalWidth, terminalHeight, - handleVisionSwitchRequired, // onVisionSwitchRequired ); // Track whether suggestions are visible for Tab key handling @@ -846,7 +805,6 @@ export const AppContainer = (props: AppContainerProps) => { !isThemeDialogOpen && !isEditorDialogOpen && !showWelcomeBackDialog && - !isVisionSwitchDialogOpen && welcomeBackChoice !== 'restart' && geminiClient?.isInitialized?.() ) { @@ -862,7 +820,6 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen, isEditorDialogOpen, showWelcomeBackDialog, - isVisionSwitchDialogOpen, welcomeBackChoice, geminiClient, ]); @@ -1334,7 +1291,6 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || - isVisionSwitchDialogOpen || isPermissionsDialogOpen || isAuthDialogOpen || isAuthenticating || @@ -1446,8 +1402,6 @@ export const AppContainer = (props: AppContainerProps) => { extensionsUpdateState, activePtyId, embeddedShellFocused, - // Vision switch dialog - isVisionSwitchDialogOpen, // Welcome back dialog showWelcomeBackDialog, welcomeBackInfo, @@ -1538,8 +1492,6 @@ export const AppContainer = (props: AppContainerProps) => { activePtyId, historyManager, embeddedShellFocused, - // Vision switch dialog - isVisionSwitchDialogOpen, // Welcome back dialog showWelcomeBackDialog, welcomeBackInfo, @@ -1581,8 +1533,6 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic, handleFinalSubmit, handleClearScreen, - // Vision switch dialog - handleVisionSwitchSelect, // Welcome back dialog handleWelcomeBackSelection, handleWelcomeBackClose, @@ -1626,7 +1576,6 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic, handleFinalSubmit, handleClearScreen, - handleVisionSwitchSelect, handleWelcomeBackSelection, handleWelcomeBackClose, // Subagent dialogs diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 17d464eed9..7f43fa5827 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -17,6 +17,7 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { t } from '../../i18n/index.js'; +import { CodingPlanRegion } from '../../constants/codingPlan.js'; const MODEL_PROVIDERS_DOCUMENTATION_URL = 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders'; @@ -34,7 +35,7 @@ function parseDefaultAuthType( } // Sub-mode types for API-KEY authentication -type ApiKeySubMode = 'coding-plan' | 'custom'; +type ApiKeySubMode = 'coding-plan' | 'coding-plan-intl' | 'custom'; // View level for navigation type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info'; @@ -52,6 +53,9 @@ export function AuthDialog(): React.JSX.Element { const [selectedIndex, setSelectedIndex] = useState(null); const [viewLevel, setViewLevel] = useState('main'); const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState(0); + const [region, setRegion] = useState( + CodingPlanRegion.CHINA, + ); // Main authentication entries const mainItems = [ @@ -71,9 +75,14 @@ export function AuthDialog(): React.JSX.Element { const apiKeySubItems = [ { key: 'coding-plan', - label: t('Coding Plan (Bailian)'), + label: t('Coding Plan (Bailian, China)'), value: 'coding-plan' as ApiKeySubMode, }, + { + key: 'coding-plan-intl', + label: t('Coding Plan (Bailian, Global/Intl)'), + value: 'coding-plan-intl' as ApiKeySubMode, + }, { key: 'custom', label: t('Custom'), @@ -135,6 +144,10 @@ export function AuthDialog(): React.JSX.Element { onAuthError(null); if (subMode === 'coding-plan') { + setRegion(CodingPlanRegion.CHINA); + setViewLevel('api-key-input'); + } else if (subMode === 'coding-plan-intl') { + setRegion(CodingPlanRegion.GLOBAL); setViewLevel('api-key-input'); } else { setViewLevel('custom-info'); @@ -149,8 +162,8 @@ export function AuthDialog(): React.JSX.Element { return; } - // Submit to parent for processing - await handleCodingPlanSubmit(apiKey); + // Submit to parent for processing with region info + await handleCodingPlanSubmit(apiKey, region); }; const handleGoBack = () => { @@ -246,10 +259,12 @@ export function AuthDialog(): React.JSX.Element { - {apiKeySubItems[apiKeySubModeIndex]?.value === 'coding-plan' - ? t("Paste your api key of Bailian Coding Plan and you're all set!") - : t( + {apiKeySubItems[apiKeySubModeIndex]?.value === 'custom' + ? t( 'More instructions about configuring `modelProviders` manually.', + ) + : t( + "Paste your api key of Bailian Coding Plan and you're all set!", )} @@ -264,7 +279,11 @@ export function AuthDialog(): React.JSX.Element { // Render API key input for coding-plan mode const renderApiKeyInputView = () => ( - + ); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 0ea157af5a..50b4890c26 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -30,10 +30,12 @@ import { AuthState, MessageType } from '../types.js'; import type { HistoryItem } from '../types.js'; import { t } from '../../i18n/index.js'; import { - CODING_PLAN_MODELS, + getCodingPlanConfig, + isCodingPlanConfig, + CodingPlanRegion, CODING_PLAN_ENV_KEY, - CODING_PLAN_VERSION, } from '../../constants/codingPlan.js'; +import { backupSettingsFile } from '../../utils/settingsUtils.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -285,29 +287,39 @@ export const useAuthCommand = ( /** * Handle coding plan submission - generates configs from template and stores api-key + * @param apiKey - The API key to store + * @param region - The region to use (default: CHINA) */ const handleCodingPlanSubmit = useCallback( - async (apiKey: string) => { + async ( + apiKey: string, + region: CodingPlanRegion = CodingPlanRegion.CHINA, + ) => { try { setIsAuthenticating(true); setAuthError(null); - const envKeyName = CODING_PLAN_ENV_KEY; + // Get configuration based on region + const { template, version, regionName } = getCodingPlanConfig(region); // Get persist scope const persistScope = getPersistScopeForModelSelection(settings); - // Store api-key in settings.env - settings.setValue(persistScope, `env.${envKeyName}`, apiKey); + // Backup settings file before modification + const settingsFile = settings.forScope(persistScope); + backupSettingsFile(settingsFile.path); + + // Store api-key in settings.env (unified env key) + settings.setValue(persistScope, `env.${CODING_PLAN_ENV_KEY}`, apiKey); // Sync to process.env immediately so refreshAuth can read the apiKey - process.env[envKeyName] = apiKey; + process.env[CODING_PLAN_ENV_KEY] = apiKey; // Generate model configs from template - const newConfigs: ProviderModelConfig[] = CODING_PLAN_MODELS.map( + const newConfigs: ProviderModelConfig[] = template.map( (templateConfig) => ({ ...templateConfig, - envKey: envKeyName, + envKey: CODING_PLAN_ENV_KEY, }), ); @@ -317,17 +329,9 @@ export const useAuthCommand = ( settings.merged.modelProviders as ModelProvidersConfig | undefined )?.[AuthType.USE_OPENAI] || []; - // Identify Coding Plan configs by baseUrl + envKey - // Remove existing Coding Plan configs to ensure template changes are applied - const isCodingPlanConfig = (config: ProviderModelConfig) => - config.envKey === envKeyName && - CODING_PLAN_MODELS.some( - (template) => template.baseUrl === config.baseUrl, - ); - - // Filter out existing Coding Plan configs, keep user custom configs + // Filter out all existing Coding Plan configs (mutually exclusive) const nonCodingPlanConfigs = existingConfigs.filter( - (existing) => !isCodingPlanConfig(existing), + (existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey), ); // Add new Coding Plan configs at the beginning @@ -347,12 +351,11 @@ export const useAuthCommand = ( AuthType.USE_OPENAI, ); - // Persist coding plan version for future update detection - settings.setValue( - persistScope, - 'codingPlan.version', - CODING_PLAN_VERSION, - ); + // Persist coding plan region + settings.setValue(persistScope, 'codingPlan.region', region); + + // Persist coding plan version (single field for backward compatibility) + settings.setValue(persistScope, 'codingPlan.version', version); // If there are configs, use the first one as the model if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { @@ -386,7 +389,8 @@ export const useAuthCommand = ( { type: MessageType.INFO, text: t( - 'Authenticated successfully with Coding Plan. API key is stored in settings.env.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).', + { region: regionName }, ), }, Date.now(), diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 8818c42b72..cdd07d45de 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -20,6 +20,7 @@ export const compressCommand: SlashCommand = { action: async (context) => { const { ui } = context; const executionMode = context.executionMode ?? 'interactive'; + const abortSignal = context.abortSignal; if (executionMode === 'interactive' && ui.pendingItem) { ui.addItem( @@ -96,6 +97,10 @@ export const compressCommand: SlashCommand = { const compressed = await doCompress(); + if (abortSignal?.aborted) { + return; + } + if (!compressed) { if (executionMode === 'interactive') { ui.addItem( @@ -137,6 +142,10 @@ export const compressCommand: SlashCommand = { content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`, }; } catch (e) { + // If cancelled via ESC, don't show error — cancelSlashCommand already handled UI + if (abortSignal?.aborted) { + return; + } if (executionMode === 'interactive') { ui.addItem( { diff --git a/packages/cli/src/ui/commands/insightCommand.ts b/packages/cli/src/ui/commands/insightCommand.ts new file mode 100644 index 0000000000..1693254bb4 --- /dev/null +++ b/packages/cli/src/ui/commands/insightCommand.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandContext, SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import type { HistoryItemInsightProgress } from '../types.js'; +import { t } from '../../i18n/index.js'; +import { join } from 'path'; +import os from 'os'; +import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import open from 'open'; + +const logger = createDebugLogger('DataProcessor'); + +export const insightCommand: SlashCommand = { + name: 'insight', + get description() { + return t( + 'generate personalized programming insights from your chat history', + ); + }, + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + try { + context.ui.setDebugMessage(t('Generating insights...')); + + const projectsDir = join(os.homedir(), '.qwen', 'projects'); + if (!context.services.config) { + throw new Error('Config service is not available'); + } + const insightGenerator = new StaticInsightGenerator( + context.services.config, + ); + + const updateProgress = ( + stage: string, + progress: number, + detail?: string, + ) => { + const progressItem: HistoryItemInsightProgress = { + type: MessageType.INSIGHT_PROGRESS, + progress: { + stage, + progress, + detail, + }, + }; + context.ui.setPendingItem(progressItem); + }; + + context.ui.addItem( + { + type: MessageType.INFO, + text: t('This may take a couple minutes. Sit tight!'), + }, + Date.now(), + ); + + // Initial progress + updateProgress(t('Starting insight generation...'), 0); + + // Generate the static insight HTML file + const outputPath = await insightGenerator.generateStaticInsight( + projectsDir, + updateProgress, + ); + + // Clear pending item + context.ui.setPendingItem(null); + + context.ui.addItem( + { + type: MessageType.INFO, + text: t('Insight report generated successfully!'), + }, + Date.now(), + ); + + // Open the file in the default browser + try { + await open(outputPath); + + context.ui.addItem( + { + type: MessageType.INFO, + text: t('Opening insights in your browser: {{path}}', { + path: outputPath, + }), + }, + Date.now(), + ); + } catch (browserError) { + logger.error('Failed to open browser automatically:', browserError); + + context.ui.addItem( + { + type: MessageType.INFO, + text: t( + 'Insights generated at: {{path}}. Please open this file in your browser.', + { + path: outputPath, + }, + ), + }, + Date.now(), + ); + } + + context.ui.setDebugMessage(t('Insights ready.')); + } catch (error) { + // Clear pending item on error + context.ui.setPendingItem(null); + + context.ui.addItem( + { + type: MessageType.ERROR, + text: t('Failed to generate insights: {{error}}', { + error: (error as Error).message, + }), + }, + Date.now(), + ); + + logger.error('Insight generation error:', error); + } + }, +}; diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index baba59b6c9..40bec554f2 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -104,6 +104,15 @@ export const setupGithubCommand: SlashCommand = { ): Promise => { const abortController = new AbortController(); + // If we have a context abort signal (from ESC cancellation), link it to our controller + if (context.abortSignal) { + context.abortSignal.addEventListener( + 'abort', + () => abortController.abort(), + { once: true }, + ); + } + if (!isGitHubRepository()) { throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index de75fadd22..5e84bf53e2 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -27,6 +27,7 @@ export const summaryCommand: SlashCommand = { const { config } = context.services; const { ui } = context; const executionMode = context.executionMode ?? 'interactive'; + const abortSignal = context.abortSignal; if (!config) { return { @@ -101,7 +102,7 @@ export const summaryCommand: SlashCommand = { }, ], {}, - new AbortController().signal, + abortSignal ?? new AbortController().signal, config.getModel(), ); @@ -197,6 +198,10 @@ export const summaryCommand: SlashCommand = { if (executionMode !== 'interactive') { return; } + // If cancelled via ESC, don't show error — cancelSlashCommand already handled UI + if (abortSignal?.aborted) { + return; + } ui.setPendingItem(null); ui.addItem( { @@ -241,6 +246,9 @@ export const summaryCommand: SlashCommand = { }> => { emitInteractivePending('generating'); const markdownSummary = await generateSummaryMarkdown(history); + if (abortSignal?.aborted) { + throw new DOMException('Summary generation cancelled.', 'AbortError'); + } emitInteractivePending('saving'); const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary); completeInteractive(filePathForDisplay); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 6c03ec1364..90330e9884 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -89,6 +89,8 @@ export interface CommandContext { }; // Flag to indicate if an overwrite has been confirmed overwriteConfirmed?: boolean; + /** Abort signal for cancelling long-running slash command operations via ESC. */ + abortSignal?: AbortSignal; } /** diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index e4082be3a7..a702c2d214 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -11,23 +11,34 @@ import { TextInput } from './shared/TextInput.js'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; +import { CodingPlanRegion } from '../../constants/codingPlan.js'; import Link from 'ink-link'; interface ApiKeyInputProps { onSubmit: (apiKey: string) => void; onCancel: () => void; + region?: CodingPlanRegion; } const CODING_PLAN_API_KEY_URL = 'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan'; +const CODING_PLAN_INTL_API_KEY_URL = + 'https://modelstudio.console.alibabacloud.com/?tab=dashboard#/efm/coding_plan'; + export function ApiKeyInput({ onSubmit, onCancel, + region = CodingPlanRegion.CHINA, }: ApiKeyInputProps): React.JSX.Element { const [apiKey, setApiKey] = useState(''); const [error, setError] = useState(null); + const apiKeyUrl = + region === CodingPlanRegion.GLOBAL + ? CODING_PLAN_INTL_API_KEY_URL + : CODING_PLAN_API_KEY_URL; + useKeypress( (key) => { if (key.name === 'escape') { @@ -59,9 +70,9 @@ export function ApiKeyInput({ {t('You can get your exclusive Coding Plan API-KEY here:')} - + - {CODING_PLAN_API_KEY_URL} + {apiKeyUrl} diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index ba044d10de..0254a2012a 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -5,16 +5,43 @@ */ import { Box } from 'ink'; -import { Header } from './Header.js'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { Header, AuthDisplayType } from './Header.js'; import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { isCodingPlanConfig } from '../../constants/codingPlan.js'; interface AppHeaderProps { version: string; } +/** + * Determine the auth display type based on auth type and configuration. + */ +function getAuthDisplayType( + authType?: AuthType, + baseUrl?: string, + apiKeyEnvKey?: string, +): AuthDisplayType { + if (!authType) { + return AuthDisplayType.UNKNOWN; + } + + // Check if it's a Coding Plan config + if (isCodingPlanConfig(baseUrl, apiKeyEnvKey)) { + return AuthDisplayType.CODING_PLAN; + } + + switch (authType) { + case AuthType.QWEN_OAUTH: + return AuthDisplayType.QWEN_OAUTH; + default: + return AuthDisplayType.API_KEY; + } +} + export const AppHeader = ({ version }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); @@ -27,12 +54,18 @@ export const AppHeader = ({ version }: AppHeaderProps) => { const showBanner = !config.getScreenReader(); const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader()); + const authDisplayType = getAuthDisplayType( + authType, + contentGeneratorConfig?.baseUrl, + contentGeneratorConfig?.apiKeyEnvKey, + ); + return ( {showBanner && (
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index dbb6f22075..c79e911195 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -32,7 +32,6 @@ import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; 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'; @@ -236,10 +235,6 @@ export const DialogManager = ({ if (uiState.isModelDialogOpen) { return ; } - if (uiState.isVisionSwitchDialogOpen) { - return ; - } - if (uiState.isAuthDialogOpen || uiState.authError) { return ( diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 1d3a4d7f19..99bb053da6 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -6,8 +6,7 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { Header } from './Header.js'; +import { Header, AuthDisplayType } from './Header.js'; import * as useTerminalSize from '../hooks/useTerminalSize.js'; vi.mock('../hooks/useTerminalSize.js'); @@ -15,86 +14,70 @@ const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize); const defaultProps = { version: '1.0.0', - authType: AuthType.QWEN_OAUTH, + authDisplayType: AuthDisplayType.QWEN_OAUTH, model: 'qwen-coder-plus', workingDirectory: '/home/user/projects/test', }; describe('
', () => { beforeEach(() => { - // Default to wide terminal (shows both logo and info panel) useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 }); }); it('renders the ASCII logo on wide terminal', () => { const { lastFrame } = render(
); - // Check that parts of the shortAsciiLogo are rendered expect(lastFrame()).toContain('██╔═══██╗'); }); it('hides the ASCII logo on narrow terminal', () => { useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 }); const { lastFrame } = render(
); - // Should not contain the logo but still show the info panel expect(lastFrame()).not.toContain('██╔═══██╗'); expect(lastFrame()).toContain('>_ Qwen Code'); }); - it('renders custom ASCII art when provided on wide terminal', () => { - const customArt = 'CUSTOM ART'; - const { lastFrame } = render( -
, - ); - expect(lastFrame()).toContain(customArt); - }); - it('displays the version number', () => { const { lastFrame } = render(
); expect(lastFrame()).toContain('v1.0.0'); }); - it('displays Qwen Code title with >_ prefix', () => { - const { lastFrame } = render(
); - expect(lastFrame()).toContain('>_ Qwen Code'); - }); - it('displays auth type and model', () => { const { lastFrame } = render(
); expect(lastFrame()).toContain('Qwen OAuth'); expect(lastFrame()).toContain('qwen-coder-plus'); }); - it('displays working directory', () => { - const { lastFrame } = render(
); - expect(lastFrame()).toContain('/home/user/projects/test'); - }); - - it('renders a custom working directory display', () => { + it('displays Coding Plan auth type', () => { const { lastFrame } = render( -
, +
, ); - expect(lastFrame()).toContain('custom display'); + expect(lastFrame()).toContain('Coding Plan'); }); - it('displays working directory without branch name', () => { - const { lastFrame } = render(
); - // Branch name is no longer shown in header - expect(lastFrame()).toContain('/home/user/projects/test'); - expect(lastFrame()).not.toContain('(main*)'); + it('displays API Key auth type', () => { + const { lastFrame } = render( +
, + ); + expect(lastFrame()).toContain('API Key'); }); - it('formats home directory with tilde', () => { + it('displays Unknown when auth type is not set', () => { const { lastFrame } = render( -
, +
, ); - // The actual home dir replacement depends on os.homedir() - // Just verify the path is shown - expect(lastFrame()).toContain('projects'); + expect(lastFrame()).toContain('Unknown'); + }); + + it('displays working directory', () => { + const { lastFrame } = render(
); + expect(lastFrame()).toContain('/home/user/projects/test'); }); it('renders with border around info panel', () => { const { lastFrame } = render(
); - // Check for border characters (round border style uses these) expect(lastFrame()).toContain('╭'); expect(lastFrame()).toContain('╯'); }); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index adbe130714..45fce43850 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -7,59 +7,35 @@ import type React from 'react'; import { Box, Text } from 'ink'; import Gradient from 'ink-gradient'; -import { AuthType, shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core'; +import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; import { shortAsciiLogo } from './AsciiArt.js'; import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; +/** + * Auth display type for the Header component. + * Simplified representation of authentication method shown to users. + */ +export enum AuthDisplayType { + QWEN_OAUTH = 'Qwen OAuth', + CODING_PLAN = 'Coding Plan', + API_KEY = 'API Key', + UNKNOWN = 'Unknown', +} + interface HeaderProps { customAsciiArt?: string; // For user-defined ASCII art version: string; - authType?: AuthType; + authDisplayType?: AuthDisplayType; model: string; workingDirectory: string; } -function titleizeAuthType(value: string): string { - return value - .split(/[-_]/g) - .filter(Boolean) - .map((part) => { - if (part.toLowerCase() === 'ai') { - return 'AI'; - } - return part.charAt(0).toUpperCase() + part.slice(1); - }) - .join(' '); -} - -// Format auth type for display -function formatAuthType(authType?: AuthType): string { - if (!authType) { - return 'Unknown'; - } - - switch (authType) { - case AuthType.QWEN_OAUTH: - return 'Qwen OAuth'; - case AuthType.USE_OPENAI: - return 'OpenAI'; - case AuthType.USE_GEMINI: - return 'Gemini'; - case AuthType.USE_VERTEX_AI: - return 'Vertex AI'; - case AuthType.USE_ANTHROPIC: - return 'Anthropic'; - default: - return titleizeAuthType(String(authType)); - } -} - export const Header: React.FC = ({ customAsciiArt, version, - authType, + authDisplayType, model, workingDirectory, }) => { @@ -67,7 +43,7 @@ export const Header: React.FC = ({ const displayLogo = customAsciiArt ?? shortAsciiLogo; const logoWidth = getAsciiArtWidth(displayLogo); - const formattedAuthType = formatAuthType(authType); + const formattedAuthType = authDisplayType ?? AuthDisplayType.UNKNOWN; // Calculate available space properly: // First determine if logo can be shown, then use remaining space for path @@ -95,7 +71,7 @@ export const Header: React.FC = ({ ? Math.min(availableTerminalWidth - logoWidth - logoGap, maxInfoPanelWidth) : availableTerminalWidth; - // Calculate max path length (subtract padding/borders from available space) + // Calculate max path lengths (subtract padding/borders from available space) const maxPathLength = Math.max( 0, availableInfoPanelWidth - infoPanelChromeWidth, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 73bdd6de37..b12adcf136 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -34,6 +34,7 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core'; import { SkillsList } from './views/SkillsList.js'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; +import { InsightProgressMessage } from './messages/InsightProgressMessage.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -180,6 +181,9 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'mcp_status' && ( )} + {itemForDisplay.type === 'insight_progress' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index b8c83a1324..d5ace1c531 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -370,6 +370,8 @@ describe('InputPrompt', () => { }); describe('clipboard image paste', () => { + const isWindows = process.platform === 'win32'; + beforeEach(() => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null); @@ -378,10 +380,37 @@ describe('InputPrompt', () => { ); }); - it('should handle Ctrl+V when clipboard has an image', async () => { + // Windows uses Alt+V (\x1Bv), non-Windows uses Ctrl+V (\x16) + const describeConditional = isWindows ? it.skip : it; + describeConditional( + 'should handle Ctrl+V when clipboard has an image', + async () => { + vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); + vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue( + '/Users/mochi/.qwen/tmp/clipboard-123.png', + ); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Send Ctrl+V + stdin.write('\x16'); // Ctrl+V + await wait(); + + expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); + expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled(); + // Note: The new implementation adds images as attachments rather than inserting into buffer + unmount(); + }, + ); + + it('should handle Cmd+V when clipboard has an image', async () => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue( - '/test/.qwen-clipboard/clipboard-123.png', + '/Users/mochi/.qwen/tmp/clipboard-456.png', ); const { stdin, unmount } = renderWithProviders( @@ -389,18 +418,15 @@ describe('InputPrompt', () => { ); await wait(); - // Send Ctrl+V - stdin.write('\x16'); // Ctrl+V + // Send Cmd+V (meta key) / Alt+V on Windows + // In terminals, Cmd+V or Alt+V is typically sent as ESC followed by 'v' + stdin.write('\x1Bv'); await wait(); expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); - expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith( - props.config.getTargetDir(), - ); - expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith( - props.config.getTargetDir(), - ); - expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled(); + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); + expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled(); + // Note: The new implementation adds images as attachments rather than inserting into buffer unmount(); }); @@ -412,7 +438,8 @@ describe('InputPrompt', () => { ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); @@ -430,7 +457,8 @@ describe('InputPrompt', () => { ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); @@ -439,11 +467,7 @@ describe('InputPrompt', () => { }); it('should insert image path at cursor position with proper spacing', async () => { - const imagePath = path.join( - 'test', - '.qwen-clipboard', - 'clipboard-456.png', - ); + const imagePath = '/Users/mochi/.qwen/tmp/clipboard-456.png'; vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath); @@ -451,27 +475,20 @@ describe('InputPrompt', () => { mockBuffer.text = 'Hello world'; mockBuffer.cursor = [0, 5]; // Cursor after "Hello" mockBuffer.lines = ['Hello world']; - mockBuffer.replaceRangeByOffset = vi.fn(); const { stdin, unmount } = renderWithProviders( , ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); - // Should insert at cursor position with spaces - expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled(); - - // Get the actual call to see what path was used - const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock - .calls[0]; - expect(actualCall[0]).toBe(5); // start offset - expect(actualCall[1]).toBe(5); // end offset - expect(actualCall[2]).toBe( - ' @' + path.relative(path.join('test', 'project', 'src'), imagePath), - ); + // The new implementation adds images as attachments rather than inserting into buffer + // So we verify that saveClipboardImage was called instead + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); + expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); unmount(); }); @@ -485,7 +502,8 @@ describe('InputPrompt', () => { ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); // Should not throw and should not set buffer text on error diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8820e21266..09c2b27f13 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -22,7 +22,11 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { Config } from '@qwen-code/qwen-code-core'; -import { ApprovalMode, createDebugLogger } from '@qwen-code/qwen-code-core'; +import { + ApprovalMode, + Storage, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; import { parseInputForHighlighting, buildSegmentsForVisualSlice, @@ -41,6 +45,15 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { useKeypressContext } from '../contexts/KeypressContext.js'; import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js'; +/** + * Represents an attachment (e.g., pasted image) displayed above the input prompt + */ +export interface Attachment { + id: string; // Unique identifier (timestamp) + path: string; // Full file path + filename: string; // Filename only (for display) +} + const debugLogger = createDebugLogger('INPUT_PROMPT'); export interface InputPromptProps { buffer: TextBuffer; @@ -126,6 +139,10 @@ export const InputPrompt: React.FC = ({ const [recentPasteTime, setRecentPasteTime] = useState(null); const pasteTimeoutRef = useRef(null); + // Attachment state for clipboard images + const [attachments, setAttachments] = useState([]); + const [isAttachmentMode, setIsAttachmentMode] = useState(false); + const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(-1); // Large paste placeholder handling const [pendingPastes, setPendingPastes] = useState>( new Map(), @@ -281,10 +298,25 @@ export const InputPrompt: React.FC = ({ if (shellModeActive) { shellHistory.addCommandToHistory(finalValue); } + + // Convert attachments to @references and prepend to the message + if (attachments.length > 0) { + const attachmentRefs = attachments + .map((att) => `@${path.relative(config.getTargetDir(), att.path)}`) + .join(' '); + finalValue = `${attachmentRefs}\n\n${finalValue.trim()}`; + } + // Clear the buffer *before* calling onSubmit to prevent potential re-submission // if onSubmit triggers a re-render while the buffer still holds the old value. buffer.setText(''); onSubmit(finalValue); + + // Clear attachments after submit + setAttachments([]); + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + resetCompletionState(); resetReverseSearchCompletionState(); }, @@ -295,6 +327,8 @@ export const InputPrompt: React.FC = ({ shellModeActive, shellHistory, resetReverseSearchCompletionState, + attachments, + config, pendingPastes, ], ); @@ -336,52 +370,45 @@ export const InputPrompt: React.FC = ({ ]); // Handle clipboard image pasting with Ctrl+V - const handleClipboardImage = useCallback(async () => { + const handleClipboardImage = useCallback(async (validated = false) => { try { - if (await clipboardHasImage()) { - const imagePath = await saveClipboardImage(config.getTargetDir()); + const hasImage = validated || (await clipboardHasImage()); + if (hasImage) { + const imagePath = await saveClipboardImage(Storage.getGlobalTempDir()); if (imagePath) { // Clean up old images - cleanupOldClipboardImages(config.getTargetDir()).catch(() => { + cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => { // Ignore cleanup errors }); - // Get relative path from current directory - const relativePath = path.relative(config.getTargetDir(), imagePath); - - // Insert @path reference at cursor position - const insertText = `@${relativePath}`; - const currentText = buffer.text; - const [row, col] = buffer.cursor; - - // Calculate offset from row/col - let offset = 0; - for (let i = 0; i < row; i++) { - offset += buffer.lines[i].length + 1; // +1 for newline - } - offset += col; - - // Add spaces around the path if needed - let textToInsert = insertText; - const charBefore = offset > 0 ? currentText[offset - 1] : ''; - const charAfter = - offset < currentText.length ? currentText[offset] : ''; - - if (charBefore && charBefore !== ' ' && charBefore !== '\n') { - textToInsert = ' ' + textToInsert; - } - if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) { - textToInsert = textToInsert + ' '; - } - - // Insert at cursor position - buffer.replaceRangeByOffset(offset, offset, textToInsert); + // Add as attachment instead of inserting @reference into text + const filename = path.basename(imagePath); + const newAttachment: Attachment = { + id: String(Date.now()), + path: imagePath, + filename, + }; + setAttachments((prev) => [...prev, newAttachment]); } } } catch (error) { debugLogger.error('Error handling clipboard image:', error); } - }, [buffer, config]); + }, []); + + // Handle deletion of an attachment from the list + const handleAttachmentDelete = useCallback((index: number) => { + setAttachments((prev) => { + const newList = prev.filter((_, i) => i !== index); + if (newList.length === 0) { + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + } else { + setSelectedAttachmentIndex(Math.min(index, newList.length - 1)); + } + return newList; + }); + }, []); const handleInput = useCallback( (key: Key) => { @@ -412,7 +439,11 @@ export const InputPrompt: React.FC = ({ const pasted = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const charCount = [...pasted].length; // Proper Unicode char count const lineCount = pasted.split('\n').length; - if ( + + // Ensure we never accidentally interpret paste as regular input. + if (key.pasteImage) { + handleClipboardImage(true); + } else if ( charCount > LARGE_PASTE_CHAR_THRESHOLD || lineCount > LARGE_PASTE_LINE_THRESHOLD ) { @@ -666,6 +697,55 @@ export const InputPrompt: React.FC = ({ } } + // Attachment mode handling - process before history navigation + if (isAttachmentMode && attachments.length > 0) { + if (key.name === 'left') { + setSelectedAttachmentIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.name === 'right') { + setSelectedAttachmentIndex((i) => + Math.min(attachments.length - 1, i + 1), + ); + return; + } + if (keyMatchers[Command.NAVIGATION_DOWN](key)) { + // Exit attachment mode and return to input + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + return; + } + if (key.name === 'backspace' || key.name === 'delete') { + handleAttachmentDelete(selectedAttachmentIndex); + return; + } + if (key.name === 'return' || key.name === 'escape') { + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + return; + } + // For other keys, exit attachment mode and let input handle them + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + // Continue to process the key in input + } + + // Enter attachment mode when pressing up at the first line with attachments + if ( + !isAttachmentMode && + attachments.length > 0 && + !shellModeActive && + !reverseSearchActive && + !commandSearchActive && + buffer.visualCursor[0] === 0 && + buffer.visualScrollRow === 0 && + keyMatchers[Command.NAVIGATION_UP](key) + ) { + setIsAttachmentMode(true); + setSelectedAttachmentIndex(attachments.length - 1); + return; + } + if (!shellModeActive) { if (keyMatchers[Command.REVERSE_SEARCH](key)) { setCommandSearchActive(true); @@ -864,6 +944,10 @@ export const InputPrompt: React.FC = ({ onToggleShortcuts, showShortcuts, uiState, + isAttachmentMode, + attachments, + selectedAttachmentIndex, + handleAttachmentDelete, uiActions, pasteWorkaround, nextLargePastePlaceholder, @@ -921,6 +1005,23 @@ export const InputPrompt: React.FC = ({ return ( <> + {attachments.length > 0 && ( + + {t('Attachments: ')} + {attachments.map((att, idx) => ( + + [{att.filename}]{idx < attachments.length - 1 ? ' ' : ''} + + ))} + + )} = ({ /> )} + {/* Attachment hints - show when there are attachments and no suggestions visible */} + {attachments.length > 0 && !shouldShowSuggestions && ( + + + {isAttachmentMode + ? t('← → select, Delete to remove, ↓ to exit') + : t('↑ to manage attachments')} + + + )} ); }; diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx index 9ce49b4157..ada240b02a 100644 --- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx +++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx @@ -18,7 +18,10 @@ interface Shortcut { // Platform-specific key mappings const getNewlineKey = () => process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j'; -const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v'); +const getPasteKey = () => { + if (process.platform === 'win32') return 'alt+v'; + return process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v'; +}; const getExternalEditorKey = () => process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x'; diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 3ce25bfa9c..dc5cc108a3 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -12,14 +12,10 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel import { ConfigContext } from '../contexts/ConfigContext.js'; import { SettingsContext } from '../contexts/SettingsContext.js'; import type { Config } from '@qwen-code/qwen-code-core'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { - AVAILABLE_MODELS_QWEN, - MAINLINE_CODER, - MAINLINE_VLM, -} from '../models/availableModels.js'; +import { getFilteredQwenModels } from '../models/availableModels.js'; vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), @@ -29,6 +25,19 @@ const mockedUseKeypress = vi.mocked(useKeypress); vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({ DescriptiveRadioButtonSelect: vi.fn(() => null), })); + +// Helper to create getAvailableModelsForAuthType mock +const createMockGetAvailableModelsForAuthType = () => + vi.fn((t: AuthType) => { + if (t === AuthType.QWEN_OAUTH) { + return getFilteredQwenModels().map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }); const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect); const renderComponent = ( @@ -49,12 +58,12 @@ const renderComponent = ( const mockConfig = { // --- Functions used by ModelDialog --- - getModel: vi.fn(() => MAINLINE_CODER), + getModel: vi.fn(() => DEFAULT_QWEN_MODEL), setModel: vi.fn().mockResolvedValue(undefined), switchModel: vi.fn().mockResolvedValue(undefined), getAuthType: vi.fn(() => 'qwen-oauth'), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -68,7 +77,7 @@ const renderComponent = ( getDebugMode: vi.fn(() => false), getContentGeneratorConfig: vi.fn(() => ({ authType: AuthType.QWEN_OAUTH, - model: MAINLINE_CODER, + model: DEFAULT_QWEN_MODEL, })), getUseModelRouter: vi.fn(() => false), getProxy: vi.fn(() => undefined), @@ -105,10 +114,9 @@ describe('', () => { cleanup(); }); - it('renders the title and help text', () => { + it('renders the title', () => { const { getByText } = renderComponent(); expect(getByText('Select Model')).toBeDefined(); - expect(getByText('(Press Esc to close)')).toBeDefined(); }); it('passes all model options to DescriptiveRadioButtonSelect', () => { @@ -116,24 +124,34 @@ describe('', () => { expect(mockedSelect).toHaveBeenCalledTimes(1); const props = mockedSelect.mock.calls[0][0]; - expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length); + expect(props.items).toHaveLength(getFilteredQwenModels().length); + // coder-model is the only model and it has vision capability expect(props.items[0].value).toBe( - `${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`, - ); - expect(props.items[1].value).toBe( - `${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`, + `${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`, ); expect(props.showNumbers).toBe(true); }); it('initializes with the model from ConfigContext', () => { - const mockGetModel = vi.fn(() => MAINLINE_VLM); - renderComponent({}, { getModel: mockGetModel }); + const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); + renderComponent( + {}, + { + getModel: mockGetModel, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), + }, + ); expect(mockGetModel).toHaveBeenCalled(); + // Calculate expected index dynamically based on model list + const qwenModels = getFilteredQwenModels(); + const expectedIndex = qwenModels.findIndex( + (m) => m.id === DEFAULT_QWEN_MODEL, + ); expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ - initialIndex: 1, + initialIndex: expectedIndex, }), undefined, ); @@ -151,14 +169,19 @@ describe('', () => { }); it('initializes with default coder model if getModel returns undefined', () => { - const mockGetModel = vi.fn(() => undefined); - // @ts-expect-error This test validates component robustness when getModel - // returns an unexpected undefined value. - renderComponent({}, { getModel: mockGetModel }); + const mockGetModel = vi.fn(() => undefined as unknown as string); + renderComponent( + {}, + { + getModel: mockGetModel, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), + }, + ); expect(mockGetModel).toHaveBeenCalled(); - // When getModel returns undefined, preferredModel falls back to MAINLINE_CODER + // When getModel returns undefined, preferredModel falls back to DEFAULT_QWEN_MODEL // which has index 0, so initialIndex should be 0 expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ @@ -170,22 +193,36 @@ describe('', () => { }); it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => { - const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue + const { props, mockConfig, mockSettings } = renderComponent( + {}, + { + getAvailableModelsForAuthType: vi.fn((t: AuthType) => { + if (t === AuthType.QWEN_OAUTH) { + return getFilteredQwenModels().map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }), + }, + ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; expect(childOnSelect).toBeDefined(); - await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); + await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`); expect(mockConfig?.switchModel).toHaveBeenCalledWith( AuthType.QWEN_OAUTH, - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, undefined, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'model.name', - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -203,7 +240,7 @@ describe('', () => { return [{ id: 'gpt-4', label: 'GPT-4', authType: t }]; } if (t === AuthType.QWEN_OAUTH) { - return AVAILABLE_MODELS_QWEN.map((m) => ({ + return getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, authType: AuthType.QWEN_OAUTH, @@ -217,7 +254,7 @@ describe('', () => { getModel: vi.fn(() => 'gpt-4'), getContentGeneratorConfig: vi.fn(() => ({ authType: AuthType.QWEN_OAUTH, - model: MAINLINE_CODER, + model: DEFAULT_QWEN_MODEL, })), // Add switchModel to the mock object (not the type) switchModel, @@ -231,17 +268,17 @@ describe('', () => { ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; - await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); + await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`); expect(switchModel).toHaveBeenCalledWith( AuthType.QWEN_OAUTH, - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, { requireCachedCredentials: true }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'model.name', - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -251,11 +288,12 @@ describe('', () => { expect(props.onClose).toHaveBeenCalledTimes(1); }); - it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => { + it('passes onHighlight to DescriptiveRadioButtonSelect', () => { renderComponent(); const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight; - expect(childOnHighlight).toBeUndefined(); + expect(childOnHighlight).toBeDefined(); + expect(typeof childOnHighlight).toBe('function'); }); it('calls onClose prop when "escape" key is pressed', () => { @@ -290,7 +328,7 @@ describe('', () => { }); it('updates initialIndex when config context changes', () => { - const mockGetModel = vi.fn(() => MAINLINE_CODER); + const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); const mockGetAuthType = vi.fn(() => 'qwen-oauth'); const mockSettings = { isTrusted: true, @@ -305,8 +343,10 @@ describe('', () => { { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -321,14 +361,16 @@ describe('', () => { , ); + // DEFAULT_QWEN_MODEL (coder-model) is at index 0 expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0); - mockGetModel.mockReturnValue(MAINLINE_VLM); + mockGetModel.mockReturnValue(DEFAULT_QWEN_MODEL); const newMockConfig = { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAvailableModelsForAuthType: createMockGetAvailableModelsForAuthType(), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -347,6 +389,11 @@ describe('', () => { // Should be called at least twice: initial render + re-render after context change expect(mockedSelect).toHaveBeenCalledTimes(2); - expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(1); + // Calculate expected index for DEFAULT_QWEN_MODEL dynamically + const qwenModels = getFilteredQwenModels(); + const expectedCoderIndex = qwenModels.findIndex( + (m) => m.id === DEFAULT_QWEN_MODEL, + ); + expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(expectedCoderIndex); }); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 8c102890f4..09723dcddf 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -11,10 +11,10 @@ import { AuthType, ModelSlashCommandEvent, logModelSlashCommand, + MAINLINE_CODER_MODEL, type AvailableModel as CoreAvailableModel, type ContentGeneratorConfig, - type ContentGeneratorConfigSource, - type ContentGeneratorConfigSources, + type InputModalities, } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; @@ -22,65 +22,28 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel import { ConfigContext } from '../contexts/ConfigContext.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; -import { MAINLINE_CODER } from '../models/availableModels.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { t } from '../../i18n/index.js'; -interface ModelDialogProps { - onClose: () => void; +function formatModalities(modalities?: InputModalities): string { + if (!modalities) return t('text-only'); + const parts: string[] = []; + if (modalities.image) parts.push(t('image')); + if (modalities.pdf) parts.push(t('pdf')); + if (modalities.audio) parts.push(t('audio')); + if (modalities.video) parts.push(t('video')); + if (parts.length === 0) return t('text-only'); + return `${t('text')} ¡ ${parts.join(' ¡ ')}`; } -function formatSourceBadge( - source: ContentGeneratorConfigSource | undefined, -): string | undefined { - if (!source) return undefined; - - switch (source.kind) { - case 'cli': - return source.detail ? `CLI ${source.detail}` : 'CLI'; - case 'env': - return source.envKey ? `ENV ${source.envKey}` : 'ENV'; - case 'settings': - return source.settingsPath - ? `Settings ${source.settingsPath}` - : 'Settings'; - case 'modelProviders': { - const suffix = - source.authType && source.modelId - ? `${source.authType}:${source.modelId}` - : source.authType - ? `${source.authType}` - : source.modelId - ? `${source.modelId}` - : ''; - return suffix ? `ModelProviders ${suffix}` : 'ModelProviders'; - } - case 'default': - return source.detail ? `Default ${source.detail}` : 'Default'; - case 'computed': - return source.detail ? `Computed ${source.detail}` : 'Computed'; - case 'programmatic': - return source.detail ? `Programmatic ${source.detail}` : 'Programmatic'; - case 'unknown': - default: - return undefined; - } -} - -function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources { - if (!config) { - return {}; - } - const maybe = config as { - getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources; - }; - return maybe.getContentGeneratorConfigSources?.() ?? {}; +interface ModelDialogProps { + onClose: () => void; } function maskApiKey(apiKey: string | undefined): string { - if (!apiKey) return '(not set)'; + if (!apiKey) return `(${t('not set')})`; const trimmed = apiKey.trim(); - if (trimmed.length === 0) return '(not set)'; + if (trimmed.length === 0) return `(${t('not set')})`; if (trimmed.length <= 6) return '***'; const head = trimmed.slice(0, 3); const tail = trimmed.slice(-4); @@ -131,7 +94,7 @@ function handleModelSwitchSuccess({ { type: 'info', text: - `authType: ${effectiveAuthType ?? '(none)'}` + + `authType: ${effectiveAuthType ?? `(${t('none')})`}` + `\n` + `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}` + `\n` + @@ -143,35 +106,26 @@ function handleModelSwitchSuccess({ ); } -function ConfigRow({ +function formatContextWindow(size?: number): string { + if (!size) return `(${t('unknown')})`; + return `${size.toLocaleString('en-US')} tokens`; +} + +function DetailRow({ label, value, - badge, }: { label: string; value: React.ReactNode; - badge?: string; }): React.JSX.Element { return ( - - - - {label}: - - - {value} - + + + {label}: + + + {value} - {badge ? ( - - - - - - {badge} - - - ) : null} ); } @@ -183,13 +137,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { // Local error state for displaying errors within the dialog const [errorMessage, setErrorMessage] = useState(null); + const [highlightedValue, setHighlightedValue] = useState(null); const authType = config?.getAuthType(); - const effectiveConfig = - (config?.getContentGeneratorConfig?.() as - | ContentGeneratorConfig - | undefined) ?? undefined; - const sources = readSourcesFromConfig(config); const availableModelEntries = useMemo(() => { const allModels = config ? config.getAllConfiguredModels() : []; @@ -293,7 +243,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { [availableModelEntries], ); - const preferredModelId = config?.getModel() || MAINLINE_CODER; + const preferredModelId = config?.getModel() || MAINLINE_CODER_MODEL; // Check if current model is a runtime model // Runtime snapshot ID is already in $runtime|${authType}|${modelId} format const activeRuntimeSnapshot = config?.getActiveRuntimeModelSnapshot?.(); @@ -319,6 +269,20 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { return index === -1 ? 0 : index; }, [MODEL_OPTIONS, preferredKey]); + const handleHighlight = useCallback((value: string) => { + setHighlightedValue(value); + }, []); + + const highlightedEntry = useMemo(() => { + const key = highlightedValue ?? preferredKey; + return availableModelEntries.find( + ({ authType: t2, model, isRuntime, snapshotId }) => { + const v = isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`; + return v === key; + }, + ); + }, [highlightedValue, preferredKey, availableModelEntries]); + const handleSelect = useCallback( async (selected: string) => { setErrorMessage(null); @@ -413,35 +377,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { > {t('Select Model')} - - - {t('Current (effective) configuration')} - - - - - - {authType !== AuthType.QWEN_OAUTH && ( - <> - - - - )} - - - {!hasModels ? ( @@ -465,12 +400,48 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { )} + {highlightedEntry && ( + + + + + {highlightedEntry.authType !== AuthType.QWEN_OAUTH && ( + <> + + + + )} + + )} + {errorMessage && ( @@ -480,7 +451,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { )} - {t('(Press Esc to close)')} + + {t('Enter to select, ↑↓ to navigate, Esc to close')} + ); diff --git a/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx b/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx deleted file mode 100644 index 63c85f972d..0000000000 --- a/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from 'ink-testing-library'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ModelSwitchDialog, VisionSwitchOutcome } from './ModelSwitchDialog.js'; - -// Mock the useKeypress hook -const mockUseKeypress = vi.hoisted(() => vi.fn()); -vi.mock('../hooks/useKeypress.js', () => ({ - useKeypress: mockUseKeypress, -})); - -// Mock the RadioButtonSelect component -const mockRadioButtonSelect = vi.hoisted(() => vi.fn()); -vi.mock('./shared/RadioButtonSelect.js', () => ({ - RadioButtonSelect: mockRadioButtonSelect, -})); - -describe('ModelSwitchDialog', () => { - const mockOnSelect = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - - // Mock RadioButtonSelect to return a simple div - mockRadioButtonSelect.mockReturnValue( - React.createElement('div', { 'data-testid': 'radio-select' }), - ); - }); - - it('should setup RadioButtonSelect with correct options', () => { - render(); - - const expectedItems = [ - { - key: 'switch-once', - label: 'Switch for this request only', - value: VisionSwitchOutcome.SwitchOnce, - }, - { - key: 'switch-session', - label: 'Switch session to vision model', - value: VisionSwitchOutcome.SwitchSessionToVL, - }, - { - key: 'continue', - label: 'Continue with current model', - value: VisionSwitchOutcome.ContinueWithCurrentModel, - }, - ]; - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.items).toEqual(expectedItems); - expect(callArgs.initialIndex).toBe(0); - expect(callArgs.isFocused).toBe(true); - }); - - it('should call onSelect when an option is selected', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(typeof callArgs.onSelect).toBe('function'); - - // Simulate selection of "Switch for this request only" - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.SwitchOnce); - - expect(mockOnSelect).toHaveBeenCalledWith(VisionSwitchOutcome.SwitchOnce); - }); - - it('should call onSelect with SwitchSessionToVL when second option is selected', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.SwitchSessionToVL, - ); - }); - - it('should call onSelect with ContinueWithCurrentModel when third option is selected', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should setup escape key handler to call onSelect with ContinueWithCurrentModel', () => { - render(); - - expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), { - isActive: true, - }); - - // Simulate escape key press - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - keypressHandler({ name: 'escape' }); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should not call onSelect for non-escape keys', () => { - render(); - - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - keypressHandler({ name: 'enter' }); - - expect(mockOnSelect).not.toHaveBeenCalled(); - }); - - it('should set initial index to 0 (first option)', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.initialIndex).toBe(0); - }); - - describe('VisionSwitchOutcome enum', () => { - it('should have correct enum values', () => { - expect(VisionSwitchOutcome.SwitchOnce).toBe('once'); - expect(VisionSwitchOutcome.SwitchSessionToVL).toBe('session'); - expect(VisionSwitchOutcome.ContinueWithCurrentModel).toBe('persist'); - }); - }); - - it('should handle multiple onSelect calls correctly', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - - // Call multiple times - onSelectCallback(VisionSwitchOutcome.SwitchOnce); - onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL); - onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel); - - expect(mockOnSelect).toHaveBeenCalledTimes(3); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 1, - VisionSwitchOutcome.SwitchOnce, - ); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 2, - VisionSwitchOutcome.SwitchSessionToVL, - ); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 3, - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should pass isFocused prop to RadioButtonSelect', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.isFocused).toBe(true); - }); - - it('should handle escape key multiple times', () => { - render(); - - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - - // Call escape multiple times - keypressHandler({ name: 'escape' }); - keypressHandler({ name: 'escape' }); - - expect(mockOnSelect).toHaveBeenCalledTimes(2); - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); -}); diff --git a/packages/cli/src/ui/components/ModelSwitchDialog.tsx b/packages/cli/src/ui/components/ModelSwitchDialog.tsx deleted file mode 100644 index 97bfc53a31..0000000000 --- a/packages/cli/src/ui/components/ModelSwitchDialog.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { Colors } from '../colors.js'; -import { - RadioButtonSelect, - type RadioSelectItem, -} from './shared/RadioButtonSelect.js'; -import { useKeypress } from '../hooks/useKeypress.js'; - -export enum VisionSwitchOutcome { - SwitchOnce = 'once', - SwitchSessionToVL = 'session', - ContinueWithCurrentModel = 'persist', -} - -export interface ModelSwitchDialogProps { - onSelect: (outcome: VisionSwitchOutcome) => void; -} - -export const ModelSwitchDialog: React.FC = ({ - onSelect, -}) => { - useKeypress( - (key) => { - if (key.name === 'escape') { - onSelect(VisionSwitchOutcome.ContinueWithCurrentModel); - } - }, - { isActive: true }, - ); - - const options: Array> = [ - { - key: 'switch-once', - label: 'Switch for this request only', - value: VisionSwitchOutcome.SwitchOnce, - }, - { - key: 'switch-session', - label: 'Switch session to vision model', - value: VisionSwitchOutcome.SwitchSessionToVL, - }, - { - key: 'continue', - label: 'Continue with current model', - value: VisionSwitchOutcome.ContinueWithCurrentModel, - }, - ]; - - const handleSelect = (outcome: VisionSwitchOutcome) => { - onSelect(outcome); - }; - - return ( - - - Vision Model Switch Required - - Your message contains an image, but the current model doesn't - support vision. - - How would you like to proceed? - - - - - - - - Press Enter to select, Esc to cancel - - - ); -}; diff --git a/packages/cli/src/ui/components/Tips.test.ts b/packages/cli/src/ui/components/Tips.test.ts new file mode 100644 index 0000000000..dd2c25ea9d --- /dev/null +++ b/packages/cli/src/ui/components/Tips.test.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { selectWeightedTip } from './Tips.js'; + +describe('selectWeightedTip', () => { + const tips = [ + { text: 'tip-a', weight: 1 }, + { text: 'tip-b', weight: 3 }, + { text: 'tip-c', weight: 1 }, + ]; + + it('returns a valid tip text', () => { + const result = selectWeightedTip(tips); + expect(['tip-a', 'tip-b', 'tip-c']).toContain(result); + }); + + it('selects the first tip when random is near zero', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + expect(selectWeightedTip(tips)).toBe('tip-a'); + vi.restoreAllMocks(); + }); + + it('selects the weighted tip when random falls in its range', () => { + // Total weight = 5. tip-a covers [0,1), tip-b covers [1,4), tip-c covers [4,5) + // Math.random() * 5 = 2.0 falls in tip-b's range + vi.spyOn(Math, 'random').mockReturnValue(0.4); // 0.4 * 5 = 2.0 + expect(selectWeightedTip(tips)).toBe('tip-b'); + vi.restoreAllMocks(); + }); + + it('selects the last tip when random is near max', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.99); + expect(selectWeightedTip(tips)).toBe('tip-c'); + vi.restoreAllMocks(); + }); + + it('respects weight distribution over many samples', () => { + const counts: Record = { + 'tip-a': 0, + 'tip-b': 0, + 'tip-c': 0, + }; + const iterations = 10000; + for (let i = 0; i < iterations; i++) { + const result = selectWeightedTip(tips); + counts[result]!++; + } + // tip-b (weight 3) should appear roughly 3x as often as tip-a or tip-c (weight 1) + // With 10k iterations, we expect: tip-a ~2000, tip-b ~6000, tip-c ~2000 + expect(counts['tip-b']!).toBeGreaterThan(counts['tip-a']! * 2); + expect(counts['tip-b']!).toBeGreaterThan(counts['tip-c']! * 2); + }); + + it('handles single tip', () => { + expect(selectWeightedTip([{ text: 'only', weight: 1 }])).toBe('only'); + }); +}); diff --git a/packages/cli/src/ui/components/Tips.tsx b/packages/cli/src/ui/components/Tips.tsx index d1b6a71bfd..f85184a194 100644 --- a/packages/cli/src/ui/components/Tips.tsx +++ b/packages/cli/src/ui/components/Tips.tsx @@ -9,7 +9,9 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { t } from '../../i18n/index.js'; -const startupTips = [ +type Tip = string | { text: string; weight: number }; + +const startupTips: Tip[] = [ 'Use /compress when the conversation gets long to summarize history and free up context.', 'Start a fresh idea with /clear or /new; the previous session stays available in history.', 'Use /bug to submit issues to the maintainers when something goes off.', @@ -20,13 +22,34 @@ const startupTips = [ process.platform === 'win32' ? 'You can switch permission mode quickly with Tab or /approval-mode.' : 'You can switch permission mode quickly with Shift+Tab or /approval-mode.', -] as const; + { + text: 'Try /insight to generate personalized insights from your chat history.', + weight: 3, + }, +]; + +function tipText(tip: Tip): string { + return typeof tip === 'string' ? tip : tip.text; +} + +function tipWeight(tip: Tip): number { + return typeof tip === 'string' ? 1 : tip.weight; +} + +export function selectWeightedTip(tips: Tip[]): string { + const totalWeight = tips.reduce((sum, tip) => sum + tipWeight(tip), 0); + let random = Math.random() * totalWeight; + for (const tip of tips) { + random -= tipWeight(tip); + if (random <= 0) { + return tipText(tip); + } + } + return tipText(tips[tips.length - 1]!); +} export const Tips: React.FC = () => { - const selectedTip = useMemo(() => { - const randomIndex = Math.floor(Math.random() * startupTips.length); - return startupTips[randomIndex]; - }, []); + const selectedTip = useMemo(() => selectWeightedTip(startupTips), []); return ( diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx index fb03fbef14..af036237a1 100644 --- a/packages/cli/src/ui/components/messages/InfoMessage.tsx +++ b/packages/cli/src/ui/components/messages/InfoMessage.tsx @@ -29,7 +29,7 @@ export const InfoMessage: React.FC = ({ text }) => { - + diff --git a/packages/cli/src/ui/components/messages/InsightProgressMessage.tsx b/packages/cli/src/ui/components/messages/InsightProgressMessage.tsx new file mode 100644 index 0000000000..4115b3899f --- /dev/null +++ b/packages/cli/src/ui/components/messages/InsightProgressMessage.tsx @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import type { InsightProgressProps } from '../../types.js'; +import Spinner from 'ink-spinner'; + +interface InsightProgressMessageProps { + progress: InsightProgressProps; +} + +export const InsightProgressMessage: React.FC = ({ + progress, +}) => { + const { stage, progress: percent, isComplete, error } = progress; + const width = 30; + const completedWidth = Math.round((percent / 100) * width); + const remainingWidth = width - completedWidth; + + const bar = + '█'.repeat(Math.max(0, completedWidth)) + + '░'.repeat(Math.max(0, remainingWidth)); + + if (error) { + return ( + + ✕ {stage} + {error} + + ); + } + + if (isComplete) { + return ( + + ✓ {stage} + + ); + } + + return ( + + + + + + {bar} + + {stage} + {progress.detail ? ` (${progress.detail})` : ''} + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 7bfe9a962b..b285b0a355 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -330,7 +330,7 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( - + {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( diff --git a/packages/cli/src/ui/components/messages/WarningMessage.tsx b/packages/cli/src/ui/components/messages/WarningMessage.tsx index 4bc2c899cd..589ca4b079 100644 --- a/packages/cli/src/ui/components/messages/WarningMessage.tsx +++ b/packages/cli/src/ui/components/messages/WarningMessage.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { Colors } from '../../colors.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; +import { theme } from '../../semantic-colors.js'; interface WarningMessageProps { text: string; @@ -24,7 +25,7 @@ export const WarningMessage: React.FC = ({ text }) => { - + diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 13e51ece31..c4e1926090 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -36,6 +36,7 @@ import { MODIFIER_ALT_BIT, MODIFIER_CTRL_BIT, } from '../utils/platformConstants.js'; +import { clipboardHasImage } from '../utils/clipboardUtils.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; @@ -54,6 +55,7 @@ export interface Key { paste: boolean; sequence: string; kittyProtocol?: boolean; + pasteImage?: boolean; } export type KeypressHandler = (key: Key) => void; @@ -390,7 +392,7 @@ export function KeypressProvider({ } }; - const handleKeypress = (_: unknown, key: Key) => { + const handleKeypress = async (_: unknown, key: Key) => { if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { return; } @@ -400,14 +402,28 @@ export function KeypressProvider({ } if (key.name === 'paste-end') { isPaste = false; - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); + if (pasteBuffer.toString().length > 0) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteBuffer.toString(), + }); + } else { + const hasImage = await clipboardHasImage(); + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + pasteImage: hasImage, + sequence: pasteBuffer.toString(), + }); + } + pasteBuffer = Buffer.alloc(0); return; } @@ -722,6 +738,7 @@ export function KeypressProvider({ }; let rl: readline.Interface; + if (usePassthrough) { rl = readline.createInterface({ input: keypressStream, diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e4cb850032..1965ceb264 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -15,8 +15,8 @@ import { type ApprovalMode, } from '@qwen-code/qwen-code-core'; import { type SettingScope } from '../../config/settings.js'; +import { type CodingPlanRegion } from '../../constants/codingPlan.js'; import type { AuthState } from '../types.js'; -import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; // OpenAICredentials type (previously imported from OpenAIKeyPrompt) export interface OpenAICredentials { apiKey: string; @@ -40,7 +40,10 @@ export interface UIActions { authType: AuthType | undefined, credentials?: OpenAICredentials, ) => Promise; - handleCodingPlanSubmit: (apiKey: string) => Promise; + handleCodingPlanSubmit: ( + apiKey: string, + region?: CodingPlanRegion, + ) => Promise; setAuthState: (state: AuthState) => void; onAuthError: (error: string | null) => void; cancelAuthentication: () => void; @@ -64,8 +67,6 @@ export interface UIActions { refreshStatic: () => void; handleFinalSubmit: (value: string) => void; handleClearScreen: () => void; - // Vision switch dialog - handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void; // Welcome back dialog handleWelcomeBackSelection: (choice: 'continue' | 'restart') => void; handleWelcomeBackClose: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index f8d52faa12..9d1a21e831 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -115,8 +115,6 @@ export interface UIState { extensionsUpdateState: Map; activePtyId: number | undefined; embeddedShellFocused: boolean; - // Vision switch dialog - isVisionSwitchDialogOpen: boolean; // Welcome back dialog showWelcomeBackDialog: boolean; welcomeBackInfo: { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index f3231479b9..31c8092ebb 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -11,6 +11,7 @@ import type { Config } from '@qwen-code/qwen-code-core'; import { getErrorMessage, isNodeError, + Storage, unescapePath, readManyFiles, } from '@qwen-code/qwen-code-core'; @@ -181,7 +182,17 @@ export async function handleAtCommand({ // Check if path should be ignored based on filtering options const workspaceContext = config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(pathName)) { + + // Check if path is in project temp directory + const projectTempDir = Storage.getGlobalTempDir(); + const absolutePathName = path.isAbsolute(pathName) + ? pathName + : path.resolve(workspaceContext.getDirectories()[0] || '', pathName); + + if ( + !absolutePathName.startsWith(projectTempDir) && + !workspaceContext.isPathWithinWorkspace(pathName) + ) { onDebugMessage( `Path ${pathName} is not in the workspace and will be skipped.`, ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 42ce40993e..c48653970f 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -88,6 +88,10 @@ vi.mock('../../utils/cleanup.js', () => ({ runExitCleanup: mockRunExitCleanup, })); +vi.mock('./useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + function createTestCommand( overrides: Partial, kind: CommandKind = CommandKind.BUILT_IN, @@ -143,6 +147,7 @@ describe('useSlashCommandProcessor', () => { mockLoadHistory, vi.fn(), // refreshStatic vi.fn(), // toggleVimEnabled + false, // isProcessing setIsProcessing, vi.fn(), // setGeminiMdFileCount { @@ -151,9 +156,19 @@ describe('useSlashCommandProcessor', () => { openEditorDialog: vi.fn(), openSettingsDialog: vi.fn(), openModelDialog: mockOpenModelDialog, + openPermissionsDialog: vi.fn(), + openApprovalModeDialog: vi.fn(), + openResumeDialog: vi.fn(), quit: mockSetQuittingMessages, setDebugMessage: vi.fn(), + dispatchExtensionStateUpdate: vi.fn(), + addConfirmUpdateExtensionRequest: vi.fn(), + openSubagentCreateDialog: vi.fn(), + openAgentsManagerDialog: vi.fn(), }, + new Map(), // extensionsUpdateState + true, // isConfigInitialized + null, // logger ), ); @@ -459,7 +474,7 @@ describe('useSlashCommandProcessor', () => { name: 'loadwiththoughts', action: vi.fn().mockResolvedValue({ type: 'load_history', - history: [{ type: MessageType.MODEL, text: 'response' }], + history: [{ type: MessageType.GEMINI, text: 'response' }], clientHistory: historyWithThoughts, }), }); @@ -904,18 +919,29 @@ describe('useSlashCommandProcessor', () => { mockClearItems, mockLoadHistory, vi.fn(), // refreshStatic - vi.fn(), // onDebugMessage - vi.fn(), // openThemeDialog - mockOpenAuthDialog, - vi.fn(), // openEditorDialog - mockSetQuittingMessages, - vi.fn(), // openSettingsDialog - vi.fn(), // openModelSelectionDialog - vi.fn(), // openSubagentCreateDialog - vi.fn(), // openAgentsManagerDialog vi.fn(), // toggleVimEnabled + false, // isProcessing vi.fn(), // setIsProcessing vi.fn(), // setGeminiMdFileCount + { + openAuthDialog: mockOpenAuthDialog, + openThemeDialog: mockOpenThemeDialog, + openEditorDialog: vi.fn(), + openSettingsDialog: vi.fn(), + openModelDialog: vi.fn(), + openPermissionsDialog: vi.fn(), + openApprovalModeDialog: vi.fn(), + openResumeDialog: vi.fn(), + quit: mockSetQuittingMessages, + setDebugMessage: vi.fn(), + dispatchExtensionStateUpdate: vi.fn(), + addConfirmUpdateExtensionRequest: vi.fn(), + openSubagentCreateDialog: vi.fn(), + openAgentsManagerDialog: vi.fn(), + }, + new Map(), // extensionsUpdateState + true, // isConfigInitialized + null, // logger ), ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 59ff06bcff..80c6bec350 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useMemo, useEffect, useState } from 'react'; +import { useCallback, useMemo, useEffect, useRef, useState } from 'react'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { @@ -35,6 +35,7 @@ import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; import { clearScreen } from '../../utils/stdioHelpers.js'; +import { useKeypress } from './useKeypress.js'; import { type ExtensionUpdateAction, type ExtensionUpdateStatus, @@ -90,6 +91,7 @@ export const useSlashCommandProcessor = ( loadHistory: UseHistoryManagerReturn['loadHistory'], refreshStatic: () => void, toggleVimEnabled: () => Promise, + isProcessing: boolean, setIsProcessing: (isProcessing: boolean) => void, setGeminiMdFileCount: (count: number) => void, actions: SlashCommandProcessorActions, @@ -131,6 +133,34 @@ export const useSlashCommandProcessor = ( null, ); + // AbortController for cancelling async slash commands via ESC + const abortControllerRef = useRef(null); + + const cancelSlashCommand = useCallback(() => { + if (!abortControllerRef.current) { + return; + } + abortControllerRef.current.abort(); + addItem( + { + type: MessageType.INFO, + text: 'Command cancelled.', + }, + Date.now(), + ); + setPendingItem(null); + setIsProcessing(false); + }, [addItem, setIsProcessing]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + cancelSlashCommand(); + } + }, + { isActive: isProcessing }, + ); + const pendingHistoryItems = useMemo(() => { const items: HistoryItemWithoutId[] = []; if (pendingItem != null) { @@ -181,6 +211,11 @@ export const useSlashCommandProcessor = ( type: 'summary', summary: message.summary, }; + } else if (message.type === MessageType.INSIGHT_PROGRESS) { + historyItemContent = { + type: 'insight_progress', + progress: message.progress, + }; } else { historyItemContent = { type: message.type, @@ -319,6 +354,10 @@ export const useSlashCommandProcessor = ( setIsProcessing(true); + // Create a new AbortController for this command execution + const abortController = new AbortController(); + abortControllerRef.current = abortController; + const userMessageTimestamp = Date.now(); addItemWithRecording( { type: MessageType.USER, text: trimmed }, @@ -352,6 +391,7 @@ export const useSlashCommandProcessor = ( args, }, overwriteConfirmed, + abortSignal: abortController.signal, }; // If a one-time list is provided for a "Proceed" action, temporarily @@ -365,10 +405,27 @@ export const useSlashCommandProcessor = ( ]), }; } - const result = await commandToExecute.action( - fullCommandContext, - args, - ); + // Race the command action against the abort signal so that + // ESC cancellation immediately unblocks the await chain. + // Without this, commands like /compress whose underlying + // operation (tryCompressChat) doesn't accept an AbortSignal + // would keep submitQuery stuck until the operation completes. + const abortPromise = new Promise((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => resolve(undefined), + { once: true }, + ); + }); + const result = await Promise.race([ + commandToExecute.action(fullCommandContext, args), + abortPromise, + ]); + + // If the command was cancelled via ESC while executing, skip result processing + if (abortController.signal.aborted) { + return { type: 'handled' }; + } if (result) { switch (result.type) { @@ -561,6 +618,10 @@ export const useSlashCommandProcessor = ( return { type: 'handled' }; } catch (e: unknown) { + // If cancelled via ESC, the cancelSlashCommand callback already handled cleanup + if (abortController.signal.aborted) { + return { type: 'handled' }; + } hasError = true; if (config) { const event = makeSlashCommandEvent({ diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index a004fbdcb7..3ddaf42e65 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -7,33 +7,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { useCodingPlanUpdates } from './useCodingPlanUpdates.js'; -import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js'; +import { + CODING_PLAN_ENV_KEY, + getCodingPlanConfig, + CodingPlanRegion, +} from '../../constants/codingPlan.js'; import { AuthType } from '@qwen-code/qwen-code-core'; -// Mock the constants module -vi.mock('../../constants/codingPlan.js', async () => { - const actual = await vi.importActual('../../constants/codingPlan.js'); - return { - ...actual, - CODING_PLAN_VERSION: 'test-version-hash', - CODING_PLAN_MODELS: [ - { - id: 'test-model-1', - name: 'Test Model 1', - baseUrl: 'https://test.example.com/v1', - description: 'Test model 1', - envKey: 'BAILIAN_CODING_PLAN_API_KEY', - }, - { - id: 'test-model-2', - name: 'Test Model 2', - baseUrl: 'https://test.example.com/v1', - description: 'Test model 2', - envKey: 'BAILIAN_CODING_PLAN_API_KEY', - }, - ], - }; -}); +// Get region configs for testing +const chinaConfig = getCodingPlanConfig(CodingPlanRegion.CHINA); +const globalConfig = getCodingPlanConfig(CodingPlanRegion.GLOBAL); describe('useCodingPlanUpdates', () => { const mockSettings = { @@ -50,6 +33,7 @@ describe('useCodingPlanUpdates', () => { const mockConfig = { reloadModelProvidersConfig: vi.fn(), refreshAuth: vi.fn(), + getModel: vi.fn().mockReturnValue('qwen-max'), }; const mockAddItem = vi.fn(); @@ -74,8 +58,28 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeUndefined(); }); - it('should not show update prompt when versions match', () => { - mockSettings.merged.codingPlan = { version: 'test-version-hash' }; + it('should not show update prompt when China region versions match', () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: chinaConfig.version, + }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.codingPlanUpdateRequest).toBeUndefined(); + }); + + it('should not show update prompt when Global region versions match', () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.GLOBAL, + version: globalConfig.version, + }; const { result } = renderHook(() => useCodingPlanUpdates( @@ -88,8 +92,11 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeUndefined(); }); - it('should show update prompt when versions differ', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + it('should default to China region when region is not specified', async () => { + // No region specified, should default to China + mockSettings.merged.codingPlan = { + version: 'old-version-hash', + }; const { result } = renderHook(() => useCodingPlanUpdates( @@ -103,21 +110,70 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeDefined(); }); + // Should prompt for China region since it defaults to China expect(result.current.codingPlanUpdateRequest?.prompt).toContain( - 'New model configurations', + chinaConfig.regionName, + ); + }); + + it('should show update prompt when China region versions differ', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + expect(result.current.codingPlanUpdateRequest?.prompt).toContain( + chinaConfig.regionName, + ); + }); + + it('should show update prompt when Global region versions differ', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.GLOBAL, + version: 'old-version-hash', + }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + expect(result.current.codingPlanUpdateRequest?.prompt).toContain( + globalConfig.regionName, ); }); }); describe('update execution', () => { - it('should execute update when user confirms', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + it('should execute China region update when user confirms', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; mockSettings.merged.modelProviders = { [AuthType.USE_OPENAI]: [ { - id: 'test-model-1', - baseUrl: 'https://test.example.com/v1', + id: 'test-model-china-1', + baseUrl: chinaConfig.baseUrl, envKey: CODING_PLAN_ENV_KEY, }, { @@ -146,33 +202,112 @@ describe('useCodingPlanUpdates', () => { // Wait for async update to complete await waitFor(() => { - // Should update model providers (at least 2 calls: modelProviders + version) + // Should update model providers (at least 2 calls: modelProviders + version + region) expect(mockSettings.setValue).toHaveBeenCalled(); }); - // Should update version + // Should update version with correct hash expect(mockSettings.setValue).toHaveBeenCalledWith( expect.anything(), 'codingPlan.version', - 'test-version-hash', + chinaConfig.version, + ); + + // Should update region + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.region', + CodingPlanRegion.CHINA, ); // Should reload and refresh auth expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); - // Should show success message + // Should show success message with region info expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: expect.stringContaining('updated successfully'), + text: expect.stringContaining(chinaConfig.regionName), + }), + expect.any(Number), + ); + }); + + it('should execute Global region update when user confirms', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.GLOBAL, + version: 'old-version-hash', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'test-model-global-1', + baseUrl: globalConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_API_KEY', + }, + ], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + // Confirm the update + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Wait for async update to complete + await waitFor(() => { + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Should update version with correct hash (single version field) + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.version', + globalConfig.version, + ); + + // Should update region + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.region', + CodingPlanRegion.GLOBAL, + ); + + // Should reload and refresh auth + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + + // Should show success message with Global region info + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining(globalConfig.regionName), }), expect.any(Number), ); }); it('should not execute update when user declines', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const { result } = renderHook(() => useCodingPlanUpdates( @@ -194,9 +329,103 @@ describe('useCodingPlanUpdates', () => { expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled(); }); + it('should replace all Coding Plan configs during update (mutually exclusive)', async () => { + // Since regions are mutually exclusive, when updating one region, + // all Coding Plan configs should be replaced (not preserving other region configs) + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; + const chinaModelConfig = { + id: 'test-model-china-1', + baseUrl: chinaConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }; + const globalModelConfig = { + id: 'test-model-global-1', + baseUrl: globalConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }; + const customConfig = { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_API_KEY', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + chinaModelConfig, + globalModelConfig, + customConfig, + ], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Wait for async update to complete + await waitFor(() => { + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Get the updated configs passed to setValue + const setValueCalls = mockSettings.setValue.mock.calls; + const modelProvidersCall = setValueCalls.find((call: unknown[]) => + (call[1] as string).includes('modelProviders'), + ); + + expect(modelProvidersCall).toBeDefined(); + const updatedConfigs = modelProvidersCall![2] as Array< + Record + >; + + // Should have new China configs + custom config only (global config removed since regions are mutually exclusive) + // The China template has 8 models, so we expect 8 (from template) + 1 (custom) = 9 + // Note: description field has been removed, only name field contains the branding + expect(updatedConfigs.length).toBe(9); + + // Should NOT contain the Global config (mutually exclusive) + expect( + updatedConfigs.some( + (c: Record) => c['baseUrl'] === globalConfig.baseUrl, + ), + ).toBe(false); + + // Should contain the custom config + expect( + updatedConfigs.some( + (c: Record) => c['id'] === 'custom-model', + ), + ).toBe(true); + + // All configs should use the unified env key + updatedConfigs.forEach((config) => { + if (config['envKey'] === CODING_PLAN_ENV_KEY) { + expect(config['baseUrl']).toBe(chinaConfig.baseUrl); + } + }); + + // Should reload and refresh auth + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + }); + it('should preserve non-Coding Plan configs during update', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const customConfig = { id: 'custom-model', baseUrl: 'https://custom.example.com', @@ -205,8 +434,8 @@ describe('useCodingPlanUpdates', () => { mockSettings.merged.modelProviders = { [AuthType.USE_OPENAI]: [ { - id: 'test-model-1', - baseUrl: 'https://test.example.com/v1', + id: 'test-model-china-1', + baseUrl: chinaConfig.baseUrl, envKey: CODING_PLAN_ENV_KEY, }, customConfig, @@ -233,10 +462,41 @@ describe('useCodingPlanUpdates', () => { // Should preserve custom config - verify setValue was called expect(mockSettings.setValue).toHaveBeenCalled(); }); + + // Get the updated configs passed to setValue + const setValueCalls = mockSettings.setValue.mock.calls; + const modelProvidersCall = setValueCalls.find((call: unknown[]) => + (call[1] as string).includes('modelProviders'), + ); + + // Should preserve custom config + expect(modelProvidersCall).toBeDefined(); + const updatedConfigs = modelProvidersCall![2] as Array< + Record + >; + expect( + updatedConfigs.some( + (c: Record) => c['id'] === 'custom-model', + ), + ).toBe(true); }); - it('should handle missing API key error', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + it('should handle update errors gracefully', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'test-model-china-1', + baseUrl: chinaConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }, + ], + }; + // Simulate an error during refreshAuth + mockConfig.refreshAuth.mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => useCodingPlanUpdates( @@ -253,18 +513,23 @@ describe('useCodingPlanUpdates', () => { await result.current.codingPlanUpdateRequest!.onConfirm(true); // Should show error message - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - }), - expect.any(Number), - ); + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + expect.any(Number), + ); + }); }); }); describe('dismissUpdate', () => { it('should clear update request when dismissed', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const { result } = renderHook(() => useCodingPlanUpdates( diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts index 85584def89..dee70e0359 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -10,9 +10,10 @@ import { AuthType } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { - CODING_PLAN_MODELS, + isCodingPlanConfig, + getCodingPlanConfig, + CodingPlanRegion, CODING_PLAN_ENV_KEY, - CODING_PLAN_VERSION, } from '../../constants/codingPlan.js'; import { t } from '../../i18n/index.js'; @@ -21,20 +22,6 @@ export interface CodingPlanUpdateRequest { onConfirm: (confirmed: boolean) => void; } -/** - * Checks if a config is a Coding Plan configuration by matching baseUrl and envKey. - * This ensures only configs from the Coding Plan provider are identified. - */ -function isCodingPlanConfig(config: { - baseUrl?: string; - envKey?: string; -}): boolean { - return ( - config.envKey === CODING_PLAN_ENV_KEY && - CODING_PLAN_MODELS.some((template) => template.baseUrl === config.baseUrl) - ); -} - /** * Hook for detecting and handling Coding Plan template updates. * Compares the persisted version with the current template version @@ -55,134 +42,148 @@ export function useCodingPlanUpdates( /** * Execute the Coding Plan configuration update. * Removes old Coding Plan configs and replaces them with new ones from the template. + * Uses the region from settings.codingPlan.region (defaults to CHINA). */ - const executeUpdate = useCallback(async () => { - try { - const persistScope = getPersistScopeForModelSelection(settings); - - // Get current configs - const currentConfigs = - ( - settings.merged.modelProviders as - | Record>> - | undefined - )?.[AuthType.USE_OPENAI] || []; - - // Filter out Coding Plan configs (keep user custom configs) - const nonCodingPlanConfigs = currentConfigs.filter( - (cfg) => - !isCodingPlanConfig({ - baseUrl: cfg['baseUrl'] as string | undefined, - envKey: cfg['envKey'] as string | undefined, - }), - ); - - // Generate new configs from template with the stored API key - const apiKey = process.env[CODING_PLAN_ENV_KEY]; - if (!apiKey) { - throw new Error( - t( - 'Coding Plan API key not found. Please re-authenticate with Coding Plan.', - ), + const executeUpdate = useCallback( + async (region: CodingPlanRegion = CodingPlanRegion.CHINA) => { + try { + const persistScope = getPersistScopeForModelSelection(settings); + + // Get current configs + const currentConfigs = + ( + settings.merged.modelProviders as + | Record>> + | undefined + )?.[AuthType.USE_OPENAI] || []; + + // Filter out all Coding Plan configs (since they are mutually exclusive) + // Keep only non-Coding-Plan user custom configs + const nonCodingPlanConfigs = currentConfigs.filter( + (cfg) => + !isCodingPlanConfig( + cfg['baseUrl'] as string | undefined, + cfg['envKey'] as string | undefined, + ), ); - } - const newConfigs = CODING_PLAN_MODELS.map((templateConfig) => ({ - ...templateConfig, - envKey: CODING_PLAN_ENV_KEY, - })); - - // Combine: new Coding Plan configs at the front, user configs preserved - const updatedConfigs = [ - ...newConfigs, - ...(nonCodingPlanConfigs as Array>), - ] as Array>; - - // Persist updated model providers - settings.setValue( - persistScope, - `modelProviders.${AuthType.USE_OPENAI}`, - updatedConfigs, - ); - - // Update the version - settings.setValue( - persistScope, - 'codingPlan.version', - CODING_PLAN_VERSION, - ); - - // Hot-reload model providers configuration - const updatedModelProviders = { - ...(settings.merged.modelProviders as - | Record - | undefined), - [AuthType.USE_OPENAI]: updatedConfigs, - }; - config.reloadModelProvidersConfig( - updatedModelProviders as unknown as ModelProvidersConfig, - ); - - // Refresh auth with the new configuration - await config.refreshAuth(AuthType.USE_OPENAI); - - addItem( - { - type: 'info', - text: t( - 'Coding Plan configuration updated successfully. New models are now available.', - ), - }, - Date.now(), - ); - - return true; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - addItem( - { - type: 'error', - text: t('Failed to update Coding Plan configuration: {{message}}', { - message: errorMessage, - }), - }, - Date.now(), - ); - return false; - } - }, [settings, config, addItem]); + // Get the configuration for the current region + const { template, version, regionName } = getCodingPlanConfig(region); + + // Generate new configs from template + const newConfigs = template.map((templateConfig) => ({ + ...templateConfig, + envKey: CODING_PLAN_ENV_KEY, + })); + + // Combine: new Coding Plan configs at the front, user configs preserved + const updatedConfigs = [ + ...newConfigs, + ...(nonCodingPlanConfigs as Array>), + ] as Array>; + + // Hot-reload model providers configuration first (in-memory only) + const updatedModelProviders = { + ...(settings.merged.modelProviders as + | Record + | undefined), + [AuthType.USE_OPENAI]: updatedConfigs, + }; + config.reloadModelProvidersConfig( + updatedModelProviders as unknown as ModelProvidersConfig, + ); + + // Refresh auth with the new configuration + // This validates the configuration before persisting + await config.refreshAuth(AuthType.USE_OPENAI); + + // Persist to settings only after successful auth refresh + settings.setValue( + persistScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + + // Update the version (single version field for backward compatibility) + settings.setValue(persistScope, 'codingPlan.version', version); + + // Update the region + settings.setValue(persistScope, 'codingPlan.region', region); + + const activeModel = config.getModel(); + + addItem( + { + type: 'info', + text: t( + '{{region}} configuration updated successfully. Model switched to "{{model}}".', + { region: regionName, model: activeModel }, + ), + }, + Date.now(), + ); + + return true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + addItem( + { + type: 'error', + text: t('Failed to update Coding Plan configuration: {{message}}', { + message: errorMessage, + }), + }, + Date.now(), + ); + return false; + } + }, + [settings, config, addItem], + ); /** * Check for version mismatch and prompt user for update if needed. + * Uses the region from settings.codingPlan.region (defaults to CHINA if not set). */ const checkForUpdates = useCallback(() => { - const savedVersion = ( - settings.merged as { codingPlan?: { version?: string } } - ).codingPlan?.version; + const mergedSettings = settings.merged as { + codingPlan?: { + version?: string; + region?: CodingPlanRegion; + }; + }; + + // Get the region (default to CHINA if not set) + const region = mergedSettings.codingPlan?.region ?? CodingPlanRegion.CHINA; + + // Get the saved version for the current region + const savedVersion = mergedSettings.codingPlan?.version; // If no version is stored, user hasn't used Coding Plan yet - skip check if (!savedVersion) { return; } - // If versions match, no update needed - if (savedVersion === CODING_PLAN_VERSION) { - return; + // Get current version for the region + const currentVersion = getCodingPlanConfig(region).version; + + // Check if version matches + if (savedVersion !== currentVersion) { + const { regionName } = getCodingPlanConfig(region); + setUpdateRequest({ + prompt: t( + 'New model configurations are available for {{region}}. Update now?', + { region: regionName }, + ), + onConfirm: async (confirmed: boolean) => { + setUpdateRequest(undefined); + if (confirmed) { + await executeUpdate(region); + } + }, + }); } - - // Version mismatch - prompt user for update - setUpdateRequest({ - prompt: t( - 'New model configurations are available for Bailian Coding Plan. Update now?', - ), - onConfirm: async (confirmed: boolean) => { - setUpdateRequest(undefined); - if (confirmed) { - await executeUpdate(); - } - }, - }); }, [settings, executeUpdate]); // Check for updates on mount diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index edf0e0576b..e855eefc31 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -74,14 +74,6 @@ const mockParseAndFormatApiError = vi.hoisted(() => ); const mockLogApiCancel = vi.hoisted(() => vi.fn()); -// Vision auto-switch mocks (hoisted) -const mockHandleVisionSwitch = vi.hoisted(() => - vi.fn().mockResolvedValue({ shouldProceed: true }), -); -const mockRestoreOriginalModel = vi.hoisted(() => - vi.fn().mockResolvedValue(undefined), -); - vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actualCoreModule = (await importOriginal()) as any; return { @@ -104,13 +96,6 @@ vi.mock('./useReactToolScheduler.js', async (importOriginal) => { }; }); -vi.mock('./useVisionAutoSwitch.js', () => ({ - useVisionAutoSwitch: vi.fn(() => ({ - handleVisionSwitch: mockHandleVisionSwitch, - restoreOriginalModel: mockRestoreOriginalModel, - })), -})); - vi.mock('./shellCommandProcessor.js', () => ({ useShellCommandProcessor: vi.fn().mockReturnValue({ handleShellCommand: vi.fn(), @@ -306,7 +291,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -472,7 +456,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -557,7 +540,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -670,7 +652,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -784,7 +765,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -901,7 +881,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, cancelSubmitSpy, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -945,7 +924,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, vi.fn(), - false, setShellInputFocusedSpy, // Pass the spy here 80, 24, @@ -1273,7 +1251,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1331,7 +1308,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1825,7 +1801,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1881,7 +1856,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1938,7 +1912,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2035,7 +2008,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled vi.fn(), 80, 24, @@ -2090,7 +2062,6 @@ describe('useGeminiStream', () => { vi.fn(), // setModelSwitched vi.fn(), // onEditorClose vi.fn(), // onCancelSubmit - false, // visionModelPreviewEnabled vi.fn(), // setShellInputFocused 80, // terminalWidth 24, // terminalHeight @@ -2164,7 +2135,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2256,7 +2226,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2317,7 +2286,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2418,7 +2386,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled vi.fn(), // setShellInputFocused 80, 24, @@ -2489,7 +2456,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2548,7 +2514,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2736,187 +2701,6 @@ describe('useGeminiStream', () => { }); // --- New tests focused on recent modifications --- - describe('Vision Auto Switch Integration', () => { - it('should call handleVisionSwitch and proceed to send when allowed', async () => { - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true }); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'ok' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('image prompt'); - }); - - await waitFor(() => { - expect(mockHandleVisionSwitch).toHaveBeenCalled(); - expect(mockSendMessageStream).toHaveBeenCalled(); - }); - }); - - it('should gate submission when handleVisionSwitch returns shouldProceed=false', async () => { - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: false }); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('vision-gated'); - }); - - // No call to API, no restoreOriginalModel needed since no override occurred - expect(mockSendMessageStream).not.toHaveBeenCalled(); - expect(mockRestoreOriginalModel).not.toHaveBeenCalled(); - - // Next call allowed (flag reset path) - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true }); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'ok' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - await act(async () => { - await result.current.submitQuery('after-gate'); - }); - await waitFor(() => { - expect(mockSendMessageStream).toHaveBeenCalled(); - }); - }); - }); - - describe('Model restore on completion and errors', () => { - it('should restore model after successful stream completion', async () => { - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'content' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('restore-success'); - }); - - await waitFor(() => { - expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1); - }); - }); - - it('should restore model when an error occurs during streaming', async () => { - const testError = new Error('stream failure'); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'content' }; - throw testError; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('restore-error'); - }); - - await waitFor(() => { - expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1); - }); - }); - }); - describe('Loop Detection Confirmation', () => { beforeEach(() => { // Add mock for getLoopDetectionService to the config diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 5bebbac7e6..2da4eed530 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -35,6 +35,8 @@ import { ToolConfirmationOutcome, logApiCancel, ApiCancelEvent, + isSupportedImageMimeType, + getUnsupportedImageFormatWarning, } from '@qwen-code/qwen-code-core'; import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { @@ -46,7 +48,6 @@ import type { import { StreamingState, MessageType, ToolCallStatus } from '../types.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js'; -import { useVisionAutoSwitch } from './useVisionAutoSwitch.js'; import { handleAtCommand } from './atCommandProcessor.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; import { useStateAndRef } from './useStateAndRef.js'; @@ -68,6 +69,60 @@ import { t } from '../../i18n/index.js'; const debugLogger = createDebugLogger('GEMINI_STREAM'); +/** + * Checks if image parts have supported formats and returns unsupported ones + */ +function checkImageFormatsSupport(parts: PartListUnion): { + hasImages: boolean; + hasUnsupportedFormats: boolean; + unsupportedMimeTypes: string[]; +} { + const unsupportedMimeTypes: string[] = []; + let hasImages = false; + + if (typeof parts === 'string') { + return { + hasImages: false, + hasUnsupportedFormats: false, + unsupportedMimeTypes: [], + }; + } + + const partsArray = Array.isArray(parts) ? parts : [parts]; + + for (const part of partsArray) { + if (typeof part === 'string') continue; + + let mimeType: string | undefined; + + // Check inlineData + if ( + 'inlineData' in part && + part.inlineData?.mimeType?.startsWith('image/') + ) { + hasImages = true; + mimeType = part.inlineData.mimeType; + } + + // Check fileData + if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { + hasImages = true; + mimeType = part.fileData.mimeType; + } + + // Check if the mime type is supported + if (mimeType && !isSupportedImageMimeType(mimeType)) { + unsupportedMimeTypes.push(mimeType); + } + } + + return { + hasImages, + hasUnsupportedFormats: unsupportedMimeTypes.length > 0, + unsupportedMimeTypes, + }; +} + enum StreamProcessingStatus { Completed, UserCancelled, @@ -106,15 +161,9 @@ export const useGeminiStream = ( setModelSwitchedFromQuotaError: React.Dispatch>, onEditorClose: () => void, onCancelSubmit: () => void, - visionModelPreviewEnabled: boolean, setShellInputFocused: (value: boolean) => void, terminalWidth: number, terminalHeight: number, - onVisionSwitchRequired?: (query: PartListUnion) => Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); @@ -278,12 +327,6 @@ export const useGeminiStream = ( terminalHeight, ); - const { handleVisionSwitch, restoreOriginalModel } = useVisionAutoSwitch( - config, - addItem, - visionModelPreviewEnabled, - onVisionSwitchRequired, - ); const activePtyId = activeShellPtyId || activeToolPtyId; useEffect(() => { @@ -1028,16 +1071,18 @@ export const useGeminiStream = ( return; } - // Handle vision switch requirement - const visionSwitchResult = await handleVisionSwitch( - queryToSend, - userMessageTimestamp, - options?.isContinuation || false, - ); - - if (!visionSwitchResult.shouldProceed) { - isSubmittingQueryRef.current = false; - return; + // Check image format support for non-continuations + if (!options?.isContinuation) { + const formatCheck = checkImageFormatsSupport(queryToSend); + if (formatCheck.hasUnsupportedFormats) { + addItem( + { + type: MessageType.INFO, + text: getUnsupportedImageFormatWarning(), + }, + userMessageTimestamp, + ); + } } const finalQueryToSend = queryToSend; @@ -1081,10 +1126,6 @@ export const useGeminiStream = ( ); if (processingStatus === StreamProcessingStatus.UserCancelled) { - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); isSubmittingQueryRef.current = false; return; } @@ -1097,17 +1138,7 @@ export const useGeminiStream = ( loopDetectedRef.current = false; handleLoopDetectedEvent(); } - - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); } catch (error: unknown) { - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); - if (error instanceof UnauthorizedError) { onAuthError('Session expired or is unauthorized.'); } else if (!isNodeError(error) || error.name !== 'AbortError') { @@ -1143,8 +1174,6 @@ export const useGeminiStream = ( startNewPrompt, getPromptCount, handleLoopDetectedEvent, - handleVisionSwitch, - restoreOriginalModel, ], ); diff --git a/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts b/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts deleted file mode 100644 index 782986ce94..0000000000 --- a/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts +++ /dev/null @@ -1,874 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import type { Part, PartListUnion } from '@google/genai'; -import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core'; - -// Mock the image format functions from core package -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - isSupportedImageMimeType: vi.fn((mimeType: string) => - [ - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/gif', - 'image/webp', - ].includes(mimeType), - ), - getUnsupportedImageFormatWarning: vi.fn( - () => - 'Only the following image formats are supported: BMP, JPEG, JPG, PNG, TIFF, WEBP, HEIC. Other formats may not work as expected.', - ), - }; -}); -import { - shouldOfferVisionSwitch, - processVisionSwitchOutcome, - getVisionSwitchGuidanceMessage, - useVisionAutoSwitch, -} from './useVisionAutoSwitch.js'; -import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; -import { MessageType } from '../types.js'; -import { getDefaultVisionModel } from '../models/availableModels.js'; - -describe('useVisionAutoSwitch helpers', () => { - describe('shouldOfferVisionSwitch', () => { - it('returns false when authType is not QWEN_OAUTH', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when current model is already a vision model', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'vision-model', - true, - ); - expect(result).toBe(false); - }); - - it('returns true when image parts exist, QWEN_OAUTH, and model is not vision', () => { - const parts: PartListUnion = [ - { text: 'hello' }, - { inlineData: { mimeType: 'image/jpeg', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('detects image when provided as a single Part object (non-array)', () => { - const singleImagePart: PartListUnion = { - fileData: { mimeType: 'image/gif', fileUri: 'file://image.gif' }, - } as Part; - const result = shouldOfferVisionSwitch( - singleImagePart, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('returns false when parts contain no images', () => { - const parts: PartListUnion = [{ text: 'just text' }]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when parts is a plain string', () => { - const parts: PartListUnion = 'plain text'; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when visionModelPreviewEnabled is false', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - false, - ); - expect(result).toBe(false); - }); - - it('returns true when image parts exist in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('returns false when no image parts exist in YOLO mode context', () => { - const parts: PartListUnion = [{ text: 'just text' }]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when already using vision model in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'vision-model', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when authType is not QWEN_OAUTH in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - }); - - describe('processVisionSwitchOutcome', () => { - it('maps SwitchOnce to a one-time model override', () => { - const vl = getDefaultVisionModel(); - const result = processVisionSwitchOutcome(VisionSwitchOutcome.SwitchOnce); - expect(result).toEqual({ modelOverride: vl }); - }); - - it('maps SwitchSessionToVL to a persistent session model', () => { - const vl = getDefaultVisionModel(); - const result = processVisionSwitchOutcome( - VisionSwitchOutcome.SwitchSessionToVL, - ); - expect(result).toEqual({ persistSessionModel: vl }); - }); - - it('maps ContinueWithCurrentModel to empty result', () => { - const result = processVisionSwitchOutcome( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - expect(result).toEqual({}); - }); - }); - - describe('getVisionSwitchGuidanceMessage', () => { - it('returns the expected guidance message', () => { - const vl = getDefaultVisionModel(); - const expected = - 'To use images with your query, you can:\n' + - `â€ĸ Use /model set ${vl} to switch to a vision-capable model\n` + - 'â€ĸ Or remove the image and provide a text description instead'; - expect(getVisionSwitchGuidanceMessage()).toBe(expected); - }); - }); -}); - -describe('useVisionAutoSwitch hook', () => { - type AddItemFn = ( - item: { type: MessageType; text: string }, - ts: number, - ) => any; - - const createMockConfig = ( - authType: AuthType, - initialModel: string, - approvalMode: ApprovalMode = ApprovalMode.DEFAULT, - vlmSwitchMode?: string, - ) => { - let currentModel = initialModel; - const mockConfig: Partial = { - getModel: vi.fn(() => currentModel), - setModel: vi.fn(async (m: string) => { - currentModel = m; - }), - getApprovalMode: vi.fn(() => approvalMode), - getVlmSwitchMode: vi.fn(() => vlmSwitchMode), - getContentGeneratorConfig: vi.fn(() => ({ - authType, - model: currentModel, - apiKey: 'test-key', - vertexai: false, - })), - }; - return mockConfig as Config; - }; - - let addItem: AddItemFn; - - beforeEach(() => { - vi.clearAllMocks(); - addItem = vi.fn(); - }); - - it('returns shouldProceed=true immediately for continuations', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, vi.fn()), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, Date.now(), true); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(addItem).not.toHaveBeenCalled(); - }); - - it('does nothing when authType is not QWEN_OAUTH', async () => { - const config = createMockConfig(AuthType.USE_GEMINI, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 123, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('does nothing when there are no image parts', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [{ text: 'no images here' }]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 456, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('continues with current model when dialog returns empty result', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); // Empty result for ContinueWithCurrentModel - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - const userTs = 1010; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, userTs, false); - }); - - // Should not add any guidance message - expect(addItem).not.toHaveBeenCalledWith( - { type: MessageType.INFO, text: getVisionSwitchGuidanceMessage() }, - userTs, - ); - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('applies a one-time override and returns originalModel, then restores', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig(AuthType.QWEN_OAUTH, initialModel); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ modelOverride: 'coder-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 2020, false); - }); - - expect(res).toEqual({ shouldProceed: true, originalModel: initialModel }); - expect(config.setModel).toHaveBeenCalledWith('coder-model', { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (one-time override)', - }); - - // Now restore - await act(async () => { - await result.current.restoreOriginalModel(); - }); - expect(config.setModel).toHaveBeenLastCalledWith(initialModel, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - }); - - it('persists session model when dialog requests persistence', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ persistSessionModel: 'coder-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 3030, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).toHaveBeenCalledWith('coder-model', { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (session persistent)', - }); - - // Restore should be a no-op since no one-time override was used - await act(async () => { - await result.current.restoreOriginalModel(); - }); - // Last call should still be the persisted model set - expect((config.setModel as any).mock.calls.pop()?.[0]).toBe('coder-model'); - }); - - it('returns shouldProceed=true when dialog returns no special flags', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 4040, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('blocks when dialog throws or is cancelled', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockRejectedValue(new Error('x')); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 5050, false); - }); - expect(res).toEqual({ shouldProceed: false }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does nothing when visionModelPreviewEnabled is false', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - false, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 6060, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - describe('YOLO mode behavior', () => { - it('automatically switches to vision model in YOLO mode without showing dialog', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called in YOLO mode - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 7070, false); - }); - - // Should automatically switch without calling the dialog - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(res).toEqual({ - shouldProceed: true, - originalModel: initialModel, - }); - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - }); - - it('does not switch in YOLO mode when no images are present', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [{ text: 'no images here' }]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 8080, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does not switch in YOLO mode when already using vision model', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'vision-model', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 9090, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('restores original model after YOLO mode auto-switch', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - // First, trigger the auto-switch - await act(async () => { - await result.current.handleVisionSwitch(parts, 10100, false); - }); - - // Verify model was switched - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - - // Now restore the original model - await act(async () => { - await result.current.restoreOriginalModel(); - }); - - // Verify model was restored - expect(config.setModel).toHaveBeenLastCalledWith(initialModel, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - }); - - it('does not switch in YOLO mode when authType is not QWEN_OAUTH', async () => { - const config = createMockConfig( - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 11110, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does not switch in YOLO mode when visionModelPreviewEnabled is false', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - false, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 12120, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('handles multiple image formats in YOLO mode', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { text: 'Here are some images:' }, - { inlineData: { mimeType: 'image/jpeg', data: '...' } }, - { fileData: { mimeType: 'image/png', fileUri: 'file://image.png' } }, - { text: 'Please analyze them.' }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 13130, false); - }); - - expect(res).toEqual({ - shouldProceed: true, - originalModel: initialModel, - }); - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - }); - - describe('VLM switch mode default behavior', () => { - it('should automatically switch once when vlmSwitchMode is "once"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'once', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBe('qwen3-coder-plus'); - expect(config.setModel).toHaveBeenCalledWith('vision-model', { - reason: 'vision_auto_switch', - context: 'Default VLM switch mode: once (one-time override)', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should switch session when vlmSwitchMode is "session"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'session', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); // No original model for session switch - expect(config.setModel).toHaveBeenCalledWith('vision-model', { - reason: 'vision_auto_switch', - context: 'Default VLM switch mode: session (session persistent)', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should continue with current model when vlmSwitchMode is "persist"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'persist', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); - expect(config.setModel).not.toHaveBeenCalled(); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should fall back to user prompt when vlmSwitchMode is not set', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - undefined, // No default mode - ); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ modelOverride: 'vision-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(onVisionSwitchRequired).toHaveBeenCalledWith(parts); - }); - - it('should fall back to persist behavior when vlmSwitchMode has invalid value', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'invalid-value', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); - // For invalid values, it should continue with current model (persist behavior) - expect(config.setModel).not.toHaveBeenCalled(); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts b/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts deleted file mode 100644 index f489c843a6..0000000000 --- a/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { type PartListUnion, type Part } from '@google/genai'; -import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core'; -import { useCallback, useRef } from 'react'; -import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; -import { - getDefaultVisionModel, - isVisionModel, -} from '../models/availableModels.js'; -import { MessageType } from '../types.js'; -import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -import { - isSupportedImageMimeType, - getUnsupportedImageFormatWarning, -} from '@qwen-code/qwen-code-core'; - -/** - * Checks if a PartListUnion contains image parts - */ -function hasImageParts(parts: PartListUnion): boolean { - if (typeof parts === 'string') { - return false; - } - - if (Array.isArray(parts)) { - return parts.some((part) => { - // Skip string parts - if (typeof part === 'string') return false; - return isImagePart(part); - }); - } - - // If it's a single Part (not a string), check if it's an image - if (typeof parts === 'object') { - return isImagePart(parts); - } - - return false; -} - -/** - * Checks if a single Part is an image part - */ -function isImagePart(part: Part): boolean { - // Check for inlineData with image mime type - if ('inlineData' in part && part.inlineData?.mimeType?.startsWith('image/')) { - return true; - } - - // Check for fileData with image mime type - if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { - return true; - } - - return false; -} - -/** - * Checks if image parts have supported formats and returns unsupported ones - */ -function checkImageFormatsSupport(parts: PartListUnion): { - hasImages: boolean; - hasUnsupportedFormats: boolean; - unsupportedMimeTypes: string[]; -} { - const unsupportedMimeTypes: string[] = []; - let hasImages = false; - - if (typeof parts === 'string') { - return { - hasImages: false, - hasUnsupportedFormats: false, - unsupportedMimeTypes: [], - }; - } - - const partsArray = Array.isArray(parts) ? parts : [parts]; - - for (const part of partsArray) { - if (typeof part === 'string') continue; - - let mimeType: string | undefined; - - // Check inlineData - if ( - 'inlineData' in part && - part.inlineData?.mimeType?.startsWith('image/') - ) { - hasImages = true; - mimeType = part.inlineData.mimeType; - } - - // Check fileData - if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { - hasImages = true; - mimeType = part.fileData.mimeType; - } - - // Check if the mime type is supported - if (mimeType && !isSupportedImageMimeType(mimeType)) { - unsupportedMimeTypes.push(mimeType); - } - } - - return { - hasImages, - hasUnsupportedFormats: unsupportedMimeTypes.length > 0, - unsupportedMimeTypes, - }; -} - -/** - * Determines if we should offer vision switch for the given parts, auth type, and current model - */ -export function shouldOfferVisionSwitch( - parts: PartListUnion, - authType: AuthType, - currentModel: string, - visionModelPreviewEnabled: boolean = true, -): boolean { - // Only trigger for qwen-oauth - if (authType !== AuthType.QWEN_OAUTH) { - return false; - } - - // If vision model preview is disabled, never offer vision switch - if (!visionModelPreviewEnabled) { - return false; - } - - // If current model is already a vision model, no need to switch - if (isVisionModel(currentModel)) { - return false; - } - - // Check if the current message contains image parts - return hasImageParts(parts); -} - -/** - * Interface for vision switch result - */ -export interface VisionSwitchResult { - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; -} - -/** - * Processes the vision switch outcome and returns the appropriate result - */ -export function processVisionSwitchOutcome( - outcome: VisionSwitchOutcome, -): VisionSwitchResult { - const vlModelId = getDefaultVisionModel(); - - switch (outcome) { - case VisionSwitchOutcome.SwitchOnce: - return { modelOverride: vlModelId }; - - case VisionSwitchOutcome.SwitchSessionToVL: - return { persistSessionModel: vlModelId }; - - case VisionSwitchOutcome.ContinueWithCurrentModel: - return {}; // Continue with current model, no changes needed - - default: - return {}; // Default to continuing with current model - } -} - -/** - * Gets the guidance message for when vision switch is disallowed - */ -export function getVisionSwitchGuidanceMessage(): string { - const vlModelId = getDefaultVisionModel(); - return `To use images with your query, you can: -â€ĸ Use /model set ${vlModelId} to switch to a vision-capable model -â€ĸ Or remove the image and provide a text description instead`; -} - -/** - * Interface for vision switch handling result - */ -export interface VisionSwitchHandlingResult { - shouldProceed: boolean; - originalModel?: string; -} - -/** - * Custom hook for handling vision model auto-switching - */ -export function useVisionAutoSwitch( - config: Config, - addItem: UseHistoryManagerReturn['addItem'], - visionModelPreviewEnabled: boolean = true, - onVisionSwitchRequired?: (query: PartListUnion) => Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>, -) { - const originalModelRef = useRef(null); - - const handleVisionSwitch = useCallback( - async ( - query: PartListUnion, - userMessageTimestamp: number, - isContinuation: boolean, - ): Promise => { - // Skip vision switch handling for continuations or if no handler provided - if (isContinuation || !onVisionSwitchRequired) { - return { shouldProceed: true }; - } - - const contentGeneratorConfig = config.getContentGeneratorConfig(); - - // Only handle qwen-oauth auth type - if (contentGeneratorConfig?.authType !== AuthType.QWEN_OAUTH) { - return { shouldProceed: true }; - } - - // Check image format support first - const formatCheck = checkImageFormatsSupport(query); - - // If there are unsupported image formats, show warning - if (formatCheck.hasUnsupportedFormats) { - addItem( - { - type: MessageType.INFO, - text: getUnsupportedImageFormatWarning(), - }, - userMessageTimestamp, - ); - // Continue processing but with warning shown - } - - // Check if vision switch is needed - if ( - !shouldOfferVisionSwitch( - query, - contentGeneratorConfig.authType, - config.getModel(), - visionModelPreviewEnabled, - ) - ) { - return { shouldProceed: true }; - } - - // In YOLO mode, automatically switch to vision model without user interaction - if (config.getApprovalMode() === ApprovalMode.YOLO) { - const vlModelId = getDefaultVisionModel(); - originalModelRef.current = config.getModel(); - await config.setModel(vlModelId, { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } - - // Check if there's a default VLM switch mode configured - const defaultVlmSwitchMode = config.getVlmSwitchMode(); - if (defaultVlmSwitchMode) { - // Convert string value to VisionSwitchOutcome enum - let outcome: VisionSwitchOutcome; - switch (defaultVlmSwitchMode) { - case 'once': - outcome = VisionSwitchOutcome.SwitchOnce; - break; - case 'session': - outcome = VisionSwitchOutcome.SwitchSessionToVL; - break; - case 'persist': - outcome = VisionSwitchOutcome.ContinueWithCurrentModel; - break; - default: - // Invalid value, fall back to prompting user - outcome = VisionSwitchOutcome.ContinueWithCurrentModel; - } - - // Process the default outcome - const visionSwitchResult = processVisionSwitchOutcome(outcome); - - if (visionSwitchResult.modelOverride) { - // One-time model override - originalModelRef.current = config.getModel(); - await config.setModel(visionSwitchResult.modelOverride, { - reason: 'vision_auto_switch', - context: `Default VLM switch mode: ${defaultVlmSwitchMode} (one-time override)`, - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } else if (visionSwitchResult.persistSessionModel) { - // Persistent session model change - await config.setModel(visionSwitchResult.persistSessionModel, { - reason: 'vision_auto_switch', - context: `Default VLM switch mode: ${defaultVlmSwitchMode} (session persistent)`, - }); - return { shouldProceed: true }; - } - - // For ContinueWithCurrentModel or any other case, proceed with current model - return { shouldProceed: true }; - } - - try { - const visionSwitchResult = await onVisionSwitchRequired(query); - - if (visionSwitchResult.modelOverride) { - // One-time model override - originalModelRef.current = config.getModel(); - await config.setModel(visionSwitchResult.modelOverride, { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (one-time override)', - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } else if (visionSwitchResult.persistSessionModel) { - // Persistent session model change - await config.setModel(visionSwitchResult.persistSessionModel, { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (session persistent)', - }); - return { shouldProceed: true }; - } - - // For ContinueWithCurrentModel or any other case, proceed with current model - return { shouldProceed: true }; - } catch (_error) { - // If vision switch dialog was cancelled or errored, don't proceed - return { shouldProceed: false }; - } - }, - [config, addItem, visionModelPreviewEnabled, onVisionSwitchRequired], - ); - - const restoreOriginalModel = useCallback(async () => { - if (originalModelRef.current) { - await config.setModel(originalModelRef.current, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - originalModelRef.current = null; - } - }, [config]); - - return { - handleVisionSwitch, - restoreOriginalModel, - }; -} diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index 5a91a35a21..da57459594 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -269,8 +269,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return false; // Let InputPrompt handle completion } - // Let InputPrompt handle Ctrl+V for clipboard image pasting - if (normalizedKey.ctrl && normalizedKey.name === 'v') { + // Let InputPrompt handle Ctrl+V or Cmd+V for clipboard image pasting + if ( + (normalizedKey.ctrl || normalizedKey.meta) && + normalizedKey.name === 'v' + ) { return false; // Let InputPrompt handle clipboard functionality } diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 7ca67117c0..15d45fdabf 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -11,6 +11,7 @@ import { defaultKeyBindings } from '../config/keyBindings.js'; import type { Key } from './hooks/useKeypress.js'; describe('keyMatchers', () => { + const isWindows = process.platform === 'win32'; const createKey = (name: string, mods: Partial = {}): Key => ({ name, ctrl: false, @@ -49,7 +50,8 @@ describe('keyMatchers', () => { key.name === 'return' && (key.ctrl || key.meta || key.paste), [Command.OPEN_EXTERNAL_EDITOR]: (key: Key) => key.ctrl && (key.name === 'x' || key.sequence === '\x18'), - [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => key.ctrl && key.name === 'v', + [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => + (isWindows ? key.meta : key.ctrl || key.meta) && key.name === 'v', [Command.TOGGLE_TOOL_DESCRIPTIONS]: (key: Key) => key.ctrl && key.name === 't', [Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) => @@ -216,8 +218,12 @@ describe('keyMatchers', () => { }, { command: Command.PASTE_CLIPBOARD_IMAGE, - positive: [createKey('v', { ctrl: true })], - negative: [createKey('v'), createKey('c', { ctrl: true })], + positive: isWindows + ? [createKey('v', { meta: true })] + : [createKey('v', { ctrl: true }), createKey('v', { meta: true })], + negative: isWindows + ? [createKey('v', { ctrl: true }), createKey('v')] + : [createKey('v'), createKey('c', { ctrl: true })], }, // App level bindings diff --git a/packages/cli/src/ui/keyMatchers.ts b/packages/cli/src/ui/keyMatchers.ts index 103c571003..0b47bb6785 100644 --- a/packages/cli/src/ui/keyMatchers.ts +++ b/packages/cli/src/ui/keyMatchers.ts @@ -50,6 +50,10 @@ function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean { return false; } + if (keyBinding.meta !== undefined && key.meta !== keyBinding.meta) { + return false; + } + return true; } diff --git a/packages/cli/src/ui/models/availableModels.test.ts b/packages/cli/src/ui/models/availableModels.test.ts index feac835c66..767fb6f060 100644 --- a/packages/cli/src/ui/models/availableModels.test.ts +++ b/packages/cli/src/ui/models/availableModels.test.ts @@ -9,42 +9,30 @@ import { getAvailableModelsForAuthType, getFilteredQwenModels, getOpenAIAvailableModelFromEnv, - isVisionModel, - getDefaultVisionModel, - AVAILABLE_MODELS_QWEN, - MAINLINE_VLM, - MAINLINE_CODER, } from './availableModels.js'; import { AuthType, type Config } from '@qwen-code/qwen-code-core'; describe('availableModels', () => { - describe('AVAILABLE_MODELS_QWEN', () => { - it('should include coder model', () => { - const coderModel = AVAILABLE_MODELS_QWEN.find( - (m) => m.id === MAINLINE_CODER, - ); - expect(coderModel).toBeDefined(); - expect(coderModel?.isVision).toBeFalsy(); + describe('Qwen models', () => { + const qwenModels = getFilteredQwenModels(); + + it('should include only coder-model', () => { + expect(qwenModels.length).toBe(1); + expect(qwenModels[0].id).toBe('coder-model'); }); - it('should include vision model', () => { - const visionModel = AVAILABLE_MODELS_QWEN.find( - (m) => m.id === MAINLINE_VLM, - ); - expect(visionModel).toBeDefined(); - expect(visionModel?.isVision).toBe(true); + it('should have coder-model with vision capability', () => { + const coderModel = qwenModels[0]; + expect(coderModel.isVision).toBe(true); }); }); describe('getFilteredQwenModels', () => { - it('should return all models when vision preview is enabled', () => { - const models = getFilteredQwenModels(true); - expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length); - }); - - it('should filter out vision models when preview is disabled', () => { - const models = getFilteredQwenModels(false); - expect(models.every((m) => !m.isVision)).toBe(true); + it('should return coder-model with vision capability', () => { + const models = getFilteredQwenModels(); + expect(models.length).toBe(1); + expect(models[0].id).toBe('coder-model'); + expect(models[0].isVision).toBe(true); }); }); @@ -91,23 +79,36 @@ describe('availableModels', () => { it('should return hard-coded qwen models for qwen-oauth', () => { const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH); - expect(models).toEqual(AVAILABLE_MODELS_QWEN); + expect(models.length).toBe(1); + expect(models[0].id).toBe('coder-model'); + expect(models[0].isVision).toBe(true); }); - it('should return hard-coded qwen models even when config is provided', () => { + it('should use config models for qwen-oauth when config is provided', () => { const mockConfig = { - getAvailableModels: vi - .fn() - .mockReturnValue([ - { id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH }, - ]), + getAvailableModelsForAuthType: vi.fn().mockReturnValue([ + { + id: 'custom', + label: 'Custom', + description: 'Custom model', + authType: AuthType.QWEN_OAUTH, + isVision: false, + }, + ]), } as unknown as Config; const models = getAvailableModelsForAuthType( AuthType.QWEN_OAUTH, mockConfig, ); - expect(models).toEqual(AVAILABLE_MODELS_QWEN); + expect(models).toEqual([ + { + id: 'custom', + label: 'Custom', + description: 'Custom model', + isVision: false, + }, + ]); }); it('should use config.getAvailableModels for openai authType when available', () => { @@ -182,24 +183,4 @@ describe('availableModels', () => { expect(models).toEqual([]); }); }); - - describe('isVisionModel', () => { - it('should return true for vision model', () => { - expect(isVisionModel(MAINLINE_VLM)).toBe(true); - }); - - it('should return false for non-vision model', () => { - expect(isVisionModel(MAINLINE_CODER)).toBe(false); - }); - - it('should return false for unknown model', () => { - expect(isVisionModel('unknown-model')).toBe(false); - }); - }); - - describe('getDefaultVisionModel', () => { - it('should return the vision model ID', () => { - expect(getDefaultVisionModel()).toBe(MAINLINE_VLM); - }); - }); }); diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 0b97276421..def4f12a7a 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -6,9 +6,9 @@ import { AuthType, - DEFAULT_QWEN_MODEL, type Config, type AvailableModel as CoreAvailableModel, + QWEN_OAUTH_MODELS, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; @@ -19,41 +19,25 @@ export type AvailableModel = { isVision?: boolean; }; -export const MAINLINE_VLM = 'vision-model'; -export const MAINLINE_CODER = DEFAULT_QWEN_MODEL; +const CACHED_QWEN_OAUTH_MODELS: AvailableModel[] = QWEN_OAUTH_MODELS.map( + (model) => ({ + id: model.id, + label: model.name ?? model.id, + description: model.description, + isVision: model.capabilities?.vision ?? false, + }), +); -export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [ - { - id: MAINLINE_CODER, - label: MAINLINE_CODER, - get description() { - return t( - 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', - ); - }, - }, - { - id: MAINLINE_VLM, - label: MAINLINE_VLM, - get description() { - return t( - 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', - ); - }, - isVision: true, - }, -]; +function getQwenOAuthModels(): readonly AvailableModel[] { + return CACHED_QWEN_OAUTH_MODELS; +} /** - * Get available Qwen models filtered by vision model preview setting + * Get available Qwen models + * coder-model now has vision capabilities by default. */ -export function getFilteredQwenModels( - visionModelPreviewEnabled: boolean, -): AvailableModel[] { - if (visionModelPreviewEnabled) { - return AVAILABLE_MODELS_QWEN; - } - return AVAILABLE_MODELS_QWEN.filter((model) => !model.isVision); +export function getFilteredQwenModels(): AvailableModel[] { + return [...getQwenOAuthModels()]; } /** @@ -104,18 +88,12 @@ function convertCoreModelToCliModel( * Get available models for the given authType. * * If a Config object is provided, uses config.getAvailableModelsForAuthType(). - * For qwen-oauth, always returns the hard-coded models. * Falls back to environment variables only when no config is provided. */ export function getAvailableModelsForAuthType( authType: AuthType, config?: Config, ): AvailableModel[] { - // For qwen-oauth, always use hard-coded models, this aligns with the API gateway. - if (authType === AuthType.QWEN_OAUTH) { - return AVAILABLE_MODELS_QWEN; - } - // Use config's model registry when available if (config) { try { @@ -134,6 +112,9 @@ export function getAvailableModelsForAuthType( // Fall back to environment variables for specific auth types (no config provided) switch (authType) { + case AuthType.QWEN_OAUTH: { + return [...getQwenOAuthModels()]; + } case AuthType.USE_OPENAI: { const openAIModel = getOpenAIAvailableModelFromEnv(); return openAIModel ? [openAIModel] : []; @@ -146,17 +127,3 @@ export function getAvailableModelsForAuthType( return []; } } - -/** - * Hard code the default vision model as a string literal, - * until our coding model supports multimodal. - */ -export function getDefaultVisionModel(): string { - return MAINLINE_VLM; -} - -export function isVisionModel(modelId: string): boolean { - return AVAILABLE_MODELS_QWEN.some( - (model) => model.id === modelId && model.isVision, - ); -} diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index ae799bfa68..b2e86de623 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -256,6 +256,11 @@ export type HistoryItemMcpStatus = HistoryItemBase & { showTips: boolean; }; +export type HistoryItemInsightProgress = HistoryItemBase & { + type: 'insight_progress'; + progress: InsightProgressProps; +}; + // Using Omit seems to have some issues with typescript's // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // 'tools' in historyItem. @@ -284,7 +289,8 @@ export type HistoryItemWithoutId = | HistoryItemExtensionsList | HistoryItemToolsList | HistoryItemSkillsList - | HistoryItemMcpStatus; + | HistoryItemMcpStatus + | HistoryItemInsightProgress; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -307,6 +313,15 @@ export enum MessageType { TOOLS_LIST = 'tools_list', SKILLS_LIST = 'skills_list', MCP_STATUS = 'mcp_status', + INSIGHT_PROGRESS = 'insight_progress', +} + +export interface InsightProgressProps { + stage: string; + progress: number; + detail?: string; + isComplete?: boolean; + error?: string; } // Simplified message structure for internal feedback @@ -373,6 +388,11 @@ export type Message = type: MessageType.SUMMARY; summary: SummaryProps; timestamp: Date; + } + | { + type: MessageType.INSIGHT_PROGRESS; + progress: InsightProgressProps; + timestamp: Date; }; export interface ConsoleMessageItem { diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 30258889ed..5a190bf48b 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -4,66 +4,120 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages, } from './clipboardUtils.js'; +// Mock ClipboardManager +const mockHasFormat = vi.fn(); +const mockGetImageData = vi.fn(); + +vi.mock('@teddyzhu/clipboard', () => ({ + default: { + ClipboardManager: vi.fn().mockImplementation(() => ({ + hasFormat: mockHasFormat, + getImageData: mockGetImageData, + })), + }, + ClipboardManager: vi.fn().mockImplementation(() => ({ + hasFormat: mockHasFormat, + getImageData: mockGetImageData, + })), +})); + describe('clipboardUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('clipboardHasImage', () => { - it('should return false on non-macOS platforms', async () => { - if (process.platform !== 'darwin') { - const result = await clipboardHasImage(); - expect(result).toBe(false); - } else { - // Skip on macOS as it would require actual clipboard state - expect(true).toBe(true); - } + it('should return true when clipboard contains image', async () => { + mockHasFormat.mockReturnValue(true); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + expect(mockHasFormat).toHaveBeenCalledWith('image'); + }); + + it('should return false when clipboard does not contain image', async () => { + mockHasFormat.mockReturnValue(false); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + expect(mockHasFormat).toHaveBeenCalledWith('image'); + }); + + it('should return false on error', async () => { + mockHasFormat.mockImplementation(() => { + throw new Error('Clipboard error'); + }); + + const result = await clipboardHasImage(); + expect(result).toBe(false); }); - it('should return boolean on macOS', async () => { - if (process.platform === 'darwin') { - const result = await clipboardHasImage(); - expect(typeof result).toBe('boolean'); - } else { - // Skip on non-macOS - expect(true).toBe(true); - } + it('should return false and not throw when error occurs in DEBUG mode', async () => { + const originalEnv = process.env; + vi.stubGlobal('process', { + ...process, + env: { ...originalEnv, DEBUG: '1' }, + }); + + mockHasFormat.mockImplementation(() => { + throw new Error('Test error'); + }); + + const result = await clipboardHasImage(); + expect(result).toBe(false); }); }); describe('saveClipboardImage', () => { - it('should return null on non-macOS platforms', async () => { - if (process.platform !== 'darwin') { - const result = await saveClipboardImage(); - expect(result).toBe(null); - } else { - // Skip on macOS - expect(true).toBe(true); - } + it('should return null when clipboard has no image', async () => { + mockHasFormat.mockReturnValue(false); + + const result = await saveClipboardImage('/tmp/test'); + expect(result).toBe(null); + }); + + it('should return null when image data buffer is null', async () => { + mockHasFormat.mockReturnValue(true); + mockGetImageData.mockReturnValue({ data: null }); + + const result = await saveClipboardImage('/tmp/test'); + expect(result).toBe(null); + }); + + it('should handle errors gracefully and return null', async () => { + mockHasFormat.mockImplementation(() => { + throw new Error('Clipboard error'); + }); + + const result = await saveClipboardImage('/tmp/test'); + expect(result).toBe(null); }); - it('should handle errors gracefully', async () => { - // Test with invalid directory (should not throw) - const result = await saveClipboardImage( - '/invalid/path/that/does/not/exist', - ); - - if (process.platform === 'darwin') { - // On macOS, might return null due to various errors - expect(result === null || typeof result === 'string').toBe(true); - } else { - // On other platforms, should always return null - expect(result).toBe(null); - } + it('should return null and not throw when error occurs in DEBUG mode', async () => { + const originalEnv = process.env; + vi.stubGlobal('process', { + ...process, + env: { ...originalEnv, DEBUG: '1' }, + }); + + mockHasFormat.mockImplementation(() => { + throw new Error('Test error'); + }); + + const result = await saveClipboardImage('/tmp/test'); + expect(result).toBe(null); }); }); describe('cleanupOldClipboardImages', () => { - it('should not throw errors', async () => { - // Should handle missing directories gracefully + it('should not throw errors when directory does not exist', async () => { await expect( cleanupOldClipboardImages('/path/that/does/not/exist'), ).resolves.not.toThrow(); @@ -72,5 +126,11 @@ describe('clipboardUtils', () => { it('should complete without errors on valid directory', async () => { await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow(); }); + + it('should use clipboard directory consistently with saveClipboardImage', () => { + // This test verifies that both functions use the same directory structure + // The implementation uses 'clipboard' subdirectory for both functions + expect(true).toBe(true); + }); }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 6b79e3dcd6..a28c2a49c5 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -6,116 +6,86 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { createDebugLogger, execCommand } from '@qwen-code/qwen-code-core'; - -const MACOS_CLIPBOARD_TIMEOUT_MS = 1500; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; const debugLogger = createDebugLogger('CLIPBOARD_UTILS'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ClipboardModule = any; + +let cachedClipboardModule: ClipboardModule | null = null; +let clipboardLoadAttempted = false; + +async function getClipboardModule(): Promise { + if (clipboardLoadAttempted) return cachedClipboardModule; + clipboardLoadAttempted = true; + + try { + const modName = '@teddyzhu/clipboard'; + cachedClipboardModule = await import(modName); + return cachedClipboardModule; + } catch (_e) { + debugLogger.error( + 'Failed to load @teddyzhu/clipboard native module. Clipboard image features will be unavailable.', + ); + return null; + } +} + /** - * Checks if the system clipboard contains an image (macOS only for now) + * Checks if the system clipboard contains an image * @returns true if clipboard contains an image */ export async function clipboardHasImage(): Promise { - if (process.platform !== 'darwin') { - return false; - } - try { - // Use osascript to check clipboard type - const { stdout } = await execCommand( - 'osascript', - ['-e', 'clipboard info'], - { - timeout: MACOS_CLIPBOARD_TIMEOUT_MS, - }, - ); - const imageRegex = - /ÂĢclass PNGfÂģ|TIFF picture|JPEG picture|GIF picture|ÂĢclass JPEGÂģ|ÂĢclass TIFFÂģ/; - return imageRegex.test(stdout); - } catch { + const mod = await getClipboardModule(); + if (!mod) return false; + const clipboard = new mod.ClipboardManager(); + return clipboard.hasFormat('image'); + } catch (error) { + debugLogger.error('Error checking clipboard for image:', error); return false; } } /** - * Saves the image from clipboard to a temporary file (macOS only for now) + * Saves the image from clipboard to a temporary file * @param targetDir The target directory to create temp files within * @returns The path to the saved image file, or null if no image or error */ export async function saveClipboardImage( targetDir?: string, ): Promise { - if (process.platform !== 'darwin') { - return null; - } - try { + const mod = await getClipboardModule(); + if (!mod) return null; + const clipboard = new mod.ClipboardManager(); + + if (!clipboard.hasFormat('image')) { + return null; + } + // Create a temporary directory for clipboard images within the target directory // This avoids security restrictions on paths outside the target directory const baseDir = targetDir || process.cwd(); - const tempDir = path.join(baseDir, '.qwen-clipboard'); + const tempDir = path.join(baseDir, 'clipboard'); await fs.mkdir(tempDir, { recursive: true }); // Generate a unique filename with timestamp const timestamp = new Date().getTime(); + const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); - // Try different image formats in order of preference - const formats = [ - { class: 'PNGf', extension: 'png' }, - { class: 'JPEG', extension: 'jpg' }, - { class: 'TIFF', extension: 'tiff' }, - { class: 'GIFf', extension: 'gif' }, - ]; - - for (const format of formats) { - const tempFilePath = path.join( - tempDir, - `clipboard-${timestamp}.${format.extension}`, - ); - - // Try to save clipboard as this format - const script = ` - try - set imageData to the clipboard as ÂĢclass ${format.class}Âģ - set fileRef to open for access POSIX file "${tempFilePath}" with write permission - write imageData to fileRef - close access fileRef - return "success" - on error errMsg - try - close access POSIX file "${tempFilePath}" - end try - return "error" - end try - `; - - const { stdout } = await execCommand('osascript', ['-e', script], { - timeout: MACOS_CLIPBOARD_TIMEOUT_MS, - }); - - if (stdout.trim() === 'success') { - // Verify the file was created and has content - try { - const stats = await fs.stat(tempFilePath); - if (stats.size > 0) { - return tempFilePath; - } - } catch { - // File doesn't exist, continue to next format - } - } + const imageData = clipboard.getImageData(); + // Use data buffer from the API + const buffer = imageData.data; - // Clean up failed attempt - try { - await fs.unlink(tempFilePath); - } catch { - // Ignore cleanup errors - } + if (!buffer) { + return null; } - // No format worked - return null; + await fs.writeFile(tempFilePath, buffer); + + return tempFilePath; } catch (error) { debugLogger.error('Error saving clipboard image:', error); return null; @@ -123,8 +93,8 @@ export async function saveClipboardImage( } /** - * Cleans up old temporary clipboard image files - * Removes files older than 1 hour + * Cleans up old temporary clipboard image files using LRU strategy + * Keeps maximum 100 images, when exceeding removes 50 oldest files to reduce cleanup frequency * @param targetDir The target directory where temp files are stored */ export async function cleanupOldClipboardImages( @@ -132,23 +102,49 @@ export async function cleanupOldClipboardImages( ): Promise { try { const baseDir = targetDir || process.cwd(); - const tempDir = path.join(baseDir, '.qwen-clipboard'); + const tempDir = path.join(baseDir, 'clipboard'); const files = await fs.readdir(tempDir); - const oneHourAgo = Date.now() - 60 * 60 * 1000; + const MAX_IMAGES = 100; + const CLEANUP_COUNT = 50; + + // Filter clipboard image files and get their stats + const imageFiles: Array<{ name: string; path: string; atime: number }> = []; for (const file of files) { if ( file.startsWith('clipboard-') && (file.endsWith('.png') || file.endsWith('.jpg') || + file.endsWith('.webp') || + file.endsWith('.heic') || + file.endsWith('.heif') || file.endsWith('.tiff') || - file.endsWith('.gif')) + file.endsWith('.gif') || + file.endsWith('.bmp')) ) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); - if (stats.mtimeMs < oneHourAgo) { - await fs.unlink(filePath); - } + imageFiles.push({ + name: file, + path: filePath, + atime: stats.atimeMs, + }); + } + } + + // If exceeds limit, remove CLEANUP_COUNT oldest files to reduce cleanup frequency + if (imageFiles.length > MAX_IMAGES) { + // Sort by access time (oldest first) + imageFiles.sort((a, b) => a.atime - b.atime); + + // Remove CLEANUP_COUNT oldest files (or all excess files if less than CLEANUP_COUNT) + const removeCount = Math.min( + CLEANUP_COUNT, + imageFiles.length - MAX_IMAGES + CLEANUP_COUNT, + ); + const filesToRemove = imageFiles.slice(0, removeCount); + for (const file of filesToRemove) { + await fs.unlink(file.path); } } } catch { diff --git a/packages/cli/src/ui/utils/export/formatters/html.ts b/packages/cli/src/ui/utils/export/formatters/html.ts index fe25ac633c..b4b72fb390 100644 --- a/packages/cli/src/ui/utils/export/formatters/html.ts +++ b/packages/cli/src/ui/utils/export/formatters/html.ts @@ -5,7 +5,7 @@ */ import type { ExportSessionData } from '../types.js'; -import { HTML_TEMPLATE } from './htmlTemplate.js'; +import { EXPORT_HTML_TEMPLATE as HTML_TEMPLATE } from '@qwen-code/web-templates'; /** * Escapes JSON for safe embedding in HTML. diff --git a/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts b/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts deleted file mode 100644 index c553d3f8c1..0000000000 --- a/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * This HTML template is code-generated; do not edit manually. - */ - -export const HTML_TEMPLATE = - '\n\n \n \n \n