Skip to content

Commit e3d7f25

Browse files
authored
Merge pull request RooCodeInc#764 from RooVetGit/slash_switch_modes
Slash commands for switching modes
2 parents 55e8170 + 97128bc commit e3d7f25

File tree

3 files changed

+141
-2
lines changed

3 files changed

+141
-2
lines changed

src/core/Cline.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import { parseMentions } from "./mentions"
5252
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
5353
import { formatResponse } from "./prompts/responses"
5454
import { SYSTEM_PROMPT } from "./prompts/system"
55-
import { modes, defaultModeSlug, getModeBySlug } from "../shared/modes"
55+
import { modes, defaultModeSlug, getModeBySlug, parseSlashCommand } from "../shared/modes"
5656
import { truncateHalfConversation } from "./sliding-window"
5757
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
5858
import { detectCodeOmission } from "../integrations/editor/detect-omission"
@@ -77,6 +77,29 @@ export class Cline {
7777
private terminalManager: TerminalManager
7878
private urlContentFetcher: UrlContentFetcher
7979
private browserSession: BrowserSession
80+
81+
/**
82+
* Processes a message for slash commands and handles mode switching if needed.
83+
* @param message The message to process
84+
* @returns The processed message with slash command removed if one was present
85+
*/
86+
private async handleSlashCommand(message: string): Promise<string> {
87+
if (!message) return message
88+
89+
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
90+
const slashCommand = parseSlashCommand(message, customModes)
91+
92+
if (slashCommand) {
93+
// Switch mode before processing the remaining message
94+
const provider = this.providerRef.deref()
95+
if (provider) {
96+
await provider.handleModeSwitch(slashCommand.modeSlug)
97+
return slashCommand.remainingMessage
98+
}
99+
}
100+
101+
return message
102+
}
80103
private didEditFile: boolean = false
81104
customInstructions?: string
82105
diffStrategy?: DiffStrategy
@@ -355,6 +378,11 @@ export class Cline {
355378
}
356379

357380
async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) {
381+
// Process slash command if present
382+
if (text) {
383+
text = await this.handleSlashCommand(text)
384+
}
385+
358386
this.askResponse = askResponse
359387
this.askResponseText = text
360388
this.askResponseImages = images
@@ -437,6 +465,22 @@ export class Cline {
437465
this.apiConversationHistory = []
438466
await this.providerRef.deref()?.postStateToWebview()
439467

468+
// Check for slash command if task is provided
469+
if (task) {
470+
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
471+
const slashCommand = parseSlashCommand(task, customModes)
472+
473+
if (slashCommand) {
474+
// Switch mode before processing the remaining message
475+
const provider = this.providerRef.deref()
476+
if (provider) {
477+
await provider.handleModeSwitch(slashCommand.modeSlug)
478+
// Update task to be just the remaining message
479+
task = slashCommand.remainingMessage
480+
}
481+
}
482+
}
483+
440484
await this.say("text", task, images)
441485

442486
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)

src/shared/__tests__/modes.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isToolAllowedForMode, FileRestrictionError, ModeConfig } from "../modes"
1+
import { isToolAllowedForMode, FileRestrictionError, ModeConfig, parseSlashCommand } from "../modes"
22

33
describe("isToolAllowedForMode", () => {
44
const customModes: ModeConfig[] = [
@@ -332,3 +332,65 @@ describe("FileRestrictionError", () => {
332332
expect(error.name).toBe("FileRestrictionError")
333333
})
334334
})
335+
336+
describe("parseSlashCommand", () => {
337+
const customModes: ModeConfig[] = [
338+
{
339+
slug: "custom-mode",
340+
name: "Custom Mode",
341+
roleDefinition: "Custom role",
342+
groups: ["read"],
343+
},
344+
]
345+
346+
it("returns null for non-slash messages", () => {
347+
expect(parseSlashCommand("hello world")).toBeNull()
348+
expect(parseSlashCommand("code help me")).toBeNull()
349+
})
350+
351+
it("returns null for incomplete commands", () => {
352+
expect(parseSlashCommand("/")).toBeNull()
353+
expect(parseSlashCommand("/code")).toBeNull()
354+
expect(parseSlashCommand("/code ")).toBeNull()
355+
})
356+
357+
it("returns null for invalid mode slugs", () => {
358+
expect(parseSlashCommand("/invalid help me")).toBeNull()
359+
expect(parseSlashCommand("/nonexistent do something")).toBeNull()
360+
})
361+
362+
it("successfully parses valid commands", () => {
363+
expect(parseSlashCommand("/code help me write tests")).toEqual({
364+
modeSlug: "code",
365+
remainingMessage: "help me write tests",
366+
})
367+
368+
expect(parseSlashCommand("/ask what is typescript?")).toEqual({
369+
modeSlug: "ask",
370+
remainingMessage: "what is typescript?",
371+
})
372+
373+
expect(parseSlashCommand("/architect plan this feature")).toEqual({
374+
modeSlug: "architect",
375+
remainingMessage: "plan this feature",
376+
})
377+
})
378+
379+
it("preserves whitespace in remaining message", () => {
380+
expect(parseSlashCommand("/code help me write tests ")).toEqual({
381+
modeSlug: "code",
382+
remainingMessage: "help me write tests",
383+
})
384+
})
385+
386+
it("handles custom modes", () => {
387+
expect(parseSlashCommand("/custom-mode do something", customModes)).toEqual({
388+
modeSlug: "custom-mode",
389+
remainingMessage: "do something",
390+
})
391+
})
392+
393+
it("returns null for invalid custom mode slugs", () => {
394+
expect(parseSlashCommand("/invalid-custom do something", customModes)).toBeNull()
395+
})
396+
})

src/shared/modes.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,36 @@ export function getCustomInstructions(modeSlug: string, customModes?: ModeConfig
257257
}
258258
return mode.customInstructions ?? ""
259259
}
260+
261+
// Slash command parsing types and functions
262+
export type SlashCommandResult = {
263+
modeSlug: string
264+
remainingMessage: string
265+
} | null
266+
267+
export function parseSlashCommand(message: string, customModes?: ModeConfig[]): SlashCommandResult {
268+
// Check if message starts with a slash
269+
if (!message.startsWith("/")) {
270+
return null
271+
}
272+
273+
// Extract command (everything between / and first space)
274+
const parts = message.trim().split(/\s+/)
275+
if (parts.length < 2) {
276+
return null // Need both command and message
277+
}
278+
279+
const command = parts[0].substring(1) // Remove leading slash
280+
const remainingMessage = parts.slice(1).join(" ")
281+
282+
// Validate command is a valid mode slug
283+
const mode = getModeBySlug(command, customModes)
284+
if (!mode) {
285+
return null
286+
}
287+
288+
return {
289+
modeSlug: command,
290+
remainingMessage,
291+
}
292+
}

0 commit comments

Comments
 (0)