diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 971ce6d90..22dff9336 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -1503,6 +1503,32 @@ async function loadUserSkillBody( return { id: skill.id, name: skill.name, body: result.body }; } +/** + * Expand a leading `/skill-id extra instructions` draft into the same + * `` block the `Skill` tool returns (issue #142), with the extra text + * appended as additional instructions. Returns null when the name is not an + * active user skill, so templates and unknown /names keep their existing + * behavior. + */ +async function expandSkillInvocation( + content: string, + projectPath: string | null, +): Promise<{ expanded: string; command: string } | null> { + if (!content.startsWith("/")) return null; + const tokenEnd = content.search(/[\s]/); + const token = tokenEnd === -1 ? content : content.slice(0, tokenEnd); + const id = token.slice(1); + if (!id || id.includes("/")) return null; + const skill = await loadUserSkillBody(id, projectPath).catch(() => null); + if (!skill) return null; + const rest = tokenEnd === -1 ? "" : content.slice(tokenEnd).trim(); + const skillBlock = `\n\n${skill.body}\n`; + return { + expanded: rest ? `${skillBlock}\n\n${rest}` : skillBlock, + command: content.trim(), + }; +} + async function resolveEffectiveCommandShell(): Promise { if (!host) throw new Error("host unavailable"); const catalog = await host.call("commandShells.list"); @@ -7532,8 +7558,20 @@ function registerIpc() { description: command.description ?? command.extensionLabel, id: trustedExtensionCommandId(command.name), })); + // User skills (issue #142): active skills join the "/" namespace so they + // are discoverable and invocable like templates. Activation scope uses + // the window's project, matching the other app-facing surfaces. + const skillCommands = (await activeUserSkills(root ?? undefined)).map( + (skill) => ({ + name: skill.id, + kind: "skill" as const, + title: skill.name, + ...(skill.description ? { description: skill.description } : {}), + }), + ); // One namespace: builtin aliases win, then project templates, then user - // templates, then plugin commands, then extension commands (spec 04 §7). + // templates, then plugin commands, then extension commands, then user + // skills (spec 04 §7). const merged = new Map< string, ReturnType[number] @@ -7543,6 +7581,7 @@ function registerIpc() { ...templateCommands, ...pluginCommands, ...extensionCommands, + ...skillCommands, ]) { if (!merged.has(command.name)) merged.set(command.name, command); } @@ -7998,7 +8037,9 @@ function registerIpc() { // persistence so reseed replays exactly what the model saw; the typed // form rides along as `command` for transcript display. Builtin/plugin // slash aliases never reach this channel, and unknown /names stay - // literal text. + // literal text. A /name matching an active user skill expands to the + // same block the `Skill` tool returns (issue #142), with any + // extra text appended as additional instructions. let promptContent = req.content; let slashCommand: string | undefined; if (req.content.startsWith("/")) { @@ -8009,6 +8050,15 @@ function registerIpc() { if (expansion) { promptContent = expansion.expanded; slashCommand = expansion.command; + } else { + const skillExpansion = await expandSkillInvocation( + req.content, + root ?? null, + ); + if (skillExpansion) { + promptContent = skillExpansion.expanded; + slashCommand = skillExpansion.command; + } } } catch (error) { logger.app("session", "warn", "slash expansion failed; sending literal text", { diff --git a/apps/desktop/src/components/ComposerAutocomplete.tsx b/apps/desktop/src/components/ComposerAutocomplete.tsx index 903c051d8..9b499bfb2 100644 --- a/apps/desktop/src/components/ComposerAutocomplete.tsx +++ b/apps/desktop/src/components/ComposerAutocomplete.tsx @@ -44,11 +44,13 @@ const GROUP_KEYS: Record = { builtin: "chat.slashGroupApp", plugin: "chat.slashGroupPlugins", extension: "chat.slashGroupExtensions", + skill: "chat.slashGroupSkills", }; function CommandIcon({ kind }: { kind: ComposerCommand["kind"] }) { if (kind === "template") return ; if (kind === "plugin" || kind === "extension") return ; + if (kind === "skill") return ; return ; } diff --git a/apps/desktop/src/hooks/use-composer-autocomplete.ts b/apps/desktop/src/hooks/use-composer-autocomplete.ts index d23164aaf..55ba34d5e 100644 --- a/apps/desktop/src/hooks/use-composer-autocomplete.ts +++ b/apps/desktop/src/hooks/use-composer-autocomplete.ts @@ -41,7 +41,7 @@ let filesCache: { truncated: boolean; } | null = null; -const COMMAND_GROUP_ORDER = { template: 0, builtin: 1, plugin: 2, extension: 3 } as const; +const COMMAND_GROUP_ORDER = { template: 0, builtin: 1, plugin: 2, extension: 3, skill: 4 } as const; function filterCommands( commands: ComposerCommand[], diff --git a/docs/spec/04-ux/04-builtin-commands.md b/docs/spec/04-ux/04-builtin-commands.md index 21feb26f4..0d131c17a 100644 --- a/docs/spec/04-ux/04-builtin-commands.md +++ b/docs/spec/04-ux/04-builtin-commands.md @@ -87,7 +87,10 @@ in the same registry that feeds palette search Aliases share one namespace with template and plugin command names; builtin aliases win collisions, then project templates, then user templates, then -plugin commands. Selecting an alias inserts `/alias `; sending `/new` or +plugin commands, then extension commands, then active user skills +(`~/.agents/skills` / `/.agents/skills`, scope-filtered like the +rest of the app-facing surfaces). Selecting an alias inserts `/alias `; +sending `/new` or `/compact` alone executes locally without creating an empty prompt. The Agent/Plan/Goal aliases also support a prompt body: `/agent-mode `, `/plan-mode `, or `/goal-mode ` switches diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 333309282..d018600d1 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -683,6 +683,29 @@ Each scenario is documented in this format: - **Status**: Unit-covered (stream processor terminates on the terminal event via the pi-ai patch); live-proxy scenario Draft +#### E2E-249: User skills appear in the composer "/" menu and expand on send + +- **Preconditions**: At least one enabled global user skill exists under + `~/.agents/skills` (or a project skill under `/.agents/skills`); + a workspace is open; no template shares the skill's id. +- **Steps**: 1) Type `/` in the composer. 2) Confirm the skill appears under + its own group with name, description, and the skill icon. 3) Type the + skill's id to filter and accept the row. 4) Append optional free text and + send. 5) Disable the skill in Settings → Agent → Skills, then type `/` + again. +- **Expected**: The "/" menu lists active user skills alongside app, plugin, + extension, and template commands, scope-filtered by the open workspace. + Sending `/skill-id extra text` persists a prompt whose content is the + `` block (same shape the `Skill` tool returns) followed by the + extra text as additional instructions, with `command` set to the typed + invocation. Disabled or out-of-scope skills disappear from the menu, and + an unknown `/name` stays literal text. +- **Specs linked**: `04-ux/04-builtin-commands.md` (§7), + `03-runtime/03-tools-and-permissions.md` +- **Acceptance**: C (skills discovery and invocation) +- **Milestone**: M2 +- **Status**: Documented; automation pending + #### E2E-005E: DeepSeek thinking replay includes reasoning_content on aggregator endpoints - **Preconditions**: An OpenAI-compatible provider whose base URL is not diff --git a/docs/zh-CN/spec/04-ux/04-builtin-commands.md b/docs/zh-CN/spec/04-ux/04-builtin-commands.md index 4688df739..dfee1cf83 100644 --- a/docs/zh-CN/spec/04-ux/04-builtin-commands.md +++ b/docs/zh-CN/spec/04-ux/04-builtin-commands.md @@ -76,7 +76,9 @@ type CommandExecutionResult = | `/goal-mode` | `builtin.mode.goal` | 别名与模板和插件命令名称共享一个命名空间;冲突时优先使用内置别名,其次是项目模板、 -用户模板和插件命令。选择别名会插入 `/alias `;单独发送 `/new` 或 `/compact` 时, +用户模板、插件命令、扩展命令,最后是激活的用户技能(`~/.agents/skills` / +`/.agents/skills`,按激活范围过滤,与其他面向应用的界面一致)。选择别名会插入 +`/alias `;单独发送 `/new` 或 `/compact` 时, 会在本地执行,不会创建空提示。Agent/Plan/Goal 别名支持附带提示词: `/agent-mode `、`/plan-mode ` 或 `/goal-mode ` 会切换空闲会话 (或下一个会话默认值),并通过正常提示路径发送 ``。不带正文的模式别名仍然 diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 16208a65e..ddcc84f1e 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -213,6 +213,7 @@ export const de = { "slashGroupApp": "App-Befehle", "slashGroupPlugins": "Plugin-Befehle", "slashGroupExtensions": "Erweiterungsbefehle", + "slashGroupSkills": "Skill-Befehle", "slashEmpty": "Keine übereinstimmenden Befehle", "fileMenu": "Dateiverweise", "fileReferences": "Von diesem Entwurf referenzierte Dateien", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 58f1e20ce..43f1aca2f 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -220,6 +220,7 @@ export const en = { slashGroupApp: "App commands", slashGroupPlugins: "Plugin commands", slashGroupExtensions: "Extension commands", + "slashGroupSkills": "Skill commands", slashEmpty: "No matching commands", fileMenu: "File references", fileReferences: "Files referenced by this draft", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 1d77fdb71..eeaeb0607 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -213,6 +213,7 @@ export const es = { "slashGroupApp": "Comandos de aplicaciones", "slashGroupPlugins": "Comandos de complemento", "slashGroupExtensions": "Comandos de extensión", + "slashGroupSkills": "Comandos de skill", "slashEmpty": "No hay comandos coincidentes", "fileMenu": "Referencias de archivos", "fileReferences": "Archivos a los que hace referencia este borrador", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 4c1f0872c..82080c97d 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -213,6 +213,7 @@ export const fr = { "slashGroupApp": "Commandes d'application", "slashGroupPlugins": "Commandes du plug-in", "slashGroupExtensions": "Commandes d'extension", + "slashGroupSkills": "Commandes de skill", "slashEmpty": "Aucune commande correspondante", "fileMenu": "Références de fichiers", "fileReferences": "Fichiers référencés par ce brouillon", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index c35395a8d..34ee475cf 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -222,6 +222,7 @@ export const ko = { slashGroupApp: "앱 명령", slashGroupPlugins: "플러그인 명령", slashGroupExtensions: "확장 명령", + "slashGroupSkills": "스킬 명령", slashEmpty: "일치하는 명령 없음", fileMenu: "파일 참조", fileReferences: "이 초안에서 참조하는 파일", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 9ce5a85f7..690af415b 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -222,6 +222,7 @@ export const tr = { slashGroupApp: "Uygulama komutları", slashGroupPlugins: "Eklenti komutları", slashGroupExtensions: "Uzantı komutları", + "slashGroupSkills": "Skill komutları", slashEmpty: "Eşleşen komut yok", fileMenu: "Dosya başvuruları", fileReferences: "Bu taslağın başvurduğu dosyalar", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 7985b9467..5689c511b 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -215,6 +215,7 @@ export const zhCN = { slashGroupApp: "应用命令", slashGroupPlugins: "插件命令", slashGroupExtensions: "扩展命令", + "slashGroupSkills": "技能命令", slashEmpty: "没有匹配的指令", fileMenu: "引用文件", fileReferences: "此草稿引用的文件", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 0e0cd64ef..cbf78fe47 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -215,6 +215,7 @@ export const zhTW = { slashGroupApp: "應用命令", slashGroupPlugins: "外掛命令", slashGroupExtensions: "擴充命令", + "slashGroupSkills": "技能命令", slashEmpty: "沒有匹配的指令", fileMenu: "引用檔案", fileReferences: "此草稿引用的檔案", diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index c39761aa3..88221d40d 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1689,7 +1689,7 @@ export type CommandItem = { export type ComposerCommand = { /** Slash name typed after "/"; unique across the merged list. */ name: string; - kind: "template" | "builtin" | "plugin" | "extension"; + kind: "template" | "builtin" | "plugin" | "extension" | "skill"; /** Display title (templates use their name). */ title: string; description?: string;