diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 39013f0cb4..7595480efd 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -52,7 +52,7 @@ import { parseMentions } from "./mentions" import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message" import { formatResponse } from "./prompts/responses" import { SYSTEM_PROMPT } from "./prompts/system" -import { modes, defaultModeSlug, getModeBySlug } from "../shared/modes" +import { modes, defaultModeSlug, getModeBySlug, parseSlashCommand } from "../shared/modes" import { truncateHalfConversation } from "./sliding-window" import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider" import { detectCodeOmission } from "../integrations/editor/detect-omission" @@ -77,6 +77,29 @@ export class Cline { private terminalManager: TerminalManager private urlContentFetcher: UrlContentFetcher private browserSession: BrowserSession + + /** + * Processes a message for slash commands and handles mode switching if needed. + * @param message The message to process + * @returns The processed message with slash command removed if one was present + */ + private async handleSlashCommand(message: string): Promise { + if (!message) return message + + const { customModes } = (await this.providerRef.deref()?.getState()) ?? {} + const slashCommand = parseSlashCommand(message, customModes) + + if (slashCommand) { + // Switch mode before processing the remaining message + const provider = this.providerRef.deref() + if (provider) { + await provider.handleModeSwitch(slashCommand.modeSlug) + return slashCommand.remainingMessage + } + } + + return message + } private didEditFile: boolean = false customInstructions?: string diffStrategy?: DiffStrategy @@ -355,6 +378,11 @@ export class Cline { } async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + // Process slash command if present + if (text) { + text = await this.handleSlashCommand(text) + } + this.askResponse = askResponse this.askResponseText = text this.askResponseImages = images @@ -437,6 +465,22 @@ export class Cline { this.apiConversationHistory = [] await this.providerRef.deref()?.postStateToWebview() + // Check for slash command if task is provided + if (task) { + const { customModes } = (await this.providerRef.deref()?.getState()) ?? {} + const slashCommand = parseSlashCommand(task, customModes) + + if (slashCommand) { + // Switch mode before processing the remaining message + const provider = this.providerRef.deref() + if (provider) { + await provider.handleModeSwitch(slashCommand.modeSlug) + // Update task to be just the remaining message + task = slashCommand.remainingMessage + } + } + } + await this.say("text", task, images) let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) diff --git a/src/shared/__tests__/modes.test.ts b/src/shared/__tests__/modes.test.ts index 44d237630c..e373bc20ca 100644 --- a/src/shared/__tests__/modes.test.ts +++ b/src/shared/__tests__/modes.test.ts @@ -1,4 +1,4 @@ -import { isToolAllowedForMode, FileRestrictionError, ModeConfig } from "../modes" +import { isToolAllowedForMode, FileRestrictionError, ModeConfig, parseSlashCommand } from "../modes" describe("isToolAllowedForMode", () => { const customModes: ModeConfig[] = [ @@ -332,3 +332,65 @@ describe("FileRestrictionError", () => { expect(error.name).toBe("FileRestrictionError") }) }) + +describe("parseSlashCommand", () => { + const customModes: ModeConfig[] = [ + { + slug: "custom-mode", + name: "Custom Mode", + roleDefinition: "Custom role", + groups: ["read"], + }, + ] + + it("returns null for non-slash messages", () => { + expect(parseSlashCommand("hello world")).toBeNull() + expect(parseSlashCommand("code help me")).toBeNull() + }) + + it("returns null for incomplete commands", () => { + expect(parseSlashCommand("/")).toBeNull() + expect(parseSlashCommand("/code")).toBeNull() + expect(parseSlashCommand("/code ")).toBeNull() + }) + + it("returns null for invalid mode slugs", () => { + expect(parseSlashCommand("/invalid help me")).toBeNull() + expect(parseSlashCommand("/nonexistent do something")).toBeNull() + }) + + it("successfully parses valid commands", () => { + expect(parseSlashCommand("/code help me write tests")).toEqual({ + modeSlug: "code", + remainingMessage: "help me write tests", + }) + + expect(parseSlashCommand("/ask what is typescript?")).toEqual({ + modeSlug: "ask", + remainingMessage: "what is typescript?", + }) + + expect(parseSlashCommand("/architect plan this feature")).toEqual({ + modeSlug: "architect", + remainingMessage: "plan this feature", + }) + }) + + it("preserves whitespace in remaining message", () => { + expect(parseSlashCommand("/code help me write tests ")).toEqual({ + modeSlug: "code", + remainingMessage: "help me write tests", + }) + }) + + it("handles custom modes", () => { + expect(parseSlashCommand("/custom-mode do something", customModes)).toEqual({ + modeSlug: "custom-mode", + remainingMessage: "do something", + }) + }) + + it("returns null for invalid custom mode slugs", () => { + expect(parseSlashCommand("/invalid-custom do something", customModes)).toBeNull() + }) +}) diff --git a/src/shared/modes.ts b/src/shared/modes.ts index f1b899cc5c..bf4735e3f7 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -257,3 +257,36 @@ export function getCustomInstructions(modeSlug: string, customModes?: ModeConfig } return mode.customInstructions ?? "" } + +// Slash command parsing types and functions +export type SlashCommandResult = { + modeSlug: string + remainingMessage: string +} | null + +export function parseSlashCommand(message: string, customModes?: ModeConfig[]): SlashCommandResult { + // Check if message starts with a slash + if (!message.startsWith("/")) { + return null + } + + // Extract command (everything between / and first space) + const parts = message.trim().split(/\s+/) + if (parts.length < 2) { + return null // Need both command and message + } + + const command = parts[0].substring(1) // Remove leading slash + const remainingMessage = parts.slice(1).join(" ") + + // Validate command is a valid mode slug + const mode = getModeBySlug(command, customModes) + if (!mode) { + return null + } + + return { + modeSlug: command, + remainingMessage, + } +}