diff --git a/packages/coding-agent/src/extensibility/extensions/get-commands-handler.ts b/packages/coding-agent/src/extensibility/extensions/get-commands-handler.ts new file mode 100644 index 000000000..c50010614 --- /dev/null +++ b/packages/coding-agent/src/extensibility/extensions/get-commands-handler.ts @@ -0,0 +1,77 @@ +/** + * Helper for wiring the `getCommands` action of {@link ExtensionAPI}. + * + * Centralizes the union over the three slash-command sources the runtime + * exposes so the five wiring sites (interactive UI, ACP, RPC, print, child + * task executor) cannot drift: + * - extension-registered hook commands (`source: "extension"`) + * - prompt commands loaded as `LoadedCustomCommand` — user/project/bundled + * custom commands and MCP prompts (`source: "prompt"`) + * - skill commands derived from `session.skills`, gated on + * `skillsSettings.enableSkillCommands` (`source: "skill"`) + * + * Built-in slash commands are intentionally excluded; `getCommands()` is the + * surface extensions use to discover dynamic commands they did not register + * themselves. Each frontend (interactive-mode, ACP) prepends its own builtins. + */ +import type { SkillsSettings } from "../../config/settings"; +import type { CustomCommandSource, LoadedCustomCommand } from "../custom-commands"; +import { getSkillSlashCommandName, type Skill } from "../skills"; +import type { SlashCommandInfo, SlashCommandLocation } from "../slash-commands"; +import type { ExtensionRunner } from "./runner"; + +interface CommandsCapableSession { + readonly extensionRunner?: ExtensionRunner; + readonly customCommands: ReadonlyArray; + readonly skills: ReadonlyArray; + readonly skillsSettings?: SkillsSettings; +} + +export function getSessionSlashCommands(session: CommandsCapableSession): SlashCommandInfo[] { + const out: SlashCommandInfo[] = []; + + const runner = session.extensionRunner; + if (runner) { + for (const cmd of runner.getRegisteredCommands()) { + out.push({ + name: cmd.name, + description: cmd.description, + source: "extension", + }); + } + } + + for (const cmd of session.customCommands) { + out.push({ + name: cmd.command.name, + description: cmd.command.description, + source: "prompt", + location: customCommandLocation(cmd.source), + path: cmd.resolvedPath, + }); + } + + if (session.skillsSettings?.enableSkillCommands) { + for (const skill of session.skills) { + out.push({ + name: getSkillSlashCommandName(skill), + description: skill.description || undefined, + source: "skill", + path: skill.filePath, + }); + } + } + + return out; +} + +function customCommandLocation(source: CustomCommandSource): SlashCommandLocation | undefined { + switch (source) { + case "user": + return "user"; + case "project": + return "project"; + case "bundled": + return undefined; + } +} diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 2017ea74a..d338b41e1 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -49,6 +49,7 @@ import { Settings } from "../../config/settings"; import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers"; import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions"; import { runExtensionCompact } from "../../extensibility/extensions/compact-handler"; +import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler"; import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills"; import { loadSlashCommands } from "../../extensibility/slash-commands"; import { MCPManager } from "../../mcp/manager"; @@ -1741,7 +1742,7 @@ export class AcpAgent implements Agent { getActiveTools: () => record.session.getActiveToolNames(), getAllTools: () => record.session.getAllToolNames(), setActiveTools: toolNames => record.session.setActiveToolsByName(toolNames), - getCommands: () => [], + getCommands: () => getSessionSlashCommands(record.session), setModel: async model => { const apiKey = await record.session.modelRegistry.getApiKey(model); if (!apiKey) { diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 12a96d307..762d70ac1 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -16,6 +16,7 @@ import type { SendUserMessageHandler, TerminalInputHandler, } from "../../extensibility/extensions"; +import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler"; import { HookEditorComponent } from "../../modes/components/hook-editor"; import { HookInputComponent } from "../../modes/components/hook-input"; import { HookSelectorComponent } from "../../modes/components/hook-selector"; @@ -109,7 +110,7 @@ export class ExtensionUiController { }, getThinkingLevel: () => this.ctx.session.thinkingLevel, setThinkingLevel: level => this.ctx.session.setThinkingLevel(level), - getCommands: () => [], + getCommands: () => getSessionSlashCommands(this.ctx.session), getSessionName: () => this.ctx.sessionManager.getSessionName(), setSessionName: name => this.#updateSessionName(name), }; @@ -349,7 +350,7 @@ export class ExtensionUiController { }, getThinkingLevel: () => this.ctx.session.thinkingLevel, setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist), - getCommands: () => [], + getCommands: () => getSessionSlashCommands(this.ctx.session), getSessionName: () => this.ctx.sessionManager.getSessionName(), setSessionName: name => this.#updateSessionName(name), }; diff --git a/packages/coding-agent/src/modes/runtime-init.ts b/packages/coding-agent/src/modes/runtime-init.ts index 426265770..a7164496c 100644 --- a/packages/coding-agent/src/modes/runtime-init.ts +++ b/packages/coding-agent/src/modes/runtime-init.ts @@ -7,6 +7,7 @@ * caller-supplied hooks. */ import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler"; +import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler"; import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types"; import type { AgentSession } from "../session/agent-session"; @@ -59,7 +60,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi getActiveTools: () => session.getActiveToolNames(), getAllTools: () => session.getAllToolNames(), setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), - getCommands: () => [], + getCommands: () => getSessionSlashCommands(session), setModel: model => runExtensionSetModel(session, model), getThinkingLevel: () => session.thinkingLevel, setThinkingLevel: level => session.setThinkingLevel(level), diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index c27aff586..a85ba6ce4 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -16,6 +16,7 @@ import { Settings } from "../config/settings"; import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema"; import type { CustomTool } from "../extensibility/custom-tools/types"; import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler"; +import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler"; import type { Skill } from "../extensibility/skills"; import type { HindsightSessionState } from "../hindsight/state"; import type { LocalProtocolOptions } from "../internal-urls"; @@ -1119,7 +1120,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise session.getAllToolNames(), setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames.filter(name => !parentOwnedToolNames.has(name))), - getCommands: () => [], + getCommands: () => getSessionSlashCommands(session), setModel: model => runExtensionSetModel(session, model), getThinkingLevel: () => session.thinkingLevel, setThinkingLevel: level => session.setThinkingLevel(level),