export interface ParsedCommand {
name: string;
arg: string;
}
const NAME_RE = /^[A-Za-z][A-Za-z0-9_:-]*$/;
export function parseSlashCommand(line: string): ParsedCommand | null {
const trimmed = line.trim();
if (!trimmed.startsWith('/')) return null;
const body = trimmed.slice(1);
const spaceIdx = body.search(/\s/);
const name = spaceIdx === -1 ? body : body.slice(0, spaceIdx);
if (!NAME_RE.test(name)) return null;
const arg = spaceIdx === -1 ? '' : body.slice(spaceIdx + 1).trim();
return { name, arg };
}
export type ApprovalMode = 'build' | 'plan' | 'bypass' | 'accept_edits';
export interface SlashHandlers {
setMode(mode: ApprovalMode): void | Promise<void>;
openModelPicker(): void;
setProvider(name: string): void | Promise<void>;
changeDir(path: string): void | Promise<void>;
openSessionSidebar(): void;
reloadConfig(): void | Promise<void>;
openSlashSkillsMenu(): void;
notice(text: string): void;
submitPrompt?(text: string): void | Promise<void>;
execServerCommand(command: string, arg: string): void | Promise<void>;
t(key: string, params?: Record<string, string | number>): string;
}
export interface SlashCommandDef {
name: string;
aliases?: string[];
descKey: string;
argHint?: string;
run(arg: string, h: SlashHandlers): void | Promise<void>;
}
export const FRONTEND_COMMANDS: SlashCommandDef[] = [
{ name: 'plan', descKey: 'cmd.plan.desc', run: (_a, h) => h.setMode('plan') },
{ name: 'build', descKey: 'cmd.build.desc', run: (_a, h) => h.setMode('build') },
{
name: 'model',
descKey: 'cmd.model.desc',
argHint: '[name]',
run: (a, h) => (a ? h.setProvider(a) : h.openModelPicker()),
},
{
name: 'cd',
descKey: 'cmd.cd.desc',
argHint: '<path>',
run: (a, h) => {
if (!a) { h.notice(h.t('cmd.cd.needArg')); return; }
return h.changeDir(a);
},
},
{ name: 'resume', descKey: 'cmd.resume.desc', run: (_a, h) => h.openSessionSidebar() },
{ name: 'reload', descKey: 'cmd.reload.desc', run: (_a, h) => h.reloadConfig() },
{ name: 'skills', descKey: 'cmd.skills.desc', run: (_a, h) => h.openSlashSkillsMenu() },
{ name: 'help', descKey: 'cmd.help.desc', run: (_a, h) => h.notice(buildHelpText(h.t)) },
{
name: 'review',
descKey: 'cmd.review.desc',
argHint: '[staged|base]',
run: (a, h) => h.submitPrompt?.(reviewPrompt(a)),
},
{ name: 'undo', descKey: 'cmd.undo.desc', argHint: '[n]', run: (a, h) => h.execServerCommand('undo', a) },
{
name: 'remember',
descKey: 'cmd.remember.desc',
argHint: '<fact>',
run: (a, h) => (a ? h.execServerCommand('remember', a) : h.notice(h.t('cmd.remember.needArg'))),
},
{
name: 'forget',
descKey: 'cmd.forget.desc',
argHint: '<keyword>',
run: (a, h) => (a ? h.execServerCommand('forget', a) : h.notice(h.t('cmd.forget.needArg'))),
},
{ name: 'memory', descKey: 'cmd.memory.desc', run: (a, h) => h.execServerCommand('memory', a) },
{ name: 'context', descKey: 'cmd.context.desc', run: (a, h) => h.execServerCommand('context', a) },
{ name: 'compact', descKey: 'cmd.compact.desc', argHint: '[focus]', run: (a, h) => h.execServerCommand('compact', a) },
{ name: 'whoami', descKey: 'cmd.whoami.desc', run: (a, h) => h.execServerCommand('whoami', a) },
{ name: 'status', descKey: 'cmd.status.desc', run: (a, h) => h.execServerCommand('status', a) },
{ name: 'config', descKey: 'cmd.config.desc', run: (a, h) => h.execServerCommand('config', a) },
{ name: 'diff', descKey: 'cmd.diff.desc', run: (a, h) => h.execServerCommand('diff', a) },
{ name: 'cost', descKey: 'cmd.cost.desc', run: (a, h) => h.execServerCommand('cost', a) },
{ name: 'todo', descKey: 'cmd.todo.desc', run: (a, h) => h.execServerCommand('todo', a) },
];
export function reviewPrompt(arg: string): string {
const scope = arg.trim();
if (!scope) {
return 'Review my current uncommitted changes: call the `code_review` tool with ' +
'{"scope":{"kind":"working_tree"}}, then give me a concise summary of its findings.';
}
if (scope.toLowerCase() === 'staged') {
return 'Review my staged changes: call the `code_review` tool with ' +
'{"scope":{"kind":"staged"}}, then give me a concise summary of its findings.';
}
return 'Review the requested committed range: call the `code_review` tool with ' +
JSON.stringify({ scope: { kind: 'range', base: scope, head: 'HEAD' } }) +
', then give me a concise summary of its findings.';
}
export function buildCommandMap(defs: SlashCommandDef[]): Map<string, SlashCommandDef> {
const m = new Map<string, SlashCommandDef>();
for (const d of defs) {
m.set(d.name, d);
for (const a of d.aliases ?? []) m.set(a, d);
}
return m;
}
export function buildHelpText(t: (key: string, params?: Record<string, string | number>) => string): string {
const lines = [t('cmd.help.title')];
for (const d of FRONTEND_COMMANDS) {
lines.push(`/${d.name}${d.argHint ? ' ' + d.argHint : ''} — ${t(d.descKey)}`);
}
return lines.join('\n');
}
export interface DispatchResult {
handled: boolean;
unknown?: boolean;
}
export async function dispatchSlashCommand(
line: string,
map: Map<string, SlashCommandDef>,
h: SlashHandlers,
): Promise<DispatchResult> {
const parsed = parseSlashCommand(line);
if (!parsed) return { handled: false };
const def = map.get(parsed.name);
if (!def) return { handled: false, unknown: true };
await def.run(parsed.arg, h);
return { handled: true };
}
export interface SlashMenuItem {
name: string;
description: string;
kind: 'command' | 'skill';
}
export function buildSlashMenuItems(
commands: SlashCommandDef[],
skills: { name: string; description?: string }[],
query: string,
t: (key: string) => string,
skillsOnly = false,
): SlashMenuItem[] {
const q = query.toLowerCase();
const cmdItems: SlashMenuItem[] = skillsOnly
? []
: commands
.filter((c) => c.name.toLowerCase().includes(q))
.map((c) => ({ name: c.name, description: t(c.descKey), kind: 'command' as const }));
const skillItems: SlashMenuItem[] = skills
.filter((s) => s.name.toLowerCase().includes(q))
.map((s) => ({ name: s.name, description: s.description ?? '', kind: 'skill' as const }))
.sort((a, b) => a.name.localeCompare(b.name));
return [...cmdItems, ...skillItems];
}