import type { AutocompleteItem } from "@f5-sales-demo/pi-tui"; import { parseFrontmatter, prompt } from "@f5-sales-demo/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, or delegates * to the matched subcommand's provider when a space is present. */ function buildArgumentCompletions(subcommands: SubcommandDef[]): (prefix: string) => AutocompleteItem[] | null { return (argumentPrefix: string) => { const firstSpace = argumentPrefix.indexOf(" "); if (firstSpace === -1) { // No space yet — filter subcommands by prefix (original behaviour). const lower = argumentPrefix.toLowerCase(); const matches = subcommands .filter(s => s.name.toLowerCase().startsWith(lower)) .map(s => ({ value: `${s.name} `, label: s.name, description: s.description, hint: s.usage, })); return matches.length > 0 ? matches : null; } // Space present: delegate to the matched subcommand's provider. Strip // leading whitespace so extra spaces between the subcommand name and its // argument (e.g. `activate pr`) don't break providers that guard on // `prefix.includes(" ")` as a past-argument signal. const subName = argumentPrefix.slice(0, firstSpace).toLowerCase(); const subPrefix = argumentPrefix.slice(firstSpace + 1).replace(/^ +/, ""); const sub = subcommands.find(s => s.name === subName); if (!sub?.getArgumentCompletions) return null; const items = sub.getArgumentCompletions(subPrefix); if (!items || items.length === 0) return null; // Prefix-rewrite values so applyCompletion replaces the full argumentText range. return items.map(item => ({ ...item, value: `${subName} ${item.value}` })); }; } /** @internal Test-only named export. Do not import outside `test/`. */ export const buildArgumentCompletionsForTest = buildArgumentCompletions; /** * 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) { // Still typing subcommand name — show remaining chars + usage 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}` : ""); } // Subcommand typed — show remaining usage params 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: 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; // e.g., "via xcsh (User)" /** Source metadata for display */ _source?: { providerName: string; level: "user" | "project" | "native" }; } /** A slash command as a CLIENT sees it: enough to render a menu entry, nothing more. */ export interface SlashCommandSummary { name: string; description: string; } /** * Project loaded slash commands onto the client-facing summary. * * `FileSlashCommand` also carries `content` — the prompt template, which can run to * hundreds of lines — plus `source` and `_source`, the operator's on-disk layout. A UI * surface needs only the name and description to populate its `/` menu, so this is the * one place that decides what crosses the boundary. Shared by every transport (the * `list_commands` bridge frame and the `list_commands` RPC command) so they can't drift * into leaking different amounts. Mirrors `toSkillSummaries` in `./skills`. */ export function toSlashCommandSummaries(commands: readonly FileSlashCommand[]): SlashCommandSummary[] { return commands.map(c => ({ name: c.name, description: c.description })); } 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() : ""; // Get description from frontmatter or first non-empty line 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 { /** Working directory for project-local commands. Default: getProjectDir() */ 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 { const result = await loadCapability(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", }); // Format source label: "via ProviderName Level" 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; }