mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-13 20:39:14 +00:00
Compare commits
2 Commits
feat/add-u
...
feat/suppo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0e561ca73 | ||
|
|
563d68ad5b |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,3 +0,0 @@
|
||||
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy
|
||||
# SDK TypeScript package changes require review from Mingholy
|
||||
packages/sdk-typescript/** @Mingholy
|
||||
17
.github/workflows/release-sdk.yml
vendored
17
.github/workflows/release-sdk.yml
vendored
@@ -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: |-
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
2
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 模式',
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -126,8 +126,6 @@ export interface UIState {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen: boolean;
|
||||
isAgentsManagerDialogOpen: boolean;
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen: boolean;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user