Compare commits

..

15 Commits

Author SHA1 Message Date
DragonnZhang
4c8414488f refactor: reorder feedback options and improve dialog feedback timestamp handling 2026-01-23 18:55:43 +08:00
DragonnZhang
6327e35a14 feat: implement persistent feedback prompting with temporary dismissal options
Add 'Fine' and 'Dismiss' options to feedback dialogs that allow temporary
dismissal without permanently closing the feedback request. Only numerical
ratings (0, 1, 2, 3) will permanently close feedback dialogs, while all
other inputs result in temporary dismissal with persistent re-prompting.

This ensures feedback collection reliability while respecting user workflow
by allowing users to temporarily dismiss prompts when busy and providing
feedback when ready.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-23 16:52:40 +08:00
Mingholy
829ba9c431 Merge pull request #1516 from QwenLM/mingholy/fix/runtime-timeout
feat: add runtime-aware fetch options for Anthropic and OpenAI providers
2026-01-23 14:27:50 +08:00
tanzhenxin
8d0f785c28 Merge pull request #1572 from weiyuanke/patch-1
Update command usage in add.ts to reflect new name
2026-01-23 09:33:01 +08:00
tanzhenxin
6be47fe008 Merge pull request #1542 from QwenLM/vscode-ide-companion-github-action-publish
Add VSCode IDE Companion Release Workflow
2026-01-23 09:32:39 +08:00
tanzhenxin
29e71a5d7d Merge pull request #1553 from QwenLM/feature/add-trendshift-badge
docs: add Trendshift badge to README
2026-01-23 09:15:12 +08:00
yiliang114
bfe451bb4a ci(vscode-ide-companion): improve release workflow and fix yaml lint errors
- Fix yaml lint errors by properly quoting conditional expressions
- Update package version step to use correct working directory
- Modify test execution to run in the correct directory (packages/vscode-ide-companion)
- Enhance version retrieval logic to use actual package version for preview releases
- Add working directory to all relevant steps for consistency
- Simplify package version update command by removing redundant workspace flag

These changes ensure the release workflow runs correctly and follows
consistent directory structure practices.
2026-01-22 21:40:09 +08:00
yiliang114
c143c68656 Merge branch 'main' of https://github.com/QwenLM/qwen-code into vscode-ide-companion-github-action-publish 2026-01-22 21:19:35 +08:00
顾盼
011f3d2320 Merge pull request #1580 from QwenLM/feat/extension-improvements
feat(extensions): add detail command and improve extension validation
2026-01-22 20:00:55 +08:00
LaZzyMan
674bb6386e feat(extensions): add detail command and improve extension validation
- Add /extensions detail command to show extension details
- Allow underscores and dots in extension names
- Fix contextFileName empty array handling to use default QWEN.md
- Fix marketplace extension clone to use correct source URL
- Add inline parameter to extensionToOutputString
- Add comprehensive tests for all changes
2026-01-22 19:37:01 +08:00
yuanke wei
27df0486a3 Update command usage in add.ts to reflect new name 2026-01-22 09:56:59 +08:00
pomelo-nwu
47ee9b5db8 docs: add Trendshift badge to README
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-20 16:17:49 +08:00
yiliang114
605e8709fb build(vscode): Add VSCode IDE Companion Publish Workflow 2026-01-19 15:04:05 +08:00
mingholy.lmh
4a0e55530b test: mock runtime fetch options in DashScope and Default OpenAI providers 2026-01-19 11:37:10 +08:00
mingholy.lmh
510d38fe3a feat: add runtime-aware fetch options for Anthropic and OpenAI providers 2026-01-16 17:18:48 +08:00
33 changed files with 951 additions and 125 deletions

View File

