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/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 c73b96ded..6acd0e819 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -49,12 +49,12 @@ In addition to a project settings file, a project's `.qwen` directory can contai 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` | | +| 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] > @@ -77,13 +77,14 @@ Settings are organized into categories. All settings should be placed within the #### general -| Setting | Type | Description | Default | -| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- | -| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | -| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | -| `general.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` | +| 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 @@ -124,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` | +| 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` | +| `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:** 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/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..6b42f369f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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..43d142ebd 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "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/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/config/config.ts b/packages/cli/src/config/config.ts index d4752d4be..f8bd784a3 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, @@ -1030,6 +1031,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/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b1a7f1299..4f89ed179 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -236,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: { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index da7f29151..5f839694a 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -341,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..a4f0597d8 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}}).', '!': '!', @@ -1368,4 +1369,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..367fc5fe6 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}}).', '!': '!', @@ -1101,6 +1102,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/ru.js b/packages/cli/src/i18n/locales/ru.js index 8bdee0b5c..a6b97ed41 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}}).', '!': '!', @@ -1372,4 +1373,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..8bb85cc7f 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}})', '!': '!', @@ -1041,6 +1042,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 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/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/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx index 0540247db..23b379eaf 100644 --- a/packages/cli/src/ui/components/Help.test.tsx +++ b/packages/cli/src/ui/components/Help.test.tsx @@ -46,6 +46,18 @@ const mockCommands: readonly SlashCommand[] = [ ]; describe('Help Component', () => { + 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/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/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index d86340283..4d9c949e3 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -26,6 +26,7 @@ import * as path from 'node:path'; describe('handleAtCommand', () => { let testRootDir: string; let mockConfig: Config; + let registry: ToolRegistry; const mockAddItem: Mock = vi.fn(); const mockOnDebugMessage: Mock<(message: string) => void> = vi.fn(); @@ -53,6 +54,7 @@ describe('handleAtCommand', () => { getToolRegistry, getTargetDir: () => testRootDir, isSandboxed: () => false, + isTrustedFolder: () => true, getFileService: () => new FileDiscoveryService(testRootDir), getFileFilteringRespectGitIgnore: () => true, getFileFilteringRespectQwenIgnore: () => true, @@ -84,7 +86,7 @@ describe('handleAtCommand', () => { getTruncateToolOutputLines: () => 500, } as unknown as Config; - const registry = new ToolRegistry(mockConfig); + registry = new ToolRegistry(mockConfig); registry.registerTool(new ReadManyFilesTool(mockConfig)); registry.registerTool(new GlobTool(mockConfig)); getToolRegistry.mockReturnValue(registry); @@ -204,6 +206,288 @@ describe('handleAtCommand', () => { ); }); + it('should expand an MCP resource reference in @server: resource format', async () => { + (mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers = + () => + ({ + github: {}, + }) as unknown; + + vi.spyOn(registry, 'readMcpResource').mockResolvedValue({ + contents: [ + { + uri: 'github://repos/owner/repo/issues', + mimeType: 'application/json', + text: '{"ok":true}', + }, + ], + } as unknown as Awaited>); + + const query = 'Show me the data from @github: repos/owner/repo/issues'; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 1000, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: 'Show me the data from @github:repos/owner/repo/issues' }, + { text: '\n--- Content from referenced MCP resources ---' }, + { text: '\nContent from @github:repos/owner/repo/issues:\n' }, + { text: '{"ok":true}' }, + { text: '\n--- End of MCP resource content ---' }, + ], + shouldProceed: true, + }); + expect(registry.readMcpResource).toHaveBeenCalledWith( + 'github', + 'github://repos/owner/repo/issues', + expect.objectContaining({ signal: abortController.signal }), + ); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tool_group', + tools: [expect.objectContaining({ status: ToolCallStatus.Success })], + }), + 1000, + ); + }); + + it('should expand an MCP resource reference in @server:resource format', async () => { + (mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers = + () => + ({ + github: {}, + }) as unknown; + + vi.spyOn(registry, 'readMcpResource').mockResolvedValue({ + contents: [ + { + uri: 'github://repos/owner/repo/issues', + mimeType: 'application/json', + text: '{"ok":true}', + }, + ], + } as unknown as Awaited>); + + const query = 'Show me the data from @github:repos/owner/repo/issues'; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 1001, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: query }, + { text: '\n--- Content from referenced MCP resources ---' }, + { text: '\nContent from @github:repos/owner/repo/issues:\n' }, + { text: '{"ok":true}' }, + { text: '\n--- End of MCP resource content ---' }, + ], + shouldProceed: true, + }); + expect(registry.readMcpResource).toHaveBeenCalledWith( + 'github', + 'github://repos/owner/repo/issues', + expect.objectContaining({ signal: abortController.signal }), + ); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tool_group', + tools: [expect.objectContaining({ status: ToolCallStatus.Success })], + }), + 1001, + ); + }); + + it('should expand an MCP resource reference with a leading slash', async () => { + (mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers = + () => + ({ + github: {}, + }) as unknown; + + vi.spyOn(registry, 'readMcpResource').mockResolvedValue({ + contents: [ + { + uri: 'github://repos/owner/repo/issues', + mimeType: 'application/json', + text: '{"ok":true}', + }, + ], + } as unknown as Awaited>); + + const query = 'Show me the data from @github:/repos/owner/repo/issues'; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 1002, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: 'Show me the data from @github:repos/owner/repo/issues' }, + { text: '\n--- Content from referenced MCP resources ---' }, + { text: '\nContent from @github:repos/owner/repo/issues:\n' }, + { text: '{"ok":true}' }, + { text: '\n--- End of MCP resource content ---' }, + ], + shouldProceed: true, + }); + expect(registry.readMcpResource).toHaveBeenCalledWith( + 'github', + 'github://repos/owner/repo/issues', + expect.objectContaining({ signal: abortController.signal }), + ); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tool_group', + tools: [expect.objectContaining({ status: ToolCallStatus.Success })], + }), + 1002, + ); + }); + + it('should ignore @server: when no MCP resource is provided', async () => { + (mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers = + () => + ({ + github: {}, + }) as unknown; + + const readMcpResourceSpy = vi.spyOn(registry, 'readMcpResource'); + const query = 'Show me the data from @github:'; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 1003, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [{ text: query }], + shouldProceed: true, + }); + expect(readMcpResourceSpy).not.toHaveBeenCalled(); + expect(mockAddItem).not.toHaveBeenCalled(); + }); + + it('should not expand MCP resources in untrusted folders', async () => { + (mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers = + () => + ({ + github: {}, + }) as unknown; + const configWithTrust = mockConfig as unknown as { + isTrustedFolder: () => boolean; + }; + configWithTrust.isTrustedFolder = () => false; + + const readMcpResourceSpy = vi.spyOn(registry, 'readMcpResource'); + + const query = 'Show me the data from @github: repos/owner/repo/issues'; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 1004, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: null, + shouldProceed: false, + }); + expect(readMcpResourceSpy).toHaveBeenCalledWith( + 'github', + 'github://repos/owner/repo/issues', + expect.objectContaining({ signal: abortController.signal }), + ); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tool_group', + tools: [ + expect.objectContaining({ + status: ToolCallStatus.Error, + resultDisplay: expect.stringContaining('untrusted'), + }), + ], + }), + 1004, + ); + }); + + it('should preserve trailing punctuation after an MCP resource reference', async () => { + (mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers = + () => + ({ + github: {}, + }) as unknown; + + vi.spyOn(registry, 'readMcpResource').mockResolvedValue({ + contents: [ + { + uri: 'github://repos/owner/repo/issues', + mimeType: 'application/json', + text: '{"ok":true}', + }, + ], + } as unknown as Awaited>); + + const query = 'Show me the data from @github: repos/owner/repo/issues.'; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 1005, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: 'Show me the data from @github:repos/owner/repo/issues.' }, + { text: '\n--- Content from referenced MCP resources ---' }, + { text: '\nContent from @github:repos/owner/repo/issues:\n' }, + { text: '{"ok":true}' }, + { text: '\n--- End of MCP resource content ---' }, + ], + shouldProceed: true, + }); + expect(registry.readMcpResource).toHaveBeenCalledWith( + 'github', + 'github://repos/owner/repo/issues', + expect.objectContaining({ signal: abortController.signal }), + ); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tool_group', + tools: [expect.objectContaining({ status: ToolCallStatus.Success })], + }), + 1005, + ); + }); + it('should handle query with text before and after @command', async () => { const fileContent = 'Markdown content.'; const filePath = await createTestFile( diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index f3e41956b..a7fb2fe36 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -36,6 +36,12 @@ interface AtCommandPart { content: string; } +interface McpResourceAtReference { + atCommand: string; // e.g. "@github:repos/owner/repo/issues" + serverName: string; + uri: string; // e.g. "github://repos/owner/repo/issues" +} + /** * Parses a query string to find all '@' commands and text segments. * Handles \ escaped spaces within paths. @@ -110,6 +116,199 @@ function parseAllAtCommands(query: string): AtCommandPart[] { ); } +function getConfiguredMcpServerNames(config: Config): Set { + const names = new Set(Object.keys(config.getMcpServers() ?? {})); + if (config.getMcpServerCommand()) { + names.add('mcp'); + } + return names; +} + +function normalizeMcpResourceUri(serverName: string, resource: string): string { + if (resource.includes('://')) { + return resource; + } + + const cleaned = resource.startsWith('/') ? resource.slice(1) : resource; + return `${serverName}://${cleaned}`; +} + +function splitLeadingToken( + text: string, +): { token: string; rest: string } | null { + let i = 0; + while (i < text.length && /\s/.test(text[i])) { + i++; + } + if (i >= text.length) { + return null; + } + + let token = ''; + let inEscape = false; + while (i < text.length) { + const char = text[i]; + if (inEscape) { + token += char; + inEscape = false; + i++; + continue; + } + if (char === '\\') { + inEscape = true; + i++; + continue; + } + if (/[,\s;!?()[\]{}]/.test(char)) { + break; + } + if (char === '.') { + const nextChar = i + 1 < text.length ? text[i + 1] : ''; + if (nextChar === '' || /\s/.test(nextChar)) { + break; + } + } + token += char; + i++; + } + + if (!token) { + return null; + } + + return { token, rest: text.slice(i) }; +} + +function extractMcpResourceAtReferences( + parts: AtCommandPart[], + config: Config, +): { parts: AtCommandPart[]; refs: McpResourceAtReference[] } { + const configuredServers = getConfiguredMcpServerNames(config); + const refs: McpResourceAtReference[] = []; + const merged: AtCommandPart[] = []; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part.type !== 'atPath') { + merged.push(part); + continue; + } + + const atText = part.content; // e.g. "@github:" or "@github:repos/..." + const colonIndex = atText.indexOf(':'); + if (!atText.startsWith('@') || colonIndex <= 1) { + merged.push(part); + continue; + } + + const serverName = atText.slice(1, colonIndex); + if (!configuredServers.has(serverName)) { + merged.push(part); + continue; + } + + let resource = atText.slice(colonIndex + 1); + + // Support the documented "@server: resource" format where the resource is + // separated into the following text part. + if (!resource) { + const next = parts[i + 1]; + if (next?.type === 'text') { + const tokenInfo = splitLeadingToken(next.content); + if (tokenInfo) { + resource = tokenInfo.token; + const remainingText = tokenInfo.rest; + // Update the next part in place, and let the next iteration handle it. + parts[i + 1] = { type: 'text', content: remainingText }; + } + } + } + + if (!resource) { + // Treat "@server:" without a resource as plain text, rather than falling + // through to file resolution for a path like "server:". + merged.push({ type: 'text', content: atText }); + continue; + } + + const normalizedResource = resource.includes('://') + ? resource + : resource.startsWith('/') + ? resource.slice(1) + : resource; + + const normalizedAtCommand = `@${serverName}:${normalizedResource}`; + refs.push({ + atCommand: normalizedAtCommand, + serverName, + uri: normalizeMcpResourceUri(serverName, normalizedResource), + }); + merged.push({ type: 'atPath', content: normalizedAtCommand }); + } + + return { + parts: merged.filter( + (p) => !(p.type === 'text' && p.content.trim() === ''), + ), + refs, + }; +} + +function formatMcpResourceContents( + raw: unknown, + limits: { maxCharsPerResource: number; maxLinesPerResource: number }, +): string { + if (!raw || typeof raw !== 'object') { + return '[Error: Invalid MCP resource response]'; + } + + const contents = (raw as { contents?: unknown }).contents; + if (!Array.isArray(contents)) { + return '[Error: Invalid MCP resource response]'; + } + + const parts: string[] = []; + for (const item of contents) { + if (!item || typeof item !== 'object') { + continue; + } + + const text = (item as { text?: unknown }).text; + const blob = (item as { blob?: unknown }).blob; + const mimeType = (item as { mimeType?: unknown }).mimeType; + + if (typeof text === 'string') { + parts.push(text); + continue; + } + + if (typeof blob === 'string') { + const mimeTypeLabel = + typeof mimeType === 'string' ? mimeType : 'application/octet-stream'; + parts.push( + `[Binary MCP resource omitted (mimeType: ${mimeTypeLabel}, bytes: ${blob.length})]`, + ); + } + } + + let combined = parts.join('\n\n'); + + const maxLines = limits.maxLinesPerResource; + if (Number.isFinite(maxLines)) { + const lines = combined.split('\n'); + if (lines.length > maxLines) { + combined = `${lines.slice(0, maxLines).join('\n')}\n[truncated]`; + } + } + + const maxChars = limits.maxCharsPerResource; + if (Number.isFinite(maxChars) && combined.length > maxChars) { + combined = `${combined.slice(0, maxChars)}\n[truncated]`; + } + + return combined; +} + /** * Processes user input potentially containing one or more '@' commands. * If found, it attempts to read the specified files/directories using the @@ -127,10 +326,17 @@ export async function handleAtCommand({ messageId: userMessageTimestamp, signal, }: HandleAtCommandParams): Promise { - const commandParts = parseAllAtCommands(query); + const parsedParts = parseAllAtCommands(query); + const { parts: commandParts, refs: mcpResourceRefs } = + extractMcpResourceAtReferences(parsedParts, config); + + const mcpAtCommands = new Set(mcpResourceRefs.map((r) => r.atCommand)); const atPathCommandParts = commandParts.filter( (part) => part.type === 'atPath', ); + const fileAtPathCommandParts = atPathCommandParts.filter( + (part) => !mcpAtCommands.has(part.content), + ); if (atPathCommandParts.length === 0) { return { processedQuery: [{ text: query }], shouldProceed: true }; @@ -154,15 +360,7 @@ export async function handleAtCommand({ const readManyFilesTool = toolRegistry.getTool('read_many_files'); const globTool = toolRegistry.getTool('glob'); - if (!readManyFilesTool) { - addItem( - { type: 'error', text: 'Error: read_many_files tool not found.' }, - userMessageTimestamp, - ); - return { processedQuery: null, shouldProceed: false }; - } - - for (const atPathPart of atPathCommandParts) { + for (const atPathPart of fileAtPathCommandParts) { const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@" if (originalAtPath === '@') { @@ -377,7 +575,7 @@ export async function handleAtCommand({ } // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText - if (pathSpecsToRead.length === 0) { + if (pathSpecsToRead.length === 0 && mcpResourceRefs.length === 0) { onDebugMessage('No valid file paths found in @ commands to read.'); if (initialQueryText === '@' && query.trim() === '@') { // If the only thing was a lone @, pass original query (which might have spaces) @@ -395,86 +593,165 @@ export async function handleAtCommand({ const processedQueryParts: PartUnion[] = [{ text: initialQueryText }]; - const toolArgs = { - paths: pathSpecsToRead, - file_filtering_options: { - respect_git_ignore: respectFileIgnore.respectGitIgnore, - respect_qwen_ignore: respectFileIgnore.respectQwenIgnore, - }, - // Use configuration setting - }; - let toolCallDisplay: IndividualToolCallDisplay; + const toolDisplays: IndividualToolCallDisplay[] = []; - let invocation: AnyToolInvocation | undefined = undefined; - try { - invocation = readManyFilesTool.build(toolArgs); - const result = await invocation.execute(signal); - toolCallDisplay = { - callId: `client-read-${userMessageTimestamp}`, - name: readManyFilesTool.displayName, - description: invocation.getDescription(), - status: ToolCallStatus.Success, - resultDisplay: - result.returnDisplay || - `Successfully read: ${contentLabelsForDisplay.join(', ')}`, - confirmationDetails: undefined, - }; - - if (Array.isArray(result.llmContent)) { - const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; - processedQueryParts.push({ - text: '\n--- Content from referenced files ---', - }); - for (const part of result.llmContent) { - if (typeof part === 'string') { - const match = fileContentRegex.exec(part); - if (match) { - const filePathSpecInContent = match[1]; // This is a resolved pathSpec - const fileActualContent = match[2].trim(); - processedQueryParts.push({ - text: `\nContent from @${filePathSpecInContent}:\n`, - }); - processedQueryParts.push({ text: fileActualContent }); - } else { - processedQueryParts.push({ text: part }); - } - } else { - // part is a Part object. - processedQueryParts.push(part); - } - } - } else { - onDebugMessage( - 'read_many_files tool returned no content or empty content.', + if (pathSpecsToRead.length > 0) { + if (!readManyFilesTool) { + addItem( + { type: 'error', text: 'Error: read_many_files tool not found.' }, + userMessageTimestamp, ); + return { processedQuery: null, shouldProceed: false }; } - addItem( - { type: 'tool_group', tools: [toolCallDisplay] } as Omit< - HistoryItem, - 'id' - >, - userMessageTimestamp, - ); - return { processedQuery: processedQueryParts, shouldProceed: true }; - } catch (error: unknown) { - toolCallDisplay = { - callId: `client-read-${userMessageTimestamp}`, - name: readManyFilesTool.displayName, - description: - invocation?.getDescription() ?? - 'Error attempting to execute tool to read files', - status: ToolCallStatus.Error, - resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, - confirmationDetails: undefined, + const toolArgs = { + paths: pathSpecsToRead, + file_filtering_options: { + respect_git_ignore: respectFileIgnore.respectGitIgnore, + respect_qwen_ignore: respectFileIgnore.respectQwenIgnore, + }, + // Use configuration setting }; + + let invocation: AnyToolInvocation | undefined = undefined; + try { + invocation = readManyFilesTool.build(toolArgs); + const result = await invocation.execute(signal); + toolDisplays.push({ + callId: `client-read-${userMessageTimestamp}`, + name: readManyFilesTool.displayName, + description: invocation.getDescription(), + status: ToolCallStatus.Success, + resultDisplay: + result.returnDisplay || + `Successfully read: ${contentLabelsForDisplay.join(', ')}`, + confirmationDetails: undefined, + }); + + if (Array.isArray(result.llmContent)) { + const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; + processedQueryParts.push({ + text: '\n--- Content from referenced files ---', + }); + for (const part of result.llmContent) { + if (typeof part === 'string') { + const match = fileContentRegex.exec(part); + if (match) { + const filePathSpecInContent = match[1]; // This is a resolved pathSpec + const fileActualContent = match[2].trim(); + processedQueryParts.push({ + text: `\nContent from @${filePathSpecInContent}:\n`, + }); + processedQueryParts.push({ text: fileActualContent }); + } else { + processedQueryParts.push({ text: part }); + } + } else { + // part is a Part object. + processedQueryParts.push(part); + } + } + } else { + onDebugMessage( + 'read_many_files tool returned no content or empty content.', + ); + } + } catch (error: unknown) { + toolDisplays.push({ + callId: `client-read-${userMessageTimestamp}`, + name: readManyFilesTool.displayName, + description: + invocation?.getDescription() ?? + 'Error attempting to execute tool to read files', + status: ToolCallStatus.Error, + resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, + confirmationDetails: undefined, + }); + addItem( + { type: 'tool_group', tools: toolDisplays } as Omit, + userMessageTimestamp, + ); + return { processedQuery: null, shouldProceed: false }; + } + } + + if (mcpResourceRefs.length > 0) { + const totalCharLimit = config.getTruncateToolOutputThreshold(); + const totalLineLimit = config.getTruncateToolOutputLines(); + const maxCharsPerResource = Number.isFinite(totalCharLimit) + ? Math.floor(totalCharLimit / Math.max(1, mcpResourceRefs.length)) + : Number.POSITIVE_INFINITY; + const maxLinesPerResource = Number.isFinite(totalLineLimit) + ? Math.floor(totalLineLimit / Math.max(1, mcpResourceRefs.length)) + : Number.POSITIVE_INFINITY; + + processedQueryParts.push({ + text: '\n--- Content from referenced MCP resources ---', + }); + + for (let i = 0; i < mcpResourceRefs.length; i++) { + const ref = mcpResourceRefs[i]; + let resourceResult: unknown; + try { + if (signal.aborted) { + const error = new Error('MCP resource read aborted'); + error.name = 'AbortError'; + throw error; + } + + resourceResult = await toolRegistry.readMcpResource( + ref.serverName, + ref.uri, + { signal }, + ); + + toolDisplays.push({ + callId: `client-mcp-resource-${userMessageTimestamp}-${i}`, + name: 'McpResourceRead', + description: `Read MCP resource ${ref.uri} (server: ${ref.serverName})`, + status: ToolCallStatus.Success, + resultDisplay: `Read: ${ref.uri}`, + confirmationDetails: undefined, + }); + } catch (error: unknown) { + toolDisplays.push({ + callId: `client-mcp-resource-${userMessageTimestamp}-${i}`, + name: 'McpResourceRead', + description: `Read MCP resource ${ref.uri} (server: ${ref.serverName})`, + status: ToolCallStatus.Error, + resultDisplay: `Error reading MCP resource (${ref.uri}): ${getErrorMessage(error)}`, + confirmationDetails: undefined, + }); + addItem( + { type: 'tool_group', tools: toolDisplays } as Omit< + HistoryItem, + 'id' + >, + userMessageTimestamp, + ); + return { processedQuery: null, shouldProceed: false }; + } + + processedQueryParts.push({ + text: `\nContent from ${ref.atCommand}:\n`, + }); + processedQueryParts.push({ + text: formatMcpResourceContents(resourceResult, { + maxCharsPerResource, + maxLinesPerResource, + }), + }); + } + + processedQueryParts.push({ text: '\n--- End of MCP resource content ---' }); + } + + if (toolDisplays.length > 0) { addItem( - { type: 'tool_group', tools: [toolCallDisplay] } as Omit< - HistoryItem, - 'id' - >, + { type: 'tool_group', tools: toolDisplays } as Omit, userMessageTimestamp, ); - return { processedQuery: null, shouldProceed: false }; } + + return { processedQuery: processedQueryParts, shouldProceed: true }; } 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/vitest.config.ts b/packages/cli/vitest.config.ts index fcffa292f..4c022d4be 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -6,8 +6,14 @@ /// import { defineConfig } from 'vitest/config'; +import path from 'node:path'; export default defineConfig({ + resolve: { + alias: { + '@qwen-code/qwen-code-core': path.resolve(__dirname, '../core/index.ts'), + }, + }, test: { include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'config.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**'], @@ -41,7 +47,7 @@ export default defineConfig({ }, server: { deps: { - inline: [/@google\/gemini-cli-core/], + inline: [/@qwen-code\/qwen-code-core/], }, }, }, diff --git a/packages/core/index.ts b/packages/core/index.ts index aab675a18..3e74d6bed 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -5,43 +5,3 @@ */ export * from './src/index.js'; -export { Storage } from './src/config/storage.js'; -export { - DEFAULT_QWEN_MODEL, - DEFAULT_QWEN_FLASH_MODEL, - DEFAULT_QWEN_EMBEDDING_MODEL, -} from './src/config/models.js'; -export { - serializeTerminalToObject, - type AnsiOutput, - type AnsiLine, - type AnsiToken, -} from './src/utils/terminalSerializer.js'; -export { - DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, -} from './src/config/config.js'; -export { detectIdeFromEnv } from './src/ide/detect-ide.js'; -export { - logExtensionEnable, - logIdeConnection, - logExtensionDisable, - logAuth, -} from './src/telemetry/loggers.js'; - -export { - IdeConnectionEvent, - IdeConnectionType, - ExtensionInstallEvent, - ExtensionDisableEvent, - ExtensionEnableEvent, - ExtensionUninstallEvent, - ModelSlashCommandEvent, - AuthEvent, -} from './src/telemetry/types.js'; -export { makeFakeConfig } from './src/test-utils/config.js'; -export * from './src/utils/pathReader.js'; -export * from './src/utils/request-tokenizer/supportedImageFormats.js'; -export { ClearcutLogger } from './src/telemetry/clearcut-logger/clearcut-logger.js'; -export { QwenLogger } from './src/telemetry/qwen-logger/qwen-logger.js'; -export { logModelSlashCommand } from './src/telemetry/loggers.js'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 894d174f5..800e9d21a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -36,6 +36,8 @@ import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { type FileSystemService, StandardFileSystemService, + type FileEncodingType, + FileEncoding, } from '../services/fileSystemService.js'; import { GitService } from '../services/gitService.js'; @@ -350,6 +352,7 @@ export interface ConfigParameters { chatCompression?: ChatCompressionSettings; interactive?: boolean; trustedFolder?: boolean; + defaultFileEncoding?: FileEncodingType; useRipgrep?: boolean; useBuiltinRipgrep?: boolean; shouldUseNodePtyShell?: boolean; @@ -512,6 +515,7 @@ export class Config { private readonly eventEmitter?: EventEmitter; private readonly useSmartEdit: boolean; private readonly channel: string | undefined; + private readonly defaultFileEncoding: FileEncodingType; constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); @@ -625,6 +629,7 @@ export class Config { this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true; this.useSmartEdit = params.useSmartEdit ?? false; this.channel = params.channel; + this.defaultFileEncoding = params.defaultFileEncoding ?? FileEncoding.UTF8; this.storage = new Storage(this.targetDir); this.vlmSwitchMode = params.vlmSwitchMode; this.inputFormat = params.inputFormat ?? InputFormat.TEXT; @@ -814,13 +819,6 @@ export class Config { return this.sessionId; } - /** - * Releases resources owned by the config instance. - */ - async shutdown(): Promise { - this.skillManager?.stopWatching(); - } - /** * Starts a new session and resets session-scoped services. */ @@ -1027,6 +1025,28 @@ export class Config { return this.toolRegistry; } + /** + * Shuts down the Config and releases all resources. + * This method is idempotent and safe to call multiple times. + * It handles the case where initialization was not completed. + */ + async shutdown(): Promise { + if (!this.initialized) { + // Nothing to clean up if not initialized + return; + } + try { + this.skillManager?.stopWatching(); + + if (this.toolRegistry) { + await this.toolRegistry.stop(); + } + } catch (error) { + // Log but don't throw - cleanup should be best-effort + console.error('Error during Config shutdown:', error); + } + } + getPromptRegistry(): PromptRegistry { return this.promptRegistry; } @@ -1432,6 +1452,14 @@ export class Config { return this.channel; } + /** + * Get the default file encoding for new files. + * @returns FileEncodingType + */ + getDefaultFileEncoding(): FileEncodingType { + return this.defaultFileEncoding; + } + /** * Get the current FileSystemService */ diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index d66787635..5fc51b109 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -75,6 +75,7 @@ export class AnthropicContentGenerator implements ContentGenerator { this.converter = new AnthropicContentConverter( contentGeneratorConfig.model, contentGeneratorConfig.schemaCompliance, + contentGeneratorConfig.enableCacheControl, ); } diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 14671b6ce..804349932 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -33,7 +33,13 @@ describe('AnthropicContentConverter', () => { config: { systemInstruction: 'sys' }, }); - expect(system).toBe('sys'); + expect(system).toEqual([ + { + type: 'text', + text: 'sys', + cache_control: { type: 'ephemeral' }, + }, + ]); }); it('extracts systemInstruction text from parts and joins with newlines', () => { @@ -48,7 +54,13 @@ describe('AnthropicContentConverter', () => { }, }); - expect(system).toBe('a\nb'); + expect(system).toEqual([ + { + type: 'text', + text: 'a\nb', + cache_control: { type: 'ephemeral' }, + }, + ]); }); it('converts a plain string content into a user message', () => { @@ -58,7 +70,16 @@ describe('AnthropicContentConverter', () => { }); expect(messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + { + role: 'user', + content: [ + { + type: 'text', + text: 'Hello', + cache_control: { type: 'ephemeral' }, + }, + ], + }, ]); }); @@ -78,7 +99,11 @@ describe('AnthropicContentConverter', () => { role: 'user', content: [ { type: 'text', text: 'Hello' }, - { type: 'text', text: 'World' }, + { + type: 'text', + text: 'World', + cache_control: { type: 'ephemeral' }, + }, ], }, ]); @@ -651,6 +676,7 @@ describe('AnthropicContentConverter', () => { properties: { location: { type: 'string' } }, required: ['location'], }, + cache_control: { type: 'ephemeral' }, }); expect(vi.mocked(convertSchema)).toHaveBeenCalledTimes(1); @@ -694,6 +720,7 @@ describe('AnthropicContentConverter', () => { name: 'no_params', description: 'no params', input_schema: { type: 'object', properties: {} }, + cache_control: { type: 'ephemeral' }, }); }); @@ -786,4 +813,78 @@ describe('AnthropicContentConverter', () => { expect(converter.mapAnthropicFinishReasonToGemini('')).toBeUndefined(); }); }); + + describe('enableCacheControl', () => { + it('does not add cache_control to system when disabled', () => { + const noCacheConverter = new AnthropicContentConverter( + 'test-model', + 'auto', + false, + ); + const { system } = noCacheConverter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: 'hi', + config: { systemInstruction: 'sys' }, + }); + + expect(system).toBe('sys'); + }); + + it('does not add cache_control to messages when disabled', () => { + const noCacheConverter = new AnthropicContentConverter( + 'test-model', + 'auto', + false, + ); + const { messages } = noCacheConverter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: 'Hello', + }); + + expect(messages).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + ]); + }); + + it('does not add cache_control to tools when disabled', async () => { + const noCacheConverter = new AnthropicContentConverter( + 'test-model', + 'auto', + false, + ); + const tools = [ + { + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get weather', + parametersJsonSchema: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + }, + ], + }, + ] as Tool[]; + + const result = + await noCacheConverter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'get_weather', + description: 'Get weather', + input_schema: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + }); + expect(result[0]).not.toHaveProperty('cache_control'); + }); + }); }); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 4aade511b..7c774e2a0 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -26,32 +26,48 @@ import { } from '../../utils/schemaConverter.js'; type AnthropicMessageParam = Anthropic.MessageParam; -type AnthropicToolParam = Anthropic.Tool; +type AnthropicToolParam = Anthropic.Tool & { + cache_control?: { type: 'ephemeral' }; +}; type AnthropicContentBlockParam = Anthropic.ContentBlockParam; export class AnthropicContentConverter { private model: string; private schemaCompliance: SchemaComplianceMode; + private enableCacheControl: boolean; - constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') { + constructor( + model: string, + schemaCompliance: SchemaComplianceMode = 'auto', + enableCacheControl: boolean = true, + ) { this.model = model; this.schemaCompliance = schemaCompliance; + this.enableCacheControl = enableCacheControl; } convertGeminiRequestToAnthropic(request: GenerateContentParameters): { - system?: string; + system?: Anthropic.TextBlockParam[] | string; messages: AnthropicMessageParam[]; } { const messages: AnthropicMessageParam[] = []; - const system = this.extractTextFromContentUnion( + const systemText = this.extractTextFromContentUnion( request.config?.systemInstruction, ); this.processContents(request.contents, messages); + // Add cache_control to enable prompt caching (if enabled) + const system = this.enableCacheControl + ? this.buildSystemWithCacheControl(systemText) + : systemText; + if (this.enableCacheControl) { + this.addCacheControlToMessages(messages); + } + return { - system: system || undefined, + system, messages, }; } @@ -103,6 +119,15 @@ export class AnthropicContentConverter { } } + // Add cache_control to the last tool for prompt caching (if enabled) + if (this.enableCacheControl && tools.length > 0) { + const lastToolIndex = tools.length - 1; + tools[lastToolIndex] = { + ...tools[lastToolIndex], + cache_control: { type: 'ephemeral' }, + }; + } + return tools; } @@ -497,4 +522,59 @@ export class AnthropicContentConverter { Array.isArray((content as Record)['parts']) ); } + + /** + * Build system content blocks with cache_control. + * Anthropic prompt caching requires cache_control on system content. + */ + private buildSystemWithCacheControl( + systemText: string, + ): Anthropic.TextBlockParam[] | string { + if (!systemText) { + return systemText; + } + + return [ + { + type: 'text', + text: systemText, + cache_control: { type: 'ephemeral' }, + }, + ]; + } + + /** + * Add cache_control to the last user message's content. + * This enables prompt caching for the conversation context. + */ + private addCacheControlToMessages(messages: Anthropic.MessageParam[]): void { + // Find the last user message to add cache_control + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === 'user') { + const content = Array.isArray(msg.content) + ? msg.content + : [{ type: 'text' as const, text: msg.content }]; + + if (content.length > 0) { + const lastContent = content[content.length - 1]; + // Only add cache_control if the last block is a non-empty text block + if ( + typeof lastContent === 'object' && + 'type' in lastContent && + lastContent.type === 'text' && + 'text' in lastContent && + lastContent.text + ) { + lastContent.cache_control = { + type: 'ephemeral', + }; + } + // If last block is not text or is empty, don't add cache_control + msg.content = content; + } + break; + } + } + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a9c091a08..c9fa73d80 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,143 +4,120 @@ * SPDX-License-Identifier: Apache-2.0 */ -// Export config -export * from './config/config.js'; -export * from './output/types.js'; -export * from './output/json-formatter.js'; +// ============================================================================ +// Configuration & Models +// ============================================================================ -// Export models +// Core configuration +export * from './config/config.js'; +export { Storage } from './config/storage.js'; +export * from './utils/configResolver.js'; + +// Model configuration +export { + DEFAULT_QWEN_MODEL, + DEFAULT_QWEN_FLASH_MODEL, + DEFAULT_QWEN_EMBEDDING_MODEL, +} from './config/models.js'; export { - type ModelCapabilities, - type ModelGenerationConfig, - type ModelConfig as ProviderModelConfig, - type ModelProvidersConfig, - type ResolvedModelConfig, type AvailableModel, - type ModelSwitchMetadata, - QWEN_OAUTH_MODELS, + type ModelCapabilities, + type ModelConfig as ProviderModelConfig, + type ModelConfigCliInput, + type ModelConfigResolutionResult, + type ModelConfigSettingsInput, + type ModelConfigSourcesInput, + type ModelConfigValidationResult, ModelRegistry, + type ModelGenerationConfig, ModelsConfig, type ModelsConfigOptions, + type ModelProvidersConfig, + type ModelSwitchMetadata, type OnModelChangeCallback, - // Model configuration resolver + QWEN_OAUTH_MODELS, resolveModelConfig, + type ResolvedModelConfig, validateModelConfig, - type ModelConfigSourcesInput, - type ModelConfigCliInput, - type ModelConfigSettingsInput, - type ModelConfigResolutionResult, - type ModelConfigValidationResult, } from './models/index.js'; -// Export Core Logic +// Output formatting +export * from './output/json-formatter.js'; +export * from './output/types.js'; + +// ============================================================================ +// Core Engine +// ============================================================================ + export * from './core/client.js'; export * from './core/contentGenerator.js'; +export * from './core/coreToolScheduler.js'; export * from './core/geminiChat.js'; +export * from './core/geminiRequest.js'; export * from './core/logger.js'; +export * from './core/nonInteractiveToolExecutor.js'; export * from './core/prompts.js'; export * from './core/tokenLimits.js'; export * from './core/turn.js'; -export * from './core/geminiRequest.js'; -export * from './core/coreToolScheduler.js'; -export * from './core/nonInteractiveToolExecutor.js'; -export * from './qwen/qwenOAuth2.js'; +// ============================================================================ +// Tools +// ============================================================================ -// Export utilities -export * from './utils/paths.js'; -export * from './utils/schemaValidator.js'; -export * from './utils/errors.js'; -export * from './utils/getFolderStructure.js'; -export * from './utils/memoryDiscovery.js'; -export * from './utils/gitIgnoreParser.js'; -export * from './utils/gitUtils.js'; -export * from './utils/editor.js'; -export * from './utils/quotaErrorDetection.js'; -export * from './utils/fileUtils.js'; -export * from './utils/retry.js'; -export * from './utils/shell-utils.js'; -export * from './utils/tool-utils.js'; -export * from './utils/terminalSerializer.js'; -export * from './utils/systemEncoding.js'; -export * from './utils/textUtils.js'; -export * from './utils/formatters.js'; -export * from './utils/generateContentResponseUtilities.js'; -export * from './utils/ripgrepUtils.js'; -export * from './utils/filesearch/fileSearch.js'; -export * from './utils/errorParsing.js'; -export * from './utils/workspaceContext.js'; -export * from './utils/ignorePatterns.js'; -export * from './utils/partUtils.js'; -export * from './utils/subagentGenerator.js'; -export * from './utils/projectSummary.js'; -export * from './utils/promptIdContext.js'; -export * from './utils/thoughtUtils.js'; -export * from './utils/toml-to-markdown-converter.js'; -export * from './utils/yaml-parser.js'; - -// Config resolution utilities -export * from './utils/configResolver.js'; - -// Export services -export * from './services/fileDiscoveryService.js'; -export * from './services/gitService.js'; -export * from './services/chatRecordingService.js'; -export * from './services/sessionService.js'; -export * from './services/fileSystemService.js'; - -// Export IDE specific logic -export * from './ide/ide-client.js'; -export * from './ide/ideContext.js'; -export * from './ide/ide-installer.js'; -export { IDE_DEFINITIONS, type IdeInfo } from './ide/detect-ide.js'; -export * from './ide/constants.js'; -export * from './ide/types.js'; - -// Export Shell Execution Service -export * from './services/shellExecutionService.js'; - -// Export base tool definitions -export * from './tools/tools.js'; -export * from './tools/tool-error.js'; -export * from './tools/tool-registry.js'; - -// Export subagents (Phase 1) -export * from './subagents/index.js'; - -// Export skills -export * from './skills/index.js'; - -// Export extension -export * from './extension/index.js'; - -// Export prompt logic -export * from './prompts/mcp-prompts.js'; - -// Export specific tool logic -export * from './tools/read-file.js'; -export * from './tools/ls.js'; -export * from './tools/grep.js'; -export * from './tools/ripGrep.js'; -export * from './tools/glob.js'; +// Base tool system export * from './tools/edit.js'; -export * from './tools/write-file.js'; -export * from './tools/web-fetch.js'; +export * from './tools/exitPlanMode.js'; +export * from './tools/glob.js'; +export * from './tools/grep.js'; +export * from './tools/ls.js'; +export * from './tools/lsp.js'; export * from './tools/memoryTool.js'; -export * from './tools/shell.js'; -export * from './tools/web-search/index.js'; -export * from './tools/read-many-files.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; +export * from './tools/read-file.js'; +export * from './tools/read-many-files.js'; +export * from './tools/ripGrep.js'; export * from './tools/sdk-control-client-transport.js'; -export * from './tools/task.js'; +export * from './tools/shell.js'; export * from './tools/skill.js'; +export * from './tools/task.js'; export * from './tools/todoWrite.js'; -export * from './tools/exitPlanMode.js'; +export * from './tools/tool-error.js'; +export * from './tools/tool-registry.js'; +export * from './tools/tools.js'; +export * from './tools/web-fetch.js'; +export * from './tools/web-search/index.js'; +export * from './tools/write-file.js'; -// Export LSP types and tools -export * from './lsp/types.js'; +// ============================================================================ +// Services +// ============================================================================ + +export * from './services/chatRecordingService.js'; +export * from './services/fileDiscoveryService.js'; +export * from './services/fileSystemService.js'; +export * from './services/gitService.js'; +export * from './services/sessionService.js'; +export * from './services/shellExecutionService.js'; + +// ============================================================================ +// IDE & LSP Support +// ============================================================================ + +// IDE integration +export * from './ide/constants.js'; +export { + IDE_DEFINITIONS, + type IdeInfo, + detectIdeFromEnv, +} from './ide/detect-ide.js'; +export * from './ide/ide-client.js'; +export * from './ide/ide-installer.js'; +export * from './ide/ideContext.js'; +export * from './ide/types.js'; + +// LSP support export * from './lsp/constants.js'; export * from './lsp/LspConfigLoader.js'; export * from './lsp/LspConnectionFactory.js'; @@ -149,29 +126,108 @@ export * from './lsp/LspResponseNormalizer.js'; export * from './lsp/LspServerManager.js'; export * from './lsp/NativeLspClient.js'; export * from './lsp/NativeLspService.js'; -export * from './tools/lsp.js'; +export * from './lsp/types.js'; + +// ============================================================================ +// MCP (Model Context Protocol) +// ============================================================================ -// MCP OAuth export { MCPOAuthProvider } from './mcp/oauth-provider.js'; -export type { - OAuthToken, - OAuthCredentials, -} from './mcp/token-storage/types.js'; +export type { MCPOAuthConfig } from './mcp/oauth-provider.js'; export { MCPOAuthTokenStorage } from './mcp/oauth-token-storage.js'; export { KeychainTokenStorage } from './mcp/token-storage/keychain-token-storage.js'; -export type { MCPOAuthConfig } from './mcp/oauth-provider.js'; +export type { + OAuthCredentials, + OAuthToken, +} from './mcp/token-storage/types.js'; +export { OAuthUtils } from './mcp/oauth-utils.js'; export type { OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata, } from './mcp/oauth-utils.js'; -export { OAuthUtils } from './mcp/oauth-utils.js'; -// Export telemetry functions +// ============================================================================ +// Telemetry +// ============================================================================ + +export { ClearcutLogger } from './telemetry/clearcut-logger/clearcut-logger.js'; +export { QwenLogger } from './telemetry/qwen-logger/qwen-logger.js'; export * from './telemetry/index.js'; -export * from './utils/browser.js'; -// OpenAI Logging Utilities -export { OpenAILogger, openaiLogger } from './utils/openaiLogger.js'; -export { Storage } from './config/storage.js'; +export { + logAuth, + logExtensionDisable, + logExtensionEnable, + logIdeConnection, + logModelSlashCommand, +} from './telemetry/loggers.js'; +export { + AuthEvent, + ExtensionDisableEvent, + ExtensionEnableEvent, + ExtensionInstallEvent, + ExtensionUninstallEvent, + IdeConnectionEvent, + IdeConnectionType, + ModelSlashCommandEvent, +} from './telemetry/types.js'; -// Export test utils +// ============================================================================ +// Extensions & Subagents +// ============================================================================ + +export * from './extension/index.js'; +export * from './prompts/mcp-prompts.js'; +export * from './skills/index.js'; +export * from './subagents/index.js'; + +// ============================================================================ +// Utilities +// ============================================================================ + +export * from './utils/browser.js'; +export * from './utils/editor.js'; +export * from './utils/errorParsing.js'; +export * from './utils/errors.js'; +export * from './utils/fileUtils.js'; +export * from './utils/filesearch/fileSearch.js'; +export * from './utils/formatters.js'; +export * from './utils/generateContentResponseUtilities.js'; +export * from './utils/getFolderStructure.js'; +export * from './utils/gitIgnoreParser.js'; +export * from './utils/gitUtils.js'; +export * from './utils/ignorePatterns.js'; +export * from './utils/memoryDiscovery.js'; +export { OpenAILogger, openaiLogger } from './utils/openaiLogger.js'; +export * from './utils/partUtils.js'; +export * from './utils/pathReader.js'; +export * from './utils/paths.js'; +export * from './utils/promptIdContext.js'; +export * from './utils/projectSummary.js'; +export * from './utils/quotaErrorDetection.js'; +export * from './utils/request-tokenizer/supportedImageFormats.js'; +export * from './utils/retry.js'; +export * from './utils/ripgrepUtils.js'; +export * from './utils/schemaValidator.js'; +export * from './utils/shell-utils.js'; +export * from './utils/subagentGenerator.js'; +export * from './utils/systemEncoding.js'; +export * from './utils/terminalSerializer.js'; +export * from './utils/textUtils.js'; +export * from './utils/thoughtUtils.js'; +export * from './utils/toml-to-markdown-converter.js'; +export * from './utils/tool-utils.js'; +export * from './utils/workspaceContext.js'; +export * from './utils/yaml-parser.js'; + +// ============================================================================ +// OAuth & Authentication +// ============================================================================ + +export * from './qwen/qwenOAuth2.js'; + +// ============================================================================ +// Testing Utilities +// ============================================================================ + +export { makeFakeConfig } from './test-utils/config.js'; export * from './test-utils/index.js'; diff --git a/packages/core/src/services/fileSystemService.test.ts b/packages/core/src/services/fileSystemService.test.ts index 4ca5c3329..69898f72d 100644 --- a/packages/core/src/services/fileSystemService.test.ts +++ b/packages/core/src/services/fileSystemService.test.ts @@ -55,5 +55,132 @@ describe('StandardFileSystemService', () => { 'utf-8', ); }); + + it('should write file with BOM when bom option is true', async () => { + vi.mocked(fs.writeFile).mockResolvedValue(); + + await fileSystem.writeTextFile('/test/file.txt', 'Hello, World!', { + bom: true, + }); + + // Verify that fs.writeFile was called with a Buffer that starts with BOM + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + expect(writeCall[0]).toBe('/test/file.txt'); + expect(writeCall[1]).toBeInstanceOf(Buffer); + const buffer = writeCall[1] as Buffer; + expect(buffer[0]).toBe(0xef); + expect(buffer[1]).toBe(0xbb); + expect(buffer[2]).toBe(0xbf); + }); + + it('should write file without BOM when bom option is false', async () => { + vi.mocked(fs.writeFile).mockResolvedValue(); + + await fileSystem.writeTextFile('/test/file.txt', 'Hello, World!', { + bom: false, + }); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/file.txt', + 'Hello, World!', + 'utf-8', + ); + }); + + it('should not duplicate BOM when content already has BOM character', async () => { + vi.mocked(fs.writeFile).mockResolvedValue(); + + // Content that includes the BOM character (as readTextFile would return) + const contentWithBOM = '\uFEFF' + 'Hello'; + await fileSystem.writeTextFile('/test/file.txt', contentWithBOM, { + bom: true, + }); + + // Verify that fs.writeFile was called with a Buffer that has only one BOM + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + expect(writeCall[0]).toBe('/test/file.txt'); + expect(writeCall[1]).toBeInstanceOf(Buffer); + const buffer = writeCall[1] as Buffer; + // First three bytes should be BOM + expect(buffer[0]).toBe(0xef); + expect(buffer[1]).toBe(0xbb); + expect(buffer[2]).toBe(0xbf); + // Fourth byte should be 'H' (0x48), not another BOM + expect(buffer[3]).toBe(0x48); + // Count BOM sequences in the buffer - should be only one + let bomCount = 0; + for (let i = 0; i <= buffer.length - 3; i++) { + if ( + buffer[i] === 0xef && + buffer[i + 1] === 0xbb && + buffer[i + 2] === 0xbf + ) { + bomCount++; + } + } + expect(bomCount).toBe(1); + }); + }); + + describe('detectFileBOM', () => { + it('should return true for file with UTF-8 BOM', async () => { + // Create a buffer with BOM + const bomBuffer = Buffer.from([0xef, 0xbb, 0xbf]); + + // Mock fs.open to return a file descriptor that fills buffer with BOM + vi.mocked(fs.open).mockImplementation( + async () => + ({ + read: async (buffer: Buffer, offset: number) => { + // Copy BOM bytes to the buffer + bomBuffer.copy(buffer, offset); + return { bytesRead: 3 }; + }, + close: async () => {}, + }) as unknown as fs.FileHandle, + ); + + const result = await fileSystem.detectFileBOM('/test/file.txt'); + expect(result).toBe(true); + }); + + it('should return false for file without BOM', async () => { + // Mock file without BOM (starts with plain text) + vi.mocked(fs.open).mockImplementation( + async () => + ({ + read: async (buffer: Buffer, offset: number) => { + // Copy plain text bytes ("// ") + const plainText = Buffer.from([0x2f, 0x2f, 0x20]); + plainText.copy(buffer, offset); + return { bytesRead: 3 }; + }, + close: async () => {}, + }) as unknown as fs.FileHandle, + ); + + const result = await fileSystem.detectFileBOM('/test/file.txt'); + expect(result).toBe(false); + }); + + it('should return false for non-existent file', async () => { + vi.mocked(fs.open).mockRejectedValue(new Error('ENOENT')); + + const result = await fileSystem.detectFileBOM('/test/nonexistent.txt'); + expect(result).toBe(false); + }); + + it('should return false for empty file', async () => { + vi.mocked(fs.open).mockImplementation( + async () => + ({ + read: async () => ({ bytesRead: 0 }), + close: async () => {}, + }) as unknown as fs.FileHandle, + ); + + const result = await fileSystem.detectFileBOM('/test/empty.txt'); + expect(result).toBe(false); + }); }); }); diff --git a/packages/core/src/services/fileSystemService.ts b/packages/core/src/services/fileSystemService.ts index 67c910611..91f36161c 100644 --- a/packages/core/src/services/fileSystemService.ts +++ b/packages/core/src/services/fileSystemService.ts @@ -8,6 +8,19 @@ import fs from 'node:fs/promises'; import * as path from 'node:path'; import { globSync } from 'glob'; +/** + * Supported file encodings for new files. + */ +export const FileEncoding = { + UTF8: 'utf-8', + UTF8_BOM: 'utf-8-bom', +} as const; + +/** + * Type for file encoding values. + */ +export type FileEncodingType = (typeof FileEncoding)[keyof typeof FileEncoding]; + /** * Interface for file system operations that may be delegated to different implementations */ @@ -25,8 +38,21 @@ export interface FileSystemService { * * @param filePath - The path to the file to write * @param content - The content to write + * @param options - Optional write options including whether to add BOM */ - writeTextFile(filePath: string, content: string): Promise; + writeTextFile( + filePath: string, + content: string, + options?: WriteTextFileOptions, + ): Promise; + + /** + * Detects if a file has UTF-8 BOM (Byte Order Mark). + * + * @param filePath - The path to the file to check + * @returns True if the file has BOM, false otherwise + */ + detectFileBOM(filePath: string): Promise; /** * Finds files with a given name within specified search paths. @@ -38,16 +64,81 @@ export interface FileSystemService { findFiles(fileName: string, searchPaths: readonly string[]): string[]; } +/** + * Options for writing text files + */ +export interface WriteTextFileOptions { + /** + * Whether to write the file with UTF-8 BOM. + * If true, EF BB BF will be prepended to the content. + * @default false + */ + bom?: boolean; +} + +/** + * Detects if a buffer has UTF-8 BOM (Byte Order Mark). + * UTF-8 BOM is the byte sequence EF BB BF. + * + * @param buffer - The buffer to check + * @returns True if the buffer starts with UTF-8 BOM + */ +function hasUTF8BOM(buffer: Buffer): boolean { + return ( + buffer.length >= 3 && + buffer[0] === 0xef && + buffer[1] === 0xbb && + buffer[2] === 0xbf + ); +} + /** * Standard file system implementation */ export class StandardFileSystemService implements FileSystemService { async readTextFile(filePath: string): Promise { - return fs.readFile(filePath, 'utf-8'); + return fs.readFile(filePath, FileEncoding.UTF8); } - async writeTextFile(filePath: string, content: string): Promise { - await fs.writeFile(filePath, content, 'utf-8'); + async writeTextFile( + filePath: string, + content: string, + options?: WriteTextFileOptions, + ): Promise { + const bom = options?.bom ?? false; + + if (bom) { + // Prepend UTF-8 BOM (EF BB BF) + // If content already starts with BOM character, strip it first to avoid double BOM + const normalizedContent = + content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; + const bomBuffer = Buffer.from([0xef, 0xbb, 0xbf]); + const contentBuffer = Buffer.from(normalizedContent, 'utf-8'); + await fs.writeFile(filePath, Buffer.concat([bomBuffer, contentBuffer])); + } else { + await fs.writeFile(filePath, content, 'utf-8'); + } + } + + async detectFileBOM(filePath: string): Promise { + let fd: fs.FileHandle | undefined; + try { + // Read only the first 3 bytes to check for BOM + fd = await fs.open(filePath, 'r'); + const buffer = Buffer.alloc(3); + const { bytesRead } = await fd.read(buffer, 0, 3, 0); + + if (bytesRead < 3) { + return false; + } + + return hasUTF8BOM(buffer); + } catch { + // File doesn't exist or can't be read - treat as no BOM + return false; + } finally { + await fd?.close(); + } } findFiles(fileName: string, searchPaths: readonly string[]): string[] { diff --git a/packages/core/src/skills/skill-load.test.ts b/packages/core/src/skills/skill-load.test.ts new file mode 100644 index 000000000..cd549fe89 --- /dev/null +++ b/packages/core/src/skills/skill-load.test.ts @@ -0,0 +1,303 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + parseSkillContent, + loadSkillsFromDir, + validateConfig, +} from './skill-load.js'; +import * as fs from 'fs/promises'; + +// Mock file system operations +vi.mock('fs/promises'); + +// Mock yaml parser - use vi.hoisted for proper hoisting +const mockParseYaml = vi.hoisted(() => vi.fn()); + +vi.mock('../utils/yaml-parser.js', () => ({ + parse: mockParseYaml, + stringify: vi.fn(), +})); + +describe('skill-load', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Setup yaml parser mocks with sophisticated behavior + mockParseYaml.mockImplementation((yamlString: string) => { + if (yamlString.includes('name: context7-docs')) { + return { + name: 'context7-docs', + description: 'Context7 documentation skill', + }; + } + if (yamlString.includes('allowedTools:')) { + return { + name: 'test-skill', + description: 'A test skill', + allowedTools: ['read_file', 'write_file'], + }; + } + // Default case + return { + name: 'test-skill', + description: 'A test skill', + }; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('parseSkillContent', () => { + const testFilePath = '/test/extension/skills/test-skill/SKILL.md'; + + it('should parse valid markdown content', () => { + const validMarkdown = `--- +name: test-skill +description: A test skill +--- + +You are a helpful assistant with this skill. +`; + + const config = parseSkillContent(validMarkdown, testFilePath); + + expect(config.name).toBe('test-skill'); + expect(config.description).toBe('A test skill'); + expect(config.body).toBe('You are a helpful assistant with this skill.'); + expect(config.level).toBe('extension'); + expect(config.filePath).toBe(testFilePath); + }); + + it('should parse markdown with CRLF line endings (Windows format)', () => { + const markdownCrlf = `---\r +name: test-skill\r +description: A test skill\r +---\r +\r +You are a helpful assistant with this skill.\r +`; + + const config = parseSkillContent(markdownCrlf, testFilePath); + + expect(config.name).toBe('test-skill'); + expect(config.description).toBe('A test skill'); + expect(config.body).toBe('You are a helpful assistant with this skill.'); + }); + + it('should parse markdown with CR only line endings (old Mac format)', () => { + const markdownCr = `---\rname: test-skill\rdescription: A test skill\r---\r\rYou are a helpful assistant with this skill.\r`; + + const config = parseSkillContent(markdownCr, testFilePath); + + expect(config.name).toBe('test-skill'); + expect(config.description).toBe('A test skill'); + expect(config.body).toBe('You are a helpful assistant with this skill.'); + }); + + it('should parse markdown with UTF-8 BOM', () => { + const markdownWithBom = `\uFEFF--- +name: test-skill +description: A test skill +--- + +You are a helpful assistant with this skill. +`; + + const config = parseSkillContent(markdownWithBom, testFilePath); + + expect(config.name).toBe('test-skill'); + expect(config.description).toBe('A test skill'); + }); + + it('should parse markdown when body is empty and file ends after frontmatter', () => { + const frontmatterOnly = `--- +name: test-skill +description: A test skill +---`; + + const config = parseSkillContent(frontmatterOnly, testFilePath); + + expect(config.name).toBe('test-skill'); + expect(config.description).toBe('A test skill'); + expect(config.body).toBe(''); + }); + + it('should parse markdown with CRLF and no trailing newline after frontmatter (Issue #1666 scenario)', () => { + // This reproduces the exact issue: Windows-created file without trailing newline + const windowsContent = `---\r\nname: context7-docs\r\ndescription: Context7 documentation skill\r\n---`; + + const config = parseSkillContent(windowsContent, testFilePath); + + expect(config.name).toBe('context7-docs'); + expect(config.description).toBe('Context7 documentation skill'); + expect(config.body).toBe(''); + }); + + it('should parse content with both UTF-8 BOM and CRLF line endings', () => { + const complexContent = `\uFEFF---\r +name: test-skill\r +description: A test skill\r +---\r +\r +Skill body content.\r +`; + + const config = parseSkillContent(complexContent, testFilePath); + + expect(config.name).toBe('test-skill'); + expect(config.description).toBe('A test skill'); + expect(config.body).toBe('Skill body content.'); + }); + + it('should parse content with allowedTools', () => { + const markdownWithTools = `--- +name: test-skill +description: A test skill +allowedTools: + - read_file + - write_file +--- + +You are a helpful assistant with this skill. +`; + + const config = parseSkillContent(markdownWithTools, testFilePath); + + expect(config.allowedTools).toEqual(['read_file', 'write_file']); + }); + + it('should throw error for invalid format without frontmatter', () => { + const invalidMarkdown = `# Just a heading +Some content without frontmatter. +`; + + expect(() => parseSkillContent(invalidMarkdown, testFilePath)).toThrow( + 'Invalid format: missing YAML frontmatter', + ); + }); + }); + + describe('loadSkillsFromDir', () => { + const testBaseDir = '/test/extension/skills'; + + it('should load skills from directory', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'skill1', isDirectory: () => true, isFile: () => false }, + { name: 'not-a-dir.txt', isDirectory: () => false, isFile: () => true }, + ] as unknown as Awaited>); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(`--- +name: test-skill +description: A test skill +--- + +Skill body. +`); + + const skills = await loadSkillsFromDir(testBaseDir); + + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe('test-skill'); + }); + + it('should return empty array if directory does not exist', async () => { + vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found')); + + const skills = await loadSkillsFromDir(testBaseDir); + + expect(skills).toEqual([]); + }); + + it('should skip skills with invalid YAML and continue loading others', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'valid-skill', isDirectory: () => true, isFile: () => false }, + { name: 'invalid-skill', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); + + vi.mocked(fs.access).mockResolvedValue(undefined); + + // First call returns valid content, second returns invalid + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + `--- +name: test-skill +description: A test skill +--- + +Valid skill. +`, + ) + .mockResolvedValueOnce('Invalid content without frontmatter'); + + const skills = await loadSkillsFromDir(testBaseDir); + + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe('test-skill'); + }); + }); + + describe('validateConfig', () => { + it('should validate valid config', () => { + const config = { + name: 'test-skill', + description: 'A test skill', + body: 'Skill body', + level: 'extension' as const, + filePath: '/path/to/skill', + }; + + const result = validateConfig(config); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return error for missing name', () => { + const config = { + description: 'A test skill', + body: 'Skill body', + }; + + const result = validateConfig(config); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Missing or invalid "name" field'); + }); + + it('should return error for empty name', () => { + const config = { + name: ' ', + description: 'A test skill', + body: 'Skill body', + }; + + const result = validateConfig(config); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('"name" cannot be empty'); + }); + + it('should return warning for empty body', () => { + const config = { + name: 'test-skill', + description: 'A test skill', + body: '', + level: 'extension' as const, + filePath: '/path/to/skill', + }; + + const result = validateConfig(config); + + expect(result.isValid).toBe(true); + expect(result.warnings).toContain('Skill body is empty'); + }); + }); +}); diff --git a/packages/core/src/skills/skill-load.ts b/packages/core/src/skills/skill-load.ts index ed88eb907..b2df7733c 100644 --- a/packages/core/src/skills/skill-load.ts +++ b/packages/core/src/skills/skill-load.ts @@ -39,13 +39,32 @@ export async function loadSkillsFromDir( } } +/** + * Normalizes skill file content for consistent parsing across platforms. + * - Strips UTF-8 BOM to ensure frontmatter starts at the first character. + * - Normalizes line endings so skills authored on Windows (CRLF) parse correctly. + */ +function normalizeSkillFileContent(content: string): string { + // Strip UTF-8 BOM to ensure frontmatter starts at the first character. + let normalized = content.replace(/^\uFEFF/, ''); + + // Normalize line endings so skills authored on Windows (CRLF) parse correctly. + normalized = normalized.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + return normalized; +} + export function parseSkillContent( content: string, filePath: string, ): SkillConfig { + // Normalize content to handle BOM and CRLF line endings + const normalizedContent = normalizeSkillFileContent(content); + // Split frontmatter and content - const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; - const match = content.match(frontmatterRegex); + // Use (?:\n|$) to allow frontmatter ending with or without trailing newline + const frontmatterRegex = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/; + const match = normalizedContent.match(frontmatterRegex); if (!match) { throw new Error('Invalid format: missing YAML frontmatter'); diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index 3e5125a4d..d21916143 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -61,6 +61,18 @@ describe('SkillManager', () => { if (yamlString.includes('name: skill3')) { return { name: 'skill3', description: 'Third skill' }; } + if (yamlString.includes('name: symlink-skill')) { + return { + name: 'symlink-skill', + description: 'A skill loaded from symlink', + }; + } + if (yamlString.includes('A symlinked skill')) { + return { name: 'symlink-skill', description: 'A symlinked skill' }; + } + if (yamlString.includes('name: regular-skill')) { + return { name: 'regular-skill', description: 'A regular skill' }; + } if (!yamlString.includes('name:')) { return { description: 'A test skill' }; // Missing name case } @@ -303,7 +315,12 @@ You are a helpful assistant. describe('loadSkill', () => { it('should load skill from project level first', async () => { vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + { + name: 'test-skill', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); @@ -318,7 +335,12 @@ You are a helpful assistant. vi.mocked(fs.readdir) .mockRejectedValueOnce(new Error('Project dir not found')) // project level fails .mockResolvedValueOnce([ - { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + { + name: 'test-skill', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, ] as unknown as Awaited>); // user level succeeds vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); @@ -341,7 +363,12 @@ You are a helpful assistant. describe('loadSkillForRuntime', () => { it('should load skill for runtime', async () => { vi.mocked(fs.readdir).mockResolvedValueOnce([ - { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + { + name: 'test-skill', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); @@ -367,17 +394,38 @@ You are a helpful assistant. // Mock directory listing for skills directories (with Dirent objects) vi.mocked(fs.readdir) .mockResolvedValueOnce([ - { name: 'skill1', isDirectory: () => true, isFile: () => false }, - { name: 'skill2', isDirectory: () => true, isFile: () => false }, + { + name: 'skill1', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + { + name: 'skill2', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, { name: 'not-a-dir.txt', isDirectory: () => false, isFile: () => true, + isSymbolicLink: () => false, }, ] as unknown as Awaited>) .mockResolvedValueOnce([ - { name: 'skill3', isDirectory: () => true, isFile: () => false }, - { name: 'skill1', isDirectory: () => true, isFile: () => false }, + { + name: 'skill3', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + { + name: 'skill1', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); @@ -503,7 +551,12 @@ Skill 3 content`); describe('parse errors', () => { it('should track parse errors', async () => { vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'bad-skill', isDirectory: () => true, isFile: () => false }, + { + name: 'bad-skill', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue( @@ -516,4 +569,124 @@ Skill 3 content`); expect(errors.size).toBeGreaterThan(0); }); }); + + describe('symlink support', () => { + it('should load skills from symlinked directories', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { + name: 'symlink-skill', + isDirectory: () => false, + isSymbolicLink: () => true, + isFile: () => false, + }, + ] as unknown as Awaited>); + + // Mock fs.stat to return directory stats for the symlink target + vi.mocked(fs.stat).mockResolvedValue({ + isDirectory: () => true, + } as Awaited>); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(`--- +name: symlink-skill +description: A skill loaded from symlink +--- +Symlink skill content`); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('symlink-skill'); + expect(skills[0].description).toBe('A skill loaded from symlink'); + }); + + it('should skip symlinks that point to non-directory targets', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { + name: 'bad-symlink', + isDirectory: () => false, + isSymbolicLink: () => true, + isFile: () => false, + }, + ] as unknown as Awaited>); + + // Mock fs.stat to return file stats (not a directory) + vi.mocked(fs.stat).mockResolvedValue({ + isDirectory: () => false, + } as Awaited>); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(0); + }); + + it('should skip broken/invalid symlinks', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { + name: 'broken-symlink', + isDirectory: () => false, + isSymbolicLink: () => true, + isFile: () => false, + }, + ] as unknown as Awaited>); + + // Mock fs.stat to throw error (symlink target doesn't exist) + vi.mocked(fs.stat).mockRejectedValue( + new Error('ENOENT: no such file or directory'), + ); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(0); + }); + + it('should load skills from both regular directories and symlinks', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { + name: 'regular-skill', + isDirectory: () => true, + isSymbolicLink: () => false, + isFile: () => false, + }, + { + name: 'symlink-skill', + isDirectory: () => false, + isSymbolicLink: () => true, + isFile: () => false, + }, + ] as unknown as Awaited>); + + // Mock fs.stat to return directory stats for the symlink + vi.mocked(fs.stat).mockResolvedValue({ + isDirectory: () => true, + } as Awaited>); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('regular-skill')) { + return Promise.resolve(`--- +name: regular-skill +description: A regular skill +--- +Regular skill content`); + } else if (pathStr.includes('symlink-skill')) { + return Promise.resolve(`--- +name: symlink-skill +description: A symlinked skill +--- +Symlinked skill content`); + } + return Promise.reject(new Error('File not found')); + }); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(2); + expect(skills.map((s) => s.name).sort()).toEqual([ + 'regular-skill', + 'symlink-skill', + ]); + }); + }); }); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index a5851fb51..bdfe0337d 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -407,10 +407,32 @@ export class SkillManager { const entries = await fs.readdir(baseDir, { withFileTypes: true }); const skills: SkillConfig[] = []; for (const entry of entries) { - // Only process directories (each skill is a directory) - if (!entry.isDirectory()) continue; + // Check if it's a directory or a symlink + const isDirectory = entry.isDirectory(); + const isSymlink = entry.isSymbolicLink(); + + if (!isDirectory && !isSymlink) continue; const skillDir = path.join(baseDir, entry.name); + + // For symlinks, verify the target is a directory + if (isSymlink) { + try { + const targetStat = await fs.stat(skillDir); + if (!targetStat.isDirectory()) { + console.warn( + `Skipping symlink ${entry.name} that does not point to a directory`, + ); + continue; + } + } catch (error) { + console.warn( + `Skipping invalid symlink ${entry.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + continue; + } + } + const skillManifest = path.join(skillDir, SKILL_MANIFEST_FILE); try { diff --git a/packages/core/src/subagents/subagent.test.ts b/packages/core/src/subagents/subagent.test.ts index d3dea2dc0..ce6e64ae4 100644 --- a/packages/core/src/subagents/subagent.test.ts +++ b/packages/core/src/subagents/subagent.test.ts @@ -38,6 +38,8 @@ import { SubAgentEventEmitter, SubAgentEventType, type SubAgentStreamTextEvent, + type SubAgentToolCallEvent, + type SubAgentToolResultEvent, } from './subagent-events.js'; import type { ModelConfig, @@ -933,5 +935,165 @@ describe('subagent.ts', () => { expect(mockSendMessageStream).toHaveBeenCalledTimes(2); }); }); + + describe('runNonInteractive - Tool Restriction Enforcement (Issue #1121)', () => { + const promptConfig: PromptConfig = { systemPrompt: 'Execute task.' }; + + it('should NOT execute tools that are not in the allowed tools list', async () => { + // Define two tools: one allowed (read_file), one not allowed (edit_file) + const readFileToolDef: FunctionDeclaration = { + name: 'read_file', + description: 'Reads a file', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const editFileToolDef: FunctionDeclaration = { + name: 'edit_file', + description: 'Edits a file', + parameters: { type: Type.OBJECT, properties: {} }, + }; + + // Track which tools were executed + const executedTools: string[] = []; + + const readFileInvocation = { + params: { path: 'test.txt' }, + getDescription: vi.fn().mockReturnValue('Read file'), + toolLocations: vi.fn().mockReturnValue([]), + shouldConfirmExecute: vi.fn().mockResolvedValue(false), + execute: vi.fn().mockImplementation(async () => { + executedTools.push('read_file'); + return { + llmContent: 'file contents', + returnDisplay: 'Read file contents', + }; + }), + }; + + const editFileInvocation = { + params: { path: 'test.txt', content: 'malicious content' }, + getDescription: vi.fn().mockReturnValue('Edit file'), + toolLocations: vi.fn().mockReturnValue([]), + shouldConfirmExecute: vi.fn().mockResolvedValue(false), + execute: vi.fn().mockImplementation(async () => { + executedTools.push('edit_file'); + return { + llmContent: 'file edited', + returnDisplay: 'Edited file', + }; + }), + }; + + const readFileTool = { + name: 'read_file', + displayName: 'Read File', + description: 'Read file contents', + kind: 'READ' as const, + schema: readFileToolDef, + build: vi.fn().mockImplementation(() => readFileInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + + const editFileTool = { + name: 'edit_file', + displayName: 'Edit File', + description: 'Edit file contents', + kind: 'WRITE' as const, + schema: editFileToolDef, + build: vi.fn().mockImplementation(() => editFileInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + + const { config } = await createMockConfig({ + // Only return read_file in the filtered list (this is what the subagent should see) + getFunctionDeclarationsFiltered: vi + .fn() + .mockReturnValue([readFileToolDef]), + // But the full registry has both tools (simulating the bug) + getFunctionDeclarations: vi + .fn() + .mockReturnValue([readFileToolDef, editFileToolDef]), + getTool: vi.fn().mockImplementation((name: string) => { + if (name === 'read_file') return readFileTool; + if (name === 'edit_file') return editFileTool; + return undefined; + }), + }); + + // Only allow read_file in the subagent's tool config + const toolConfig: ToolConfig = { tools: ['read_file'] }; + + // Model calls BOTH read_file (allowed) AND edit_file (NOT allowed) + // This simulates the bug where the model hallucinates an unauthorized tool call + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + id: 'call_read', + name: 'read_file', + args: { path: 'test.txt' }, + }, + { + id: 'call_edit', + name: 'edit_file', // This tool is NOT in the allowed list! + args: { path: 'test.txt', content: 'malicious content' }, + }, + ], + 'stop', + ]), + ); + + // Track emitted events + const toolCallEvents: SubAgentToolCallEvent[] = []; + const toolResultEvents: SubAgentToolResultEvent[] = []; + + // Create event emitter BEFORE the scope and subscribe to events + const eventEmitter = new SubAgentEventEmitter(); + eventEmitter.on(SubAgentEventType.TOOL_CALL, (event: unknown) => { + toolCallEvents.push(event as SubAgentToolCallEvent); + }); + eventEmitter.on(SubAgentEventType.TOOL_RESULT, (event: unknown) => { + toolResultEvents.push(event as SubAgentToolResultEvent); + }); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + eventEmitter, + ); + + await scope.runNonInteractive(new ContextState()); + + // 1. Only allowed tool should be executed + expect(executedTools).toContain('read_file'); + expect(executedTools).not.toContain('edit_file'); + expect(editFileInvocation.execute).not.toHaveBeenCalled(); + + // 2. TOOL_CALL events should be emitted for BOTH tools (for visibility) + expect(toolCallEvents).toHaveLength(2); + expect(toolCallEvents.map((e) => e.name)).toContain('read_file'); + expect(toolCallEvents.map((e) => e.name)).toContain('edit_file'); + + // 3. TOOL_RESULT events should be emitted for both + expect(toolResultEvents).toHaveLength(2); + + // 4. Verify blocked tool result has success=false and error message + const editResult = toolResultEvents.find((e) => e.name === 'edit_file'); + expect(editResult).toBeDefined(); + expect(editResult!.success).toBe(false); + expect(editResult!.error).toContain('not found'); + expect(editResult!.callId).toBe('call_edit'); + + // 5. Verify allowed tool result has success=true + const readResult = toolResultEvents.find((e) => e.name === 'read_file'); + expect(readResult).toBeDefined(); + expect(readResult!.success).toBe(true); + }); + }); }); }); diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts index 7f3146e98..11b60ad4b 100644 --- a/packages/core/src/subagents/subagent.ts +++ b/packages/core/src/subagents/subagent.ts @@ -487,6 +487,7 @@ export class SubAgentScope { abortController, promptId, turnCounter, + toolsList, currentResponseId, ); } else { @@ -585,10 +586,67 @@ export class SubAgentScope { abortController: AbortController, promptId: string, currentRound: number, + toolsList: FunctionDeclaration[], responseId?: string, ): Promise { const toolResponseParts: Part[] = []; + // Build allowed tool names set for filtering + const allowedToolNames = new Set(toolsList.map((t) => t.name)); + + // Filter unauthorized tool calls before scheduling + const authorizedCalls: FunctionCall[] = []; + for (const fc of functionCalls) { + const callId = fc.id ?? `${fc.name}-${Date.now()}`; + + if (!allowedToolNames.has(fc.name)) { + const toolName = String(fc.name); + const errorMessage = `Tool "${toolName}" not found. Tools must use the exact names provided.`; + + // Emit TOOL_CALL event for visibility + this.eventEmitter?.emit(SubAgentEventType.TOOL_CALL, { + subagentId: this.subagentId, + round: currentRound, + callId, + name: toolName, + args: fc.args ?? {}, + description: `Tool "${toolName}" not found`, + timestamp: Date.now(), + } as SubAgentToolCallEvent); + + // Build function response part (used for both event and LLM) + const functionResponsePart = { + functionResponse: { + id: callId, + name: toolName, + response: { error: errorMessage }, + }, + }; + + // Emit TOOL_RESULT event with error (include responseParts for UI rendering) + this.eventEmitter?.emit(SubAgentEventType.TOOL_RESULT, { + subagentId: this.subagentId, + round: currentRound, + callId, + name: toolName, + success: false, + error: errorMessage, + responseParts: [functionResponsePart], + resultDisplay: errorMessage, + durationMs: 0, + timestamp: Date.now(), + } as SubAgentToolResultEvent); + + // Record blocked tool call in stats + this.recordToolCallStats(toolName, false, 0, errorMessage); + + // Add function response for LLM + toolResponseParts.push(functionResponsePart); + continue; + } + authorizedCalls.push(fc); + } + // Build scheduler const responded = new Set(); let resolveBatch: (() => void) | null = null; @@ -605,33 +663,8 @@ export class SubAgentScope { ? call.response.error?.message : undefined; - // Update aggregate stats - this.executionStats.totalToolCalls += 1; - if (success) { - this.executionStats.successfulToolCalls += 1; - } else { - this.executionStats.failedToolCalls += 1; - } - - // Per-tool usage - const tu = this.toolUsage.get(toolName) || { - count: 0, - success: 0, - failure: 0, - totalDurationMs: 0, - averageDurationMs: 0, - }; - tu.count += 1; - if (success) { - tu.success += 1; - } else { - tu.failure += 1; - tu.lastError = errorMessage || 'Unknown error'; - } - tu.totalDurationMs = (tu.totalDurationMs || 0) + duration; - tu.averageDurationMs = - tu.count > 0 ? tu.totalDurationMs / tu.count : 0; - this.toolUsage.set(toolName, tu); + // Record stats + this.recordToolCallStats(toolName, success, duration, errorMessage); // Emit tool result event this.eventEmitter?.emit(SubAgentEventType.TOOL_RESULT, { @@ -642,12 +675,6 @@ export class SubAgentScope { success, error: errorMessage, responseParts: call.response.responseParts, - /** - * Tools like todoWrite will add some extra contents to the result, - * making it unable to deserialize the `responseParts` to a JSON object. - * While `resultDisplay` is normally a string, if not we stringify it, - * so that we can deserialize it to a JSON object when needed. - */ resultDisplay: call.response.resultDisplay ? typeof call.response.resultDisplay === 'string' ? call.response.resultDisplay @@ -657,14 +684,6 @@ export class SubAgentScope { timestamp: Date.now(), } as SubAgentToolResultEvent); - // Update statistics service - this.stats.recordToolCall( - toolName, - success, - duration, - this.toolUsage.get(toolName)?.lastError, - ); - // post-tool hook await this.hooks?.postToolUse?.({ subagentId: this.subagentId, @@ -736,7 +755,7 @@ export class SubAgentScope { }); // Prepare requests and emit TOOL_CALL events - const requests: ToolCallRequestInfo[] = functionCalls.map((fc) => { + const requests: ToolCallRequestInfo[] = authorizedCalls.map((fc) => { const toolName = String(fc.name || 'unknown'); const callId = fc.id ?? `${fc.name}-${Date.now()}`; const args = (fc.args ?? {}) as Record; @@ -902,6 +921,52 @@ export class SubAgentScope { } } + /** + * Records tool call statistics for both successful and failed tool calls. + * This includes updating aggregate stats, per-tool usage, and the statistics service. + */ + private recordToolCallStats( + toolName: string, + success: boolean, + durationMs: number, + errorMessage?: string, + ): void { + // Update aggregate stats + this.executionStats.totalToolCalls += 1; + if (success) { + this.executionStats.successfulToolCalls += 1; + } else { + this.executionStats.failedToolCalls += 1; + } + + // Per-tool usage + const tu = this.toolUsage.get(toolName) || { + count: 0, + success: 0, + failure: 0, + totalDurationMs: 0, + averageDurationMs: 0, + }; + tu.count += 1; + if (success) { + tu.success += 1; + } else { + tu.failure += 1; + tu.lastError = errorMessage || 'Unknown error'; + } + tu.totalDurationMs = (tu.totalDurationMs || 0) + durationMs; + tu.averageDurationMs = tu.count > 0 ? tu.totalDurationMs / tu.count : 0; + this.toolUsage.set(toolName, tu); + + // Update statistics service + this.stats.recordToolCall( + toolName, + success, + durationMs, + this.toolUsage.get(toolName)?.lastError, + ); + } + private buildChatSystemPrompt(context: ContextState): string { if (!this.promptConfig.systemPrompt) { // This should ideally be caught in createChatObject, but serves as a safeguard. diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 9e41b938e..8b55e28a9 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -87,6 +87,7 @@ describe('EditTool', () => { getGeminiMdFileCount: () => 0, setGeminiMdFileCount: vi.fn(), getToolRegistry: () => ({}) as any, // Minimal mock for ToolRegistry + getDefaultFileEncoding: vi.fn().mockReturnValue('utf-8'), } as unknown as Config; // Reset mocks before each test @@ -473,6 +474,80 @@ describe('EditTool', () => { }); }); + it('should create new file with BOM when defaultFileEncoding is utf-8-bom', async () => { + // Change config to use utf-8-bom + (mockConfig.getDefaultFileEncoding as Mock).mockReturnValue('utf-8-bom'); + + const newFileName = 'bom_new_file.txt'; + const newFilePath = path.join(rootDir, newFileName); + const fileContent = 'Content for BOM file.'; + const params: EditToolParams = { + file_path: newFilePath, + old_string: '', + new_string: fileContent, + }; + + (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( + ApprovalMode.AUTO_EDIT, + ); + const invocation = tool.build(params); + await invocation.execute(new AbortController().signal); + + // Verify file has BOM + const fileBuffer = fs.readFileSync(newFilePath); + expect(fileBuffer[0]).toBe(0xef); + expect(fileBuffer[1]).toBe(0xbb); + expect(fileBuffer[2]).toBe(0xbf); + expect(fileBuffer.toString('utf8')).toContain(fileContent); + }); + + it('should create new file without BOM when defaultFileEncoding is utf-8', async () => { + // Config defaults to utf-8 + const newFileName = 'no_bom_new_file.txt'; + const newFilePath = path.join(rootDir, newFileName); + const fileContent = 'Content without BOM.'; + const params: EditToolParams = { + file_path: newFilePath, + old_string: '', + new_string: fileContent, + }; + + (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( + ApprovalMode.AUTO_EDIT, + ); + const invocation = tool.build(params); + await invocation.execute(new AbortController().signal); + + // Verify file does not have BOM + const fileBuffer = fs.readFileSync(newFilePath); + expect(fileBuffer[0]).not.toBe(0xef); + expect(fileBuffer.toString('utf8')).toBe(fileContent); + }); + + it('should preserve BOM character in content when editing existing file', async () => { + const bomFilePath = path.join(rootDir, 'existing_bom.txt'); + // Create file with BOM (BOM is \ufeff character in string) + const originalContent = '\ufeff// Original line\nconst x = 1;'; + fs.writeFileSync(bomFilePath, originalContent, 'utf8'); + + const params: EditToolParams = { + file_path: bomFilePath, + old_string: 'const x = 1;', + new_string: 'const x = 2;', + }; + + (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( + ApprovalMode.AUTO_EDIT, + ); + const invocation = tool.build(params); + await invocation.execute(new AbortController().signal); + + // Verify file still has BOM and new content + const resultContent = fs.readFileSync(bomFilePath, 'utf8'); + expect(resultContent.charCodeAt(0)).toBe(0xfeff); // BOM preserved + expect(resultContent).toContain('const x = 2;'); + }); + it('should return error if old_string is not found in file', async () => { fs.writeFileSync(filePath, 'Some content.', 'utf8'); const params: EditToolParams = { diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index ec2572904..e7d8aea7f 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -20,6 +20,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js'; import { isNodeError } from '../utils/errors.js'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; +import { FileEncoding } from '../services/fileSystemService.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; import { ReadFileTool } from './read-file.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js'; @@ -367,9 +368,22 @@ class EditToolInvocation implements ToolInvocation { try { this.ensureParentDirectoriesExist(this.params.file_path); - await this.config - .getFileSystemService() - .writeTextFile(this.params.file_path, editData.newContent); + + // For new files, apply default file encoding setting + // For existing files, keep original content as-is (including any BOM character) + if (editData.isNewFile) { + const useBOM = + this.config.getDefaultFileEncoding() === FileEncoding.UTF8_BOM; + await this.config + .getFileSystemService() + .writeTextFile(this.params.file_path, editData.newContent, { + bom: useBOM, + }); + } else { + await this.config + .getFileSystemService() + .writeTextFile(this.params.file_path, editData.newContent); + } const fileName = path.basename(this.params.file_path); const originallyProposedContent = diff --git a/packages/core/src/tools/mcp-client-manager.test.ts b/packages/core/src/tools/mcp-client-manager.test.ts index ff2cb60fc..051c9d87a 100644 --- a/packages/core/src/tools/mcp-client-manager.test.ts +++ b/packages/core/src/tools/mcp-client-manager.test.ts @@ -9,15 +9,16 @@ import { McpClientManager } from './mcp-client-manager.js'; import { McpClient } from './mcp-client.js'; import type { ToolRegistry } from './tool-registry.js'; import type { Config } from '../config/config.js'; +import type { PromptRegistry } from '../prompts/prompt-registry.js'; +import type { WorkspaceContext } from '../utils/workspaceContext.js'; vi.mock('./mcp-client.js', async () => { const originalModule = await vi.importActual('./mcp-client.js'); return { ...originalModule, McpClient: vi.fn(), - populateMcpServerCommand: vi.fn(() => ({ - 'test-server': {}, - })), + // Return the input servers unchanged (identity function) + populateMcpServerCommand: vi.fn((servers) => servers), }; }); @@ -73,4 +74,178 @@ describe('McpClientManager', () => { expect(mockedMcpClient.connect).not.toHaveBeenCalled(); expect(mockedMcpClient.discover).not.toHaveBeenCalled(); }); + + it('should disconnect all clients when stop is called', async () => { + // Track disconnect calls across all instances + const disconnectCalls: string[] = []; + vi.mocked(McpClient).mockImplementation( + (name: string) => + ({ + connect: vi.fn(), + discover: vi.fn(), + disconnect: vi.fn().mockImplementation(() => { + disconnectCalls.push(name); + return Promise.resolve(); + }), + getStatus: vi.fn(), + }) as unknown as McpClient, + ); + const mockConfig = { + isTrustedFolder: () => true, + getMcpServers: () => ({ 'test-server': {}, 'another-server': {} }), + getMcpServerCommand: () => undefined, + getPromptRegistry: () => ({}) as PromptRegistry, + getWorkspaceContext: () => ({}) as WorkspaceContext, + getDebugMode: () => false, + } as unknown as Config; + const manager = new McpClientManager(mockConfig, {} as ToolRegistry); + // First connect to create the clients + await manager.discoverAllMcpTools({ + isTrustedFolder: () => true, + } as unknown as Config); + + // Clear the disconnect calls from initial stop() in discoverAllMcpTools + disconnectCalls.length = 0; + + // Then stop + await manager.stop(); + expect(disconnectCalls).toHaveLength(2); + expect(disconnectCalls).toContain('test-server'); + expect(disconnectCalls).toContain('another-server'); + }); + + it('should be idempotent - stop can be called multiple times safely', async () => { + const mockedMcpClient = { + connect: vi.fn(), + discover: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + getStatus: vi.fn(), + }; + vi.mocked(McpClient).mockReturnValue( + mockedMcpClient as unknown as McpClient, + ); + const mockConfig = { + isTrustedFolder: () => true, + getMcpServers: () => ({ 'test-server': {} }), + getMcpServerCommand: () => undefined, + getPromptRegistry: () => ({}) as PromptRegistry, + getWorkspaceContext: () => ({}) as WorkspaceContext, + getDebugMode: () => false, + } as unknown as Config; + const manager = new McpClientManager(mockConfig, {} as ToolRegistry); + await manager.discoverAllMcpTools({ + isTrustedFolder: () => true, + } as unknown as Config); + + // Call stop multiple times - should not throw + await manager.stop(); + await manager.stop(); + await manager.stop(); + }); + + it('should discover tools for a single server and track the client for stop', async () => { + const mockedMcpClient = { + connect: vi.fn(), + discover: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + getStatus: vi.fn(), + }; + vi.mocked(McpClient).mockReturnValue( + mockedMcpClient as unknown as McpClient, + ); + + const mockConfig = { + isTrustedFolder: () => true, + getMcpServers: () => ({ 'test-server': {} }), + getMcpServerCommand: () => undefined, + getPromptRegistry: () => ({}) as PromptRegistry, + getWorkspaceContext: () => ({}) as WorkspaceContext, + getDebugMode: () => false, + } as unknown as Config; + const manager = new McpClientManager(mockConfig, {} as ToolRegistry); + + await manager.discoverMcpToolsForServer( + 'test-server', + {} as unknown as Config, + ); + + expect(mockedMcpClient.connect).toHaveBeenCalledOnce(); + expect(mockedMcpClient.discover).toHaveBeenCalledOnce(); + + await manager.stop(); + expect(mockedMcpClient.disconnect).toHaveBeenCalledOnce(); + }); + + it('should replace an existing client when re-discovering a server', async () => { + const firstClient = { + connect: vi.fn(), + discover: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + getStatus: vi.fn(), + }; + const secondClient = { + connect: vi.fn(), + discover: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + getStatus: vi.fn(), + }; + + vi.mocked(McpClient) + .mockReturnValueOnce(firstClient as unknown as McpClient) + .mockReturnValueOnce(secondClient as unknown as McpClient); + + const mockConfig = { + isTrustedFolder: () => true, + getMcpServers: () => ({ 'test-server': {} }), + getMcpServerCommand: () => undefined, + getPromptRegistry: () => ({}) as PromptRegistry, + getWorkspaceContext: () => ({}) as WorkspaceContext, + getDebugMode: () => false, + } as unknown as Config; + const manager = new McpClientManager(mockConfig, {} as ToolRegistry); + + await manager.discoverMcpToolsForServer( + 'test-server', + {} as unknown as Config, + ); + await manager.discoverMcpToolsForServer( + 'test-server', + {} as unknown as Config, + ); + + expect(firstClient.disconnect).toHaveBeenCalledOnce(); + expect(secondClient.connect).toHaveBeenCalledOnce(); + expect(secondClient.discover).toHaveBeenCalledOnce(); + + await manager.stop(); + expect(secondClient.disconnect).toHaveBeenCalledOnce(); + }); + + it('should no-op when discovering an unknown server', async () => { + const mockedMcpClient = { + connect: vi.fn(), + discover: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + getStatus: vi.fn(), + }; + vi.mocked(McpClient).mockReturnValue( + mockedMcpClient as unknown as McpClient, + ); + + const mockConfig = { + isTrustedFolder: () => true, + getMcpServers: () => ({}), + getMcpServerCommand: () => undefined, + getPromptRegistry: () => ({}) as PromptRegistry, + getWorkspaceContext: () => ({}) as WorkspaceContext, + getDebugMode: () => false, + } as unknown as Config; + const manager = new McpClientManager(mockConfig, {} as ToolRegistry); + + await manager.discoverMcpToolsForServer('unknown-server', { + isTrustedFolder: () => true, + } as unknown as Config); + + expect(vi.mocked(McpClient)).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index 354776c8d..123088699 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -10,11 +10,13 @@ import type { ToolRegistry } from './tool-registry.js'; import { McpClient, MCPDiscoveryState, + MCPServerStatus, populateMcpServerCommand, } from './mcp-client.js'; import type { SendSdkMcpMessage } from './mcp-client.js'; import { getErrorMessage } from '../utils/errors.js'; import type { EventEmitter } from 'node:events'; +import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; /** * Manages the lifecycle of multiple MCP clients, including local child processes. @@ -100,6 +102,73 @@ export class McpClientManager { this.discoveryState = MCPDiscoveryState.COMPLETED; } + /** + * Connects to a single MCP server and discovers its tools/prompts. + * The connected client is tracked so it can be closed by {@link stop}. + * + * This is primarily used for on-demand re-discovery flows (e.g. after OAuth). + */ + async discoverMcpToolsForServer( + serverName: string, + cliConfig: Config, + ): Promise { + const servers = populateMcpServerCommand( + this.cliConfig.getMcpServers() || {}, + this.cliConfig.getMcpServerCommand(), + ); + const serverConfig = servers[serverName]; + if (!serverConfig) { + return; + } + + // Ensure we don't leak an existing connection for this server. + const existingClient = this.clients.get(serverName); + if (existingClient) { + try { + await existingClient.disconnect(); + } catch (error) { + console.error( + `Error stopping client '${serverName}': ${getErrorMessage(error)}`, + ); + } finally { + this.clients.delete(serverName); + this.eventEmitter?.emit('mcp-client-update', this.clients); + } + } + + // For SDK MCP servers, pass the sendSdkMcpMessage callback. + const sdkCallback = isSdkMcpServerConfig(serverConfig) + ? this.sendSdkMcpMessage + : undefined; + + const client = new McpClient( + serverName, + serverConfig, + this.toolRegistry, + this.cliConfig.getPromptRegistry(), + this.cliConfig.getWorkspaceContext(), + this.cliConfig.getDebugMode(), + sdkCallback, + ); + + this.clients.set(serverName, client); + this.eventEmitter?.emit('mcp-client-update', this.clients); + + try { + await client.connect(); + await client.discover(cliConfig); + } catch (error) { + // Log the error but don't throw: callers expect best-effort discovery. + console.error( + `Error during discovery for server '${serverName}': ${getErrorMessage( + error, + )}`, + ); + } finally { + this.eventEmitter?.emit('mcp-client-update', this.clients); + } + } + /** * Stops all running local MCP servers and closes all client connections. * This is the cleanup method to be called on application exit. @@ -124,4 +193,44 @@ export class McpClientManager { getDiscoveryState(): MCPDiscoveryState { return this.discoveryState; } + + async readResource( + serverName: string, + uri: string, + options?: { signal?: AbortSignal }, + ): Promise { + let client = this.clients.get(serverName); + if (!client) { + const servers = populateMcpServerCommand( + this.cliConfig.getMcpServers() || {}, + this.cliConfig.getMcpServerCommand(), + ); + const serverConfig = servers[serverName]; + if (!serverConfig) { + throw new Error(`MCP server '${serverName}' is not configured.`); + } + + const sdkCallback = isSdkMcpServerConfig(serverConfig) + ? this.sendSdkMcpMessage + : undefined; + + client = new McpClient( + serverName, + serverConfig, + this.toolRegistry, + this.cliConfig.getPromptRegistry(), + this.cliConfig.getWorkspaceContext(), + this.cliConfig.getDebugMode(), + sdkCallback, + ); + this.clients.set(serverName, client); + this.eventEmitter?.emit('mcp-client-update', this.clients); + } + + if (client.getStatus() !== MCPServerStatus.CONNECTED) { + await client.connect(); + } + + return client.readResource(uri, options); + } } diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index efea02ad0..cfae34506 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -15,11 +15,13 @@ import type { GetPromptResult, JSONRPCMessage, Prompt, + ReadResourceResult, } from '@modelcontextprotocol/sdk/types.js'; import { GetPromptResultSchema, ListPromptsResultSchema, ListRootsRequestSchema, + ReadResourceResultSchema, } from '@modelcontextprotocol/sdk/types.js'; import { parse } from 'shell-quote'; import type { Config, MCPServerConfig } from '../config/config.js'; @@ -194,6 +196,26 @@ export class McpClient { return this.status; } + async readResource( + uri: string, + options?: { signal?: AbortSignal }, + ): Promise { + if (this.status !== MCPServerStatus.CONNECTED) { + throw new Error('Client is not connected.'); + } + + // Only request resources if the server supports them. + if (this.client.getServerCapabilities()?.resources == null) { + throw new Error('MCP server does not support resources.'); + } + + return this.client.request( + { method: 'resources/read', params: { uri } }, + ReadResourceResultSchema, + options, + ); + } + private updateStatus(status: MCPServerStatus): void { this.status = status; updateMCPServerStatus(this.serverName, status); diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 4db7bd789..a2096a2a5 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -15,7 +15,6 @@ import { Kind, BaseDeclarativeTool, BaseToolInvocation } from './tools.js'; import type { Config } from '../config/config.js'; import { spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; -import { connectAndDiscover } from './mcp-client.js'; import type { SendSdkMcpMessage } from './mcp-client.js'; import { McpClientManager } from './mcp-client-manager.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; @@ -23,6 +22,7 @@ import { parse } from 'shell-quote'; import { ToolErrorType } from './tool-error.js'; import { safeJsonStringify } from '../utils/safeJsonStringify.js'; import type { EventEmitter } from 'node:events'; +import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; type ToolParams = Record; @@ -279,19 +279,10 @@ export class ToolRegistry { this.config.getPromptRegistry().removePromptsByServer(serverName); - const mcpServers = this.config.getMcpServers() ?? {}; - const serverConfig = mcpServers[serverName]; - if (serverConfig) { - await connectAndDiscover( - serverName, - serverConfig, - this, - this.config.getPromptRegistry(), - this.config.getDebugMode(), - this.config.getWorkspaceContext(), - this.config, - ); - } + await this.mcpClientManager.discoverMcpToolsForServer( + serverName, + this.config, + ); } private async discoverAndRegisterToolsFromCommand(): Promise { @@ -479,4 +470,29 @@ export class ToolRegistry { getTool(name: string): AnyDeclarativeTool | undefined { return this.tools.get(name); } + + async readMcpResource( + serverName: string, + uri: string, + options?: { signal?: AbortSignal }, + ): Promise { + if (!this.config.isTrustedFolder()) { + throw new Error('MCP resources are unavailable in untrusted folders.'); + } + + return this.mcpClientManager.readResource(serverName, uri, options); + } + + /** + * Stops all MCP clients and cleans up resources. + * This method is idempotent and safe to call multiple times. + */ + async stop(): Promise { + try { + await this.mcpClientManager.stop(); + } catch (error) { + // Log but don't throw - cleanup should be best-effort + console.error('Error stopping MCP clients:', error); + } + } } diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 7797659ed..99aeec3a5 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -148,7 +148,11 @@ ${textContent} override async shouldConfirmExecute(): Promise< ToolCallConfirmationDetails | false > { - if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { + // Auto-execute in AUTO_EDIT mode and PLAN mode (read-only tool) + if ( + this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT || + this.config.getApprovalMode() === ApprovalMode.PLAN + ) { return false; } diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index b9aa83c53..cd245128b 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -55,7 +55,11 @@ class WebSearchToolInvocation extends BaseToolInvocation< override async shouldConfirmExecute( _abortSignal: AbortSignal, ): Promise { - if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { + // Auto-execute in AUTO_EDIT mode and PLAN mode (read-only tool) + if ( + this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT || + this.config.getApprovalMode() === ApprovalMode.PLAN + ) { return false; } diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 6b60c42b3..b0d7a2b0d 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -81,6 +81,7 @@ const mockConfigInternal = { registerTool: vi.fn(), discoverTools: vi.fn(), }) as unknown as ToolRegistry, + getDefaultFileEncoding: () => 'utf-8', }; const mockConfig = mockConfigInternal as unknown as Config; @@ -730,4 +731,129 @@ describe('WriteFileTool', () => { ); }); }); + + describe('BOM preservation (Issue #1672)', () => { + const abortSignal = new AbortController().signal; + + it('should preserve BOM when overwriting existing file with BOM', async () => { + const filePath = path.join(rootDir, 'bom_file.txt'); + const originalContent = 'original content'; + const newContent = 'new content'; + + // Create file with BOM + fs.writeFileSync( + filePath, + Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from(originalContent, 'utf-8'), + ]), + ); + + // Spy on writeTextFile to verify BOM option + const writeSpy = vi.spyOn(fsService, 'writeTextFile'); + + const params = { file_path: filePath, content: newContent }; + const invocation = tool.build(params); + await invocation.execute(abortSignal); + + // Verify writeTextFile was called with bom: true + expect(writeSpy).toHaveBeenCalledWith(filePath, newContent, { + bom: true, + }); + + // Cleanup + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + }); + + it('should not add BOM when overwriting existing file without BOM', async () => { + const filePath = path.join(rootDir, 'no_bom_file.txt'); + const originalContent = 'original content'; + const newContent = 'new content'; + + // Create file without BOM + fs.writeFileSync(filePath, originalContent, 'utf-8'); + + // Spy on writeTextFile to verify BOM option + const writeSpy = vi.spyOn(fsService, 'writeTextFile'); + + const params = { file_path: filePath, content: newContent }; + const invocation = tool.build(params); + await invocation.execute(abortSignal); + + // Verify writeTextFile was called with bom: false + expect(writeSpy).toHaveBeenCalledWith(filePath, newContent, { + bom: false, + }); + + // Cleanup + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + }); + + it('should use default encoding for new files', async () => { + const filePath = path.join(rootDir, 'new_file.txt'); + const newContent = 'new content'; + + // Ensure file does not exist + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + // Spy on writeTextFile to verify BOM option + const writeSpy = vi.spyOn(fsService, 'writeTextFile'); + + const params = { file_path: filePath, content: newContent }; + const invocation = tool.build(params); + await invocation.execute(abortSignal); + + // Verify writeTextFile was called with bom: false (default is utf-8) + expect(writeSpy).toHaveBeenCalledWith(filePath, newContent, { + bom: false, + }); + + // Cleanup + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + }); + + it('should use BOM for new files when defaultFileEncoding is utf-8-bom', async () => { + const filePath = path.join(rootDir, 'new_file_bom.txt'); + const newContent = 'new content'; + + // Ensure file does not exist + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + // Mock config to return utf-8-bom + const originalGetDefaultFileEncoding = + mockConfigInternal.getDefaultFileEncoding; + mockConfigInternal.getDefaultFileEncoding = () => 'utf-8-bom'; + + // Spy on writeTextFile to verify BOM option + const writeSpy = vi.spyOn(fsService, 'writeTextFile'); + + const params = { file_path: filePath, content: newContent }; + const invocation = tool.build(params); + await invocation.execute(abortSignal); + + // Verify writeTextFile was called with bom: true + expect(writeSpy).toHaveBeenCalledWith(filePath, newContent, { + bom: true, + }); + + // Restore mock + mockConfigInternal.getDefaultFileEncoding = + originalGetDefaultFileEncoding; + + // Cleanup + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + }); + }); }); diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index c13c95539..b3d524a92 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -24,6 +24,7 @@ import { ToolConfirmationOutcome, } from './tools.js'; import { ToolErrorType } from './tool-error.js'; +import { FileEncoding } from '../services/fileSystemService.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; @@ -235,9 +236,20 @@ class WriteFileToolInvocation extends BaseToolInvocation< fs.mkdirSync(dirName, { recursive: true }); } + // Check if file exists and has BOM to preserve encoding + // For new files, use the configured default encoding + let useBOM = false; + if (!isNewFile) { + useBOM = await this.config + .getFileSystemService() + .detectFileBOM(file_path); + } else { + useBOM = this.config.getDefaultFileEncoding() === FileEncoding.UTF8_BOM; + } + await this.config .getFileSystemService() - .writeTextFile(file_path, fileContent); + .writeTextFile(file_path, fileContent, { bom: useBOM }); // Generate diff for display result const fileName = path.basename(file_path); diff --git a/packages/core/src/utils/editHelper.test.ts b/packages/core/src/utils/editHelper.test.ts index 79fe78f6e..467a426ff 100644 --- a/packages/core/src/utils/editHelper.test.ts +++ b/packages/core/src/utils/editHelper.test.ts @@ -16,11 +16,11 @@ describe('normalizeEditStrings', () => { const two = 2; `; - it('returns literal matches unchanged and trims new_string trailing whitespace', () => { + it('returns literal matches unchanged', () => { const result = normalizeEditStrings( file, 'const two = 2;', - ' const two = 42; ', + ' const two = 42;', ); expect(result).toEqual({ oldString: 'const two = 2;', @@ -32,11 +32,11 @@ const two = 2; const result = normalizeEditStrings( "const greeting = 'Don't';\n", 'const greeting = ‘Don’t’;', - 'const greeting = “Hello”; ', + 'const greeting = "Hello";', ); expect(result).toEqual({ oldString: "const greeting = 'Don't';", - newString: 'const greeting = “Hello”;', + newString: 'const greeting = "Hello";', }); }); @@ -48,15 +48,7 @@ const two = 2; }); }); - it('still trims new_string when editing a brand-new file', () => { - const result = normalizeEditStrings(null, '', 'new file contents '); - expect(result).toEqual({ - oldString: '', - newString: 'new file contents', - }); - }); - - it('matches unicode dash variants', () => { + it('matches unicode dash variants and preserves newString', () => { const result = normalizeEditStrings( 'const range = "1-2";\n', 'const range = "1\u20132";', @@ -64,19 +56,7 @@ const two = 2; ); expect(result).toEqual({ oldString: 'const range = "1-2";', - newString: 'const range = "3\u20135";', - }); - }); - - it('matches when trailing whitespace differs only at line ends', () => { - const result = normalizeEditStrings( - 'value = 1;\n', - 'value = 1; \n', - 'value = 2; \n', - ); - expect(result).toEqual({ - oldString: 'value = 1;\n', - newString: 'value = 2;\n', + newString: 'const range = "3\u20135"; ', }); }); @@ -103,6 +83,83 @@ const two = 2; newString: 'console.log("bye")', }); }); + + // Tests for issue #1618: Preserve trailing whitespace in newString + describe('trailing whitespace preservation in newString', () => { + it('preserves trailing whitespace when intentionally adding to end of line', () => { + // Test with tab + const result1 = normalizeEditStrings( + 'value = 1;\n', + 'value = 1;\n', + 'value = 1;\t\n', + ); + expect(result1.newString).toBe('value = 1;\t\n'); + + // Test with spaces (same behavior, just different whitespace char) + const result2 = normalizeEditStrings('text\n', 'text\n', 'text \n'); + expect(result2.newString).toBe('text \n'); + }); + + it('preserves newString trailing whitespace even when oldString is fuzzy matched', () => { + const result = normalizeEditStrings( + 'value = 1;\n', // File has no trailing spaces + 'value = 1; \n', // LLM copied with extra spaces (will be fuzzy matched) + 'value = 2; \n', // LLM replacement also has spaces + ); + expect(result).toEqual({ + oldString: 'value = 1;\n', // Canonical from file + newString: 'value = 2; \n', // Preserved as LLM intended + }); + }); + + it('preserves trailing whitespace in multi-line template literals', () => { + const file = 'const s = "";\n'; + const result = normalizeEditStrings( + file, + 'const s = "";', + 'const s = `line1 \nline2`;', // Trailing spaces after line1 are significant + ); + expect(result.newString).toBe('const s = `line1 \nline2`;'); + }); + + it('preserves trailing whitespace when creating new file', () => { + const result = normalizeEditStrings( + null, + '', + 'content with trailing tab\t\n', + ); + expect(result).toEqual({ + oldString: '', + newString: 'content with trailing tab\t\n', + }); + }); + + it('still supports fuzzy matching after trailing whitespace was added in previous edit', () => { + // Round 1: Add trailing spaces to a line + let fileContent = 'value = 1;\n'; + const round1 = normalizeEditStrings( + fileContent, + 'value = 1;\n', + 'value = 1; \n', // Adding trailing spaces + ); + expect(round1.newString).toBe('value = 1; \n'); + + // Simulate the edit being applied + fileContent = fileContent.replace(round1.oldString, round1.newString); + expect(fileContent).toBe('value = 1; \n'); // File now has trailing spaces + + // Round 2: LLM tries to edit again, but its oldString doesn't have trailing spaces + // (because LLM context may not preserve exact whitespace) + const round2 = normalizeEditStrings( + fileContent, + 'value = 1;\n', // LLM thinks there's no trailing spaces + 'value = 2;\n', + ); + // Fuzzy matching should still find the line and return canonical slice WITH trailing spaces + expect(round2.oldString).toBe('value = 1; \n'); + expect(round2.newString).toBe('value = 2;\n'); + }); + }); }); describe('countOccurrences', () => { diff --git a/packages/core/src/utils/editHelper.ts b/packages/core/src/utils/editHelper.ts index 6b4a388db..797655a2f 100644 --- a/packages/core/src/utils/editHelper.ts +++ b/packages/core/src/utils/editHelper.ts @@ -64,30 +64,6 @@ function normalizeBasicCharacters(text: string): string { return normalized; } -/** - * Removes trailing whitespace from each line while keeping the original newline - * separators intact. - */ -function stripTrailingWhitespacePreserveNewlines(text: string): string { - const pieces = text.split(/(\r\n|\n|\r)/); - let result = ''; - - for (let i = 0; i < pieces.length; i++) { - const segment = pieces[i]; - if (segment === undefined) { - continue; - } - - if (i % 2 === 0) { - result += segment.trimEnd(); - } else { - result += segment; - } - } - - return result; -} - /* -------------------------------------------------------------------------- */ /* Line-based search helpers */ /* -------------------------------------------------------------------------- */ @@ -323,23 +299,26 @@ export interface NormalizedEditStrings { /** * Runs the core normalization pipeline: - * 1. Strip trailing whitespace copied from numbered output. - * 2. Attempt to find the literal text inside {@link fileContent}. - * 3. If found through a relaxed match (smart quotes, line trims, etc.), + * 1. Attempt to find the literal text inside {@link fileContent}. + * 2. If found through a relaxed match (smart quotes, line trims, etc.), * return the canonical slice from disk so later replacements operate on * exact bytes. + * 3. Preserve newString as-is (it represents the LLM's intent). + * + * Note: Trailing whitespace in newString is intentionally NOT stripped. + * While LLMs may sometimes accidentally add trailing whitespace, stripping it + * unconditionally breaks legitimate use cases where trailing whitespace is + * intentional (e.g., multi-line strings, heredocs). See issue #1618. */ export function normalizeEditStrings( fileContent: string | null, oldString: string, newString: string, ): NormalizedEditStrings { - const trimmedNewString = stripTrailingWhitespacePreserveNewlines(newString); - if (fileContent === null || oldString === '') { return { oldString, - newString: trimmedNewString, + newString, }; } @@ -348,7 +327,7 @@ export function normalizeEditStrings( return { oldString: canonicalOriginal.slice, newString: adjustNewStringForTrailingLine( - trimmedNewString, + newString, canonicalOriginal.removedTrailingFinalEmptyLine, ), }; @@ -356,7 +335,7 @@ export function normalizeEditStrings( return { oldString, - newString: trimmedNewString, + newString, }; } diff --git a/packages/core/src/utils/ripgrepUtils.ts b/packages/core/src/utils/ripgrepUtils.ts index 1f432541e..87998b6ab 100644 --- a/packages/core/src/utils/ripgrepUtils.ts +++ b/packages/core/src/utils/ripgrepUtils.ts @@ -100,38 +100,23 @@ export function getBuiltinRipgrep(): string | null { return null; } - // Binary name includes .exe on Windows const binaryName = platform === 'win32' ? 'rg.exe' : 'rg'; - // Path resolution: - // When running from transpiled code: dist/src/utils/ripgrepUtils.js -> ../../../vendor/ripgrep/ - // When running from bundle: dist/index.js -> vendor/ripgrep/ + // Determine levels to traverse up to reach package root where vendor/ lives: + // - Bundle (dist/index.js): vendor copied into dist/, 0 levels + // - Source (src/utils/*.ts): 2 levels up + // - Transpiled (dist/src/utils/*.js): 3 levels up + const inSrcUtils = __filename.includes(path.join('src', 'utils')); + const levelsUp = !inSrcUtils ? 0 : __filename.endsWith('.ts') ? 2 : 3; - // Detect if we're running from a bundle (single file) - // In bundle, __filename will be something like /path/to/dist/index.js - // In transpiled code, __filename will be /path/to/dist/src/utils/ripgrepUtils.js - const isBundled = !__filename.includes(path.join('src', 'utils')); - - const vendorPath = isBundled - ? path.join( - __dirname, - 'vendor', - 'ripgrep', - `${arch}-${platform}`, - binaryName, - ) - : path.join( - __dirname, - '..', - '..', - '..', - 'vendor', - 'ripgrep', - `${arch}-${platform}`, - binaryName, - ); - - return vendorPath; + return path.join( + __dirname, + ...Array(levelsUp).fill('..'), + 'vendor', + 'ripgrep', + `${arch}-${platform}`, + binaryName, + ); } /** diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index d133660ff..b974bfd5a 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -169,6 +169,121 @@ describe('isCommandAllowed', () => { const result = isCommandAllowed("echo '$(pwd)'", config); expect(result.allowed).toBe(true); }); + + describe('heredocs', () => { + it('should allow substitution-like content in a quoted heredoc delimiter', () => { + const cmd = [ + "cat <<'EOF' > user_session.md", + '```', + '$(rm -rf /)', + '`not executed`', + '```', + 'EOF', + ].join('\n'); + + const result = isCommandAllowed(cmd, config); + expect(result.allowed).toBe(true); + }); + + it('should block command substitution in an unquoted heredoc body', () => { + const cmd = [ + 'cat < user_session.md', + "'$(rm -rf /)'", + 'EOF', + ].join('\n'); + + const result = isCommandAllowed(cmd, config); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Command substitution'); + }); + + it('should block backtick command substitution in an unquoted heredoc body', () => { + const cmd = ['cat < user_session.md', '`rm -rf /`', 'EOF'].join( + '\n', + ); + + const result = isCommandAllowed(cmd, config); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Command substitution'); + }); + + it('should allow escaped command substitution in an unquoted heredoc body', () => { + const cmd = [ + 'cat < user_session.md', + '\\$(rm -rf /)', + 'EOF', + ].join('\n'); + + const result = isCommandAllowed(cmd, config); + expect(result.allowed).toBe(true); + }); + + it('should support tab-stripping heredocs (<<-)', () => { + const cmd = [ + "cat <<-'EOF' > user_session.md", + '\t$(rm -rf /)', + '\tEOF', + ].join('\n'); + + const result = isCommandAllowed(cmd, config); + expect(result.allowed).toBe(true); + }); + + it('should block command substitution split by line continuation in an unquoted heredoc body', () => { + const cmd = [ + 'cat < user_session.md', + '$\\', + '(rm -rf /)', + 'EOF', + ].join('\n'); + + const result = isCommandAllowed(cmd, config); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Command substitution'); + }); + + it('should allow escaped command substitution split by line continuation in an unquoted heredoc body', () => { + const cmd = [ + 'cat < user_session.md', + '\\$\\', + '(rm -rf /)', + 'EOF', + ].join('\n'); + + const result = isCommandAllowed(cmd, config); + expect(result.allowed).toBe(true); + }); + }); + + describe('comments', () => { + it('should ignore heredoc operators inside comments', () => { + const cmd = ["# Fake heredoc <<'EOF'", '$(rm -rf /)', 'EOF'].join('\n'); + + const result = isCommandAllowed(cmd, config); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Command substitution'); + }); + + it('should allow command substitution patterns inside full-line comments', () => { + const cmd = ['# Note: $(rm -rf /) is dangerous', 'echo hello'].join( + '\n', + ); + + const result = isCommandAllowed(cmd, config); + expect(result.allowed).toBe(true); + }); + + it('should allow command substitution patterns inside inline comments', () => { + const result = isCommandAllowed('echo hello # $(rm -rf /)', config); + expect(result.allowed).toBe(true); + }); + + it('should not treat # inside a word as a comment starter', () => { + const result = isCommandAllowed('echo foo#$(rm -rf /)', config); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Command substitution'); + }); + }); }); }); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index ea20ed08c..1f0476866 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -240,19 +240,306 @@ export function stripShellWrapper(command: string): string { * - Single quotes ('): Everything literal, no substitution possible * - Double quotes ("): Command substitution with $() and backticks unless escaped with \ * - No quotes: Command substitution with $(), <(), and backticks + * + * This function also understands heredocs: + * - If a heredoc delimiter is quoted (e.g. `<<'EOF'`), bash will not perform + * expansions in the heredoc body, so substitution-like text is allowed. + * - If a heredoc delimiter is unquoted (e.g. `< { + if (command[index] !== '#') return false; + if (index === 0) return true; + + const prev = command[index - 1]!; + if (prev === ' ' || prev === '\t' || prev === '\n' || prev === '\r') { + return true; + } + + // `#` starts a comment when it begins a word. In practice this includes + // common command separators/operators where a new word can begin. + return [';', '&', '|', '(', ')', '<', '>'].includes(prev); + }; + + const isWordBoundary = (char: string): boolean => { + if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { + return true; + } + // Shell metacharacters that would terminate a WORD token in this context. + // This helps correctly parse heredoc delimiters in cases like `<', '(', ')'].includes(char); + }; + + const parseHeredocOperator = ( + startIndex: number, + ): { nextIndex: number; heredoc: PendingHeredoc } | null => { + // startIndex points at the first '<' of the `<<` operator. + if (command[startIndex] !== '<' || command[startIndex + 1] !== '<') { + return null; + } + + let i = startIndex + 2; + const stripLeadingTabs = command[i] === '-'; + if (stripLeadingTabs) i++; + + // Skip whitespace between operator and delimiter word. + while (i < command.length && (command[i] === ' ' || command[i] === '\t')) { + i++; + } + + // Parse the delimiter WORD token. If any quoting is used in the delimiter, + // bash disables expansions in the heredoc body. + let delimiter = ''; + let isQuotedDelimiter = false; + let inSingleQuotes = false; + let inDoubleQuotes = false; + + while (i < command.length) { + const char = command[i]!; + if (!inSingleQuotes && !inDoubleQuotes && isWordBoundary(char)) { + break; + } + + if (!inSingleQuotes && !inDoubleQuotes) { + if (char === "'") { + isQuotedDelimiter = true; + inSingleQuotes = true; + i++; + continue; + } + if (char === '"') { + isQuotedDelimiter = true; + inDoubleQuotes = true; + i++; + continue; + } + if (char === '\\') { + isQuotedDelimiter = true; + i++; + if (i >= command.length) break; + delimiter += command[i]!; + i++; + continue; + } + delimiter += char; + i++; + continue; + } + + if (inSingleQuotes) { + if (char === "'") { + inSingleQuotes = false; + i++; + continue; + } + delimiter += char; + i++; + continue; + } + + // inDoubleQuotes + if (char === '"') { + inDoubleQuotes = false; + i++; + continue; + } + if (char === '\\') { + // Backslash quoting is supported in double-quoted words. For our + // purposes, treat it as quoting and include the escaped char as-is. + isQuotedDelimiter = true; + i++; + if (i >= command.length) break; + delimiter += command[i]!; + i++; + continue; + } + delimiter += char; + i++; + } + + // If we couldn't parse a delimiter WORD, this isn't a supported heredoc + // operator for our purposes (e.g. a here-string like `<<<`). + if (delimiter.length === 0) { + return null; + } + + return { + nextIndex: i, + heredoc: { + delimiter, + isQuotedDelimiter, + stripLeadingTabs, + }, + }; + }; + + const lineHasCommandSubstitution = (line: string): boolean => { + for (let i = 0; i < line.length; i++) { + const char = line[i]!; + const nextChar = line[i + 1]; + + // In unquoted heredocs, backslash can be used to escape `$` and backticks. + if (char === '\\') { + i++; // Skip the escaped char (if any) + continue; + } + + if (char === '$' && nextChar === '(') { + return true; + } + + if (char === '`') { + return true; + } + } + return false; + }; + + const consumeHeredocBodies = ( + startIndex: number, + pending: PendingHeredoc[], + ): { nextIndex: number; hasSubstitution: boolean } => { + let i = startIndex; + + for (const heredoc of pending) { + // Track `$\` line continuations in unquoted heredocs, since + // bash ignores `\` during heredoc expansions and this can join + // `$` and `(` across lines to form `$(`. + let pendingDollarLineContinuation = false; + + while (i <= command.length) { + const lineStart = i; + while ( + i < command.length && + command[i] !== '\n' && + command[i] !== '\r' + ) { + i++; + } + const lineEnd = i; + + let newlineLength = 0; + if ( + i < command.length && + command[i] === '\r' && + command[i + 1] === '\n' + ) { + newlineLength = 2; + } else if ( + i < command.length && + (command[i] === '\n' || command[i] === '\r') + ) { + newlineLength = 1; + } + + const rawLine = command.slice(lineStart, lineEnd); + const effectiveLine = heredoc.stripLeadingTabs + ? rawLine.replace(/^\t+/, '') + : rawLine; + + if (effectiveLine === heredoc.delimiter) { + i = lineEnd + newlineLength; + break; + } + + if (!heredoc.isQuotedDelimiter) { + if (pendingDollarLineContinuation && effectiveLine.startsWith('(')) { + return { nextIndex: i, hasSubstitution: true }; + } + + if (lineHasCommandSubstitution(effectiveLine)) { + return { nextIndex: i, hasSubstitution: true }; + } + + pendingDollarLineContinuation = false; + if ( + newlineLength > 0 && + rawLine.length >= 2 && + rawLine.endsWith('\\') && + rawLine[rawLine.length - 2] === '$' + ) { + let backslashCount = 0; + for ( + let j = rawLine.length - 3; + j >= 0 && rawLine[j] === '\\'; + j-- + ) { + backslashCount++; + } + const isEscapedDollar = backslashCount % 2 === 1; + pendingDollarLineContinuation = !isEscapedDollar; + } + } + + // Advance to the next line (or end). + i = lineEnd + newlineLength; + if (newlineLength === 0) { + break; + } + } + } + + return { nextIndex: i, hasSubstitution: false }; + }; + let inSingleQuotes = false; let inDoubleQuotes = false; let inBackticks = false; + let inComment = false; + const pendingHeredocs: PendingHeredoc[] = []; let i = 0; while (i < command.length) { - const char = command[i]; + const char = command[i]!; const nextChar = command[i + 1]; + // If we just finished parsing a heredoc operator, the heredoc body begins + // after the command line ends (a newline). Once we hit that newline, + // consume heredoc bodies sequentially before continuing. + if (!inSingleQuotes && !inDoubleQuotes && !inBackticks) { + if (char === '\r' && nextChar === '\n') { + inComment = false; + if (pendingHeredocs.length > 0) { + const result = consumeHeredocBodies(i + 2, pendingHeredocs); + if (result.hasSubstitution) return true; + pendingHeredocs.length = 0; + i = result.nextIndex; + continue; + } + } else if (char === '\n' || char === '\r') { + inComment = false; + if (pendingHeredocs.length > 0) { + const result = consumeHeredocBodies(i + 1, pendingHeredocs); + if (result.hasSubstitution) return true; + pendingHeredocs.length = 0; + i = result.nextIndex; + continue; + } + } + } + + if (!inSingleQuotes && !inDoubleQuotes && !inBackticks) { + if (!inComment && isCommentStart(i)) { + inComment = true; + i++; + continue; + } + + if (inComment) { + i++; + continue; + } + } + // Handle escaping - only works outside single quotes if (char === '\\' && !inSingleQuotes) { i += 2; // Skip the escaped character @@ -269,7 +556,24 @@ export function detectCommandSubstitution(command: string): boolean { inBackticks = !inBackticks; } - // Check for command substitution patterns that would be executed + // Detect heredoc operators (`<<` / `<<-`) only in command-line context. + if ( + !inSingleQuotes && + !inDoubleQuotes && + !inBackticks && + char === '<' && + nextChar === '<' + ) { + const parsed = parseHeredocOperator(i); + if (parsed) { + pendingHeredocs.push(parsed.heredoc); + i = parsed.nextIndex; + continue; + } + } + + // Check for command substitution patterns that would be executed. + // Note: heredoc body content is handled separately via consumeHeredocBodies. if (!inSingleQuotes) { // $(...) command substitution - works in double quotes and unquoted if (char === '$' && nextChar === '(') { @@ -286,9 +590,9 @@ export function detectCommandSubstitution(command: string): boolean { return true; } - // Backtick command substitution - check for opening backtick - // (We track the state above, so this catches the start of backtick substitution) - if (char === '`' && !inBackticks) { + // Backtick command substitution. + // We treat any unescaped backtick outside single quotes as substitution. + if (char === '`') { return true; } } @@ -296,6 +600,8 @@ export function detectCommandSubstitution(command: string): boolean { i++; } + // If there are pending heredocs but no newline/body, there is nothing left to + // scan for heredoc-body substitutions. return false; } diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 567b6f363..69381bafc 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -23,8 +23,11 @@ const esbuildProblemMatcherPlugin = { name: 'esbuild-problem-matcher', setup(build) { + const isWatchMode = build.initialOptions.watch; build.onStart(() => { - console.log('[watch] build started'); + if (isWatchMode) { + console.log('[watch] build started'); + } }); build.onEnd((result) => { result.errors.forEach(({ text, location }) => { @@ -33,7 +36,9 @@ const esbuildProblemMatcherPlugin = { ` ${location.file}:${location.line}:${location.column}:`, ); }); - console.log('[watch] build finished'); + if (isWatchMode) { + console.log('[watch] build finished'); + } }); }, }; diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 64c49e2a4..a8339c466 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -115,8 +115,8 @@ "scripts": { "prepackage": "node ./scripts/prepackage.js", "build": "npm run build:dev", - "build:dev": "npm --workspace @qwen-code/webui run build && npm run check-types && npm run lint && node esbuild.js", - "build:prod": "npm --workspace @qwen-code/webui run build && node esbuild.js --production", + "build:dev": "npm run check-types && npm run lint && node esbuild.js", + "build:prod": "node esbuild.js --production", "generate:notices": "node ./scripts/generate-notices.js", "prepare": "npm run generate:notices", "check-types": "tsc --noEmit", diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 9b4a188c8..af9140905 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -55,7 +55,7 @@ export class QwenConnectionHandler { let availableModels: ModelInfo[] | undefined; // Build extra CLI arguments (only essential parameters) - const extraArgs: string[] = []; + const extraArgs: string[] = ['--experimental-skills']; await connection.connect(cliEntryPath!, workingDir, extraArgs); diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index a1a4ceb0a..33f509929 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -264,16 +264,11 @@ export const App: React.FC = () => { [fileContext.workspaceFiles], ); - // When workspace files update while menu open for @, refresh items so the first @ shows the list + // When workspace files update while menu open for @, refresh items to reflect latest search results. // Note: Avoid depending on the entire `completion` object here, since its identity // changes on every render which would retrigger this effect and can cause a refresh loop. useEffect(() => { - // Only auto-refresh when there's no query (first @ popup) to avoid repeated refreshes during search - if ( - completion.isOpen && - completion.triggerChar === '@' && - !completion.query - ) { + if (completion.isOpen && completion.triggerChar === '@') { // Only refresh items; do not change other completion state to avoid re-renders loops completion.refreshCompletion(); } diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts new file mode 100644 index 000000000..8cccae79e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; +import { FileMessageHandler } from './FileMessageHandler.js'; +import * as vscode from 'vscode'; + +const shouldIgnoreFileMock = vi.hoisted(() => vi.fn()); +const vscodeMock = vi.hoisted(() => { + class Uri { + fsPath: string; + constructor(fsPath: string) { + this.fsPath = fsPath; + } + static file(fsPath: string) { + return new Uri(fsPath); + } + } + + return { + Uri, + workspace: { + findFiles: vi.fn(), + getWorkspaceFolder: vi.fn(), + asRelativePath: vi.fn(), + workspaceFolders: [], + }, + window: { + activeTextEditor: undefined, + tabGroups: { + all: [], + }, + }, + }; +}); + +vi.mock('vscode', () => vscodeMock); +vi.mock( + '@qwen-code/qwen-code-core/src/services/fileDiscoveryService.js', + () => ({ + FileDiscoveryService: class { + shouldIgnoreFile(filePath: string, options?: unknown) { + return shouldIgnoreFileMock(filePath, options); + } + }, + }), +); + +describe('FileMessageHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('filters ignored paths and includes request metadata in workspace files', async () => { + const rootPath = '/workspace'; + const allowedPath = `${rootPath}/allowed.txt`; + const ignoredPath = `${rootPath}/ignored.log`; + + const allowedUri = vscode.Uri.file(allowedPath); + const ignoredUri = vscode.Uri.file(ignoredPath); + + vscodeMock.workspace.findFiles.mockResolvedValue([allowedUri, ignoredUri]); + vscodeMock.workspace.getWorkspaceFolder.mockImplementation(() => ({ + uri: vscode.Uri.file(rootPath), + })); + vscodeMock.workspace.asRelativePath.mockImplementation((uri: vscode.Uri) => + uri.fsPath.replace(`${rootPath}/`, ''), + ); + + shouldIgnoreFileMock.mockImplementation((filePath: string) => + filePath.includes('ignored'), + ); + + const sendToWebView = vi.fn(); + const handler = new FileMessageHandler( + {} as QwenAgentManager, + {} as ConversationStore, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'getWorkspaceFiles', + data: { query: 'txt', requestId: 7 }, + }); + + expect(vscodeMock.workspace.findFiles).toHaveBeenCalledWith( + '**/*[tT][xX][tT]*', + '**/{.git,node_modules}/**', + 50, + ); + expect(shouldIgnoreFileMock).toHaveBeenCalledWith(ignoredPath, { + respectGitIgnore: true, + respectQwenIgnore: false, + }); + + expect(sendToWebView).toHaveBeenCalledTimes(1); + const payload = sendToWebView.mock.calls[0]?.[0] as { + type: string; + data: { + files: Array<{ path: string }>; + query?: string; + requestId?: number; + }; + }; + + expect(payload.type).toBe('workspaceFiles'); + expect(payload.data.requestId).toBe(7); + expect(payload.data.query).toBe('txt'); + expect(payload.data.files).toHaveLength(1); + expect(payload.data.files[0]?.path).toBe(allowedPath); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts index c786d1eea..908de9ca4 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -13,12 +13,32 @@ import { ensureLeftGroupOfChatWebview, } from '../../utils/editorGroupUtils.js'; import { ReadonlyFileSystemProvider } from '../../services/readonlyFileSystemProvider.js'; +import { FileDiscoveryService } from '@qwen-code/qwen-code-core/src/services/fileDiscoveryService.js'; /** * File message handler * Handles all file-related messages */ export class FileMessageHandler extends BaseMessageHandler { + private readonly fileDiscoveryServices = new Map< + string, + FileDiscoveryService + >(); + private readonly globSpecialChars = new Set([ + '\\', + '*', + '?', + '[', + ']', + '{', + '}', + '(', + ')', + '!', + '+', + '@', + ]); + canHandle(messageType: string): boolean { return [ 'attachFile', @@ -43,7 +63,10 @@ export class FileMessageHandler extends BaseMessageHandler { break; case 'getWorkspaceFiles': - await this.handleGetWorkspaceFiles(data?.query as string | undefined); + await this.handleGetWorkspaceFiles( + data?.query as string | undefined, + data?.requestId as number | undefined, + ); break; case 'openFile': @@ -190,10 +213,14 @@ export class FileMessageHandler extends BaseMessageHandler { /** * Get workspace files */ - private async handleGetWorkspaceFiles(query?: string): Promise { + private async handleGetWorkspaceFiles( + query?: string, + requestId?: number, + ): Promise { try { console.log('[FileMessageHandler] handleGetWorkspaceFiles start', { query, + requestId, }); const files: Array<{ id: string; @@ -208,8 +235,26 @@ export class FileMessageHandler extends BaseMessageHandler { return; } - const fileName = getFileName(uri.fsPath); const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + if (workspaceFolder) { + const rootPath = workspaceFolder.uri.fsPath; + let discovery = this.fileDiscoveryServices.get(rootPath); + if (!discovery) { + discovery = new FileDiscoveryService(rootPath); + this.fileDiscoveryServices.set(rootPath, discovery); + } + // Apply gitignore filtering so ignored paths don't appear in @ results. + if ( + discovery.shouldIgnoreFile(uri.fsPath, { + respectGitIgnore: true, + respectQwenIgnore: false, + }) + ) { + return; + } + } + + const fileName = getFileName(uri.fsPath); const relativePath = workspaceFolder ? vscode.workspace.asRelativePath(uri, false) : uri.fsPath; @@ -234,14 +279,15 @@ export class FileMessageHandler extends BaseMessageHandler { // Search or show recent files if (query) { + const includePattern = `**/*${this.buildCaseInsensitiveGlob(query)}*`; // Query mode: perform filesystem search (may take longer on large workspaces) console.log( '[FileMessageHandler] Searching workspace files for query', query, ); const uris = await vscode.workspace.findFiles( - `**/*${query}*`, - '**/node_modules/**', + includePattern, + '**/{.git,node_modules}/**', 50, ); @@ -269,7 +315,10 @@ export class FileMessageHandler extends BaseMessageHandler { // Send an initial quick response so UI can render immediately try { - this.sendToWebView({ type: 'workspaceFiles', data: { files } }); + this.sendToWebView({ + type: 'workspaceFiles', + data: { files, query, requestId }, + }); console.log( '[FileMessageHandler] Sent initial workspaceFiles (open tabs/active)', files.length, @@ -285,7 +334,7 @@ export class FileMessageHandler extends BaseMessageHandler { if (files.length < 10) { const recentUris = await vscode.workspace.findFiles( '**/*', - '**/node_modules/**', + '**/{.git,node_modules}/**', 20, ); @@ -298,7 +347,10 @@ export class FileMessageHandler extends BaseMessageHandler { } } - this.sendToWebView({ type: 'workspaceFiles', data: { files } }); + this.sendToWebView({ + type: 'workspaceFiles', + data: { files, query, requestId }, + }); console.log( '[FileMessageHandler] Sent final workspaceFiles', files.length, @@ -496,4 +548,18 @@ export class FileMessageHandler extends BaseMessageHandler { ); } } + + private buildCaseInsensitiveGlob(query: string): string { + let pattern = ''; + for (const char of query) { + if (/[a-zA-Z]/.test(char)) { + pattern += `[${char.toLowerCase()}${char.toUpperCase()}]`; + } else if (this.globSpecialChars.has(char)) { + pattern += `\\${char}`; + } else { + pattern += char; + } + } + return pattern; + } } diff --git a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts index 8bccc658e..0f5296550 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts @@ -34,6 +34,10 @@ export const useFileContext = (vscode: VSCodeAPI) => { // Whether workspace files have been requested const hasRequestedFilesRef = useRef(false); + // Use request ids to avoid applying stale workspace file responses. + const workspaceFilesRequestIdRef = useRef(0); + const latestWorkspaceFilesRequestIdRef = useRef(null); + // Last non-empty query to decide when to refetch full list const lastQueryRef = useRef(undefined); @@ -46,31 +50,47 @@ export const useFileContext = (vscode: VSCodeAPI) => { const requestWorkspaceFiles = useCallback( (query?: string) => { const normalizedQuery = query?.trim(); + const normalizedQueryKey = normalizedQuery?.toLowerCase(); // If there's a query, clear previous timer and set up debounce if (normalizedQuery && normalizedQuery.length >= 1) { + if (normalizedQueryKey === lastQueryRef.current) { + return; + } if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); } + const requestId = workspaceFilesRequestIdRef.current + 1; + workspaceFilesRequestIdRef.current = requestId; + latestWorkspaceFilesRequestIdRef.current = requestId; + searchTimerRef.current = setTimeout(() => { vscode.postMessage({ type: 'getWorkspaceFiles', - data: { query: normalizedQuery }, + data: { query: normalizedQuery, requestId }, }); }, 300); - lastQueryRef.current = normalizedQuery; + lastQueryRef.current = normalizedQueryKey; } else { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = null; + } + // For empty query, request once initially and whenever we are returning from a search const shouldRequestFullList = !hasRequestedFilesRef.current || lastQueryRef.current !== undefined; if (shouldRequestFullList) { + const requestId = workspaceFilesRequestIdRef.current + 1; + workspaceFilesRequestIdRef.current = requestId; + latestWorkspaceFilesRequestIdRef.current = requestId; lastQueryRef.current = undefined; hasRequestedFilesRef.current = true; vscode.postMessage({ type: 'getWorkspaceFiles', - data: {}, + data: { requestId }, }); } } @@ -78,6 +98,30 @@ export const useFileContext = (vscode: VSCodeAPI) => { [vscode], ); + /** + * Apply workspace file responses only if they are current. + */ + const setWorkspaceFilesFromResponse = useCallback( + ( + files: Array<{ + id: string; + label: string; + description: string; + path: string; + }>, + requestId?: number, + ) => { + if ( + typeof requestId === 'number' && + latestWorkspaceFilesRequestIdRef.current !== requestId + ) { + return; + } + setWorkspaceFiles(files); + }, + [], + ); + /** * Add file reference */ @@ -130,6 +174,7 @@ export const useFileContext = (vscode: VSCodeAPI) => { setActiveFilePath, setActiveSelection, setWorkspaceFiles, + setWorkspaceFilesFromResponse, // File reference operations addFileReference, diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index b18843ef5..f3a660366 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -57,6 +57,8 @@ export function useCompletionTrigger( // Timer for loading timeout const timeoutRef = useRef | null>(null); + // Track request order so slower responses can't overwrite newer completions. + const requestIdRef = useRef(0); const closeCompletion = useCallback(() => { // Clear pending timeout @@ -64,6 +66,7 @@ export function useCompletionTrigger( clearTimeout(timeoutRef.current); timeoutRef.current = null; } + requestIdRef.current += 1; setState({ isOpen: false, triggerChar: null, @@ -79,6 +82,8 @@ export function useCompletionTrigger( query: string, position: { top: number; left: number }, ) => { + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; // Clear previous timeout if any if (timeoutRef.current) { clearTimeout(timeoutRef.current); @@ -96,6 +101,9 @@ export function useCompletionTrigger( // Schedule a timeout fallback if loading takes too long timeoutRef.current = setTimeout(() => { + if (requestIdRef.current !== requestId) { + return; + } setState((prev) => { // Only show timeout if still open and still for the same request if ( @@ -112,6 +120,9 @@ export function useCompletionTrigger( }, TIMEOUT_MS); const items = await getCompletionItems(trigger, query); + if (requestIdRef.current !== requestId) { + return; + } // Clear timeout on success if (timeoutRef.current) { @@ -171,7 +182,12 @@ export function useCompletionTrigger( if (!state.isOpen || !state.triggerChar) { return; } + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; const items = await getCompletionItems(state.triggerChar, state.query); + if (requestIdRef.current !== requestId) { + return; + } // Only update state if items have actually changed setState((prev) => { diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 43375f5a6..7a66e393f 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -54,13 +54,14 @@ interface UseWebViewMessagesProps { setActiveSelection: ( selection: { startLine: number; endLine: number } | null, ) => void; - setWorkspaceFiles: ( + setWorkspaceFilesFromResponse: ( files: Array<{ id: string; label: string; description: string; path: string; }>, + requestId?: number, ) => void; addFileReference: (name: string, path: string) => void; }; @@ -923,9 +924,13 @@ export const useWebViewMessages = ({ description: string; path: string; }>; + const requestId = message.data?.requestId as number | undefined; if (files) { console.log('[WebView] Received workspaceFiles:', files.length); - handlers.fileContext.setWorkspaceFiles(files); + handlers.fileContext.setWorkspaceFilesFromResponse( + files, + requestId, + ); } break; } diff --git a/packages/webui/package.json b/packages/webui/package.json index 7c9e84fc8..4b6221a1a 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -56,7 +56,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", "storybook": "^10.1.11", "@storybook/react-vite": "^10.1.11", "@chromatic-com/storybook": "^5.0.0", diff --git a/scripts/build.js b/scripts/build.js index 8a525e98e..9ea075e68 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -30,9 +30,31 @@ if (!existsSync(join(root, 'node_modules'))) { execSync('npm install', { stdio: 'inherit', cwd: root }); } -// build all workspaces/packages +// build all workspaces/packages in dependency order execSync('npm run generate', { stdio: 'inherit', cwd: root }); -execSync('npm run build --workspaces', { stdio: 'inherit', cwd: root }); + +// Build in dependency order: +// 1. test-utils (no internal dependencies) +// 2. core (foundation package) +// 3. cli (depends on core, test-utils) +// 4. webui (shared UI components - used by vscode companion) +// 5. sdk (no internal dependencies) +// 6. vscode-ide-companion (depends on webui) +const buildOrder = [ + 'packages/test-utils', + 'packages/core', + 'packages/cli', + 'packages/webui', + 'packages/sdk-typescript', + 'packages/vscode-ide-companion', +]; + +for (const workspace of buildOrder) { + execSync(`npm run build --workspace=${workspace}`, { + stdio: 'inherit', + cwd: root, + }); +} // also build container image if sandboxing is enabled // skip (-s) npm install + build since we did that above diff --git a/scripts/dev.js b/scripts/dev.js new file mode 100644 index 000000000..e0adcaea0 --- /dev/null +++ b/scripts/dev.js @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Development entry point for Qwen Code CLI. + * + * Runs the CLI directly from TypeScript source files without requiring a build step. + * Changes to packages/core or packages/cli are reflected immediately. + * + * Usage: npm run dev -- [args] + * Example: npm run dev -- help + */ + +import { spawn } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const cliPackageDir = join(root, 'packages', 'cli'); + +// Resolve tsx from node_modules +const tsxPath = resolve(root, 'node_modules', '.bin', 'tsx'); + +// Entry point for the CLI +const cliEntry = join(cliPackageDir, 'index.ts'); + +// Create a temporary loader file +const tmpDir = mkdtempSync(join(tmpdir(), 'qwen-dev-')); +const loaderPath = join(tmpDir, 'loader.mjs'); + +const coreSourcePath = join(root, 'packages', 'core', 'index.ts'); +const coreSourceUrl = pathToFileURL(coreSourcePath).href; + +const loaderCode = ` +import { pathToFileURL } from 'node:url'; + +const coreSourceUrl = '${coreSourceUrl}'; + +export function resolve(specifier, context, nextResolve) { + if (specifier === '@qwen-code/qwen-code-core') { + return { + shortCircuit: true, + url: coreSourceUrl, + format: 'module', + }; + } + return nextResolve(specifier, context); +} +`; + +writeFileSync(loaderPath, loaderCode); + +// Create the register script that uses the new register() API +const registerPath = join(tmpDir, 'register.mjs'); +const loaderUrl = pathToFileURL(loaderPath).href; +const registerCode = ` +import { register } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +register('${loaderUrl}', pathToFileURL('./')); +`; +writeFileSync(registerPath, registerCode); + +const env = { + ...process.env, + DEV: 'true', + CLI_VERSION: 'dev', + NODE_ENV: 'development', + // Use --import with register() instead of deprecated --loader + NODE_OPTIONS: `--import ${pathToFileURL(registerPath).href}`, +}; + +const nodeArgs = [tsxPath, cliEntry, ...process.argv.slice(2)]; + +const child = spawn('node', nodeArgs, { + stdio: 'inherit', + env, + cwd: process.cwd(), +}); + +child.on('error', (err) => { + console.error('Failed to start dev server:', err.message); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + process.exit(1); +}); + +child.on('close', (code) => { + // Cleanup temp directory + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + process.exit(code ?? 0); +});