Просмотр исходного кода

Slash commands for switching modes

Matt Rubens 11 месяцев назад
Родитель
Сommit
97128bc144
3 измененных файлов с 141 добавлено и 2 удалено
  1. 45 1
      src/core/Cline.ts
  2. 63 1
      src/shared/__tests__/modes.test.ts
  3. 33 0
      src/shared/modes.ts

+ 45 - 1
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<string> {
+		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)

+ 63 - 1
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()
+	})
+})

+ 33 - 0
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,
+	}
+}