@@ -0,0 +1,207 @@
name: 'Release VSCode IDE Companion'
on:
workflow_dispatch:
inputs:
version:
description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.'
required: false
type: 'string'
ref:
description: 'The branch or ref (full git sha) to release from.'
required: true
type: 'string'
default: 'main'
dry_run:
description: 'Run a dry-run of the release process; no branches, vsix packages or GitHub releases will be created.'
required: true
type: 'boolean'
default: true
create_preview_release:
description: 'Auto apply the preview release tag, input version is ignored.'
required: false
type: 'boolean'
default: false
force_skip_tests:
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
required: false
type: 'boolean'
default: false
concurrency:
group: '${{ github.workflow }}'
cancel-in-progress: false
jobs:
release-vscode-companion:
runs-on: 'ubuntu-latest'
environment:
name: 'production-release'
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ steps.version.outputs.RELEASE_TAG }}'
if: |-
${{ github.repository == 'QwenLM/qwen-code' }}
permissions:
contents: 'read'
issues: 'write'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- name: 'Set booleans for simplified logic'
env:
CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}'
DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}'
id: 'vars'
run: |-
is_preview="false"
if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then
is_preview="true"
fi
echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}"
is_dry_run="false"
if [[ "${DRY_RUN_INPUT}" == "true" ]]; then
is_dry_run="true"
fi
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
npm ci
- name: 'Install VSCE and OVSX'
run: |-
npm install -g @vscode/vsce
npm install -g ovsx
- name: 'Get the version'
id: 'version'
working-directory: 'packages/vscode-ide-companion'
run: |
# Get the base version from package.json regardless of scenario
BASE_VERSION=$(node -p "require('./package.json').version")
if [[ "${IS_PREVIEW}" == "true" ]]; then
# Generate preview version with timestamp based on actual package version
TIMESTAMP=$(date +%Y%m%d%H%M%S)
PREVIEW_VERSION="${BASE_VERSION}-preview.${TIMESTAMP}"
RELEASE_TAG="preview.${TIMESTAMP}"
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=${PREVIEW_VERSION}" >> "$GITHUB_OUTPUT"
echo "VSCODE_TAG=preview" >> "$GITHUB_OUTPUT"
else
# Use specified version or get from package.json
if [[ -n "${MANUAL_VERSION}" ]]; then
RELEASE_VERSION="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
RELEASE_TAG="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
else
RELEASE_VERSION="${BASE_VERSION}"
RELEASE_TAG="${BASE_VERSION}"
fi
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "VSCODE_TAG=latest" >> "$GITHUB_OUTPUT"
fi
env:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Update package version (for preview releases)'
if: '${{ steps.vars.outputs.is_preview == ''true'' }}'
working-directory: 'packages/vscode-ide-companion'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
# Update package.json with preview version
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Run Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
working-directory: 'packages/vscode-ide-companion'
run: |
npm run test:ci
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
- name: 'Prepare VSCode Extension'
run: |
# Build and stage the extension + bundled CLI once.
npm --workspace=qwen-code-vscode-ide-companion run prepackage
- name: 'Package VSIX (dry run)'
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
working-directory: 'packages/vscode-ide-companion'
run: |-
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
else
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
fi
- name: 'Upload VSIX Artifact (dry run)'
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
uses: 'actions/upload-artifact@v4'
with:
name: 'qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
path: 'packages/qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
if-no-files-found: 'error'
- name: 'Publish to Microsoft Marketplace'
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
working-directory: 'packages/vscode-ide-companion'
env:
VSCE_PAT: '${{ secrets.VSCE_PAT }}'
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
run: |-
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
echo "Skipping Microsoft Marketplace for preview release"
else
vsce publish --pat "${VSCE_PAT}" --tag "${VSCODE_TAG}"
fi
- name: 'Publish to OpenVSX'
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
working-directory: 'packages/vscode-ide-companion'
env:
OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}'
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
run: |-
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
# For preview releases, publish with preview tag
# First package the extension for preview
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --pre-release
else
# Package and publish normally
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --tag "${VSCODE_TAG}"
fi
- name: 'Create Issue on Failure'
if: |-
${{ failure() }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
run: |-
gh issue create \
--title "VSCode IDE Companion Release Failed for ${{ steps.version.outputs.RELEASE_VERSION }} on $(date +'%Y-%m-%d')" \
--body "The VSCode IDE Companion release workflow failed. See the full run for details: ${DETAILS_URL}"

View File

@@ -5,6 +5,8 @@
[![Node.js Version](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg)](https://nodejs.org/)
[![Downloads](https://img.shields.io/npm/dm/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code)
<a href="https://trendshift.io/repositories/15287" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15287" alt="QwenLM%2Fqwen-code | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
**An open-source AI agent that lives in your terminal.**
<a href="https://qwenlm.github.io/qwen-code-docs/zh/users/overview">中文</a> |

View File

@@ -5,7 +5,8 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getExtensionManager } from './utils.js';
import { getExtensionManager, extensionToOutputString } from './utils.js';
import type { Extension, ExtensionManager } from '@qwen-code/qwen-code-core';
const mockRefreshCache = vi.fn();
const mockExtensionManagerInstance = {
@@ -64,3 +65,70 @@ describe('getExtensionManager', () => {
);
});
});
describe('extensionToOutputString', () => {
const mockIsEnabled = vi.fn();
const mockExtensionManager = {
isEnabled: mockIsEnabled,
} as unknown as ExtensionManager;
const createMockExtension = (overrides = {}): Extension => ({
id: 'test-ext-id',
name: 'test-extension',
version: '1.0.0',
isActive: true,
path: '/path/to/extension',
contextFiles: [],
config: { name: 'test-extension', version: '1.0.0' },
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
mockIsEnabled.mockReturnValue(true);
});
it('should include status icon when inline is false', () => {
const extension = createMockExtension();
const result = extensionToOutputString(
extension,
mockExtensionManager,
'/workspace',
false,
);
// Should contain either ✓ or ✗ (with ANSI color codes)
expect(result).toMatch(/test-extension/);
expect(result).toContain('(1.0.0)');
});
it('should exclude status icon when inline is true', () => {
const extension = createMockExtension();
const result = extensionToOutputString(
extension,
mockExtensionManager,
'/workspace',
true,
);
// Should start with extension name (after stripping potential whitespace)
expect(result.trim()).toMatch(/^test-extension/);
});
it('should default inline to false', () => {
const extension = createMockExtension();
const resultWithoutInline = extensionToOutputString(
extension,
mockExtensionManager,
'/workspace',
);
const resultWithInlineFalse = extensionToOutputString(
extension,
mockExtensionManager,
'/workspace',
false,
);
expect(resultWithoutInline).toEqual(resultWithInlineFalse);
});
});

View File

@@ -32,6 +32,7 @@ export function extensionToOutputString(
extension: Extension,
extensionManager: ExtensionManager,
workspaceDir: string,
inline = false,
): string {
const cwd = workspaceDir;
const userEnabled = extensionManager.isEnabled(
@@ -44,7 +45,7 @@ export function extensionToOutputString(
);
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${status} ${extension.config.name} (${extension.config.version})`;
let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;

View File

@@ -139,7 +139,7 @@ export const addCommand: CommandModule = {
describe: 'Add a server',
builder: (yargs) =>
yargs
.usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
.usage('Usage: qwen mcp add [options] <name> <commandOrUrl> [args...]')
.parserConfiguration({
'unknown-options-as-args': true, // Pass unknown options as server args
'populate--': true, // Populate server args after -- separator

View File

@@ -298,7 +298,9 @@ export default {
'How is Qwen doing this session? (optional)':
'Wie macht sich Qwen in dieser Sitzung? (optional)',
Bad: 'Schlecht',
Fine: 'In Ordnung',
Good: 'Gut',
Dismiss: 'Ignorieren',
'Not Sure Yet': 'Noch nicht sicher',
'Any other key': 'Beliebige andere Taste',
'Disable Loading Phrases': 'Ladesprüche deaktivieren',

View File

@@ -315,7 +315,9 @@ export default {
'How is Qwen doing this session? (optional)':
'How is Qwen doing this session? (optional)',
Bad: 'Bad',
Fine: 'Fine',
Good: 'Good',
Dismiss: 'Dismiss',
'Not Sure Yet': 'Not Sure Yet',
'Any other key': 'Any other key',
'Disable Loading Phrases': 'Disable Loading Phrases',

View File

@@ -319,7 +319,9 @@ export default {
'How is Qwen doing this session? (optional)':
'Как дела у Qwen в этой сессии? (необязательно)',
Bad: 'Плохо',
Fine: 'Нормально',
Good: 'Хорошо',
Dismiss: 'Отклонить',
'Not Sure Yet': 'Пока не уверен',
'Any other key': 'Любая другая клавиша',
'Disable Loading Phrases': 'Отключить фразы при загрузке',

View File

@@ -305,7 +305,9 @@ export default {
'Enable User Feedback': '启用用户反馈',
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
Bad: '不满意',
Fine: '还行',
Good: '满意',
Dismiss: '忽略',
'Not Sure Yet': '暂不评价',
'Any other key': '任意其他键',
'Disable Loading Phrases': '禁用加载短语',

View File

@@ -1326,6 +1326,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
@@ -1571,6 +1572,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
}),
[
@@ -1611,6 +1613,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
],
);

View File

@@ -5,19 +5,21 @@ import { useUIActions } from './contexts/UIActionsContext.js';
import { useUIState } from './contexts/UIStateContext.js';
import { useKeypress } from './hooks/useKeypress.js';
const FEEDBACK_OPTIONS = {
export const FEEDBACK_OPTIONS = {
GOOD: 1,
BAD: 2,
NOT_SURE: 3,
FINE: 3,
DISMISS: 0,
} as const;
const FEEDBACK_OPTION_KEYS = {
[FEEDBACK_OPTIONS.GOOD]: '1',
[FEEDBACK_OPTIONS.BAD]: '2',
[FEEDBACK_OPTIONS.NOT_SURE]: 'any',
[FEEDBACK_OPTIONS.FINE]: '3',
[FEEDBACK_OPTIONS.DISMISS]: '0',
} as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2'] as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2', '3', '0'] as const;
export const FeedbackDialog: React.FC = () => {
const uiState = useUIState();
@@ -25,15 +27,19 @@ export const FeedbackDialog: React.FC = () => {
useKeypress(
(key) => {
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
// Handle keys 0-3: permanent close with feedback/dismiss
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.BAD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.FINE]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.FINE);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.DISMISS]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.DISMISS);
} else {
uiActions.submitFeedback(FEEDBACK_OPTIONS.NOT_SURE);
// Handle other keys: temporary close
uiActions.temporaryCloseFeedbackDialog();
}
uiActions.closeFeedbackDialog();
},
{ isActive: uiState.isFeedbackDialogOpen },
);
@@ -53,8 +59,16 @@ export const FeedbackDialog: React.FC = () => {
<Text color="cyan">{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]}: </Text>
<Text>{t('Bad')}</Text>
<Text> </Text>
<Text color="cyan">{t('Any other key')}: </Text>
<Text>{t('Not Sure Yet')}</Text>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.FINE]}:{' '}
</Text>
<Text>{t('Fine')}</Text>
<Text> </Text>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.DISMISS]}:{' '}
</Text>
<Text>{t('Dismiss')}</Text>
<Text> </Text>
</Box>
</Box>
);

View File

@@ -777,4 +777,87 @@ describe('extensionsCommand', () => {
);
});
});
describe('detail', () => {
const detailAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'detail',
)?.action;
if (!detailAction) {
throw new Error('Detail action not found');
}
let realMockExtensionManager: ExtensionManager;
beforeEach(() => {
vi.resetAllMocks();
realMockExtensionManager = Object.create(ExtensionManager.prototype);
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
mockContext = createMockCommandContext({
invocation: {
raw: '/extensions detail',
name: 'detail',
args: '',
},
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () => realMockExtensionManager,
},
},
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
});
});
it('should show usage if no name is provided', async () => {
await detailAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions detail <extension-name>',
},
expect.any(Number),
);
});
it('should show error if extension not found', async () => {
mockGetExtensions.mockReturnValue([]);
await detailAction(mockContext, 'nonexistent-extension');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Extension "nonexistent-extension" not found.',
},
expect.any(Number),
);
});
it('should show extension details when found', async () => {
const extension: Extension = {
id: 'test-ext',
name: 'test-ext',
version: '1.0.0',
isActive: true,
path: '/test/dir/test-ext',
contextFiles: [],
config: { name: 'test-ext', version: '1.0.0' },
};
mockGetExtensions.mockReturnValue([extension]);
realMockExtensionManager.isEnabled = vi.fn().mockReturnValue(true);
await detailAction(mockContext, 'test-ext');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining('test-ext'),
},
expect.any(Number),
);
});
});
});

View File

@@ -20,6 +20,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { SettingScope } from '../../config/settings.js';
import open from 'open';
import { extensionToOutputString } from '../../commands/extensions/utils.js';
const EXTENSION_EXPLORE_URL = {
Gemini: 'https://geminicli.com/extensions/',
@@ -475,6 +476,53 @@ async function enableAction(context: CommandContext, args: string) {
}
}
async function detailAction(context: CommandContext, args: string) {
const extensionManager = context.services.config?.getExtensionManager();
if (!(extensionManager instanceof ExtensionManager)) {
console.error(
`Cannot ${context.invocation?.name} extensions in this environment`,
);
return;
}
const name = args.trim();
if (!name) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Usage: /extensions detail <extension-name>'),
},
Date.now(),
);
return;
}
const extensions = context.services.config!.getExtensions();
const extension = extensions.find((extension) => extension.name === name);
if (!extension) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Extension "{{name}}" not found.', { name }),
},
Date.now(),
);
return;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: extensionToOutputString(
extension,
extensionManager,
process.cwd(),
true,
),
},
Date.now(),
);
}
export async function completeExtensions(
context: CommandContext,
partialArg: string,
@@ -495,7 +543,10 @@ export async function completeExtensions(
name.startsWith(partialArg),
);
if (context.invocation?.name !== 'uninstall') {
if (
context.invocation?.name !== 'uninstall' &&
context.invocation?.name !== 'detail'
) {
if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
suggestions.unshift('--all');
}
@@ -594,6 +645,16 @@ const uninstallCommand: SlashCommand = {
completion: completeExtensions,
};
const detailCommand: SlashCommand = {
name: 'detail',
get description() {
return t('Get detail of an extension');
},
kind: CommandKind.BUILT_IN,
action: detailAction,
completion: completeExtensions,
};
export const extensionsCommand: SlashCommand = {
name: 'extensions',
get description() {
@@ -608,6 +669,7 @@ export const extensionsCommand: SlashCommand = {
installCommand,
uninstallCommand,
exploreExtensionsCommand,
detailCommand,
],
action: (context, args) =>
// Default to list if no subcommand is provided

View File

@@ -36,6 +36,11 @@ vi.mock('../utils/clipboardUtils.js');
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
}));
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => ({
temporaryCloseFeedbackDialog: vi.fn(),
})),
}));
const mockSlashCommands: SlashCommand[] = [
{

View File

@@ -37,6 +37,7 @@ import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
export interface InputPromptProps {
buffer: TextBuffer;
@@ -109,6 +110,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}) => {
const isShellFocused = useShellFocusState();
const uiState = useUIState();
const uiActions = useUIActions();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -337,12 +339,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Intercept feedback dialog option keys (1, 2) when dialog is open
if (
uiState.isFeedbackDialogOpen &&
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
) {
return;
// Handle feedback dialog keyboard interactions when dialog is open
if (uiState.isFeedbackDialogOpen) {
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
return;
} else {
// For any other key, close feedback dialog temporarily and continue with normal processing
uiActions.temporaryCloseFeedbackDialog();
// Continue processing the key for normal input handling
}
}
// Reset ESC count and hide prompt on any non-ESC key
@@ -712,6 +718,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onToggleShortcuts,
showShortcuts,
uiState,
uiActions,
],
);

View File

@@ -71,6 +71,7 @@ export interface UIActions {
// Feedback dialog
openFeedbackDialog: () => void;
closeFeedbackDialog: () => void;
temporaryCloseFeedbackDialog: () => void;
submitFeedback: (rating: number) => void;
}

View File

@@ -15,6 +15,7 @@ import {
USER_SETTINGS_PATH,
} from '../../config/settings.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import { FEEDBACK_OPTIONS } from '../FeedbackDialog.js';
import stripJsonComments from 'strip-json-comments';
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
@@ -96,37 +97,48 @@ export const useFeedbackDialog = ({
}: UseFeedbackDialogProps) => {
// Feedback dialog state
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const [isFeedbackDismissedTemporarily, setIsFeedbackDismissedTemporarily] =
useState(false);
const openFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(true);
// Record the timestamp when feedback dialog is shown (fire and forget)
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
}, [settings]);
}, []);
const closeFeedbackDialog = useCallback(
() => setIsFeedbackDialogOpen(false),
[],
);
const temporaryCloseFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(false);
setIsFeedbackDismissedTemporarily(true);
}, []);
const submitFeedback = useCallback(
(rating: number) => {
// Create and log the feedback event
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
// Only create and log feedback event for ratings 1-3 (GOOD, BAD, FINE)
// Rating 0 (DISMISS) should not trigger any telemetry
if (rating >= FEEDBACK_OPTIONS.GOOD && rating <= FEEDBACK_OPTIONS.FINE) {
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
);
logUserFeedback(config, feedbackEvent);
}
// Record the timestamp when feedback dialog is submitted
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
logUserFeedback(config, feedbackEvent);
closeFeedbackDialog();
},
[config, sessionStats, closeFeedbackDialog],
[closeFeedbackDialog, sessionStats.sessionId, config, settings],
);
useEffect(() => {
@@ -140,13 +152,15 @@ export const useFeedbackDialog = ({
// 5. Random chance (25% probability)
// 6. Meets minimum requirements (tool calls > 10 OR user messages > 5)
// 7. Fatigue mechanism allows showing (not shown recently across sessions)
// 8. Not temporarily dismissed
if (
config.getAuthType() !== AuthType.QWEN_OAUTH ||
!config.getUsageStatisticsEnabled() ||
settings.merged.ui?.enableUserFeedback === false ||
!lastMessageIsAIResponse(history) ||
Math.random() > FEEDBACK_SHOW_PROBABILITY ||
!meetsMinimumSessionRequirements(sessionStats)
!meetsMinimumSessionRequirements(sessionStats) ||
isFeedbackDismissedTemporarily
) {
return;
}
@@ -164,15 +178,27 @@ export const useFeedbackDialog = ({
history,
sessionStats,
isFeedbackDialogOpen,
isFeedbackDismissedTemporarily,
openFeedbackDialog,
settings.merged.ui?.enableUserFeedback,
config,
]);
// Reset temporary dismissal when a new AI response starts streaming
useEffect(() => {
if (
streamingState === StreamingState.Responding &&
isFeedbackDismissedTemporarily
) {
setIsFeedbackDismissedTemporarily(false);
}
}, [streamingState, isFeedbackDismissedTemporarily]);
return {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
};
};

View File

@@ -28,6 +28,7 @@ type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent;
import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js';
import { safeJsonParse } from '../../utils/safeJsonParse.js';
import { AnthropicContentConverter } from './converter.js';
import { buildRuntimeFetchOptions } from '../../utils/runtimeFetchOptions.js';
type StreamingBlockState = {
type: string;
@@ -54,6 +55,9 @@ export class AnthropicContentGenerator implements ContentGenerator {
) {
const defaultHeaders = this.buildHeaders();
const baseURL = contentGeneratorConfig.baseUrl;
// Configure runtime options to ensure user-configured timeout works as expected
// bodyTimeout is always disabled (0) to let Anthropic SDK timeout control the request
const runtimeOptions = buildRuntimeFetchOptions('anthropic');
this.client = new Anthropic({
apiKey: contentGeneratorConfig.apiKey,
@@ -61,6 +65,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
timeout: contentGeneratorConfig.timeout,
maxRetries: contentGeneratorConfig.maxRetries,
defaultHeaders,
...runtimeOptions,
});
this.converter = new AnthropicContentConverter(

View File

@@ -19,6 +19,8 @@ import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { AuthType } from '../../contentGenerator.js';
import type { ChatCompletionToolWithCache } from './types.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
import type { OpenAIRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
// Mock OpenAI
vi.mock('openai', () => ({
@@ -32,6 +34,10 @@ vi.mock('openai', () => ({
})),
}));
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
buildRuntimeFetchOptions: vi.fn(),
}));
describe('DashScopeOpenAICompatibleProvider', () => {
let provider: DashScopeOpenAICompatibleProvider;
let mockContentGeneratorConfig: ContentGeneratorConfig;
@@ -39,6 +45,11 @@ describe('DashScopeOpenAICompatibleProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
const mockedBuildRuntimeFetchOptions =
buildRuntimeFetchOptions as unknown as MockedFunction<
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
>;
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
// Mock ContentGeneratorConfig
mockContentGeneratorConfig = {
@@ -185,18 +196,20 @@ describe('DashScopeOpenAICompatibleProvider', () => {
it('should create OpenAI client with DashScope configuration', () => {
const client = provider.buildClient();
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeout: 60000,
maxRetries: 2,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
'X-DashScope-CacheControl': 'enable',
'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
'X-DashScope-AuthType': AuthType.QWEN_OAUTH,
},
});
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-api-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeout: 60000,
maxRetries: 2,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
'X-DashScope-CacheControl': 'enable',
'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
'X-DashScope-AuthType': AuthType.QWEN_OAUTH,
},
}),
);
expect(client).toBeDefined();
});
@@ -207,13 +220,15 @@ describe('DashScopeOpenAICompatibleProvider', () => {
provider.buildClient();
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeout: DEFAULT_TIMEOUT,
maxRetries: DEFAULT_MAX_RETRIES,
defaultHeaders: expect.any(Object),
});
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-api-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeout: DEFAULT_TIMEOUT,
maxRetries: DEFAULT_MAX_RETRIES,
defaultHeaders: expect.any(Object),
}),
);
});
});

View File

@@ -16,6 +16,7 @@ import type {
ChatCompletionContentPartWithCache,
ChatCompletionToolWithCache,
} from './types.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
export class DashScopeOpenAICompatibleProvider
implements OpenAICompatibleProvider
@@ -68,12 +69,16 @@ export class DashScopeOpenAICompatibleProvider
maxRetries = DEFAULT_MAX_RETRIES,
} = this.contentGeneratorConfig;
const defaultHeaders = this.buildHeaders();
// Configure fetch options to ensure user-configured timeout works as expected
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
const fetchOptions = buildRuntimeFetchOptions('openai');
return new OpenAI({
apiKey,
baseURL: baseUrl,
timeout,
maxRetries,
defaultHeaders,
...(fetchOptions ? { fetchOptions } : {}),
});
}

View File

@@ -17,6 +17,8 @@ import { DefaultOpenAICompatibleProvider } from './default.js';
import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
import type { OpenAIRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
// Mock OpenAI
vi.mock('openai', () => ({
@@ -30,6 +32,10 @@ vi.mock('openai', () => ({
})),
}));
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
buildRuntimeFetchOptions: vi.fn(),
}));
describe('DefaultOpenAICompatibleProvider', () => {
let provider: DefaultOpenAICompatibleProvider;
let mockContentGeneratorConfig: ContentGeneratorConfig;
@@ -37,6 +43,11 @@ describe('DefaultOpenAICompatibleProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
const mockedBuildRuntimeFetchOptions =
buildRuntimeFetchOptions as unknown as MockedFunction<
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
>;
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
// Mock ContentGeneratorConfig
mockContentGeneratorConfig = {
@@ -112,15 +123,17 @@ describe('DefaultOpenAICompatibleProvider', () => {
it('should create OpenAI client with correct configuration', () => {
const client = provider.buildClient();
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
baseURL: 'https://api.openai.com/v1',
timeout: 60000,
maxRetries: 2,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
},
});
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-api-key',
baseURL: 'https://api.openai.com/v1',
timeout: 60000,
maxRetries: 2,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
},
}),
);
expect(client).toBeDefined();
});
@@ -131,15 +144,17 @@ describe('DefaultOpenAICompatibleProvider', () => {
provider.buildClient();
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
baseURL: 'https://api.openai.com/v1',
timeout: DEFAULT_TIMEOUT,
maxRetries: DEFAULT_MAX_RETRIES,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
},
});
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-api-key',
baseURL: 'https://api.openai.com/v1',
timeout: DEFAULT_TIMEOUT,
maxRetries: DEFAULT_MAX_RETRIES,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
},
}),
);
});
it('should include custom headers from buildHeaders', () => {

View File

@@ -4,6 +4,7 @@ import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
import type { OpenAICompatibleProvider } from './types.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
/**
* Default provider for standard OpenAI-compatible APIs
@@ -43,12 +44,16 @@ export class DefaultOpenAICompatibleProvider
maxRetries = DEFAULT_MAX_RETRIES,
} = this.contentGeneratorConfig;
const defaultHeaders = this.buildHeaders();
// Configure fetch options to ensure user-configured timeout works as expected
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
const fetchOptions = buildRuntimeFetchOptions('openai');
return new OpenAI({
apiKey,
baseURL: baseUrl,
timeout,
maxRetries,
defaultHeaders,
...(fetchOptions ? { fetchOptions } : {}),
});
}

View File

@@ -218,6 +218,30 @@ describe('extension tests', () => {
]);
});
it('should use default QWEN.md when contextFileName is empty array', async () => {
const extDir = path.join(userExtensionsDir, 'ext-empty-context');
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({
name: 'ext-empty-context',
version: '1.0.0',
contextFileName: [],
}),
);
fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context content');
const manager = createExtensionManager();
await manager.refreshCache();
const extensions = manager.getLoadedExtensions();
expect(extensions).toHaveLength(1);
const ext = extensions.find((e) => e.config.name === 'ext-empty-context');
expect(ext?.contextFiles).toEqual([
path.join(userExtensionsDir, 'ext-empty-context', 'QWEN.md'),
]);
});
it('should skip extensions with invalid JSON and log a warning', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
@@ -694,13 +718,14 @@ describe('extension tests', () => {
expect(() => validateName('UPPERCASE')).not.toThrow();
});
it('should accept names with underscores and dots', () => {
expect(() => validateName('my_extension')).not.toThrow();
expect(() => validateName('my.extension')).not.toThrow();
expect(() => validateName('my_ext.v1')).not.toThrow();
expect(() => validateName('ext_1.2.3')).not.toThrow();
});
it('should reject names with invalid characters', () => {
expect(() => validateName('my_extension')).toThrow(
'Invalid extension name',
);
expect(() => validateName('my.extension')).toThrow(
'Invalid extension name',
);
expect(() => validateName('my extension')).toThrow(
'Invalid extension name',
);

View File

@@ -190,7 +190,7 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
}
function getContextFileNames(config: ExtensionConfig): string[] {
if (!config.contextFileName) {
if (!config.contextFileName || config.contextFileName.length === 0) {
return ['QWEN.md'];
} else if (!Array.isArray(config.contextFileName)) {
return [config.contextFileName];
@@ -1244,9 +1244,9 @@ export function hashValue(value: string): string {
}
export function validateName(name: string) {
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
if (!/^[a-zA-Z0-9-_.]+$/.test(name)) {
throw new Error(
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`,
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), underscores (_), dots (.), and dashes (-) are allowed.`,
);
}
}

View File

@@ -117,6 +117,51 @@ describe('git extension helpers', () => {
'Failed to clone Git repository from http://my-repo.com',
);
});
it('should use marketplace source for marketplace type extensions', async () => {
const installMetadata = {
source: 'marketplace:my-plugin',
type: 'marketplace' as const,
marketplace: {
pluginName: 'my-plugin',
marketplaceSource: 'https://github.com/marketplace/my-plugin',
},
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{
name: 'origin',
refs: { fetch: 'https://github.com/marketplace/my-plugin' },
},
]);
await cloneFromGit(installMetadata, destination);
expect(mockGit.clone).toHaveBeenCalledWith(
'https://github.com/marketplace/my-plugin',
'./',
['--depth', '1'],
);
});
it('should use source for marketplace type without marketplace metadata', async () => {
const installMetadata = {
source: 'http://fallback-repo.com',
type: 'marketplace' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://fallback-repo.com' } },
]);
await cloneFromGit(installMetadata, destination);
expect(mockGit.clone).toHaveBeenCalledWith(
'http://fallback-repo.com',
'./',
['--depth', '1'],
);
});
});
describe('checkForExtensionUpdate', () => {

View File

@@ -53,7 +53,10 @@ export async function cloneFromGit(
): Promise<void> {
try {
const git = simpleGit(destination);
let sourceUrl = installMetadata.source;
let sourceUrl =
installMetadata.type === 'marketplace' && installMetadata.marketplace
? installMetadata.marketplace.marketplaceSource
: installMetadata.source;
const token = getGitHubToken();
if (token) {
try {

View File

@@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { EnvHttpProxyAgent } from 'undici';
/**
* JavaScript runtime type
*/
export type Runtime = 'node' | 'bun' | 'unknown';
/**
* Detect the current JavaScript runtime
*/
export function detectRuntime(): Runtime {
if (typeof process !== 'undefined' && process.versions?.['bun']) {
return 'bun';
}
if (typeof process !== 'undefined' && process.versions?.node) {
return 'node';
}
return 'unknown';
}
/**
* Runtime fetch options for OpenAI SDK
*/
export type OpenAIRuntimeFetchOptions =
| {
dispatcher?: EnvHttpProxyAgent;
timeout?: false;
}
| undefined;
/**
* Runtime fetch options for Anthropic SDK
*/
export type AnthropicRuntimeFetchOptions = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpAgent?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetch?: any;
};
/**
* SDK type identifier
*/
export type SDKType = 'openai' | 'anthropic';
/**
* Build runtime-specific fetch options for OpenAI SDK
*/
export function buildRuntimeFetchOptions(
sdkType: 'openai',
): OpenAIRuntimeFetchOptions;
/**
* Build runtime-specific fetch options for Anthropic SDK
*/
export function buildRuntimeFetchOptions(
sdkType: 'anthropic',
): AnthropicRuntimeFetchOptions;
/**
* Build runtime-specific fetch options based on the detected runtime and SDK type
* This function applies runtime-specific configurations to handle timeout differences
* across Node.js and Bun, ensuring user-configured timeout works as expected.
*
* @param sdkType - The SDK type ('openai' or 'anthropic') to determine return type
* @returns Runtime-specific options compatible with the specified SDK
*/
export function buildRuntimeFetchOptions(
sdkType: SDKType,
): OpenAIRuntimeFetchOptions | AnthropicRuntimeFetchOptions {
const runtime = detectRuntime();
// Always disable bodyTimeout (set to 0) to let SDK's timeout parameter
// control the total request time. bodyTimeout only monitors intervals between
// data chunks, not the total request time, so we disable it to ensure user-configured
// timeout works as expected for both streaming and non-streaming requests.
switch (runtime) {
case 'bun': {
if (sdkType === 'openai') {
// Bun: Disable built-in 300s timeout to let OpenAI SDK timeout control
// This ensures user-configured timeout works as expected without interference
return {
timeout: false,
};
} else {
// Bun: Use custom fetch to disable built-in 300s timeout
// This allows Anthropic SDK timeout to control the request
// Note: Bun's fetch automatically uses proxy settings from environment variables
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY), so proxy behavior is preserved
const bunFetch: typeof fetch = async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const bunFetchOptions: RequestInit = {
...init,
// @ts-expect-error - Bun-specific timeout option
timeout: false,
};
return fetch(input, bunFetchOptions);
};
return {
fetch: bunFetch,
};
}
}
case 'node': {
// Node.js: Use EnvHttpProxyAgent to configure proxy and disable bodyTimeout
// EnvHttpProxyAgent automatically reads proxy settings from environment variables
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, etc.) to preserve proxy functionality
// bodyTimeout is always 0 (disabled) to let SDK timeout control the request
try {
const agent = new EnvHttpProxyAgent({
bodyTimeout: 0, // Disable to let SDK timeout control total request time
});
if (sdkType === 'openai') {
return {
dispatcher: agent,
};
} else {
return {
httpAgent: agent,
};
}
} catch {
// If undici is not available, return appropriate default
if (sdkType === 'openai') {
return undefined;
} else {
return {};
}
}
}
default: {
// Unknown runtime: Try to use EnvHttpProxyAgent if available
// EnvHttpProxyAgent automatically reads proxy settings from environment variables
try {
const agent = new EnvHttpProxyAgent({
bodyTimeout: 0, // Disable to let SDK timeout control total request time
});
if (sdkType === 'openai') {
return {
dispatcher: agent,
};
} else {
return {
httpAgent: agent,
};
}
} catch {
if (sdkType === 'openai') {
return undefined;
} else {
return {};
}
}
}
}
}

View File

@@ -1,14 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export interface PermissionResponsePayload {
optionId: string;
}
export interface PermissionResponseMessage {
type: string;
data: PermissionResponsePayload;
}

View File

@@ -431,7 +431,6 @@ export const App: React.FC = () => {
type: 'permissionResponse',
data: { optionId },
});
setPermissionRequest(null);
},
[vscode],

View File

@@ -6,7 +6,6 @@
import type { QwenAgentManager } from '../services/qwenAgentManager.js';
import type { ConversationStore } from '../services/conversationStore.js';
import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js';
import { MessageRouter } from './handlers/MessageRouter.js';
/**
@@ -56,7 +55,7 @@ export class MessageHandler {
* Set permission handler
*/
setPermissionHandler(
handler: (message: PermissionResponseMessage) => void,
handler: (message: { type: string; data: { optionId: string } }) => void,
): void {
this.router.setPermissionHandler(handler);
}

View File

@@ -8,7 +8,6 @@ import * as vscode from 'vscode';
import { QwenAgentManager } from '../services/qwenAgentManager.js';
import { ConversationStore } from '../services/conversationStore.js';
import type { AcpPermissionRequest } from '../types/acpTypes.js';
import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js';
import { PanelManager } from '../webview/PanelManager.js';
import { MessageHandler } from '../webview/MessageHandler.js';
import { WebViewContent } from '../webview/WebViewContent.js';
@@ -252,7 +251,10 @@ export class WebViewProvider {
}
}
};
const handler = (message: PermissionResponseMessage) => {
const handler = (message: {
type: string;
data: { optionId: string };
}) => {
if (message.type !== 'permissionResponse') {
return;
}
@@ -268,16 +270,6 @@ export class WebViewProvider {
optionId.toLowerCase().includes('reject');
if (isCancel) {
// Close any open qwen-diff editors first
try {
void vscode.commands.executeCommand('qwen.diff.closeAll');
} catch (err) {
console.warn(
'[WebViewProvider] Failed to close diffs after reject:',
err,
);
}
// Fire and forget do not block the ACP resolve
(async () => {
try {
@@ -304,6 +296,7 @@ export class WebViewProvider {
const title =
(request.toolCall as { title?: string } | undefined)
?.title || '';
// Normalize kind for UI fall back to 'execute'
let kind = ((
request.toolCall as { kind?: string } | undefined
)?.kind || 'execute') as string;
@@ -326,6 +319,7 @@ export class WebViewProvider {
title,
kind,
status: 'failed',
// Best-effort pass-through (used by UI hints)
rawInput: (request.toolCall as { rawInput?: unknown })
?.rawInput,
locations: (

View File

@@ -24,7 +24,10 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
onClose,
}) => {
const [focusedIndex, setFocusedIndex] = useState(0);
const [customMessage, setCustomMessage] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
// Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting
const customInputRef = useRef<HTMLInputElement>(null);
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall);
// Prefer file name from locations, fall back to content[].path if present
@@ -91,7 +94,10 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
// Number keys 1-9 for quick select
const numMatch = e.key.match(/^[1-9]$/);
if (numMatch) {
if (
numMatch &&
!customInputRef.current?.contains(document.activeElement)
) {
const index = parseInt(e.key, 10) - 1;
if (index < options.length) {
e.preventDefault();
@@ -103,10 +109,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
// Arrow keys for navigation
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (options.length === 0) {
return;
}
const totalItems = options.length;
const totalItems = options.length + 1; // +1 for custom input
if (e.key === 'ArrowDown') {
setFocusedIndex((prev) => (prev + 1) % totalItems);
} else {
@@ -115,7 +118,10 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
}
// Enter to select
if (e.key === 'Enter') {
if (
e.key === 'Enter' &&
!customInputRef.current?.contains(document.activeElement)
) {
e.preventDefault();
if (focusedIndex < options.length) {
onResponse(options[focusedIndex].optionId);
@@ -228,6 +234,28 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
</button>
);
})}
{/* Custom message input (extracted component) */}
{(() => {
const isFocused = focusedIndex === options.length;
const rejectOptionId = options.find((o) =>
o.kind.includes('reject'),
)?.optionId;
return (
<CustomMessageInputRow
isFocused={isFocused}
customMessage={customMessage}
setCustomMessage={setCustomMessage}
onFocusRow={() => setFocusedIndex(options.length)}
onSubmitReject={() => {
if (rejectOptionId) {
onResponse(rejectOptionId);
}
}}
inputRef={customInputRef}
/>
);
})()}
</div>
</div>
@@ -235,3 +263,50 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
</div>
);
};
/**
* CustomMessageInputRow: Reusable custom input row component (without hooks)
*/
interface CustomMessageInputRowProps {
isFocused: boolean;
customMessage: string;
setCustomMessage: (val: string) => void;
onFocusRow: () => void; // Set focus when mouse enters or input box is focused
onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option)
inputRef: React.RefObject<HTMLInputElement | null>;
}
const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
isFocused,
customMessage,
setCustomMessage,
onFocusRow,
onSubmitReject,
inputRef,
}) => (
<div
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] cursor-text text-[var(--app-primary-foreground)] ${
isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
}`}
onMouseEnter={onFocusRow}
onClick={() => inputRef.current?.focus()}
>
<input
ref={inputRef as React.LegacyRef<HTMLInputElement> | undefined}
type="text"
placeholder="Tell Qwen what to do instead"
spellCheck={false}
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
style={{ color: 'var(--app-input-foreground)' }}
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
onFocus={onFocusRow}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
e.preventDefault();
onSubmitReject();
}
}}
/>
</div>
);

