Compare commits

..

2 Commits

45 changed files with 1274 additions and 1510 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,3 +0,0 @@
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy
# SDK TypeScript package changes require review from Mingholy
packages/sdk-typescript/** @Mingholy

View File

@@ -241,7 +241,7 @@ jobs:
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'pr'
env:
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
@@ -258,15 +258,26 @@ jobs:
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
- name: 'Wait for CI checks to complete'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
echo "Waiting for CI checks to complete..."
gh pr checks "${PR_URL}" --watch --interval 30
- name: 'Enable auto-merge for release PR'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
gh pr merge "${PR_URL}" --merge --auto --delete-branch
gh pr merge "${PR_URL}" --merge --auto
- name: 'Create Issue on Failure'
if: |-

View File

@@ -49,8 +49,6 @@ Cross-platform sandboxing with complete process isolation.
By default, Qwen Code uses a published sandbox image (configured in the CLI package) and will pull it as needed.
The container sandbox mounts your workspace and your `~/.qwen` directory into the container so auth and settings persist between runs.
**Best for**: Strong isolation on any OS, consistent tooling inside a known image.
### Choosing a method
@@ -159,7 +157,7 @@ For a working allowlist-style proxy example, see: [Example Proxy Script](/develo
## Linux UID/GID handling
On Linux, Qwen Code defaults to enabling UID/GID mapping so the sandbox runs as your user (and reuses the mounted `~/.qwen`). Override with:
The sandbox automatically handles user permissions on Linux. Override these permissions with:
```bash
export SANDBOX_SET_UID_GID=true # Force host UID/GID

View File

@@ -18,7 +18,7 @@
### Requirements
- VS Code 1.85.0 or higher
- VS Code 1.98.0 or higher
### Installation
@@ -34,7 +34,7 @@
### Extension not installing
- Ensure you have VS Code 1.85.0 or higher
- Ensure you have VS Code 1.98.0 or higher
- Check that VS Code has permission to install extensions
- Try installing directly from the Marketplace website

View File

@@ -9,18 +9,11 @@ This guide provides solutions to common issues and debugging tips, including top
## Authentication or login errors
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, or `unable to get local issuer certificate`**
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or `unable to get local issuer certificate`**
- **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js.
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
- **Error: `Device authorization flow failed: fetch failed`**
- **Cause:** Node.js could not reach Qwen OAuth endpoints (often a proxy or SSL/TLS trust issue). When available, Qwen Code will also print the underlying error cause (for example: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`).
- **Solution:**
- Confirm you can access `https://chat.qwen.ai` from the same machine/network.
- If you are behind a proxy, set it via `qwen --proxy <url>` (or the `proxy` setting in `settings.json`).
- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` as described above.
- **Issue: Unable to display UI after authentication failure**
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:

2
package-lock.json generated
View File

@@ -18593,7 +18593,7 @@
},
"packages/sdk-typescript": {
"name": "@qwen-code/sdk",
"version": "0.1.2",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -55,7 +55,6 @@ import { disableExtension } from './extension.js';
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
import {
getSettingsWarnings,
loadSettings,
USER_SETTINGS_PATH, // This IS the mocked path.
getSystemSettingsPath,
@@ -419,86 +418,6 @@ describe('Settings Loading and Merging', () => {
});
});
it('should warn about ignored legacy keys in a v2 settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
usageStatisticsEnabled: false,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(getSettingsWarnings(settings)).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Legacy setting 'usageStatisticsEnabled' will be ignored",
),
]),
);
expect(getSettingsWarnings(settings)).toEqual(
expect.arrayContaining([
expect.stringContaining("'privacy.usageStatisticsEnabled'"),
]),
);
});
it('should warn about unknown top-level keys in a v2 settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
someUnknownKey: 'value',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(getSettingsWarnings(settings)).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Unknown setting 'someUnknownKey' will be ignored",
),
]),
);
});
it('should not warn for valid v2 container keys', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
model: { name: 'qwen-coder' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(getSettingsWarnings(settings)).toEqual([]);
});
it('should rewrite allowedTools to tools.allowed during migration', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,

View File

@@ -344,97 +344,6 @@ const KNOWN_V2_CONTAINERS = new Set(
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
);
function getSettingsFileKeyWarnings(
settings: Record<string, unknown>,
settingsFilePath: string,
): string[] {
const version = settings[SETTINGS_VERSION_KEY];
if (typeof version !== 'number' || version < SETTINGS_VERSION) {
return [];
}
const warnings: string[] = [];
const ignoredLegacyKeys = new Set<string>();
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
if (oldKey === newPath) {
continue;
}
if (!(oldKey in settings)) {
continue;
}
const oldValue = settings[oldKey];
// If this key is a V2 container (like 'model') and it's already an object,
// it's likely already in V2 format. Don't warn.
if (
KNOWN_V2_CONTAINERS.has(oldKey) &&
typeof oldValue === 'object' &&
oldValue !== null &&
!Array.isArray(oldValue)
) {
continue;
}
ignoredLegacyKeys.add(oldKey);
warnings.push(
`⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
);
}
// Unknown top-level keys.
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
for (const key of Object.keys(settings)) {
if (key === SETTINGS_VERSION_KEY) {
continue;
}
if (ignoredLegacyKeys.has(key)) {
continue;
}
if (schemaKeys.has(key)) {
continue;
}
warnings.push(
`⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
);
}
return warnings;
}
/**
* Collects warnings for ignored legacy and unknown settings keys.
*
* For `$version: 2` settings files, we do not apply implicit migrations.
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
*/
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
const warningSet = new Set<string>();
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const settingsFile = loadedSettings.forScope(scope);
if (settingsFile.rawJson === undefined) {
continue; // File not present / not loaded.
}
const settingsObject = settingsFile.originalSettings as unknown as Record<
string,
unknown
>;
for (const warning of getSettingsFileKeyWarnings(
settingsObject,
settingsFile.path,
)) {
warningSet.add(warning);
}
}
return [...warningSet];
}
export function migrateSettingsToV1(
v2Settings: Record<string, unknown>,
): Record<string, unknown> {

View File

@@ -434,16 +434,6 @@ const SETTINGS_SCHEMA = {
'Show welcome back dialog when returning to a project with conversation history.',
showInDialog: true,
},
enableUserFeedback: {
type: 'boolean',
label: 'Enable User Feedback',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Show optional feedback dialog after conversations to help improve Qwen performance.',
showInDialog: true,
},
accessibility: {
type: 'object',
label: 'Accessibility',

View File

@@ -17,11 +17,7 @@ import * as cliConfig from './config/config.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { ExtensionStorage, loadExtensions } from './config/extension.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import {
getSettingsWarnings,
loadSettings,
migrateDeprecatedSettings,
} from './config/settings.js';
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
import {
initializeApp,
type InitializationResult,
@@ -404,15 +400,12 @@ export async function main() {
let input = config.getQuestion();
const startupWarnings = [
...new Set([
...(await getStartupWarnings()),
...(await getUserStartupWarnings({
workspaceRoot: process.cwd(),
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
})),
...getSettingsWarnings(settings),
]),
...(await getStartupWarnings()),
...(await getUserStartupWarnings({
workspaceRoot: process.cwd(),
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
})),
];
// Render UI, passing necessary config values. Check that there is no command line question.

View File

@@ -289,12 +289,6 @@ export default {
'Show Citations': 'Quellenangaben anzeigen',
'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche',
'Enable Welcome Back': 'Willkommen-zurück aktivieren',
'Enable User Feedback': 'Benutzerfeedback aktivieren',
'How is Qwen doing this session? (optional)':
'Wie macht sich Qwen in dieser Sitzung? (optional)',
Bad: 'Schlecht',
Good: 'Gut',
'Not Sure Yet': 'Noch nicht sicher',
'Disable Loading Phrases': 'Ladesprüche deaktivieren',
'Screen Reader Mode': 'Bildschirmleser-Modus',
'IDE Mode': 'IDE-Modus',

View File

@@ -286,12 +286,6 @@ export default {
'Show Citations': 'Show Citations',
'Custom Witty Phrases': 'Custom Witty Phrases',
'Enable Welcome Back': 'Enable Welcome Back',
'Enable User Feedback': 'Enable User Feedback',
'How is Qwen doing this session? (optional)':
'How is Qwen doing this session? (optional)',
Bad: 'Bad',
Good: 'Good',
'Not Sure Yet': 'Not Sure Yet',
'Disable Loading Phrases': 'Disable Loading Phrases',
'Screen Reader Mode': 'Screen Reader Mode',
'IDE Mode': 'IDE Mode',

View File

@@ -289,12 +289,6 @@ export default {
'Show Citations': 'Показывать цитаты',
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
'Enable Welcome Back': 'Включить приветствие при возврате',
'Enable User Feedback': 'Включить отзывы пользователей',
'How is Qwen doing this session? (optional)':
'Как дела у Qwen в этой сессии? (необязательно)',
Bad: 'Плохо',
Good: 'Хорошо',
'Not Sure Yet': 'Пока не уверен',
'Disable Loading Phrases': 'Отключить фразы при загрузке',
'Screen Reader Mode': 'Режим программы чтения с экрана',
'IDE Mode': 'Режим IDE',

View File

@@ -277,11 +277,6 @@ export default {
'Show Citations': '显示引用',
'Custom Witty Phrases': '自定义诙谐短语',
'Enable Welcome Back': '启用欢迎回来',
'Enable User Feedback': '启用用户反馈',
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
Bad: '不满意',
Good: '满意',
'Not Sure Yet': '暂不评价',
'Disable Loading Phrases': '禁用加载短语',
'Screen Reader Mode': '屏幕阅读器模式',
'IDE Mode': 'IDE 模式',

View File

@@ -45,7 +45,6 @@ import process from 'node:process';
import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
@@ -1174,19 +1173,6 @@ export const AppContainer = (props: AppContainerProps) => {
const nightly = props.version.includes('nightly');
const {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
settings,
streamingState,
history: historyManager.history,
sessionStats,
});
const dialogsVisible =
showWelcomeBackDialog ||
showWorkspaceMigrationDialog ||
@@ -1208,8 +1194,7 @@ export const AppContainer = (props: AppContainerProps) => {
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen ||
isApprovalModeDialogOpen ||
isResumeDialogOpen ||
isFeedbackDialogOpen;
isResumeDialogOpen;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -1307,8 +1292,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
}),
[
isThemeDialogOpen,
@@ -1399,8 +1382,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
],
);
@@ -1441,10 +1422,6 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
}),
[
handleThemeSelect,
@@ -1480,10 +1457,6 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
],
);

View File

@@ -1,51 +0,0 @@
import { Box, Text } from 'ink';
import type React from 'react';
import { t } from '../i18n/index.js';
import { useUIActions } from './contexts/UIActionsContext.js';
import { useUIState } from './contexts/UIStateContext.js';
import { useKeypress } from './hooks/useKeypress.js';
export const FeedbackDialog: React.FC = () => {
const uiState = useUIState();
const uiActions = useUIActions();
useKeypress(
(key) => {
if (key.name === 'escape') {
uiActions.closeFeedbackDialog();
} else if (key.name === '1') {
uiActions.submitFeedback(1);
} else if (key.name === '2') {
uiActions.submitFeedback(2);
} else if (key.name === '3') {
uiActions.submitFeedback(3);
} else if (key.name === '0') {
uiActions.closeFeedbackDialog();
}
},
{ isActive: uiState.isFeedbackDialogOpen },
);
if (!uiState.isFeedbackDialogOpen) {
return null;
}
return (
<Box flexDirection="column" marginY={1}>
<Box>
<Text color="cyan"> </Text>
<Text bold>{t('How is Qwen doing this session? (optional)')}</Text>
</Box>
<Box marginTop={1}>
<Text color="cyan">1: </Text>
<Text>{t('Good')}</Text>
<Text> </Text>
<Text color="cyan">2: </Text>
<Text>{t('Bad')}</Text>
<Text> </Text>
<Text color="cyan">3: </Text>
<Text>{t('Not Sure Yet')}</Text>
</Box>
</Box>
);
};

View File

@@ -35,7 +35,6 @@ import { ModelSwitchDialog } from './ModelSwitchDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import { SessionPicker } from './SessionPicker.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -292,9 +291,5 @@ export const DialogManager = ({
);
}
if (uiState.isFeedbackDialogOpen) {
return <FeedbackDialog />;
}
return null;
};

View File

@@ -66,10 +66,6 @@ export interface UIActions {
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
// Feedback dialog
openFeedbackDialog: () => void;
closeFeedbackDialog: () => void;
submitFeedback: (rating: number) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);

View File

@@ -126,8 +126,6 @@ export interface UIState {
// Subagent dialogs
isSubagentCreateDialogOpen: boolean;
isAgentsManagerDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);

View File

@@ -1,173 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import {
type Config,
logUserFeedback,
UserFeedbackEvent,
type UserFeedbackRating,
} from '@qwen-code/qwen-code-core';
import { StreamingState, MessageType, type HistoryItem } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
const MIN_TOOL_CALLS = 10; // Minimum tool calls to show feedback dialog
const MIN_USER_MESSAGES = 5; // Minimum user messages to show feedback dialog
/**
* Check if there's an AI response after the last user message in the conversation history
*/
const hasAIResponseAfterLastUserMessage = (history: HistoryItem[]): boolean => {
// Find the last user message
let lastUserMessageIndex = -1;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].type === MessageType.USER) {
lastUserMessageIndex = i;
break;
}
}
// Check if there's any AI response (GEMINI message) after the last user message
if (lastUserMessageIndex !== -1) {
for (let i = lastUserMessageIndex + 1; i < history.length; i++) {
if (history[i].type === MessageType.GEMINI) {
return true;
}
}
}
return false;
};
/**
* Count the number of user messages in the conversation history
*/
const countUserMessages = (history: HistoryItem[]): number =>
history.filter((item) => item.type === MessageType.USER).length;
export interface UseFeedbackDialogProps {
config: Config;
settings: LoadedSettings;
streamingState: StreamingState;
history: HistoryItem[];
sessionStats: SessionStatsState;
}
export const useFeedbackDialog = ({
config,
settings,
streamingState,
history,
sessionStats,
}: UseFeedbackDialogProps) => {
// Feedback dialog state
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const [feedbackShownForSession, setFeedbackShownForSession] = useState(false);
const openFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(true);
setFeedbackShownForSession(true);
}, []);
const closeFeedbackDialog = useCallback(
() => setIsFeedbackDialogOpen(false),
[],
);
const submitFeedback = useCallback(
(rating: number) => {
// Calculate session duration and turn count
const sessionDurationMs =
Date.now() - sessionStats.sessionStartTime.getTime();
let lastUserMessageIndex = -1;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].type === MessageType.USER) {
lastUserMessageIndex = i;
break;
}
}
const turnCount =
lastUserMessageIndex === -1 ? 0 : history.length - lastUserMessageIndex;
// Create and log the feedback event
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
sessionDurationMs,
turnCount,
config.getModel(),
config.getApprovalMode(),
);
logUserFeedback(config, feedbackEvent);
closeFeedbackDialog();
},
[config, sessionStats, history, closeFeedbackDialog],
);
// Track when to show feedback dialog
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (streamingState === StreamingState.Idle && history.length > 0) {
const hasAIResponseAfterLastUser =
hasAIResponseAfterLastUserMessage(history);
const sessionDurationMs =
Date.now() - sessionStats.sessionStartTime.getTime();
// Get tool calls count and user messages count
const toolCallsCount = sessionStats.metrics.tools.totalCalls;
const userMessagesCount = countUserMessages(history);
// Check if the session meets the minimum requirements:
// Either tool calls > 10 OR user messages > 5
const meetsMinimumRequirements =
toolCallsCount > MIN_TOOL_CALLS ||
userMessagesCount > MIN_USER_MESSAGES;
// Show feedback dialog if:
// 1. Telemetry is enabled (required for feedback submission)
// 2. User feedback is enabled in settings
// 3. There's an AI response after the last user message (real AI conversation)
// 4. Session duration > 10 seconds (meaningful interaction)
// 5. Not already shown for this session
// 6. Random chance (25% probability)
// 7. Meets minimum requirements (tool calls > 10 OR user messages > 5)
if (
config.getUsageStatisticsEnabled() && // Only show if telemetry is enabled
settings.merged.ui?.enableUserFeedback !== false && // Default to true if not set
hasAIResponseAfterLastUser &&
sessionDurationMs > 10000 && // 10 seconds minimum for meaningful interaction
!feedbackShownForSession &&
Math.random() < FEEDBACK_SHOW_PROBABILITY &&
meetsMinimumRequirements
) {
timeoutId = setTimeout(() => {
openFeedbackDialog();
}, 1000); // Delay to ensure user has time to see the completion
}
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [
streamingState,
history,
sessionStats,
isFeedbackDialogOpen,
feedbackShownForSession,
openFeedbackDialog,
settings.merged.ui?.enableUserFeedback,
config,
]);
return {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
};
};

