diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 7595480efd..39013f0cb4 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, parseSlashCommand } from "../shared/modes" +import { modes, defaultModeSlug, getModeBySlug } from "../shared/modes" import { truncateHalfConversation } from "./sliding-window" import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider" import { detectCodeOmission } from "../integrations/editor/detect-omission" @@ -77,29 +77,6 @@ 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 @@ -378,11 +355,6 @@ 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 @@ -465,22 +437,6 @@ 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 e373bc20ca..44d237630c 100644 --- a/src/shared/__tests__/modes.test.ts +++ b/src/shared/__tests__/modes.test.ts @@ -1,4 +1,4 @@ -import { isToolAllowedForMode, FileRestrictionError, ModeConfig, parseSlashCommand } from "../modes" +import { isToolAllowedForMode, FileRestrictionError, ModeConfig } from "../modes" describe("isToolAllowedForMode", () => { const customModes: ModeConfig[] = [ @@ -332,65 +332,3 @@ 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 bf4735e3f7..f1b899cc5c 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -257,36 +257,3 @@ 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, - } -} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index a20922db54..ae1b342dfe 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -179,6 +179,18 @@ const ChatTextArea = forwardRef( return } + if (type === ContextMenuOptionType.Mode && value) { + // Handle mode selection + setMode(value) + setInputValue("") + setShowContextMenu(false) + vscode.postMessage({ + type: "mode", + text: value, + }) + return + } + if ( type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder || @@ -242,7 +254,12 @@ const ChatTextArea = forwardRef( event.preventDefault() setSelectedMenuIndex((prevIndex) => { const direction = event.key === "ArrowUp" ? -1 : 1 - const options = getContextMenuOptions(searchQuery, selectedType, queryItems) + const options = getContextMenuOptions( + searchQuery, + selectedType, + queryItems, + getAllModes(customModes), + ) const optionsLength = options.length if (optionsLength === 0) return prevIndex @@ -272,9 +289,12 @@ const ChatTextArea = forwardRef( } if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) { event.preventDefault() - const selectedOption = getContextMenuOptions(searchQuery, selectedType, queryItems)[ - selectedMenuIndex - ] + const selectedOption = getContextMenuOptions( + searchQuery, + selectedType, + queryItems, + getAllModes(customModes), + )[selectedMenuIndex] if ( selectedOption && selectedOption.type !== ContextMenuOptionType.URL && @@ -340,6 +360,7 @@ const ChatTextArea = forwardRef( setInputValue, justDeletedSpaceAfterMention, queryItems, + customModes, ], ) @@ -360,13 +381,21 @@ const ChatTextArea = forwardRef( setShowContextMenu(showMenu) if (showMenu) { - const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1) - const query = newValue.slice(lastAtIndex + 1, newCursorPosition) - setSearchQuery(query) - if (query.length > 0) { + if (newValue.startsWith("/")) { + // Handle slash command + const query = newValue + setSearchQuery(query) setSelectedMenuIndex(0) } else { - setSelectedMenuIndex(3) // Set to "File" option by default + // Existing @ mention handling + const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1) + const query = newValue.slice(lastAtIndex + 1, newCursorPosition) + setSearchQuery(query) + if (query.length > 0) { + setSelectedMenuIndex(0) + } else { + setSelectedMenuIndex(3) // Set to "File" option by default + } } } else { setSearchQuery("") @@ -614,6 +643,7 @@ const ChatTextArea = forwardRef( setSelectedIndex={setSelectedMenuIndex} selectedType={selectedType} queryItems={queryItems} + modes={getAllModes(customModes)} /> )} diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index b1142fd5db..a102ca1fc2 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -878,7 +878,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie const placeholderText = useMemo(() => { const baseText = task ? "Type a message..." : "Type your task here..." - const contextText = "(@ to add context" + const contextText = "(@ to add context, / to switch modes" const imageText = shouldDisableImages ? "" : ", hold shift to drag in images" const helpText = imageText ? `\n${contextText}${imageText})` : `\n${contextText})` return baseText + helpText diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index 85ec865ccd..bd631a98d5 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef } from "react" import { ContextMenuOptionType, ContextMenuQueryItem, getContextMenuOptions } from "../../utils/context-mentions" import { removeLeadingNonAlphanumeric } from "../common/CodeAccordian" +import { ModeConfig } from "../../../../src/shared/modes" interface ContextMenuProps { onSelect: (type: ContextMenuOptionType, value?: string) => void @@ -10,6 +11,7 @@ interface ContextMenuProps { setSelectedIndex: (index: number) => void selectedType: ContextMenuOptionType | null queryItems: ContextMenuQueryItem[] + modes?: ModeConfig[] } const ContextMenu: React.FC = ({ @@ -20,12 +22,13 @@ const ContextMenu: React.FC = ({ setSelectedIndex, selectedType, queryItems, + modes, }) => { const menuRef = useRef(null) const filteredOptions = useMemo( - () => getContextMenuOptions(searchQuery, selectedType, queryItems), - [searchQuery, selectedType, queryItems], + () => getContextMenuOptions(searchQuery, selectedType, queryItems, modes), + [searchQuery, selectedType, queryItems, modes], ) useEffect(() => { @@ -46,6 +49,25 @@ const ContextMenu: React.FC = ({ const renderOptionContent = (option: ContextMenuQueryItem) => { switch (option.type) { + case ContextMenuOptionType.Mode: + return ( +
+ {option.label} + {option.description && ( + + {option.description} + + )} +
+ ) case ContextMenuOptionType.Problems: return Problems case ContextMenuOptionType.URL: @@ -101,6 +123,8 @@ const ContextMenu: React.FC = ({ const getIconForOption = (option: ContextMenuQueryItem): string => { switch (option.type) { + case ContextMenuOptionType.Mode: + return "symbol-misc" case ContextMenuOptionType.OpenedFile: return "window" case ContextMenuOptionType.File: @@ -174,15 +198,17 @@ const ContextMenu: React.FC = ({ overflow: "hidden", paddingTop: 0, }}> - + {option.type !== ContextMenuOptionType.Mode && getIconForOption(option) && ( + + )} {renderOptionContent(option)} {(option.type === ContextMenuOptionType.File || diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 5cce936b28..0fb57071ab 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -1,11 +1,20 @@ import { mentionRegex } from "../../../src/shared/context-mentions" import { Fzf } from "fzf" +import { ModeConfig } from "../../../src/shared/modes" export function insertMention( text: string, position: number, value: string, ): { newValue: string; mentionIndex: number } { + // Handle slash command + if (text.startsWith("/")) { + return { + newValue: value, + mentionIndex: 0, + } + } + const beforeCursor = text.slice(0, position) const afterCursor = text.slice(position) @@ -55,6 +64,7 @@ export enum ContextMenuOptionType { URL = "url", Git = "git", NoResults = "noResults", + Mode = "mode", // Add mode type } export interface ContextMenuQueryItem { @@ -69,7 +79,42 @@ export function getContextMenuOptions( query: string, selectedType: ContextMenuOptionType | null = null, queryItems: ContextMenuQueryItem[], + modes?: ModeConfig[], ): ContextMenuQueryItem[] { + // Handle slash commands for modes + if (query.startsWith("/")) { + const modeQuery = query.slice(1) + if (!modes?.length) return [{ type: ContextMenuOptionType.NoResults }] + + // Create searchable strings array for fzf + const searchableItems = modes.map((mode) => ({ + original: mode, + searchStr: mode.name, + })) + + // Initialize fzf instance for fuzzy search + const fzf = new Fzf(searchableItems, { + selector: (item) => item.searchStr, + }) + + // Get fuzzy matching items + const matchingModes = modeQuery + ? fzf.find(modeQuery).map((result) => ({ + type: ContextMenuOptionType.Mode, + value: result.item.original.slug, + label: result.item.original.name, + description: result.item.original.roleDefinition.split("\n")[0], + })) + : modes.map((mode) => ({ + type: ContextMenuOptionType.Mode, + value: mode.slug, + label: mode.name, + description: mode.roleDefinition.split("\n")[0], + })) + + return matchingModes.length > 0 ? matchingModes : [{ type: ContextMenuOptionType.NoResults }] + } + const workingChanges: ContextMenuQueryItem = { type: ContextMenuOptionType.Git, value: "git-changes", @@ -203,6 +248,11 @@ export function getContextMenuOptions( } export function shouldShowContextMenu(text: string, position: number): boolean { + // Handle slash command + if (text.startsWith("/")) { + return position <= text.length && !text.includes(" ") + } + const beforeCursor = text.slice(0, position) const atIndex = beforeCursor.lastIndexOf("@")