View File

@@ -7,7 +7,6 @@
import type { IMessageHandler } from './BaseMessageHandler.js';
import type { QwenAgentManager } from '../../services/qwenAgentManager.js';
import type { ConversationStore } from '../../services/conversationStore.js';
import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js';
import { SessionMessageHandler } from './SessionMessageHandler.js';
import { FileMessageHandler } from './FileMessageHandler.js';
import { EditorMessageHandler } from './EditorMessageHandler.js';
@@ -23,7 +22,7 @@ export class MessageRouter {
private authHandler: AuthMessageHandler;
private currentConversationId: string | null = null;
private permissionHandler:
| ((message: PermissionResponseMessage) => void)
| ((message: { type: string; data: { optionId: string } }) => void)
| null = null;
constructor(
@@ -81,7 +80,9 @@ export class MessageRouter {
// Handle permission response specially
if (message.type === 'permissionResponse') {
if (this.permissionHandler) {
this.permissionHandler(message as PermissionResponseMessage);
this.permissionHandler(
message as { type: string; data: { optionId: string } },
);
}
return;
}
@@ -130,7 +131,7 @@ export class MessageRouter {
* Set permission handler
*/
setPermissionHandler(
handler: (message: PermissionResponseMessage) => void,
handler: (message: { type: string; data: { optionId: string } }) => void,
): void {
this.permissionHandler = handler;
}