View File

@@ -8,6 +8,7 @@ import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { quote, parse } from 'shell-quote';
import {
@@ -49,16 +50,16 @@ const BUILTIN_SEATBELT_PROFILES = [
/**
* Determines whether the sandbox container should be run with the current user's UID and GID.
* This is often necessary on Linux systems when using rootful Docker without userns-remap
* configured, to avoid permission issues with
* This is often necessary on Linux systems (especially Debian/Ubuntu based) when using
* rootful Docker without userns-remap configured, to avoid permission issues with
* mounted volumes.
*
* The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable:
* - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`.
* - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`.
* - If `SANDBOX_SET_UID_GID` is not set:
* - On Linux, it defaults to `true`.
* - On other OSes, it defaults to `false`.
* - On Debian/Ubuntu Linux, it defaults to `true`.
* - On other OSes, or if OS detection fails, it defaults to `false`.
*
* For more context on running Docker containers as non-root, see:
* https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15
@@ -75,20 +76,31 @@ async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
return false;
}
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
if (os.platform() === 'linux') {
const debugEnv = [process.env['DEBUG'], process.env['DEBUG_MODE']].some(
(v) => v === 'true' || v === '1',
);
if (debugEnv) {
// Use stderr so it doesn't clutter normal STDOUT output (e.g. in `--prompt` runs).
console.error(
'INFO: Using current user UID/GID in Linux sandbox. Set SANDBOX_SET_UID_GID=false to disable.',
try {
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
if (
osReleaseContent.includes('ID=debian') ||
osReleaseContent.includes('ID=ubuntu') ||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
) {
// note here and below we use console.error for informational messages on stderr
console.error(
'INFO: Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
);
return true;
}
} catch (_err) {
// Silently ignore if /etc/os-release is not found or unreadable.
// The default (false) will be applied in this case.
console.warn(
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
);
}
return true;
}
return false;
return false; // Default to false if no other condition is met
}
// docker does not allow container names to contain ':' or '/', so we

View File

@@ -16,8 +16,6 @@ import {
isDeviceTokenPending,
isDeviceTokenSuccess,
isErrorResponse,
qwenOAuth2Events,
QwenOAuth2Event,
QwenOAuth2Client,
type DeviceAuthorizationResponse,
type DeviceTokenResponse,
@@ -847,58 +845,6 @@ describe('getQwenOAuthClient', () => {
SharedTokenManager.getInstance = originalGetInstance;
});
it('should include troubleshooting hints when device auth fetch fails', async () => {
// Make SharedTokenManager fail so we hit the fallback device-flow path
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('Token refresh failed')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
const tlsCause = new Error('unable to verify the first certificate');
(tlsCause as Error & { code?: string }).code =
'UNABLE_TO_VERIFY_LEAF_SIGNATURE';
const fetchError = new TypeError('fetch failed') as TypeError & {
cause?: unknown;
};
fetchError.cause = tlsCause;
vi.mocked(global.fetch).mockRejectedValue(fetchError);
const emitSpy = vi.spyOn(qwenOAuth2Events, 'emit');
let thrownError: unknown;
try {
const { getQwenOAuthClient } = await import('./qwenOAuth2.js');
await getQwenOAuthClient(mockConfig);
} catch (error: unknown) {
thrownError = error;
}
expect(thrownError).toBeInstanceOf(Error);
expect((thrownError as Error).message).toContain(
'Device authorization flow failed: fetch failed',
);
expect((thrownError as Error).message).toContain(
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
);
expect((thrownError as Error).message).toContain('NODE_EXTRA_CA_CERTS');
expect((thrownError as Error).message).toContain('--proxy');
expect(emitSpy).toHaveBeenCalledWith(
QwenOAuth2Event.AuthProgress,
'error',
expect.stringContaining('NODE_EXTRA_CA_CERTS'),
);
emitSpy.mockRestore();
SharedTokenManager.getInstance = originalGetInstance;
});
});
describe('CredentialsClearRequiredError', () => {

View File

@@ -13,7 +13,6 @@ import open from 'open';
import { EventEmitter } from 'events';
import type { Config } from '../config/config.js';
import { randomUUID } from 'node:crypto';
import { formatFetchErrorForUser } from '../utils/fetch.js';
import {
SharedTokenManager,
TokenManagerError,
@@ -848,12 +847,8 @@ async function authWithQwenDeviceFlow(
console.error('\n' + timeoutMessage);
return { success: false, reason: 'timeout', message: timeoutMessage };
} catch (error: unknown) {
const fullErrorMessage = formatFetchErrorForUser(error, {
url: QWEN_OAUTH_BASE_URL,
});
const message = `Device authorization flow failed: ${fullErrorMessage}`;
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
const errorMessage = error instanceof Error ? error.message : String(error);
const message = `Device authorization flow failed: ${errorMessage}`;
console.error(message);
return { success: false, reason: 'error', message };
} finally {

View File

@@ -818,7 +818,7 @@ describe('ShellExecutionService child_process fallback', () => {
});
describe('Platform-Specific Behavior', () => {
it('should use cmd.exe and hide window on Windows', async () => {
it('should use cmd.exe on Windows', async () => {
mockPlatform.mockReturnValue('win32');
await simulateExecution('dir "foo bar"', (cp) =>
cp.emit('exit', 0, null),
@@ -829,8 +829,7 @@ describe('ShellExecutionService child_process fallback', () => {
[],
expect.objectContaining({
shell: true,
detached: false,
windowsHide: true,
detached: true,
}),
);
});

View File

@@ -229,8 +229,7 @@ export class ShellExecutionService {
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: true,
shell: isWindows ? true : 'bash',
detached: !isWindows,
windowsHide: isWindows,
detached: true,
env: {
...process.env,
QWEN_CODE: '1',

View File

@@ -35,7 +35,6 @@ export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch';
export const EVENT_AUTH = 'qwen-code.auth';
export const EVENT_USER_FEEDBACK = 'qwen-code.user_feedback';
// Performance Events
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';

View File

@@ -45,7 +45,6 @@ export {
logNextSpeakerCheck,
logAuth,
logSkillLaunch,
logUserFeedback,
} from './loggers.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
export {
@@ -66,8 +65,6 @@ export {
NextSpeakerCheckEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
UserFeedbackRating,
} from './types.js';
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
export type { TelemetryEvent } from './types.js';

View File

@@ -38,7 +38,6 @@ import {
EVENT_INVALID_CHUNK,
EVENT_AUTH,
EVENT_SKILL_LAUNCH,
EVENT_USER_FEEDBACK,
} from './constants.js';
import {
recordApiErrorMetrics,
@@ -87,7 +86,6 @@ import type {
InvalidChunkEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
} from './types.js';
import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js';
@@ -889,32 +887,3 @@ export function logSkillLaunch(config: Config, event: SkillLaunchEvent): void {
};
logger.emit(logRecord);
}
export function logUserFeedback(
config: Config,
event: UserFeedbackEvent,
): void {
const uiEvent = {
...event,
'event.name': EVENT_USER_FEEDBACK,
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent);
QwenLogger.getInstance(config)?.logUserFeedbackEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_USER_FEEDBACK,
'event.timestamp': new Date().toISOString(),
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `User feedback: Rating ${event.rating} for session ${event.session_id}. Turn count: ${event.turn_count}. Duration: ${event.session_duration_ms}ms.`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -39,7 +39,6 @@ import type {
ExtensionDisableEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
RipgrepFallbackEvent,
EndSessionEvent,
} from '../types.js';
@@ -843,23 +842,6 @@ export class QwenLogger {
this.flushIfNeeded();
}
logUserFeedbackEvent(event: UserFeedbackEvent): void {
const rumEvent = this.createActionEvent('user', 'user_feedback', {
properties: {
session_id: event.session_id,
rating: event.rating,
session_duration_ms: event.session_duration_ms,
turn_count: event.turn_count,
model: event.model,
approval_mode: event.approval_mode,
prompt_id: event.prompt_id || '',
},
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logChatCompressionEvent(event: ChatCompressionEvent): void {
const rumEvent = this.createActionEvent('misc', 'chat_compression', {
properties: {

View File

@@ -757,44 +757,6 @@ export class SkillLaunchEvent implements BaseTelemetryEvent {
}
}
export enum UserFeedbackRating {
BAD = 1,
FINE = 2,
GOOD = 3,
}
export class UserFeedbackEvent implements BaseTelemetryEvent {
'event.name': 'user_feedback';
'event.timestamp': string;
session_id: string;
rating: UserFeedbackRating;
session_duration_ms: number;
turn_count: number;
model: string;
approval_mode: string;
prompt_id?: string;
constructor(
session_id: string,
rating: UserFeedbackRating,
session_duration_ms: number,
turn_count: number,
model: string,
approval_mode: string,
prompt_id?: string,
) {
this['event.name'] = 'user_feedback';
this['event.timestamp'] = new Date().toISOString();
this.session_id = session_id;
this.rating = rating;
this.session_duration_ms = session_duration_ms;
this.turn_count = turn_count;
this.model = model;
this.approval_mode = approval_mode;
this.prompt_id = prompt_id;
}
}
export type TelemetryEvent =
| StartSessionEvent
| EndSessionEvent
@@ -824,8 +786,7 @@ export type TelemetryEvent =
| ToolOutputTruncatedEvent
| ModelSlashCommandEvent
| AuthEvent
| SkillLaunchEvent
| UserFeedbackEvent;
| SkillLaunchEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable';

View File

@@ -1,52 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { FetchError, formatFetchErrorForUser } from './fetch.js';
describe('formatFetchErrorForUser', () => {
it('includes troubleshooting hints for TLS errors', () => {
const tlsCause = new Error('unable to verify the first certificate');
(tlsCause as Error & { code?: string }).code =
'UNABLE_TO_VERIFY_LEAF_SIGNATURE';
const fetchError = new TypeError('fetch failed') as TypeError & {
cause?: unknown;
};
fetchError.cause = tlsCause;
const message = formatFetchErrorForUser(fetchError, {
url: 'https://chat.qwen.ai',
});
expect(message).toContain('fetch failed');
expect(message).toContain('UNABLE_TO_VERIFY_LEAF_SIGNATURE');
expect(message).toContain('Troubleshooting:');
expect(message).toContain('Confirm you can reach https://chat.qwen.ai');
expect(message).toContain('--proxy');
expect(message).toContain('NODE_EXTRA_CA_CERTS');
});
it('includes troubleshooting hints for network codes', () => {
const fetchError = new FetchError(
'Request timed out after 100ms',
'ETIMEDOUT',
);
const message = formatFetchErrorForUser(fetchError, {
url: 'https://example.com',
});
expect(message).toContain('Request timed out after 100ms');
expect(message).toContain('Troubleshooting:');
expect(message).toContain('Confirm you can reach https://example.com');
expect(message).toContain('--proxy');
expect(message).not.toContain('NODE_EXTRA_CA_CERTS');
});
it('does not include troubleshooting for non-fetch errors', () => {
expect(formatFetchErrorForUser(new Error('boom'))).toBe('boom');
});
});

View File

@@ -17,26 +17,6 @@ const PRIVATE_IP_RANGES = [
/^fe80:/,
];
const TLS_ERROR_CODES = new Set([
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'SELF_SIGNED_CERT_IN_CHAIN',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'CERT_HAS_EXPIRED',
'ERR_TLS_CERT_ALTNAME_INVALID',
]);
const FETCH_TROUBLESHOOTING_ERROR_CODES = new Set([
...TLS_ERROR_CODES,
'ECONNRESET',
'ETIMEDOUT',
'ECONNREFUSED',
'ENOTFOUND',
'EAI_AGAIN',
'EHOSTUNREACH',
'ENETUNREACH',
]);
export class FetchError extends Error {
constructor(
message: string,
@@ -75,118 +55,3 @@ export async function fetchWithTimeout(
clearTimeout(timeoutId);
}
}
function getErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object') {
return undefined;
}
if (
'code' in error &&
typeof (error as Record<string, unknown>)['code'] === 'string'
) {
return (error as Record<string, string>)['code'];
}
return undefined;
}
function formatUnknownErrorMessage(error: unknown): string | undefined {
if (typeof error === 'string') {
return error;
}
if (
typeof error === 'number' ||
typeof error === 'boolean' ||
typeof error === 'bigint'
) {
return String(error);
}
if (error instanceof Error) {
return error.message;
}
if (!error || typeof error !== 'object') {
return undefined;
}
const message = (error as Record<string, unknown>)['message'];
if (typeof message === 'string') {
return message;
}
return undefined;
}
function formatErrorCause(error: unknown): string | undefined {
if (!(error instanceof Error)) {
return undefined;
}
const cause = (error as Error & { cause?: unknown }).cause;
if (!cause) {
return undefined;
}
const causeCode = getErrorCode(cause);
const causeMessage = formatUnknownErrorMessage(cause);
if (!causeCode && !causeMessage) {
return undefined;
}
if (causeCode && causeMessage && !causeMessage.includes(causeCode)) {
return `${causeCode}: ${causeMessage}`;
}
return causeMessage ?? causeCode;
}
export function formatFetchErrorForUser(
error: unknown,
options: { url?: string } = {},
): string {
const errorMessage = getErrorMessage(error);
const code =
error instanceof Error
? (getErrorCode((error as Error & { cause?: unknown }).cause) ??
getErrorCode(error))
: getErrorCode(error);
const cause = formatErrorCause(error);
const fullErrorMessage = [
errorMessage,
cause ? `(cause: ${cause})` : undefined,
]
.filter(Boolean)
.join(' ');
const shouldShowFetchHints =
errorMessage.toLowerCase().includes('fetch failed') ||
(code != null && FETCH_TROUBLESHOOTING_ERROR_CODES.has(code));
const shouldShowTlsHint = code != null && TLS_ERROR_CODES.has(code);
if (!shouldShowFetchHints) {
return fullErrorMessage;
}
const hintLines = [
'',
'Troubleshooting:',
...(options.url
? [`- Confirm you can reach ${options.url} from this machine.`]
: []),
'- If you are behind a proxy, pass `--proxy <url>` (or set `proxy` in settings).',
...(shouldShowTlsHint
? [
'- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` to your CA bundle.',
]
: []),
];
return `${fullErrorMessage}${hintLines.join('\n')}`;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/sdk",
"version": "0.1.2",
"version": "0.1.0",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",

View File

@@ -5,7 +5,7 @@
import type { SDKUserMessage } from '../types/protocol.js';
import { serializeJsonLine } from '../utils/jsonLines.js';
import { ProcessTransport } from '../transport/ProcessTransport.js';
import { prepareSpawnInfo, type SpawnInfo } from '../utils/cliPath.js';
import { parseExecutableSpec } from '../utils/cliPath.js';
import { Query } from './Query.js';
import type { QueryOptions } from '../types/types.js';
import { QueryOptionsSchema } from '../types/queryOptionsSchema.js';
@@ -32,17 +32,17 @@ export function query({
*/
options?: QueryOptions;
}): Query {
const spawnInfo = validateOptions(options);
const parsedExecutable = validateOptions(options);
const isSingleTurn = typeof prompt === 'string';
const pathToQwenExecutable = options.pathToQwenExecutable;
const pathToQwenExecutable =
options.pathToQwenExecutable ?? parsedExecutable.executablePath;
const abortController = options.abortController ?? new AbortController();
const transport = new ProcessTransport({
pathToQwenExecutable,
spawnInfo,
cwd: options.cwd,
model: options.model,
permissionMode: options.permissionMode,
@@ -97,7 +97,9 @@ export function query({
return queryInstance;
}
function validateOptions(options: QueryOptions): SpawnInfo | undefined {
function validateOptions(
options: QueryOptions,
): ReturnType<typeof parseExecutableSpec> {
const validationResult = QueryOptionsSchema.safeParse(options);
if (!validationResult.success) {
const errors = validationResult.error.errors
@@ -106,10 +108,13 @@ function validateOptions(options: QueryOptions): SpawnInfo | undefined {
throw new Error(`Invalid QueryOptions: ${errors}`);
}
let parsedExecutable: ReturnType<typeof parseExecutableSpec>;
try {
return prepareSpawnInfo(options.pathToQwenExecutable);
parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
}
return parsedExecutable;
}

View File

@@ -44,9 +44,7 @@ export class ProcessTransport implements Transport {
const cwd = this.options.cwd ?? process.cwd();
const env = { ...process.env, ...this.options.env };
const spawnInfo =
this.options.spawnInfo ??
prepareSpawnInfo(this.options.pathToQwenExecutable);
const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable);
const stderrMode =
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
@@ -142,7 +140,6 @@ export class ProcessTransport implements Transport {
'--output-format',
'stream-json',
'--channel=SDK',
'--experimental-skills',
];
if (this.options.model) {

View File

@@ -4,13 +4,11 @@ import type {
SubagentConfig,
SDKMcpServerConfig,
} from './protocol.js';
import type { SpawnInfo } from '../utils/cliPath.js';
export type { PermissionMode };
export type TransportOptions = {
pathToQwenExecutable?: string;
spawnInfo?: SpawnInfo;
pathToQwenExecutable: string;
cwd?: string;
model?: string;
permissionMode?: PermissionMode;
@@ -179,25 +177,32 @@ export interface QueryOptions {
model?: string;
/**
* Path to the Qwen CLI executable.
*
* If not provided, the SDK automatically uses the bundled CLI included in the package.
* Path to the Qwen CLI executable or runtime specification.
*
* Supports multiple formats:
* - Command name (no path separators): `'qwen'` -> executes from PATH
* - JavaScript file: `'/path/to/cli.js'` -> uses Node.js (or Bun if running under Bun)
* - TypeScript file: `'/path/to/index.ts'` -> uses tsx if available (silent support for dev/debug)
* - Native binary: `'/path/to/qwen'` -> executes directly
* - 'qwen' -> native binary (auto-detected from PATH)
* - '/path/to/qwen' -> native binary (explicit path)
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
* - 'bun:/path/to/cli.js' -> Force Bun runtime
* - 'node:/path/to/cli.js' -> Force Node.js runtime
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
*
* Runtime detection:
* - `.js/.mjs/.cjs` files: Node.js (or Bun if running under Bun)
* - `.ts/.tsx` files: tsx if available, otherwise treated as native
* - Command names: executed directly from PATH
* - Other files: executed as native binaries
* If not provided, the SDK will auto-detect the native binary in this order:
* 1. QWEN_CODE_CLI_PATH environment variable
* 2. ~/.volta/bin/qwen
* 3. ~/.npm-global/bin/qwen
* 4. /usr/local/bin/qwen
* 5. ~/.local/bin/qwen
* 6. ~/node_modules/.bin/qwen
* 7. ~/.yarn/bin/qwen
*
* The .ts files are only supported for debugging purposes.
*
* @example '/path/to/cli.js'
* @example 'qwen'
* @example './packages/cli/index.ts'
* @example '/usr/local/bin/qwen'
* @example 'tsx:/path/to/packages/cli/src/index.ts'
*/
pathToQwenExecutable?: string;

View File

@@ -1,29 +1,28 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* CLI path resolution and subprocess spawning utilities
* CLI path auto-detection and subprocess spawning utilities
*
* Supports multiple execution modes:
* 1. Bundled CLI: Node.js bundle included in the SDK package (default)
* 2. Node.js bundle: 'node /path/to/cli.js' (custom path)
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
/**
* Executable types supported by the SDK
*/
export type ExecutableType = 'node' | 'bun' | 'tsx' | 'native';
export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno';
/**
* Spawn information for CLI process
*/
export type SpawnInfo = {
/** Command to execute (e.g., 'node', 'bun', 'tsx', or native binary path) */
/** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */
command: string;
/** Arguments to pass to command */
args: string[];
@@ -33,243 +32,49 @@ export type SpawnInfo = {
originalInput: string;
};
/**
* Get the directory containing the current module (ESM or CJS)
*/
function getCurrentModuleDir(): string {
let moduleDir: string | null = null;
try {
if (typeof import.meta !== 'undefined' && import.meta.url) {
moduleDir = path.dirname(fileURLToPath(import.meta.url));
}
} catch {
// Fall through to CJS
}
if (!moduleDir) {
try {
if (typeof __dirname !== 'undefined') {
moduleDir = __dirname;
}
} catch {
// Fall through
}
}
if (moduleDir) {
return path.normalize(moduleDir);
}
throw new Error('Cannot find module directory.');
}
/**
* Find the SDK package root directory
*/
function findSdkPackageRoot(): string | null {
try {
const require = createRequire(import.meta.url);
const packageJsonPath = require.resolve('@qwen-code/sdk/package.json');
const packageRoot = path.dirname(packageJsonPath);
const cliPath = path.join(packageRoot, 'dist', 'cli', 'cli.js');
if (fs.existsSync(cliPath)) {
return packageRoot;
}
} catch {
// Continue to fallback strategy
}
const currentDir = getCurrentModuleDir();
let dir = currentDir;
const root = path.parse(dir).root;
let bestMatch: string | null = null;
while (dir !== root) {
const packageJsonPath = path.join(dir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const cliPath = path.join(dir, 'dist', 'cli', 'cli.js');
if (fs.existsSync(cliPath)) {
try {
const packageJson = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf-8'),
);
if (packageJson.name === '@qwen-code/sdk') {
return dir;
}
if (!bestMatch) {
bestMatch = dir;
}
} catch {
if (!bestMatch) {
bestMatch = dir;
}
}
}
}
dir = path.dirname(dir);
}
return bestMatch;
}
/**
* Normalize path separators for regex matching
*/
function normalizeForRegex(dirPath: string): string {
return dirPath.replace(/\\/g, '/');
}
/**
* Resolve bundled CLI using import.meta.url relative path
*/
function tryResolveCliFromImportMeta(): string | null {
try {
if (typeof import.meta !== 'undefined' && import.meta.url) {
const cliUrl = new URL('./cli/cli.js', import.meta.url);
const cliPath = fileURLToPath(cliUrl);
if (fs.existsSync(cliPath)) {
return cliPath;
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Get all candidate paths for the bundled CLI
*/
function getBundledCliCandidatePaths(): string[] {
const candidates: string[] = [];
const importMetaResolved = tryResolveCliFromImportMeta();
if (importMetaResolved) {
candidates.push(importMetaResolved);
}
try {
const currentDir = getCurrentModuleDir();
const normalizedDir = normalizeForRegex(currentDir);
candidates.push(path.join(currentDir, 'cli', 'cli.js'));
if (/\/src\/utils$/.test(normalizedDir)) {
const packageRoot = path.dirname(path.dirname(currentDir));
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
}
const packageRoot = findSdkPackageRoot();
if (packageRoot) {
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
}
const monorepoMatch = normalizedDir.match(
/^(.+?)\/packages\/sdk-typescript/,
);
if (monorepoMatch && monorepoMatch[1]) {
const monorepoRoot =
process.platform === 'win32'
? monorepoMatch[1].replace(/\//g, '\\')
: monorepoMatch[1];
candidates.push(path.join(monorepoRoot, 'dist', 'cli.js'));
}
} catch {
const packageRoot = findSdkPackageRoot();
if (packageRoot) {
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
}
}
return candidates;
}
/**
* Find the bundled CLI path
*/
function getBundledCliPath(): string | null {
const candidates = getBundledCliCandidatePaths();
try {
const currentFile =
typeof __filename !== 'undefined'
? __filename
: fileURLToPath(import.meta.url);
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
const currentDir = path.dirname(currentFile);
const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
if (fs.existsSync(bundledCliPath)) {
return bundledCliPath;
}
}
return null;
return null;
} catch {
return null;
}
}
/**
* Find the bundled CLI path or throw error
*/
export function findBundledCliPath(): string {
export function findNativeCliPath(): string {
const bundledCli = getBundledCliPath();
if (bundledCli) {
return bundledCli;
}
const candidates = getBundledCliCandidatePaths();
throw new Error(
'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' +
'Searched locations:\n' +
candidates.map((c) => ` - ${c}`).join('\n') +
'\n\nIf you need to use a custom CLI, provide explicit path:\n' +
' • query({ pathToQwenExecutable: "/path/to/cli.js" })',
'If you need to use a custom CLI, provide explicit executable:\n' +
' • query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' +
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
);
}
/**
* Validate file exists and is a file
*/
function validateFilePath(filePath: string): void {
if (!fs.existsSync(filePath)) {
throw new Error(
`Executable file not found at '${filePath}'. ` +
'Please check the file path and ensure the file exists.',
);
}
const stats = fs.statSync(filePath);
if (!stats.isFile()) {
throw new Error(
`Path '${filePath}' exists but is not a file. ` +
'Please provide a path to an executable file.',
);
}
}
/**
* Check if path contains separators (file path vs command name)
*/
function isFilePath(spec: string): boolean {
return spec.includes('/') || spec.includes('\\');
}
/**
* Check if file is JavaScript
*/
function isJavaScriptFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return ['.js', '.mjs', '.cjs'].includes(ext);
}
/**
* Check if file is TypeScript
*/
function isTypeScriptFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return ['.ts', '.tsx'].includes(ext);
}
/**
* Check if command is available in PATH
*/
function isCommandAvailable(command: string): boolean {
try {
// Use 'which' on Unix-like systems, 'where' on Windows
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
execSync(`${whichCommand} ${command}`, {
stdio: 'ignore',
timeout: 1000,
timeout: 5000, // 5 second timeout
});
return true;
} catch {
@@ -277,87 +82,245 @@ function isCommandAvailable(command: string): boolean {
}
}
/**
* Check if tsx is available
*/
function isTsxAvailable(): boolean {
return isCommandAvailable('tsx');
function validateRuntimeAvailability(runtime: string): boolean {
// Node.js is always available since we're running in Node.js
if (runtime === 'node') {
return true;
}
// Check if the runtime command is available in PATH
return isCommandAvailable(runtime);
}
function validateFileExtensionForRuntime(
filePath: string,
runtime: string,
): boolean {
const ext = path.extname(filePath).toLowerCase();
switch (runtime) {
case 'node':
case 'bun':
return ['.js', '.mjs', '.cjs'].includes(ext);
case 'tsx':
return ['.ts', '.tsx'].includes(ext);
case 'deno':
return ['.ts', '.tsx', '.js', '.mjs'].includes(ext);
default:
return true; // Unknown runtime, let it pass
}
}
/**
* Get JavaScript runtime type (bun if running under bun, otherwise node)
* Parse executable specification into components with comprehensive validation
*
* Supports multiple formats:
* - 'qwen' -> native binary (auto-detected)
* - '/path/to/qwen' -> native binary (explicit path)
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
*
* Advanced runtime specification (for overriding defaults):
* - 'bun:/path/to/cli.js' -> Force Bun runtime
* - 'node:/path/to/cli.js' -> Force Node.js runtime
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
*
* @param executableSpec - Executable specification
* @returns Parsed executable information
* @throws Error if specification is invalid or files don't exist
*/
function getJsRuntimeType(): 'bun' | 'node' {
export function parseExecutableSpec(executableSpec?: string): {
runtime?: string;
executablePath: string;
isExplicitRuntime: boolean;
} {
if (
typeof process !== 'undefined' &&
'versions' in process &&
'bun' in process.versions
executableSpec === '' ||
(executableSpec && executableSpec.trim() === '')
) {
return 'bun';
}
return 'node';
}
/**
* Prepare spawn information for CLI process
*/
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
if (executableSpec !== undefined && executableSpec.trim() === '') {
throw new Error('Executable path cannot be empty');
throw new Error('Command name cannot be empty');
}
if (executableSpec === undefined) {
const bundledCliPath = findBundledCliPath();
if (!executableSpec) {
return {
command: process.execPath,
args: [bundledCliPath],
type: getJsRuntimeType(),
originalInput: '',
executablePath: findNativeCliPath(),
isExplicitRuntime: false,
};
}
if (!isFilePath(executableSpec)) {
if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) {
throw new Error(
`Invalid command name '${executableSpec}'. ` +
'Command names should only contain letters, numbers, dots, hyphens, and underscores.',
);
}
return {
command: executableSpec,
args: [],
type: 'native',
originalInput: executableSpec,
};
}
// Check for runtime prefix (e.g., 'bun:/path/to/cli.js')
// Use whitelist mechanism: only treat as runtime spec if prefix matches supported runtimes
const supportedRuntimes = ['node', 'bun', 'tsx', 'deno'];
const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/);
const resolvedPath = path.resolve(executableSpec);
validateFilePath(resolvedPath);
if (runtimeMatch) {
const [, runtime, filePath] = runtimeMatch;
if (isJavaScriptFile(resolvedPath)) {
return {
command: process.execPath,
args: [resolvedPath],
type: getJsRuntimeType(),
originalInput: executableSpec,
};
}
// Only process as runtime specification if it matches a supported runtime
if (runtime && supportedRuntimes.includes(runtime)) {
if (!filePath) {
throw new Error(`Invalid runtime specification: '${executableSpec}'`);
}
if (!validateRuntimeAvailability(runtime)) {
throw new Error(
`Runtime '${runtime}' is not available on this system. Please install it first.`,
);
}
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(
`Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` +
'Please check the file path and ensure the file exists.',
);
}
if (!validateFileExtensionForRuntime(resolvedPath, runtime)) {
const ext = path.extname(resolvedPath);
throw new Error(
`File extension '${ext}' is not compatible with runtime '${runtime}'. ` +
`Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`,
);
}
if (isTypeScriptFile(resolvedPath)) {
if (isTsxAvailable()) {
return {
command: 'tsx',
args: [resolvedPath],
type: 'tsx',
originalInput: executableSpec,
runtime,
executablePath: resolvedPath,
isExplicitRuntime: true,
};
}
// If not a supported runtime, fall through to treat as file path (e.g., Windows paths like 'D:\path\to\cli.js')
}
// Check if it's a command name (no path separators) or a file path
const isCommandName =
!executableSpec.includes('/') && !executableSpec.includes('\\');
if (isCommandName) {
// It's a command name like 'qwen' - validate it's a reasonable command name
if (!executableSpec || executableSpec.trim() === '') {
throw new Error('Command name cannot be empty');
}
// Basic validation for command names
if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) {
throw new Error(
`Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`,
);
}
return {
executablePath: executableSpec,
isExplicitRuntime: false,
};
}
// It's a file path - validate and resolve
const resolvedPath = path.resolve(executableSpec);
if (!fs.existsSync(resolvedPath)) {
throw new Error(
`Executable file not found at '${resolvedPath}'. ` +
'Please check the file path and ensure the file exists. ' +
'You can also:\n' +
' • Set QWEN_CODE_CLI_PATH environment variable\n' +
' • Install qwen globally: npm install -g qwen\n' +
' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' +
' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
);
}
// Additional validation for file paths
const stats = fs.statSync(resolvedPath);
if (!stats.isFile()) {
throw new Error(
`Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`,
);
}
return {
command: resolvedPath,
args: [],
type: 'native',
originalInput: executableSpec,
executablePath: resolvedPath,
isExplicitRuntime: false,
};
}
function getExpectedExtensions(runtime: string): string[] {
switch (runtime) {
case 'node':
case 'bun':
return ['.js', '.mjs', '.cjs'];
case 'tsx':
return ['.ts', '.tsx'];
case 'deno':
return ['.ts', '.tsx', '.js', '.mjs'];
default:
return [];
}
}
function detectRuntimeFromExtension(filePath: string): string | undefined {
const ext = path.extname(filePath).toLowerCase();
if (['.js', '.mjs', '.cjs'].includes(ext)) {
// Default to Node.js for JavaScript files
return 'node';
}
if (['.ts', '.tsx'].includes(ext)) {
// Check if tsx is available for TypeScript files
if (isCommandAvailable('tsx')) {
return 'tsx';
}
// If tsx is not available, suggest it in error message
throw new Error(
`TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` +
'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts',
);
}
// Native executable or unknown extension
return undefined;
}
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
const parsed = parseExecutableSpec(executableSpec);
const { runtime, executablePath, isExplicitRuntime } = parsed;
// If runtime is explicitly specified, use it
if (isExplicitRuntime && runtime) {
const runtimeCommand = runtime === 'node' ? process.execPath : runtime;
return {
command: runtimeCommand,
args: [executablePath],
type: runtime as ExecutableType,
originalInput: executableSpec || '',
};
}
// If no explicit runtime, try to detect from file extension
const detectedRuntime = detectRuntimeFromExtension(executablePath);
if (detectedRuntime) {
const runtimeCommand =
detectedRuntime === 'node' ? process.execPath : detectedRuntime;
return {
command: runtimeCommand,
args: [executablePath],
type: detectedRuntime as ExecutableType,
originalInput: executableSpec || '',
};
}
// Native executable or command name - use it directly
return {
command: executablePath,
args: [],
type: 'native',
originalInput: executableSpec || '',
};
}

View File

@@ -1,21 +1,16 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Unit tests for CLI path utilities
* Tests executable detection, parsing, and spawn info preparation
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import {
parseExecutableSpec,
prepareSpawnInfo,
findBundledCliPath,
findNativeCliPath,
} from '../../src/utils/cliPath.js';
// Mock fs module
@@ -26,43 +21,36 @@ const mockFs = vi.mocked(fs);
vi.mock('node:child_process');
const mockExecSync = vi.mocked(execSync);
// Mock process.versions for bun detection
const originalVersions = process.versions;
describe('CLI Path Utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset process.versions
Object.defineProperty(process, 'versions', {
value: { ...originalVersions },
writable: true,
});
// Default: tsx is available (can be overridden in specific tests)
mockExecSync.mockReturnValue(Buffer.from(''));
// Default: mock statSync to return a proper file stat object
mockFs.statSync.mockReturnValue({
isFile: () => true,
} as ReturnType<typeof import('fs').statSync>);
// Default: return true for existsSync (can be overridden in specific tests)
mockFs.existsSync.mockReturnValue(true);
// Default: tsx is available (can be overridden in specific tests)
mockExecSync.mockReturnValue(Buffer.from(''));
});
describe('findBundledCliPath', () => {
it('should find bundled CLI when it exists', () => {
// Mock existsSync to return true for bundled CLI
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
const result = findBundledCliPath();
expect(result).toContain('cli.js');
});
it('should throw descriptive error when bundled CLI not found', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => findBundledCliPath()).toThrow('Bundled qwen CLI not found');
expect(() => findBundledCliPath()).toThrow('Searched locations:');
afterEach(() => {
// Restore original process.versions
Object.defineProperty(process, 'versions', {
value: originalVersions,
writable: true,
});
});
describe('prepareSpawnInfo', () => {
describe('parseExecutableSpec', () => {
describe('auto-detection (no spec provided)', () => {
it('should auto-detect bundled CLI when no spec provided', () => {
// Mock existsSync to return true for bundled CLI
@@ -73,23 +61,176 @@ describe('CLI Path Utilities', () => {
);
});
const result = prepareSpawnInfo();
const result = parseExecutableSpec();
expect(result.command).toBe(process.execPath);
expect(result.args[0]).toContain('cli.js');
expect(result.type).toBe('node');
expect(result.originalInput).toBe('');
expect(result.executablePath).toContain('cli.js');
expect(result.isExplicitRuntime).toBe(false);
});
it('should throw when bundled CLI not found', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo()).toThrow('Bundled qwen CLI not found');
expect(() => parseExecutableSpec()).toThrow(
'Bundled qwen CLI not found',
);
});
});
describe('runtime prefix parsing', () => {
it('should parse node runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('node:/path/to/cli.js');
expect(result).toEqual({
runtime: 'node',
executablePath: path.resolve('/path/to/cli.js'),
isExplicitRuntime: true,
});
});
it('should parse bun runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('bun:/path/to/cli.js');
expect(result).toEqual({
runtime: 'bun',
executablePath: path.resolve('/path/to/cli.js'),
isExplicitRuntime: true,
});
});
it('should parse tsx runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('tsx:/path/to/index.ts');
expect(result).toEqual({
runtime: 'tsx',
executablePath: path.resolve('/path/to/index.ts'),
isExplicitRuntime: true,
});
});
it('should parse deno runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('deno:/path/to/cli.ts');
expect(result).toEqual({
runtime: 'deno',
executablePath: path.resolve('/path/to/cli.ts'),
isExplicitRuntime: true,
});
});
it('should treat non-whitelisted runtime prefixes as command names', () => {
// With whitelist approach, 'invalid:format' is not recognized as a runtime spec
// so it's treated as a command name, which fails validation due to the colon
expect(() => parseExecutableSpec('invalid:format')).toThrow(
'Invalid command name',
);
});
it('should treat Windows drive letters as file paths, not runtime specs', () => {
mockFs.existsSync.mockReturnValue(true);
// Test various Windows drive letters
const windowsPaths = [
'C:\\path\\to\\cli.js',
'D:\\path\\to\\cli.js',
'E:\\Users\\dev\\qwen\\cli.js',
];
for (const winPath of windowsPaths) {
const result = parseExecutableSpec(winPath);
expect(result.isExplicitRuntime).toBe(false);
expect(result.runtime).toBeUndefined();
expect(result.executablePath).toBe(path.resolve(winPath));
}
});
it('should handle Windows paths with forward slashes', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('C:/path/to/cli.js');
expect(result.isExplicitRuntime).toBe(false);
expect(result.runtime).toBeUndefined();
expect(result.executablePath).toBe(path.resolve('C:/path/to/cli.js'));
});
it('should throw when runtime-prefixed file does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow(
'Executable file not found at',
);
});
});
describe('command name detection', () => {
it('should detect command names without path separators', () => {
const result = parseExecutableSpec('qwen');
expect(result).toEqual({
executablePath: 'qwen',
isExplicitRuntime: false,
});
});
it('should detect command names on Windows', () => {
const result = parseExecutableSpec('qwen.exe');
expect(result).toEqual({
executablePath: 'qwen.exe',
isExplicitRuntime: false,
});
});
});
describe('file path resolution', () => {
it('should resolve absolute file paths', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('/absolute/path/to/qwen');
expect(result).toEqual({
executablePath: path.resolve('/absolute/path/to/qwen'),
isExplicitRuntime: false,
});
});
it('should resolve relative file paths', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('./relative/path/to/qwen');
expect(result).toEqual({
executablePath: path.resolve('./relative/path/to/qwen'),
isExplicitRuntime: false,
});
});
it('should throw when file path does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
'Executable file not found at',
);
});
});
});
describe('prepareSpawnInfo', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
describe('native executables', () => {
it('should prepare spawn info for native binary command', () => {
const result = prepareSpawnInfo('qwen');
expect(result).toEqual({
@@ -100,38 +241,37 @@ describe('CLI Path Utilities', () => {
});
});
it('should detect command names on Windows', () => {
const result = prepareSpawnInfo('qwen.exe');
it('should prepare spawn info for native binary path', () => {
const result = prepareSpawnInfo('/usr/local/bin/qwen');
expect(result).toEqual({
command: 'qwen.exe',
command: path.resolve('/usr/local/bin/qwen'),
args: [],
type: 'native',
originalInput: 'qwen.exe',
originalInput: '/usr/local/bin/qwen',
});
});
it('should reject invalid command name characters', () => {
expect(() => prepareSpawnInfo('qwen@invalid')).toThrow(
"Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.",
);
});
it('should accept valid command names', () => {
expect(() => prepareSpawnInfo('qwen')).not.toThrow();
expect(() => prepareSpawnInfo('qwen-code')).not.toThrow();
expect(() => prepareSpawnInfo('qwen_code')).not.toThrow();
expect(() => prepareSpawnInfo('qwen.exe')).not.toThrow();
expect(() => prepareSpawnInfo('qwen123')).not.toThrow();
});
});
describe('JavaScript files', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
it('should use node for .js files', () => {
const result = prepareSpawnInfo('/path/to/cli.js');
expect(result).toEqual({
command: process.execPath,
args: [path.resolve('/path/to/cli.js')],
type: 'node',
originalInput: '/path/to/cli.js',
});
});
it('should use node for .js files', () => {
it('should default to node for .js files (not auto-detect bun)', () => {
// Even when running under bun, default to node for .js files
Object.defineProperty(process, 'versions', {
value: { ...originalVersions, bun: '1.0.0' },
writable: true,
});
const result = prepareSpawnInfo('/path/to/cli.js');
expect(result).toEqual({
@@ -166,10 +306,6 @@ describe('CLI Path Utilities', () => {
});
describe('TypeScript files', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
it('should use tsx for .ts files when tsx is available', () => {
// tsx is available by default in beforeEach
const result = prepareSpawnInfo('/path/to/index.ts');
@@ -193,178 +329,107 @@ describe('CLI Path Utilities', () => {
});
});
it('should fallback to native when tsx is not available', () => {
it('should throw helpful error when tsx is not available', () => {
// Mock tsx not being available
mockExecSync.mockImplementation(() => {
throw new Error('Command not found');
});
const result = prepareSpawnInfo('/path/to/index.ts');
const resolvedPath = path.resolve('/path/to/index.ts');
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
`TypeScript file '${resolvedPath}' requires 'tsx' runtime, but it's not available`,
);
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
'Please install tsx: npm install -g tsx',
);
});
});
describe('explicit runtime specifications', () => {
it('should use explicit node runtime', () => {
const result = prepareSpawnInfo('node:/path/to/cli.js');
expect(result).toEqual({
command: path.resolve('/path/to/index.ts'),
args: [],
type: 'native',
originalInput: '/path/to/index.ts',
command: process.execPath,
args: [path.resolve('/path/to/cli.js')],
type: 'node',
originalInput: 'node:/path/to/cli.js',
});
});
it('should use explicit bun runtime', () => {
const result = prepareSpawnInfo('bun:/path/to/cli.js');
expect(result).toEqual({
command: 'bun',
args: [path.resolve('/path/to/cli.js')],
type: 'bun',
originalInput: 'bun:/path/to/cli.js',
});
});
it('should use explicit tsx runtime', () => {
const result = prepareSpawnInfo('tsx:/path/to/index.ts');
expect(result).toEqual({
command: 'tsx',
args: [path.resolve('/path/to/index.ts')],
type: 'tsx',
originalInput: 'tsx:/path/to/index.ts',
});
});
it('should use explicit deno runtime', () => {
const result = prepareSpawnInfo('deno:/path/to/cli.ts');
expect(result).toEqual({
command: 'deno',
args: [path.resolve('/path/to/cli.ts')],
type: 'deno',
originalInput: 'deno:/path/to/cli.ts',
});
});
});
describe('native executables', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
it('should prepare spawn info for native binary path', () => {
const result = prepareSpawnInfo('/usr/local/bin/qwen');
expect(result).toEqual({
command: path.resolve('/usr/local/bin/qwen'),
args: [],
type: 'native',
originalInput: '/usr/local/bin/qwen',
describe('auto-detection fallback', () => {
it('should auto-detect bundled CLI when no spec provided', () => {
// Mock existsSync to return true for bundled CLI
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
});
});
describe('file path resolution', () => {
it('should resolve absolute file paths', () => {
mockFs.existsSync.mockReturnValue(true);
const result = prepareSpawnInfo('/absolute/path/to/qwen');
expect(result.command).toBe(path.resolve('/absolute/path/to/qwen'));
expect(result.type).toBe('native');
});
it('should resolve relative file paths', () => {
mockFs.existsSync.mockReturnValue(true);
const result = prepareSpawnInfo('./relative/path/to/cli.js');
const result = prepareSpawnInfo();
expect(result.command).toBe(process.execPath);
expect(result.args[0]).toBe(path.resolve('./relative/path/to/cli.js'));
expect(result.args[0]).toContain('cli.js');
expect(result.type).toBe('node');
});
it('should throw when file path does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('/nonexistent/path')).toThrow(
'Executable file not found at',
);
});
it('should throw when path is a directory', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockReturnValue({
isFile: () => false,
} as ReturnType<typeof import('fs').statSync>);
expect(() => prepareSpawnInfo('/path/to/directory')).toThrow(
'exists but is not a file',
);
expect(result.originalInput).toBe('');
});
});
});
describe('Windows path handling', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
it('should handle Windows paths with drive letters', () => {
const windowsPath = 'D:\\path\\to\\cli.js';
const result = prepareSpawnInfo(windowsPath);
expect(result).toEqual({
command: process.execPath,
args: [path.resolve(windowsPath)],
type: 'node',
originalInput: windowsPath,
describe('findNativeCliPath', () => {
it('should find bundled CLI', () => {
// Mock existsSync to return true for bundled CLI
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
const result = findNativeCliPath();
expect(result).toContain('cli.js');
});
it('should handle Windows paths with forward slashes', () => {
const windowsPath = 'C:/path/to/cli.js';
const result = prepareSpawnInfo(windowsPath);
expect(result.command).toBe(process.execPath);
expect(result.args[0]).toBe(path.resolve(windowsPath));
expect(result.type).toBe('node');
});
it('should not confuse Windows drive letters with invalid syntax', () => {
const windowsPath = 'D:\\workspace\\project\\cli.js';
const result = prepareSpawnInfo(windowsPath);
// Should use node runtime based on .js extension
expect(result.type).toBe('node');
expect(result.command).toBe(process.execPath);
});
it('should handle Windows paths when file is missing', () => {
it('should throw descriptive error when bundled CLI not found', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow(
'Executable file not found at',
);
});
it('should handle mixed path separators', () => {
// Users might paste paths with mixed separators
const mixedPath = 'C:\\Users/project\\cli.js';
const result = prepareSpawnInfo(mixedPath);
expect(result.command).toBe(process.execPath);
expect(result.type).toBe('node');
// path.resolve normalizes the separators
expect(result.args[0]).toBe(path.resolve(mixedPath));
});
it('should handle UNC paths', () => {
// Windows network paths: \\server\share\path
const uncPath = '\\\\server\\share\\path\\cli.js';
const result = prepareSpawnInfo(uncPath);
expect(result.command).toBe(process.execPath);
expect(result.type).toBe('node');
expect(result.args[0]).toBe(path.resolve(uncPath));
});
it('should handle Windows native executables', () => {
const windowsPath = 'C:\\Program Files\\qwen\\qwen.exe';
const result = prepareSpawnInfo(windowsPath);
// .exe files without .js extension should be treated as native
expect(result.type).toBe('native');
expect(result.command).toBe(path.resolve(windowsPath));
expect(result.args).toEqual([]);
});
});
describe('error cases', () => {
it('should throw for empty string', () => {
expect(() => prepareSpawnInfo('')).toThrow(
'Executable path cannot be empty',
);
});
it('should throw for whitespace-only string', () => {
expect(() => prepareSpawnInfo(' ')).toThrow(
'Executable path cannot be empty',
);
});
it('should provide helpful error for missing file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('/missing/file')).toThrow(
'Executable file not found at',
);
expect(() => prepareSpawnInfo('/missing/file')).toThrow(
'Please check the file path and ensure the file exists',
);
expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
});
});
@@ -373,6 +438,18 @@ describe('CLI Path Utilities', () => {
mockFs.existsSync.mockReturnValue(true);
});
it('should handle development with TypeScript source', () => {
const devPath = '/Users/dev/qwen-code/packages/cli/index.ts';
const result = prepareSpawnInfo(devPath);
expect(result).toEqual({
command: 'tsx',
args: [path.resolve(devPath)],
type: 'tsx',
originalInput: devPath,
});
});
it('should handle production bundle validation', () => {
const bundlePath = '/path/to/bundled/cli.js';
const result = prepareSpawnInfo(bundlePath);
@@ -396,27 +473,235 @@ describe('CLI Path Utilities', () => {
});
});
it('should handle ESM bundle', () => {
const bundlePath = '/path/to/cli.mjs';
const result = prepareSpawnInfo(bundlePath);
it('should handle bun runtime with bundle', () => {
const bundlePath = '/path/to/cli.js';
const result = prepareSpawnInfo(`bun:${bundlePath}`);
expect(result).toEqual({
command: process.execPath,
command: 'bun',
args: [path.resolve(bundlePath)],
type: 'node',
originalInput: bundlePath,
type: 'bun',
originalInput: `bun:${bundlePath}`,
});
});
it('should handle CJS bundle', () => {
const bundlePath = '/path/to/cli.cjs';
const result = prepareSpawnInfo(bundlePath);
it('should handle Windows paths with drive letters', () => {
const windowsPath = 'D:\\path\\to\\cli.js';
const result = prepareSpawnInfo(windowsPath);
expect(result).toEqual({
command: process.execPath,
args: [path.resolve(bundlePath)],
args: [path.resolve(windowsPath)],
type: 'node',
originalInput: bundlePath,
originalInput: windowsPath,
});
});
it('should handle Windows paths with TypeScript files', () => {
const windowsPath = 'C:\\Users\\dev\\qwen\\index.ts';
const result = prepareSpawnInfo(windowsPath);
expect(result).toEqual({
command: 'tsx',
args: [path.resolve(windowsPath)],
type: 'tsx',
originalInput: windowsPath,
});
});
it('should not confuse Windows drive letters with runtime prefixes', () => {
// Ensure 'D:' is not treated as a runtime specification
const windowsPath = 'D:\\workspace\\project\\cli.js';
const result = prepareSpawnInfo(windowsPath);
// Should use node runtime based on .js extension, not treat 'D' as runtime
expect(result.type).toBe('node');
expect(result.command).toBe(process.execPath);
expect(result.args).toEqual([path.resolve(windowsPath)]);
});
});
describe('error cases', () => {
it('should provide helpful error for missing TypeScript file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow(
'Executable file not found at',
);
});
it('should provide helpful error for missing JavaScript file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow(
'Executable file not found at',
);
});
it('should treat non-whitelisted runtime prefixes as command names', () => {
// With whitelist approach, 'invalid:spec' is not recognized as a runtime spec
// so it's treated as a command name, which fails validation due to the colon
expect(() => prepareSpawnInfo('invalid:spec')).toThrow(
'Invalid command name',
);
});
it('should handle Windows paths correctly even when file is missing', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow(
'Executable file not found at',
);
// Should not throw 'Invalid command name' error (which would happen if 'D:' was treated as invalid command)
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).not.toThrow(
'Invalid command name',
);
});
});
describe('comprehensive validation', () => {
describe('runtime validation', () => {
it('should treat unsupported runtime prefixes as file paths', () => {
mockFs.existsSync.mockReturnValue(true);
// With whitelist approach, 'unsupported:' is not recognized as a runtime spec
// so 'unsupported:/path/to/file.js' is treated as a file path
const result = parseExecutableSpec('unsupported:/path/to/file.js');
// Should be treated as a file path, not a runtime specification
expect(result.isExplicitRuntime).toBe(false);
expect(result.runtime).toBeUndefined();
});
it('should validate runtime availability for explicit runtime specs', () => {
mockFs.existsSync.mockReturnValue(true);
// Mock bun not being available
mockExecSync.mockImplementation((command) => {
if (command.includes('bun')) {
throw new Error('Command not found');
}
return Buffer.from('');
});
expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow(
"Runtime 'bun' is not available on this system. Please install it first.",
);
});
it('should allow node runtime (always available)', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow();
});
it('should validate file extension matches runtime', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow(
"File extension '.js' is not compatible with runtime 'tsx'",
);
});
it('should validate node runtime with JavaScript files', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow(
"File extension '.ts' is not compatible with runtime 'node'",
);
});
it('should accept valid runtime-file combinations', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow();
expect(() =>
parseExecutableSpec('node:/path/to/file.js'),
).not.toThrow();
expect(() =>
parseExecutableSpec('bun:/path/to/file.mjs'),
).not.toThrow();
});
});
describe('command name validation', () => {
it('should reject empty command names', () => {
expect(() => parseExecutableSpec('')).toThrow(
'Command name cannot be empty',
);
expect(() => parseExecutableSpec(' ')).toThrow(
'Command name cannot be empty',
);
});
it('should reject invalid command name characters', () => {
expect(() => parseExecutableSpec('qwen@invalid')).toThrow(
"Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.",
);
expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path
});
it('should accept valid command names', () => {
expect(() => parseExecutableSpec('qwen')).not.toThrow();
expect(() => parseExecutableSpec('qwen-code')).not.toThrow();
expect(() => parseExecutableSpec('qwen_code')).not.toThrow();
expect(() => parseExecutableSpec('qwen.exe')).not.toThrow();
expect(() => parseExecutableSpec('qwen123')).not.toThrow();
});
});
describe('file path validation', () => {
it('should validate file exists', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
'Executable file not found at',
);
});
it('should validate path points to a file, not directory', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockReturnValue({
isFile: () => false,
} as ReturnType<typeof import('fs').statSync>);
expect(() => parseExecutableSpec('/path/to/directory')).toThrow(
'exists but is not a file',
);
});
it('should accept valid file paths', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockReturnValue({
isFile: () => true,
} as ReturnType<typeof import('fs').statSync>);
expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow();
expect(() => parseExecutableSpec('./relative/path')).not.toThrow();
});
});
describe('error message quality', () => {
it('should provide helpful error for missing runtime-prefixed file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
'Executable file not found at',
);
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
'Please check the file path and ensure the file exists',
);
});
it('should provide helpful error for missing regular file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Executable file not found at',
);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Please check the file path and ensure the file exists',
);
});
});
});

