import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
import { parseFrontmatter, prompt } from "@oh-my-pi/pi-utils";
import { slashCommandCapability } from "../capability/slash-command";
import { appendInlineArgsFallback, templateUsesInlineArgPlaceholders } from "../config/prompt-templates";
import type { SlashCommand } from "../discovery";
import { loadCapability } from "../discovery";
import {
BUILTIN_SLASH_COMMAND_DEFS,
type BuiltinSlashCommand,
type SubcommandDef,
} from "../slash-commands/builtin-registry";
import { EMBEDDED_COMMAND_TEMPLATES } from "../task/commands";
import { parseCommandArgs, substituteArgs } from "../utils/command-args";
export type SlashCommandSource = "extension" | "prompt" | "skill";
export type SlashCommandLocation = "user" | "project" | "path";
export interface SlashCommandInfo {
name: string;
description?: string;
source: SlashCommandSource;
location?: SlashCommandLocation;
path?: string;
}
export type { BuiltinSlashCommand, SubcommandDef } from "../slash-commands/builtin-registry";
* Build getArgumentCompletions from declarative subcommand definitions.
* Returns subcommand names filtered by prefix in the dropdown.
*/
function buildArgumentCompletions(subcommands: SubcommandDef[]): (prefix: string) => AutocompleteItem[] | null {
return (argumentPrefix: string) => {
if (argumentPrefix.includes(" ")) return null;
const lower = argumentPrefix.toLowerCase();
const matches = subcommands
.filter(s => s.name.startsWith(lower))
.map(s => ({
value: `${s.name} `,
label: s.name,
description: s.description,
hint: s.usage,
}));
return matches.length > 0 ? matches : null;
};
}
* Build getInlineHint from declarative subcommand definitions.
* Shows remaining completion + usage as dim ghost text after cursor.
*/
function buildSubcommandInlineHint(subcommands: SubcommandDef[]): (argumentText: string) => string | null {
return (argumentText: string) => {
const trimmed = argumentText.trimStart();
const spaceIndex = trimmed.indexOf(" ");
if (spaceIndex === -1) {
const prefix = trimmed.toLowerCase();
if (prefix.length === 0) return null;
const match = subcommands.find(s => s.name.startsWith(prefix));
if (!match) return null;
const remaining = match.name.slice(prefix.length);
return remaining + (match.usage ? ` ${match.usage}` : "");
}
const subName = trimmed.slice(0, spaceIndex).toLowerCase();
const afterSub = trimmed.slice(spaceIndex + 1);
const sub = subcommands.find(s => s.name === subName);
if (!sub?.usage) return null;
if (afterSub.length > 0) {
const usageParts = sub.usage.split(" ");
const inputParts = afterSub.trim().split(/\s+/);
const remaining = usageParts.slice(inputParts.length);
return remaining.length > 0 ? remaining.join(" ") : null;
}
return sub.usage;
};
}
* Build getInlineHint for commands with a simple static hint string.
* Shows the hint only when no arguments have been typed yet.
*/
function buildStaticInlineHint(hint: string): (argumentText: string) => string | null {
return (argumentText: string) => (argumentText.trim().length === 0 ? hint : null);
}
* Materialized builtin slash commands with completion functions derived from
* declarative subcommand/hint definitions.
*/
export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<
BuiltinSlashCommand & {
getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
getInlineHint?: (argumentText: string) => string | null;
}
> = BUILTIN_SLASH_COMMAND_DEFS.map(cmd => {
if (cmd.subcommands) {
return {
...cmd,
getArgumentCompletions: buildArgumentCompletions(cmd.subcommands),
getInlineHint: buildSubcommandInlineHint(cmd.subcommands),
};
}
if (cmd.inlineHint) {
return {
...cmd,
getInlineHint: buildStaticInlineHint(cmd.inlineHint),
};
}
return cmd;
});
* Represents a custom slash command loaded from a file
*/
export interface FileSlashCommand {
name: string;
description: string;
content: string;
source: string;
_source?: { providerName: string; level: "user" | "project" | "native" };
}
const EMBEDDED_SLASH_COMMANDS = EMBEDDED_COMMAND_TEMPLATES;
function parseCommandTemplate(
content: string,
options: { source: string; level?: "off" | "warn" | "fatal" },
): { description: string; body: string } {
const { frontmatter, body } = parseFrontmatter(content, options);
const frontmatterDesc = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
let description = frontmatterDesc;
if (!description) {
const firstLine = body.split("\n").find(line => line.trim());
if (firstLine) {
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
}
return { description, body };
}
export interface LoadSlashCommandsOptions {
cwd?: string;
}
* Load all custom slash commands using the capability API.
* Loads from all registered providers (builtin, user, project).
*/
export async function loadSlashCommands(options: LoadSlashCommandsOptions = {}): Promise<FileSlashCommand[]> {
const result = await loadCapability<SlashCommand>(slashCommandCapability.id, { cwd: options.cwd });
const fileCommands: FileSlashCommand[] = result.items.map(cmd => {
const { description, body } = parseCommandTemplate(cmd.content, {
source: cmd.path ?? `slash-command:${cmd.name}`,
level: cmd.level === "native" ? "fatal" : "warn",
});
const capitalizedLevel = cmd.level.charAt(0).toUpperCase() + cmd.level.slice(1);
const sourceStr = `via ${cmd._source.providerName} ${capitalizedLevel}`;
return {
name: cmd.name,
description,
content: body,
source: sourceStr,
_source: { providerName: cmd._source.providerName, level: cmd.level },
};
});
const seenNames = new Set(fileCommands.map(cmd => cmd.name));
for (const cmd of EMBEDDED_SLASH_COMMANDS) {
const name = cmd.name.replace(/\.md$/, "");
if (seenNames.has(name)) continue;
const { description, body } = parseCommandTemplate(cmd.content, {
source: `embedded:${cmd.name}`,
level: "fatal",
});
fileCommands.push({
name,
description,
content: body,
source: "bundled",
});
seenNames.add(name);
}
return fileCommands;
}
* Expand a slash command if it matches a file-based command.
* Returns the expanded content or the original text if not a slash command.
*/
export function expandSlashCommand(text: string, fileCommands: FileSlashCommand[]): string {
if (!text.startsWith("/")) return text;
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
const fileCommand = fileCommands.find(cmd => cmd.name === commandName);
if (fileCommand) {
const args = parseCommandArgs(argsString);
const argsText = args.join(" ");
const usesInlineArgPlaceholders = templateUsesInlineArgPlaceholders(fileCommand.content);
const substituted = substituteArgs(fileCommand.content, args);
const rendered = prompt.render(substituted, { args, ARGUMENTS: argsText, arguments: argsText });
return appendInlineArgsFallback(rendered, argsText, usesInlineArgPlaceholders);
}
return text;
}