diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e8dd60da..c142d038 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `ExtensionAPI.getCommands()` to let extensions list available slash commands (extensions, prompt templates, skills) for invocation via `prompt` ([#1210](https://github.com/badlogic/pi-mono/pull/1210) by [@w-winter](https://github.com/w-winter)) + ## [0.51.2] - 2026-02-03 ### New Features diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 5b12e1b8..6b1a54e5 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -942,6 +942,31 @@ pi.registerCommand("deploy", { }); ``` +### pi.getCommands() + +Get the slash commands available for invocation via `prompt` in the current session. Includes extension commands, prompt templates, and skill commands. +The list matches the RPC `get_commands` ordering: extensions first, then templates, then skills. + +```typescript +const commands = pi.getCommands(); +const bySource = commands.filter((command) => command.source === "extension"); +``` + +Each entry has this shape: + +```typescript +{ + name: string; // Command name without the leading slash + description?: string; + source: "extension" | "template" | "skill"; + location?: "user" | "project" | "path"; // For templates and skills + path?: string; // Files backing templates, skills, and extensions +} +``` + +Built-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive +mode and would not execute if sent via `prompt`. + ### pi.registerMessageRenderer(customType, renderer) Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui). diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c2c6295a..4c69c7f8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -67,6 +67,7 @@ import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.js"; import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js"; import type { SettingsManager } from "./settings-manager.js"; +import { BUILTIN_SLASH_COMMANDS, type SlashCommandInfo, type SlashCommandLocation } from "./slash-commands.js"; import { buildSystemPrompt } from "./system-prompt.js"; import type { BashOperations } from "./tools/bash.js"; import { createAllTools } from "./tools/index.js"; @@ -1775,6 +1776,45 @@ export class AgentSession { } private _bindExtensionCore(runner: ExtensionRunner): void { + const normalizeLocation = (source: string): SlashCommandLocation | undefined => { + if (source === "user" || source === "project" || source === "path") { + return source; + } + return undefined; + }; + + const reservedBuiltins = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name)); + + const getCommands = (): SlashCommandInfo[] => { + const extensionCommands: SlashCommandInfo[] = runner + .getRegisteredCommandsWithPaths() + .filter(({ command }) => !reservedBuiltins.has(command.name)) + .map(({ command, extensionPath }) => ({ + name: command.name, + description: command.description, + source: "extension", + path: extensionPath, + })); + + const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({ + name: template.name, + description: template.description, + source: "template", + location: normalizeLocation(template.source), + path: template.filePath, + })); + + const skills: SlashCommandInfo[] = this._resourceLoader.getSkills().skills.map((skill) => ({ + name: `skill:${skill.name}`, + description: skill.description, + source: "skill", + location: normalizeLocation(skill.source), + path: skill.filePath, + })); + + return [...extensionCommands, ...templates, ...skills]; + }; + runner.bindCore( { sendMessage: (message, options) => { @@ -1810,6 +1850,7 @@ export class AgentSession { getActiveTools: () => this.getActiveToolNames(), getAllTools: () => this.getAllTools(), setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames), + getCommands, setModel: async (model) => { const key = await this.modelRegistry.getApiKey(model); if (!key) return false; diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 14cbe658..0934395e 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -2,6 +2,7 @@ * Extension system for lifecycle events and custom tools. */ +export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands.js"; export { createExtensionRuntime, discoverAndLoadExtensions, @@ -68,6 +69,7 @@ export type { FindToolResultEvent, GetActiveToolsHandler, GetAllToolsHandler, + GetCommandsHandler, GetThinkingLevelHandler, GrepToolCallEvent, GrepToolResultEvent, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 4b46bb56..12f7c82c 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -119,6 +119,7 @@ export function createExtensionRuntime(): ExtensionRuntime { getActiveTools: notInitialized, getAllTools: notInitialized, setActiveTools: notInitialized, + getCommands: notInitialized, setModel: () => Promise.reject(new Error("Extension runtime not initialized")), getThinkingLevel: notInitialized, setThinkingLevel: notInitialized, @@ -228,6 +229,10 @@ function createExtensionAPI( runtime.setActiveTools(toolNames); }, + getCommands() { + return runtime.getCommands(); + }, + setModel(model) { return runtime.setModel(model); }, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 2eadb102..38791505 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -200,6 +200,7 @@ export class ExtensionRunner { this.runtime.getActiveTools = actions.getActiveTools; this.runtime.getAllTools = actions.getAllTools; this.runtime.setActiveTools = actions.setActiveTools; + this.runtime.getCommands = actions.getCommands; this.runtime.setModel = actions.setModel; this.runtime.getThinkingLevel = actions.getThinkingLevel; this.runtime.setThinkingLevel = actions.setThinkingLevel; diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index b805c8be..45bf4806 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -53,6 +53,7 @@ import type { SessionEntry, SessionManager, } from "../session-manager.js"; +import type { SlashCommandInfo } from "../slash-commands.js"; import type { BashOperations } from "../tools/bash.js"; import type { EditToolDetails } from "../tools/edit.js"; import type { @@ -973,6 +974,9 @@ export interface ExtensionAPI { /** Set the active tools by name. */ setActiveTools(toolNames: string[]): void; + /** Get available slash commands in the current session. */ + getCommands(): SlashCommandInfo[]; + // ========================================================================= // Model and Thinking Level // ========================================================================= @@ -1154,6 +1158,8 @@ export type ToolInfo = Pick; export type GetAllToolsHandler = () => ToolInfo[]; +export type GetCommandsHandler = () => SlashCommandInfo[]; + export type SetActiveToolsHandler = (toolNames: string[]) => void; export type SetModelHandler = (model: Model) => Promise; @@ -1188,6 +1194,7 @@ export interface ExtensionActions { getActiveTools: GetActiveToolsHandler; getAllTools: GetAllToolsHandler; setActiveTools: SetActiveToolsHandler; + getCommands: GetCommandsHandler; setModel: SetModelHandler; getThinkingLevel: GetThinkingLevelHandler; setThinkingLevel: SetThinkingLevelHandler; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 3edac009..beca4417 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -88,6 +88,9 @@ export type { ExtensionCommandContext, ExtensionContext, ExtensionFactory, + SlashCommandInfo, + SlashCommandLocation, + SlashCommandSource, ToolDefinition, } from "./extensions/index.js"; export type { PromptTemplate } from "./prompt-templates.js"; diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts new file mode 100644 index 00000000..ef77c889 --- /dev/null +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -0,0 +1,37 @@ +export type SlashCommandSource = "extension" | "template" | "skill"; + +export type SlashCommandLocation = "user" | "project" | "path"; + +export interface SlashCommandInfo { + name: string; + description?: string; + source: SlashCommandSource; + location?: SlashCommandLocation; + path?: string; +} + +export interface BuiltinSlashCommand { + name: string; + description: string; +} + +export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ + { name: "settings", description: "Open settings menu" }, + { name: "model", description: "Select model (opens selector UI)" }, + { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, + { name: "export", description: "Export session to HTML file" }, + { name: "share", description: "Share session as a secret GitHub gist" }, + { name: "copy", description: "Copy last agent message to clipboard" }, + { name: "name", description: "Set session display name" }, + { name: "session", description: "Show session info and stats" }, + { name: "changelog", description: "Show changelog entries" }, + { name: "hotkeys", description: "Show all keyboard shortcuts" }, + { name: "fork", description: "Create a new fork from a previous message" }, + { name: "tree", description: "Navigate session tree (switch branches)" }, + { name: "login", description: "Login with OAuth provider" }, + { name: "logout", description: "Logout from OAuth provider" }, + { name: "new", description: "Start a new session" }, + { name: "compact", description: "Manually compact the session context" }, + { name: "resume", description: "Resume a different session" }, + { name: "reload", description: "Reload extensions, skills, prompts, and themes" }, +]; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 68216790..0b43924a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -66,6 +66,7 @@ import { createCompactionSummaryMessage } from "../../core/messages.js"; import { resolveModelScope } from "../../core/model-resolver.js"; import type { ResourceDiagnostic } from "../../core/resource-loader.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; +import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js"; import { copyToClipboard } from "../../utils/clipboard.js"; @@ -288,56 +289,41 @@ export class InteractiveMode { private setupAutocomplete(fdPath: string | undefined): void { // Define commands for autocomplete - const slashCommands: SlashCommand[] = [ - { name: "settings", description: "Open settings menu" }, - { - name: "model", - description: "Select model (opens selector UI)", - getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { - // Get available models (scoped or from registry) - const models = - this.session.scopedModels.length > 0 - ? this.session.scopedModels.map((s) => s.model) - : this.session.modelRegistry.getAvailable(); + const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({ + name: command.name, + description: command.description, + })); - if (models.length === 0) return null; + const modelCommand = slashCommands.find((command) => command.name === "model"); + if (modelCommand) { + modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + // Get available models (scoped or from registry) + const models = + this.session.scopedModels.length > 0 + ? this.session.scopedModels.map((s) => s.model) + : this.session.modelRegistry.getAvailable(); - // Create items with provider/id format - const items = models.map((m) => ({ - id: m.id, - provider: m.provider, - label: `${m.provider}/${m.id}`, - })); + if (models.length === 0) return null; - // Fuzzy filter by model ID + provider (allows "opus anthropic" to match) - const filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`); + // Create items with provider/id format + const items = models.map((m) => ({ + id: m.id, + provider: m.provider, + label: `${m.provider}/${m.id}`, + })); - if (filtered.length === 0) return null; + // Fuzzy filter by model ID + provider (allows "opus anthropic" to match) + const filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`); - return filtered.map((item) => ({ - value: item.label, - label: item.id, - description: item.provider, - })); - }, - }, - { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, - { name: "export", description: "Export session to HTML file" }, - { name: "share", description: "Share session as a secret GitHub gist" }, - { name: "copy", description: "Copy last agent message to clipboard" }, - { name: "name", description: "Set session display name" }, - { name: "session", description: "Show session info and stats" }, - { name: "changelog", description: "Show changelog entries" }, - { name: "hotkeys", description: "Show all keyboard shortcuts" }, - { name: "fork", description: "Create a new fork from a previous message" }, - { name: "tree", description: "Navigate session tree (switch branches)" }, - { name: "login", description: "Login with OAuth provider" }, - { name: "logout", description: "Logout from OAuth provider" }, - { name: "new", description: "Start a new session" }, - { name: "compact", description: "Manually compact the session context" }, - { name: "resume", description: "Resume a different session" }, - { name: "reload", description: "Reload extensions, skills, prompts, and themes" }, - ]; + if (filtered.length === 0) return null; + + return filtered.map((item) => ({ + value: item.label, + label: item.id, + description: item.provider, + })); + }; + } // Convert prompt templates to SlashCommand format for autocomplete const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({