diff --git a/.github/workflows/build-and-publish-image.yml b/.github/workflows/build-and-publish-image.yml index ab8b85fd5..0051ccebf 100644 --- a/.github/workflows/build-and-publish-image.yml +++ b/.github/workflows/build-and-publish-image.yml @@ -6,8 +6,12 @@ on: - '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 (only works on main branch)' + description: 'Publish to GHCR' type: 'boolean' default: false @@ -25,6 +29,42 @@ jobs: 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 @@ -38,15 +78,17 @@ jobs: with: images: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}' tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=sha-,format=short + 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 != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true') }} + ${{ (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 }}' @@ -60,8 +102,8 @@ jobs: context: '.' platforms: 'linux/amd64,linux/arm64' push: |- - ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true') }} + ${{ (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=${{ github.sha }} + CLI_VERSION_ARG=${{ steps.version.outputs.clean || github.sha }} diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 823c0055a..b5327f6d9 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -8,10 +8,15 @@ on: required: false type: 'string' ref: - description: 'The branch or ref (full git sha) to release from.' + description: 'The branch or ref (full git sha) to release SDK from.' required: true type: 'string' default: 'main' + cli_ref: + description: 'CLI ref to bundle (tag, branch, or commit). Default: latest stable CLI release tag (recommended for stable releases)' + required: false + type: 'string' + default: '' dry_run: description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' required: true @@ -136,10 +141,62 @@ jobs: # This is required for nightly/preview because npm does not allow re-publishing the same version. npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version - - name: 'Build CLI Bundle' + - name: 'Determine CLI ref to bundle' + id: 'cli_ref' + env: + CLI_REF_INPUT: '${{ github.event.inputs.cli_ref }}' + IS_STABLE: '${{ steps.vars.outputs.is_nightly == false && steps.vars.outputs.is_preview == false }}' run: | - npm run build + if [[ -n "${CLI_REF_INPUT}" ]]; then + # User explicitly specified CLI ref + echo "CLI_REF=${CLI_REF_INPUT}" >> "$GITHUB_OUTPUT" + echo "Using user-specified CLI ref: ${CLI_REF_INPUT}" + else + # Auto-detect latest stable CLI release tag + # Exclude sdk-typescript tags, nightly, and preview tags + LATEST_CLI_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -v 'sdk-typescript' | grep -v 'nightly' | grep -v 'preview' | head -1) + if [[ -z "${LATEST_CLI_TAG}" ]]; then + echo '::error::Could not find latest stable CLI tag' + exit 1 + fi + echo "CLI_REF=${LATEST_CLI_TAG}" >> "$GITHUB_OUTPUT" + echo "Using latest stable CLI tag: ${LATEST_CLI_TAG}" + fi + + - name: 'Validate CLI ref for stable releases' + env: + CLI_REF: '${{ steps.cli_ref.outputs.CLI_REF }}' + IS_STABLE: '${{ steps.vars.outputs.is_nightly == false && steps.vars.outputs.is_preview == false }}' + run: | + if [[ "${IS_STABLE}" == "true" ]]; then + # For stable releases, ensure CLI ref is a tag (not main or a branch) + if [[ "${CLI_REF}" == "main" ]] || [[ "${CLI_REF}" == "master" ]]; then + echo "::error::Stable SDK releases cannot bundle CLI from '${CLI_REF}' branch. Please specify a CLI release tag via 'cli_ref' input, or ensure the latest stable CLI tag is available." + exit 1 + fi + # Check if it's a valid tag + if ! git rev-parse "refs/tags/${CLI_REF}" >/dev/null 2>&1; then + echo "::warning::CLI ref '${CLI_REF}' is not a tag. Stable releases should use tagged CLI versions." + else + echo "✓ CLI ref '${CLI_REF}' is a valid tag" + fi + fi + + - name: 'Build CLI Bundle' + env: + CLI_REF: '${{ steps.cli_ref.outputs.CLI_REF }}' + run: | + echo "Building CLI from ref: ${CLI_REF}" + # Save current state + CURRENT_REF=$(git rev-parse HEAD) + # Checkout CLI ref + git checkout "${CLI_REF}" + # Install dependencies and build CLI + npm ci npm run bundle + # Return to original ref for SDK build + git checkout "${CURRENT_REF}" + echo "CLI bundle built successfully from ${CLI_REF}" - name: 'Run Tests' if: |- @@ -173,6 +230,14 @@ jobs: run: |- npm run build + - name: 'Record bundled CLI version' + env: + CLI_REF: '${{ steps.cli_ref.outputs.CLI_REF }}' + run: | + # Create a metadata file to record which CLI version was bundled + echo "${CLI_REF}" > packages/sdk-typescript/dist/BUNDLED_CLI_VERSION + echo "Bundled CLI version: ${CLI_REF}" + - name: 'Publish @qwen-code/sdk' working-directory: 'packages/sdk-typescript' run: |- @@ -219,6 +284,7 @@ jobs: IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' REF: '${{ github.event.inputs.ref || github.sha }}' + CLI_REF: '${{ steps.cli_ref.outputs.CLI_REF }}' run: |- # For stable releases, use the release branch; for nightly/preview, use the current ref if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then @@ -229,11 +295,14 @@ jobs: PRERELEASE_FLAG="" fi + # Create release notes with CLI version info + NOTES="## Bundled CLI Version\n\nThis SDK release bundles CLI version: \`${CLI_REF}\`\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}" \ - --generate-notes \ + --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' diff --git a/.github/workflows/release-vscode-companion.yml b/.github/workflows/release-vscode-companion.yml index 2e0c4b60e..ea02b01fb 100644 --- a/.github/workflows/release-vscode-companion.yml +++ b/.github/workflows/release-vscode-companion.yml @@ -18,7 +18,7 @@ on: type: 'boolean' default: true create_preview_release: - description: 'Auto apply the preview release tag, input version is ignored.' + 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 @@ -93,10 +93,24 @@ jobs: BASE_VERSION=$(node -p "require('./package.json').version") if [[ "${IS_PREVIEW}" == "true" ]]; then - # Generate preview version with timestamp based on actual package version - TIMESTAMP=$(date +%Y%m%d%H%M%S) - PREVIEW_VERSION="${BASE_VERSION}-preview.${TIMESTAMP}" - RELEASE_TAG="preview.${TIMESTAMP}" + # 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" @@ -119,6 +133,12 @@ jobs: 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' }} @@ -279,7 +299,7 @@ jobs: echo "Publishing to Microsoft Marketplace..." for vsix in vsix-artifacts/*.vsix; do echo "Publishing: ${vsix}" - vsce publish --packagePath "${vsix}" --pat "${VSCE_PAT}" + vsce publish --packagePath "${vsix}" --pat "${VSCE_PAT}" --skip-duplicate done - name: 'Publish to OpenVSX' diff --git a/Dockerfile b/Dockerfile index 378880c8d..52a4d4416 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,8 +20,9 @@ COPY . /home/node/app WORKDIR /home/node/app # Install dependencies and build packages +# Use scripts/build.js which handles workspace dependencies in correct order RUN npm ci \ - && npm run build --workspaces \ + && 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 diff --git a/docs/developers/roadmap.md b/docs/developers/roadmap.md index 0fb05f8ad..125a4d36e 100644 --- a/docs/developers/roadmap.md +++ b/docs/developers/roadmap.md @@ -40,7 +40,7 @@ | 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+` | DashScope cache control | 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 | diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 2ce94c38b..6acd0e819 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -45,20 +45,46 @@ In addition to a project settings file, a project's `.qwen` directory can contai - [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). - [Agent Skills](../features/skills) (experimental) under `.qwen/skills/` (each Skill is a directory containing a `SKILL.md`). +### Configuration migration + +Qwen Code automatically migrates legacy configuration settings to the new format. Old settings files are backed up before migration. The following settings have been renamed from negative (`disable*`) to positive (`enable*`) naming: + +| Old Setting | New Setting | Notes | +| ---------------------------------------- | ------------------------------------------- | ---------------------------------- | +| `disableAutoUpdate` + `disableUpdateNag` | `general.enableAutoUpdate` | Consolidated into a single setting | +| `disableLoadingPhrases` | `ui.accessibility.enableLoadingPhrases` | | +| `disableFuzzySearch` | `context.fileFiltering.enableFuzzySearch` | | +| `disableCacheControl` | `model.generationConfig.enableCacheControl` | | + +> [!note] +> +> **Boolean value inversion:** When migrating, boolean values are inverted (e.g., `disableAutoUpdate: true` becomes `enableAutoUpdate: false`). + +#### Consolidation policy for `disableAutoUpdate` and `disableUpdateNag` + +When both legacy settings are present with different values, the migration follows this policy: if **either** `disableAutoUpdate` **or** `disableUpdateNag` is `true`, then `enableAutoUpdate` becomes `false`: + +| `disableAutoUpdate` | `disableUpdateNag` | Migrated `enableAutoUpdate` | +| ------------------- | ------------------ | --------------------------- | +| `false` | `false` | `true` | +| `false` | `true` | `false` | +| `true` | `false` | `false` | +| `true` | `true` | `false` | + ### Available settings in `settings.json` Settings are organized into categories. All settings should be placed within their corresponding top-level category object in your `settings.json` file. #### general -| Setting | Type | Description | Default | -| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- | -| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | -| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | -| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` | -| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` | -| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | -| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` | +| Setting | Type | Description | Default | +| ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | +| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | +| `general.enableAutoUpdate` | boolean | Enable automatic update checks and installations on startup. | `true` | +| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | +| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` | +| `general.defaultFileEncoding` | string | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` | #### output @@ -68,18 +94,21 @@ Settings are organized into categories. All settings should be placed within the #### ui -| Setting | Type | Description | Default | -| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` | -| `ui.customThemes` | object | Custom theme definitions. | `{}` | -| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` | -| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` | -| `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` | -| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` | -| `enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | -| `ui.accessibility.disableLoadingPhrases` | boolean | Disable loading phrases for accessibility. | `false` | -| `ui.accessibility.screenReader` | boolean | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | `false` | -| `ui.customWittyPhrases` | array of strings | A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones. | `[]` | +| Setting | Type | Description | Default | +| --------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` | +| `ui.customThemes` | object | Custom theme definitions. | `{}` | +| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` | +| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` | +| `ui.hideBanner` | boolean | Hide the application banner. | `false` | +| `ui.hideFooter` | boolean | Hide the footer from the UI. | `false` | +| `ui.showMemoryUsage` | boolean | Display memory usage information in the UI. | `false` | +| `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` | +| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` | +| `enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | +| `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` | +| `ui.accessibility.screenReader` | boolean | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | `false` | +| `ui.customWittyPhrases` | array of strings | A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones. | `[]` | #### ide @@ -96,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`, `disableCacheControl`, `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), `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:** @@ -116,8 +145,8 @@ Settings are organized into categories. All settings should be placed within the "model": { "generationConfig": { "timeout": 60000, - "disableCacheControl": false, "contextWindowSize": 128000, + "enableCacheControl": true, "customHeaders": { "X-Request-ID": "req-123", "X-User-ID": "user-456" @@ -258,14 +287,14 @@ Per-field precedence for `generationConfig`: | `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` | | `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore files when searching. | `true` | | `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` | -| `context.fileFiltering.disableFuzzySearch` | boolean | When `true`, disables the fuzzy search capabilities when searching for files, which can improve performance on projects with a large number of files. | `false` | +| `context.fileFiltering.enableFuzzySearch` | boolean | When `true`, enables fuzzy search capabilities when searching for files. Set to `false` to improve performance on projects with a large number of files. | `true` | #### Troubleshooting File Search Performance If you are experiencing performance issues with file searching (e.g., with `@` completions), especially in projects with a very large number of files, here are a few things you can try in order of recommendation: 1. **Use `.qwenignore`:** Create a `.qwenignore` file in your project root to exclude directories that contain a large number of files that you don't need to reference (e.g., build artifacts, logs, `node_modules`). Reducing the total number of files crawled is the most effective way to improve performance. -2. **Disable Fuzzy Search:** If ignoring files is not enough, you can disable fuzzy search by setting `disableFuzzySearch` to `true` in your `settings.json` file. This will use a simpler, non-fuzzy matching algorithm, which can be faster. +2. **Disable Fuzzy Search:** If ignoring files is not enough, you can disable fuzzy search by setting `enableFuzzySearch` to `false` in your `settings.json` file. This will use a simpler, non-fuzzy matching algorithm, which can be faster. 3. **Disable Recursive File Search:** As a last resort, you can disable recursive file search entirely by setting `enableRecursiveFileSearch` to `false`. This will be the fastest option as it avoids a recursive crawl of your project. However, it means you will need to type the full path to files when using `@` completions. #### tools diff --git a/docs/users/features/approval-mode.md b/docs/users/features/approval-mode.md index e072f237c..c46067093 100644 --- a/docs/users/features/approval-mode.md +++ b/docs/users/features/approval-mode.md @@ -20,7 +20,7 @@ Qwen Code offers three distinct permission modes that allow you to flexibly cont > [!tip] > -> You can quickly cycle through modes during a session using **Shift+Tab**. The terminal status bar shows your current mode, so you always know what permissions Qwen Code has. +> You can quickly cycle through modes during a session using **Shift+Tab** (or **Tab** on Windows). The terminal status bar shows your current mode, so you always know what permissions Qwen Code has. ## 1. Use Plan Mode for safe code analysis @@ -36,9 +36,9 @@ Plan Mode instructs Qwen Code to create a plan by analyzing the codebase with ** **Turn on Plan Mode during a session** -You can switch into Plan Mode during a session using **Shift+Tab** to cycle through permission modes. +You can switch into Plan Mode during a session using **Shift+Tab** (or **Tab** on Windows) to cycle through permission modes. -If you are in Normal Mode, **Shift+Tab** first switches into `auto-edits` Mode, indicated by `⏵⏵ accept edits on` at the bottom of the terminal. A subsequent **Shift+Tab** will switch into Plan Mode, indicated by `⏸ plan mode`. +If you are in Normal Mode, **Shift+Tab** (or **Tab** on Windows) first switches into `auto-edits` Mode, indicated by `⏵⏵ accept edits on` at the bottom of the terminal. A subsequent **Shift+Tab** (or **Tab** on Windows) will switch into Plan Mode, indicated by `⏸ plan mode`. **Start a new session in Plan Mode** @@ -100,7 +100,7 @@ Default Mode is the standard way to work with Qwen Code. In this mode, you maint **Turn on Default Mode during a session** -You can switch into Default Mode during a session using **Shift+Tab**​ to cycle through permission modes. If you're in any other mode, pressing **Shift+Tab**​ will eventually cycle back to Default Mode, indicated by the absence of any mode indicator at the bottom of the terminal. +You can switch into Default Mode during a session using **Shift+Tab**​ (or **Tab** on Windows) to cycle through permission modes. If you're in any other mode, pressing **Shift+Tab** (or **Tab** on Windows) will eventually cycle back to Default Mode, indicated by the absence of any mode indicator at the bottom of the terminal. **Start a new session in Default Mode** @@ -164,7 +164,7 @@ Auto-Edit Mode instructs Qwen Code to automatically approve file edits while req /approval-mode auto-edit # Or use keyboard shortcut -Shift+Tab # Switch from other modes +Shift+Tab (or Tab on Windows) # Switch from other modes ``` ### Workflow Example @@ -235,7 +235,7 @@ qwen --prompt "Run the test suite, fix all failing tests, then commit changes" ### Keyboard Shortcut Switching -During a Qwen Code session, use **Shift+Tab**​ to quickly cycle through the three modes: +During a Qwen Code session, use **Shift+Tab**​ (or **Tab** on Windows) to quickly cycle through the three modes: ``` Default Mode → Auto-Edit Mode → YOLO Mode → Plan Mode → Default Mode diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md index 46f3c8c42..fc2f86286 100644 --- a/docs/users/reference/keyboard-shortcuts.md +++ b/docs/users/reference/keyboard-shortcuts.md @@ -4,16 +4,16 @@ This document lists the available keyboard shortcuts in Qwen Code. ## General -| Shortcut | Description | -| ----------- | --------------------------------------------------------------------------------------------------------------------- | -| `Esc` | Close dialogs and suggestions. | -| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. | -| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. | -| `Ctrl+L` | Clear the screen. | -| `Ctrl+O` | Toggle the display of the debug console. | -| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | -| `Ctrl+T` | Toggle the display of tool descriptions. | -| `Shift+Tab` | Cycle approval modes (`plan` → `default` → `auto-edit` → `yolo`). | +| Shortcut | Description | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `Esc` | Close dialogs and suggestions. | +| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. | +| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. | +| `Ctrl+L` | Clear the screen. | +| `Ctrl+O` | Toggle the display of the debug console. | +| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | +| `Ctrl+T` | Toggle the display of tool descriptions. | +| `Shift+Tab` (`Tab` on Windows) | Cycle approval modes (`plan` → `default` → `auto-edit` → `yolo`) | ## Input Prompt diff --git a/eslint.config.js b/eslint.config.js index ea3158688..bd3585a92 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -258,6 +258,25 @@ export default tseslint.config( '@typescript-eslint/no-require-imports': 'off', }, }, + // Settings for export-html assets + { + files: ['packages/cli/assets/export-html/**/*.{js,jsx,ts,tsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + }, + }, // Prettier config must be last prettierConfig, // extra settings for scripts that we run directly with node diff --git a/integration-tests/utf-bom-encoding.test.ts b/integration-tests/utf-bom-encoding.test.ts index bb682de1e..31dd41522 100644 --- a/integration-tests/utf-bom-encoding.test.ts +++ b/integration-tests/utf-bom-encoding.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { writeFileSync } from 'node:fs'; +import { writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { TestRig } from './test-helper.js'; @@ -121,4 +121,98 @@ d('BOM end-to-end integration', () => { 'BOM_OK UTF-32BE', ); }); + + it('should preserve UTF-8 BOM when editing existing file', async () => { + // Create a file with UTF-8 BOM and Chinese content + const originalContent = + '// 这是一个测试文件\n// 包含中文注释\nfunction test() {\n return "hello";\n}\n'; + const fileWithBOM = Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from(originalContent, 'utf8'), + ]); + + const filename = 'bom-test.js'; + writeFileSync(join(dir, filename), fileWithBOM); + + // Ask Qwen Code to edit the file + const prompt = `edit the file ${filename} to change the return value from "hello" to "world"`; + await rig.run(prompt); + await rig.waitForToolCall('edit_file'); + + // Read the modified file as raw bytes + const modifiedBuffer = readFileSync(join(dir, filename)); + + // Verify BOM is preserved (first 3 bytes should be EF BB BF) + expect(modifiedBuffer[0]).toBe(0xef); + expect(modifiedBuffer[1]).toBe(0xbb); + expect(modifiedBuffer[2]).toBe(0xbf); + + // Verify the content was actually changed to include 'world' + const modifiedContent = modifiedBuffer.toString('utf8'); + expect(modifiedContent).toContain('world'); + }); + + it('should preserve UTF-8 BOM when overwriting file with write_file', async () => { + // Create a file with UTF-8 BOM + const originalContent = '// Original BOM file\nconst x = 1;\n'; + const fileWithBOM = Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from(originalContent, 'utf8'), + ]); + + const filename = 'bom-overwrite.js'; + writeFileSync(join(dir, filename), fileWithBOM); + + // Ask Qwen Code to overwrite the file with new content + const prompt = `overwrite the file ${filename} with: const y = 2;\n// new content`; + await rig.run(prompt); + await rig.waitForToolCall('write_file'); + + // Read the modified file as raw bytes + const modifiedBuffer = readFileSync(join(dir, filename)); + + // Verify BOM is preserved (first 3 bytes should be EF BB BF) + expect(modifiedBuffer[0]).toBe(0xef); + expect(modifiedBuffer[1]).toBe(0xbb); + expect(modifiedBuffer[2]).toBe(0xbf); + + // Verify the new content includes 'const y = 2' + const modifiedContent = modifiedBuffer.toString('utf8'); + expect(modifiedContent).toContain('const y = 2'); + }); +}); + +describe('BOM with defaultFileEncoding configuration', () => { + it('should create new file with BOM when defaultFileEncoding is utf-8-bom', async () => { + const rigWithBOM = new TestRig(); + await rigWithBOM.setup('bom-default-encoding', { + settings: { + general: { + defaultFileEncoding: 'utf-8-bom', + }, + }, + }); + + const filename = 'new-file-with-bom.js'; + + // Ask Qwen Code to create a new file + const prompt = `create a new file called ${filename} with content: const greeting = "hello";`; + await rigWithBOM.run(prompt); + await rigWithBOM.waitForToolCall('write_file'); + + // Read the created file as raw bytes + const filePath = join(rigWithBOM.testDir!, filename); + const fileBuffer = readFileSync(filePath); + + // Verify BOM is present (first 3 bytes should be EF BB BF) + expect(fileBuffer[0]).toBe(0xef); + expect(fileBuffer[1]).toBe(0xbb); + expect(fileBuffer[2]).toBe(0xbf); + + // Verify the content includes the expected string + const fileContent = fileBuffer.toString('utf8'); + expect(fileContent).toContain('const greeting'); + + await rigWithBOM.cleanup(); + }); }); diff --git a/package-lock.json b/package-lock.json index 27101fe8f..ff5a902d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.9.0", + "version": "0.10.0", "workspaces": [ "packages/*" ], @@ -2260,140 +2260,6 @@ "react": ">=16" } }, - "node_modules/@microsoft/api-extractor": { - "version": "7.43.0", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.43.0.tgz", - "integrity": "sha512-GFhTcJpB+MI6FhvXEI9b2K0snulNLWHqC/BbcJtyNYcKUiw7l3Lgis5ApsYncJ0leALX7/of4XfmXk+maT111w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@microsoft/api-extractor-model": "7.28.13", - "@microsoft/tsdoc": "0.14.2", - "@microsoft/tsdoc-config": "~0.16.1", - "@rushstack/node-core-library": "4.0.2", - "@rushstack/rig-package": "0.5.2", - "@rushstack/terminal": "0.10.0", - "@rushstack/ts-command-line": "4.19.1", - "lodash": "~4.17.15", - "minimatch": "~3.0.3", - "resolve": "~1.22.1", - "semver": "~7.5.4", - "source-map": "~0.6.1", - "typescript": "5.4.2" - }, - "bin": { - "api-extractor": "bin/api-extractor" - } - }, - "node_modules/@microsoft/api-extractor-model": { - "version": "7.28.13", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.28.13.tgz", - "integrity": "sha512-39v/JyldX4MS9uzHcdfmjjfS6cYGAoXV+io8B5a338pkHiSt+gy2eXQ0Q7cGFJ7quSa1VqqlMdlPrB6sLR/cAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@microsoft/tsdoc": "0.14.2", - "@microsoft/tsdoc-config": "~0.16.1", - "@rushstack/node-core-library": "4.0.2" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", - "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/@microsoft/tsdoc": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz", - "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@microsoft/tsdoc-config": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz", - "integrity": "sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@microsoft/tsdoc": "0.14.2", - "ajv": "~6.12.6", - "jju": "~1.4.0", - "resolve": "~1.19.0" - } - }, - "node_modules/@microsoft/tsdoc-config/node_modules/resolve": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", - "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.1.0", - "path-parse": "^1.0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@mswjs/interceptors": { "version": "0.39.5", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.5.tgz", @@ -3472,20 +3338,12 @@ "dev": true, "license": "MIT" }, - "node_modules/@rushstack/node-core-library": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-4.0.2.tgz", - "integrity": "sha512-hyES82QVpkfQMeBMteQUnrhASL/KHPhd7iJ8euduwNJG4mu2GSOKybf0rOEjOm1Wz7CwJEUm9y0yD7jg2C1bfg==", + "node_modules/@rushstack/problem-matcher": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.1.1.tgz", + "integrity": "sha512-Fm5XtS7+G8HLcJHCWpES5VmeMyjAKaWeyZU5qPzZC+22mPlJzAsOxymHiWIfuirtPckX3aptWws+K2d0BzniJA==", "dev": true, "license": "MIT", - "dependencies": { - "fs-extra": "~7.0.1", - "import-lazy": "~4.0.0", - "jju": "~1.4.0", - "resolve": "~1.22.1", - "semver": "~7.5.4", - "z-schema": "~5.0.2" - }, "peerDependencies": { "@types/node": "*" }, @@ -3495,146 +3353,6 @@ } } }, - "node_modules/@rushstack/node-core-library/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@rushstack/node-core-library/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@rushstack/node-core-library/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@rushstack/node-core-library/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@rushstack/node-core-library/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/@rushstack/node-core-library/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/@rushstack/rig-package": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.5.2.tgz", - "integrity": "sha512-mUDecIJeH3yYGZs2a48k+pbhM6JYwWlgjs2Ca5f2n1G2/kgdgP9D/07oglEGf6mRyXEnazhEENeYTSNDRCwdqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve": "~1.22.1", - "strip-json-comments": "~3.1.1" - } - }, - "node_modules/@rushstack/terminal": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.10.0.tgz", - "integrity": "sha512-UbELbXnUdc7EKwfH2sb8ChqNgapUOdqcCIdQP4NGxBpTZV2sQyeekuK3zmfQSa/MN+/7b4kBogl2wq0vpkpYGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rushstack/node-core-library": "4.0.2", - "supports-color": "~8.1.1" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@rushstack/terminal/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/@rushstack/ts-command-line": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.19.1.tgz", - "integrity": "sha512-J7H768dgcpG60d7skZ5uSSwyCZs/S2HrWP1Ds8d1qYAyaaeJmpmmLr9BVw97RjFzmQPOYnoXcKA4GkqDCkduQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rushstack/terminal": "0.10.0", - "@types/argparse": "1.0.38", - "argparse": "~1.0.9", - "string-argv": "~0.3.1" - } - }, - "node_modules/@rushstack/ts-command-line/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/@secretlint/config-creator": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", @@ -5396,37 +5114,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@volar/language-core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", - "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/source-map": "1.11.1" - } - }, - "node_modules/@volar/source-map": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", - "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "muggle-string": "^0.3.1" - } - }, - "node_modules/@volar/typescript": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", - "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "1.11.1", - "path-browserify": "^1.0.1" - } - }, "node_modules/@vscode/vsce": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz", @@ -5793,56 +5480,15 @@ "@vue/shared": "3.5.27" } }, - "node_modules/@vue/language-core": { - "version": "1.8.27", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", - "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "~1.11.1", - "@volar/source-map": "~1.11.1", - "@vue/compiler-dom": "^3.3.0", - "@vue/shared": "^3.3.0", - "computeds": "^0.0.1", - "minimatch": "^9.0.3", - "muggle-string": "^0.3.1", - "path-browserify": "^1.0.1", - "vue-template-compiler": "^2.7.14" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@vue/language-core/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "de-indent": "^1.0.2", + "he": "^1.2.0" } }, "node_modules/@vue/shared": { @@ -6016,6 +5662,13 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/alien-signals": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.4.14.tgz", + "integrity": "sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -6691,9 +6344,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", - "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7573,6 +7226,13 @@ "node": ">= 6" } }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "dev": true, + "license": "MIT" + }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -7632,13 +7292,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/computeds": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", - "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", - "dev": true, - "license": "MIT" - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -9650,6 +9303,13 @@ "node": ">= 0.8" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -12650,14 +12310,6 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -12672,14 +12324,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -13254,13 +12898,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/muggle-string": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", - "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", - "dev": true, - "license": "MIT" - }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -14952,6 +14589,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -18125,16 +17779,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/validator": { - "version": "13.15.26", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", - "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -18255,34 +17899,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite-plugin-dts": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-3.9.1.tgz", - "integrity": "sha512-rVp2KM9Ue22NGWB8dNtWEr+KekN3rIgz1tWD050QnRGlriUCmaDwa7qA5zDEjbXg5lAXhYMSBJtx3q3hQIJZSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@microsoft/api-extractor": "7.43.0", - "@rollup/pluginutils": "^5.1.0", - "@vue/language-core": "^1.8.27", - "debug": "^4.3.4", - "kolorist": "^1.8.0", - "magic-string": "^0.30.8", - "vue-tsc": "^1.8.27" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "typescript": "*", - "vite": "*" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, "node_modules/vite/node_modules/fdir": { "version": "6.4.6", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", @@ -18397,34 +18013,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vue-template-compiler": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", - "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, - "node_modules/vue-tsc": { - "version": "1.8.27", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", - "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/typescript": "~1.11.1", - "@vue/language-core": "1.8.27", - "semver": "^7.5.4" - }, - "bin": { - "vue-tsc": "bin/vue-tsc.js" - }, - "peerDependencies": { - "typescript": "*" - } + "license": "MIT" }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", @@ -18993,38 +18587,6 @@ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" }, - "node_modules/z-schema": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", - "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "validator": "^13.7.0" - }, - "bin": { - "z-schema": "bin/z-schema" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "commander": "^9.4.1" - } - }, - "node_modules/z-schema/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", @@ -19093,7 +18655,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -19712,7 +19274,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.9.0", + "version": "0.10.0", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -23192,7 +22754,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.9.0", + "version": "0.10.0", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -23204,7 +22766,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.9.0", + "version": "0.10.0", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -23451,7 +23013,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.1.0-beta.4", + "version": "0.10.0", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" @@ -23477,7 +23039,7 @@ "tailwindcss": "^3.4.0", "typescript": "^5.0.0", "vite": "^5.0.0", - "vite-plugin-dts": "^3.7.0" + "vite-plugin-dts": "^4.5.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", @@ -23875,6 +23437,289 @@ "node": ">=12" } }, + "packages/webui/node_modules/@microsoft/api-extractor": { + "version": "7.56.0", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.56.0.tgz", + "integrity": "sha512-H0V69QG5jIb9Ayx35NVBv2lOgFSS3q+Eab2oyGEy0POL3ovYPST+rCNPbwYoczOZXNG8IKjWUmmAMxmDTsXlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/api-extractor-model": "7.32.2", + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.0", + "@rushstack/node-core-library": "5.19.1", + "@rushstack/rig-package": "0.6.0", + "@rushstack/terminal": "0.21.0", + "@rushstack/ts-command-line": "5.1.7", + "diff": "~8.0.2", + "lodash": "~4.17.15", + "minimatch": "10.0.3", + "resolve": "~1.22.1", + "semver": "~7.5.4", + "source-map": "~0.6.1", + "typescript": "5.8.2" + }, + "bin": { + "api-extractor": "bin/api-extractor" + } + }, + "packages/webui/node_modules/@microsoft/api-extractor-model": { + "version": "7.32.2", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.32.2.tgz", + "integrity": "sha512-Ussc25rAalc+4JJs9HNQE7TuO9y6jpYQX9nWD1DhqUzYPBr3Lr7O9intf+ZY8kD5HnIqeIRJX7ccCT0QyBy2Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.0", + "@rushstack/node-core-library": "5.19.1" + } + }, + "packages/webui/node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "dev": true, + "license": "MIT" + }, + "packages/webui/node_modules/@microsoft/tsdoc-config": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.0.tgz", + "integrity": "sha512-8N/vClYyfOH+l4fLkkr9+myAoR6M7akc8ntBJ4DJdWH2b09uVfr71+LTMpNyG19fNqWDg8KEDZhx5wxuqHyGjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "ajv": "~8.12.0", + "jju": "~1.4.0", + "resolve": "~1.22.2" + } + }, + "packages/webui/node_modules/@rushstack/node-core-library": { + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.19.1.tgz", + "integrity": "sha512-ESpb2Tajlatgbmzzukg6zyAhH+sICqJR2CNXNhXcEbz6UGCQfrKCtkxOpJTftWc8RGouroHG0Nud1SJAszvpmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "~8.13.0", + "ajv-draft-04": "~1.0.0", + "ajv-formats": "~3.0.1", + "fs-extra": "~11.3.0", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.5.4" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "packages/webui/node_modules/@rushstack/node-core-library/node_modules/ajv": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", + "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.4.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/webui/node_modules/@rushstack/rig-package": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.6.0.tgz", + "integrity": "sha512-ZQmfzsLE2+Y91GF15c65L/slMRVhF6Hycq04D4TwtdGaUAbIXXg9c5pKA5KFU7M4QMaihoobp9JJYpYcaY3zOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "~1.22.1", + "strip-json-comments": "~3.1.1" + } + }, + "packages/webui/node_modules/@rushstack/terminal": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.21.0.tgz", + "integrity": "sha512-cLaI4HwCNYmknM5ns4G+drqdEB6q3dCPV423+d3TZeBusYSSm09+nR7CnhzJMjJqeRcdMAaLnrA4M/3xDz4R3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rushstack/node-core-library": "5.19.1", + "@rushstack/problem-matcher": "0.1.1", + "supports-color": "~8.1.1" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "packages/webui/node_modules/@rushstack/ts-command-line": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.1.7.tgz", + "integrity": "sha512-Ugwl6flarZcL2nqH5IXFYk3UR3mBVDsVFlCQW/Oaqidvdb/5Ota6b/Z3JXWIdqV3rOR2/JrYoAHanWF5rgenXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rushstack/terminal": "0.21.0", + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "string-argv": "~0.3.1" + } + }, + "packages/webui/node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "packages/webui/node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "packages/webui/node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "packages/webui/node_modules/@vue/language-core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.0.tgz", + "integrity": "sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~2.4.11", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^0.4.9", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/webui/node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/webui/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/webui/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "packages/webui/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "packages/webui/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/webui/node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true, + "license": "MIT" + }, + "packages/webui/node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "packages/webui/node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -23914,6 +23759,125 @@ "@esbuild/win32-x64": "0.21.5" } }, + "packages/webui/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "packages/webui/node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "packages/webui/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "packages/webui/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/webui/node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "packages/webui/node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "packages/webui/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "packages/webui/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "packages/webui/node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/webui/node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -23973,6 +23937,40 @@ "optional": true } } + }, + "packages/webui/node_modules/vite-plugin-dts": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.5.4.tgz", + "integrity": "sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/api-extractor": "^7.50.1", + "@rollup/pluginutils": "^5.1.4", + "@volar/typescript": "^2.4.11", + "@vue/language-core": "2.2.0", + "compare-versions": "^6.1.1", + "debug": "^4.4.0", + "kolorist": "^1.8.0", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17" + }, + "peerDependencies": { + "typescript": "*", + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "packages/webui/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" } } } diff --git a/package.json b/package.json index 5d5055dfb..374dd32c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.9.0", + "version": "0.10.0", "engines": { "node": ">=20.0.0" }, @@ -13,10 +13,11 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.9.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.0" }, "scripts": { "start": "cross-env node scripts/start.js", + "dev": "node scripts/dev.js", "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", "generate": "node scripts/generate-git-commit-info.js", "build": "node scripts/build.js", @@ -62,7 +63,8 @@ "ansi-regex": "6.2.2", "cliui": { "wrap-ansi": "7.0.0" - } + }, + "baseline-browser-mapping": "^2.9.19" }, "bin": { "qwen": "dist/cli.js" diff --git a/packages/cli/assets/export-html/build.mjs b/packages/cli/assets/export-html/build.mjs new file mode 100644 index 000000000..30a77dfdd --- /dev/null +++ b/packages/cli/assets/export-html/build.mjs @@ -0,0 +1,105 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { build } from 'esbuild'; +import { buildConfig } from './esbuild.config.mjs'; +import prettier from 'prettier'; + +const assetsDir = dirname(fileURLToPath(import.meta.url)); +const srcDir = join(assetsDir, 'src'); +const assetsDistDir = join(assetsDir, 'dist'); +const packageDistDir = join( + assetsDir, + '..', + '..', + 'dist', + 'assets', + 'export-html', +); +const templateModulePath = join( + assetsDir, + '..', + '..', + 'src', + 'ui', + 'utils', + 'export', + 'formatters', + 'htmlTemplate.ts', +); +const packageJsonPath = join(assetsDir, 'package.json'); + +await mkdir(assetsDistDir, { recursive: true }); +await mkdir(packageDistDir, { recursive: true }); + +const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); +const dependencyVersions = packageJson?.dependencies ?? {}; +const getDependencyVersion = (name) => { + const version = dependencyVersions[name]; + if (!version) { + throw new Error(`Missing ${name} dependency version in package.json.`); + } + // Handle various version formats: + // - "^0.1.0" -> "0.1.0" + // - "~1.2.3" -> "1.2.3" + // - "latest" -> "latest" + // - "^0.1.0@latest" -> "0.1.0" (remove npm tag suffix) + const versionWithoutPrefix = version.replace(/^[^0-9a-zA-Z]+/, ''); + // Remove npm tag suffix (e.g., "0.1.0@latest" -> "0.1.0") + return versionWithoutPrefix.replace(/@.+$/, ''); +}; +const webuiVersion = getDependencyVersion('@qwen-code/webui'); +const reactUmdVersion = '18.2.0'; +const reactDomUmdVersion = '18.2.0'; + +const buildResult = await build(buildConfig); + +const jsBundle = buildResult.outputFiles.find((file) => + file.path.endsWith('.js'), +); +const cssBundle = buildResult.outputFiles.find((file) => + file.path.endsWith('.css'), +); +if (!jsBundle) { + throw new Error('Failed to generate inline script bundle.'); +} + +const css = cssBundle + ? cssBundle.text + : await readFile(join(srcDir, 'styles.css'), 'utf8'); +const htmlTemplate = await readFile(join(srcDir, 'index.html'), 'utf8'); +const faviconSvg = await readFile(join(srcDir, 'favicon.svg'), 'utf8'); +const faviconData = encodeURIComponent(faviconSvg.trim()); + +const htmlOutput = htmlTemplate + .replace('__INLINE_CSS__', css.trim()) + .replace('__INLINE_SCRIPT__', jsBundle.text.trim()) + .replaceAll('__REACT_UMD_VERSION__', reactUmdVersion) + .replaceAll('__REACT_DOM_UMD_VERSION__', reactDomUmdVersion) + .replaceAll('__WEBUI_VERSION__', webuiVersion) + .replace('__FAVICON_SVG__', faviconSvg.trim()) + .replace('__FAVICON_DATA__', faviconData); + +const templateModule = `/** + * @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 = ${JSON.stringify(htmlOutput)}; +`; + +const formattedTemplateModule = await prettier.format(templateModule, { + parser: 'typescript', + singleQuote: true, + semi: true, + trailingComma: 'all', + printWidth: 80, + tabWidth: 2, +}); + +await writeFile(join(assetsDistDir, 'index.html'), htmlOutput); +await writeFile(join(packageDistDir, 'index.html'), htmlOutput); +await writeFile(templateModulePath, formattedTemplateModule); diff --git a/packages/cli/assets/export-html/esbuild.config.mjs b/packages/cli/assets/export-html/esbuild.config.mjs new file mode 100644 index 000000000..5d88b01ad --- /dev/null +++ b/packages/cli/assets/export-html/esbuild.config.mjs @@ -0,0 +1,35 @@ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const assetsDir = dirname(fileURLToPath(import.meta.url)); +const srcDir = join(assetsDir, 'src'); + +export const buildConfig = { + entryPoints: [join(srcDir, 'main.tsx')], + bundle: true, + minify: true, + write: false, + outdir: join(assetsDir, 'dist'), + platform: 'browser', + format: 'iife', + target: ['es2018'], + jsx: 'transform', + jsxFactory: 'React.createElement', + jsxFragment: 'React.Fragment', + tsconfigRaw: { + compilerOptions: { + jsx: 'react', + }, + }, + external: ['react', 'react-dom', 'react-dom/client'], + legalComments: 'none', + loader: { + '.ts': 'ts', + '.tsx': 'tsx', + '.js': 'jsx', + '.jsx': 'jsx', + '.css': 'css', + '.svg': 'text', + }, +}; diff --git a/packages/cli/assets/export-html/package.json b/packages/cli/assets/export-html/package.json new file mode 100644 index 000000000..934da462d --- /dev/null +++ b/packages/cli/assets/export-html/package.json @@ -0,0 +1,14 @@ +{ + "name": "@qwen-code/cli-export-html", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs" + }, + "dependencies": { + "@qwen-code/webui": "latest" + }, + "devDependencies": { + "esbuild": "^0.25.0" + } +} diff --git a/packages/cli/assets/export-html/src/favicon.svg b/packages/cli/assets/export-html/src/favicon.svg new file mode 100644 index 000000000..d489bdabf --- /dev/null +++ b/packages/cli/assets/export-html/src/favicon.svg @@ -0,0 +1,6 @@ + + + diff --git a/packages/cli/assets/export-html/src/index.html b/packages/cli/assets/export-html/src/index.html new file mode 100644 index 000000000..895ce6836 --- /dev/null +++ b/packages/cli/assets/export-html/src/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + Qwen Code Chat Export + + + +
+ + + + + diff --git a/packages/cli/assets/export-html/src/main.tsx b/packages/cli/assets/export-html/src/main.tsx new file mode 100644 index 000000000..525b7a006 --- /dev/null +++ b/packages/cli/assets/export-html/src/main.tsx @@ -0,0 +1,167 @@ +import './styles.css'; +import logoSvg from './favicon.svg'; + +declare global { + interface Window { + React: typeof import('react'); + ReactDOM: typeof import('react-dom/client'); + } +} + +const ReactDOM = window.ReactDOM; + +declare const QwenCodeWebUI: { + ChatViewer: (props: { + messages: unknown[]; + autoScroll: boolean; + theme: string; + }) => React.ReactNode; + PlatformProvider: (props: { + value: unknown; + children: React.ReactNode; + }) => React.ReactNode; +}; + +const { ChatViewer, PlatformProvider } = QwenCodeWebUI; + +type ChatData = { + messages?: unknown[]; + sessionId?: string; + startTime?: string; +}; + +type PlatformContextValue = { + platform: 'web'; + postMessage: (message: unknown) => void; + onMessage: (handler: (event: MessageEvent) => void) => () => void; + openFile: (path: string) => void; + getResourceUrl: () => string | undefined; + features: { + canOpenFile: boolean; + canCopy: boolean; + }; +}; +type ChatViewerMessage = { type?: string } & Record; + +const logoSvgWithGradient = (() => { + if (!logoSvg) { + return logoSvg; + } + + const gradientDef = + ''; + + const withDefs = logoSvg.replace(/]*)>/, `${gradientDef}`); + + return withDefs.replace(/fill="[^"]*"/, 'fill="url(#qwen-logo-gradient)"'); +})(); + +const platformContext = { + platform: 'web' as PlatformContextValue['platform'], + postMessage: (message: unknown) => { + console.log('Posted message:', message); + }, + onMessage: (handler: (event: MessageEvent) => void) => { + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, + openFile: (path: string) => { + console.log('Opening file:', path); + }, + getResourceUrl: () => undefined, + features: { + canOpenFile: false, + canCopy: true, + }, +} satisfies PlatformContextValue; + +const isChatViewerMessage = (value: unknown): value is ChatViewerMessage => + Boolean(value) && typeof value === 'object'; + +const parseChatData = (): ChatData => { + const chatDataElement = document.getElementById('chat-data'); + if (!chatDataElement?.textContent) { + return {}; + } + + try { + const parsed = JSON.parse(chatDataElement.textContent) as unknown; + if (parsed && typeof parsed === 'object') { + return parsed as ChatData; + } + return {}; + } catch (error) { + console.error('Failed to parse chat data.', error); + return {}; + } +}; + +const formatSessionDate = (startTime?: string | null) => { + if (!startTime) { + return '-'; + } + + try { + const date = new Date(startTime); + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return startTime; + } +}; + +const App = () => { + const chatData = parseChatData(); + const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : []; + const messages = rawMessages + .filter(isChatViewerMessage) + .filter((record) => record.type !== 'system'); + const sessionId = chatData.sessionId ?? '-'; + const sessionDate = formatSessionDate(chatData.startTime); + + return ( +
+
+
+ +
+
+ Session Id + {sessionId} +
+
+ Export Time + {sessionDate} +
+
+
+
+ + + +
+
+ ); +}; + +const rootElement = document.getElementById('app'); +if (!rootElement) { + console.error('App container not found.'); +} else { + ReactDOM.createRoot(rootElement).render(); +} diff --git a/packages/cli/assets/export-html/src/styles.css b/packages/cli/assets/export-html/src/styles.css new file mode 100644 index 000000000..e8286b2c5 --- /dev/null +++ b/packages/cli/assets/export-html/src/styles.css @@ -0,0 +1,203 @@ +:root { + --bg-primary: #18181b; + --bg-secondary: #27272a; + --text-primary: #f4f4f5; + --text-secondary: #a1a1aa; + --border-color: #3f3f46; + --accent-color: #3b82f6; +} + +body { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; + margin: 0; + padding: 0; + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +.page-wrapper { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; +} + +.header { + width: 100%; + padding: 16px 24px; + border-bottom: 1px solid var(--border-color); + background-color: rgba(24, 24, 27, 0.95); + backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 100; + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; +} + +.header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-icon { + width: 24px; + height: 24px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.logo-icon svg { + width: 100%; + height: 100%; +} + +.logo { + display: flex; + flex-direction: column; + line-height: 1; +} + +.logo-text { + font-family: 'Press Start 2P', cursive; + font-weight: 400; + font-size: 24px; + letter-spacing: -0.05em; + position: relative; + color: white; +} + +.logo-text-inner { + background: linear-gradient(to right, #60a5fa, #a855f7); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + position: relative; + z-index: 2; +} + +.logo-text::before, +.logo-text::after { + content: attr(data-text); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + background: none; + -webkit-text-fill-color: transparent; + -webkit-text-stroke: 1px rgba(96, 165, 250, 0.3); +} + +.logo-text::before { + transform: translate(2px, 2px); + -webkit-text-stroke: 1px rgba(168, 85, 247, 0.3); +} + +.logo-text::after { + transform: translate(4px, 4px); + opacity: 0.4; +} + +.logo-sub { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.05em; + text-transform: uppercase; + margin-top: 4px; +} + +.badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background-color: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-color); + font-weight: 500; +} + +.meta { + display: flex; + gap: 24px; + font-size: 13px; + color: var(--text-secondary); +} + +.meta-item { + display: flex; + align-items: center; + gap: 8px; +} + +.meta-label { + color: #71717a; +} + +.chat-container { + width: 100%; + max-width: 900px; + padding: 40px 20px; + box-sizing: border-box; + flex: 1; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-secondary); + border-radius: 5px; + border: 2px solid var(--bg-primary); +} + +::-webkit-scrollbar-thumb:hover { + background: #52525b; +} + +@media (max-width: 768px) { + .chat-container { + max-width: 100%; + padding: 20px 16px; + } + + .header { + padding: 12px 16px; + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .header-left { + width: 100%; + justify-content: space-between; + } + + .meta { + width: 100%; + flex-direction: column; + gap: 6px; + } +} + +@media (max-width: 480px) { + .chat-container { + padding: 16px 12px; + } +} diff --git a/packages/cli/assets/export-html/src/types.d.ts b/packages/cli/assets/export-html/src/types.d.ts new file mode 100644 index 000000000..9077ec08c --- /dev/null +++ b/packages/cli/assets/export-html/src/types.d.ts @@ -0,0 +1,9 @@ +declare module '*.svg' { + const content: string; + export default content; +} + +declare module '*.css' { + const content: string; + export default content; +} diff --git a/packages/cli/assets/export-html/tsconfig.json b/packages/cli/assets/export-html/tsconfig.json new file mode 100644 index 000000000..5878beab6 --- /dev/null +++ b/packages/cli/assets/export-html/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "target": "es2018", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/packages/cli/assets/parallel-build.mjs b/packages/cli/assets/parallel-build.mjs new file mode 100644 index 000000000..e070aa08f --- /dev/null +++ b/packages/cli/assets/parallel-build.mjs @@ -0,0 +1,96 @@ +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 14e9a460e..729f04a33 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.9.0", + "version": "0.10.0", "description": "Qwen Code", "repository": { "type": "git", @@ -19,7 +19,8 @@ } }, "scripts": { - "build": "node ../../scripts/build_package.js", + "build:assets": "node ./assets/parallel-build.mjs", + "build": "npm run build:assets && node ../../scripts/build_package.js", "start": "node dist/index.js", "debug": "node --inspect-brk dist/index.js", "lint": "eslint . --ext .ts,.tsx", @@ -33,7 +34,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.9.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.0" }, "dependencies": { "@google/genai": "1.30.0", diff --git a/packages/cli/src/acp-integration/service/filesystem.test.ts b/packages/cli/src/acp-integration/service/filesystem.test.ts index bc1f56e81..6eb3dfa1b 100644 --- a/packages/cli/src/acp-integration/service/filesystem.test.ts +++ b/packages/cli/src/acp-integration/service/filesystem.test.ts @@ -12,10 +12,98 @@ import { ACP_ERROR_CODES } from '../errorCodes.js'; const createFallback = (): FileSystemService => ({ readTextFile: vi.fn(), writeTextFile: vi.fn(), + detectFileBOM: vi.fn().mockResolvedValue(false), findFiles: vi.fn().mockReturnValue([]), }); describe('AcpFileSystemService', () => { + describe('detectFileBOM', () => { + it('detects BOM through ACP client when content starts with U+FEFF', async () => { + const client = { + readTextFile: vi + .fn() + .mockResolvedValue({ content: '\ufeff// BOM file' }), + } as unknown as import('../acp.js').Client; + + const svc = new AcpFileSystemService( + client, + 'session-1', + { readTextFile: true, writeTextFile: true }, + createFallback(), + ); + + const result = await svc.detectFileBOM('/test/file.txt'); + expect(result).toBe(true); + expect(client.readTextFile).toHaveBeenCalledWith({ + path: '/test/file.txt', + sessionId: 'session-1', + line: null, + limit: 1, + }); + }); + + it('detects no BOM through ACP client when content does not start with U+FEFF', async () => { + const client = { + readTextFile: vi.fn().mockResolvedValue({ content: '// No BOM file' }), + } as unknown as import('../acp.js').Client; + + const svc = new AcpFileSystemService( + client, + 'session-2', + { readTextFile: true, writeTextFile: true }, + createFallback(), + ); + + const result = await svc.detectFileBOM('/test/file.txt'); + expect(result).toBe(false); + }); + + it('falls back to local filesystem when ACP client fails', async () => { + const client = { + readTextFile: vi.fn().mockRejectedValue(new Error('Network error')), + } as unknown as import('../acp.js').Client; + + const fallback = createFallback(); + (fallback.detectFileBOM as ReturnType).mockResolvedValue( + true, + ); + + const svc = new AcpFileSystemService( + client, + 'session-3', + { readTextFile: true, writeTextFile: true }, + fallback, + ); + + const result = await svc.detectFileBOM('/test/file.txt'); + expect(result).toBe(true); + expect(fallback.detectFileBOM).toHaveBeenCalledWith('/test/file.txt'); + }); + + it('falls back to local filesystem when readTextFile capability is disabled', async () => { + const client = { + readTextFile: vi.fn(), + } as unknown as import('../acp.js').Client; + + const fallback = createFallback(); + (fallback.detectFileBOM as ReturnType).mockResolvedValue( + false, + ); + + const svc = new AcpFileSystemService( + client, + 'session-4', + { readTextFile: false, writeTextFile: true }, + fallback, + ); + + const result = await svc.detectFileBOM('/test/file.txt'); + expect(result).toBe(false); + expect(fallback.detectFileBOM).toHaveBeenCalledWith('/test/file.txt'); + expect(client.readTextFile).not.toHaveBeenCalled(); + }); + }); + describe('readTextFile ENOENT handling', () => { it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => { const resourceNotFoundError = { diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index 18aef1bec..17a0cdbcf 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -54,17 +54,45 @@ export class AcpFileSystemService implements FileSystemService { return response.content; } - async writeTextFile(filePath: string, content: string): Promise { + async writeTextFile( + filePath: string, + content: string, + options?: { bom?: boolean }, + ): Promise { if (!this.capabilities.writeTextFile) { - return this.fallback.writeTextFile(filePath, content); + return this.fallback.writeTextFile(filePath, content, options); } + // Prepend BOM character if requested + const finalContent = options?.bom ? '\uFEFF' + content : content; + await this.client.writeTextFile({ path: filePath, - content, + content: finalContent, sessionId: this.sessionId, }); } + + async detectFileBOM(filePath: string): Promise { + // Try to detect BOM through ACP client first by reading first line + if (this.capabilities.readTextFile) { + try { + const response = await this.client.readTextFile({ + path: filePath, + sessionId: this.sessionId, + line: null, + limit: 1, + }); + // Check if content starts with BOM character (U+FEFF) + return response.content.charCodeAt(0) === 0xfeff; + } catch { + // Fall through to fallback if ACP read fails + } + } + // Fall back to local filesystem detection + return this.fallback.detectFileBOM(filePath); + } + findFiles(fileName: string, searchPaths: readonly string[]): string[] { return this.fallback.findFiles(fileName, searchPaths); } diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index 0ecbccb9b..b6121eef6 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -21,10 +21,12 @@ import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; * have appeared during the original session. */ export class HistoryReplayer { + private readonly ctx: SessionContext; private readonly messageEmitter: MessageEmitter; private readonly toolCallEmitter: ToolCallEmitter; constructor(ctx: SessionContext) { + this.ctx = ctx; this.messageEmitter = new MessageEmitter(ctx); this.toolCallEmitter = new ToolCallEmitter(ctx); } @@ -44,6 +46,7 @@ export class HistoryReplayer { * Replays a single chat record. */ private async replayRecord(record: ChatRecord): Promise { + this.setActiveRecordId(record.uuid, record.timestamp); switch (record.type) { case 'user': if (record.message) { @@ -68,6 +71,7 @@ export class HistoryReplayer { // Skip system records (compression, telemetry, slash commands) break; } + this.setActiveRecordId(null); } /** @@ -199,4 +203,13 @@ export class HistoryReplayer { } return ''; } + + private setActiveRecordId(recordId: string | null, timestamp?: string): void { + const context = this.ctx as unknown as { + setActiveRecordId?: (id: string | null, timestamp?: string) => void; + }; + if (typeof context.setActiveRecordId === 'function') { + context.setActiveRecordId(recordId, timestamp); + } + } } diff --git a/packages/cli/src/commands/extensions/consent.test.ts b/packages/cli/src/commands/extensions/consent.test.ts index 7d48a7c8c..da41ec04c 100644 --- a/packages/cli/src/commands/extensions/consent.test.ts +++ b/packages/cli/src/commands/extensions/consent.test.ts @@ -35,6 +35,7 @@ describe('extensionConsentString', () => { const config: ExtensionConfig = { name: 'test-extension', version: '1.0.0', + commands: [], }; const result = extensionConsentString(config); @@ -209,6 +210,7 @@ describe('requestConsentOrFail', () => { await requestConsentOrFail(mockRequestConsent, { extensionConfig: { name: 'test-extension', version: '1.0.0' }, + originSource: 'QwenCode', }); expect(mockRequestConsent).toHaveBeenCalled(); @@ -220,6 +222,7 @@ describe('requestConsentOrFail', () => { await expect( requestConsentOrFail(mockRequestConsent, { extensionConfig: { name: 'test-extension', version: '1.0.0' }, + originSource: 'QwenCode', }), ).rejects.toThrow('Installation cancelled for "test-extension".'); }); @@ -233,6 +236,7 @@ describe('requestConsentOrFail', () => { await requestConsentOrFail(mockRequestConsent, { extensionConfig, previousExtensionConfig: extensionConfig, + originSource: 'QwenCode', }); expect(mockRequestConsent).not.toHaveBeenCalled(); @@ -246,6 +250,7 @@ describe('requestConsentOrFail', () => { commands: ['command1'], previousExtensionConfig: { name: 'test-extension', version: '1.0.0' }, previousCommands: [], + originSource: 'QwenCode', }); expect(mockRequestConsent).toHaveBeenCalled(); diff --git a/packages/cli/src/commands/extensions/consent.ts b/packages/cli/src/commands/extensions/consent.ts index cfff6e5b7..0f2321075 100644 --- a/packages/cli/src/commands/extensions/consent.ts +++ b/packages/cli/src/commands/extensions/consent.ts @@ -148,8 +148,17 @@ export function extensionConsentString( commands: string[] = [], skills: SkillConfig[] = [], subagents: SubagentConfig[] = [], + originSource: string = 'QwenCode', ): string { const output: string[] = []; + if (originSource !== 'QwenCode') { + output.push( + t( + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.', + { originSource }, + ), + ); + } const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); output.push( t('Installing extension "{{name}}".', { name: extensionConfig.name }), @@ -221,6 +230,7 @@ export const requestConsentOrFail = async ( if (!options) return; const { extensionConfig, + originSource = 'QwenCode', commands = [], skills = [], subagents = [], @@ -234,6 +244,7 @@ export const requestConsentOrFail = async ( commands, skills, subagents, + originSource, ); if (previousExtensionConfig) { const previousExtensionConsent = extensionConsentString( @@ -241,6 +252,7 @@ export const requestConsentOrFail = async ( previousCommands, previousSkills, previousSubagents, + originSource, ); if (previousExtensionConsent === extensionConsent) { return; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 67d3b114b..16ec45c65 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2045,13 +2045,13 @@ describe('loadCliConfig fileFiltering', () => { value: boolean; }> = [ { - property: 'disableFuzzySearch', - getter: (c) => c.getFileFilteringDisableFuzzySearch(), + property: 'enableFuzzySearch', + getter: (c) => c.getFileFilteringEnableFuzzySearch(), value: true, }, { - property: 'disableFuzzySearch', - getter: (c) => c.getFileFilteringDisableFuzzySearch(), + property: 'enableFuzzySearch', + getter: (c) => c.getFileFilteringEnableFuzzySearch(), value: false, }, { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 88d1d65a6..f241ec1ca 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -10,6 +10,7 @@ import { Config, DEFAULT_QWEN_EMBEDDING_MODEL, FileDiscoveryService, + FileEncoding, getCurrentGeminiMdFilename, loadServerHierarchicalMemory, setGeminiMdFilename as setServerGeminiMdFilename, @@ -131,7 +132,6 @@ export interface CliArgs { webSearchDefault: string | undefined; screenReader: boolean | undefined; vlmSwitchMode: string | undefined; - useSmartEdit: boolean | undefined; inputFormat?: string | undefined; outputFormat: string | undefined; includePartialMessages?: boolean; @@ -1007,7 +1007,6 @@ export async function loadCliConfig( truncateToolOutputLines: settings.tools?.truncateToolOutputLines, enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, eventEmitter: appEvents, - useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, gitCoAuthor: settings.general?.gitCoAuthor, output: { format: outputSettingsFormat, @@ -1018,6 +1017,8 @@ export async function loadCliConfig( // always be true and the settings file can never disable recording. chatRecording: argv.chatRecording ?? settings.general?.chatRecording ?? true, + defaultFileEncoding: + settings.general?.defaultFileEncoding ?? FileEncoding.UTF8, lsp: { enabled: lspEnabled, }, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 3f3980c10..8d8872b61 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -643,6 +643,105 @@ describe('Settings Loading and Merging', () => { expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION); }); + it('should consolidate disableAutoUpdate and disableUpdateNag - both false means enableAutoUpdate is true', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + // V1 settings with both disable* settings as false + const legacySettingsContent = { + disableAutoUpdate: false, + disableUpdateNag: false, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(legacySettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Both are false, so enableAutoUpdate should be true + expect(settings.merged.general?.enableAutoUpdate).toBe(true); + }); + + it('should consolidate disableAutoUpdate and disableUpdateNag - any true means enableAutoUpdate is false', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + // V1 settings with disableAutoUpdate=false but disableUpdateNag=true + const legacySettingsContent = { + disableAutoUpdate: false, + disableUpdateNag: true, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(legacySettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // disableUpdateNag is true, so enableAutoUpdate should be false + expect(settings.merged.general?.enableAutoUpdate).toBe(false); + }); + + it('should consolidate disableAutoUpdate and disableUpdateNag - disableAutoUpdate=true takes precedence', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + // V1 settings with disableAutoUpdate=true + const legacySettingsContent = { + disableAutoUpdate: true, + disableUpdateNag: false, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(legacySettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // disableAutoUpdate is true, so enableAutoUpdate should be false + expect(settings.merged.general?.enableAutoUpdate).toBe(false); + }); + + it('should bump version to 3 even when V2 settings already have V3-compatible content', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + // V2 settings that already have V3-compatible keys (no migration needed) + const v2SettingsWithV3Content = { + $version: 2, + general: { + enableAutoUpdate: true, + }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(v2SettingsWithV3Content); + return '{}'; + }, + ); + + loadSettings(MOCK_WORKSPACE_DIR); + + // Version should be bumped to 3 even though no keys needed migration + const writeCall = (fs.writeFileSync as Mock).mock.calls.find( + (call: unknown[]) => call[0] === USER_SETTINGS_PATH, + ); + expect(writeCall).toBeDefined(); + const writtenContent = JSON.parse(writeCall[1] as string); + expect(writtenContent.$version).toBe(SETTINGS_VERSION); + }); + it('should correctly merge and migrate legacy array properties from multiple scopes', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const legacyUserSettings = { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 0f213acf3..089bb3ef6 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -56,7 +56,7 @@ export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; const MIGRATE_V2_OVERWRITE = true; // Settings version to track migration state -export const SETTINGS_VERSION = 2; +export const SETTINGS_VERSION = 3; export const SETTINGS_VERSION_KEY = '$version'; const MIGRATION_MAP: Record = { @@ -73,8 +73,6 @@ const MIGRATION_MAP: Record = { customThemes: 'ui.customThemes', customWittyPhrases: 'ui.customWittyPhrases', debugKeystrokeLogging: 'general.debugKeystrokeLogging', - disableAutoUpdate: 'general.disableAutoUpdate', - disableUpdateNag: 'general.disableUpdateNag', dnsResolutionOrder: 'advanced.dnsResolutionOrder', enforcedAuthType: 'security.auth.enforcedType', excludeTools: 'tools.exclude', @@ -127,6 +125,43 @@ const MIGRATION_MAP: Record = { visionModelPreview: 'experimental.visionModelPreview', }; +// Settings that need boolean inversion during migration (V1 -> V3) +// Old negative naming -> new positive naming with inverted value +const INVERTED_BOOLEAN_MIGRATIONS: Record = { + disableAutoUpdate: 'general.enableAutoUpdate', + disableUpdateNag: 'general.enableAutoUpdate', + disableLoadingPhrases: 'ui.accessibility.enableLoadingPhrases', + disableFuzzySearch: 'context.fileFiltering.enableFuzzySearch', + disableCacheControl: 'model.generationConfig.enableCacheControl', +}; + +// Consolidated settings: multiple old V1 keys that map to a single new key. +// Policy: if ANY of the old disable* settings is true, the new enable* should be false. +const CONSOLIDATED_SETTINGS: Record = { + 'general.enableAutoUpdate': ['disableAutoUpdate', 'disableUpdateNag'], +}; + +// V2 nested paths that need inversion when migrating to V3 +const INVERTED_V2_PATHS: Record = { + 'general.disableAutoUpdate': 'general.enableAutoUpdate', + 'general.disableUpdateNag': 'general.enableAutoUpdate', + 'ui.accessibility.disableLoadingPhrases': + 'ui.accessibility.enableLoadingPhrases', + 'context.fileFiltering.disableFuzzySearch': + 'context.fileFiltering.enableFuzzySearch', + 'model.generationConfig.disableCacheControl': + 'model.generationConfig.enableCacheControl', +}; + +// Consolidated V2 paths: multiple old paths that map to a single new path. +// Policy: if ANY of the old disable* settings is true, the new enable* should be false. +const CONSOLIDATED_V2_PATHS: Record = { + 'general.enableAutoUpdate': [ + 'general.disableAutoUpdate', + 'general.disableUpdateNag', + ], +}; + export function getSystemSettingsPath(): string { if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) { return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; @@ -168,7 +203,7 @@ export interface SummarizeToolOutputSettings { } export interface AccessibilitySettings { - disableLoadingPhrases?: boolean; + enableLoadingPhrases?: boolean; screenReader?: boolean; } @@ -209,6 +244,14 @@ function setNestedProperty( current[lastKey] = value; } +// Dynamically determine the top-level keys from the V2 settings structure. +const KNOWN_V2_CONTAINERS = new Set([ + ...Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]), + ...Object.values(INVERTED_BOOLEAN_MIGRATIONS).map( + (path) => path.split('.')[0], + ), +]); + export function needsMigration(settings: Record): boolean { // Check version field first - if present and matches current version, no migration needed if (SETTINGS_VERSION_KEY in settings) { @@ -237,10 +280,20 @@ export function needsMigration(settings: Record): boolean { return true; }); - return hasV1Keys; + // Also check for old inverted boolean keys (disable* -> enable*) + const hasInvertedBooleanKeys = Object.keys(INVERTED_BOOLEAN_MIGRATIONS).some( + (v1Key) => v1Key in settings, + ); + + return hasV1Keys || hasInvertedBooleanKeys; } -function migrateSettingsToV2( +/** + * Migrates V1 (flat) settings directly to V3. + * This includes both structural migration (flat -> nested) and boolean + * inversion (disable* -> enable*), so migrateV2ToV3 will be skipped. + */ +function migrateV1ToV3( flatSettings: Record, ): Record | null { if (!needsMigration(flatSettings)) { @@ -272,6 +325,43 @@ function migrateSettingsToV2( } } + // Handle consolidated settings first (multiple old keys -> single new key) + // Policy: if ANY of the old disable* settings is true, the new enable* should be false + for (const [newPath, oldKeys] of Object.entries(CONSOLIDATED_SETTINGS)) { + let hasAnyDisable = false; + let hasAnyValue = false; + for (const oldKey of oldKeys) { + if (flatKeys.has(oldKey)) { + hasAnyValue = true; + const oldValue = flatSettings[oldKey]; + if (typeof oldValue === 'boolean' && oldValue === true) { + hasAnyDisable = true; + } + flatKeys.delete(oldKey); + } + } + if (hasAnyValue) { + // enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false) + setNestedProperty(v2Settings, newPath, !hasAnyDisable); + } + } + + // Handle remaining V1 settings that need boolean inversion (disable* -> enable*) + // Skip keys that were already handled by consolidated settings + const consolidatedKeys = new Set(Object.values(CONSOLIDATED_SETTINGS).flat()); + for (const [oldKey, newPath] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) { + if (consolidatedKeys.has(oldKey)) { + continue; + } + if (flatKeys.has(oldKey)) { + const oldValue = flatSettings[oldKey]; + if (typeof oldValue === 'boolean') { + setNestedProperty(v2Settings, newPath, !oldValue); + } + flatKeys.delete(oldKey); + } + } + // Preserve mcpServers at the top level if (flatSettings['mcpServers']) { v2Settings['mcpServers'] = flatSettings['mcpServers']; @@ -310,6 +400,90 @@ function migrateSettingsToV2( return v2Settings; } +// Migrate V2 settings to V3 (invert disable* -> enable* booleans) +function migrateV2ToV3( + settings: Record, +): Record | null { + const version = settings[SETTINGS_VERSION_KEY]; + if (typeof version === 'number' && version >= 3) { + return null; + } + + let changed = false; + const result = structuredClone(settings); + const processedPaths = new Set(); + + // Handle consolidated V2 paths first (multiple old paths -> single new path) + // Policy: if ANY of the old disable* settings is true, the new enable* should be false + for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) { + let hasAnyDisable = false; + let hasAnyValue = false; + for (const oldPath of oldPaths) { + const oldValue = getNestedProperty(result, oldPath); + if (typeof oldValue === 'boolean') { + hasAnyValue = true; + if (oldValue === true) { + hasAnyDisable = true; + } + deleteNestedProperty(result, oldPath); + processedPaths.add(oldPath); + changed = true; + } + } + if (hasAnyValue) { + // enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false) + setNestedProperty(result, newPath, !hasAnyDisable); + } + } + + // Handle remaining V2 paths that need inversion + for (const [oldPath, newPath] of Object.entries(INVERTED_V2_PATHS)) { + if (processedPaths.has(oldPath)) { + continue; + } + const oldValue = getNestedProperty(result, oldPath); + if (typeof oldValue === 'boolean') { + // Remove old property + deleteNestedProperty(result, oldPath); + // Set new property with inverted value + setNestedProperty(result, newPath, !oldValue); + changed = true; + } + } + + if (changed) { + result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; + return result; + } + + // Even if no changes, bump version to 3 to skip future migration checks + if (typeof version === 'number' && version < SETTINGS_VERSION) { + result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; + return result; + } + + return null; +} + +function deleteNestedProperty( + obj: Record, + path: string, +): void { + const keys = path.split('.'); + const lastKey = keys.pop(); + if (!lastKey) return; + + let current: Record = obj; + for (const key of keys) { + const next = current[key]; + if (typeof next !== 'object' || next === null) { + return; + } + current = next as Record; + } + delete current[lastKey]; +} + function getNestedProperty( obj: Record, path: string, @@ -329,9 +503,26 @@ const REVERSE_MIGRATION_MAP: Record = Object.fromEntries( Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]), ); -// Dynamically determine the top-level keys from the V2 settings structure. -const KNOWN_V2_CONTAINERS = new Set( - Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]), +// Reverse map for old V2 paths (before rename) to V1 keys. +// Used when migrating settings that still have old V2 naming (e.g., general.disableAutoUpdate). +const OLD_V2_TO_V1_MAP: Record = {}; +for (const [oldV2Path, newV3Path] of Object.entries(INVERTED_V2_PATHS)) { + // Find the V1 key that maps to this V3 path + for (const [v1Key, v3Path] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) { + if (v3Path === newV3Path) { + OLD_V2_TO_V1_MAP[oldV2Path] = v1Key; + break; + } + } +} + +// Reverse map for new V3 paths to V1 keys (with boolean inversion). +// Used when migrating settings that have new V3 naming (e.g., general.enableAutoUpdate). +const V3_TO_V1_INVERTED_MAP: Record = Object.fromEntries( + Object.entries(INVERTED_BOOLEAN_MIGRATIONS).map(([v1Key, v3Path]) => [ + v3Path, + v1Key, + ]), ); function getSettingsFileKeyWarnings( @@ -370,7 +561,7 @@ function getSettingsFileKeyWarnings( ignoredLegacyKeys.add(oldKey); warnings.push( - `⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`, + `Warning: Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`, ); } @@ -388,7 +579,7 @@ function getSettingsFileKeyWarnings( } warnings.push( - `⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`, + `Warning: Unknown setting '${key}' will be ignored in ${settingsFilePath}.`, ); } @@ -407,7 +598,8 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] { for (const scope of [SettingScope.User, SettingScope.Workspace]) { const settingsFile = loadedSettings.forScope(scope); if (settingsFile.rawJson === undefined) { - continue; // File not present / not loaded. + continue; + // File not present / not loaded. } const settingsObject = settingsFile.originalSettings as unknown as Record< string, @@ -439,6 +631,26 @@ export function migrateSettingsToV1( } } + // Handle old V2 inverted paths (no value inversion needed) + // e.g., general.disableAutoUpdate -> disableAutoUpdate + for (const [oldV2Path, v1Key] of Object.entries(OLD_V2_TO_V1_MAP)) { + const value = getNestedProperty(v2Settings, oldV2Path); + if (value !== undefined) { + v1Settings[v1Key] = value; + v2Keys.delete(oldV2Path.split('.')[0]); + } + } + + // Handle new V3 inverted paths (WITH value inversion) + // e.g., general.enableAutoUpdate -> disableAutoUpdate (inverted) + for (const [v3Path, v1Key] of Object.entries(V3_TO_V1_INVERTED_MAP)) { + const value = getNestedProperty(v2Settings, v3Path); + if (value !== undefined && typeof value === 'boolean') { + v1Settings[v1Key] = !value; + v2Keys.delete(v3Path.split('.')[0]); + } + } + // Preserve mcpServers at the top level if (v2Settings['mcpServers']) { v1Settings['mcpServers'] = v2Settings['mcpServers']; @@ -736,7 +948,7 @@ export function loadSettings( let settingsObject = rawSettings as Record; if (needsMigration(settingsObject)) { - const migratedSettings = migrateSettingsToV2(settingsObject); + const migratedSettings = migrateV1ToV3(settingsObject); if (migratedSettings) { if (MIGRATE_V2_OVERWRITE) { try { @@ -775,6 +987,33 @@ export function loadSettings( } } } + + // V2 to V3 migration (invert disable* -> enable* booleans) + const v3Migrated = migrateV2ToV3(settingsObject); + if (v3Migrated) { + if (MIGRATE_V2_OVERWRITE) { + try { + // Only backup if not already backed up by V1->V2 migration + const backupPath = `${filePath}.orig`; + if (!fs.existsSync(backupPath)) { + fs.renameSync(filePath, backupPath); + } + fs.writeFileSync( + filePath, + JSON.stringify(v3Migrated, null, 2), + 'utf-8', + ); + } catch (e) { + console.error( + `Error migrating settings file to V3: ${getErrorMessage(e)}`, + ); + } + } else { + migratedInMemorScopes.add(scope); + } + settingsObject = v3Migrated; + } + return { settings: settingsObject as Settings, rawJson: content }; } } catch (error: unknown) { diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 7d97d5465..fc902234f 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -80,7 +80,7 @@ describe('SettingsSchema', () => { ).toBeDefined(); expect( getSettingsSchema().ui?.properties?.accessibility.properties - ?.disableLoadingPhrases.type, + ?.enableLoadingPhrases.type, ).toBe('boolean'); }); @@ -164,7 +164,7 @@ describe('SettingsSchema', () => { true, ); expect( - getSettingsSchema().general.properties.disableAutoUpdate.showInDialog, + getSettingsSchema().general.properties.enableAutoUpdate.showInDialog, ).toBe(true); expect( getSettingsSchema().ui.properties.hideWindowTitle.showInDialog, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 44340b81e..52bfe0ecd 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -143,24 +143,16 @@ const SETTINGS_SCHEMA = { description: 'Enable Vim keybindings', showInDialog: true, }, - disableAutoUpdate: { + enableAutoUpdate: { type: 'boolean', - label: 'Disable Auto Update', + label: 'Enable Auto Update', category: 'General', requiresRestart: false, - default: false, - description: 'Disable automatic updates', + default: true, + description: + 'Enable automatic update checks and installations on startup.', showInDialog: true, }, - disableUpdateNag: { - type: 'boolean', - label: 'Disable Update Nag', - category: 'General', - requiresRestart: false, - default: false, - description: 'Disable update notification prompts.', - showInDialog: false, - }, gitCoAuthor: { type: 'boolean', label: 'Attribution: commit', @@ -244,6 +236,20 @@ const SETTINGS_SCHEMA = { 'Enable saving chat history to disk. Disabling this will also prevent --continue and --resume from working.', showInDialog: false, }, + defaultFileEncoding: { + type: 'enum', + label: 'Default File Encoding', + category: 'General', + requiresRestart: false, + default: 'utf-8', + description: + 'Default encoding for new files. Use "utf-8" (default) for UTF-8 without BOM, or "utf-8-bom" for UTF-8 with BOM. Only change this if your project specifically requires BOM.', + showInDialog: false, + options: [ + { value: 'utf-8', label: 'UTF-8 (without BOM)' }, + { value: 'utf-8-bom', label: 'UTF-8 with BOM' }, + ], + }, }, }, output: { @@ -382,14 +388,14 @@ const SETTINGS_SCHEMA = { description: 'Accessibility settings.', showInDialog: false, properties: { - disableLoadingPhrases: { + enableLoadingPhrases: { type: 'boolean', - label: 'Disable Loading Phrases', + label: 'Enable Loading Phrases', category: 'UI', requiresRestart: true, - default: false, - description: 'Disable loading phrases for accessibility', - showInDialog: false, + default: true, + description: 'Enable loading phrases (disable for accessibility)', + showInDialog: true, }, screenReader: { type: 'boolean', @@ -609,13 +615,13 @@ const SETTINGS_SCHEMA = { parentKey: 'generationConfig', showInDialog: false, }, - disableCacheControl: { + enableCacheControl: { type: 'boolean', - label: 'Disable Cache Control', + label: 'Enable Cache Control', category: 'Generation Configuration', requiresRestart: false, - default: false, - description: 'Disable cache control for DashScope providers.', + default: true, + description: 'Enable cache control for DashScope providers.', parentKey: 'generationConfig', showInDialog: false, }, @@ -733,14 +739,14 @@ const SETTINGS_SCHEMA = { description: 'Enable recursive file search functionality', showInDialog: false, }, - disableFuzzySearch: { + enableFuzzySearch: { type: 'boolean', - label: 'Disable Fuzzy Search', + label: 'Enable Fuzzy Search', category: 'Context', requiresRestart: true, - default: false, - description: 'Disable fuzzy search when searching for files.', - showInDialog: false, + default: true, + description: 'Enable fuzzy search when searching for files.', + showInDialog: true, }, }, }, @@ -968,15 +974,6 @@ const SETTINGS_SCHEMA = { }, }, }, - useSmartEdit: { - type: 'boolean', - label: 'Use Smart Edit', - category: 'Advanced', - requiresRestart: false, - default: false, - description: 'Enable the smart-edit tool instead of the replace tool.', - showInDialog: false, - }, security: { type: 'object', label: 'Security', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 1a480c52b..c75dc78a2 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -477,7 +477,6 @@ describe('gemini.tsx main function kitty protocol', () => { webSearchDefault: undefined, screenReader: undefined, vlmSwitchMode: undefined, - useSmartEdit: undefined, inputFormat: undefined, outputFormat: undefined, includePartialMessages: undefined, @@ -642,9 +641,20 @@ describe('startInteractiveUI', () => { expect(checkForUpdates).toHaveBeenCalledTimes(1); }); - it('should not check for updates when update nag is disabled', async () => { + it('should not call checkForUpdates when enableAutoUpdate is false', async () => { const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); + const settingsWithAutoUpdateDisabled = { + merged: { + general: { + enableAutoUpdate: false, + }, + ui: { + hideWindowTitle: false, + }, + }, + } as LoadedSettings; + const mockInitializationResult = { authError: null, themeError: null, @@ -652,26 +662,17 @@ describe('startInteractiveUI', () => { geminiMdFileCount: 0, }; - const settingsWithUpdateNagDisabled = { - merged: { - general: { - disableUpdateNag: true, - }, - ui: { - hideWindowTitle: false, - }, - }, - } as LoadedSettings; - await startInteractiveUI( mockConfig, - settingsWithUpdateNagDisabled, + settingsWithAutoUpdateDisabled, mockStartupWarnings, mockWorkspaceRoot, mockInitializationResult, ); await new Promise((resolve) => setTimeout(resolve, 0)); + + // checkForUpdates should NOT be called when enableAutoUpdate is false expect(checkForUpdates).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 16fea6311..5f839694a 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -182,7 +182,9 @@ export async function startInteractiveUI( }, ); - if (!settings.merged.general?.disableUpdateNag) { + // Check for updates only if enableAutoUpdate is not explicitly disabled. + // Using !== false ensures updates are enabled by default when undefined. + if (settings.merged.general?.enableAutoUpdate !== false) { checkForUpdates() .then((info) => { handleAutoUpdate(info, settings, config.getProjectRoot()); @@ -339,6 +341,9 @@ export async function main() { process.cwd(), argv.extensions, ); + + // Register cleanup for MCP clients as early as possible + // This ensures MCP server subprocesses are properly terminated on exit registerCleanup(() => config.shutdown()); // FIXME: list extensions after the config initialize diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 44d982378..772e40a89 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -23,6 +23,7 @@ export default { 'auto-accept edits': 'Änderungen automatisch akzeptieren', 'Accepting edits': 'Änderungen werden akzeptiert', '(shift + tab to cycle)': '(Umschalt + Tab zum Wechseln)', + '(tab to cycle)': '(Tab zum Wechseln)', 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': 'Shell-Befehle über {{symbol}} ausführen (z.B. {{example1}}) oder natürliche Sprache verwenden (z.B. {{example2}}).', '!': '!', @@ -424,6 +425,8 @@ export default { 'Diese Erweiterung wird folgende Unteragenten installieren:', 'Installation cancelled for "{{name}}".': 'Installation von "{{name}}" abgebrochen.', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'Sie installieren eine Erweiterung von {{originSource}}. Einige Funktionen funktionieren möglicherweise nicht perfekt mit Qwen Code.', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref und --auto-update sind nicht anwendbar für Marketplace-Erweiterungen.', 'Extension "{{name}}" installed successfully and enabled.': @@ -1368,4 +1371,8 @@ 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}}', + '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.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 95d908b11..be76025b7 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -23,6 +23,7 @@ export default { 'auto-accept edits': 'auto-accept edits', 'Accepting edits': 'Accepting edits', '(shift + tab to cycle)': '(shift + tab to cycle)', + '(tab to cycle)': '(tab to cycle)', 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).', '!': '!', @@ -438,6 +439,8 @@ export default { 'This extension will install the following subagents:', 'Installation cancelled for "{{name}}".': 'Installation cancelled for "{{name}}".', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref and --auto-update are not applicable for marketplace extensions.', 'Extension "{{name}}" installed successfully and enabled.': @@ -1101,6 +1104,8 @@ export default { 'You can resume a previous conversation by running qwen --continue or qwen --resume.', 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': '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.', // ============================================================================ // Exit Screen / Stats diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 201d1ee3d..2cfad0700 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -326,6 +326,8 @@ export default { 'List active extensions': '有効な拡張機能を一覧表示', 'Update extensions. Usage: update |--all': '拡張機能を更新。使い方: update <拡張機能名>|--all', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + '{{originSource}} から拡張機能をインストールしています。一部の機能は Qwen Code で完全に動作しない可能性があります。', 'manage IDE integration': 'IDE連携を管理', 'check status of IDE integration': 'IDE連携の状態を確認', 'install required IDE companion for {{ideName}}': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 40410ce61..62e81def1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -454,6 +454,8 @@ export default { 'Esta extensão instalará os seguintes subagentes:', 'Installation cancelled for "{{name}}".': 'Instalação cancelada para "{{name}}".', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'Você está instalando uma extensão de {{originSource}}. Alguns recursos podem não funcionar perfeitamente com o Qwen Code.', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref e --auto-update não são aplicáveis para extensões de marketplace.', 'Extension "{{name}}" installed successfully and enabled.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 8bdee0b5c..46778ecac 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -23,6 +23,7 @@ export default { 'auto-accept edits': 'Режим принятия правок', 'Accepting edits': 'Принятие правок', '(shift + tab to cycle)': '(shift + tab для переключения)', + '(tab to cycle)': '(Tab для переключения)', 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': 'Выполняйте команды терминала через {{symbol}} (например, {{example1}}) или используйте естественный язык (например, {{example2}}).', '!': '!', @@ -443,6 +444,8 @@ export default { 'This extension will install the following subagents:': 'Это расширение установит следующие подагенты:', 'Installation cancelled for "{{name}}".': 'Установка "{{name}}" отменена.', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'Вы устанавливаете расширение от {{originSource}}. Некоторые функции могут работать не идеально с Qwen Code.', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref и --auto-update неприменимы для расширений из маркетплейса.', 'Extension "{{name}}" installed successfully and enabled.': @@ -1372,4 +1375,8 @@ export default { 'Открываем страницу расширений в браузере: {{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': 'Не удалось открыть браузер. Посетите галерею расширений по адресу {{url}}', + '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.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 4f0523d7d..d488f9028 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -22,6 +22,7 @@ export default { 'auto-accept edits': '自动接受编辑', 'Accepting edits': '接受编辑', '(shift + tab to cycle)': '(shift + tab 切换)', + '(tab to cycle)': '(按 tab 切换)', 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': '通过 {{symbol}} 执行 shell 命令(例如,{{example1}})或使用自然语言(例如,{{example2}})', '!': '!', @@ -144,15 +145,15 @@ export default { // Commands - Agents // ============================================================================ 'Manage subagents for specialized task delegation.': - '管理用于专门任务委派的子代理', + '管理用于专门任务委派的子智能体', 'Manage existing subagents (view, edit, delete).': - '管理现有子代理(查看、编辑、删除)', - 'Create a new subagent with guided setup.': '通过引导式设置创建新的子代理', + '管理现有子智能体(查看、编辑、删除)', + 'Create a new subagent with guided setup.': '通过引导式设置创建新的子智能体', // ============================================================================ // Agents - Management Dialog // ============================================================================ - Agents: '代理', + Agents: '智能体', 'Choose Action': '选择操作', 'Edit {{name}}': '编辑 {{name}}', 'Edit Tools: {{name}}': '编辑工具: {{name}}', @@ -167,21 +168,21 @@ export default { 'Enter to select, ↑↓ to navigate, Esc to go back': 'Enter 选择,↑↓ 导航,Esc 返回', 'Invalid step: {{step}}': '无效步骤: {{step}}', - 'No subagents found.': '未找到子代理。', + 'No subagents found.': '未找到子智能体。', "Use '/agents create' to create your first subagent.": - "使用 '/agents create' 创建您的第一个子代理。", + "使用 '/agents create' 创建您的第一个子智能体。", '(built-in)': '(内置)', - '(overridden by project level agent)': '(已被项目级代理覆盖)', + '(overridden by project level agent)': '(已被项目级智能体覆盖)', 'Project Level ({{path}})': '项目级 ({{path}})', 'User Level ({{path}})': '用户级 ({{path}})', - 'Built-in Agents': '内置代理', - 'Extension Agents': '扩展代理', - 'Using: {{count}} agents': '使用中: {{count}} 个代理', - 'View Agent': '查看代理', - 'Edit Agent': '编辑代理', - 'Delete Agent': '删除代理', + 'Built-in Agents': '内置智能体', + 'Extension Agents': '扩展智能体', + 'Using: {{count}} agents': '使用中: {{count}} 个智能体', + 'View Agent': '查看智能体', + 'Edit Agent': '编辑智能体', + 'Delete Agent': '删除智能体', Back: '返回', - 'No agent selected': '未选择代理', + 'No agent selected': '未选择智能体', 'File Path: ': '文件路径: ', 'Tools: ': '工具: ', 'Color: ': '颜色: ', @@ -192,25 +193,25 @@ export default { 'Edit color': '编辑颜色', '❌ Error:': '❌ 错误:', 'Are you sure you want to delete agent "{{name}}"?': - '您确定要删除代理 "{{name}}" 吗?', + '您确定要删除智能体 "{{name}}" 吗?', // ============================================================================ // Agents - Creation Wizard // ============================================================================ 'Project Level (.qwen/agents/)': '项目级 (.qwen/agents/)', 'User Level (~/.qwen/agents/)': '用户级 (~/.qwen/agents/)', - '✅ Subagent Created Successfully!': '✅ 子代理创建成功!', + '✅ Subagent Created Successfully!': '✅ 子智能体创建成功!', 'Subagent "{{name}}" has been saved to {{level}} level.': - '子代理 "{{name}}" 已保存到 {{level}} 级别。', + '子智能体 "{{name}}" 已保存到 {{level}} 级别。', 'Name: ': '名称: ', 'Location: ': '位置: ', - '❌ Error saving subagent:': '❌ 保存子代理时出错:', + '❌ Error saving subagent:': '❌ 保存子智能体时出错:', 'Warnings:': '警告:', 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': - '名称 "{{name}}" 在 {{level}} 级别已存在 - 将覆盖现有子代理', + '名称 "{{name}}" 在 {{level}} 级别已存在 - 将覆盖现有子智能体', 'Name "{{name}}" exists at user level - project level will take precedence': '名称 "{{name}}" 在用户级别存在 - 项目级别将优先', 'Name "{{name}}" exists at project level - existing subagent will take precedence': - '名称 "{{name}}" 在项目级别存在 - 现有子代理将优先', + '名称 "{{name}}" 在项目级别存在 - 现有子智能体将优先', 'Description is over {{length}} characters': '描述超过 {{length}} 个字符', 'System prompt is over {{length}} characters': '系统提示超过 {{length}} 个字符', @@ -220,13 +221,13 @@ export default { 'Generate with Qwen Code (Recommended)': '使用 Qwen Code 生成(推荐)', 'Manual Creation': '手动创建', 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': - '描述此子代理应该做什么以及何时使用它。(为了获得最佳效果,请全面描述)', + '描述此子智能体应该做什么以及何时使用它。(为了获得最佳效果,请全面描述)', 'e.g., Expert code reviewer that reviews code based on best practices...': '例如:专业的代码审查员,根据最佳实践审查代码...', - 'Generating subagent configuration...': '正在生成子代理配置...', - 'Failed to generate subagent: {{error}}': '生成子代理失败: {{error}}', - 'Step {{n}}: Describe Your Subagent': '步骤 {{n}}: 描述您的子代理', - 'Step {{n}}: Enter Subagent Name': '步骤 {{n}}: 输入子代理名称', + 'Generating subagent configuration...': '正在生成子智能体配置...', + 'Failed to generate subagent: {{error}}': '生成子智能体失败: {{error}}', + 'Step {{n}}: Describe Your Subagent': '步骤 {{n}}: 描述您的子智能体', + 'Step {{n}}: Enter Subagent Name': '步骤 {{n}}: 输入子智能体名称', 'Step {{n}}: Enter System Prompt': '步骤 {{n}}: 输入系统提示', 'Step {{n}}: Enter Description': '步骤 {{n}}: 输入描述', // Agents - Tool Selection @@ -253,22 +254,22 @@ export default { 'go back': '返回', '↑↓ to navigate, ': '↑↓ 导航,', 'Enter a clear, unique name for this subagent.': - '为此子代理输入一个清晰、唯一的名称。', + '为此子智能体输入一个清晰、唯一的名称。', 'e.g., Code Reviewer': '例如:代码审查员', 'Name cannot be empty.': '名称不能为空。', "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": - '编写定义此子代理行为的系统提示。为了获得最佳效果,请全面描述。', + '编写定义此子智能体行为的系统提示。为了获得最佳效果,请全面描述。', 'e.g., You are an expert code reviewer...': '例如:您是一位专业的代码审查员...', 'System prompt cannot be empty.': '系统提示不能为空。', 'Describe when and how this subagent should be used.': - '描述何时以及如何使用此子代理。', + '描述何时以及如何使用此子智能体。', 'e.g., Reviews code for best practices and potential bugs.': '例如:审查代码以查找最佳实践和潜在错误。', 'Description cannot be empty.': '描述不能为空。', 'Failed to launch editor: {{error}}': '启动编辑器失败: {{error}}', 'Failed to save and edit subagent: {{error}}': - '保存并编辑子代理失败: {{error}}', + '保存并编辑子智能体失败: {{error}}', // ============================================================================ // Commands - General (continued) @@ -418,8 +419,10 @@ export default { '此扩展将排除以下核心工具:{{tools}}', 'This extension will install the following skills:': '此扩展将安装以下技能:', 'This extension will install the following subagents:': - '此扩展将安装以下子代理:', + '此扩展将安装以下子智能体:', 'Installation cancelled for "{{name}}".': '已取消安装 "{{name}}"。', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + '您正在安装来自 {{originSource}} 的扩展。某些功能可能无法完美兼容 Qwen Code。', '--ref and --auto-update are not applicable for marketplace extensions.': '--ref 和 --auto-update 不适用于市场扩展。', 'Extension "{{name}}" installed successfully and enabled.': @@ -478,7 +481,7 @@ export default { 'Enabled (Workspace):': '已启用(工作区):', 'Context files:': '上下文文件:', 'Skills:': '技能:', - 'Agents:': '代理:', + 'Agents:': '智能体:', 'MCP servers:': 'MCP 服务器:', 'Link extension failed to install.': '链接扩展安装失败。', 'Extension "{{name}}" linked successfully and enabled.': @@ -1041,6 +1044,8 @@ export default { '运行 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 可快速切换权限模式。', // ============================================================================ // Exit Screen / Stats @@ -1056,7 +1061,7 @@ export default { 'Code Changes:': '代码变更:', Performance: '性能', 'Wall Time:': '总耗时:', - 'Agent Active:': '代理活跃时间:', + 'Agent Active:': '智能体活跃时间:', 'API Time:': 'API 时间:', 'Tool Time:': '工具时间:', 'Session Stats': '会话统计', diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 89b742fc2..8e2237766 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -18,6 +18,7 @@ import { copyCommand } from '../ui/commands/copyCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; +import { exportCommand } from '../ui/commands/exportCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; @@ -67,6 +68,7 @@ export class BuiltinCommandLoader implements ICommandLoader { docsCommand, directoryCommand, editorCommand, + exportCommand, extensionsCommand, helpCommand, await ideCommand(), diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 610cb1152..83208614f 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -438,9 +438,11 @@ describe('AuthDialog', () => { await wait(); // Should show error message instead of calling handleAuthSelect - expect(lastFrame()).toContain( - 'You must select an auth method to proceed. Press Ctrl+C again to exit.', - ); + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('You must select an auth method'); + expect(frame).toContain('Press Ctrl+C again to exit'); + }); expect(handleAuthSelect).not.toHaveBeenCalled(); unmount(); }); diff --git a/packages/cli/src/ui/commands/exportCommand.test.ts b/packages/cli/src/ui/commands/exportCommand.test.ts new file mode 100644 index 000000000..9d931b328 --- /dev/null +++ b/packages/cli/src/ui/commands/exportCommand.test.ts @@ -0,0 +1,383 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import { exportCommand } from './exportCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { ChatRecord } from '@qwen-code/qwen-code-core'; +import type { Part, Content } from '@google/genai'; +import { + collectSessionData, + normalizeSessionData, + toMarkdown, + toHtml, + generateExportFilename, +} from '../utils/export/index.js'; + +const mockSessionServiceMocks = vi.hoisted(() => ({ + loadLastSession: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', () => { + class SessionService { + constructor(_cwd: string) {} + async loadLastSession() { + return mockSessionServiceMocks.loadLastSession(); + } + } + + return { + SessionService, + }; +}); + +vi.mock('../utils/export/index.js', () => ({ + collectSessionData: vi.fn(), + normalizeSessionData: vi.fn(), + toMarkdown: vi.fn(), + toHtml: vi.fn(), + generateExportFilename: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn(), +})); + +describe('exportCommand', () => { + const mockSessionData = { + conversation: { + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: [ + { + type: 'user', + message: { + parts: [{ text: 'Hello' }] as Part[], + } as Content, + }, + ] as ChatRecord[], + }, + }; + + let mockContext: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData); + + mockContext = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue('/test/dir'), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + + vi.mocked(collectSessionData).mockResolvedValue({ + sessionId: 'test-session-id', + startTime: '2025-01-01T00:00:00Z', + messages: [], + }); + vi.mocked(normalizeSessionData).mockImplementation((data) => data); + vi.mocked(toMarkdown).mockReturnValue('# Test Markdown'); + vi.mocked(toHtml).mockReturnValue( + '', + ); + vi.mocked(generateExportFilename).mockImplementation( + (ext: string) => `export-2025-01-01T00-00-00-000Z.${ext}`, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('command structure', () => { + it('should have correct name and description', () => { + expect(exportCommand.name).toBe('export'); + expect(exportCommand.description).toBe( + 'Export current session message history to a file', + ); + }); + + it('should have html, md, json, and jsonl subcommands', () => { + expect(exportCommand.subCommands).toHaveLength(4); + expect(exportCommand.subCommands?.map((c) => c.name)).toEqual([ + 'html', + 'md', + 'json', + 'jsonl', + ]); + }); + }); + + describe('exportMarkdownAction', () => { + it('should export session to markdown file', async () => { + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + + const result = await mdCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'), + }); + + expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(collectSessionData).toHaveBeenCalledWith( + mockSessionData.conversation, + expect.anything(), + ); + expect(normalizeSessionData).toHaveBeenCalled(); + expect(toMarkdown).toHaveBeenCalled(); + expect(generateExportFilename).toHaveBeenCalledWith('md'); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'), + '# Test Markdown', + 'utf-8', + ); + }); + + it('should return error when config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }); + }); + + it('should return error when working directory cannot be determined', async () => { + const contextWithoutCwd = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue(null), + getProjectRoot: vi.fn().mockReturnValue(null), + }, + }, + }); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand || !mdCommand.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(contextWithoutCwd, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }); + }); + + it('should return error when no session is found', async () => { + mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }); + }); + + it('should handle errors during export', async () => { + const error = new Error('File write failed'); + vi.mocked(fs.writeFile).mockRejectedValue(error); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + const result = await mdCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: File write failed', + }); + }); + + it('should use project root when working dir is not available', async () => { + const contextWithProjectRoot = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue(null), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + }, + }, + }); + + const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); + if (!mdCommand?.action) { + throw new Error('md command not found'); + } + await mdCommand.action(contextWithProjectRoot, ''); + }); + }); + + describe('exportHtmlAction', () => { + it('should export session to HTML file', async () => { + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining( + 'export-2025-01-01T00-00-00-000Z.html', + ), + }); + + expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(collectSessionData).toHaveBeenCalledWith( + mockSessionData.conversation, + expect.anything(), + ); + expect(normalizeSessionData).toHaveBeenCalled(); + expect(toHtml).toHaveBeenCalled(); + expect(generateExportFilename).toHaveBeenCalledWith('html'); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('export-2025-01-01T00-00-00-000Z.html'), + expect.stringContaining('{"data": "test"}'), + 'utf-8', + ); + }); + + it('should return error when config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }); + }); + + it('should return error when working directory cannot be determined', async () => { + const contextWithoutCwd = createMockCommandContext({ + services: { + config: { + getWorkingDir: vi.fn().mockReturnValue(null), + getProjectRoot: vi.fn().mockReturnValue(null), + }, + }, + }); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand || !htmlCommand.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(contextWithoutCwd, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }); + }); + + it('should return error when no session is found', async () => { + mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }); + }); + + it('should handle errors during HTML generation', async () => { + const error = new Error('Failed to generate HTML'); + vi.mocked(toHtml).mockImplementation(() => { + throw error; + }); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: Failed to generate HTML', + }); + }); + + it('should handle errors during file write', async () => { + const error = new Error('File write failed'); + vi.mocked(fs.writeFile).mockRejectedValue(error); + + const htmlCommand = exportCommand.subCommands?.find( + (c) => c.name === 'html', + ); + if (!htmlCommand?.action) { + throw new Error('html command not found'); + } + const result = await htmlCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: File write failed', + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts new file mode 100644 index 000000000..42af225ac --- /dev/null +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -0,0 +1,347 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { + type CommandContext, + type SlashCommand, + type MessageActionReturn, + CommandKind, +} from './types.js'; +import { SessionService } from '@qwen-code/qwen-code-core'; +import { + collectSessionData, + normalizeSessionData, + toMarkdown, + toHtml, + toJson, + toJsonl, + generateExportFilename, +} from '../utils/export/index.js'; + +/** + * Action for the 'md' subcommand - exports session to markdown. + */ +async function exportMarkdownAction( + context: CommandContext, +): Promise { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const cwd = config.getWorkingDir() || config.getProjectRoot(); + if (!cwd) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }; + } + + try { + // Load the current session + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadLastSession(); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + const { conversation } = sessionData; + + // Collect and normalize export data (SSOT) + const exportData = await collectSessionData(conversation, config); + const normalizedData = normalizeSessionData( + exportData, + conversation.messages, + config, + ); + + // Generate markdown from SSOT + const markdown = toMarkdown(normalizedData); + + const filename = generateExportFilename('md'); + const filepath = path.join(cwd, filename); + + // Write to file + await fs.writeFile(filepath, markdown, 'utf-8'); + + return { + type: 'message', + messageType: 'info', + content: `Session exported to markdown: ${filename}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Action for the 'html' subcommand - exports session to HTML. + */ +async function exportHtmlAction( + context: CommandContext, +): Promise { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const cwd = config.getWorkingDir() || config.getProjectRoot(); + if (!cwd) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }; + } + + try { + // Load the current session + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadLastSession(); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + const { conversation } = sessionData; + + // Collect and normalize export data (SSOT) + const exportData = await collectSessionData(conversation, config); + const normalizedData = normalizeSessionData( + exportData, + conversation.messages, + config, + ); + + // Generate HTML from SSOT + const html = toHtml(normalizedData); + + const filename = generateExportFilename('html'); + const filepath = path.join(cwd, filename); + + // Write to file + await fs.writeFile(filepath, html, 'utf-8'); + + return { + type: 'message', + messageType: 'info', + content: `Session exported to HTML: ${filename}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Action for the 'json' subcommand - exports session to JSON. + */ +async function exportJsonAction( + context: CommandContext, +): Promise { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const cwd = config.getWorkingDir() || config.getProjectRoot(); + if (!cwd) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }; + } + + try { + // Load the current session + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadLastSession(); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + const { conversation } = sessionData; + + // Collect and normalize export data (SSOT) + const exportData = await collectSessionData(conversation, config); + const normalizedData = normalizeSessionData( + exportData, + conversation.messages, + config, + ); + + // Generate JSON from SSOT + const json = toJson(normalizedData); + + const filename = generateExportFilename('json'); + const filepath = path.join(cwd, filename); + + // Write to file + await fs.writeFile(filepath, json, 'utf-8'); + + return { + type: 'message', + messageType: 'info', + content: `Session exported to JSON: ${filename}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Action for the 'jsonl' subcommand - exports session to JSONL. + */ +async function exportJsonlAction( + context: CommandContext, +): Promise { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const cwd = config.getWorkingDir() || config.getProjectRoot(); + if (!cwd) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine current working directory.', + }; + } + + try { + // Load the current session + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadLastSession(); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + const { conversation } = sessionData; + + // Collect and normalize export data (SSOT) + const exportData = await collectSessionData(conversation, config); + const normalizedData = normalizeSessionData( + exportData, + conversation.messages, + config, + ); + + // Generate JSONL from SSOT + const jsonl = toJsonl(normalizedData); + + const filename = generateExportFilename('jsonl'); + const filepath = path.join(cwd, filename); + + // Write to file + await fs.writeFile(filepath, jsonl, 'utf-8'); + + return { + type: 'message', + messageType: 'info', + content: `Session exported to JSONL: ${filename}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Main export command with subcommands. + */ +export const exportCommand: SlashCommand = { + name: 'export', + description: 'Export current session message history to a file', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'html', + description: 'Export session to HTML format', + kind: CommandKind.BUILT_IN, + action: exportHtmlAction, + }, + { + name: 'md', + description: 'Export session to markdown format', + kind: CommandKind.BUILT_IN, + action: exportMarkdownAction, + }, + { + name: 'json', + description: 'Export session to JSON format', + kind: CommandKind.BUILT_IN, + action: exportJsonAction, + }, + { + name: 'jsonl', + description: 'Export session to JSONL format (one message per line)', + kind: CommandKind.BUILT_IN, + action: exportJsonlAction, + }, + ], +}; diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx index 550c77dc7..d22b39a19 100644 --- a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx +++ b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx @@ -21,21 +21,26 @@ export const AutoAcceptIndicator: React.FC = ({ let textContent = ''; let subText = ''; + const cycleText = + process.platform === 'win32' + ? ` ${t('(tab to cycle)')}` + : ` ${t('(shift + tab to cycle)')}`; + switch (approvalMode) { case ApprovalMode.PLAN: textColor = theme.status.success; textContent = t('plan mode'); - subText = ` ${t('(shift + tab to cycle)')}`; + subText = cycleText; break; case ApprovalMode.AUTO_EDIT: textColor = theme.status.warning; textContent = t('auto-accept edits'); - subText = ` ${t('(shift + tab to cycle)')}`; + subText = cycleText; break; case ApprovalMode.YOLO: textColor = theme.status.error; textContent = t('YOLO mode'); - subText = ` ${t('(shift + tab to cycle)')}`; + subText = cycleText; break; case ApprovalMode.DEFAULT: default: diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 50b04a1d2..30608a961 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -56,14 +56,16 @@ export const Composer = () => { {!uiState.embeddedShellFocused && ( { + it('should render platform-specific keyboard shortcuts', () => { + const { lastFrame } = render(); + const output = lastFrame(); + + if (process.platform === 'win32') { + expect(output).toContain('Tab'); + expect(output).not.toContain('Shift+Tab'); + } else { + expect(output).toContain('Shift+Tab'); + } + }); + it('should not render hidden commands', () => { const { lastFrame } = render(); const output = lastFrame(); diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index 6b51a6a8c..64c2f7688 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -154,7 +154,7 @@ export const Help: React.FC = ({ commands, width }) => ( - Shift+Tab + {process.platform === 'win32' ? 'Tab' : 'Shift+Tab'} {' '} - {t('Cycle approval modes')} diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx index 75ca5eca9..9ce49b415 100644 --- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx +++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx @@ -28,7 +28,10 @@ const getShortcuts = (): Shortcut[] => [ { key: '/', description: t('for commands') }, { key: '@', description: t('for file paths') }, { key: 'esc esc', description: t('to clear input') }, - { key: 'shift+tab', description: t('to cycle approvals') }, + { + key: process.platform === 'win32' ? 'tab' : 'shift+tab', + description: t('to cycle approvals'), + }, { key: 'ctrl+c', description: t('to quit') }, { key: getNewlineKey(), description: t('for newline') + ' ⏎' }, { key: 'ctrl+l', description: t('to clear screen') }, diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 98da6031f..8bff7a2a2 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -55,7 +55,6 @@ const renderComponent = ( switchModel: vi.fn().mockResolvedValue(undefined), getAuthType: vi.fn(() => 'qwen-oauth'), - // --- Functions used by ClearcutLogger --- getUsageStatisticsEnabled: vi.fn(() => true), getSessionId: vi.fn(() => 'mock-session-id'), getDebugMode: vi.fn(() => false), @@ -63,7 +62,6 @@ const renderComponent = ( authType: AuthType.QWEN_OAUTH, model: MAINLINE_CODER, })), - getUseSmartEdit: vi.fn(() => false), getUseModelRouter: vi.fn(() => false), getProxy: vi.fn(() => undefined), diff --git a/packages/cli/src/ui/components/Tips.tsx b/packages/cli/src/ui/components/Tips.tsx index 62d82ba4c..d1b6a71bf 100644 --- a/packages/cli/src/ui/components/Tips.tsx +++ b/packages/cli/src/ui/components/Tips.tsx @@ -17,7 +17,9 @@ const startupTips = [ 'You can run any shell commands from Qwen Code using ! (e.g. !ls).', 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.', 'You can resume a previous conversation by running qwen --continue or qwen --resume.', - 'You can switch permission mode quickly with Shift+Tab or /approval-mode.', + 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; export const Tips: React.FC = () => { diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index e379b87b0..588d53fcf 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -49,7 +49,7 @@ describe('useAtCompletion', () => { respectQwenIgnore: true, })), getEnableRecursiveFileSearch: () => true, - getFileFilteringDisableFuzzySearch: () => false, + getFileFilteringEnableFuzzySearch: () => true, } as unknown as Config; vi.clearAllMocks(); }); @@ -197,7 +197,7 @@ describe('useAtCompletion', () => { cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, - disableFuzzySearch: false, + enableFuzzySearch: true, }); await realFileSearch.initialize(); @@ -479,7 +479,7 @@ describe('useAtCompletion', () => { respectGitIgnore: true, respectQwenIgnore: true, })), - getFileFilteringDisableFuzzySearch: () => false, + getFileFilteringEnableFuzzySearch: () => true, } as unknown as Config; const { result } = renderHook(() => diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index 3d7da8978..8f3c870ba 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -166,8 +166,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void { cacheTtl: 30, // 30 seconds enableRecursiveFileSearch: config?.getEnableRecursiveFileSearch() ?? true, - disableFuzzySearch: - config?.getFileFilteringDisableFuzzySearch() ?? false, + // Use enableFuzzySearch with !== false to default to true when undefined. + enableFuzzySearch: + config?.getFileFilteringEnableFuzzySearch() !== false, }); await searcher.initialize(); fileSearch.current = searcher; diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts index 35e7d7430..430fc4c3c 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts @@ -240,7 +240,13 @@ describe('useAutoAcceptIndicator', () => { shift: false, } as Key); }); - expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); + if (process.platform === 'win32') { + // On Windows, Tab alone toggles approval mode + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalled(); + mockConfigInstance.setApprovalMode.mockClear(); + } else { + expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); + } act(() => { capturedUseKeypressHandler({ diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts index e09c5d0eb..e3908608c 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts @@ -36,7 +36,16 @@ export function useAutoAcceptIndicator({ useKeypress( (key) => { // Handle Shift+Tab to cycle through all modes - if (key.shift && key.name === 'tab') { + // On Windows, Shift+Tab is indistinguishable from Tab (\t) in some terminals, + // so we allow Tab to switch modes as well to support the shortcut. + const isShiftTab = key.shift && key.name === 'tab'; + const isWindowsTab = + process.platform === 'win32' && + key.name === 'tab' && + !key.ctrl && + !key.meta; + + if (isShiftTab || isWindowsTab) { const currentMode = config.getApprovalMode(); const currentIndex = APPROVAL_MODES.indexOf(currentMode); const nextIndex = diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index d9aafa2e2..7ebfc2200 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -233,7 +233,6 @@ describe('useGeminiStream', () => { .fn() .mockReturnValue(contentGeneratorConfig), getMaxSessionTurns: vi.fn(() => 50), - getUseSmartEdit: () => false, } as unknown as Config; mockOnDebugMessage = vi.fn(); mockHandleSlashCommand = vi.fn().mockResolvedValue(false); diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 961b52b24..4e0b753d3 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -64,7 +64,6 @@ const mockConfig = { model: 'test-model', authType: 'gemini', }), - getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }), diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts new file mode 100644 index 000000000..30943eee9 --- /dev/null +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -0,0 +1,266 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import type { Config, ChatRecord } from '@qwen-code/qwen-code-core'; +import type { SessionContext } from '../../../acp-integration/session/types.js'; +import type * as acp from '../../../acp-integration/acp.js'; +import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js'; +import type { ExportMessage, ExportSessionData } from './types.js'; + +/** + * Export session context that captures session updates into export messages. + * Implements SessionContext to work with HistoryReplayer. + */ +class ExportSessionContext implements SessionContext { + readonly sessionId: string; + readonly config: Config; + private messages: ExportMessage[] = []; + private currentMessage: { + type: 'user' | 'assistant'; + role: 'user' | 'assistant' | 'thinking'; + parts: Array<{ text: string }>; + timestamp: number; + } | null = null; + private activeRecordId: string | null = null; + private activeRecordTimestamp: string | null = null; + private toolCallMap: Map = new Map(); + + constructor(sessionId: string, config: Config) { + this.sessionId = sessionId; + this.config = config; + } + + async sendUpdate(update: acp.SessionUpdate): Promise { + switch (update.sessionUpdate) { + case 'user_message_chunk': + this.handleMessageChunk('user', update.content); + break; + case 'agent_message_chunk': + this.handleMessageChunk('assistant', update.content); + break; + case 'agent_thought_chunk': + this.handleMessageChunk('assistant', update.content, 'thinking'); + break; + case 'tool_call': + this.flushCurrentMessage(); + this.handleToolCallStart(update); + break; + case 'tool_call_update': + this.handleToolCallUpdate(update); + break; + case 'plan': + this.flushCurrentMessage(); + this.handlePlanUpdate(update); + break; + default: + // Ignore other update types + break; + } + } + + setActiveRecordId(recordId: string | null, timestamp?: string): void { + this.activeRecordId = recordId; + this.activeRecordTimestamp = timestamp ?? null; + } + + private getMessageTimestamp(): string { + return this.activeRecordTimestamp ?? new Date().toISOString(); + } + + private getMessageUuid(): string { + return this.activeRecordId ?? randomUUID(); + } + + private handleMessageChunk( + role: 'user' | 'assistant', + content: { type: string; text?: string }, + messageRole: 'user' | 'assistant' | 'thinking' = role, + ): void { + if (content.type !== 'text' || !content.text) return; + + // If we're starting a new message type, flush the previous one + if ( + this.currentMessage && + (this.currentMessage.type !== role || + this.currentMessage.role !== messageRole) + ) { + this.flushCurrentMessage(); + } + + // Add to current message or create new one + if ( + this.currentMessage && + this.currentMessage.type === role && + this.currentMessage.role === messageRole + ) { + this.currentMessage.parts.push({ text: content.text }); + } else { + this.currentMessage = { + type: role, + role: messageRole, + parts: [{ text: content.text }], + timestamp: Date.now(), + }; + } + } + + private handleToolCallStart(update: acp.ToolCall): void { + const toolCall: ExportMessage['toolCall'] = { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: + typeof update.title === 'string' ? update.title : update.title || '', + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + locations: update.locations, + timestamp: Date.now(), + }; + + this.toolCallMap.set(update.toolCallId, toolCall); + + // Immediately add tool call to messages to preserve order + const uuid = this.getMessageUuid(); + this.messages.push({ + uuid, + sessionId: this.sessionId, + timestamp: this.getMessageTimestamp(), + type: 'tool_call', + toolCall, + }); + } + + private handleToolCallUpdate(update: { + toolCallId: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed' | null; + title?: string | null; + content?: Array<{ type: string; [key: string]: unknown }> | null; + kind?: string | null; + }): void { + const toolCall = this.toolCallMap.get(update.toolCallId); + if (toolCall) { + // Update the tool call in place + if (update.status) toolCall.status = update.status; + if (update.content) toolCall.content = update.content; + if (update.title) + toolCall.title = typeof update.title === 'string' ? update.title : ''; + } + } + + private handlePlanUpdate(update: { + entries: Array<{ + content: string; + status: 'pending' | 'in_progress' | 'completed'; + priority?: string; + }>; + }): void { + // Create a tool_call message for plan updates (TodoWriteTool) + // This ensures todos appear at the correct position in the chat + const uuid = this.getMessageUuid(); + const timestamp = this.getMessageTimestamp(); + + // Format entries as markdown checklist text for UpdatedPlanToolCall.parsePlanEntries + const todoText = update.entries + .map((entry) => { + const checkbox = + entry.status === 'completed' + ? '[x]' + : entry.status === 'in_progress' + ? '[-]' + : '[ ]'; + return `- ${checkbox} ${entry.content}`; + }) + .join('\n'); + + const todoContent = [ + { + type: 'content' as const, + content: { + type: 'text', + text: todoText, + }, + }, + ]; + + this.messages.push({ + uuid, + sessionId: this.sessionId, + timestamp, + type: 'tool_call', + toolCall: { + toolCallId: uuid, // Use the same uuid as toolCallId for plan updates + kind: 'todowrite', + title: 'TodoWrite', + status: 'completed', + content: todoContent, + timestamp: Date.parse(timestamp), + }, + }); + } + + private flushCurrentMessage(): void { + if (!this.currentMessage) return; + + const uuid = this.getMessageUuid(); + this.messages.push({ + uuid, + sessionId: this.sessionId, + timestamp: this.getMessageTimestamp(), + type: this.currentMessage.type, + message: { + role: this.currentMessage.role, + parts: this.currentMessage.parts, + }, + }); + + this.currentMessage = null; + } + + flushMessages(): void { + this.flushCurrentMessage(); + } + + getMessages(): ExportMessage[] { + return this.messages; + } +} + +/** + * Collects session data from ChatRecord[] using HistoryReplayer. + * Returns the raw ExportSessionData (SSOT) without normalization. + */ +export async function collectSessionData( + conversation: { + sessionId: string; + startTime: string; + messages: ChatRecord[]; + }, + config: Config, +): Promise { + // Create export session context + const exportContext = new ExportSessionContext( + conversation.sessionId, + config, + ); + + // Create history replayer with export context + const replayer = new HistoryReplayer(exportContext); + + // Replay chat records to build export messages + await replayer.replay(conversation.messages); + + // Flush any buffered messages + exportContext.flushMessages(); + + // Get the export messages + const messages = exportContext.getMessages(); + + return { + sessionId: conversation.sessionId, + startTime: conversation.startTime, + messages, + }; +} diff --git a/packages/cli/src/ui/utils/export/formatters/html.ts b/packages/cli/src/ui/utils/export/formatters/html.ts new file mode 100644 index 000000000..fe25ac633 --- /dev/null +++ b/packages/cli/src/ui/utils/export/formatters/html.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ExportSessionData } from '../types.js'; +import { HTML_TEMPLATE } from './htmlTemplate.js'; + +/** + * Escapes JSON for safe embedding in HTML. + */ +function escapeJsonForHtml(json: string): string { + return json + .replace(/<\/script/gi, '<\\/script') + .replace(/&/g, '\\u0026') + .replace(//g, '\\u003e') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + +/** + * Loads the HTML template built from assets. + */ +export function loadHtmlTemplate(): string { + return HTML_TEMPLATE; +} + +/** + * Injects JSON data into the HTML template. + */ +export function injectDataIntoHtmlTemplate( + template: string, + data: { + sessionId: string; + startTime: string; + messages: unknown[]; + }, +): string { + const jsonData = JSON.stringify(data, null, 2); + const escapedJsonData = escapeJsonForHtml(jsonData); + const idAttribute = 'id="chat-data"'; + const idIndex = template.indexOf(idAttribute); + if (idIndex === -1) { + return template; + } + + const openTagStart = template.lastIndexOf('', idIndex); + if (openTagEnd === -1) { + return template; + } + + const closeTagStart = template.indexOf('', openTagEnd); + if (closeTagStart === -1) { + return template; + } + + const lineStart = template.lastIndexOf('\n', openTagStart); + const lineIndent = + lineStart === -1 ? '' : template.slice(lineStart + 1, openTagStart); + const indentedJson = escapedJsonData + .split('\n') + .map((line) => `${lineIndent}${line}`) + .join('\n'); + + const before = template.slice(0, openTagEnd + 1); + const after = template.slice(closeTagStart); + return `${before}\n${indentedJson}\n${after}`; +} + +/** + * Converts ExportSessionData to HTML format. + */ +export function toHtml(sessionData: ExportSessionData): string { + const template = loadHtmlTemplate(); + return injectDataIntoHtmlTemplate(template, sessionData); +} diff --git a/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts b/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts new file mode 100644 index 000000000..c553d3f8c --- /dev/null +++ b/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts @@ -0,0 +1,10 @@ +/** + * @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