Compare commits

..

6 Commits

54 changed files with 602 additions and 1429 deletions

View File

@@ -25,7 +25,7 @@ Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Code
- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 2,000 free requests/day.
- **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together.
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience.
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs.
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code and Zed.
## Installation
@@ -137,11 +137,10 @@ Use `-p` to run Qwen Code without the interactive UI—ideal for scripts, automa
#### IDE integration
Use Qwen Code inside your editor (VS Code, Zed, and JetBrains IDEs):
Use Qwen Code inside your editor (VS Code and Zed):
- [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/)
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
- [Use in JetBrains IDEs](https://qwenlm.github.io/qwen-code-docs/en/users/integration-jetbrains/)
#### TypeScript SDK

View File

@@ -12,7 +12,6 @@ export default {
},
'integration-vscode': 'Visual Studio Code',
'integration-zed': 'Zed IDE',
'integration-jetbrains': 'JetBrains IDEs',
'integration-github-action': 'Github Actions',
'Code with Qwen Code': {
type: 'separator',

View File

@@ -59,7 +59,6 @@ Commands for managing AI tools and models.
| ---------------- | --------------------------------------------- | --------------------------------------------- |
| `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc` |
| `/tools` | Display currently available tool list | `/tools`, `/tools desc` |
| `/skills` | List and run available skills (experimental) | `/skills`, `/skills <name>` |
| `/approval-mode` | Change approval mode for tool usage | `/approval-mode <mode (auto-edit)> --project` |
| →`plan` | Analysis only, no execution | Secure review |
| →`default` | Require approval for edits | Daily use |

View File

@@ -27,14 +27,6 @@ Agent Skills package expertise into discoverable capabilities. Each Skill consis
Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skills description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`).
If you want to invoke a Skill explicitly, use the `/skills` slash command:
```bash
/skills <skill-name>
```
The `/skills` command is only available when you run with `--experimental-skills`. Use autocomplete to browse available Skills and descriptions.
### Benefits
- Extend Qwen Code for your workflows

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,57 +0,0 @@
# JetBrains IDEs
> JetBrains IDEs provide native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions.
### Features
- **Native agent experience**: Integrated AI assistant panel within your JetBrains IDE
- **Agent Control Protocol**: Full support for ACP enabling advanced IDE interactions
- **Symbol management**: #-mention files to add them to the conversation context
- **Conversation history**: Access to past conversations within the IDE
### Requirements
- JetBrains IDE with ACP support (IntelliJ IDEA, WebStorm, PyCharm, etc.)
- Qwen Code CLI installed
### Installation
1. Install Qwen Code CLI:
```bash
npm install -g @qwen-code/qwen-code
```
2. Open your JetBrains IDE and navigate to AI Chat tool window.
3. Click the 3-dot menu in the upper-right corner and select **Configure ACP Agent** and configure Qwen Code with the following settings:
```json
{
"agent_servers": {
"qwen": {
"command": "/path/to/qwen",
"args": ["--acp"],
"env": {}
}
}
}
```
4. The Qwen Code agent should now be available in the AI Assistant panel
![Qwen Code in JetBrains AI Chat](./images/jetbrains-acp.png)
## Troubleshooting
### Agent not appearing
- Run `qwen --version` in terminal to verify installation
- Ensure your JetBrains IDE version supports ACP
- Restart your JetBrains IDE
### Qwen Code not responding
- Check your internet connection
- Verify CLI works by running `qwen` in terminal
- [File an issue on GitHub](https://github.com/qwenlm/qwen-code/issues) if the problem persists

7
package-lock.json generated
View File

@@ -6216,7 +6216,10 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -13879,7 +13882,10 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
@@ -17968,7 +17974,6 @@
"ajv-formats": "^3.0.0",
"async-mutex": "^0.5.0",
"chardet": "^2.1.0",
"chokidar": "^4.0.3",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"fast-levenshtein": "^2.0.6",

View File

@@ -170,17 +170,7 @@ function normalizeOutputFormat(
}
export async function parseArguments(settings: Settings): Promise<CliArgs> {
let rawArgv = hideBin(process.argv);
// hack: if the first argument is the CLI entry point, remove it
if (
rawArgv.length > 0 &&
(rawArgv[0].endsWith('/dist/qwen-cli/cli.js') ||
rawArgv[0].endsWith('/dist/cli.js'))
) {
rawArgv = rawArgv.slice(1);
}
const rawArgv = hideBin(process.argv);
const yargsInstance = yargs(rawArgv)
.locale('en')
.scriptName('qwen')

View File

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

View File

@@ -346,7 +346,6 @@ export async function main() {
extensionEnablementManager,
argv,
);
registerCleanup(() => config.shutdown());
if (config.getListExtensions()) {
console.log('Installed extensions:');

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,6 @@ import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { summaryCommand } from '../ui/commands/summaryCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
@@ -79,7 +78,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
quitCommand,
restoreCommand(this.config),
resumeCommand,
...(this.config?.getExperimentalSkills?.() ? [skillsCommand] : []),
statsCommand,
summaryCommand,
themeCommand,

View File

@@ -45,6 +45,7 @@ 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';
@@ -1173,6 +1174,19 @@ 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 ||
@@ -1292,6 +1306,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
}),
[
isThemeDialogOpen,
@@ -1382,6 +1398,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
],
);
@@ -1422,6 +1440,10 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
}),
[
handleThemeSelect,
@@ -1457,6 +1479,10 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
],
);

View File

@@ -0,0 +1,45 @@
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 === '1') {
uiActions.submitFeedback(1);
} else if (key.name === '2') {
uiActions.submitFeedback(2);
} else if (key.name === '3') {
uiActions.submitFeedback(3);
}
uiActions.closeFeedbackDialog();
},
{ isActive: uiState.isFeedbackDialogOpen },
);
return (
<Box flexDirection="column" marginY={1}>
<Box>
<Text color="cyan"> </Text>
<Text bold>{t('How is Qwen doing this session? (optional)')}</Text>
</Box>
<Box marginTop={1}>
<Text color="cyan">1: </Text>
<Text>{t('Good')}</Text>
<Text> </Text>
<Text color="cyan">2: </Text>
<Text>{t('Bad')}</Text>
<Text> </Text>
<Text color="cyan">3: </Text>
<Text>{t('Not Sure Yet')}</Text>
</Box>
</Box>
);
};

View File

@@ -1,132 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
CommandKind,
type CommandCompletionItem,
type CommandContext,
type SlashCommand,
} from './types.js';
import { MessageType, type HistoryItemSkillsList } from '../types.js';
import { t } from '../../i18n/index.js';
import { AsyncFzf } from 'fzf';
import type { SkillConfig } from '@qwen-code/qwen-code-core';
export const skillsCommand: SlashCommand = {
name: 'skills',
get description() {
return t('List available skills.');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string) => {
const rawArgs = args?.trim() ?? '';
const [skillName = ''] = rawArgs.split(/\s+/);
const skillManager = context.services.config?.getSkillManager();
if (!skillManager) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Could not retrieve skill manager.'),
},
Date.now(),
);
return;
}
const skills = await skillManager.listSkills();
if (skills.length === 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: t('No skills are currently available.'),
},
Date.now(),
);
return;
}
if (!skillName) {
const sortedSkills = [...skills].sort((left, right) =>
left.name.localeCompare(right.name),
);
const skillsListItem: HistoryItemSkillsList = {
type: MessageType.SKILLS_LIST,
skills: sortedSkills.map((skill) => ({ name: skill.name })),
};
context.ui.addItem(skillsListItem, Date.now());
return;
}
const normalizedName = skillName.toLowerCase();
const hasSkill = skills.some(
(skill) => skill.name.toLowerCase() === normalizedName,
);
if (!hasSkill) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Unknown skill: {{name}}', { name: skillName }),
},
Date.now(),
);
return;
}
const rawInput = context.invocation?.raw ?? `/skills ${rawArgs}`;
return {
type: 'submit_prompt',
content: [{ text: rawInput }],
};
},
completion: async (
context: CommandContext,
partialArg: string,
): Promise<CommandCompletionItem[]> => {
const skillManager = context.services.config?.getSkillManager();
if (!skillManager) {
return [];
}
const skills = await skillManager.listSkills();
const normalizedPartial = partialArg.trim();
const matches = await getSkillMatches(skills, normalizedPartial);
return matches.map((skill) => ({
value: skill.name,
description: skill.description,
}));
},
};
async function getSkillMatches(
skills: SkillConfig[],
query: string,
): Promise<SkillConfig[]> {
if (!query) {
return skills;
}
const names = skills.map((skill) => skill.name);
const skillMap = new Map(skills.map((skill) => [skill.name, skill]));
try {
const fzf = new AsyncFzf(names, {
fuzzy: 'v2',
casing: 'case-insensitive',
});
const results = (await fzf.find(query)) as Array<{ item: string }>;
return results
.map((result) => skillMap.get(result.item))
.filter((skill): skill is SkillConfig => !!skill);
} catch (error) {
console.error('[skillsCommand] Fuzzy match failed:', error);
const lowerQuery = query.toLowerCase();
return skills.filter((skill) =>
skill.name.toLowerCase().startsWith(lowerQuery),
);
}
}

View File

@@ -209,12 +209,6 @@ export enum CommandKind {
MCP_PROMPT = 'mcp-prompt',
}
export interface CommandCompletionItem {
value: string;
label?: string;
description?: string;
}
// The standardized contract for any command in the system.
export interface SlashCommand {
name: string;
@@ -240,7 +234,7 @@ export interface SlashCommand {
completion?: (
context: CommandContext,
partialArg: string,
) => Promise<Array<string | CommandCompletionItem> | null>;
) => Promise<string[]>;
subCommands?: SlashCommand[];
}

View File

@@ -26,6 +26,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
import { t } from '../../i18n/index.js';
export const Composer = () => {
@@ -134,6 +135,8 @@ export const Composer = () => {
</OverflowProvider>
)}
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}

View File

@@ -30,7 +30,6 @@ import { Help } from './Help.js';
import type { SlashCommand } from '../commands/types.js';
import { ExtensionsList } from './views/ExtensionsList.js';
import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
import { SkillsList } from './views/SkillsList.js';
import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js';
@@ -154,9 +153,6 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
showDescriptions={itemForDisplay.showDescriptions}
/>
)}
{itemForDisplay.type === 'skills_list' && (
<SkillsList skills={itemForDisplay.skills} />
)}
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}

View File

@@ -106,7 +106,7 @@ export function SuggestionsDisplay({
</Box>
{suggestion.description && (
<Box flexGrow={1} paddingLeft={2}>
<Box flexGrow={1} paddingLeft={3}>
<Text color={textColor} wrap="truncate">
{suggestion.description}
</Text>

View File

@@ -23,7 +23,7 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="row" marginTop={1}>
<Box width={prefixWidth}>
<Text color={theme.status.warning}>{prefix}</Text>
</Box>

View File

@@ -18,7 +18,7 @@ export const WarningMessage: React.FC<WarningMessageProps> = ({ text }) => {
const prefixWidth = 3;
return (
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="row" marginTop={1}>
<Box width={prefixWidth}>
<Text color={Colors.AccentYellow}>{prefix}</Text>
</Box>

View File

@@ -1,36 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { type SkillDefinition } from '../../types.js';
import { t } from '../../../i18n/index.js';
interface SkillsListProps {
skills: readonly SkillDefinition[];
}
export const SkillsList: React.FC<SkillsListProps> = ({ skills }) => (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
{t('Available skills:')}
</Text>
<Box height={1} />
{skills.length > 0 ? (
skills.map((skill) => (
<Box key={skill.name} flexDirection="row">
<Text color={theme.text.primary}>{' '}- </Text>
<Text bold color={theme.text.accent}>
{skill.name}
</Text>
</Box>
))
) : (
<Text color={theme.text.primary}> {t('No skills available')}</Text>
)}
</Box>
);

View File

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

View File

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

View File

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

View File

@@ -573,45 +573,6 @@ describe('useSlashCompletion', () => {
});
});
it('should map completion items with descriptions for argument suggestions', async () => {
const mockCompletionFn = vi.fn().mockResolvedValue([
{ value: 'pdf', description: 'Create PDF documents' },
{ value: 'xlsx', description: 'Work with spreadsheets' },
]);
const slashCommands = [
createTestCommand({
name: 'skills',
description: 'List available skills',
completion: mockCompletionFn,
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/skills ',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'pdf',
value: 'pdf',
description: 'Create PDF documents',
},
{
label: 'xlsx',
value: 'xlsx',
description: 'Work with spreadsheets',
},
]);
});
});
it('should call command.completion with an empty string when args start with a space', async () => {
const mockCompletionFn = vi
.fn()

View File

@@ -9,7 +9,6 @@ import { AsyncFzf } from 'fzf';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import {
CommandKind,
type CommandCompletionItem,
type CommandContext,
type SlashCommand,
} from '../commands/types.js';
@@ -216,9 +215,10 @@ function useCommandSuggestions(
)) || [];
if (!signal.aborted) {
const finalSuggestions = results
.map((item) => toSuggestion(item))
.filter((suggestion): suggestion is Suggestion => !!suggestion);
const finalSuggestions = results.map((s) => ({
label: s,
value: s,
}));
setSuggestions(finalSuggestions);
setIsLoading(false);
}
@@ -310,20 +310,6 @@ function useCommandSuggestions(
return { suggestions, isLoading };
}
function toSuggestion(item: string | CommandCompletionItem): Suggestion | null {
if (typeof item === 'string') {
return { label: item, value: item };
}
if (!item.value) {
return null;
}
return {
label: item.label ?? item.value,
value: item.value,
description: item.description,
};
}
function useCompletionPositions(
query: string | null,
parserResult: CommandParserResult,

View File

@@ -201,21 +201,12 @@ export interface ToolDefinition {
description?: string;
}
export interface SkillDefinition {
name: string;
}
export type HistoryItemToolsList = HistoryItemBase & {
type: 'tools_list';
tools: ToolDefinition[];
showDescriptions: boolean;
};
export type HistoryItemSkillsList = HistoryItemBase & {
type: 'skills_list';
skills: SkillDefinition[];
};
// JSON-friendly types for using as a simple data model showing info about an
// MCP Server.
export interface JsonMcpTool {
@@ -277,7 +268,6 @@ export type HistoryItemWithoutId =
| HistoryItemCompression
| HistoryItemExtensionsList
| HistoryItemToolsList
| HistoryItemSkillsList
| HistoryItemMcpStatus;
export type HistoryItem = HistoryItemWithoutId & { id: number };
@@ -299,7 +289,6 @@ export enum MessageType {
SUMMARY = 'summary',
EXTENSIONS_LIST = 'extensions_list',
TOOLS_LIST = 'tools_list',
SKILLS_LIST = 'skills_list',
MCP_STATUS = 'mcp_status',
}

View File

@@ -27,6 +27,7 @@
"@google/genai": "1.30.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"@opentelemetry/api": "^1.9.0",
"async-mutex": "^0.5.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.203.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.203.0",
@@ -39,9 +40,7 @@
"@xterm/headless": "5.5.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"async-mutex": "^0.5.0",
"chardet": "^2.1.0",
"chokidar": "^4.0.3",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"fast-levenshtein": "^2.0.6",

View File

@@ -673,7 +673,6 @@ export class Config {
this.promptRegistry = new PromptRegistry();
this.subagentManager = new SubagentManager(this);
this.skillManager = new SkillManager(this);
await this.skillManager.startWatching();
// Load session subagents if they were provided before initialization
if (this.sessionSubagents.length > 0) {
@@ -774,13 +773,6 @@ export class Config {
return this.sessionId;
}
/**
* Releases resources owned by the config instance.
*/
async shutdown(): Promise<void> {
this.skillManager?.stopWatching();
}
/**
* Starts a new session and resets session-scoped services.
*/

View File

@@ -5,10 +5,8 @@
*/
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import * as os from 'os';
import { watch as watchFs, type FSWatcher } from 'chokidar';
import { parse as parseYaml } from '../utils/yaml-parser.js';
import type {
SkillConfig,
@@ -31,9 +29,6 @@ export class SkillManager {
private skillsCache: Map<SkillLevel, SkillConfig[]> | null = null;
private readonly changeListeners: Set<() => void> = new Set();
private parseErrors: Map<string, SkillError> = new Map();
private readonly watchers: Map<string, FSWatcher> = new Map();
private watchStarted = false;
private refreshTimer: NodeJS.Timeout | null = null;
constructor(private readonly config: Config) {}
@@ -226,36 +221,6 @@ export class SkillManager {
this.notifyChangeListeners();
}
/**
* Starts watching skill directories for changes.
*/
async startWatching(): Promise<void> {
if (this.watchStarted) {
return;
}
this.watchStarted = true;
await this.refreshCache();
this.updateWatchersFromCache();
}
/**
* Stops watching skill directories for changes.
*/
stopWatching(): void {
for (const watcher of this.watchers.values()) {
void watcher.close().catch((error) => {
console.warn('Failed to close skills watcher:', error);
});
}
this.watchers.clear();
this.watchStarted = false;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
/**
* Parses a SKILL.md file and returns the configuration.
*
@@ -484,77 +449,4 @@ export class SkillManager {
this.skillsCache.set(level, levelSkills);
}
}
private updateWatchersFromCache(): void {
const desiredPaths = new Set<string>();
for (const level of ['project', 'user'] as const) {
const baseDir = this.getSkillsBaseDir(level);
const parentDir = path.dirname(baseDir);
if (fsSync.existsSync(parentDir)) {
desiredPaths.add(parentDir);
}
if (fsSync.existsSync(baseDir)) {
desiredPaths.add(baseDir);
}
const levelSkills = this.skillsCache?.get(level) || [];
for (const skill of levelSkills) {
const skillDir = path.dirname(skill.filePath);
if (fsSync.existsSync(skillDir)) {
desiredPaths.add(skillDir);
}
}
}
for (const existingPath of this.watchers.keys()) {
if (!desiredPaths.has(existingPath)) {
void this.watchers
.get(existingPath)
?.close()
.catch((error) => {
console.warn(
`Failed to close skills watcher for ${existingPath}:`,
error,
);
});
this.watchers.delete(existingPath);
}
}
for (const watchPath of desiredPaths) {
if (this.watchers.has(watchPath)) {
continue;
}
try {
const watcher = watchFs(watchPath, {
ignoreInitial: true,
})
.on('all', () => {
this.scheduleRefresh();
})
.on('error', (error) => {
console.warn(`Skills watcher error for ${watchPath}:`, error);
});
this.watchers.set(watchPath, watcher);
} catch (error) {
console.warn(
`Failed to watch skills directory at ${watchPath}:`,
error,
);
}
}
}
private scheduleRefresh(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
this.refreshTimer = null;
void this.refreshCache().then(() => this.updateWatchersFromCache());
}, 150);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -757,6 +757,44 @@ 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
@@ -786,7 +824,8 @@ export type TelemetryEvent =
| ToolOutputTruncatedEvent
| ModelSlashCommandEvent
| AuthEvent
| SkillLaunchEvent;
| SkillLaunchEvent
| UserFeedbackEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable';

View File

@@ -1,98 +1,67 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ShellTool > getDescription > should return the non-windows description when not on windows 1`] = `
"Executes a given shell command (as \`bash -c <command>\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures.
"This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
**Background vs Foreground Execution:**
You should decide whether commands should run in background or foreground based on their nature:
**Use background execution (is_background: true) for:**
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
**Use foreground execution (is_background: false) for:**
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
The following information is returned:
**Usage notes**:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use glob (NOT find or ls)
- Content search: Use grep_search (NOT grep or rg)
- Read files: Use read_file (NOT cat/head/tail)
- Edit files: Use edit (NOT sed/awk)
- Write files: Use write_file (NOT echo >/cat <<EOF)
- Communication: Output text directly (NOT echo/printf)
- When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple run_shell_command tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two run_shell_command tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single run_shell_command call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before run_shell_command for git operations, or git add before git commit), run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
**Background vs Foreground Execution:**
- You should decide whether commands should run in background or foreground based on their nature:
- Use background execution (is_background: true) for:
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
- Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
- Use foreground execution (is_background: false) for:
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
"
Command: Executed command.
Directory: Directory where command was executed, or \`(root)\`.
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Error: Error or \`(none)\` if no error was reported for the subprocess.
Exit Code: Exit code or \`(none)\` if terminated by signal.
Signal: Signal number or \`(none)\` if no signal was received.
Background PIDs: List of background processes started or \`(none)\`.
Process Group PGID: Process group started or \`(none)\`"
`;
exports[`ShellTool > getDescription > should return the windows description when on windows 1`] = `
"Executes a given shell command (as \`cmd.exe /c <command>\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures.
"This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
**Background vs Foreground Execution:**
You should decide whether commands should run in background or foreground based on their nature:
**Use background execution (is_background: true) for:**
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
**Use foreground execution (is_background: false) for:**
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
The following information is returned:
**Usage notes**:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use glob (NOT find or ls)
- Content search: Use grep_search (NOT grep or rg)
- Read files: Use read_file (NOT cat/head/tail)
- Edit files: Use edit (NOT sed/awk)
- Write files: Use write_file (NOT echo >/cat <<EOF)
- Communication: Output text directly (NOT echo/printf)
- When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple run_shell_command tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two run_shell_command tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single run_shell_command call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before run_shell_command for git operations, or git add before git commit), run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
**Background vs Foreground Execution:**
- You should decide whether commands should run in background or foreground based on their nature:
- Use background execution (is_background: true) for:
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
- Use foreground execution (is_background: false) for:
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
"
Command: Executed command.
Directory: Directory where command was executed, or \`(root)\`.
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Error: Error or \`(none)\` if no error was reported for the subprocess.
Exit Code: Exit code or \`(none)\` if terminated by signal.
Signal: Signal number or \`(none)\` if no signal was received.
Background PIDs: List of background processes started or \`(none)\`.
Process Group PGID: Process group started or \`(none)\`"
`;

View File

@@ -59,9 +59,6 @@ describe('ShellTool', () => {
getWorkspaceContext: vi
.fn()
.mockReturnValue(createMockWorkspaceContext('/test/dir')),
storage: {
getUserSkillsDir: vi.fn().mockReturnValue('/test/dir/.qwen/skills'),
},
getGeminiClient: vi.fn(),
getGitCoAuthor: vi.fn().mockReturnValue({
enabled: true,
@@ -145,42 +142,6 @@ describe('ShellTool', () => {
);
});
it('should throw an error for a directory within the user skills directory', () => {
expect(() =>
shellTool.build({
command: 'ls',
directory: '/test/dir/.qwen/skills/my-skill',
is_background: false,
}),
).toThrow(
'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.',
);
});
it('should throw an error for the user skills directory itself', () => {
expect(() =>
shellTool.build({
command: 'ls',
directory: '/test/dir/.qwen/skills',
is_background: false,
}),
).toThrow(
'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.',
);
});
it('should resolve directory path before checking user skills directory', () => {
expect(() =>
shellTool.build({
command: 'ls',
directory: '/test/dir/.qwen/skills/../skills/my-skill',
is_background: false,
}),
).toThrow(
'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.',
);
});
it('should return an invocation for a valid absolute directory path', () => {
(mockConfig.getWorkspaceContext as Mock).mockReturnValue(
createMockWorkspaceContext('/test/dir', ['/another/workspace']),
@@ -709,7 +670,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
mockAbortSignal,
false,
{},
);
@@ -900,7 +861,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
mockAbortSignal,
false,
{},
);
@@ -909,8 +870,8 @@ describe('ShellTool', () => {
it('should add co-author to git commit with multi-line message', async () => {
const command = `git commit -m "Fix bug
This is a detailed description
spanning multiple lines"`;
This is a detailed description
spanning multiple lines"`;
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
@@ -933,7 +894,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
mockAbortSignal,
false,
{},
);
@@ -1038,248 +999,4 @@ describe('ShellTool', () => {
);
});
});
describe('timeout parameter', () => {
it('should validate timeout parameter correctly', () => {
// Valid timeout
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 5000,
});
}).not.toThrow();
// Valid small timeout
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 500,
});
}).not.toThrow();
// Zero timeout
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 0,
});
}).toThrow('Timeout must be a positive number.');
// Negative timeout
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: -1000,
});
}).toThrow('Timeout must be a positive number.');
// Timeout too large
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 700000,
});
}).toThrow('Timeout cannot exceed 600000ms (10 minutes).');
// Non-integer timeout
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 5000.5,
});
}).toThrow('Timeout must be an integer number of milliseconds.');
// Non-number timeout (schema validation catches this first)
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 'invalid' as unknown as number,
});
}).toThrow('params/timeout must be number');
});
it('should include timeout in description for foreground commands', () => {
const invocation = shellTool.build({
command: 'npm test',
is_background: false,
timeout: 30000,
});
expect(invocation.getDescription()).toBe('npm test [timeout: 30000ms]');
});
it('should not include timeout in description for background commands', () => {
const invocation = shellTool.build({
command: 'npm start',
is_background: true,
timeout: 30000,
});
expect(invocation.getDescription()).toBe('npm start [background]');
});
it('should create combined signal with timeout for foreground execution', async () => {
const mockAbortSignal = new AbortController().signal;
const invocation = shellTool.build({
command: 'sleep 1',
is_background: false,
timeout: 5000,
});
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
// Verify that ShellExecutionService was called with a combined signal
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
false,
{},
);
// The signal passed should be different from the original signal
const calledSignal = mockShellExecutionService.mock.calls[0][3];
expect(calledSignal).not.toBe(mockAbortSignal);
});
it('should not create timeout signal for background execution', async () => {
const mockAbortSignal = new AbortController().signal;
const invocation = shellTool.build({
command: 'npm start',
is_background: true,
timeout: 5000,
});
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: 'Background command started. PID: 12345',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
// For background execution, the original signal should be used
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Function),
mockAbortSignal,
false,
{},
);
});
it('should handle timeout vs user cancellation correctly', async () => {
const userAbortController = new AbortController();
const invocation = shellTool.build({
command: 'sleep 10',
is_background: false,
timeout: 5000,
});
// Mock AbortSignal.timeout and AbortSignal.any
const mockTimeoutSignal = {
aborted: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as AbortSignal;
const mockCombinedSignal = {
aborted: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as AbortSignal;
const originalAbortSignal = globalThis.AbortSignal;
vi.stubGlobal('AbortSignal', {
...originalAbortSignal,
timeout: vi.fn().mockReturnValue(mockTimeoutSignal),
any: vi.fn().mockReturnValue(mockCombinedSignal),
});
const promise = invocation.execute(userAbortController.signal);
resolveExecutionPromise({
rawOutput: Buffer.from('partial output'),
output: 'partial output',
exitCode: null,
signal: null,
error: null,
aborted: true,
pid: 12345,
executionMethod: 'child_process',
});
const result = await promise;
// Restore original AbortSignal
vi.stubGlobal('AbortSignal', originalAbortSignal);
expect(result.llmContent).toContain('Command timed out after 5000ms');
expect(result.llmContent).toContain(
'Below is the output before it timed out',
);
});
it('should use default timeout behavior when timeout is not specified', async () => {
const mockAbortSignal = new AbortController().signal;
const invocation = shellTool.build({
command: 'echo test',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from('test'),
output: 'test',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
// Should create a combined signal with the default timeout when no timeout is specified
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
false,
{},
);
});
});
});

