Browse Source

Merge pull request #771 from RooVetGit/slash_command_autocomplete

Slash command autocomplete
Matt Rubens 11 months ago
parent
commit
2141d8492f

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

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

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

+ 39 - 9
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -179,6 +179,18 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 					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<HTMLTextAreaElement, ChatTextAreaProps>(
 						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<HTMLTextAreaElement, ChatTextAreaProps>(
 					}
 					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<HTMLTextAreaElement, ChatTextAreaProps>(
 				setInputValue,
 				justDeletedSpaceAfterMention,
 				queryItems,
+				customModes,
 			],
 		)
 
@@ -360,13 +381,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 
 				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<HTMLTextAreaElement, ChatTextAreaProps>(
 							setSelectedIndex={setSelectedMenuIndex}
 							selectedType={selectedType}
 							queryItems={queryItems}
+							modes={getAllModes(customModes)}
 						/>
 					</div>
 				)}

+ 1 - 1
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

+ 37 - 11
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<ContextMenuProps> = ({
@@ -20,12 +22,13 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 	setSelectedIndex,
 	selectedType,
 	queryItems,
+	modes,
 }) => {
 	const menuRef = useRef<HTMLDivElement>(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<ContextMenuProps> = ({
 
 	const renderOptionContent = (option: ContextMenuQueryItem) => {
 		switch (option.type) {
+			case ContextMenuOptionType.Mode:
+				return (
+					<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
+						<span style={{ lineHeight: "1.2" }}>{option.label}</span>
+						{option.description && (
+							<span
+								style={{
+									opacity: 0.5,
+									fontSize: "0.9em",
+									lineHeight: "1.2",
+									whiteSpace: "nowrap",
+									overflow: "hidden",
+									textOverflow: "ellipsis",
+								}}>
+								{option.description}
+							</span>
+						)}
+					</div>
+				)
 			case ContextMenuOptionType.Problems:
 				return <span>Problems</span>
 			case ContextMenuOptionType.URL:
@@ -101,6 +123,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 
 	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<ContextMenuProps> = ({
 								overflow: "hidden",
 								paddingTop: 0,
 							}}>
-							<i
-								className={`codicon codicon-${getIconForOption(option)}`}
-								style={{
-									marginRight: "6px",
-									flexShrink: 0,
-									fontSize: "14px",
-									marginTop: 0,
-								}}
-							/>
+							{option.type !== ContextMenuOptionType.Mode && getIconForOption(option) && (
+								<i
+									className={`codicon codicon-${getIconForOption(option)}`}
+									style={{
+										marginRight: "6px",
+										flexShrink: 0,
+										fontSize: "14px",
+										marginTop: 0,
+									}}
+								/>
+							)}
 							{renderOptionContent(option)}
 						</div>
 						{(option.type === ContextMenuOptionType.File ||

+ 50 - 0
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("@")