Merge pull request #764 from RooVetGit/slash_switch_modes

Slash commands for switching modes
This commit is contained in:
Matt Rubens 2025-02-04 01:14:35 -05:00 committed by GitHub
commit e3d7f25d26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 141 additions and 2 deletions

View file

@ -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)

View file

@ -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()
})
})

View file

@ -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,
}
}