mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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
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:
parent
3a2ee4ac1d
commit
202be6ec7d
25 changed files with 1006 additions and 98 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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+/);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface SessionUpdateMeta {
|
|||
usage?: Usage | null;
|
||||
durationMs?: number | null;
|
||||
timestamp?: number | null;
|
||||
availableSkills?: string[] | null;
|
||||
}
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
413
packages/vscode-ide-companion/src/webview/App.test.tsx
Normal file
413
packages/vscode-ide-companion/src/webview/App.test.tsx
Normal 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 ',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue