/** * The composer's local slash commands. * * Most of them do the same thing: render a pair of magic prompts — one the * user sees, one the model is instructed with — and send them as a single * message. That shape was previously written out nine times as an `else if` * chain, so adding a command meant copying twenty lines and remembering to * change every string in them. Here the shape is the executor and the * commands are data. * * Commands that are not "send a prompt pair" (undo, redo, timeline, compact, * handoff-review) stay with the composer: they manipulate session state or * open UI rather than producing a message. */ import type { I18nKey } from '@/lib/i18n'; import type { MagicPromptId } from '@/lib/magicPrompts'; /** What a command needs before it can run. */ export type CommandRequirement = 'session' | 'session-or-draft'; export interface MagicPromptCommand { /** The name typed after the slash. */ name: string; /** Magic prompt shown to the user as their message. */ visiblePrompt: MagicPromptId; /** Magic prompt attached as synthetic instructions for the model. */ instructionsPrompt: MagicPromptId; /** i18n key for the toast shown when the command fails. */ errorToastKey: I18nKey; requires: CommandRequirement; /** * Turn the text typed after the command name into template variables. * Commands without an argument omit this. */ buildVariables?: (argument: string) => { visible?: Record; instructions?: Record; }; } /** * `/summary rate limiting` focuses the summary on that topic. The topic is * woven into the visible message as a phrase and into the instructions as a * directive, so both read naturally when it is absent. */ const summaryVariables = (topic: string) => ({ visible: { topic_line: topic ? ` focused on: ${topic}` : '' }, instructions: { topic_block: topic ? `The user asked you to focus this summary on: ${topic}. Prioritize that topic; mention unrelated threads only in passing.` : '', }, }); /** `/craft-goal ` and `/schedule-task ` seed the prompt with the idea. */ const ideaVariables = (idea: string) => ({ visible: { idea_block: idea ? `\n\nHere is my initial idea:\n${idea}` : '' }, }); export const MAGIC_PROMPT_COMMANDS: readonly MagicPromptCommand[] = [ { name: 'summary', visiblePrompt: 'session.summary.visible', instructionsPrompt: 'session.summary.instructions', errorToastKey: 'chat.chatInput.toast.summaryFailed', // Summarizing needs a conversation to summarize. requires: 'session', buildVariables: summaryVariables, }, { name: 'workspace-review', visiblePrompt: 'session.review.visible', instructionsPrompt: 'session.review.instructions', errorToastKey: 'chat.chatInput.toast.reviewFailed', requires: 'session-or-draft', }, { name: 'plan-feature', visiblePrompt: 'session.plan.visible', instructionsPrompt: 'session.plan.instructions', errorToastKey: 'chat.chatInput.toast.planFeatureFailed', requires: 'session-or-draft', }, { name: 'craft-goal', visiblePrompt: 'session.craftGoal.visible', instructionsPrompt: 'session.craftGoal.instructions', errorToastKey: 'chat.chatInput.toast.craftGoalFailed', requires: 'session-or-draft', buildVariables: ideaVariables, }, { name: 'schedule-task', visiblePrompt: 'session.scheduleTask.visible', instructionsPrompt: 'session.scheduleTask.instructions', errorToastKey: 'chat.chatInput.toast.scheduleTaskFailed', requires: 'session-or-draft', buildVariables: ideaVariables, }, { name: 'catch-up', visiblePrompt: 'session.catchup.visible', instructionsPrompt: 'session.catchup.instructions', errorToastKey: 'chat.chatInput.toast.catchUpFailed', requires: 'session-or-draft', }, { name: 'debug', visiblePrompt: 'session.debug.visible', instructionsPrompt: 'session.debug.instructions', errorToastKey: 'chat.chatInput.toast.debugFailed', requires: 'session-or-draft', }, { name: 'weigh', visiblePrompt: 'session.weigh.visible', instructionsPrompt: 'session.weigh.instructions', errorToastKey: 'chat.chatInput.toast.weighFailed', requires: 'session-or-draft', }, { name: 'explore', visiblePrompt: 'session.explore.visible', instructionsPrompt: 'session.explore.instructions', errorToastKey: 'chat.chatInput.toast.exploreFailed', requires: 'session-or-draft', }, ]; const COMMANDS_BY_NAME = new Map(MAGIC_PROMPT_COMMANDS.map((command) => [command.name, command])); export interface ParsedSlashCommand { name: string; /** Everything typed after the command name, trimmed. */ argument: string; } /** * Read the leading slash command out of a message, if there is one. Only the * first word counts as the command; the rest is its argument. */ export function parseSlashCommand(text: string): ParsedSlashCommand | null { const trimmed = text.trimStart(); if (!trimmed.startsWith('/')) return null; const withoutSlash = trimmed.slice(1); const name = withoutSlash.trim().split(/\s+/)[0]?.toLowerCase() ?? ''; if (!name) return null; return { name, argument: withoutSlash.slice(name.length).trim(), }; } /** The prompt-pair command for `name`, or null when it is not one. */ export function findMagicPromptCommand(name: string): MagicPromptCommand | null { return COMMANDS_BY_NAME.get(name) ?? null; } /** Whether the current session state satisfies the command's requirement. */ export function canRunCommand( command: MagicPromptCommand, state: { hasSession: boolean; hasDraft: boolean }, ): boolean { return command.requires === 'session' ? state.hasSession : state.hasSession || state.hasDraft; } /** The template variables for both prompts of a command invocation. */ export function buildCommandVariables( command: MagicPromptCommand, argument: string, ): { visible: Record; instructions: Record } { const built = command.buildVariables?.(argument) ?? {}; return { visible: built.visible ?? {}, instructions: built.instructions ?? {}, }; }