diff --git a/webview-ui/src/__tests__/command-autocomplete.spec.ts b/webview-ui/src/__tests__/command-autocomplete.spec.ts index 3789ad1bf50..154b6c614ad 100644 --- a/webview-ui/src/__tests__/command-autocomplete.spec.ts +++ b/webview-ui/src/__tests__/command-autocomplete.spec.ts @@ -18,7 +18,7 @@ describe("Command Autocomplete", () => { describe("slash command command suggestions", () => { it('should return all commands when query is just "/"', () => { - const options = getContextMenuOptions("/", "/", null, mockQueryItems, [], [], mockCommands) + const options = getContextMenuOptions("/", null, mockQueryItems, [], [], mockCommands) // Should have 6 items: 1 section header + 5 commands expect(options).toHaveLength(6) @@ -36,7 +36,7 @@ describe("Command Autocomplete", () => { }) it("should filter commands based on fuzzy search", () => { - const options = getContextMenuOptions("/set", "/set", null, mockQueryItems, [], [], mockCommands) + const options = getContextMenuOptions("/set", null, mockQueryItems, [], [], mockCommands) // Should match 'setup' (fuzzy search behavior may vary) expect(options.length).toBeGreaterThan(0) @@ -46,7 +46,7 @@ describe("Command Autocomplete", () => { }) it("should return commands with correct format", () => { - const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], mockCommands) + const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], mockCommands) const setupOption = options.find((option) => option.value === "setup") expect(setupOption).toBeDefined() @@ -56,7 +56,7 @@ describe("Command Autocomplete", () => { }) it("should handle empty command list", () => { - const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], []) + const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], []) // Should return NoResults when no commands match expect(options).toHaveLength(1) @@ -64,15 +64,7 @@ describe("Command Autocomplete", () => { }) it("should handle no matching commands", () => { - const options = getContextMenuOptions( - "/nonexistent", - "/nonexistent", - null, - mockQueryItems, - [], - [], - mockCommands, - ) + const options = getContextMenuOptions("/nonexistent", null, mockQueryItems, [], [], mockCommands) // Should return NoResults when no commands match expect(options).toHaveLength(1) @@ -80,7 +72,7 @@ describe("Command Autocomplete", () => { }) it("should not return command suggestions for non-slash queries", () => { - const options = getContextMenuOptions("setup", "setup", null, mockQueryItems, [], [], mockCommands) + const options = getContextMenuOptions("setup", null, mockQueryItems, [], [], mockCommands) // Should not contain command options for non-slash queries const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command) @@ -94,7 +86,7 @@ describe("Command Autocomplete", () => { { name: "deploy.prod", source: "global" }, ] - const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], specialCommands) + const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], specialCommands) const setupDevOption = options.find((option) => option.value === "setup-dev") expect(setupDevOption).toBeDefined() @@ -102,7 +94,7 @@ describe("Command Autocomplete", () => { }) it("should handle case-insensitive fuzzy matching", () => { - const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], mockCommands) + const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], mockCommands) const commandNames = options.map((option) => option.value) expect(commandNames).toContain("setup") @@ -115,15 +107,7 @@ describe("Command Autocomplete", () => { { name: "integration-test", source: "project" }, ] - const options = getContextMenuOptions( - "/test", - "/test", - null, - mockQueryItems, - [], - [], - commandsWithSimilarNames, - ) + const options = getContextMenuOptions("/test", null, mockQueryItems, [], [], commandsWithSimilarNames) // Filter out section headers and check the first command const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command) @@ -131,7 +115,7 @@ describe("Command Autocomplete", () => { }) it("should handle partial matches correctly", () => { - const options = getContextMenuOptions("/te", "/te", null, mockQueryItems, [], [], mockCommands) + const options = getContextMenuOptions("/te", null, mockQueryItems, [], [], mockCommands) // Should match 'test-suite' const commandNames = options.map((option) => option.value) @@ -158,7 +142,7 @@ describe("Command Autocomplete", () => { ] as any[] it("should return both modes and commands for slash commands", () => { - const options = getContextMenuOptions("/", "/", null, mockQueryItems, [], mockModes, mockCommands) + const options = getContextMenuOptions("/", null, mockQueryItems, [], mockModes, mockCommands) const modeOptions = options.filter((option) => option.type === ContextMenuOptionType.Mode) const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command) @@ -168,7 +152,7 @@ describe("Command Autocomplete", () => { }) it("should filter both modes and commands based on query", () => { - const options = getContextMenuOptions("/co", "/co", null, mockQueryItems, [], mockModes, mockCommands) + const options = getContextMenuOptions("/co", null, mockQueryItems, [], mockModes, mockCommands) // Should match 'code' mode and possibly some commands (fuzzy search may match) const modeOptions = options.filter((option) => option.type === ContextMenuOptionType.Mode) @@ -183,7 +167,7 @@ describe("Command Autocomplete", () => { describe("command source indication", () => { it("should not expose source information in autocomplete", () => { - const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], mockCommands) + const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], mockCommands) const setupOption = options.find((option) => option.value === "setup") expect(setupOption).toBeDefined() @@ -199,14 +183,14 @@ describe("Command Autocomplete", () => { describe("edge cases", () => { it("should handle undefined commands gracefully", () => { - const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], undefined) + const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], undefined) expect(options).toHaveLength(1) expect(options[0].type).toBe(ContextMenuOptionType.NoResults) }) it("should handle empty query with commands", () => { - const options = getContextMenuOptions("", "", null, mockQueryItems, [], [], mockCommands) + const options = getContextMenuOptions("", null, mockQueryItems, [], [], mockCommands) // Should not return command options for empty query const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command) @@ -218,7 +202,7 @@ describe("Command Autocomplete", () => { { name: "very-long-command-name-that-exceeds-normal-length", source: "project" }, ] - const options = getContextMenuOptions("/very", "/very", null, mockQueryItems, [], [], longNameCommands) + const options = getContextMenuOptions("/very", null, mockQueryItems, [], [], longNameCommands) // Should have 2 items: 1 section header + 1 command expect(options.length).toBe(2) @@ -233,7 +217,7 @@ describe("Command Autocomplete", () => { { name: "123test", source: "project" }, ] - const options = getContextMenuOptions("/v", "/v", null, mockQueryItems, [], [], numericCommands) + const options = getContextMenuOptions("/v", null, mockQueryItems, [], [], numericCommands) const commandNames = options.map((option) => option.value) expect(commandNames).toContain("v2-setup") diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 02020b453c2..ddd7e7f60e5 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -2,7 +2,7 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, us import { useEvent } from "react-use" import DynamicTextArea from "react-textarea-autosize" -import { mentionRegex, mentionRegexGlobal, unescapeSpaces } from "@roo/context-mentions" +import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions" import { WebviewMessage } from "@roo/WebviewMessage" import { Mode, getAllModes } from "@roo/modes" import { ExtensionMessage } from "@roo/ExtensionMessage" @@ -356,10 +356,14 @@ const ChatTextArea = forwardRef( insertValue = value ? `/${value}` : "" } + // Determine if this is a slash command selection + const isSlashCommand = type === ContextMenuOptionType.Mode || type === ContextMenuOptionType.Command + const { newValue, mentionIndex } = insertMention( textAreaRef.current.value, cursorPosition, insertValue, + isSlashCommand, ) setInputValue(newValue) @@ -395,7 +399,6 @@ const ChatTextArea = forwardRef( const direction = event.key === "ArrowUp" ? -1 : 1 const options = getContextMenuOptions( searchQuery, - inputValue, selectedType, queryItems, fileSearchResults, @@ -434,7 +437,6 @@ const ChatTextArea = forwardRef( event.preventDefault() const selectedOption = getContextMenuOptions( searchQuery, - inputValue, selectedType, queryItems, fileSearchResults, @@ -557,7 +559,7 @@ const ChatTextArea = forwardRef( setShowContextMenu(showMenu) if (showMenu) { - if (newValue.startsWith("/")) { + if (newValue.startsWith("/") && !newValue.includes(" ")) { // Handle slash command - request fresh commands const query = newValue setSearchQuery(query) @@ -716,6 +718,7 @@ const ChatTextArea = forwardRef( .replace(/\n$/, "\n\n") .replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] || c) .replace(mentionRegexGlobal, '$&') + .replace(commandRegexGlobal, '$&') highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index b938e06bef1..d240d8600bf 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -30,7 +30,6 @@ interface ContextMenuProps { const ContextMenu: React.FC = ({ onSelect, searchQuery, - inputValue, onMouseDown, selectedIndex, setSelectedIndex, @@ -44,16 +43,8 @@ const ContextMenu: React.FC = ({ const menuRef = useRef(null) const filteredOptions = useMemo(() => { - return getContextMenuOptions( - searchQuery, - inputValue, - selectedType, - queryItems, - dynamicSearchResults, - modes, - commands, - ) - }, [searchQuery, inputValue, selectedType, queryItems, dynamicSearchResults, modes, commands]) + return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands) + }, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands]) useEffect(() => { if (menuRef.current) { diff --git a/webview-ui/src/utils/__tests__/context-mentions.spec.ts b/webview-ui/src/utils/__tests__/context-mentions.spec.ts index 4fc1502892f..48b03907c34 100644 --- a/webview-ui/src/utils/__tests__/context-mentions.spec.ts +++ b/webview-ui/src/utils/__tests__/context-mentions.spec.ts @@ -51,7 +51,7 @@ describe("insertMention", () => { }) it("should handle slash command replacement", () => { - const result = insertMention("/mode some", 5, "code") // Simulating mode selection + const result = insertMention("/mode some", 5, "code", true) // Simulating mode selection expect(result.newValue).toBe("code") // Should replace the whole text expect(result.mentionIndex).toBe(0) }) @@ -103,6 +103,48 @@ describe("insertMention", () => { expect(result.mentionIndex).toBe(7) expect(result.newValue.includes("\\ ")).toBe(false) }) + + // --- Tests for isSlashCommand parameter --- + describe("isSlashCommand parameter", () => { + it("should replace entire text when isSlashCommand is true", () => { + const result = insertMention("/cod", 4, "code", true) + expect(result.newValue).toBe("code") + expect(result.mentionIndex).toBe(0) + }) + + it("should replace entire text even when @ mentions exist and isSlashCommand is true", () => { + const result = insertMention("/code @some/file.ts", 5, "debug", true) + expect(result.newValue).toBe("debug") + expect(result.mentionIndex).toBe(0) + }) + + it("should insert @ mention correctly after slash command when isSlashCommand is false", () => { + const text = "/code @" + const position = 8 // cursor after @ + const result = insertMention(text, position, "src/file.ts", false) + + expect(result.newValue).toBe("/code @src/file.ts ") + expect(result.mentionIndex).toBe(6) // position of @ + }) + + it("should not treat text starting with / as slash command when isSlashCommand is false", () => { + const text = "/some/path/file.ts @" + const position = 20 + const result = insertMention(text, position, "another.ts", false) + + expect(result.newValue).toBe("/some/path/file.ts @another.ts ") + expect(result.mentionIndex).toBe(19) // position of @ + }) + + it("should work with default parameter (isSlashCommand = false)", () => { + const text = "/code @" + const position = 8 + const result = insertMention(text, position, "src/file.ts") // No isSlashCommand parameter + + expect(result.newValue).toBe("/code @src/file.ts ") + expect(result.mentionIndex).toBe(6) + }) + }) }) describe("removeMention", () => { @@ -195,7 +237,7 @@ describe("getContextMenuOptions", () => { ] it("should return all option types for empty query", () => { - const result = getContextMenuOptions("", "", null, []) + const result = getContextMenuOptions("", null, []) expect(result).toHaveLength(6) expect(result.map((item) => item.type)).toEqual([ ContextMenuOptionType.Problems, @@ -208,7 +250,7 @@ describe("getContextMenuOptions", () => { }) it("should filter by selected type when query is empty", () => { - const result = getContextMenuOptions("", "", ContextMenuOptionType.File, mockQueryItems) + const result = getContextMenuOptions("", ContextMenuOptionType.File, mockQueryItems) expect(result).toHaveLength(2) expect(result.map((item) => item.type)).toContain(ContextMenuOptionType.File) expect(result.map((item) => item.type)).toContain(ContextMenuOptionType.OpenedFile) @@ -217,19 +259,19 @@ describe("getContextMenuOptions", () => { }) it("should match git commands", () => { - const result = getContextMenuOptions("git", "git", null, mockQueryItems) + const result = getContextMenuOptions("git", null, mockQueryItems) expect(result[0].type).toBe(ContextMenuOptionType.Git) expect(result[0].label).toBe("Git Commits") }) it("should match git commit hashes", () => { - const result = getContextMenuOptions("abc1234", "abc1234", null, mockQueryItems) + const result = getContextMenuOptions("abc1234", null, mockQueryItems) expect(result[0].type).toBe(ContextMenuOptionType.Git) expect(result[0].value).toBe("abc1234") }) it("should return NoResults when no matches found", () => { - const result = getContextMenuOptions("nonexistent", "nonexistent", null, mockQueryItems) + const result = getContextMenuOptions("nonexistent", null, mockQueryItems) expect(result).toHaveLength(1) expect(result[0].type).toBe(ContextMenuOptionType.NoResults) }) @@ -250,7 +292,7 @@ describe("getContextMenuOptions", () => { }, ] - const result = getContextMenuOptions("test", "test", null, testItems, mockDynamicSearchResults) + const result = getContextMenuOptions("test", null, testItems, mockDynamicSearchResults) // Check if opened files and dynamic search results are included expect(result.some((item) => item.type === ContextMenuOptionType.OpenedFile)).toBe(true) @@ -259,7 +301,7 @@ describe("getContextMenuOptions", () => { it("should maintain correct result ordering according to implementation", () => { // Add multiple item types to test ordering - const result = getContextMenuOptions("t", "t", null, mockQueryItems, mockDynamicSearchResults) + const result = getContextMenuOptions("t", null, mockQueryItems, mockDynamicSearchResults) // Find the different result types const fileResults = result.filter( @@ -290,7 +332,7 @@ describe("getContextMenuOptions", () => { }) it("should include opened files when dynamic search results exist", () => { - const result = getContextMenuOptions("open", "open", null, mockQueryItems, mockDynamicSearchResults) + const result = getContextMenuOptions("open", null, mockQueryItems, mockDynamicSearchResults) // Verify opened files are included expect(result.some((item) => item.type === ContextMenuOptionType.OpenedFile)).toBe(true) @@ -299,7 +341,7 @@ describe("getContextMenuOptions", () => { }) it("should include git results when dynamic search results exist", () => { - const result = getContextMenuOptions("commit", "commit", null, mockQueryItems, mockDynamicSearchResults) + const result = getContextMenuOptions("commit", null, mockQueryItems, mockDynamicSearchResults) // Verify git results are included expect(result.some((item) => item.type === ContextMenuOptionType.Git)).toBe(true) @@ -320,7 +362,7 @@ describe("getContextMenuOptions", () => { }, ] - const result = getContextMenuOptions("test", "test", null, mockQueryItems, duplicateSearchResults) + const result = getContextMenuOptions("test", null, mockQueryItems, duplicateSearchResults) // Count occurrences of src/test.ts in results const duplicateCount = result.filter( @@ -338,7 +380,6 @@ describe("getContextMenuOptions", () => { it("should return NoResults when all combined results are empty with dynamic search", () => { // Use a query that won't match anything const result = getContextMenuOptions( - "nonexistentquery123456", "nonexistentquery123456", null, mockQueryItems, @@ -387,7 +428,7 @@ describe("getContextMenuOptions", () => { ] // Get results for "test" query - const result = getContextMenuOptions(testQuery, testQuery, null, testItems, testSearchResults) + const result = getContextMenuOptions(testQuery, null, testItems, testSearchResults) // Verify we have results expect(result.length).toBeGreaterThan(0) @@ -433,7 +474,7 @@ describe("getContextMenuOptions", () => { }, ] - const result = getContextMenuOptions("/co", "/co", null, [], [], mockModes) + const result = getContextMenuOptions("/co", null, [], [], mockModes) // Should have section header first, then mode results expect(result[0].type).toBe(ContextMenuOptionType.SectionHeader) @@ -444,7 +485,7 @@ describe("getContextMenuOptions", () => { it("should not process slash commands when query starts with slash but inputValue doesn't", () => { // Use a completely non-matching query to ensure we get NoResults // and provide empty query items to avoid any matches - const result = getContextMenuOptions("/nonexistentquery", "Hello /code", null, [], []) + const result = getContextMenuOptions("/nonexistentquery", null, [], []) // Should not process as a mode command expect(result[0].type).not.toBe(ContextMenuOptionType.Mode) @@ -454,7 +495,7 @@ describe("getContextMenuOptions", () => { // --- Tests for Escaped Spaces (Focus on how paths are presented) --- it("should return search results with correct labels/descriptions (no escaping needed here)", () => { - const options = getContextMenuOptions("@search", "search", null, mockQueryItems, mockSearchResults) + const options = getContextMenuOptions("@search", null, mockQueryItems, mockSearchResults) const fileResult = options.find((o) => o.label === "search result spaces.ts") expect(fileResult).toBeDefined() // Value should be the normalized path, description might be the same or label @@ -467,7 +508,7 @@ describe("getContextMenuOptions", () => { }) it("should return query items (like opened files) with correct labels/descriptions", () => { - const options = getContextMenuOptions("open", "@open", null, mockQueryItems, []) + const options = getContextMenuOptions("open", null, mockQueryItems, []) const openedFile = options.find((o) => o.label === "open file.ts") expect(openedFile).toBeDefined() expect(openedFile?.value).toBe("src/open file.ts") @@ -484,7 +525,7 @@ describe("getContextMenuOptions", () => { ] // The formatting happens in getContextMenuOptions when converting search results to menu items - const formattedItems = getContextMenuOptions("spaces", "@spaces", null, [], searchResults) + const formattedItems = getContextMenuOptions("spaces", null, [], searchResults) // Verify we get some results back that aren't "No Results" expect(formattedItems.length).toBeGreaterThan(0) diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index f0a0b355cf6..b428d79d220 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -29,9 +29,10 @@ export function insertMention( text: string, position: number, value: string, + isSlashCommand: boolean = false, ): { newValue: string; mentionIndex: number } { - // Handle slash command - if (text.startsWith("/")) { + // Handle slash command selection (only when explicitly selecting a slash command) + if (isSlashCommand) { return { newValue: value, mentionIndex: 0, @@ -122,7 +123,6 @@ export interface ContextMenuQueryItem { export function getContextMenuOptions( query: string, - inputValue: string, selectedType: ContextMenuOptionType | null = null, queryItems: ContextMenuQueryItem[], dynamicSearchResults: SearchResult[] = [], @@ -130,7 +130,8 @@ export function getContextMenuOptions( commands?: Command[], ): ContextMenuQueryItem[] { // Handle slash commands for modes and commands - if (query.startsWith("/") && inputValue.startsWith("/")) { + // Only process as slash command if the query itself starts with "/" (meaning we're typing a slash command) + if (query.startsWith("/")) { const slashQuery = query.slice(1) const results: ContextMenuQueryItem[] = [] @@ -362,11 +363,14 @@ 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) + + // Check if we're in a slash command context (at the beginning and no space yet) + if (text.startsWith("/") && !text.includes(" ") && position <= text.length) { + return true + } + + // Check for @ mention context const atIndex = beforeCursor.lastIndexOf("@") if (atIndex === -1) {