View File

@@ -1,36 +1,25 @@
# Qwen Code Companion
Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visual Studio Code with native IDE features and an intuitive interface. This extension bundles everything you need to get started immediately.
The Qwen Code Companion extension seamlessly integrates [Qwen Code](https://github.com/QwenLM/qwen-code). This extension is compatible with both VS Code and VS Code forks.
## Demo
# Features
<video src="https://cloud.video.taobao.com/vod/IKKwfM-kqNI3OJjM_U8uMCSMAoeEcJhs6VNCQmZxUfk.mp4" controls width="800">
Your browser does not support the video tag. You can open the video directly:
https://cloud.video.taobao.com/vod/IKKwfM-kqNI3OJjM_U8uMCSMAoeEcJhs6VNCQmZxUfk.mp4
</video>
- Open Editor File Context: Qwen Code gains awareness of the files you have open in your editor, providing it with a richer understanding of your project's structure and content.
## Features
- Selection Context: Qwen Code can easily access your cursor's position and selected text within the editor, giving it valuable context directly from your current work.
- **Native IDE experience**: Dedicated Qwen Code sidebar panel accessed via the Qwen icon
- **Native diffing**: Review, edit, and accept changes in VS Code's diff view
- **Auto-accept edits mode**: Automatically apply Qwen's changes as they're made
- **File management**: @-mention files or attach files and images using the system file picker
- **Conversation history & multiple sessions**: Access past conversations and run multiple sessions simultaneously
- **Open file & selection context**: Share active files, cursor position, and selections for more precise help
- Native Diffing: Seamlessly view, modify, and accept code changes suggested by Qwen Code directly within the editor.
## Requirements
- Launch Qwen Code: Quickly start a new Qwen Code session from the Command Palette (Cmd+Shift+P or Ctrl+Shift+P) by running the "Qwen Code: Run" command.
- Visual Studio Code 1.85.0 or newer
# Requirements
## Installation
To use this extension, you'll need:
1. Install from the VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion
- VS Code version 1.101.0 or newer
- Qwen Code (installed separately) running within the VS Code integrated terminal
2. Two ways to use
- Chat panel: Click the Qwen icon in the Activity Bar, or run `Qwen Code: Open` from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`).
- Terminal session (classic): Run `Qwen Code: Run` to launch a session in the integrated terminal (bundled CLI).
## Development and Debugging
# Development and Debugging
To debug and develop this extension locally:
@@ -87,6 +76,6 @@ npx vsce package
pnpm vsce package
```
## Terms of Service and Privacy Notice
# Terms of Service and Privacy Notice
By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md).

