Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 17 additions & 33 deletions webview-ui/src/__tests__/command-autocomplete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -56,31 +56,23 @@ 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)
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
})

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)
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
})

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)
Expand All @@ -94,15 +86,15 @@ 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()
expect(setupDevOption!.slashCommand).toBe("/setup-dev")
})

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")
Expand All @@ -115,23 +107,15 @@ 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)
expect(commandOptions[0].value).toBe("test")
})

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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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")
Expand Down
11 changes: 7 additions & 4 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -356,10 +356,14 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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)
Expand Down Expand Up @@ -395,7 +399,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const direction = event.key === "ArrowUp" ? -1 : 1
const options = getContextMenuOptions(
searchQuery,
inputValue,
selectedType,
queryItems,
fileSearchResults,
Expand Down Expand Up @@ -434,7 +437,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
event.preventDefault()
const selectedOption = getContextMenuOptions(
searchQuery,
inputValue,
selectedType,
queryItems,
fileSearchResults,
Expand Down Expand Up @@ -557,7 +559,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setShowContextMenu(showMenu)

if (showMenu) {
if (newValue.startsWith("/")) {
if (newValue.startsWith("/") && !newValue.includes(" ")) {
// Handle slash command - request fresh commands
const query = newValue
setSearchQuery(query)
Expand Down Expand Up @@ -716,6 +718,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
.replace(/\n$/, "\n\n")
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
.replace(commandRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')

highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
Expand Down
13 changes: 2 additions & 11 deletions webview-ui/src/components/chat/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ interface ContextMenuProps {
const ContextMenu: React.FC<ContextMenuProps> = ({
onSelect,
searchQuery,
inputValue,
onMouseDown,
selectedIndex,
setSelectedIndex,
Expand All @@ -44,16 +43,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
const menuRef = useRef<HTMLDivElement>(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) {
Expand Down
Loading