From 395791feeb8f354504767b839ef01f2a5cd6f322 Mon Sep 17 00:00:00 2001
From: liqoingyu
Date: Sun, 18 Jan 2026 21:21:44 +0800
Subject: [PATCH 01/41] test(cli): stabilize AuthDialog ESC assertion
---
packages/cli/src/ui/auth/AuthDialog.test.tsx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx
index 610cb1152..b33caf649 100644
--- a/packages/cli/src/ui/auth/AuthDialog.test.tsx
+++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx
@@ -438,9 +438,10 @@ 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(() => {
+ expect(lastFrame()).toContain('You must select an auth method');
+ });
+ expect(lastFrame()).toContain('Press Ctrl+C again to exit');
expect(handleAuthSelect).not.toHaveBeenCalled();
unmount();
});
From 1527c0333f49bfc2227b38a7147ec31c293eafc2 Mon Sep 17 00:00:00 2001
From: liqoingyu
Date: Mon, 19 Jan 2026 11:52:49 +0800
Subject: [PATCH 02/41] test(cli): wait for full auth error copy
---
packages/cli/src/ui/auth/AuthDialog.test.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx
index b33caf649..83208614f 100644
--- a/packages/cli/src/ui/auth/AuthDialog.test.tsx
+++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx
@@ -439,9 +439,10 @@ describe('AuthDialog', () => {
// Should show error message instead of calling handleAuthSelect
await vi.waitFor(() => {
- expect(lastFrame()).toContain('You must select an auth method');
+ const frame = lastFrame();
+ expect(frame).toContain('You must select an auth method');
+ expect(frame).toContain('Press Ctrl+C again to exit');
});
- expect(lastFrame()).toContain('Press Ctrl+C again to exit');
expect(handleAuthSelect).not.toHaveBeenCalled();
unmount();
});
From 9bee51dc176879ee8e709c692e942050b618c825 Mon Sep 17 00:00:00 2001
From: LaZzyMan
Date: Mon, 26 Jan 2026 14:16:13 +0800
Subject: [PATCH 03/41] fix: enable Shift+Tab shortcut in Windows PowerShell
- Parse CSI sequences regardless of Kitty protocol status
- Fix approval mode cycle shortcut not working on Windows
- Add test for non-Kitty protocol mode
- Update debug log messages for clarity
---
.../src/ui/contexts/KeypressContext.test.tsx | 33 +++-
.../cli/src/ui/contexts/KeypressContext.tsx | 163 +++++++++---------
2 files changed, 110 insertions(+), 86 deletions(-)
diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
index 1130f8352..8bef05596 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
@@ -1256,13 +1256,13 @@ describe('KeypressContext - Kitty Protocol', () => {
});
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] Kitty buffer accumulating:',
+ '[DEBUG] CSI buffer accumulating:',
expect.stringContaining('\x1b[27u'),
);
const parsedCall = consoleLogSpy.mock.calls.find(
(args) =>
typeof args[0] === 'string' &&
- args[0].includes('[DEBUG] Kitty sequence parsed successfully'),
+ args[0].includes('[DEBUG] CSI sequence parsed successfully'),
);
expect(parsedCall).toBeTruthy();
expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u'));
@@ -1293,7 +1293,7 @@ describe('KeypressContext - Kitty Protocol', () => {
});
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] Kitty buffer overflow, clearing:',
+ '[DEBUG] CSI buffer overflow, clearing:',
expect.any(String),
);
});
@@ -1384,13 +1384,13 @@ describe('KeypressContext - Kitty Protocol', () => {
// Verify debug logging for accumulation
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] Kitty buffer accumulating:',
+ '[DEBUG] CSI buffer accumulating:',
sequence,
);
// Verify warning for char codes
expect(consoleWarnSpy).toHaveBeenCalledWith(
- 'Kitty sequence buffer has char codes:',
+ 'CSI sequence buffer has char codes:',
[27, 91, 49, 50],
);
});
@@ -1468,6 +1468,29 @@ describe('KeypressContext - Kitty Protocol', () => {
);
},
);
+
+ it('should recognize Shift+Tab in non-Kitty protocol mode (Windows PowerShell)', () => {
+ const keyHandler = vi.fn();
+
+ // Create a wrapper with Kitty protocol disabled to simulate Windows PowerShell
+ const nonKittyWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useKeypressContext(), {
+ wrapper: nonKittyWrapper,
+ });
+ act(() => result.current.subscribe(keyHandler));
+
+ // Send legacy reverse Tab sequence (ESC [ Z)
+ act(() => stdin.sendKittySequence(`\x1b[Z`));
+
+ expect(keyHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'tab', shift: true }),
+ );
+ });
});
describe('Double-tap and batching', () => {
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index 0f01712cc..acf3b30d6 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -508,95 +508,96 @@ export function KeypressProvider({
return;
}
- if (kittyProtocolEnabled) {
- if (
- kittySequenceBuffer ||
- (key.sequence.startsWith(`${ESC}[`) &&
- !key.sequence.startsWith(PASTE_MODE_PREFIX) &&
- !key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
- !key.sequence.startsWith(FOCUS_IN) &&
- !key.sequence.startsWith(FOCUS_OUT))
- ) {
- kittySequenceBuffer += key.sequence;
+ // Parse CSI sequences for both Kitty protocol and legacy terminals
+ // This ensures Shift+Tab and other special keys work correctly even when
+ // Kitty protocol is not available (e.g., Windows PowerShell)
+ if (
+ kittySequenceBuffer ||
+ (key.sequence.startsWith(`${ESC}[`) &&
+ !key.sequence.startsWith(PASTE_MODE_PREFIX) &&
+ !key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
+ !key.sequence.startsWith(FOCUS_IN) &&
+ !key.sequence.startsWith(FOCUS_OUT))
+ ) {
+ kittySequenceBuffer += key.sequence;
+ if (debugKeystrokeLogging) {
+ console.log('[DEBUG] CSI buffer accumulating:', kittySequenceBuffer);
+ }
+
+ // Try to peel off as many complete sequences as are available at the
+ // start of the buffer. This handles batched inputs cleanly. If the
+ // prefix is incomplete or invalid, skip to the next CSI introducer
+ // (ESC[) so that a following valid sequence can still be parsed.
+ let parsedAny = false;
+ while (kittySequenceBuffer) {
+ const parsed = parseKittyPrefix(kittySequenceBuffer);
+ if (!parsed) {
+ // Look for the next potential CSI start beyond index 0
+ const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
+ if (nextStart > 0) {
+ if (debugKeystrokeLogging) {
+ console.log(
+ '[DEBUG] Skipping incomplete/invalid CSI prefix:',
+ kittySequenceBuffer.slice(0, nextStart),
+ );
+ }
+ kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
+ continue;
+ }
+ break;
+ }
+ if (debugKeystrokeLogging) {
+ const parsedSequence = kittySequenceBuffer.slice(0, parsed.length);
+ if (kittySequenceBuffer.length > parsed.length) {
+ console.log(
+ '[DEBUG] CSI sequence parsed successfully (prefix):',
+ parsedSequence,
+ );
+ } else {
+ console.log(
+ '[DEBUG] CSI sequence parsed successfully:',
+ parsedSequence,
+ );
+ }
+ }
+ // Consume the parsed prefix and broadcast it.
+ kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
+ broadcast(parsed.key);
+ parsedAny = true;
+ }
+ if (parsedAny) return;
+
+ if (config?.getDebugMode() || debugKeystrokeLogging) {
+ const codes = Array.from(kittySequenceBuffer).map((ch) =>
+ ch.charCodeAt(0),
+ );
+ console.warn('CSI sequence buffer has char codes:', codes);
+ }
+
+ if (
+ kittyProtocolEnabled &&
+ kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH
+ ) {
if (debugKeystrokeLogging) {
console.log(
- '[DEBUG] Kitty buffer accumulating:',
+ '[DEBUG] CSI buffer overflow, clearing:',
kittySequenceBuffer,
);
}
-
- // Try to peel off as many complete sequences as are available at the
- // start of the buffer. This handles batched inputs cleanly. If the
- // prefix is incomplete or invalid, skip to the next CSI introducer
- // (ESC[) so that a following valid sequence can still be parsed.
- let parsedAny = false;
- while (kittySequenceBuffer) {
- const parsed = parseKittyPrefix(kittySequenceBuffer);
- if (!parsed) {
- // Look for the next potential CSI start beyond index 0
- const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
- if (nextStart > 0) {
- if (debugKeystrokeLogging) {
- console.log(
- '[DEBUG] Skipping incomplete/invalid CSI prefix:',
- kittySequenceBuffer.slice(0, nextStart),
- );
- }
- kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
- continue;
- }
- break;
- }
- if (debugKeystrokeLogging) {
- const parsedSequence = kittySequenceBuffer.slice(
- 0,
- parsed.length,
- );
- if (kittySequenceBuffer.length > parsed.length) {
- console.log(
- '[DEBUG] Kitty sequence parsed successfully (prefix):',
- parsedSequence,
- );
- } else {
- console.log(
- '[DEBUG] Kitty sequence parsed successfully:',
- parsedSequence,
- );
- }
- }
- // Consume the parsed prefix and broadcast it.
- kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
- broadcast(parsed.key);
- parsedAny = true;
- }
- if (parsedAny) return;
-
- if (config?.getDebugMode() || debugKeystrokeLogging) {
- const codes = Array.from(kittySequenceBuffer).map((ch) =>
- ch.charCodeAt(0),
+ if (config) {
+ const event = new KittySequenceOverflowEvent(
+ kittySequenceBuffer.length,
+ kittySequenceBuffer,
);
- console.warn('Kitty sequence buffer has char codes:', codes);
- }
-
- if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
- if (debugKeystrokeLogging) {
- console.log(
- '[DEBUG] Kitty buffer overflow, clearing:',
- kittySequenceBuffer,
- );
- }
- if (config) {
- const event = new KittySequenceOverflowEvent(
- kittySequenceBuffer.length,
- kittySequenceBuffer,
- );
- logKittySequenceOverflow(config, event);
- }
- kittySequenceBuffer = '';
- } else {
- return;
+ logKittySequenceOverflow(config, event);
}
+ kittySequenceBuffer = '';
+ } else if (!kittyProtocolEnabled) {
+ // For non-Kitty terminals, clear the buffer to avoid accumulation
+ kittySequenceBuffer = '';
+ } else {
+ return;
}
}
From 4534f5ec1d4915f1b6c44a8327f4eff12a523cd4 Mon Sep 17 00:00:00 2001
From: LaZzyMan
Date: Mon, 26 Jan 2026 17:05:42 +0800
Subject: [PATCH 04/41] fix undefined error
---
packages/cli/src/ui/contexts/KeypressContext.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index acf3b30d6..8df81a9d8 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -513,7 +513,8 @@ export function KeypressProvider({
// Kitty protocol is not available (e.g., Windows PowerShell)
if (
kittySequenceBuffer ||
- (key.sequence.startsWith(`${ESC}[`) &&
+ (key.sequence &&
+ key.sequence.startsWith(`${ESC}[`) &&
!key.sequence.startsWith(PASTE_MODE_PREFIX) &&
!key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
!key.sequence.startsWith(FOCUS_IN) &&
From 6ca725769dcebc95ea39ccb25125024f80f00c2a Mon Sep 17 00:00:00 2001
From: Weaxs <459312872@qq.com>
Date: Mon, 26 Jan 2026 17:36:58 +0800
Subject: [PATCH 05/41] optimize: ADD cache_control for system and last user
text message
---
.../anthropicContentGenerator/converter.ts | 65 ++++++++++++++++++-
1 file changed, 62 insertions(+), 3 deletions(-)
diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts
index 2fb9b7fee..92ad82648 100644
--- a/packages/core/src/core/anthropicContentGenerator/converter.ts
+++ b/packages/core/src/core/anthropicContentGenerator/converter.ts
@@ -49,19 +49,23 @@ export class AnthropicContentConverter {
}
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
+ const system = this.buildSystemWithCacheControl(systemText);
+ this.addCacheControlToMessages(messages);
+
return {
- system: system || undefined,
+ system,
messages,
};
}
@@ -445,4 +449,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;
+ }
+ }
+ }
}
From 52ea0d55a844e0f1deaeb7a32eae1d264ed59b8f Mon Sep 17 00:00:00 2001
From: Weaxs <459312872@qq.com>
Date: Mon, 26 Jan 2026 18:22:17 +0800
Subject: [PATCH 06/41] fix: anthropic cache_control
---
.../converter.test.ts | 33 ++++++++++++++++---
1 file changed, 29 insertions(+), 4 deletions(-)
diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts
index f2ab79411..0e2b95198 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' },
+ },
],
},
]);
From f964c2a26b9f26ff2b0f45e61cda225613a67f84 Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Fri, 30 Jan 2026 11:05:35 +0800
Subject: [PATCH 07/41] fix(ci): honor manual preview version input
When create_preview_release is enabled, allow the workflow_dispatch version input
to control the preview version (and avoid appending a timestamp if a preview id
is already provided) to match user intent.
---
.../workflows/release-vscode-companion.yml | 24 +++++++++++++++----
1 file changed, 19 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/release-vscode-companion.yml b/.github/workflows/release-vscode-companion.yml
index 2e0c4b60e..57ee0aefb 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"
From 2779faf0103f6dc8e594e4f827f79fbe2eb701f2 Mon Sep 17 00:00:00 2001
From: yiliang114 <1204183885@qq.com>
Date: Fri, 30 Jan 2026 11:32:51 +0800
Subject: [PATCH 08/41] chore(ci): add webui dependency build step in release
workflow
Co-authored-by: Qwen-Coder
---
.github/workflows/release-vscode-companion.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/release-vscode-companion.yml b/.github/workflows/release-vscode-companion.yml
index 57ee0aefb..8b8d1ea24 100644
--- a/.github/workflows/release-vscode-companion.yml
+++ b/.github/workflows/release-vscode-companion.yml
@@ -133,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' }}
From 47666f79c1e7c23434817bc4d04706ef55fc90c3 Mon Sep 17 00:00:00 2001
From: LaZzyMan
Date: Fri, 30 Jan 2026 11:41:47 +0800
Subject: [PATCH 09/41] fix: normalize skill file content in extensions to
handle BOM and CRLF
- Add normalizeSkillFileContent function to skill-load.ts
- Update regex to allow frontmatter ending without trailing newline
- Add comprehensive tests for CRLF, BOM, and edge cases
Fixes #1666
---
packages/core/src/skills/skill-load.test.ts | 303 ++++++++++++++++++++
packages/core/src/skills/skill-load.ts | 23 +-
2 files changed, 324 insertions(+), 2 deletions(-)
create mode 100644 packages/core/src/skills/skill-load.test.ts
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');
From 07b186fcbfe6d7b029aa7e36cc6d4d694bacf66f Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Sat, 31 Jan 2026 22:41:54 +0800
Subject: [PATCH 10/41] build: Improve build efficiency and add dev mode
- Remove duplicate webui build in vscode-ide-companion (fixes double build)
- Fix misleading [watch] log messages in esbuild.js (only show in watch mode)
- Update vite-plugin-dts to ^4.5.4 for TypeScript 5.8+ support
- Update baseline-browser-mapping to ^2.9.19 to silence outdated data warnings
- Fix vitest config to use @qwen-code/qwen-code-core instead of old gemini-cli-core
- Add resolve.alias in cli vitest.config.ts for source-based testing
- Add npm run dev script for running from TypeScript source without build
Co-authored-by: Qwen-Coder
---
package-lock.json | 988 ++++++++++-----------
package.json | 4 +-
packages/cli/vitest.config.ts | 11 +-
packages/vscode-ide-companion/esbuild.js | 9 +-
packages/vscode-ide-companion/package.json | 4 +-
packages/webui/package.json | 2 +-
scripts/dev.js | 95 ++
7 files changed, 611 insertions(+), 502 deletions(-)
create mode 100644 scripts/dev.js
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/vitest.config.ts b/packages/cli/vitest.config.ts
index fcffa292f..afd78a720 100644
--- a/packages/cli/vitest.config.ts
+++ b/packages/cli/vitest.config.ts
@@ -6,8 +6,17 @@
///
import { defineConfig } from 'vitest/config';
+import path from 'node:path';
export default defineConfig({
+ resolve: {
+ alias: {
+ '@qwen-code/qwen-code-core': path.resolve(
+ __dirname,
+ '../core/src/index.ts',
+ ),
+ },
+ },
test: {
include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'config.test.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**'],
@@ -41,7 +50,7 @@ export default defineConfig({
},
server: {
deps: {
- inline: [/@google\/gemini-cli-core/],
+ inline: [/@qwen-code\/qwen-code-core/],
},
},
},
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/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/dev.js b/scripts/dev.js
new file mode 100644
index 000000000..811a0e34e
--- /dev/null
+++ b/scripts/dev.js
@@ -0,0 +1,95 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * 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('close', (code) => {
+ // Cleanup temp directory
+ try {
+ rmSync(tmpDir, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ process.exit(code ?? 0);
+});
From aa02bcc4e1f5adf183dd5863a8f9bae3a1f12175 Mon Sep 17 00:00:00 2001
From: yiliang114 <1204183885@qq.com>
Date: Sat, 31 Jan 2026 23:45:12 +0800
Subject: [PATCH 11/41] fix(vscode-ide-companion): fix race conditions and
improve @ file completion search
- Add requestId mechanism to prevent stale async responses from overwriting newer results
- Implement case-insensitive file search with buildCaseInsensitiveGlob method
- Filter gitignored files using FileDiscoveryService integration
- Allow completion list refresh during search by removing query check condition
- Add --experimental-skills CLI argument for qwen connection
- Add unit tests for FileMessageHandler
Co-Authored-By: Claude
---
.../src/services/qwenConnectionHandler.ts | 2 +-
.../vscode-ide-companion/src/webview/App.tsx | 9 +-
.../handlers/FileMessageHandler.test.ts | 118 ++++++++++++++++++
.../webview/handlers/FileMessageHandler.ts | 82 ++++++++++--
.../src/webview/hooks/file/useFileContext.ts | 51 +++++++-
.../src/webview/hooks/useCompletionTrigger.ts | 16 +++
.../src/webview/hooks/useWebViewMessages.ts | 9 +-
7 files changed, 266 insertions(+), 21 deletions(-)
create mode 100644 packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts
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;
}
From 831d74dbfec292674a4d25db7825e525d91d0882 Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Sun, 1 Feb 2026 10:32:20 +0800
Subject: [PATCH 12/41] feat: Preserve UTF-8 BOM when editing files (Fix #1672)
- Add FileEncoding constants (UTF8, UTF8_BOM)
- Add detectFileBOM() to detect existing file encoding
- Modify writeTextFile() to support BOM option
- Add defaultFileEncoding configuration option
- Preserve BOM when editing existing files
- Use configured encoding for new files
- Add comprehensive tests (unit, integration, e2e)
- Update documentation
Co-authored-by: Qwen-Coder
---
docs/users/configuration/settings.md | 17 +--
integration-tests/utf-bom-encoding.test.ts | 96 ++++++++++++-
.../service/filesystem.test.ts | 1 +
.../src/acp-integration/service/filesystem.ts | 19 ++-
packages/cli/src/config/config.ts | 3 +
packages/cli/src/config/settingsSchema.ts | 14 ++
packages/core/src/config/config.ts | 13 ++
.../src/services/fileSystemService.test.ts | 93 +++++++++++++
.../core/src/services/fileSystemService.ts | 92 ++++++++++++-
packages/core/src/tools/write-file.test.ts | 126 ++++++++++++++++++
packages/core/src/tools/write-file.ts | 14 +-
11 files changed, 472 insertions(+), 16 deletions(-)
diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md
index 2ce94c38b..a7d40ee54 100644
--- a/docs/users/configuration/settings.md
+++ b/docs/users/configuration/settings.md
@@ -51,14 +51,15 @@ Settings are organized into categories. All settings should be placed within the
#### general
-| Setting | Type | Description | Default |
-| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- |
-| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
-| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
-| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` |
-| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` |
-| `general.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.disableAutoUpdate` | boolean | Disable automatic updates. | `false` |
+| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` |
+| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` |
+| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` |
+| `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
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/packages/cli/src/acp-integration/service/filesystem.test.ts b/packages/cli/src/acp-integration/service/filesystem.test.ts
index bc1f56e81..a2ae0939e 100644
--- a/packages/cli/src/acp-integration/service/filesystem.test.ts
+++ b/packages/cli/src/acp-integration/service/filesystem.test.ts
@@ -12,6 +12,7 @@ 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([]),
});
diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts
index 18aef1bec..fc86ee5bb 100644
--- a/packages/cli/src/acp-integration/service/filesystem.ts
+++ b/packages/cli/src/acp-integration/service/filesystem.ts
@@ -54,17 +54,30 @@ 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 {
+ // Always use fallback for BOM 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 44340b81e..943045608 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -244,6 +244,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/core/src/config/config.ts b/packages/core/src/config/config.ts
index af2d28555..7169e0be9 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;
@@ -1432,6 +1437,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/services/fileSystemService.test.ts b/packages/core/src/services/fileSystemService.test.ts
index 4ca5c3329..64219c2dc 100644
--- a/packages/core/src/services/fileSystemService.test.ts
+++ b/packages/core/src/services/fileSystemService.test.ts
@@ -55,5 +55,98 @@ 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',
+ );
+ });
+ });
+
+ 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..97a4d30b1 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,6 +64,34 @@ 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
*/
@@ -46,8 +100,40 @@ export class StandardFileSystemService implements FileSystemService {
return fs.readFile(filePath, 'utf-8');
}
- 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)
+ const bomBuffer = Buffer.from([0xef, 0xbb, 0xbf]);
+ const contentBuffer = Buffer.from(content, 'utf-8');
+ await fs.writeFile(filePath, Buffer.concat([bomBuffer, contentBuffer]));
+ } else {
+ await fs.writeFile(filePath, content, 'utf-8');
+ }
+ }
+
+ async detectFileBOM(filePath: string): Promise {
+ try {
+ // Read only the first 3 bytes to check for BOM
+ const fd = await fs.open(filePath, 'r');
+ const buffer = Buffer.alloc(3);
+ const { bytesRead } = await fd.read(buffer, 0, 3, 0);
+ await fd.close();
+
+ if (bytesRead < 3) {
+ return false;
+ }
+
+ return hasUTF8BOM(buffer);
+ } catch {
+ // File doesn't exist or can't be read - treat as no BOM
+ return false;
+ }
}
findFiles(fileName: string, searchPaths: readonly string[]): string[] {
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);
From 2d525d9fd03f87c563c861a5ccbb454cd1e2880a Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Sun, 1 Feb 2026 11:23:02 +0800
Subject: [PATCH 13/41] fix: Address review comments for BOM encoding support
- Edit tool now respects defaultFileEncoding for new files
- Edit tool preserves BOM character for existing files without re-adding
- AcpFileSystemService detects BOM through ACP client with fallback
- Use line: null, limit: 1 for efficient BOM detection
- Add unit tests for AcpFileSystemService.detectFileBOM
- Add unit tests for EditTool BOM handling
Co-authored-by: Qwen-Coder
---
.../service/filesystem.test.ts | 87 +++++++++++++++++++
.../src/acp-integration/service/filesystem.ts | 17 +++-
packages/core/src/tools/edit.test.ts | 75 ++++++++++++++++
packages/core/src/tools/edit.ts | 20 ++++-
4 files changed, 195 insertions(+), 4 deletions(-)
diff --git a/packages/cli/src/acp-integration/service/filesystem.test.ts b/packages/cli/src/acp-integration/service/filesystem.test.ts
index a2ae0939e..6eb3dfa1b 100644
--- a/packages/cli/src/acp-integration/service/filesystem.test.ts
+++ b/packages/cli/src/acp-integration/service/filesystem.test.ts
@@ -17,6 +17,93 @@ const createFallback = (): FileSystemService => ({
});
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 fc86ee5bb..17a0cdbcf 100644
--- a/packages/cli/src/acp-integration/service/filesystem.ts
+++ b/packages/cli/src/acp-integration/service/filesystem.ts
@@ -74,7 +74,22 @@ export class AcpFileSystemService implements FileSystemService {
}
async detectFileBOM(filePath: string): Promise {
- // Always use fallback for BOM detection
+ // 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);
}
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 =
From b1fa12f323210440d288b06451b9924e517b463c Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Sun, 1 Feb 2026 11:59:05 +0800
Subject: [PATCH 14/41] refactor(core): Unify package exports and improve dev
experience
- Update license header to include Qwen copyright
- Add error handler for spawn in dev.js
- Refactor core/src/index.ts to export all public APIs
- Simplify core/index.ts to be a clean re-export
- Fix vitest alias to point to package entry
Co-authored-by: Qwen-Coder
---
packages/cli/vitest.config.ts | 5 +-
packages/core/index.ts | 40 -----
packages/core/src/index.ts | 300 ++++++++++++++++++++--------------
scripts/dev.js | 12 +-
4 files changed, 190 insertions(+), 167 deletions(-)
diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts
index afd78a720..4c022d4be 100644
--- a/packages/cli/vitest.config.ts
+++ b/packages/cli/vitest.config.ts
@@ -11,10 +11,7 @@ import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
- '@qwen-code/qwen-code-core': path.resolve(
- __dirname,
- '../core/src/index.ts',
- ),
+ '@qwen-code/qwen-code-core': path.resolve(__dirname, '../core/index.ts'),
},
},
test: {
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/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/scripts/dev.js b/scripts/dev.js
index 811a0e34e..e0adcaea0 100644
--- a/scripts/dev.js
+++ b/scripts/dev.js
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
@@ -84,6 +84,16 @@ const child = spawn('node', nodeArgs, {
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 {
From 9304bc4b4d4fa50739a3cb604d785dae6533c85f Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Sun, 1 Feb 2026 12:11:12 +0800
Subject: [PATCH 15/41] fix(build): Fix workspace build order to respect
dependencies
VSCode IDE Companion depends on @qwen-code/webui, but npm workspaces
build packages in alphabetical order, causing webui to be built after
its dependent.
Fixed by explicitly defining the build order:
1. test-utils
2. core
3. cli
4. webui
5. sdk
6. vscode-ide-companion
Co-authored-by: Qwen-Coder
---
scripts/build.js | 26 ++++++++++++++++++++++++--
1 file changed, 24 insertions(+), 2 deletions(-)
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
From 96e49497b8feb673c4d16dae87b6415653ad19f8 Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Sun, 1 Feb 2026 14:17:08 +0800
Subject: [PATCH 16/41] fix(core): Fix ripgrep vendor path resolution for
source files
When vitest loads @qwen-code/qwen-code-core from source files (via alias),
ripgrepUtils.ts failed to find the bundled ripgrep binary because it only
handled bundle and transpiled code paths.
Added detection for source file loading (.ts files in src/utils/) to correctly
resolve the vendor path (2 levels up instead of 3).
Also refactored the path resolution logic to be more concise using levelsUp
calculation.
Co-authored-by: Qwen-Coder
---
packages/core/src/utils/ripgrepUtils.ts | 43 ++++++++-----------------
1 file changed, 14 insertions(+), 29 deletions(-)
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,
+ );
}
/**
From bfdc361b6209119d2d49b49c9284d55735da0395 Mon Sep 17 00:00:00 2001
From: Alexander Farber
Date: Sun, 1 Feb 2026 23:19:54 +0100
Subject: [PATCH 17/41] Auto-enable WebFetch and WebSearch tools in Plan mode
---
packages/core/src/tools/web-fetch.ts | 6 +++++-
packages/core/src/tools/web-search/index.ts | 6 +++++-
2 files changed, 10 insertions(+), 2 deletions(-)
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;
}
From 88af3a4723a79564c5f405f01ba7d435d5024f20 Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Mon, 2 Feb 2026 11:39:42 +0800
Subject: [PATCH 18/41] fix(core): Preserve trailing whitespace in newString
during edits
Remove unconditional trimming of trailing whitespace from newString in
normalizeEditStrings(). This fixes cases where intentional trailing
whitespace (e.g., in multi-line strings, heredocs) was being stripped.
The oldString fuzzy matching still works correctly, and newString is
now preserved exactly as the LLM intended.
Fixes #1618
Co-authored-by: Qwen-Coder
---
packages/core/src/utils/editHelper.test.ts | 109 ++++++++++++++++-----
packages/core/src/utils/editHelper.ts | 43 +++-----
2 files changed, 94 insertions(+), 58 deletions(-)
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,
};
}
From f022252264e6abb4d69fd539b127f949f7773f20 Mon Sep 17 00:00:00 2001
From: DragonnZhang <731557579@qq.com>
Date: Mon, 2 Feb 2026 14:30:12 +0800
Subject: [PATCH 19/41] feat(core): add symlink support for skill manager
Add support for loading skills from symlinked directories in the skill manager. This allows users to organize and share skills more flexibly by using symbolic links.
Changes:
- Modified skill discovery logic to detect and follow symlinks
- Added validation to ensure symlink targets point to valid directories
- Skip broken or invalid symlinks with appropriate warnings
- Added comprehensive test coverage for symlink scenarios
---
.../core/src/skills/skill-manager.test.ts | 189 +++++++++++++++++-
packages/core/src/skills/skill-manager.ts | 26 ++-
2 files changed, 205 insertions(+), 10 deletions(-)
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 {
From 781bd7880b1113cde918b4b0c2867f5c2cb1eb9f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=AE=87=E6=BA=AF?=
Date: Sun, 1 Feb 2026 23:49:56 -0800
Subject: [PATCH 20/41] rebase and resolve conflict
---
packages/core/src/config/config.ts | 29 ++-
.../core/src/tools/mcp-client-manager.test.ts | 181 +++++++++++++++++-
packages/core/src/tools/mcp-client-manager.ts | 67 +++++++
packages/core/src/tools/tool-registry.ts | 31 +--
4 files changed, 284 insertions(+), 24 deletions(-)
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index af2d28555..56a5d8ff6 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -814,13 +814,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 +1020,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 {
+ this.skillManager?.stopWatching();
+
+ if (!this.initialized) {
+ // Nothing to clean up if not initialized
+ return;
+ }
+ try {
+ 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;
}
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..d72c76ca5 100644
--- a/packages/core/src/tools/mcp-client-manager.ts
+++ b/packages/core/src/tools/mcp-client-manager.ts
@@ -100,6 +100,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.
diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts
index 4db7bd789..540851f50 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';
@@ -279,19 +278,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 +469,17 @@ export class ToolRegistry {
getTool(name: string): AnyDeclarativeTool | undefined {
return this.tools.get(name);
}
+
+ /**
+ * 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);
+ }
+ }
}
From 635f0fad895b98c33927f3af32af97d28e48b372 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=AE=87=E6=BA=AF?=
Date: Sun, 1 Feb 2026 19:50:58 -0800
Subject: [PATCH 21/41] Revert "reslove comment"
This reverts commit d7b003076e8a5f6b387b7313ecab0cd7ebea0997.
---
packages/cli/src/gemini.tsx | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 16fea6311..786b3f8ac 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -339,16 +339,18 @@ 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
- // if (config.getListExtensions()) {
- // console.log('Installed extensions:');
- // for (const extension of extensions) {
- // console.log(`- ${extension.config.name}`);
- // }
- // process.exit(0);
- // }
+ if (config.getListExtensions()) {
+ console.log('Installed extensions:');
+ for (const extension of extensions) {
+ console.log(`- ${extension.config.name}`);
+ }
+ process.exit(0);
+ }
// Setup unified ConsolePatcher based on interactive mode
const isInteractive = config.isInteractive();
From 91ec945e818b2ecb3c51cb91c5fb22f4ae3b4454 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=AE=87=E6=BA=AF?=
Date: Sun, 1 Feb 2026 22:27:13 -0800
Subject: [PATCH 22/41] resolve comment
---
packages/cli/src/gemini.tsx | 18 ++++++++----------
1 file changed, 8 insertions(+), 10 deletions(-)
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 786b3f8ac..16fea6311 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -339,18 +339,16 @@ 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());
- if (config.getListExtensions()) {
- console.log('Installed extensions:');
- for (const extension of extensions) {
- console.log(`- ${extension.config.name}`);
- }
- process.exit(0);
- }
+ // FIXME: list extensions after the config initialize
+ // if (config.getListExtensions()) {
+ // console.log('Installed extensions:');
+ // for (const extension of extensions) {
+ // console.log(`- ${extension.config.name}`);
+ // }
+ // process.exit(0);
+ // }
// Setup unified ConsolePatcher based on interactive mode
const isInteractive = config.isInteractive();
From 3719104d8531b63a1d99fb7edc2256f39e329c70 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=AE=87=E6=BA=AF?=
Date: Sun, 1 Feb 2026 23:01:06 -0800
Subject: [PATCH 23/41] Revert "resolve comment"
This reverts commit 61421e65872acebe20d88c2d40c9fea40fa3fefa.
---
packages/cli/src/gemini.tsx | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 16fea6311..786b3f8ac 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -339,16 +339,18 @@ 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
- // if (config.getListExtensions()) {
- // console.log('Installed extensions:');
- // for (const extension of extensions) {
- // console.log(`- ${extension.config.name}`);
- // }
- // process.exit(0);
- // }
+ if (config.getListExtensions()) {
+ console.log('Installed extensions:');
+ for (const extension of extensions) {
+ console.log(`- ${extension.config.name}`);
+ }
+ process.exit(0);
+ }
// Setup unified ConsolePatcher based on interactive mode
const isInteractive = config.isInteractive();
From 813e2d2b1217e125f7e999a7359b875b1dc49f75 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=AE=87=E6=BA=AF?=
Date: Sun, 1 Feb 2026 23:56:21 -0800
Subject: [PATCH 24/41] FIXME: list extensions after the config initialize
---
packages/cli/src/gemini.tsx | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 786b3f8ac..a17e85139 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -344,13 +344,14 @@ export async function main() {
// This ensures MCP server subprocesses are properly terminated on exit
registerCleanup(() => config.shutdown());
- if (config.getListExtensions()) {
- console.log('Installed extensions:');
- for (const extension of extensions) {
- console.log(`- ${extension.config.name}`);
- }
- process.exit(0);
- }
+ // FIXME: list extensions after the config initialize
+ // if (config.getListExtensions()) {
+ // console.log('Installed extensions:');
+ // for (const extension of extensions) {
+ // console.log(`- ${extension.config.name}`);
+ // }
+ // process.exit(0);
+ // }
// Setup unified ConsolePatcher based on interactive mode
const isInteractive = config.isInteractive();
From 11c198f8003a3046b31ba5ae77292c78327637f9 Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Mon, 2 Feb 2026 16:31:00 +0800
Subject: [PATCH 25/41] fix(core): enforce tool restrictions in subagents
(fixes #1121)
Add filtering to block unauthorized tool calls in subagents. When a model
attempts to call a tool not in the allowed tools list, the call is now
blocked and an error is returned instead of executing.
- Pass toolsList to scheduleToolCalls for filtering
- Emit TOOL_CALL and TOOL_RESULT events for blocked calls (for visibility)
- Extract recordToolCallStats helper to reduce duplication
- Add comprehensive test coverage for tool restriction enforcement
Co-authored-by: Qwen-Coder
---
packages/core/src/subagents/subagent.test.ts | 162 +++++++++++++++++++
packages/core/src/subagents/subagent.ts | 149 ++++++++++++-----
2 files changed, 269 insertions(+), 42 deletions(-)
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.
From f43bd7f8ab96a5569f1b046c3d78a968fd13c23b Mon Sep 17 00:00:00 2001
From: LaZzyMan <--global>
Date: Mon, 2 Feb 2026 17:23:59 +0800
Subject: [PATCH 26/41] Revert "fix undefined error"
This reverts commit 4534f5ec1d4915f1b6c44a8327f4eff12a523cd4.
---
packages/cli/src/ui/contexts/KeypressContext.tsx | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index 8df81a9d8..acf3b30d6 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -513,8 +513,7 @@ export function KeypressProvider({
// Kitty protocol is not available (e.g., Windows PowerShell)
if (
kittySequenceBuffer ||
- (key.sequence &&
- key.sequence.startsWith(`${ESC}[`) &&
+ (key.sequence.startsWith(`${ESC}[`) &&
!key.sequence.startsWith(PASTE_MODE_PREFIX) &&
!key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
!key.sequence.startsWith(FOCUS_IN) &&
From 7935482c3a4d352b410193d961556ee556ed05ce Mon Sep 17 00:00:00 2001
From: LaZzyMan <--global>
Date: Mon, 2 Feb 2026 17:24:01 +0800
Subject: [PATCH 27/41] Revert "fix: enable Shift+Tab shortcut in Windows
PowerShell"
This reverts commit 9bee51dc176879ee8e709c692e942050b618c825.
---
.../src/ui/contexts/KeypressContext.test.tsx | 33 +---
.../cli/src/ui/contexts/KeypressContext.tsx | 163 +++++++++---------
2 files changed, 86 insertions(+), 110 deletions(-)
diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
index 8bef05596..1130f8352 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
@@ -1256,13 +1256,13 @@ describe('KeypressContext - Kitty Protocol', () => {
});
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] CSI buffer accumulating:',
+ '[DEBUG] Kitty buffer accumulating:',
expect.stringContaining('\x1b[27u'),
);
const parsedCall = consoleLogSpy.mock.calls.find(
(args) =>
typeof args[0] === 'string' &&
- args[0].includes('[DEBUG] CSI sequence parsed successfully'),
+ args[0].includes('[DEBUG] Kitty sequence parsed successfully'),
);
expect(parsedCall).toBeTruthy();
expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u'));
@@ -1293,7 +1293,7 @@ describe('KeypressContext - Kitty Protocol', () => {
});
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] CSI buffer overflow, clearing:',
+ '[DEBUG] Kitty buffer overflow, clearing:',
expect.any(String),
);
});
@@ -1384,13 +1384,13 @@ describe('KeypressContext - Kitty Protocol', () => {
// Verify debug logging for accumulation
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] CSI buffer accumulating:',
+ '[DEBUG] Kitty buffer accumulating:',
sequence,
);
// Verify warning for char codes
expect(consoleWarnSpy).toHaveBeenCalledWith(
- 'CSI sequence buffer has char codes:',
+ 'Kitty sequence buffer has char codes:',
[27, 91, 49, 50],
);
});
@@ -1468,29 +1468,6 @@ describe('KeypressContext - Kitty Protocol', () => {
);
},
);
-
- it('should recognize Shift+Tab in non-Kitty protocol mode (Windows PowerShell)', () => {
- const keyHandler = vi.fn();
-
- // Create a wrapper with Kitty protocol disabled to simulate Windows PowerShell
- const nonKittyWrapper = ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
- );
-
- const { result } = renderHook(() => useKeypressContext(), {
- wrapper: nonKittyWrapper,
- });
- act(() => result.current.subscribe(keyHandler));
-
- // Send legacy reverse Tab sequence (ESC [ Z)
- act(() => stdin.sendKittySequence(`\x1b[Z`));
-
- expect(keyHandler).toHaveBeenCalledWith(
- expect.objectContaining({ name: 'tab', shift: true }),
- );
- });
});
describe('Double-tap and batching', () => {
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index acf3b30d6..0f01712cc 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -508,96 +508,95 @@ export function KeypressProvider({
return;
}
- // Parse CSI sequences for both Kitty protocol and legacy terminals
- // This ensures Shift+Tab and other special keys work correctly even when
- // Kitty protocol is not available (e.g., Windows PowerShell)
- if (
- kittySequenceBuffer ||
- (key.sequence.startsWith(`${ESC}[`) &&
- !key.sequence.startsWith(PASTE_MODE_PREFIX) &&
- !key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
- !key.sequence.startsWith(FOCUS_IN) &&
- !key.sequence.startsWith(FOCUS_OUT))
- ) {
- kittySequenceBuffer += key.sequence;
-
- if (debugKeystrokeLogging) {
- console.log('[DEBUG] CSI buffer accumulating:', kittySequenceBuffer);
- }
-
- // Try to peel off as many complete sequences as are available at the
- // start of the buffer. This handles batched inputs cleanly. If the
- // prefix is incomplete or invalid, skip to the next CSI introducer
- // (ESC[) so that a following valid sequence can still be parsed.
- let parsedAny = false;
- while (kittySequenceBuffer) {
- const parsed = parseKittyPrefix(kittySequenceBuffer);
- if (!parsed) {
- // Look for the next potential CSI start beyond index 0
- const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
- if (nextStart > 0) {
- if (debugKeystrokeLogging) {
- console.log(
- '[DEBUG] Skipping incomplete/invalid CSI prefix:',
- kittySequenceBuffer.slice(0, nextStart),
- );
- }
- kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
- continue;
- }
- break;
- }
- if (debugKeystrokeLogging) {
- const parsedSequence = kittySequenceBuffer.slice(0, parsed.length);
- if (kittySequenceBuffer.length > parsed.length) {
- console.log(
- '[DEBUG] CSI sequence parsed successfully (prefix):',
- parsedSequence,
- );
- } else {
- console.log(
- '[DEBUG] CSI sequence parsed successfully:',
- parsedSequence,
- );
- }
- }
- // Consume the parsed prefix and broadcast it.
- kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
- broadcast(parsed.key);
- parsedAny = true;
- }
- if (parsedAny) return;
-
- if (config?.getDebugMode() || debugKeystrokeLogging) {
- const codes = Array.from(kittySequenceBuffer).map((ch) =>
- ch.charCodeAt(0),
- );
- console.warn('CSI sequence buffer has char codes:', codes);
- }
-
+ if (kittyProtocolEnabled) {
if (
- kittyProtocolEnabled &&
- kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH
+ kittySequenceBuffer ||
+ (key.sequence.startsWith(`${ESC}[`) &&
+ !key.sequence.startsWith(PASTE_MODE_PREFIX) &&
+ !key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
+ !key.sequence.startsWith(FOCUS_IN) &&
+ !key.sequence.startsWith(FOCUS_OUT))
) {
+ kittySequenceBuffer += key.sequence;
+
if (debugKeystrokeLogging) {
console.log(
- '[DEBUG] CSI buffer overflow, clearing:',
+ '[DEBUG] Kitty buffer accumulating:',
kittySequenceBuffer,
);
}
- if (config) {
- const event = new KittySequenceOverflowEvent(
- kittySequenceBuffer.length,
- kittySequenceBuffer,
- );
- logKittySequenceOverflow(config, event);
+
+ // Try to peel off as many complete sequences as are available at the
+ // start of the buffer. This handles batched inputs cleanly. If the
+ // prefix is incomplete or invalid, skip to the next CSI introducer
+ // (ESC[) so that a following valid sequence can still be parsed.
+ let parsedAny = false;
+ while (kittySequenceBuffer) {
+ const parsed = parseKittyPrefix(kittySequenceBuffer);
+ if (!parsed) {
+ // Look for the next potential CSI start beyond index 0
+ const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
+ if (nextStart > 0) {
+ if (debugKeystrokeLogging) {
+ console.log(
+ '[DEBUG] Skipping incomplete/invalid CSI prefix:',
+ kittySequenceBuffer.slice(0, nextStart),
+ );
+ }
+ kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
+ continue;
+ }
+ break;
+ }
+ if (debugKeystrokeLogging) {
+ const parsedSequence = kittySequenceBuffer.slice(
+ 0,
+ parsed.length,
+ );
+ if (kittySequenceBuffer.length > parsed.length) {
+ console.log(
+ '[DEBUG] Kitty sequence parsed successfully (prefix):',
+ parsedSequence,
+ );
+ } else {
+ console.log(
+ '[DEBUG] Kitty sequence parsed successfully:',
+ parsedSequence,
+ );
+ }
+ }
+ // Consume the parsed prefix and broadcast it.
+ kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
+ broadcast(parsed.key);
+ parsedAny = true;
+ }
+ if (parsedAny) return;
+
+ if (config?.getDebugMode() || debugKeystrokeLogging) {
+ const codes = Array.from(kittySequenceBuffer).map((ch) =>
+ ch.charCodeAt(0),
+ );
+ console.warn('Kitty sequence buffer has char codes:', codes);
+ }
+
+ if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
+ if (debugKeystrokeLogging) {
+ console.log(
+ '[DEBUG] Kitty buffer overflow, clearing:',
+ kittySequenceBuffer,
+ );
+ }
+ if (config) {
+ const event = new KittySequenceOverflowEvent(
+ kittySequenceBuffer.length,
+ kittySequenceBuffer,
+ );
+ logKittySequenceOverflow(config, event);
+ }
+ kittySequenceBuffer = '';
+ } else {
+ return;
}
- kittySequenceBuffer = '';
- } else if (!kittyProtocolEnabled) {
- // For non-Kitty terminals, clear the buffer to avoid accumulation
- kittySequenceBuffer = '';
- } else {
- return;
}
}
From 3296785b23429f7f8d61653fa2e41ea7939e5cb9 Mon Sep 17 00:00:00 2001
From: LaZzyMan <--global>
Date: Mon, 2 Feb 2026 19:48:07 +0800
Subject: [PATCH 28/41] feat use tab on windows instead of shift+tab
---
docs/users/features/approval-mode.md | 12 +-
docs/users/reference/keyboard-shortcuts.md | 20 +--
package-lock.json | 3 +-
packages/cli/src/i18n/locales/de.js | 5 +
packages/cli/src/i18n/locales/en.js | 3 +
packages/cli/src/i18n/locales/ru.js | 5 +
packages/cli/src/i18n/locales/zh.js | 3 +
.../src/ui/components/AutoAcceptIndicator.tsx | 11 +-
packages/cli/src/ui/components/Help.test.tsx | 12 ++
packages/cli/src/ui/components/Help.tsx | 2 +-
.../src/ui/components/KeyboardShortcuts.tsx | 5 +-
packages/cli/src/ui/components/Tips.tsx | 4 +-
.../src/ui/contexts/KeypressContext.test.tsx | 37 +---
.../cli/src/ui/contexts/KeypressContext.tsx | 164 +++++++++---------
.../ui/hooks/useAutoAcceptIndicator.test.ts | 8 +-
.../src/ui/hooks/useAutoAcceptIndicator.ts | 11 +-
16 files changed, 167 insertions(+), 138 deletions(-)
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/package-lock.json b/package-lock.json
index b4f0301d3..c198aeb82 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3879,6 +3879,7 @@
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz",
"integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -17348,7 +17349,6 @@
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.25.1",
"@qwen-code/qwen-code-core": "file:../core",
- "@types/prompts": "^2.4.9",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"command-exists": "^1.2.9",
@@ -17392,6 +17392,7 @@
"@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1",
"@types/node": "^20.11.24",
+ "@types/prompts": "^2.4.9",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.0",
diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js
index 95496b4be..10082a350 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}}).',
'!': '!',
@@ -1358,4 +1359,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 cef757a0b..063c586d4 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}}).',
'!': '!',
@@ -1091,6 +1092,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 24fd24b9d..5aa3ef4c2 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}}).',
'!': '!',
@@ -1363,4 +1364,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 4f8c95d88..0fa64a46c 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}})',
'!': '!',
@@ -1031,6 +1032,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/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/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
index 1130f8352..93e7742f8 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
@@ -229,33 +229,6 @@ describe('KeypressContext - Kitty Protocol', () => {
}),
);
});
-
- it('should not process kitty sequences when kitty protocol is disabled', async () => {
- const keyHandler = vi.fn();
-
- const { result } = renderHook(() => useKeypressContext(), {
- wrapper: ({ children }) =>
- wrapper({ children, kittyProtocolEnabled: false }),
- });
-
- act(() => {
- result.current.subscribe(keyHandler);
- });
-
- // Send kitty protocol sequence for numpad enter
- act(() => {
- stdin.sendKittySequence(`\x1b[57414u`);
- });
-
- // When kitty protocol is disabled, the sequence should be passed through
- // as individual keypresses, not recognized as a single enter key
- expect(keyHandler).not.toHaveBeenCalledWith(
- expect.objectContaining({
- name: 'return',
- kittyProtocol: true,
- }),
- );
- });
});
describe('Escape key handling', () => {
@@ -1256,13 +1229,13 @@ describe('KeypressContext - Kitty Protocol', () => {
});
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] Kitty buffer accumulating:',
+ '[DEBUG] CSI buffer accumulating:',
expect.stringContaining('\x1b[27u'),
);
const parsedCall = consoleLogSpy.mock.calls.find(
(args) =>
typeof args[0] === 'string' &&
- args[0].includes('[DEBUG] Kitty sequence parsed successfully'),
+ args[0].includes('[DEBUG] CSI sequence parsed successfully'),
);
expect(parsedCall).toBeTruthy();
expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u'));
@@ -1293,7 +1266,7 @@ describe('KeypressContext - Kitty Protocol', () => {
});
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] Kitty buffer overflow, clearing:',
+ '[DEBUG] CSI buffer overflow, clearing:',
expect.any(String),
);
});
@@ -1384,13 +1357,13 @@ describe('KeypressContext - Kitty Protocol', () => {
// Verify debug logging for accumulation
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] Kitty buffer accumulating:',
+ '[DEBUG] CSI buffer accumulating:',
sequence,
);
// Verify warning for char codes
expect(consoleWarnSpy).toHaveBeenCalledWith(
- 'Kitty sequence buffer has char codes:',
+ 'CSI sequence buffer has char codes:',
[27, 91, 49, 50],
);
});
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index 0f01712cc..8df81a9d8 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -508,95 +508,97 @@ export function KeypressProvider({
return;
}
- if (kittyProtocolEnabled) {
- if (
- kittySequenceBuffer ||
- (key.sequence.startsWith(`${ESC}[`) &&
- !key.sequence.startsWith(PASTE_MODE_PREFIX) &&
- !key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
- !key.sequence.startsWith(FOCUS_IN) &&
- !key.sequence.startsWith(FOCUS_OUT))
- ) {
- kittySequenceBuffer += key.sequence;
+ // Parse CSI sequences for both Kitty protocol and legacy terminals
+ // This ensures Shift+Tab and other special keys work correctly even when
+ // Kitty protocol is not available (e.g., Windows PowerShell)
+ if (
+ kittySequenceBuffer ||
+ (key.sequence &&
+ key.sequence.startsWith(`${ESC}[`) &&
+ !key.sequence.startsWith(PASTE_MODE_PREFIX) &&
+ !key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
+ !key.sequence.startsWith(FOCUS_IN) &&
+ !key.sequence.startsWith(FOCUS_OUT))
+ ) {
+ kittySequenceBuffer += key.sequence;
+ if (debugKeystrokeLogging) {
+ console.log('[DEBUG] CSI buffer accumulating:', kittySequenceBuffer);
+ }
+
+ // Try to peel off as many complete sequences as are available at the
+ // start of the buffer. This handles batched inputs cleanly. If the
+ // prefix is incomplete or invalid, skip to the next CSI introducer
+ // (ESC[) so that a following valid sequence can still be parsed.
+ let parsedAny = false;
+ while (kittySequenceBuffer) {
+ const parsed = parseKittyPrefix(kittySequenceBuffer);
+ if (!parsed) {
+ // Look for the next potential CSI start beyond index 0
+ const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
+ if (nextStart > 0) {
+ if (debugKeystrokeLogging) {
+ console.log(
+ '[DEBUG] Skipping incomplete/invalid CSI prefix:',
+ kittySequenceBuffer.slice(0, nextStart),
+ );
+ }
+ kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
+ continue;
+ }
+ break;
+ }
+ if (debugKeystrokeLogging) {
+ const parsedSequence = kittySequenceBuffer.slice(0, parsed.length);
+ if (kittySequenceBuffer.length > parsed.length) {
+ console.log(
+ '[DEBUG] CSI sequence parsed successfully (prefix):',
+ parsedSequence,
+ );
+ } else {
+ console.log(
+ '[DEBUG] CSI sequence parsed successfully:',
+ parsedSequence,
+ );
+ }
+ }
+ // Consume the parsed prefix and broadcast it.
+ kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
+ broadcast(parsed.key);
+ parsedAny = true;
+ }
+ if (parsedAny) return;
+
+ if (config?.getDebugMode() || debugKeystrokeLogging) {
+ const codes = Array.from(kittySequenceBuffer).map((ch) =>
+ ch.charCodeAt(0),
+ );
+ console.warn('CSI sequence buffer has char codes:', codes);
+ }
+
+ if (
+ kittyProtocolEnabled &&
+ kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH
+ ) {
if (debugKeystrokeLogging) {
console.log(
- '[DEBUG] Kitty buffer accumulating:',
+ '[DEBUG] CSI buffer overflow, clearing:',
kittySequenceBuffer,
);
}
-
- // Try to peel off as many complete sequences as are available at the
- // start of the buffer. This handles batched inputs cleanly. If the
- // prefix is incomplete or invalid, skip to the next CSI introducer
- // (ESC[) so that a following valid sequence can still be parsed.
- let parsedAny = false;
- while (kittySequenceBuffer) {
- const parsed = parseKittyPrefix(kittySequenceBuffer);
- if (!parsed) {
- // Look for the next potential CSI start beyond index 0
- const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
- if (nextStart > 0) {
- if (debugKeystrokeLogging) {
- console.log(
- '[DEBUG] Skipping incomplete/invalid CSI prefix:',
- kittySequenceBuffer.slice(0, nextStart),
- );
- }
- kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
- continue;
- }
- break;
- }
- if (debugKeystrokeLogging) {
- const parsedSequence = kittySequenceBuffer.slice(
- 0,
- parsed.length,
- );
- if (kittySequenceBuffer.length > parsed.length) {
- console.log(
- '[DEBUG] Kitty sequence parsed successfully (prefix):',
- parsedSequence,
- );
- } else {
- console.log(
- '[DEBUG] Kitty sequence parsed successfully:',
- parsedSequence,
- );
- }
- }
- // Consume the parsed prefix and broadcast it.
- kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
- broadcast(parsed.key);
- parsedAny = true;
- }
- if (parsedAny) return;
-
- if (config?.getDebugMode() || debugKeystrokeLogging) {
- const codes = Array.from(kittySequenceBuffer).map((ch) =>
- ch.charCodeAt(0),
+ if (config) {
+ const event = new KittySequenceOverflowEvent(
+ kittySequenceBuffer.length,
+ kittySequenceBuffer,
);
- console.warn('Kitty sequence buffer has char codes:', codes);
- }
-
- if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
- if (debugKeystrokeLogging) {
- console.log(
- '[DEBUG] Kitty buffer overflow, clearing:',
- kittySequenceBuffer,
- );
- }
- if (config) {
- const event = new KittySequenceOverflowEvent(
- kittySequenceBuffer.length,
- kittySequenceBuffer,
- );
- logKittySequenceOverflow(config, event);
- }
- kittySequenceBuffer = '';
- } else {
- return;
+ logKittySequenceOverflow(config, event);
}
+ kittySequenceBuffer = '';
+ } else if (!kittyProtocolEnabled) {
+ // For non-Kitty terminals, clear the buffer to avoid accumulation
+ kittySequenceBuffer = '';
+ } else {
+ return;
}
}
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 =
From 7a154b8a6275a96fe0d31c28b5258ba7e56736c2 Mon Sep 17 00:00:00 2001
From: LaZzyMan
Date: Mon, 2 Feb 2026 20:08:12 +0800
Subject: [PATCH 29/41] fix revert changes
---
.../src/ui/contexts/KeypressContext.test.tsx | 37 +++-
.../cli/src/ui/contexts/KeypressContext.tsx | 199 ++++++++++--------
2 files changed, 139 insertions(+), 97 deletions(-)
diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
index 93e7742f8..1130f8352 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
@@ -229,6 +229,33 @@ describe('KeypressContext - Kitty Protocol', () => {
}),
);
});
+
+ it('should not process kitty sequences when kitty protocol is disabled', async () => {
+ const keyHandler = vi.fn();
+
+ const { result } = renderHook(() => useKeypressContext(), {
+ wrapper: ({ children }) =>
+ wrapper({ children, kittyProtocolEnabled: false }),
+ });
+
+ act(() => {
+ result.current.subscribe(keyHandler);
+ });
+
+ // Send kitty protocol sequence for numpad enter
+ act(() => {
+ stdin.sendKittySequence(`\x1b[57414u`);
+ });
+
+ // When kitty protocol is disabled, the sequence should be passed through
+ // as individual keypresses, not recognized as a single enter key
+ expect(keyHandler).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'return',
+ kittyProtocol: true,
+ }),
+ );
+ });
});
describe('Escape key handling', () => {
@@ -1229,13 +1256,13 @@ describe('KeypressContext - Kitty Protocol', () => {
});
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] CSI buffer accumulating:',
+ '[DEBUG] Kitty buffer accumulating:',
expect.stringContaining('\x1b[27u'),
);
const parsedCall = consoleLogSpy.mock.calls.find(
(args) =>
typeof args[0] === 'string' &&
- args[0].includes('[DEBUG] CSI sequence parsed successfully'),
+ args[0].includes('[DEBUG] Kitty sequence parsed successfully'),
);
expect(parsedCall).toBeTruthy();
expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u'));
@@ -1266,7 +1293,7 @@ describe('KeypressContext - Kitty Protocol', () => {
});
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] CSI buffer overflow, clearing:',
+ '[DEBUG] Kitty buffer overflow, clearing:',
expect.any(String),
);
});
@@ -1357,13 +1384,13 @@ describe('KeypressContext - Kitty Protocol', () => {
// Verify debug logging for accumulation
expect(consoleLogSpy).toHaveBeenCalledWith(
- '[DEBUG] CSI buffer accumulating:',
+ '[DEBUG] Kitty buffer accumulating:',
sequence,
);
// Verify warning for char codes
expect(consoleWarnSpy).toHaveBeenCalledWith(
- 'CSI sequence buffer has char codes:',
+ 'Kitty sequence buffer has char codes:',
[27, 91, 49, 50],
);
});
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index 8df81a9d8..dbdbf3e55 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -35,6 +35,7 @@ import {
MODIFIER_ALT_BIT,
MODIFIER_CTRL_BIT,
} from '../utils/platformConstants.js';
+import { clipboardHasImage } from '../utils/clipboardUtils.js';
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
@@ -53,6 +54,7 @@ export interface Key {
paste: boolean;
sequence: string;
kittyProtocol?: boolean;
+ pasteImage?: boolean;
}
export type KeypressHandler = (key: Key) => void;
@@ -387,7 +389,7 @@ export function KeypressProvider({
}
};
- const handleKeypress = (_: unknown, key: Key) => {
+ const handleKeypress = async (_: unknown, key: Key) => {
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
return;
}
@@ -397,14 +399,28 @@ export function KeypressProvider({
}
if (key.name === 'paste-end') {
isPaste = false;
- broadcast({
- name: '',
- ctrl: false,
- meta: false,
- shift: false,
- paste: true,
- sequence: pasteBuffer.toString(),
- });
+ if (pasteBuffer.toString().length > 0) {
+ broadcast({
+ name: '',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ paste: true,
+ sequence: pasteBuffer.toString(),
+ });
+ } else {
+ const hasImage = await clipboardHasImage();
+ broadcast({
+ name: '',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ paste: true,
+ pasteImage: hasImage,
+ sequence: pasteBuffer.toString(),
+ });
+ }
+
pasteBuffer = Buffer.alloc(0);
return;
}
@@ -508,97 +524,95 @@ export function KeypressProvider({
return;
}
- // Parse CSI sequences for both Kitty protocol and legacy terminals
- // This ensures Shift+Tab and other special keys work correctly even when
- // Kitty protocol is not available (e.g., Windows PowerShell)
- if (
- kittySequenceBuffer ||
- (key.sequence &&
- key.sequence.startsWith(`${ESC}[`) &&
- !key.sequence.startsWith(PASTE_MODE_PREFIX) &&
- !key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
- !key.sequence.startsWith(FOCUS_IN) &&
- !key.sequence.startsWith(FOCUS_OUT))
- ) {
- kittySequenceBuffer += key.sequence;
-
- if (debugKeystrokeLogging) {
- console.log('[DEBUG] CSI buffer accumulating:', kittySequenceBuffer);
- }
-
- // Try to peel off as many complete sequences as are available at the
- // start of the buffer. This handles batched inputs cleanly. If the
- // prefix is incomplete or invalid, skip to the next CSI introducer
- // (ESC[) so that a following valid sequence can still be parsed.
- let parsedAny = false;
- while (kittySequenceBuffer) {
- const parsed = parseKittyPrefix(kittySequenceBuffer);
- if (!parsed) {
- // Look for the next potential CSI start beyond index 0
- const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
- if (nextStart > 0) {
- if (debugKeystrokeLogging) {
- console.log(
- '[DEBUG] Skipping incomplete/invalid CSI prefix:',
- kittySequenceBuffer.slice(0, nextStart),
- );
- }
- kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
- continue;
- }
- break;
- }
- if (debugKeystrokeLogging) {
- const parsedSequence = kittySequenceBuffer.slice(0, parsed.length);
- if (kittySequenceBuffer.length > parsed.length) {
- console.log(
- '[DEBUG] CSI sequence parsed successfully (prefix):',
- parsedSequence,
- );
- } else {
- console.log(
- '[DEBUG] CSI sequence parsed successfully:',
- parsedSequence,
- );
- }
- }
- // Consume the parsed prefix and broadcast it.
- kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
- broadcast(parsed.key);
- parsedAny = true;
- }
- if (parsedAny) return;
-
- if (config?.getDebugMode() || debugKeystrokeLogging) {
- const codes = Array.from(kittySequenceBuffer).map((ch) =>
- ch.charCodeAt(0),
- );
- console.warn('CSI sequence buffer has char codes:', codes);
- }
-
+ if (kittyProtocolEnabled) {
if (
- kittyProtocolEnabled &&
- kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH
+ kittySequenceBuffer ||
+ (key.sequence.startsWith(`${ESC}[`) &&
+ !key.sequence.startsWith(PASTE_MODE_PREFIX) &&
+ !key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
+ !key.sequence.startsWith(FOCUS_IN) &&
+ !key.sequence.startsWith(FOCUS_OUT))
) {
+ kittySequenceBuffer += key.sequence;
+
if (debugKeystrokeLogging) {
console.log(
- '[DEBUG] CSI buffer overflow, clearing:',
+ '[DEBUG] Kitty buffer accumulating:',
kittySequenceBuffer,
);
}
- if (config) {
- const event = new KittySequenceOverflowEvent(
- kittySequenceBuffer.length,
- kittySequenceBuffer,
- );
- logKittySequenceOverflow(config, event);
+
+ // Try to peel off as many complete sequences as are available at the
+ // start of the buffer. This handles batched inputs cleanly. If the
+ // prefix is incomplete or invalid, skip to the next CSI introducer
+ // (ESC[) so that a following valid sequence can still be parsed.
+ let parsedAny = false;
+ while (kittySequenceBuffer) {
+ const parsed = parseKittyPrefix(kittySequenceBuffer);
+ if (!parsed) {
+ // Look for the next potential CSI start beyond index 0
+ const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
+ if (nextStart > 0) {
+ if (debugKeystrokeLogging) {
+ console.log(
+ '[DEBUG] Skipping incomplete/invalid CSI prefix:',
+ kittySequenceBuffer.slice(0, nextStart),
+ );
+ }
+ kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
+ continue;
+ }
+ break;
+ }
+ if (debugKeystrokeLogging) {
+ const parsedSequence = kittySequenceBuffer.slice(
+ 0,
+ parsed.length,
+ );
+ if (kittySequenceBuffer.length > parsed.length) {
+ console.log(
+ '[DEBUG] Kitty sequence parsed successfully (prefix):',
+ parsedSequence,
+ );
+ } else {
+ console.log(
+ '[DEBUG] Kitty sequence parsed successfully:',
+ parsedSequence,
+ );
+ }
+ }
+ // Consume the parsed prefix and broadcast it.
+ kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
+ broadcast(parsed.key);
+ parsedAny = true;
+ }
+ if (parsedAny) return;
+
+ if (config?.getDebugMode() || debugKeystrokeLogging) {
+ const codes = Array.from(kittySequenceBuffer).map((ch) =>
+ ch.charCodeAt(0),
+ );
+ console.warn('Kitty sequence buffer has char codes:', codes);
+ }
+
+ if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
+ if (debugKeystrokeLogging) {
+ console.log(
+ '[DEBUG] Kitty buffer overflow, clearing:',
+ kittySequenceBuffer,
+ );
+ }
+ if (config) {
+ const event = new KittySequenceOverflowEvent(
+ kittySequenceBuffer.length,
+ kittySequenceBuffer,
+ );
+ logKittySequenceOverflow(config, event);
+ }
+ kittySequenceBuffer = '';
+ } else {
+ return;
}
- kittySequenceBuffer = '';
- } else if (!kittyProtocolEnabled) {
- // For non-Kitty terminals, clear the buffer to avoid accumulation
- kittySequenceBuffer = '';
- } else {
- return;
}
}
@@ -721,6 +735,7 @@ export function KeypressProvider({
};
let rl: readline.Interface;
+
if (usePassthrough) {
rl = readline.createInterface({
input: keypressStream,
From 4a578a61f2f32032e8bf999f71028001a16a1a53 Mon Sep 17 00:00:00 2001
From: LaZzyMan
Date: Mon, 2 Feb 2026 20:16:59 +0800
Subject: [PATCH 30/41] fix test
---
.../cli/src/ui/contexts/KeypressContext.tsx | 35 +++++--------------
1 file changed, 9 insertions(+), 26 deletions(-)
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index dbdbf3e55..0f01712cc 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -35,7 +35,6 @@ import {
MODIFIER_ALT_BIT,
MODIFIER_CTRL_BIT,
} from '../utils/platformConstants.js';
-import { clipboardHasImage } from '../utils/clipboardUtils.js';
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
@@ -54,7 +53,6 @@ export interface Key {
paste: boolean;
sequence: string;
kittyProtocol?: boolean;
- pasteImage?: boolean;
}
export type KeypressHandler = (key: Key) => void;
@@ -389,7 +387,7 @@ export function KeypressProvider({
}
};
- const handleKeypress = async (_: unknown, key: Key) => {
+ const handleKeypress = (_: unknown, key: Key) => {
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
return;
}
@@ -399,28 +397,14 @@ export function KeypressProvider({
}
if (key.name === 'paste-end') {
isPaste = false;
- if (pasteBuffer.toString().length > 0) {
- broadcast({
- name: '',
- ctrl: false,
- meta: false,
- shift: false,
- paste: true,
- sequence: pasteBuffer.toString(),
- });
- } else {
- const hasImage = await clipboardHasImage();
- broadcast({
- name: '',
- ctrl: false,
- meta: false,
- shift: false,
- paste: true,
- pasteImage: hasImage,
- sequence: pasteBuffer.toString(),
- });
- }
-
+ broadcast({
+ name: '',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ paste: true,
+ sequence: pasteBuffer.toString(),
+ });
pasteBuffer = Buffer.alloc(0);
return;
}
@@ -735,7 +719,6 @@ export function KeypressProvider({
};
let rl: readline.Interface;
-
if (usePassthrough) {
rl = readline.createInterface({
input: keypressStream,
From 640196e779f4b109e93b698ac409dc4401bedb30 Mon Sep 17 00:00:00 2001
From: DennisYu07
Date: Mon, 2 Feb 2026 19:47:24 -0800
Subject: [PATCH 31/41] merge shutdown
---
packages/core/src/config/config.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 56a5d8ff6..79b9921dd 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -1026,13 +1026,13 @@ export class Config {
* It handles the case where initialization was not completed.
*/
async shutdown(): Promise {
- this.skillManager?.stopWatching();
-
if (!this.initialized) {
// Nothing to clean up if not initialized
return;
}
try {
+ this.skillManager?.stopWatching();
+
if (this.toolRegistry) {
await this.toolRegistry.stop();
}
From 44b7dad966f3bb7ddf55f7bf312a021aba088051 Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Tue, 3 Feb 2026 15:10:38 +0800
Subject: [PATCH 32/41] fix(ci): add --skip-duplicate flag to vsce publish
Prevents workflow failure when some platform VSIXes are already
published (e.g., darwin-arm64, darwin-x64) during retry runs.
Co-authored-by: Qwen-Coder
---
.github/workflows/release-vscode-companion.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/release-vscode-companion.yml b/.github/workflows/release-vscode-companion.yml
index 8b8d1ea24..ea02b01fb 100644
--- a/.github/workflows/release-vscode-companion.yml
+++ b/.github/workflows/release-vscode-companion.yml
@@ -299,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'
From c8a148b92ed5be0d16a4a43d45952cf8476c6112 Mon Sep 17 00:00:00 2001
From: liqoingyu
Date: Sun, 18 Jan 2026 18:47:33 +0800
Subject: [PATCH 33/41] fix(cli): expand MCP @server: resource references
---
.../src/ui/hooks/atCommandProcessor.test.ts | 55 ++-
.../cli/src/ui/hooks/atCommandProcessor.ts | 457 ++++++++++++++----
packages/core/src/tools/mcp-client-manager.ts | 42 ++
packages/core/src/tools/mcp-client.ts | 18 +
packages/core/src/tools/tool-registry.ts | 12 +
5 files changed, 499 insertions(+), 85 deletions(-)
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
index d86340283..f159eb385 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,57 @@ 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(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'tool_group',
+ tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
+ }),
+ 1000,
+ );
+ });
+
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..d099958da 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,191 @@ 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) {
+ merged.push(part);
+ continue;
+ }
+
+ const normalizedAtCommand = `@${serverName}:${resource}`;
+ refs.push({
+ atCommand: normalizedAtCommand,
+ serverName,
+ uri: normalizeMcpResourceUri(serverName, resource),
+ });
+ 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 +318,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 +352,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 +567,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 +585,185 @@ 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 {
+ resourceResult = await new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ const error = new Error('MCP resource read aborted');
+ error.name = 'AbortError';
+ reject(error);
+ return;
+ }
+
+ const onAbort = () => {
+ cleanup();
+ const error = new Error('MCP resource read aborted');
+ error.name = 'AbortError';
+ reject(error);
+ };
+ const cleanup = () => {
+ signal.removeEventListener('abort', onAbort);
+ };
+
+ signal.addEventListener('abort', onAbort, { once: true });
+
+ toolRegistry
+ .readMcpResource(ref.serverName, ref.uri)
+ .then((res) => {
+ cleanup();
+ resolve(res);
+ })
+ .catch((err) => {
+ cleanup();
+ reject(err);
+ });
+ });
+
+ 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/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts
index d72c76ca5..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.
@@ -191,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..8277cd63e 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,22 @@ export class McpClient {
return this.status;
}
+ async readResource(uri: string): 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,
+ );
+ }
+
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 540851f50..c8abf5ee5 100644
--- a/packages/core/src/tools/tool-registry.ts
+++ b/packages/core/src/tools/tool-registry.ts
@@ -22,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;
@@ -470,6 +471,17 @@ export class ToolRegistry {
return this.tools.get(name);
}
+ async readMcpResource(
+ serverName: string,
+ uri: string,
+ ): Promise {
+ if (!this.config.isTrustedFolder()) {
+ throw new Error('MCP resources are unavailable in untrusted folders.');
+ }
+
+ return this.mcpClientManager.readResource(serverName, uri);
+ }
+
/**
* Stops all MCP clients and cleans up resources.
* This method is idempotent and safe to call multiple times.
From 5b2dc788975e80b44ffc3b724995ecc1ef8d78ff Mon Sep 17 00:00:00 2001
From: liqoingyu
Date: Sun, 18 Jan 2026 20:38:45 +0800
Subject: [PATCH 34/41] fix(cli,core): harden MCP resource references
---
.../src/ui/hooks/atCommandProcessor.test.ts | 129 ++++++++++++++++++
.../cli/src/ui/hooks/atCommandProcessor.ts | 14 +-
2 files changed, 140 insertions(+), 3 deletions(-)
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
index f159eb385..3b91dd269 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
@@ -257,6 +257,135 @@ 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: 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(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(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 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 d099958da..a5ea3a46d 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts
@@ -225,15 +225,23 @@ function extractMcpResourceAtReferences(
}
if (!resource) {
- merged.push(part);
+ // 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 normalizedAtCommand = `@${serverName}:${resource}`;
+ const normalizedResource = resource.includes('://')
+ ? resource
+ : resource.startsWith('/')
+ ? resource.slice(1)
+ : resource;
+
+ const normalizedAtCommand = `@${serverName}:${normalizedResource}`;
refs.push({
atCommand: normalizedAtCommand,
serverName,
- uri: normalizeMcpResourceUri(serverName, resource),
+ uri: normalizeMcpResourceUri(serverName, normalizedResource),
});
merged.push({ type: 'atPath', content: normalizedAtCommand });
}
From 5087426af738ab711ef03026b906810d1024a2de Mon Sep 17 00:00:00 2001
From: liqoingyu
Date: Mon, 19 Jan 2026 11:46:48 +0800
Subject: [PATCH 35/41] test(cli): cover MCP resource edge cases
---
.../src/ui/hooks/atCommandProcessor.test.ts | 97 +++++++++++++++++++
1 file changed, 97 insertions(+)
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
index 3b91dd269..9b1f9635a 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
@@ -386,6 +386,103 @@ describe('handleAtCommand', () => {
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(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(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(
From 7e5d1470c813182f3c525c30377025b20dafc805 Mon Sep 17 00:00:00 2001
From: liqoingyu
Date: Tue, 3 Feb 2026 17:32:24 +0800
Subject: [PATCH 36/41] fix(cli,core): pass abort signal to MCP resource reads
---
.../src/ui/hooks/atCommandProcessor.test.ts | 5 +++
.../cli/src/ui/hooks/atCommandProcessor.ts | 40 +++++--------------
packages/core/src/tools/mcp-client.ts | 6 ++-
packages/core/src/tools/tool-registry.ts | 3 +-
4 files changed, 22 insertions(+), 32 deletions(-)
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
index 9b1f9635a..4d9c949e3 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
@@ -247,6 +247,7 @@ describe('handleAtCommand', () => {
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
+ expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -298,6 +299,7 @@ describe('handleAtCommand', () => {
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
+ expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -349,6 +351,7 @@ describe('handleAtCommand', () => {
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
+ expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -417,6 +420,7 @@ describe('handleAtCommand', () => {
expect(readMcpResourceSpy).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
+ expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -473,6 +477,7 @@ describe('handleAtCommand', () => {
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
+ expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts
index a5ea3a46d..a7fb2fe36 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts
@@ -693,37 +693,17 @@ export async function handleAtCommand({
const ref = mcpResourceRefs[i];
let resourceResult: unknown;
try {
- resourceResult = await new Promise((resolve, reject) => {
- if (signal.aborted) {
- const error = new Error('MCP resource read aborted');
- error.name = 'AbortError';
- reject(error);
- return;
- }
+ if (signal.aborted) {
+ const error = new Error('MCP resource read aborted');
+ error.name = 'AbortError';
+ throw error;
+ }
- const onAbort = () => {
- cleanup();
- const error = new Error('MCP resource read aborted');
- error.name = 'AbortError';
- reject(error);
- };
- const cleanup = () => {
- signal.removeEventListener('abort', onAbort);
- };
-
- signal.addEventListener('abort', onAbort, { once: true });
-
- toolRegistry
- .readMcpResource(ref.serverName, ref.uri)
- .then((res) => {
- cleanup();
- resolve(res);
- })
- .catch((err) => {
- cleanup();
- reject(err);
- });
- });
+ resourceResult = await toolRegistry.readMcpResource(
+ ref.serverName,
+ ref.uri,
+ { signal },
+ );
toolDisplays.push({
callId: `client-mcp-resource-${userMessageTimestamp}-${i}`,
diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts
index 8277cd63e..cfae34506 100644
--- a/packages/core/src/tools/mcp-client.ts
+++ b/packages/core/src/tools/mcp-client.ts
@@ -196,7 +196,10 @@ export class McpClient {
return this.status;
}
- async readResource(uri: string): Promise {
+ async readResource(
+ uri: string,
+ options?: { signal?: AbortSignal },
+ ): Promise {
if (this.status !== MCPServerStatus.CONNECTED) {
throw new Error('Client is not connected.');
}
@@ -209,6 +212,7 @@ export class McpClient {
return this.client.request(
{ method: 'resources/read', params: { uri } },
ReadResourceResultSchema,
+ options,
);
}
diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts
index c8abf5ee5..a2096a2a5 100644
--- a/packages/core/src/tools/tool-registry.ts
+++ b/packages/core/src/tools/tool-registry.ts
@@ -474,12 +474,13 @@ export class ToolRegistry {
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);
+ return this.mcpClientManager.readResource(serverName, uri, options);
}
/**
From 7c3515b7030ce3c683ec16b023dc5baf4e2e5894 Mon Sep 17 00:00:00 2001
From: liqoingyu
Date: Tue, 3 Feb 2026 20:33:55 +0800
Subject: [PATCH 37/41] fix(core): handle heredoc in command substitution guard
---
packages/core/src/utils/shell-utils.test.ts | 60 +++++
packages/core/src/utils/shell-utils.ts | 256 +++++++++++++++++++-
2 files changed, 311 insertions(+), 5 deletions(-)
diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts
index d133660ff..d6cb928bb 100644
--- a/packages/core/src/utils/shell-utils.test.ts
+++ b/packages/core/src/utils/shell-utils.test.ts
@@ -169,6 +169,66 @@ 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);
+ });
+ });
});
});
diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts
index ea20ed08c..ce5a7a3bd 100644
--- a/packages/core/src/utils/shell-utils.ts
+++ b/packages/core/src/utils/shell-utils.ts
@@ -240,19 +240,246 @@ 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 (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) {
+ 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 compareLine = heredoc.stripLeadingTabs
+ ? rawLine.replace(/^\t+/, '')
+ : rawLine;
+
+ if (compareLine === heredoc.delimiter) {
+ i = lineEnd + newlineLength;
+ break;
+ }
+
+ if (!heredoc.isQuotedDelimiter && lineHasCommandSubstitution(rawLine)) {
+ return { nextIndex: i, hasSubstitution: true };
+ }
+
+ // 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;
+ 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') {
+ 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') {
+ if (pendingHeredocs.length > 0) {
+ const result = consumeHeredocBodies(i + 1, pendingHeredocs);
+ if (result.hasSubstitution) return true;
+ pendingHeredocs.length = 0;
+ i = result.nextIndex;
+ continue;
+ }
+ }
+ }
+
// Handle escaping - only works outside single quotes
if (char === '\\' && !inSingleQuotes) {
i += 2; // Skip the escaped character
@@ -269,7 +496,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 +530,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 +540,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;
}
From bfaada45a2ea601b65afc57e4dc0b1768e56faef Mon Sep 17 00:00:00 2001
From: liqoingyu
Date: Wed, 4 Feb 2026 09:28:59 +0800
Subject: [PATCH 38/41] fix(core): ignore comments in substitution guard
---
packages/core/src/utils/shell-utils.test.ts | 30 +++++++++++++++++++
packages/core/src/utils/shell-utils.ts | 32 ++++++++++++++++++++-
2 files changed, 61 insertions(+), 1 deletion(-)
diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts
index d6cb928bb..0ee8b70d9 100644
--- a/packages/core/src/utils/shell-utils.test.ts
+++ b/packages/core/src/utils/shell-utils.test.ts
@@ -229,6 +229,36 @@ describe('isCommandAllowed', () => {
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 ce5a7a3bd..f74e70601 100644
--- a/packages/core/src/utils/shell-utils.ts
+++ b/packages/core/src/utils/shell-utils.ts
@@ -256,6 +256,20 @@ export function detectCommandSubstitution(command: string): boolean {
stripLeadingTabs: boolean;
};
+ const isCommentStart = (index: number): boolean => {
+ 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;
@@ -450,6 +464,7 @@ export function detectCommandSubstitution(command: string): boolean {
let inSingleQuotes = false;
let inDoubleQuotes = false;
let inBackticks = false;
+ let inComment = false;
const pendingHeredocs: PendingHeredoc[] = [];
let i = 0;
@@ -462,6 +477,7 @@ export function detectCommandSubstitution(command: string): boolean {
// 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;
@@ -469,7 +485,8 @@ export function detectCommandSubstitution(command: string): boolean {
i = result.nextIndex;
continue;
}
- } else if (char === '\n') {
+ } else if (char === '\n' || char === '\r') {
+ inComment = false;
if (pendingHeredocs.length > 0) {
const result = consumeHeredocBodies(i + 1, pendingHeredocs);
if (result.hasSubstitution) return true;
@@ -480,6 +497,19 @@ export function detectCommandSubstitution(command: string): boolean {
}
}
+ 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
From 6a8d5ce83670e9a8be55b93af500cd62dbe98efa Mon Sep 17 00:00:00 2001
From: liqoingyu
Date: Wed, 4 Feb 2026 09:50:11 +0800
Subject: [PATCH 39/41] fix(core): detect heredoc line continuation
substitution
---
packages/core/src/utils/shell-utils.test.ts | 25 ++++++++++++++
packages/core/src/utils/shell-utils.ts | 38 ++++++++++++++++++---
2 files changed, 59 insertions(+), 4 deletions(-)
diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts
index 0ee8b70d9..b974bfd5a 100644
--- a/packages/core/src/utils/shell-utils.test.ts
+++ b/packages/core/src/utils/shell-utils.test.ts
@@ -228,6 +228,31 @@ describe('isCommandAllowed', () => {
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', () => {
diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts
index f74e70601..1f0476866 100644
--- a/packages/core/src/utils/shell-utils.ts
+++ b/packages/core/src/utils/shell-utils.ts
@@ -411,6 +411,11 @@ export function detectCommandSubstitution(command: string): 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 (
@@ -437,17 +442,42 @@ export function detectCommandSubstitution(command: string): boolean {
}
const rawLine = command.slice(lineStart, lineEnd);
- const compareLine = heredoc.stripLeadingTabs
+ const effectiveLine = heredoc.stripLeadingTabs
? rawLine.replace(/^\t+/, '')
: rawLine;
- if (compareLine === heredoc.delimiter) {
+ if (effectiveLine === heredoc.delimiter) {
i = lineEnd + newlineLength;
break;
}
- if (!heredoc.isQuotedDelimiter && lineHasCommandSubstitution(rawLine)) {
- return { nextIndex: i, hasSubstitution: true };
+ 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).
From e4fba169a143aa90c9a5170c66c000c2f99c1380 Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Wed, 4 Feb 2026 14:11:58 +0800
Subject: [PATCH 40/41] fix(core): Prevent double BOM when writing files with
BOM option
- Strip BOM character (\uFEFF) from content before prepending BOM bytes
- Use FileEncoding.UTF8 constant instead of string literal
- Ensure file descriptor is closed in finally block
- Add test for double BOM prevention
Co-authored-by: Qwen-Coder
---
.../src/services/fileSystemService.test.ts | 34 +++++++++++++++++++
.../core/src/services/fileSystemService.ts | 13 ++++---
2 files changed, 43 insertions(+), 4 deletions(-)
diff --git a/packages/core/src/services/fileSystemService.test.ts b/packages/core/src/services/fileSystemService.test.ts
index 64219c2dc..69898f72d 100644
--- a/packages/core/src/services/fileSystemService.test.ts
+++ b/packages/core/src/services/fileSystemService.test.ts
@@ -86,6 +86,40 @@ describe('StandardFileSystemService', () => {
'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', () => {
diff --git a/packages/core/src/services/fileSystemService.ts b/packages/core/src/services/fileSystemService.ts
index 97a4d30b1..91f36161c 100644
--- a/packages/core/src/services/fileSystemService.ts
+++ b/packages/core/src/services/fileSystemService.ts
@@ -97,7 +97,7 @@ function hasUTF8BOM(buffer: Buffer): boolean {
*/
export class StandardFileSystemService implements FileSystemService {
async readTextFile(filePath: string): Promise {
- return fs.readFile(filePath, 'utf-8');
+ return fs.readFile(filePath, FileEncoding.UTF8);
}
async writeTextFile(
@@ -109,8 +109,11 @@ export class StandardFileSystemService implements FileSystemService {
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(content, 'utf-8');
+ const contentBuffer = Buffer.from(normalizedContent, 'utf-8');
await fs.writeFile(filePath, Buffer.concat([bomBuffer, contentBuffer]));
} else {
await fs.writeFile(filePath, content, 'utf-8');
@@ -118,12 +121,12 @@ export class StandardFileSystemService implements FileSystemService {
}
async detectFileBOM(filePath: string): Promise {
+ let fd: fs.FileHandle | undefined;
try {
// Read only the first 3 bytes to check for BOM
- const fd = await fs.open(filePath, 'r');
+ fd = await fs.open(filePath, 'r');
const buffer = Buffer.alloc(3);
const { bytesRead } = await fd.read(buffer, 0, 3, 0);
- await fd.close();
if (bytesRead < 3) {
return false;
@@ -133,6 +136,8 @@ export class StandardFileSystemService implements FileSystemService {
} catch {
// File doesn't exist or can't be read - treat as no BOM
return false;
+ } finally {
+ await fd?.close();
}
}
From 46327f219e075056642dfeadaebd853c26230fcd Mon Sep 17 00:00:00 2001
From: tanzhenxin
Date: Wed, 4 Feb 2026 15:03:37 +0800
Subject: [PATCH 41/41] feat(core): add disableCacheControl support for
Anthropic provider
Co-authored-by: Qwen-Coder
---
docs/developers/roadmap.md | 2 +-
packages/cli/src/config/settingsSchema.ts | 3 +-
.../anthropicContentGenerator.ts | 1 +
.../converter.test.ts | 76 +++++++++++++++++++
.../anthropicContentGenerator/converter.ts | 31 ++++++--
packages/core/src/core/contentGenerator.ts | 2 +-
6 files changed, 107 insertions(+), 8 deletions(-)
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/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 943045608..66caa0608 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -629,7 +629,8 @@ const SETTINGS_SCHEMA = {
category: 'Generation Configuration',
requiresRestart: false,
default: false,
- description: 'Disable cache control for DashScope providers.',
+ description:
+ 'Disable cache control for Anthropic and DashScope providers.',
parentKey: 'generationConfig',
showInDialog: false,
},
diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts
index d66787635..97f5e4d2d 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.disableCacheControl,
);
}
diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts
index 34bbab9c8..b0fe105bc 100644
--- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts
+++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts
@@ -676,6 +676,7 @@ describe('AnthropicContentConverter', () => {
properties: { location: { type: 'string' } },
required: ['location'],
},
+ cache_control: { type: 'ephemeral' },
});
expect(vi.mocked(convertSchema)).toHaveBeenCalledTimes(1);
@@ -719,6 +720,7 @@ describe('AnthropicContentConverter', () => {
name: 'no_params',
description: 'no params',
input_schema: { type: 'object', properties: {} },
+ cache_control: { type: 'ephemeral' },
});
});
@@ -811,4 +813,78 @@ describe('AnthropicContentConverter', () => {
expect(converter.mapAnthropicFinishReasonToGemini('')).toBeUndefined();
});
});
+
+ describe('disableCacheControl', () => {
+ it('does not add cache_control to system when disabled', () => {
+ const noCacheConverter = new AnthropicContentConverter(
+ 'test-model',
+ 'auto',
+ true,
+ );
+ 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',
+ true,
+ );
+ 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',
+ true,
+ );
+ 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 9c302f0dc..688953d4b 100644
--- a/packages/core/src/core/anthropicContentGenerator/converter.ts
+++ b/packages/core/src/core/anthropicContentGenerator/converter.ts
@@ -26,16 +26,24 @@ 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 disableCacheControl: boolean;
- constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') {
+ constructor(
+ model: string,
+ schemaCompliance: SchemaComplianceMode = 'auto',
+ disableCacheControl: boolean = false,
+ ) {
this.model = model;
this.schemaCompliance = schemaCompliance;
+ this.disableCacheControl = disableCacheControl;
}
convertGeminiRequestToAnthropic(request: GenerateContentParameters): {
@@ -50,9 +58,13 @@ export class AnthropicContentConverter {
this.processContents(request.contents, messages);
- // Add cache_control to enable prompt caching
- const system = this.buildSystemWithCacheControl(systemText);
- this.addCacheControlToMessages(messages);
+ // Add cache_control to enable prompt caching (if not disabled)
+ const system = this.disableCacheControl
+ ? systemText
+ : this.buildSystemWithCacheControl(systemText);
+ if (!this.disableCacheControl) {
+ this.addCacheControlToMessages(messages);
+ }
return {
system,
@@ -107,6 +119,15 @@ export class AnthropicContentConverter {
}
}
+ // Add cache_control to the last tool for prompt caching (if not disabled)
+ if (!this.disableCacheControl && tools.length > 0) {
+ const lastToolIndex = tools.length - 1;
+ tools[lastToolIndex] = {
+ ...tools[lastToolIndex],
+ cache_control: { type: 'ephemeral' },
+ };
+ }
+
return tools;
}
diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts
index 6ac6d9c72..d455c42f4 100644
--- a/packages/core/src/core/contentGenerator.ts
+++ b/packages/core/src/core/contentGenerator.ts
@@ -71,7 +71,7 @@ export type ContentGeneratorConfig = {
openAILoggingDir?: string;
timeout?: number; // Timeout configuration in milliseconds
maxRetries?: number; // Maximum retries for failed requests
- disableCacheControl?: boolean; // Disable cache control for DashScope providers
+ disableCacheControl?: boolean; // Disable prompt caching (Anthropic, DashScope)
samplingParams?: {
top_p?: number;
top_k?: number;