- Extracted prompt rendering and formatting utilities from coding-agent to centralized pi-utils package with new API surface (prompt.render, prompt.format, prompt.registerHelper). - Migrated parseFrontmatter utility from coding-agent to pi-utils package; updated 8 files to import from @oh-my-pi/pi-utils. - Removed 170-line prompt-format.ts module and consolidated 192 lines of Handlebars helper registrations into pi-utils prompt module. - Updated 60+ files across coding-agent and typescript-edit-benchmark to use new prompt.render() and prompt.format() API from pi-utils. - Simplified prompt-templates.ts by delegating core functionality to pi-utils while retaining custom helper registrations (jtdToTypeScript, jsonStringify, etc.).
228 lines
7.5 KiB
TypeScript
228 lines
7.5 KiB
TypeScript
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; // past the subcommand
|
|
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) {
|
|
// 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: 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 Claude Code (User)"
|
|
/** Source metadata for display */
|
|
_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() : "";
|
|
|
|
// 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<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",
|
|
});
|
|
|
|
// 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;
|
|
}
|