From 202be6ec7d3da998f1e8da82875de161e3d78f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Fri, 24 Apr 2026 23:28:53 +0800 Subject: [PATCH] feat(vscode): expose /skills as slash command with secondary picker (#2548) * 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 * 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/agents/test-engineer.md | 33 +- .qwen/commands/qc/code-review.md | 5 +- .qwen/commands/qc/commit.md | 6 +- .qwen/commands/qc/create-issue.md | 13 +- .qwen/commands/qc/create-pr.md | 10 +- .qwen/skills/qwen-code-claw/SKILL.md | 38 +- .qwen/skills/structured-debugging/SKILL.md | 3 +- .qwen/skills/terminal-capture/SKILL.md | 24 +- AGENTS.md | 18 +- .../acp-integration/session/Session.test.ts | 37 ++ .../src/acp-integration/session/Session.ts | 18 + packages/cli/src/ui/commands/skillsCommand.ts | 2 +- .../src/services/qwenAgentManager.ts | 8 + .../services/qwenSessionUpdateHandler.test.ts | 40 ++ .../src/services/qwenSessionUpdateHandler.ts | 5 + .../src/types/acpTypes.ts | 1 + .../src/types/chatTypes.ts | 1 + .../src/webview/App.test.tsx | 413 ++++++++++++++++++ .../vscode-ide-companion/src/webview/App.tsx | 136 +++++- .../src/webview/hooks/useCompletionTrigger.ts | 15 +- .../src/webview/hooks/useWebViewMessages.ts | 17 + .../webview/providers/WebViewProvider.test.ts | 138 ++++++ .../src/webview/providers/WebViewProvider.ts | 18 + .../src/webview/utils/completionUtils.test.ts | 59 +++ .../src/webview/utils/completionUtils.ts | 46 ++ 25 files changed, 1006 insertions(+), 98 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/App.test.tsx create mode 100644 packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts create mode 100644 packages/vscode-ide-companion/src/webview/utils/completionUtils.ts diff --git a/.qwen/agents/test-engineer.md b/.qwen/agents/test-engineer.md index 3e6d8876d..595ca9635 100644 --- a/.qwen/agents/test-engineer.md +++ b/.qwen/agents/test-engineer.md @@ -33,15 +33,15 @@ Your sole responsibility is to **reproduce bugs** and **verify fixes**. ## Critical constraints 1. **You must NEVER fix the bug.** Your job ends at confirming the bug exists - or confirming a fix works. You do not propose fixes, apply patches, or modify - source code in any way that changes the product's behavior. + or confirming a fix works. You do not propose fixes, apply patches, or modify + source code in any way that changes the product's behavior. 2. **You must NEVER use Edit or WriteFile on source files.** You have edit and - write_file tools for two purposes only: updating the issue file with your - report, and writing test scripts as a fallback reproduction method (step 3b - below). Any use of these tools on project source code is forbidden. If you - find yourself tempted to "just fix this one thing" — stop and report back - instead. + write_file tools for two purposes only: updating the issue file with your + report, and writing test scripts as a fallback reproduction method (step 3b + below). Any use of these tools on project source code is forbidden. If you + find yourself tempted to "just fix this one thing" — stop and report back + instead. ## Issue file @@ -57,22 +57,23 @@ can read your findings without relying on the agent return message. Follow these steps: 1. **Understand the issue.** Read the issue file. Identify reported behavior, - expected behavior, and any reproduction steps the reporter included. + expected behavior, and any reproduction steps the reporter included. 2. **Study the feature.** Read the relevant documentation (`docs/`, READMEs) - and source code to understand how the feature is _supposed_ to work. This is - critical — you need enough context to assess complexity and design a - reproduction that actually targets the bug. + and source code to understand how the feature is _supposed_ to work. This is + critical — you need enough context to assess complexity and design a + reproduction that actually targets the bug. 3. **Reproduce the bug.** Always attempt E2E reproduction — no exceptions: a. **E2E reproduction (required first attempt).** Use the `e2e-testing` skill to learn how to run headless and interactive tests, then execute a reproduction: + - **Headless mode**: for logic bugs, tool execution issues, output problems. - **Interactive mode (tmux)**: for TUI rendering, keyboard, visual issues. - Use the globally installed `qwen` command — this matches what the user - ran. Do NOT run `npm run build`, `npm run bundle`, or use - `node dist/cli.js` during reproduction. + ran. Do NOT run `npm run build`, `npm run bundle`, or use + `node dist/cli.js` during reproduction. b. **Test-script fallback.** Only if E2E reproduction is genuinely impractical (e.g., the bug is deep in internal logic with no observable CLI behavior, or the @@ -89,14 +90,14 @@ The caller will tell you they've applied a fix and built the bundle, and give you the issue file path. 1. Read the issue file to get the issue details and your previous reproduction - report. + report. 2. Use `node dist/cli.js` (not `qwen`) — this tests the local changes. 3. Re-run the same reproduction steps that previously triggered the bug. 4. Confirm the bug is gone and the basic happy path still works. 5. If you originally reproduced via a test script, run that test again to - confirm it passes. + confirm it passes. 6. Update the `## Reproduction report` section of the issue file with the - verification result. + verification result. ## Output format diff --git a/.qwen/commands/qc/code-review.md b/.qwen/commands/qc/code-review.md index 1d23aef27..6d7a0c6b6 100644 --- a/.qwen/commands/qc/code-review.md +++ b/.qwen/commands/qc/code-review.md @@ -5,11 +5,12 @@ description: Code review a pull request You are an expert code reviewer. Follow these steps: 1. If no PR number is provided in the args, use Bash(\"gh pr list\") to show - open PRs + open PRs 2. If a PR number is provided, use Bash(\"gh pr view \") to get PR - details + details 3. Use Bash(\"gh pr diff \") to get the diff 4. Analyze the changes and provide a thorough code review that includes: + - Overview of what the PR does - Analysis of code quality and style - Specific suggestions for improvements diff --git a/.qwen/commands/qc/commit.md b/.qwen/commands/qc/commit.md index f26fd350d..bc86ae1e5 100644 --- a/.qwen/commands/qc/commit.md +++ b/.qwen/commands/qc/commit.md @@ -41,9 +41,9 @@ the user, then commit and push. - **If current branch is NOT main/master:** - Check if branch name matches the staged changes - If branch name doesn't match changes, ask user: - - "Current branch `` doesn't seem to match these changes." - - "Options: (1) Create a new branch, (2) Commit on current branch" - - Wait for user decision + - "Current branch `` doesn't seem to match these changes." + - "Options: (1) Create a new branch, (2) Commit on current branch" + - Wait for user decision ### 5. Generate commit message diff --git a/.qwen/commands/qc/create-issue.md b/.qwen/commands/qc/create-issue.md index 7adf9ca94..497b3fa14 100644 --- a/.qwen/commands/qc/create-issue.md +++ b/.qwen/commands/qc/create-issue.md @@ -17,30 +17,35 @@ The user provides a brief description of a feature request or bug report: ## Steps 1. **Understand the request** + - Read the user's description carefully - Determine whether this is a feature request or a bug report 2. **Investigate the codebase** + - Search for relevant code, files, and existing behavior related to the request - Build a thorough understanding of how the current system works - Identify any related issues or prior art if mentioned 3. **Draft the issue** + - Write a markdown file for the user to review - Use the appropriate template: - - Feature request: follow @.github/ISSUE_TEMPLATE/feature_request.yml - - Bug report: follow @.github/ISSUE_TEMPLATE/bug_report.yml + - Feature request: follow @.github/ISSUE_TEMPLATE/feature_request.yml + - Bug report: follow @.github/ISSUE_TEMPLATE/bug_report.yml - Write from the user's perspective, not as an implementation spec - Keep the language clear and concise, AVOID internal implementation details 4. **Review with user** + - Present the draft file to the user - Iterate on feedback until the user is satisfied - Do NOT submit until the user explicitly asks to 5. **Submit the issue** + - When the user confirms, create the issue using `gh issue create` - Apply the appropriate labels: - - Feature request: `type/feature-request`, `status/needs-triage` - - Bug report: `type/bug`, `status/needs-triage` + - Feature request: `type/feature-request`, `status/needs-triage` + - Bug report: `type/bug`, `status/needs-triage` - Report back the issue URL diff --git a/.qwen/commands/qc/create-pr.md b/.qwen/commands/qc/create-pr.md index d731949d4..208193cce 100644 --- a/.qwen/commands/qc/create-pr.md +++ b/.qwen/commands/qc/create-pr.md @@ -11,15 +11,18 @@ Create a well-structured pull request with proper description and title. ## Steps 1. **Review staged changes** + - Review all staged changes to understand what has been done - Do not touch unstaged changes 2. **Prepare branch** + - Create a new branch with proper name if current branch is main - Ensure all changes are committed - Push branch to remote 3. **Write PR description** + - Use PR Template below - Summarize changes clearly - Include context and motivation @@ -31,13 +34,14 @@ Create a well-structured pull request with proper description and title. Code](https://github.com/QwenLM/qwen-code)", with a line separator 4. **Set up PR** + - Create PR title and body - Submit PR with gh command - **If a GitHub token is provided in the user's message**, use it by setting the `GH_TOKEN` environment variable: - ```bash - GH_TOKEN= gh pr create --title "..." --body "..." - ``` + ```bash + GH_TOKEN= gh pr create --title "..." --body "..." + ``` - If no token is provided, use the default `gh` authentication ## PR Template diff --git a/.qwen/skills/qwen-code-claw/SKILL.md b/.qwen/skills/qwen-code-claw/SKILL.md index ee626a573..3d12203e3 100644 --- a/.qwen/skills/qwen-code-claw/SKILL.md +++ b/.qwen/skills/qwen-code-claw/SKILL.md @@ -65,27 +65,27 @@ To use acpx: 1. **Install acpx globally** (recommended) or use npx: - ```bash - npm install -g acpx@latest - ``` +```bash +npm install -g acpx@latest +``` Or without installing: - ```bash - npx acpx@latest - ``` +```bash +npx acpx@latest +``` 2. **Install the acpx skill** for full reference: - ```bash - npx acpx@latest --skill install acpx - ``` +```bash +npx acpx@latest --skill install acpx +``` 3. **Read the acpx skill reference** for every command, flag, and workflow - pattern: See the [ACPX Reference](#acpx-reference) section below for details + pattern: See the [ACPX Reference](#acpx-reference) section below for details 4. **For complete CLI reference with all options and examples**: - https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md + https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md From now on, when you need to delegate work to Qwen Code, use `acpx` instead of spawning raw terminal sessions. For example: @@ -96,7 +96,7 @@ npx acpx@latest qwen "refactor the auth module" npx acpx@latest qwen exec "one-shot: summarize this repo" ``` -> **Tip:** You should typically use the `--approve-all` flag to prevent task +> **Tip:** You should typically use the `--approve-all` flag to prevent task > interruptions. ## Practical Workflows @@ -207,17 +207,17 @@ Full reference: `docs/users/configuration/model-providers.md`. ### Key Features - Approval Mode: control tool execution permissions. - See `docs/users/features/approval-mode.md`. + See `docs/users/features/approval-mode.md`. - MCP: Model Context Protocol server integration. - See `docs/users/features/mcp.md`. + See `docs/users/features/mcp.md`. - Skills: reusable skill system via `/skill`. - See `docs/users/features/skills.md`. + See `docs/users/features/skills.md`. - Sub-agents: delegate tasks to specialized agents. - See `docs/users/features/sub-agents.md`. + See `docs/users/features/sub-agents.md`. - Sandbox: secure code execution environment. - See `docs/users/features/sandbox.md`. + See `docs/users/features/sandbox.md`. - Headless: non-interactive or CI mode. - See `docs/users/features/headless.md`. + See `docs/users/features/headless.md`. ## ACPX Reference @@ -254,7 +254,7 @@ acpx [global options] prompt [options] [prompt text...] acpx [global options] exec [options] [prompt text...] ``` -> **Note:** If prompt text is omitted and stdin is piped, `acpx` reads prompt +> **Note:** If prompt text is omitted and stdin is piped, `acpx` reads prompt > from stdin. ### Global Options diff --git a/.qwen/skills/structured-debugging/SKILL.md b/.qwen/skills/structured-debugging/SKILL.md index 4b8c8d82a..eb5dd196e 100644 --- a/.qwen/skills/structured-debugging/SKILL.md +++ b/.qwen/skills/structured-debugging/SKILL.md @@ -187,8 +187,7 @@ Then apply the fix, remove instrumentation, and verify with a clean run. ## Worked examples -- - `examples/headless-bg-agent-empty-stdout.md` +- `examples/headless-bg-agent-empty-stdout.md` — pipe-captured runs all passed; the user's TTY printed nothing. The contradiction _was_ the bug. Illustrates _reproduction contradiction is data_ and _instrument data, not code paths_. diff --git a/.qwen/skills/terminal-capture/SKILL.md b/.qwen/skills/terminal-capture/SKILL.md index efc902035..3fba541ab 100644 --- a/.qwen/skills/terminal-capture/SKILL.md +++ b/.qwen/skills/terminal-capture/SKILL.md @@ -32,13 +32,13 @@ node-pty (pseudo-terminal) Core files: - `integration-tests/terminal-capture/terminal-capture.ts` - Low-level PTY, xterm.js, and Playwright engine. + Low-level PTY, xterm.js, and Playwright engine. - `integration-tests/terminal-capture/scenario-runner.ts` - Scenario executor for config, interactions, and screenshots. + Scenario executor for config, interactions, and screenshots. - `integration-tests/terminal-capture/run.ts` - CLI entry point for batch scenario runs. + CLI entry point for batch scenario runs. - `integration-tests/terminal-capture/scenarios/*.ts` - Scenario configuration files. + Scenario configuration files. ## Quick Start @@ -226,17 +226,17 @@ This tool is commonly used for visual verification during PR reviews. ## Troubleshooting - Playwright error `browser not found` - Cause: browser not installed. - Solution: `npx playwright install chromium`. + Cause: browser not installed. + Solution: `npx playwright install chromium`. - Blank screenshot - Cause: process starts slowly or build failed. - Solution: check build success and the spawn command. + Cause: process starts slowly or build failed. + Solution: check build success and the spawn command. - PTY-related errors - Cause: node-pty native module not compiled. - Solution: `npm rebuild node-pty`. + Cause: node-pty native module not compiled. + Solution: `npm rebuild node-pty`. - Unstable screenshot output - Cause: terminal output not fully rendered. - Solution: add scenario wait time. + Cause: terminal output not fully rendered. + Solution: add scenario wait time. ## Full ScenarioConfig Type diff --git a/AGENTS.md b/AGENTS.md index 307f5d8fa..9deb36d68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,12 +165,12 @@ applicable. Project artifacts live under `.qwen/`: -| Directory | Purpose | -|---|---| -| `.qwen/design/` | Design docs for planned features | -| `.qwen/e2e-tests/` | E2E test plans and results | -| `.qwen/issues/` | Issue drafts before filing on GitHub | -| `.qwen/pr-drafts/` | PR drafts before submitting | -| `.qwen/pr-reviews/` | PR review notes | -| `.qwen/investigations/` | Structured debugging journals | -| `.qwen/scripts/` | Utility scripts | +| Directory | Purpose | +| ----------------------- | ------------------------------------ | +| `.qwen/design/` | Design docs for planned features | +| `.qwen/e2e-tests/` | E2E test plans and results | +| `.qwen/issues/` | Issue drafts before filing on GitHub | +| `.qwen/pr-drafts/` | PR drafts before submitting | +| `.qwen/pr-reviews/` | PR review notes | +| `.qwen/investigations/` | Structured debugging journals | +| `.qwen/scripts/` | Utility scripts | diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 9996980f3..1f806b510 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -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'), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9e921434a..f4426faab 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -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); diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 801da28c9..23687d828 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -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+/); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 426558ece..f0495670e 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -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) */ diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts index c0fff31d6..3ceb806ae 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts @@ -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(); diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index 17ab80768..7630a1f7c 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -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', diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 8e3a32263..1770c9487 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -39,6 +39,7 @@ export interface SessionUpdateMeta { usage?: Usage | null; durationMs?: number | null; timestamp?: number | null; + availableSkills?: string[] | null; } export { diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index 81acd7c92..8bdaf640c 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -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; diff --git a/packages/vscode-ide-companion/src/webview/App.test.tsx b/packages/vscode-ide-companion/src/webview/App.test.tsx new file mode 100644 index 000000000..53e70510d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/App.test.tsx @@ -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; + onCompletionSelect: (item: CompletionItem) => void; + onCompletionFill?: (item: CompletionItem) => void; + }) => ( +
+
+ {inputText} +
+
{inputText}
+ + + +
+ ), +})); + +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(); + }); + + 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 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 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 ', + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 94c015557..cbc3dc687 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -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([]); const [availableModels, setAvailableModels] = useState([]); 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 ` 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 ? ( diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index 27ca238d1..d61bd23c0 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -55,12 +55,17 @@ export function useCompletionTrigger( position: { top: 0, left: 0 }, items: [], }); + const stateRef = useRef(state); // Timer for loading timeout const timeoutRef = useRef | 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; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 8ad6cf865..77aaa6a2a 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -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; diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts index 4412cdb6e..61cb7a4f7 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -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) + | 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, + ) => { + 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; + }; + } + ).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) + | 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, + ) => { + 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; + }; + } + ).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', () => { diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index b9b287e0a..2b24293c6 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -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( diff --git a/packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts b/packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts new file mode 100644 index 000000000..156b419bb --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts @@ -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); + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts b/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts new file mode 100644 index 000000000..adcb387a6 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts @@ -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 " 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 + ); +}