feat(vscode): expose /skills as slash command with secondary picker (#2548)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(vscode): expose /skills as slash command with secondary picker

Add a secondary completion picker for the /skills slash command in the
VSCode IDE companion, allowing users to browse and select skills from
a dropdown before sending.

Changes:
- CLI: add 'skills' to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist
- CLI: send available_skills_update via ACP with skill names/descriptions
- Extension: handle available_skills_update in session update handler
- Webview: implement secondary picker that triggers after selecting /skills
- Webview: allow spaces in completion trigger for /skills sub-queries

Closes #1562

Made-with: Cursor

* feat(vscode-ide-companion): embed skills in commands update metadata

- Move available skills from separate session update to _meta field of
  available_commands_update for more efficient delivery
- Simplify skill data to just skill names (string array)
- Add skillsCompletion utility for secondary picker logic
- Cache available skills in WebViewProvider for replay on webview ready
- Update all related types and handlers to support the new structure

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(vscode-ide-companion): simplify skills picker flow

* refactor(vscode-ide-companion): extract skills completion utils to shared module

Move `isSkillsSecondaryQuery`, `shouldOpenSkillsSecondaryPicker`, and
`SKILL_ITEM_ID_PREFIX` from App.tsx and useCompletionTrigger.ts into a
shared `completionUtils.ts` file to eliminate duplication.

* fix(vscode-ide-companion): restore skills picker state on reload

Cache and replay available skills when the webview becomes ready again.

Clear stale skills when commands metadata does not include availableSkills.

* fix(vscode-ide-companion): replay slash commands after webview reload

Cache available commands in the webview provider.

Replay them on webviewReady so slash command state survives reloads.

* fix(vscode-ide-companion): import AvailableCommand from ACP SDK

* fix(vscode-ide-companion): fallback /skills to direct command

* test(vscode-ide-companion): cover skills secondary picker flow

* test(vscode-ide-companion): guard App mock initialization

* fix(vscode-ide-companion): remove duplicate AvailableCommand import

The auto-merge introduced a duplicate AvailableCommand in the
@agentclientprotocol/sdk import block, causing TS2300.

* fix(vscode-ide-companion): remove duplicate availableCommands replay in handleWebviewReady

The handleWebviewReady method was sending cachedAvailableCommands twice
on every webview-ready handshake, causing an unnecessary extra state
update in the webview.

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
易良 2026-04-24 23:28:53 +08:00 committed by GitHub
parent 3a2ee4ac1d
commit 202be6ec7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1006 additions and 98 deletions

View file

@ -246,6 +246,43 @@ describe('Session', () => {
});
});
it('attaches available skills to available_commands_update metadata', async () => {
getAvailableCommandsSpy.mockResolvedValueOnce([
{
name: 'init',
description: 'Initialize project context',
},
]);
mockConfig.getSkillManager = vi.fn().mockReturnValue({
listSkills: vi
.fn()
.mockResolvedValue([
{ name: 'code-review-expert' },
{ name: 'verification-pack' },
]),
});
await session.sendAvailableCommandsUpdate();
expect(mockClient.sessionUpdate).toHaveBeenCalledTimes(1);
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{
name: 'init',
description: 'Initialize project context',
input: null,
},
],
_meta: {
availableSkills: ['code-review-expert', 'verification-pack'],
},
},
});
});
it('swallows errors and does not throw', async () => {
getAvailableCommandsSpy.mockRejectedValueOnce(
new Error('Command discovery failed'),

View file

@ -985,9 +985,27 @@ export class Session implements SessionContext {
}),
);
let availableSkills: string[] | undefined;
try {
const skillManager = this.config.getSkillManager();
if (skillManager) {
const skills = await skillManager.listSkills();
availableSkills = skills.map((skill) => skill.name);
}
} catch (error) {
debugLogger.error('Error loading available skills:', error);
}
const update: SessionUpdate = {
sessionUpdate: 'available_commands_update',
availableCommands,
...(availableSkills
? {
_meta: {
availableSkills,
},
}
: {}),
};
await this.sendUpdate(update);

View file

@ -24,7 +24,7 @@ export const skillsCommand: SlashCommand = {
return t('List available skills.');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
supportedModes: ['interactive', 'acp'] as const,
action: async (context: CommandContext, args?: string) => {
const rawArgs = args?.trim() ?? '';
const [skillName = ''] = rawArgs.split(/\s+/);

View file

@ -1478,6 +1478,14 @@ export class QwenAgentManager {
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* Register callback for available skills updates (from ACP available_skills_update)
*/
onAvailableSkills(callback: (skills: string[]) => void): void {
this.callbacks.onAvailableSkills = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* Register callback for available models updates (from session/new response)
*/

View file

@ -352,6 +352,46 @@ describe('QwenSessionUpdateHandler', () => {
});
});
describe('available skills handling', () => {
it('reads available skills from available_commands_update metadata', () => {
mockCallbacks.onAvailableSkills = vi.fn();
const commandsUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [],
_meta: {
availableSkills: ['code-review-expert', 'verification-pack'],
},
},
} as unknown as SessionNotification;
handler.handleSessionUpdate(commandsUpdate);
expect(mockCallbacks.onAvailableSkills).toHaveBeenCalledWith([
'code-review-expert',
'verification-pack',
]);
});
it('clears available skills when metadata is absent', () => {
mockCallbacks.onAvailableSkills = vi.fn();
const commandsUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [],
},
} as unknown as SessionNotification;
handler.handleSessionUpdate(commandsUpdate);
expect(mockCallbacks.onAvailableSkills).toHaveBeenCalledWith([]);
});
});
describe('updateCallbacks', () => {
it('updates mode callback and uses new one', () => {
const newOnModeChanged = vi.fn();

View file

@ -201,6 +201,11 @@ export class QwenSessionUpdateHandler {
if (commands && this.callbacks.onAvailableCommands) {
this.callbacks.onAvailableCommands(commands);
}
const meta = (update as { _meta?: SessionUpdateMeta | null })._meta;
if (this.callbacks.onAvailableSkills) {
this.callbacks.onAvailableSkills(meta?.availableSkills ?? []);
}
} catch (err) {
console.warn(
'[SessionUpdateHandler] Failed to handle available commands update',

View file

@ -39,6 +39,7 @@ export interface SessionUpdateMeta {
usage?: Usage | null;
durationMs?: number | null;
timestamp?: number | null;
availableSkills?: string[] | null;
}
export {

View file

@ -81,6 +81,7 @@ export interface QwenAgentCallbacks {
onModelInfo?: (info: ModelInfo) => void;
onModelChanged?: (model: ModelInfo) => void;
onAvailableCommands?: (commands: AvailableCommand[]) => void;
onAvailableSkills?: (skills: string[]) => void;
onAvailableModels?: (models: ModelInfo[]) => void;
onDisconnected?: (code: number | null, signal: string | null) => void;
onSlashCommandNotification?: (event: SlashCommandNotification) => void;

View file

@ -0,0 +1,413 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
import type React from 'react';
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRoot, type Root } from 'react-dom/client';
import type { CompletionItem } from '../types/completionItemTypes.js';
const { mockPostMessage, mockOpenCompletion, mockCloseCompletion } = vi.hoisted(
() => ({
mockPostMessage: vi.fn(),
mockOpenCompletion: vi.fn().mockResolvedValue(undefined),
mockCloseCompletion: vi.fn(),
}),
);
const slashSkillsItem: CompletionItem = {
id: 'skills',
label: '/skills',
type: 'command',
value: 'skills',
};
const secondarySkillItem: CompletionItem = {
id: 'skill:code-review',
label: 'code-review',
type: 'command',
value: 'skills code-review',
};
vi.mock('./hooks/useVSCode.js', () => ({
useVSCode: () => ({
postMessage: mockPostMessage,
}),
}));
vi.mock('./hooks/session/useSessionManagement.js', () => ({
useSessionManagement: () => ({
showSessionSelector: false,
filteredSessions: [],
currentSessionId: 'session-1',
sessionSearchQuery: '',
setSessionSearchQuery: vi.fn(),
handleSwitchSession: vi.fn(),
setShowSessionSelector: vi.fn(),
hasMore: false,
isLoading: false,
handleLoadMoreSessions: vi.fn(),
handleLoadQwenSessions: vi.fn(),
handleNewQwenSession: vi.fn(),
currentSessionTitle: 'Session 1',
}),
}));
vi.mock('./hooks/file/useFileContext.js', () => ({
useFileContext: () => ({
hasRequestedFiles: false,
workspaceFiles: [],
requestWorkspaceFiles: vi.fn(),
addFileReference: vi.fn(),
activeFileName: null,
activeSelection: null,
focusActiveEditor: vi.fn(),
}),
}));
vi.mock('./hooks/message/useMessageHandling.js', () => ({
useMessageHandling: () => ({
messages: [],
isStreaming: false,
isWaitingForResponse: false,
loadingMessage: null,
addMessage: vi.fn(),
endStreaming: vi.fn(),
setWaitingForResponse: vi.fn(),
}),
}));
vi.mock('./hooks/useToolCalls.js', () => ({
useToolCalls: () => ({
inProgressToolCalls: [],
completedToolCalls: [],
handleToolCallUpdate: vi.fn(),
clearToolCalls: vi.fn(),
}),
}));
vi.mock('./hooks/useWebViewMessages.js', async () => {
const React = await import('react');
return {
useWebViewMessages: ({
setIsAuthenticated,
setAvailableCommands,
setAvailableSkills,
}: {
setIsAuthenticated: (value: boolean) => void;
setAvailableCommands: (
value: Array<{ name: string; description?: string }>,
) => void;
setAvailableSkills: (value: string[]) => void;
}) => {
const initializedRef = React.useRef(false);
React.useEffect(() => {
if (initializedRef.current) {
return;
}
initializedRef.current = true;
setIsAuthenticated(true);
setAvailableCommands([
{ name: 'skills', description: 'List available skills' },
]);
setAvailableSkills(['code-review']);
}, [setAvailableCommands, setAvailableSkills, setIsAuthenticated]);
},
};
});
vi.mock('./hooks/useMessageSubmit.js', () => ({
useMessageSubmit: () => ({
handleSubmit: vi.fn(),
}),
shouldSendMessage: () => true,
}));
vi.mock('./hooks/useImage.js', () => ({
useImagePaste: () => ({
attachedImages: [],
handleRemoveImage: vi.fn(),
clearImages: vi.fn(),
handlePaste: vi.fn(),
}),
}));
vi.mock('./hooks/useCompletionTrigger.js', () => ({
useCompletionTrigger: () => ({
isOpen: true,
triggerChar: '/',
query: 'skills ',
items: [slashSkillsItem, secondarySkillItem],
closeCompletion: mockCloseCompletion,
openCompletion: mockOpenCompletion,
refreshCompletion: vi.fn(),
}),
}));
vi.mock('./utils/contextUsage.js', () => ({
computeContextUsage: () => null,
}));
vi.mock('./utils/utils.js', () => ({
hasToolCallOutput: () => false,
}));
vi.mock('./components/messages/toolcalls/ToolCall.js', () => ({
ToolCall: () => null,
}));
vi.mock('./components/layout/Onboarding.js', () => ({
Onboarding: () => null,
}));
vi.mock('./components/AccountInfoDialog.js', () => ({
AccountInfoDialog: () => null,
}));
vi.mock('@qwen-code/webui', () => ({
AssistantMessage: () => null,
UserMessage: () => null,
ThinkingMessage: () => null,
WaitingMessage: () => null,
InterruptedMessage: () => null,
FileIcon: () => null,
PermissionDrawer: () => null,
AskUserQuestionDialog: () => null,
ImageMessageRenderer: () => null,
ImagePreview: () => null,
EmptyState: () => null,
ChatHeader: () => null,
SessionSelector: () => null,
}));
vi.mock('./components/layout/InputForm.js', () => ({
InputForm: ({
inputText,
inputFieldRef,
onCompletionSelect,
onCompletionFill,
}: {
inputText: string;
inputFieldRef: React.RefObject<HTMLDivElement>;
onCompletionSelect: (item: CompletionItem) => void;
onCompletionFill?: (item: CompletionItem) => void;
}) => (
<div>
<div
data-testid="input-field"
ref={inputFieldRef}
contentEditable
suppressContentEditableWarning
>
{inputText}
</div>
<div data-testid="input-text">{inputText}</div>
<button onClick={() => onCompletionSelect(slashSkillsItem)}>
select-skills-command
</button>
<button onClick={() => onCompletionSelect(secondarySkillItem)}>
select-skill-enter
</button>
<button onClick={() => onCompletionFill?.(secondarySkillItem)}>
select-skill-tab
</button>
</div>
),
}));
import { App } from './App.js';
function createDomRect(): DOMRect {
return {
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON: () => ({}),
} as DOMRect;
}
function clickButton(container: HTMLDivElement, label: string) {
const button = Array.from(container.querySelectorAll('button')).find(
(candidate) => candidate.textContent === label,
);
if (!button) {
throw new Error(`Button not found: ${label}`);
}
act(() => {
button.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
}),
);
});
}
function setInputSelection(container: HTMLDivElement, text: string) {
const input = container.querySelector(
'[data-testid="input-field"]',
) as HTMLDivElement | null;
if (!input) {
throw new Error('Input field not found');
}
act(() => {
input.textContent = text;
if (!input.firstChild) {
input.appendChild(document.createTextNode(text));
} else {
input.firstChild.textContent = text;
}
const textNode = input.firstChild;
if (!textNode) {
throw new Error('Missing text node');
}
const selection = window.getSelection();
const range = document.createRange();
range.setStart(textNode, text.length);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
});
}
function getRenderedInputText(container: HTMLDivElement): string {
return (
container.querySelector('[data-testid="input-text"]')?.textContent ?? ''
);
}
function renderApp() {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(<App />);
});
return { container, root };
}
describe('App /skills secondary picker', () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
vi.clearAllMocks();
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
configurable: true,
value: () => createDomRect(),
});
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
configurable: true,
value: () => createDomRect(),
});
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: class {
observe() {}
disconnect() {}
},
});
Object.defineProperty(globalThis, 'requestAnimationFrame', {
configurable: true,
value: (callback: FrameRequestCallback) => {
callback(0);
return 1;
},
});
Object.defineProperty(globalThis, 'cancelAnimationFrame', {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
root = null;
}
if (container) {
container.remove();
container = null;
}
});
it('opens the secondary picker after selecting /skills', async () => {
const rendered = renderApp();
root = rendered.root;
container = rendered.container;
await act(async () => {});
setInputSelection(rendered.container, '/');
clickButton(rendered.container, 'select-skills-command');
expect(mockPostMessage).not.toHaveBeenCalled();
expect(mockOpenCompletion).toHaveBeenCalledWith(
'/',
'skills ',
expect.any(Object),
);
});
it('sends /skills <name> when pressing Enter on a skill item', async () => {
const rendered = renderApp();
root = rendered.root;
container = rendered.container;
await act(async () => {});
setInputSelection(rendered.container, '/skills ');
clickButton(rendered.container, 'select-skill-enter');
expect(mockPostMessage).toHaveBeenCalledWith({
type: 'sendMessage',
data: { text: '/skills code-review' },
});
expect(mockCloseCompletion).toHaveBeenCalled();
});
it('fills /skills <name> without sending when pressing Tab on a skill item', async () => {
const rendered = renderApp();
root = rendered.root;
container = rendered.container;
await act(async () => {});
setInputSelection(rendered.container, '/skills ');
clickButton(rendered.container, 'select-skill-tab');
expect(mockPostMessage).not.toHaveBeenCalled();
expect(getRenderedInputText(rendered.container)).toBe(
'/skills code-review ',
);
});
});