View File

@@ -6,7 +6,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as vscode from 'vscode';
import { OpenFilesManager, MAX_FILES } from './open-files-manager.js';
import { OpenFilesManager } from './open-files-manager.js';
import { MAX_FILES } from './services/open-files-manager/constants.js';
vi.mock('vscode', () => ({
EventEmitter: vi.fn(() => {

View File

@@ -9,9 +9,23 @@ import type {
File,
IdeContext,
} from '@qwen-code/qwen-code-core/src/ide/types.js';
export const MAX_FILES = 10;
const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
import {
isFileUri,
isNotebookFileUri,
isNotebookCellUri,
removeFile,
renameFile,
getNotebookUriFromCellUri,
} from './services/open-files-manager/utils.js';
import {
addOrMoveToFront,
updateActiveContext,
} from './services/open-files-manager/text-handler.js';
import {
addOrMoveToFrontNotebook,
updateNotebookActiveContext,
updateNotebookCellSelection,
} from './services/open-files-manager/notebook-handler.js';
/**
* Keeps track of the workspace state, including open files, cursor position, and selected text.
@@ -25,33 +39,102 @@ export class OpenFilesManager {
constructor(private readonly context: vscode.ExtensionContext) {
const editorWatcher = vscode.window.onDidChangeActiveTextEditor(
(editor) => {
if (editor && this.isFileUri(editor.document.uri)) {
this.addOrMoveToFront(editor);
if (editor && isFileUri(editor.document.uri)) {
addOrMoveToFront(this.openFiles, editor);
this.fireWithDebounce();
} else if (editor && isNotebookCellUri(editor.document.uri)) {
// Handle when a notebook cell becomes active (which indicates the notebook is active)
const notebookUri = getNotebookUriFromCellUri(editor.document.uri);
if (notebookUri && isNotebookFileUri(notebookUri)) {
// Find the notebook editor for this cell
const notebookEditor = vscode.window.visibleNotebookEditors.find(
(nbEditor) =>
nbEditor.notebook.uri.toString() === notebookUri.toString(),
);
if (notebookEditor) {
addOrMoveToFrontNotebook(this.openFiles, notebookEditor);
this.fireWithDebounce();
}
}
}
},
);
// Watch for when notebook editors gain focus by monitoring focus changes
// Since VS Code doesn't have a direct onDidChangeActiveNotebookEditor event,
// we monitor when visible notebook editors change and assume the last one shown is active
let notebookFocusWatcher: vscode.Disposable | undefined;
if (vscode.window.onDidChangeVisibleNotebookEditors) {
notebookFocusWatcher = vscode.window.onDidChangeVisibleNotebookEditors(
() => {
// When visible notebook editors change, the currently focused one is likely the active one
const activeNotebookEditor = vscode.window.activeNotebookEditor;
if (
activeNotebookEditor &&
isNotebookFileUri(activeNotebookEditor.notebook.uri)
) {
addOrMoveToFrontNotebook(this.openFiles, activeNotebookEditor);
this.fireWithDebounce();
}
},
);
}
const selectionWatcher = vscode.window.onDidChangeTextEditorSelection(
(event) => {
if (this.isFileUri(event.textEditor.document.uri)) {
this.updateActiveContext(event.textEditor);
if (isFileUri(event.textEditor.document.uri)) {
updateActiveContext(this.openFiles, event.textEditor);
this.fireWithDebounce();
} else if (isNotebookCellUri(event.textEditor.document.uri)) {
// Handle text selections within notebook cells
updateNotebookCellSelection(
this.openFiles,
event.textEditor,
event.selections,
);
this.fireWithDebounce();
}
},
);
// Add notebook cell selection watcher for .ipynb files if the API is available
let notebookCellSelectionWatcher: vscode.Disposable | undefined;
if (vscode.window.onDidChangeNotebookEditorSelection) {
notebookCellSelectionWatcher =
vscode.window.onDidChangeNotebookEditorSelection((event) => {
if (isNotebookFileUri(event.notebookEditor.notebook.uri)) {
// Ensure the notebook is added to the active list if selected
addOrMoveToFrontNotebook(this.openFiles, event.notebookEditor);
updateNotebookActiveContext(this.openFiles, event.notebookEditor);
this.fireWithDebounce();
}
});
}
const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => {
if (this.isFileUri(document.uri)) {
this.remove(document.uri);
if (isFileUri(document.uri)) {
removeFile(this.openFiles, document.uri);
this.fireWithDebounce();
}
});
// Add notebook close watcher if the API is available
let notebookCloseWatcher: vscode.Disposable | undefined;
if (vscode.workspace.onDidCloseNotebookDocument) {
notebookCloseWatcher = vscode.workspace.onDidCloseNotebookDocument(
(document) => {
if (isNotebookFileUri(document.uri)) {
removeFile(this.openFiles, document.uri);
this.fireWithDebounce();
}
},
);
}
const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => {
for (const uri of event.files) {
if (this.isFileUri(uri)) {
this.remove(uri);
if (isFileUri(uri) || isNotebookFileUri(uri)) {
removeFile(this.openFiles, uri);
}
}
this.fireWithDebounce();
@@ -59,12 +142,12 @@ export class OpenFilesManager {
const renameWatcher = vscode.workspace.onDidRenameFiles((event) => {
for (const { oldUri, newUri } of event.files) {
if (this.isFileUri(oldUri)) {
if (this.isFileUri(newUri)) {
this.rename(oldUri, newUri);
if (isFileUri(oldUri) || isNotebookFileUri(oldUri)) {
if (isFileUri(newUri) || isNotebookFileUri(newUri)) {
renameFile(this.openFiles, oldUri, newUri);
} else {
// The file was renamed to a non-file URI, so we should remove it.
this.remove(oldUri);
removeFile(this.openFiles, oldUri);
}
}
}
@@ -79,87 +162,37 @@ export class OpenFilesManager {
renameWatcher,
);
// Conditionally add notebook-specific watchers if they were created
if (notebookCellSelectionWatcher) {
context.subscriptions.push(notebookCellSelectionWatcher);
}
if (notebookCloseWatcher) {
context.subscriptions.push(notebookCloseWatcher);
}
if (notebookFocusWatcher) {
context.subscriptions.push(notebookFocusWatcher);
}
// Just add current active file on start-up.
if (
vscode.window.activeTextEditor &&
this.isFileUri(vscode.window.activeTextEditor.document.uri)
isFileUri(vscode.window.activeTextEditor.document.uri)
) {
this.addOrMoveToFront(vscode.window.activeTextEditor);
}
}
private isFileUri(uri: vscode.Uri): boolean {
return uri.scheme === 'file';
}
private addOrMoveToFront(editor: vscode.TextEditor) {
// Deactivate previous active file
const currentActive = this.openFiles.find((f) => f.isActive);
if (currentActive) {
currentActive.isActive = false;
currentActive.cursor = undefined;
currentActive.selectedText = undefined;
addOrMoveToFront(this.openFiles, vscode.window.activeTextEditor);
}
// Remove if it exists
const index = this.openFiles.findIndex(
(f) => f.path === editor.document.uri.fsPath,
);
if (index !== -1) {
this.openFiles.splice(index, 1);
// Also add current active notebook if applicable and the API is available
if (
vscode.window.activeNotebookEditor &&
isNotebookFileUri(vscode.window.activeNotebookEditor.notebook.uri)
) {
addOrMoveToFrontNotebook(
this.openFiles,
vscode.window.activeNotebookEditor,
);
}
// Add to the front as active
this.openFiles.unshift({
path: editor.document.uri.fsPath,
timestamp: Date.now(),
isActive: true,
});
// Enforce max length
if (this.openFiles.length > MAX_FILES) {
this.openFiles.pop();
}
this.updateActiveContext(editor);
}
private remove(uri: vscode.Uri) {
const index = this.openFiles.findIndex((f) => f.path === uri.fsPath);
if (index !== -1) {
this.openFiles.splice(index, 1);
}
}
private rename(oldUri: vscode.Uri, newUri: vscode.Uri) {
const index = this.openFiles.findIndex((f) => f.path === oldUri.fsPath);
if (index !== -1) {
this.openFiles[index].path = newUri.fsPath;
}
}
private updateActiveContext(editor: vscode.TextEditor) {
const file = this.openFiles.find(
(f) => f.path === editor.document.uri.fsPath,
);
if (!file || !file.isActive) {
return;
}
file.cursor = editor.selection.active
? {
line: editor.selection.active.line + 1,
character: editor.selection.active.character,
}
: undefined;
let selectedText: string | undefined =
editor.document.getText(editor.selection) || undefined;
if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) {
selectedText =
selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]';
}
file.selectedText = selectedText;
}
private fireWithDebounce() {

View File

@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const MAX_FILES = 10;
export const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit

View File

@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js';
import {
deactivateCurrentActiveFile,
enforceMaxFiles,
truncateSelectedText,
getNotebookUriFromCellUri,
} from './utils.js';
export function addOrMoveToFrontNotebook(
openFiles: File[],
notebookEditor: vscode.NotebookEditor,
) {
// Deactivate previous active file
deactivateCurrentActiveFile(openFiles);
// Remove if it exists
const index = openFiles.findIndex(
(f) => f.path === notebookEditor.notebook.uri.fsPath,
);
if (index !== -1) {
openFiles.splice(index, 1);
}
// Add to the front as active
openFiles.unshift({
path: notebookEditor.notebook.uri.fsPath,
timestamp: Date.now(),
isActive: true,
});
// Enforce max length
enforceMaxFiles(openFiles, MAX_FILES);
updateNotebookActiveContext(openFiles, notebookEditor);
}
export function updateNotebookActiveContext(
openFiles: File[],
notebookEditor: vscode.NotebookEditor,
) {
const file = openFiles.find(
(f) => f.path === notebookEditor.notebook.uri.fsPath,
);
if (!file || !file.isActive) {
return;
}
// For notebook editors, selections may span multiple cells
// We'll gather selected text from all selected cells
const selections = notebookEditor.selections;
let combinedSelectedText = '';
for (const selection of selections) {
// Process each selected cell range
for (let i = selection.start; i < selection.end; i++) {
const cell = notebookEditor.notebook.cellAt(i);
if (cell && cell.kind === vscode.NotebookCellKind.Code) {
// For now, we'll get the full cell content if it's in a selection
// TODO: Implement per-cell cursor position and finer-grained selection if needed
combinedSelectedText += cell.document.getText() + '\n';
}
}
}
if (combinedSelectedText) {
combinedSelectedText = combinedSelectedText.trim();
file.selectedText = truncateSelectedText(
combinedSelectedText,
MAX_SELECTED_TEXT_LENGTH,
);
} else {
file.selectedText = undefined;
}
}
export function updateNotebookCellSelection(
openFiles: File[],
cellEditor: vscode.TextEditor,
selections: readonly vscode.Selection[],
) {
// Find the parent notebook by traversing the URI
const notebookUri = getNotebookUriFromCellUri(cellEditor.document.uri);
if (!notebookUri) {
return;
}
// Find the corresponding file entry for this notebook
const file = openFiles.find((f) => f.path === notebookUri.fsPath);
if (!file || !file.isActive) {
return;
}
// Extract the selected text from the cell editor
let selectedText = '';
for (const selection of selections) {
const text = cellEditor.document.getText(selection);
if (text) {
selectedText += text + '\n';
}
}
if (selectedText) {
selectedText = selectedText.trim();
file.selectedText = truncateSelectedText(
selectedText,
MAX_SELECTED_TEXT_LENGTH,
);
} else {
file.selectedText = undefined;
}
}

View File

@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type * as vscode from 'vscode';
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js';
import {
deactivateCurrentActiveFile,
enforceMaxFiles,
truncateSelectedText,
} from './utils.js';
export function addOrMoveToFront(openFiles: File[], editor: vscode.TextEditor) {
// Deactivate previous active file
deactivateCurrentActiveFile(openFiles);
// Remove if it exists
const index = openFiles.findIndex(
(f) => f.path === editor.document.uri.fsPath,
);
if (index !== -1) {
openFiles.splice(index, 1);
}
// Add to the front as active
openFiles.unshift({
path: editor.document.uri.fsPath,
timestamp: Date.now(),
isActive: true,
});
// Enforce max length
enforceMaxFiles(openFiles, MAX_FILES);
updateActiveContext(openFiles, editor);
}
export function updateActiveContext(
openFiles: File[],
editor: vscode.TextEditor,
) {
const file = openFiles.find((f) => f.path === editor.document.uri.fsPath);
if (!file || !file.isActive) {
return;
}
file.cursor = editor.selection.active
? {
line: editor.selection.active.line + 1,
character: editor.selection.active.character,
}
: undefined;
let selectedText: string | undefined =
editor.document.getText(editor.selection) || undefined;
selectedText = truncateSelectedText(selectedText, MAX_SELECTED_TEXT_LENGTH);
file.selectedText = selectedText;
}

View File

@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
export function isFileUri(uri: vscode.Uri): boolean {
return uri.scheme === 'file';
}
export function isNotebookFileUri(uri: vscode.Uri): boolean {
return uri.scheme === 'file' && uri.path.toLowerCase().endsWith('.ipynb');
}
export function isNotebookCellUri(uri: vscode.Uri): boolean {
// Notebook cell URIs have the scheme 'vscode-notebook-cell'
return uri.scheme === 'vscode-notebook-cell';
}
export function removeFile(openFiles: File[], uri: vscode.Uri): void {
const index = openFiles.findIndex((f) => f.path === uri.fsPath);
if (index !== -1) {
openFiles.splice(index, 1);
}
}
export function renameFile(
openFiles: File[],
oldUri: vscode.Uri,
newUri: vscode.Uri,
): void {
const index = openFiles.findIndex((f) => f.path === oldUri.fsPath);
if (index !== -1) {
openFiles[index].path = newUri.fsPath;
}
}
export function deactivateCurrentActiveFile(openFiles: File[]): void {
const currentActive = openFiles.find((f) => f.isActive);
if (currentActive) {
currentActive.isActive = false;
currentActive.cursor = undefined;
currentActive.selectedText = undefined;
}
}
export function enforceMaxFiles(openFiles: File[], maxFiles: number): void {
if (openFiles.length > maxFiles) {
openFiles.pop();
}
}
export function truncateSelectedText(
selectedText: string | undefined,
maxLength: number,
): string | undefined {
if (!selectedText) {
return undefined;
}
if (selectedText.length > maxLength) {
return selectedText.substring(0, maxLength) + '... [TRUNCATED]';
}
return selectedText;
}
export function getNotebookUriFromCellUri(
cellUri: vscode.Uri,
): vscode.Uri | null {
// Most efficient approach: Check if the currently active notebook editor contains this cell
const activeNotebookEditor = vscode.window.activeNotebookEditor;
if (
activeNotebookEditor &&
isNotebookFileUri(activeNotebookEditor.notebook.uri)
) {
for (let i = 0; i < activeNotebookEditor.notebook.cellCount; i++) {
const cell = activeNotebookEditor.notebook.cellAt(i);
if (cell.document.uri.toString() === cellUri.toString()) {
return activeNotebookEditor.notebook.uri;
}
}
}
// If not in the active editor, check all visible notebook editors
for (const editor of vscode.window.visibleNotebookEditors) {
if (
editor !== activeNotebookEditor &&
isNotebookFileUri(editor.notebook.uri)
) {
for (let i = 0; i < editor.notebook.cellCount; i++) {
const cell = editor.notebook.cellAt(i);
if (cell.document.uri.toString() === cellUri.toString()) {
return editor.notebook.uri;
}
}
}
}
return null;
}