Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<skill>` 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 = `<skill name="${skill.name}" location="${skill.id}">\n\n${skill.body}\n</skill>`;
return {
expanded: rest ? `${skillBlock}\n\n${rest}` : skillBlock,
command: content.trim(),
};
}

async function resolveEffectiveCommandShell(): Promise<CommandShellCatalog> {
if (!host) throw new Error("host unavailable");
const catalog = await host.call<CommandShellCatalog>("commandShells.list");
Expand Down Expand Up @@ -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<typeof builtinComposerCommands>[number]
Expand All @@ -7543,6 +7581,7 @@ function registerIpc() {
...templateCommands,
...pluginCommands,
...extensionCommands,
...skillCommands,
]) {
if (!merged.has(command.name)) merged.set(command.name, command);
}
Expand Down Expand Up @@ -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 <skill> 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("/")) {
Expand All @@ -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", {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/components/ComposerAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ const GROUP_KEYS: Record<ComposerCommand["kind"], string> = {
builtin: "chat.slashGroupApp",
plugin: "chat.slashGroupPlugins",
extension: "chat.slashGroupExtensions",
skill: "chat.slashGroupSkills",
};

function CommandIcon({ kind }: { kind: ComposerCommand["kind"] }) {
if (kind === "template") return <IconSlash size={14} />;
if (kind === "plugin" || kind === "extension") return <IconPlug size={14} />;
if (kind === "skill") return <IconSparkles size={14} />;
return <IconSparkles size={14} />;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/hooks/use-composer-autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
5 changes: 4 additions & 1 deletion docs/spec/04-ux/04-builtin-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` / `<project>/.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 <prompt>`, `/plan-mode <prompt>`, or `/goal-mode <prompt>` switches
Expand Down
23 changes: 23 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project>/.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
`<skill>` 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
Expand Down
4 changes: 3 additions & 1 deletion docs/zh-CN/spec/04-ux/04-builtin-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ type CommandExecutionResult =
| `/goal-mode` | `builtin.mode.goal` |

别名与模板和插件命令名称共享一个命名空间;冲突时优先使用内置别名,其次是项目模板、
用户模板和插件命令。选择别名会插入 `/alias `;单独发送 `/new` 或 `/compact` 时,
用户模板、插件命令、扩展命令,最后是激活的用户技能(`~/.agents/skills` /
`<project>/.agents/skills`,按激活范围过滤,与其他面向应用的界面一致)。选择别名会插入
`/alias `;单独发送 `/new` 或 `/compact` 时,
会在本地执行,不会创建空提示。Agent/Plan/Goal 别名支持附带提示词:
`/agent-mode <prompt>`、`/plan-mode <prompt>` 或 `/goal-mode <prompt>` 会切换空闲会话
(或下一个会话默认值),并通过正常提示路径发送 `<prompt>`。不带正文的模式别名仍然
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export const ko = {
slashGroupApp: "앱 명령",
slashGroupPlugins: "플러그인 명령",
slashGroupExtensions: "확장 명령",
"slashGroupSkills": "스킬 명령",
slashEmpty: "일치하는 명령 없음",
fileMenu: "파일 참조",
fileReferences: "이 초안에서 참조하는 파일",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/tr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh-CN/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export const zhCN = {
slashGroupApp: "应用命令",
slashGroupPlugins: "插件命令",
slashGroupExtensions: "扩展命令",
"slashGroupSkills": "技能命令",
slashEmpty: "没有匹配的指令",
fileMenu: "引用文件",
fileReferences: "此草稿引用的文件",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh-TW/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export const zhTW = {
slashGroupApp: "應用命令",
slashGroupPlugins: "外掛命令",
slashGroupExtensions: "擴充命令",
"slashGroupSkills": "技能命令",
slashEmpty: "沒有匹配的指令",
fileMenu: "引用檔案",
fileReferences: "此草稿引用的檔案",
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down