Skip to content

Commit e956174

Browse files
authored
Support inserting mentions after a slash command (RooCodeInc#6327)
* Support inserting mentions after a slash command * Highlight slash commands
1 parent cd26fc4 commit e956174

File tree

5 files changed

+97
-74
lines changed

5 files changed

+97
-74
lines changed

webview-ui/src/__tests__/command-autocomplete.spec.ts

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe("Command Autocomplete", () => {
1818

1919
describe("slash command command suggestions", () => {
2020
it('should return all commands when query is just "/"', () => {
21-
const options = getContextMenuOptions("/", "/", null, mockQueryItems, [], [], mockCommands)
21+
const options = getContextMenuOptions("/", null, mockQueryItems, [], [], mockCommands)
2222

2323
// Should have 6 items: 1 section header + 5 commands
2424
expect(options).toHaveLength(6)
@@ -36,7 +36,7 @@ describe("Command Autocomplete", () => {
3636
})
3737

3838
it("should filter commands based on fuzzy search", () => {
39-
const options = getContextMenuOptions("/set", "/set", null, mockQueryItems, [], [], mockCommands)
39+
const options = getContextMenuOptions("/set", null, mockQueryItems, [], [], mockCommands)
4040

4141
// Should match 'setup' (fuzzy search behavior may vary)
4242
expect(options.length).toBeGreaterThan(0)
@@ -46,7 +46,7 @@ describe("Command Autocomplete", () => {
4646
})
4747

4848
it("should return commands with correct format", () => {
49-
const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], mockCommands)
49+
const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], mockCommands)
5050

5151
const setupOption = options.find((option) => option.value === "setup")
5252
expect(setupOption).toBeDefined()
@@ -56,31 +56,23 @@ describe("Command Autocomplete", () => {
5656
})
5757

5858
it("should handle empty command list", () => {
59-
const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], [])
59+
const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], [])
6060

6161
// Should return NoResults when no commands match
6262
expect(options).toHaveLength(1)
6363
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
6464
})
6565

6666
it("should handle no matching commands", () => {
67-
const options = getContextMenuOptions(
68-
"/nonexistent",
69-
"/nonexistent",
70-
null,
71-
mockQueryItems,
72-
[],
73-
[],
74-
mockCommands,
75-
)
67+
const options = getContextMenuOptions("/nonexistent", null, mockQueryItems, [], [], mockCommands)
7668

7769
// Should return NoResults when no commands match
7870
expect(options).toHaveLength(1)
7971
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
8072
})
8173

8274
it("should not return command suggestions for non-slash queries", () => {
83-
const options = getContextMenuOptions("setup", "setup", null, mockQueryItems, [], [], mockCommands)
75+
const options = getContextMenuOptions("setup", null, mockQueryItems, [], [], mockCommands)
8476

8577
// Should not contain command options for non-slash queries
8678
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
@@ -94,15 +86,15 @@ describe("Command Autocomplete", () => {
9486
{ name: "deploy.prod", source: "global" },
9587
]
9688

97-
const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], specialCommands)
89+
const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], specialCommands)
9890

9991
const setupDevOption = options.find((option) => option.value === "setup-dev")
10092
expect(setupDevOption).toBeDefined()
10193
expect(setupDevOption!.slashCommand).toBe("/setup-dev")
10294
})
10395

10496
it("should handle case-insensitive fuzzy matching", () => {
105-
const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], mockCommands)
97+
const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], mockCommands)
10698

10799
const commandNames = options.map((option) => option.value)
108100
expect(commandNames).toContain("setup")
@@ -115,23 +107,15 @@ describe("Command Autocomplete", () => {
115107
{ name: "integration-test", source: "project" },
116108
]
117109

118-
const options = getContextMenuOptions(
119-
"/test",
120-
"/test",
121-
null,
122-
mockQueryItems,
123-
[],
124-
[],
125-
commandsWithSimilarNames,
126-
)
110+
const options = getContextMenuOptions("/test", null, mockQueryItems, [], [], commandsWithSimilarNames)
127111

128112
// Filter out section headers and check the first command
129113
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
130114
expect(commandOptions[0].value).toBe("test")
131115
})
132116

