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