View File

@@ -34,7 +34,6 @@ import type {
import { ShellExecutionService } from '../services/shellExecutionService.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { isSubpath } from '../utils/paths.js';
import {
getCommandRoots,
isCommandAllowed,
@@ -43,12 +42,10 @@ import {
} from '../utils/shell-utils.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const DEFAULT_FOREGROUND_TIMEOUT_MS = 120000;
export interface ShellToolParams {
command: string;
is_background: boolean;
timeout?: number;
description?: string;
directory?: string;
}
@@ -75,9 +72,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
// append background indicator
if (this.params.is_background) {
description += ` [background]`;
} else if (this.params.timeout) {
// append timeout for foreground commands
description += ` [timeout: ${this.params.timeout}ms]`;
}
// append optional (description), replacing any line breaks with spaces
if (this.params.description) {
@@ -136,17 +130,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
};
}
const effectiveTimeout = this.params.is_background
? undefined
: (this.params.timeout ?? DEFAULT_FOREGROUND_TIMEOUT_MS);
// Create combined signal with timeout for foreground execution
let combinedSignal = signal;
if (effectiveTimeout) {
const timeoutSignal = AbortSignal.timeout(effectiveTimeout);
combinedSignal = AbortSignal.any([signal, timeoutSignal]);
}
const isWindows = os.platform() === 'win32';
const tempFileName = `shell_pgrep_${crypto
.randomBytes(6)
@@ -236,7 +219,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
lastUpdateTime = Date.now();
}
},
combinedSignal,
signal,
this.config.getShouldUseNodePtyShell(),
shellExecutionConfig ?? {},
);
@@ -287,28 +270,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
let llmContent = '';
if (result.aborted) {
// Check if it was a timeout or user cancellation
const wasTimeout =
!this.params.is_background &&
effectiveTimeout &&
combinedSignal.aborted &&
!signal.aborted;
if (wasTimeout) {
llmContent = `Command timed out after ${effectiveTimeout}ms before it could complete.`;
if (result.output.trim()) {
llmContent += ` Below is the output before it timed out:\n${result.output}`;
} else {
llmContent += ' There was no output before it timed out.';
}
llmContent = 'Command was cancelled by user before it could complete.';
if (result.output.trim()) {
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
} else {
llmContent =
'Command was cancelled by user before it could complete.';
if (result.output.trim()) {
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
} else {
llmContent += ' There was no output before it was cancelled.';
}
llmContent += ' There was no output before it was cancelled.';
}
} else {
// Create a formatted error string for display, replacing the wrapper command
@@ -339,16 +305,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
returnDisplayMessage = result.output;
} else {
if (result.aborted) {
// Check if it was a timeout or user cancellation
const wasTimeout =
!this.params.is_background &&
effectiveTimeout &&
combinedSignal.aborted &&
!signal.aborted;
returnDisplayMessage = wasTimeout
? `Command timed out after ${effectiveTimeout}ms.`
: 'Command cancelled by user.';
returnDisplayMessage = 'Command cancelled by user.';
} else if (result.signal) {
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
} else if (result.error) {
@@ -449,59 +406,42 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
}
function getShellToolDescription(): string {
const isWindows = os.platform() === 'win32';
const executionWrapper = isWindows
? 'cmd.exe /c <command>'
: 'bash -c <command>';
const processGroupNote = isWindows
? ''
: '\n - Command is executed as a subprocess that leads its own process group. Command process group can be terminated as `kill -- -PGID` or signaled as `kill -s SIGNAL -- -PGID`.';
const toolDescription = `
return `Executes a given shell command (as \`${executionWrapper}\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures.
**Background vs Foreground Execution:**
You should decide whether commands should run in background or foreground based on their nature:
**Use background execution (is_background: true) for:**
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
**Use foreground execution (is_background: false) for:**
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
The following information is returned:
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
Command: Executed command.
Directory: Directory where command was executed, or \`(root)\`.
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Error: Error or \`(none)\` if no error was reported for the subprocess.
Exit Code: Exit code or \`(none)\` if terminated by signal.
Signal: Signal number or \`(none)\` if no signal was received.
Background PIDs: List of background processes started or \`(none)\`.
Process Group PGID: Process group started or \`(none)\``;
**Usage notes**:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use ${ToolNames.GLOB} (NOT find or ls)
- Content search: Use ${ToolNames.GREP} (NOT grep or rg)
- Read files: Use ${ToolNames.READ_FILE} (NOT cat/head/tail)
- Edit files: Use ${ToolNames.EDIT} (NOT sed/awk)
- Write files: Use ${ToolNames.WRITE_FILE} (NOT echo >/cat <<EOF)
- Communication: Output text directly (NOT echo/printf)
- When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple run_shell_command tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two run_shell_command tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single run_shell_command call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before run_shell_command for git operations, or git add before git commit), run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
**Background vs Foreground Execution:**
- You should decide whether commands should run in background or foreground based on their nature:
- Use background execution (is_background: true) for:
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
${processGroupNote}
- Use foreground execution (is_background: false) for:
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
`;
if (os.platform() === 'win32') {
return `This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.${toolDescription}`;
} else {
return `This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${toolDescription}`;
}
}
function getCommandDescription(): string {
@@ -545,10 +485,6 @@ export class ShellTool extends BaseDeclarativeTool<
description:
'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
},
timeout: {
type: 'number',
description: 'Optional timeout in milliseconds (max 600000)',
},
description: {
type: 'string',
description:
@@ -586,35 +522,10 @@ export class ShellTool extends BaseDeclarativeTool<
if (getCommandRoots(params.command).length === 0) {
return 'Could not identify command root to obtain permission from user.';
}
if (params.timeout !== undefined) {
if (
typeof params.timeout !== 'number' ||
!Number.isInteger(params.timeout)
) {
return 'Timeout must be an integer number of milliseconds.';
}
if (params.timeout <= 0) {
return 'Timeout must be a positive number.';
}
if (params.timeout > 600000) {
return 'Timeout cannot exceed 600000ms (10 minutes).';
}
}
if (params.directory) {
if (!path.isAbsolute(params.directory)) {
return 'Directory must be an absolute path.';
}
const userSkillsDir = this.config.storage.getUserSkillsDir();
const resolvedDirectoryPath = path.resolve(params.directory);
const isWithinUserSkills = isSubpath(
userSkillsDir,
resolvedDirectoryPath,
);
if (isWithinUserSkills) {
return `Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.`;
}
const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
const isWithinWorkspace = workspaceDirs.some((wsDir) =>
params.directory!.startsWith(wsDir),

View File

@@ -324,9 +324,7 @@ describe('SkillTool', () => {
'Review code for quality and best practices.',
);
expect(result.returnDisplay).toBe(
'Specialized skill for reviewing code quality',
);
expect(result.returnDisplay).toBe('Launching skill: code-review');
});
it('should include allowedTools in result when present', async () => {
@@ -351,7 +349,7 @@ describe('SkillTool', () => {
// Base description is omitted from llmContent; ensure body is present.
expect(llmText).toContain('Help write comprehensive tests.');
expect(result.returnDisplay).toBe('Skill for writing and running tests');
expect(result.returnDisplay).toBe('Launching skill: testing');
});
it('should handle skill not found error', async () => {
@@ -418,7 +416,7 @@ describe('SkillTool', () => {
).createInvocation(params);
const description = invocation.getDescription();
expect(description).toBe('Use skill: "code-review"');
expect(description).toBe('Launching skill: "code-review"');
});
it('should handle skill without additional files', async () => {
@@ -438,9 +436,7 @@ describe('SkillTool', () => {
const llmText = partToString(result.llmContent);
expect(llmText).not.toContain('## Additional Files');
expect(result.returnDisplay).toBe(
'Specialized skill for reviewing code quality',
);
expect(result.returnDisplay).toBe('Launching skill: code-review');
});
});
});

View File

@@ -49,7 +49,7 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
'Execute a skill within the main conversation. Loading available skills...', // Initial description
Kind.Read,
initialSchema,
false, // isOutputMarkdown
true, // isOutputMarkdown
false, // canUpdateOutput
);
@@ -128,10 +128,6 @@ Important:
- Only use skills listed in <available_skills> below
- Do not invoke a skill that is already running
- Do not use this tool for built-in CLI commands (like /help, /clear, etc.)
- When executing scripts or loading referenced files, ALWAYS resolve absolute paths from skill's base directory. Examples:
- \`bash scripts/init.sh\` -> \`bash /path/to/skill/scripts/init.sh\`
- \`python scripts/helper.py\` -> \`python /path/to/skill/scripts/helper.py\`
- \`reference.md\` -> \`/path/to/skill/reference.md\`
</skills_instructions>
<available_skills>
@@ -187,7 +183,7 @@ class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> {
}
getDescription(): string {
return `Use skill: "${this.params.skill}"`;
return `Launching skill: "${this.params.skill}"`;
}
override async shouldConfirmExecute(): Promise<false> {
@@ -242,16 +238,16 @@ class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> {
const baseDir = path.dirname(skill.filePath);
// Build markdown content for LLM (show base dir, then body)
const llmContent = `Base directory for this skill: ${baseDir}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${skill.body}\n`;
const llmContent = `Base directory for this skill: ${baseDir}\n\n${skill.body}\n`;
return {
llmContent: [{ text: llmContent }],
returnDisplay: skill.description,
returnDisplay: `Launching skill: ${skill.name}`,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[SkillsTool] Error using skill: ${errorMessage}`);
console.error(`[SkillsTool] Error launching skill: ${errorMessage}`);
// Log failed skill launch
logSkillLaunch(

View File

@@ -17,7 +17,6 @@ import {
import { WebViewProvider } from './webview/WebViewProvider.js';
import { registerNewCommands } from './commands/index.js';
import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js';
import { isWindows } from './utils/platform.js';
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
@@ -313,38 +312,13 @@ export async function activate(context: vscode.ExtensionContext) {
'qwen-cli',
'cli.js',
).fsPath;
const execPath = process.execPath;
const lowerExecPath = execPath.toLowerCase();
const needsElectronRunAsNode =
lowerExecPath.includes('code') ||
lowerExecPath.includes('electron');
let qwenCmd: string;
const terminalOptions: vscode.TerminalOptions = {
const quote = (s: string) => `"${s.replaceAll('"', '\\"')}"`;
const qwenCmd = `${quote(process.execPath)} ${quote(cliEntry)}`;
const terminal = vscode.window.createTerminal({
name: `Qwen Code (${selectedFolder.name})`,
cwd: selectedFolder.uri.fsPath,
location,
};
if (isWindows) {
// Use system Node via cmd.exe; avoid PowerShell parsing issues
const quoteCmd = (s: string) => `"${s.replace(/"/g, '""')}"`;
const cliQuoted = quoteCmd(cliEntry);
// TODO: @yiliang114, temporarily run through node, and later hope to decouple from the local node
qwenCmd = `node ${cliQuoted}`;
terminalOptions.shellPath = process.env.ComSpec;
} else {
const quotePosix = (s: string) => `"${s.replace(/"/g, '\\"')}"`;
const baseCmd = `${quotePosix(execPath)} ${quotePosix(cliEntry)}`;
if (needsElectronRunAsNode) {
// macOS Electron helper needs ELECTRON_RUN_AS_NODE=1;
qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`;
} else {
qwenCmd = baseCmd;
}
}
const terminal = vscode.window.createTerminal(terminalOptions);
});
terminal.show();
terminal.sendText(qwenCmd);
}

View File

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

View File

@@ -9,23 +9,9 @@ import type {
File,
IdeContext,
} from '@qwen-code/qwen-code-core/src/ide/types.js';
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';
export const MAX_FILES = 10;
const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
/**
* Keeps track of the workspace state, including open files, cursor position, and selected text.
@@ -39,102 +25,33 @@ export class OpenFilesManager {
constructor(private readonly context: vscode.ExtensionContext) {
const editorWatcher = vscode.window.onDidChangeActiveTextEditor(
(editor) => {
if (editor && isFileUri(editor.document.uri)) {
addOrMoveToFront(this.openFiles, editor);
if (editor && this.isFileUri(editor.document.uri)) {
this.addOrMoveToFront(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 (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,
);
if (this.isFileUri(event.textEditor.document.uri)) {
this.updateActiveContext(event.textEditor);
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 (isFileUri(document.uri)) {
removeFile(this.openFiles, document.uri);
if (this.isFileUri(document.uri)) {
this.remove(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 (isFileUri(uri) || isNotebookFileUri(uri)) {
removeFile(this.openFiles, uri);
if (this.isFileUri(uri)) {
this.remove(uri);
}
}
this.fireWithDebounce();
@@ -142,12 +59,12 @@ export class OpenFilesManager {
const renameWatcher = vscode.workspace.onDidRenameFiles((event) => {
for (const { oldUri, newUri } of event.files) {
if (isFileUri(oldUri) || isNotebookFileUri(oldUri)) {
if (isFileUri(newUri) || isNotebookFileUri(newUri)) {
renameFile(this.openFiles, oldUri, newUri);
if (this.isFileUri(oldUri)) {
if (this.isFileUri(newUri)) {
this.rename(oldUri, newUri);
} else {
// The file was renamed to a non-file URI, so we should remove it.
removeFile(this.openFiles, oldUri);
this.remove(oldUri);
}
}
}
@@ -162,37 +79,87 @@ 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 &&
isFileUri(vscode.window.activeTextEditor.document.uri)
this.isFileUri(vscode.window.activeTextEditor.document.uri)
) {
addOrMoveToFront(this.openFiles, vscode.window.activeTextEditor);
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;
}
// 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,
);
// Remove if it exists
const index = this.openFiles.findIndex(
(f) => f.path === editor.document.uri.fsPath,
);
if (index !== -1) {
this.openFiles.splice(index, 1);
}
// Add to the front as active
this.openFiles.unshift({
path: editor.document.uri.fsPath,
timestamp: Date.now(),
isActive: true,
});
// Enforce max length
if (this.openFiles.length > MAX_FILES) {
this.openFiles.pop();
}
this.updateActiveContext(editor);
}
private remove(uri: vscode.Uri) {
const index = this.openFiles.findIndex((f) => f.path === uri.fsPath);
if (index !== -1) {
this.openFiles.splice(index, 1);
}
}
private rename(oldUri: vscode.Uri, newUri: vscode.Uri) {
const index = this.openFiles.findIndex((f) => f.path === oldUri.fsPath);
if (index !== -1) {
this.openFiles[index].path = newUri.fsPath;
}
}
private updateActiveContext(editor: vscode.TextEditor) {
const file = this.openFiles.find(
(f) => f.path === editor.document.uri.fsPath,
);
if (!file || !file.isActive) {
return;
}
file.cursor = editor.selection.active
? {
line: editor.selection.active.line + 1,
character: editor.selection.active.character,
}
: undefined;
let selectedText: string | undefined =
editor.document.getText(editor.selection) || undefined;
if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) {
selectedText =
selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]';
}
file.selectedText = selectedText;
}
private fireWithDebounce() {

View File

@@ -26,7 +26,6 @@ import type {
} from '../types/connectionTypes.js';
import { AcpFileHandler } from '../services/acpFileHandler.js';
import type { ChildProcess } from 'child_process';
import { isWindows } from '../utils/platform.js';
/**
* ACP Message Handler Class
@@ -48,7 +47,7 @@ export class AcpMessageHandler {
sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void {
if (child?.stdin) {
const jsonString = JSON.stringify(response);
const lineEnding = isWindows ? '\r\n' : '\n';
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
child.stdin.write(jsonString + lineEnding);
}
}

View File

@@ -19,7 +19,6 @@ import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { AGENT_METHODS } from '../constants/acpSchema.js';
import type { PendingRequest } from '../types/connectionTypes.js';
import type { ChildProcess } from 'child_process';
import { isWindows } from '../utils/platform.js';
/**
* ACP Session Manager Class
@@ -103,7 +102,7 @@ export class AcpSessionManager {
): void {
if (child?.stdin) {
const jsonString = JSON.stringify(message);
const lineEnding = isWindows ? '\r\n' : '\n';
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
child.stdin.write(jsonString + lineEnding);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,101 +0,0 @@
/**
* @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;
}

View File

@@ -1,8 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/** Whether the current platform is Windows */
export const isWindows = process.platform === 'win32';