133117
it("should handle partial matches correctly", () => {
134-
const options = getContextMenuOptions("/te", "/te", null, mockQueryItems, [], [], mockCommands)
118+
const options = getContextMenuOptions("/te", null, mockQueryItems, [], [], mockCommands)
135119

136120
// Should match 'test-suite'
137121
const commandNames = options.map((option) => option.value)
@@ -158,7 +142,7 @@ describe("Command Autocomplete", () => {
158142
] as any[]
159143

160144
it("should return both modes and commands for slash commands", () => {
161-
const options = getContextMenuOptions("/", "/", null, mockQueryItems, [], mockModes, mockCommands)
145+
const options = getContextMenuOptions("/", null, mockQueryItems, [], mockModes, mockCommands)
162146

163147
const modeOptions = options.filter((option) => option.type === ContextMenuOptionType.Mode)
164148
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
@@ -168,7 +152,7 @@ describe("Command Autocomplete", () => {
168152
})
169153

170154
it("should filter both modes and commands based on query", () => {
171-
const options = getContextMenuOptions("/co", "/co", null, mockQueryItems, [], mockModes, mockCommands)
155+
const options = getContextMenuOptions("/co", null, mockQueryItems, [], mockModes, mockCommands)
172156

173157
// Should match 'code' mode and possibly some commands (fuzzy search may match)
174158
const modeOptions = options.filter((option) => option.type === ContextMenuOptionType.Mode)
@@ -183,7 +167,7 @@ describe("Command Autocomplete", () => {
183167

184168
describe("command source indication", () => {
185169
it("should not expose source information in autocomplete", () => {
186-
const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], mockCommands)
170+
const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], mockCommands)
187171

188172
const setupOption = options.find((option) => option.value === "setup")
189173
expect(setupOption).toBeDefined()
@@ -199,14 +183,14 @@ describe("Command Autocomplete", () => {
199183

200184
describe("edge cases", () => {
201185
it("should handle undefined commands gracefully", () => {
202-
const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], undefined)
186+
const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], undefined)
203187

204188
expect(options).toHaveLength(1)
205189
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
206190
})
207191

208192
it("should handle empty query with commands", () => {
209-
const options = getContextMenuOptions("", "", null, mockQueryItems, [], [], mockCommands)
193+
const options = getContextMenuOptions("", null, mockQueryItems, [], [], mockCommands)
210194

211195
// Should not return command options for empty query
212196
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
@@ -218,7 +202,7 @@ describe("Command Autocomplete", () => {
218202
{ name: "very-long-command-name-that-exceeds-normal-length", source: "project" },
219203
]
220204

221-
const options = getContextMenuOptions("/very", "/very", null, mockQueryItems, [], [], longNameCommands)
205+
const options = getContextMenuOptions("/very", null, mockQueryItems, [], [], longNameCommands)
222206

223207
// Should have 2 items: 1 section header + 1 command
224208
expect(options.length).toBe(2)
@@ -233,7 +217,7 @@ describe("Command Autocomplete", () => {
233217
{ name: "123test", source: "project" },
234218
]
235219

236-
const options = getContextMenuOptions("/v", "/v", null, mockQueryItems, [], [], numericCommands)
220+
const options = getContextMenuOptions("/v", null, mockQueryItems, [], [], numericCommands)
237221

238222
const commandNames = options.map((option) => option.value)
239223
expect(commandNames).toContain("v2-setup")

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, us
22
import { useEvent } from "react-use"
33
import DynamicTextArea from "react-textarea-autosize"
44

5-
import { mentionRegex, mentionRegexGlobal, unescapeSpaces } from "@roo/context-mentions"
5+
import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions"
66
import { WebviewMessage } from "@roo/WebviewMessage"
77
import { Mode, getAllModes } from "@roo/modes"
88
import { ExtensionMessage } from "@roo/ExtensionMessage"
@@ -356,10 +356,14 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
356356
insertValue = value ? `/${value}` : ""
357357
}
358358

359+
// Determine if this is a slash command selection
360+
const isSlashCommand = type === ContextMenuOptionType.Mode || type === ContextMenuOptionType.Command
361+
359362
const { newValue, mentionIndex } = insertMention(
360363
textAreaRef.current.value,
361364
cursorPosition,
362365
insertValue,
366+
isSlashCommand,
363367
)
364368

365369
setInputValue(newValue)
@@ -395,7 +399,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
395399
const direction = event.key === "ArrowUp" ? -1 : 1
396400
const options = getContextMenuOptions(
397401
searchQuery,
398-
inputValue,
399402
selectedType,
400403
queryItems,
401404
fileSearchResults,
@@ -434,7 +437,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
434437
event.preventDefault()
435438
const selectedOption = getContextMenuOptions(
436439
searchQuery,
437-
inputValue,
438440
selectedType,
439441
queryItems,
440442
fileSearchResults,
@@ -557,7 +559,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
557559
setShowContextMenu(showMenu)
558560

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

720723
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
721724
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft

webview-ui/src/components/chat/ContextMenu.tsx

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ interface ContextMenuProps {
3030
const ContextMenu: React.FC<ContextMenuProps> = ({
3131
onSelect,
3232
searchQuery,
33-
inputValue,
3433
onMouseDown,
3534
selectedIndex,
3635
setSelectedIndex,
@@ -44,16 +43,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
4443
const menuRef = useRef<HTMLDivElement>(null)
4544

4645
const filteredOptions = useMemo(() => {
47-
return getContextMenuOptions(
48-
searchQuery,
49-
inputValue,
50-
selectedType,
51-
queryItems,
52-
dynamicSearchResults,
53-
modes,
54-
commands,
55-
)
56-
}, [searchQuery, inputValue, selectedType, queryItems, dynamicSearchResults, modes, commands])
46+
return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands)
47+
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands])
5748

5849
useEffect(() => {
5950
if (menuRef.current) {

0 commit comments

Comments
 (0)