View file

@ -59,6 +59,11 @@ import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk';
import type { Question } from '../types/acpTypes.js';
import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js';
import { computeContextUsage } from './utils/contextUsage.js';
import {
SKILL_ITEM_ID_PREFIX,
isSkillsSecondaryQuery,
shouldOpenSkillsSecondaryPicker,
} from './utils/completionUtils.js';
import {
buildSlashCommandItems,
isExpandableSlashCommand,
@ -254,6 +259,7 @@ export const App: React.FC = () => {
const [availableCommands, setAvailableCommands] = useState<
AvailableCommand[]
>([]);
const [availableSkills, setAvailableSkills] = useState<string[]>([]);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [insightProgress, setInsightProgress] = useState<{
stage: string;
@ -324,6 +330,22 @@ export const App: React.FC = () => {
return allItems;
} else {
if (availableSkills.length > 0 && isSkillsSecondaryQuery(query)) {
const skillQuery = query.replace(/^skills\s+/i, '').toLowerCase();
return availableSkills
.map(
(skill) =>
({
id: `${SKILL_ITEM_ID_PREFIX}${skill}`,
label: skill,
type: 'command' as const,
group: 'Skills',
value: `skills ${skill}`,
}) satisfies CompletionItem,
)
.filter((item) => item.label.toLowerCase().includes(skillQuery));
}
// Handle slash commands with grouping
// Model group - special items without / prefix
const modelGroupItems: CompletionItem[] = [
@ -375,10 +397,19 @@ export const App: React.FC = () => {
);
}
},
[fileContext, availableCommands, modelInfo?.name],
[fileContext, availableCommands, availableSkills, modelInfo?.name],
);
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
const {
isOpen: completionIsOpen,
triggerChar: completionTriggerChar,
query: completionQuery,
items: completionItems,
closeCompletion,
openCompletion,
refreshCompletion,
} = completion;
const contextUsage = useMemo(
() => computeContextUsage(usageStats, modelInfo),
@ -401,17 +432,32 @@ export const App: React.FC = () => {
// Note: Avoid depending on the entire `completion` object here, since its identity
// changes on every render which would retrigger this effect and can cause a refresh loop.
useEffect(() => {
if (completion.isOpen && completion.triggerChar === '@') {
if (completionIsOpen && completionTriggerChar === '@') {
// Only refresh items; do not change other completion state to avoid re-renders loops
completion.refreshCompletion();
refreshCompletion();
}
// Only re-run when the actual data source changes, not on every render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
workspaceFilesSignature,
completion.isOpen,
completion.triggerChar,
completion.query,
completionIsOpen,
completionTriggerChar,
completionQuery,
refreshCompletion,
]);
useEffect(() => {
if (
completionIsOpen &&
completionTriggerChar === '/' &&
isSkillsSecondaryQuery(completionQuery)
) {
refreshCompletion();
}
}, [
availableSkills,
completionIsOpen,
completionTriggerChar,
completionQuery,
refreshCompletion,
]);
const { attachedImages, handleRemoveImage, clearImages, handlePaste } =
@ -493,6 +539,9 @@ export const App: React.FC = () => {
setAvailableCommands: (commands) => {
setAvailableCommands(commands);
},
setAvailableSkills: (skills) => {
setAvailableSkills(skills);
},
setAvailableModels: (models) => {
setAvailableModels(models);
},
@ -683,7 +732,7 @@ export const App: React.FC = () => {
// Ignore info items (placeholders like "Searching files…")
if (item.type === 'info') {
completion.closeCompletion();
closeCompletion();
return;
}
@ -750,31 +799,38 @@ export const App: React.FC = () => {
if (itemId === 'auth') {
clearTriggerText();
vscode.postMessage({ type: 'auth', data: {} });
completion.closeCompletion();
closeCompletion();
return;
}
if (itemId === 'account') {
clearTriggerText();
vscode.postMessage({ type: 'getAccountInfo', data: {} });
completion.closeCompletion();
closeCompletion();
return;
}
if (itemId === 'model') {
clearTriggerText();
setShowModelSelector(true);
completion.closeCompletion();
closeCompletion();
return;
}
// Handle server-provided slash commands by sending them as messages.
// Skip when fillOnly (Tab) — let the generic insertion path fill the
// command text so the user can keep typing arguments.
// Special case: /skills always uses fill behavior (Enter = Tab) to
// allow the secondary skill picker to appear.
const serverCmd = availableCommands.find((c) => c.name === itemId);
const isSkillsCmd = shouldOpenSkillsSecondaryPicker(
item,
availableSkills,
);
if (
serverCmd &&
!fillOnly &&
!isSkillsCmd &&
!isExpandableSlashCommand(serverCmd.name)
) {
// Clear the trigger text since we're sending the command
@ -784,7 +840,23 @@ export const App: React.FC = () => {
type: 'sendMessage',
data: { text: `/${serverCmd.name}` },
});
completion.closeCompletion();
closeCompletion();
return;
}
// Handle secondary skill selection — send `/skills <name>` with
// optional trailing user text
if (itemId.startsWith(SKILL_ITEM_ID_PREFIX) && !fillOnly) {
clearTriggerText();
const value =
typeof item.value === 'string'
? item.value
: itemId.slice(SKILL_ITEM_ID_PREFIX.length);
vscode.postMessage({
type: 'sendMessage',
data: { text: `/${value}` },
});
closeCompletion();
return;
}
}
@ -846,7 +918,7 @@ export const App: React.FC = () => {
const atPos = textBeforeCursor.lastIndexOf('@');
// Only consider slash as trigger if we're in slash command mode
const slashPos =
completion.triggerChar === '/' ? textBeforeCursor.lastIndexOf('/') : -1;
completionTriggerChar === '/' ? textBeforeCursor.lastIndexOf('/') : -1;
const triggerPos = Math.max(atPos, slashPos);
if (triggerPos >= 0) {
@ -869,6 +941,18 @@ export const App: React.FC = () => {
sel?.removeAllRanges();
sel?.addRange(newRange);
if (shouldOpenSkillsSecondaryPicker(item, availableSkills)) {
const rangeRect = newRange.getBoundingClientRect();
const inputRect = inputElement.getBoundingClientRect();
const position =
rangeRect.top > 0 || rangeRect.left > 0
? { top: rangeRect.top, left: rangeRect.left }
: { top: inputRect.top, left: inputRect.left };
void openCompletion('/', `${insertValue} `, position);
return;
}
if (
completion.triggerChar === '/' &&
isExpandableSlashCommand(insertValue.trim())
@ -882,15 +966,19 @@ export const App: React.FC = () => {
}
// Close the completion menu
completion.closeCompletion();
closeCompletion();
},
[
completion,
inputFieldRef,
setInputText,
fileContext,
vscode,
availableCommands,
availableSkills,
closeCompletion,
completion,
completionTriggerChar,
fileContext,
inputFieldRef,
openCompletion,
setInputText,
vscode,
],
);
@ -1370,16 +1458,16 @@ export const App: React.FC = () => {
position = { top: inputRect.top, left: inputRect.left };
}
await completion.openCompletion('/', '', position);
await openCompletion('/', '', position);
}
}}
onAttachContext={handleAttachContextClick}
onPaste={handlePaste}
completionIsOpen={completion.isOpen}
completionItems={completion.items}
completionIsOpen={completionIsOpen}
completionItems={completionItems}
onCompletionSelect={handleCompletionSelect}
onCompletionFill={(item) => handleCompletionSelect(item, true)}
onCompletionClose={completion.closeCompletion}
onCompletionClose={closeCompletion}
canSubmit={canSubmit}
extraContent={
attachedImages.length > 0 ? (

View file

@ -55,12 +55,17 @@ export function useCompletionTrigger(
position: { top: 0, left: 0 },
items: [],
});
const stateRef = useRef(state);
// Timer for loading timeout
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track request order so slower responses can't overwrite newer completions.
const requestIdRef = useRef(0);
useEffect(() => {
stateRef.current = state;
}, [state]);
const closeCompletion = useCallback(() => {
// Clear pending timeout
if (timeoutRef.current) {
@ -180,12 +185,16 @@ export function useCompletionTrigger(
};
const refreshCompletion = useCallback(async () => {
if (!state.isOpen || !state.triggerChar) {
const currentState = stateRef.current;
if (!currentState.isOpen || !currentState.triggerChar) {
return;
}
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
const items = await getCompletionItems(state.triggerChar, state.query);
const items = await getCompletionItems(
currentState.triggerChar,
currentState.query,
);
if (requestIdRef.current !== requestId) {
return;
}
@ -197,7 +206,7 @@ export function useCompletionTrigger(
}
return { ...prev, items };
});
}, [state.isOpen, state.triggerChar, state.query, getCompletionItems]);
}, [getCompletionItems]);
useEffect(() => {
const inputElement = inputRef.current;

View file

@ -130,6 +130,8 @@ interface UseWebViewMessagesProps {
setModelInfo?: (info: ModelInfo | null) => void;
// Available commands setter
setAvailableCommands?: (commands: AvailableCommand[]) => void;
// Available skills setter
setAvailableSkills?: (skills: string[]) => void;
// Available models setter
setAvailableModels?: (models: ModelInfo[]) => void;
// Account info setter (triggers dialog)
@ -219,6 +221,7 @@ export const useWebViewMessages = ({
setUsageStats,
setModelInfo,
setAvailableCommands,
setAvailableSkills,
setAvailableModels,
setAccountInfo,
setInsightReportPath,
@ -259,6 +262,7 @@ export const useWebViewMessages = ({
setUsageStats,
setModelInfo,
setAvailableCommands,
setAvailableSkills,
setAvailableModels,
setAccountInfo,
setInsightReportPath,
@ -335,6 +339,7 @@ export const useWebViewMessages = ({
setUsageStats,
setModelInfo,
setAvailableCommands,
setAvailableSkills,
setAvailableModels,
setAccountInfo,
setInsightReportPath,
@ -397,6 +402,18 @@ export const useWebViewMessages = ({
break;
}
case 'availableSkills': {
try {
const skills = message.data?.skills as string[] | undefined;
if (skills) {
handlers.setAvailableSkills?.(skills);
}
} catch (_error) {
// Ignore error when setting available skills
}
break;
}
case 'availableModels': {
try {
const models = message.data?.models as ModelInfo[] | undefined;

View file

@ -161,6 +161,7 @@ vi.mock('../../services/qwenAgentManager.js', () => ({
availableCommandsCallbackRef.current = callback;
},
);
onAvailableSkills = vi.fn();
onAvailableModels = vi.fn();
onSlashCommandNotification = vi.fn(
(
@ -724,6 +725,143 @@ describe('WebViewProvider.attachToView', () => {
}),
});
});
it('replays available skills to the webview after webviewReady', async () => {
let messageHandler:
| ((message: { type: string; data?: unknown }) => Promise<void>)
| undefined;
const postMessage = vi.fn();
const webview = {
options: undefined as unknown,
html: '',
postMessage,
asWebviewUri: vi.fn((uri: { fsPath: string }) => ({
toString: () => `webview:${uri.fsPath}`,
})),
onDidReceiveMessage: vi.fn(
(
handler: (message: { type: string; data?: unknown }) => Promise<void>,
) => {
messageHandler = handler;
return { dispose: vi.fn() };
},
),
};
const provider = new WebViewProvider(
{ subscriptions: [] } as never,
{ fsPath: '/extension-root' } as never,
);
await provider.attachToView(
{
webview,
visible: true,
onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })),
onDidDispose: vi.fn(() => ({ dispose: vi.fn() })),
} as never,
'qwen-code.chatView.sidebar',
);
const agentManager = (
provider as unknown as {
agentManager: {
onAvailableSkills: ReturnType<typeof vi.fn>;
};
}
).agentManager;
const onAvailableSkills = agentManager.onAvailableSkills.mock
.calls[0]?.[0] as ((skills: string[]) => void) | undefined;
expect(onAvailableSkills).toBeTypeOf('function');
const skills = ['code-review-expert'];
onAvailableSkills?.(skills);
postMessage.mockClear();
await messageHandler?.({
type: 'webviewReady',
data: {},
});
expect(postMessage).toHaveBeenCalledWith({
type: 'availableSkills',
data: { skills },
});
});
it('replays available commands to the webview after webviewReady', async () => {
let messageHandler:
| ((message: { type: string; data?: unknown }) => Promise<void>)
| undefined;
const postMessage = vi.fn();
const webview = {
options: undefined as unknown,
html: '',
postMessage,
asWebviewUri: vi.fn((uri: { fsPath: string }) => ({
toString: () => `webview:${uri.fsPath}`,
})),
onDidReceiveMessage: vi.fn(
(
handler: (message: { type: string; data?: unknown }) => Promise<void>,
) => {
messageHandler = handler;
return { dispose: vi.fn() };
},
),
};
const provider = new WebViewProvider(
{ subscriptions: [] } as never,
{ fsPath: '/extension-root' } as never,
);
await provider.attachToView(
{
webview,
visible: true,
onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })),
onDidDispose: vi.fn(() => ({ dispose: vi.fn() })),
} as never,
'qwen-code.chatView.sidebar',
);
const agentManager = (
provider as unknown as {
agentManager: {
onAvailableCommands: ReturnType<typeof vi.fn>;
};
}
).agentManager;
const onAvailableCommands = agentManager.onAvailableCommands.mock
.calls[0]?.[0] as
| ((commands: Array<{ name: string; description: string }>) => void)
| undefined;
expect(onAvailableCommands).toBeTypeOf('function');
const commands = [
{ name: 'skills', description: 'List available skills' },
{ name: 'compress', description: 'Compress the context' },
];
onAvailableCommands?.(commands);
postMessage.mockClear();
await messageHandler?.({
type: 'webviewReady',
data: {},
});
expect(postMessage).toHaveBeenCalledWith({
type: 'availableCommands',
data: { commands },
});
});
});
describe('WebViewProvider settings sync', () => {

View file

@ -70,6 +70,8 @@ export class WebViewProvider {
private static lastContextMenuProvider: WebViewProvider | null = null;
/** Cached available commands for re-sending on webview ready */
private cachedAvailableCommands: AvailableCommand[] | null = null;
/** Cached available skills for re-sending on webview ready */
private cachedAvailableSkills: string[] | null = null;
/** Cached available models for re-sending on webview ready */
private cachedAvailableModels: ModelInfo[] | null = null;
/** Model to apply once a new editor-tab session is initialized */
@ -337,6 +339,15 @@ export class WebViewProvider {
});
});
// Surface available skills for the /skills secondary picker
this.agentManager.onAvailableSkills((skills) => {
this.cachedAvailableSkills = skills;
this.sendMessageToWebView({
type: 'availableSkills',
data: { skills },
});
});
// Surface available models (from session/new response)
this.agentManager.onAvailableModels((models) => {
console.log(
@ -1594,6 +1605,13 @@ export class WebViewProvider {
});
}
if (this.cachedAvailableSkills !== null) {
this.sendMessageToWebView({
type: 'availableSkills',
data: { skills: this.cachedAvailableSkills },
});
}
// Send cached available models to webview
if (this.cachedAvailableModels && this.cachedAvailableModels.length > 0) {
console.log(

View file

@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import type { CompletionItem } from '../../types/completionItemTypes.js';
import {
isSkillsSecondaryQuery,
shouldOpenSkillsSecondaryPicker,
} from './completionUtils.js';
const skillsCommandItem: CompletionItem = {
id: 'skills',
label: '/skills',
type: 'command',
value: 'skills',
};
describe('completionUtils', () => {
describe('isSkillsSecondaryQuery', () => {
it('matches /skills subqueries with trailing space', () => {
expect(isSkillsSecondaryQuery('skills ')).toBe(true);
expect(isSkillsSecondaryQuery('skills review')).toBe(true);
expect(isSkillsSecondaryQuery('skills code review')).toBe(true);
});
it('does not treat bare /skills as a secondary query', () => {
expect(isSkillsSecondaryQuery('skills')).toBe(false);
expect(isSkillsSecondaryQuery('compress')).toBe(false);
});
});
describe('shouldOpenSkillsSecondaryPicker', () => {
it('opens the secondary picker only when skills are available', () => {
expect(
shouldOpenSkillsSecondaryPicker(skillsCommandItem, ['review', 'test']),
).toBe(true);
expect(shouldOpenSkillsSecondaryPicker(skillsCommandItem, [])).toBe(
false,
);
});
it('does not open for non-/skills commands', () => {
expect(
shouldOpenSkillsSecondaryPicker(
{
id: 'compress',
label: '/compress',
type: 'command',
value: 'compress',
},
['review'],
),
).toBe(false);
});
});
});

View file

@ -0,0 +1,46 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Utility helpers for the /skills secondary completion picker.
*/
import type { CompletionItem } from '../../types/completionItemTypes.js';
/**
* Prefix used to distinguish skill completion items from other commands.
* For example, a skill named "code-review" gets item id "skill:code-review".
*/
export const SKILL_ITEM_ID_PREFIX = 'skill:';
/**
* Check whether the current completion query is targeting the secondary
* skills picker (i.e. the user typed "/skills " followed by optional text).
*
* @param query - The text after the "/" trigger character
* @returns true when the query matches the "skills <filter>" pattern
*/
export function isSkillsSecondaryQuery(query: string): boolean {
return /^skills\s+/i.test(query);
}
/**
* Determine whether selecting this completion item should open the
* secondary skills picker instead of sending the command immediately.
*
* @param item - The completion item the user selected
* @param availableSkills - Skills advertised by the backend for the picker
* @returns true when the item represents the /skills command and there are
* available skills to show
*/
export function shouldOpenSkillsSecondaryPicker(
item: CompletionItem,
availableSkills: string[],
): boolean {
return (
item.type === 'command' &&
item.id === 'skills' &&
availableSkills.length > 0
);
}