Skip to content

Commit 2141d84

Browse files
authored
Merge pull request #771 from RooVetGit/slash_command_autocomplete
Slash command autocomplete
2 parents 08ce482 + ebd9084 commit 2141d84

File tree

7 files changed

+129
-162
lines changed

7 files changed

+129
-162
lines changed

src/core/Cline.ts

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import { parseMentions } from "./mentions"
5252
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
5353
import { formatResponse } from "./prompts/responses"
5454
import { SYSTEM_PROMPT } from "./prompts/system"
55-
import { modes, defaultModeSlug, getModeBySlug, parseSlashCommand } from "../shared/modes"
55+
import { modes, defaultModeSlug, getModeBySlug } from "../shared/modes"
5656
import { truncateHalfConversation } from "./sliding-window"
5757
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
5858
import { detectCodeOmission } from "../integrations/editor/detect-omission"
@@ -77,29 +77,6 @@ export class Cline {
7777
private terminalManager: TerminalManager
7878
private urlContentFetcher: UrlContentFetcher
7979
private browserSession: BrowserSession
80-
81-
/**
82-
* Processes a message for slash commands and handles mode switching if needed.
83-
* @param message The message to process
84-
* @returns The processed message with slash command removed if one was present
85-
*/
86-
private async handleSlashCommand(message: string): Promise<string> {
87-
if (!message) return message
88-
89-
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
90-
const slashCommand = parseSlashCommand(message, customModes)
91-
92-
if (slashCommand) {
93-
// Switch mode before processing the remaining message
94-
const provider = this.providerRef.deref()
95-
if (provider) {
96-
await provider.handleModeSwitch(slashCommand.modeSlug)
97-
return slashCommand.remainingMessage
98-
}
99-
}
100-
101-
return message
102-
}
10380
private didEditFile: boolean = false
10481
customInstructions?: string
10582
diffStrategy?: DiffStrategy
@@ -378,11 +355,6 @@ export class Cline {
378355
}
379356

380357
async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) {
381-
// Process slash command if present
382-
if (text) {
383-
text = await this.handleSlashCommand(text)
384-
}
385-
386358
this.askResponse = askResponse
387359
this.askResponseText = text
388360
this.askResponseImages = images
@@ -465,22 +437,6 @@ export class Cline {
465437
this.apiConversationHistory = []
466438
await this.providerRef.deref()?.postStateToWebview()
467439

468-
// Check for slash command if task is provided
469-
if (task) {
470-
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
471-
const slashCommand = parseSlashCommand(task, customModes)
472-
473-
if (slashCommand) {
474-
// Switch mode before processing the remaining message
475-
const provider = this.providerRef.deref()
476-
if (provider) {
477-
await provider.handleModeSwitch(slashCommand.modeSlug)
478-
// Update task to be just the remaining message
479-
task = slashCommand.remainingMessage
480-
}
481-
}
482-
}
483-
484440
await this.say("text", task, images)
485441

486442
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)

src/shared/__tests__/modes.test.ts

Lines changed: 1 addition & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isToolAllowedForMode, FileRestrictionError, ModeConfig, parseSlashCommand } from "../modes"
1+
import { isToolAllowedForMode, FileRestrictionError, ModeConfig } from "../modes"
22

33
describe("isToolAllowedForMode", () => {
44
const customModes: ModeConfig[] = [
@@ -332,65 +332,3 @@ describe("FileRestrictionError", () => {
332332
expect(error.name).toBe("FileRestrictionError")
333333
})
334334
})
335-
336-
describe("parseSlashCommand", () => {
337-
const customModes: ModeConfig[] = [
338-
{
339-
slug: "custom-mode",
340-
name: "Custom Mode",
341-
roleDefinition: "Custom role",
342-
groups: ["read"],
343-
},
344-
]
345-
346-
it("returns null for non-slash messages", () => {
347-
expect(parseSlashCommand("hello world")).toBeNull()
348-
expect(parseSlashCommand("code help me")).toBeNull()
349-
})
350-
351-
it("returns null for incomplete commands", () => {
352-
expect(parseSlashCommand("/")).toBeNull()
353-
expect(parseSlashCommand("/code")).toBeNull()
354-
expect(parseSlashCommand("/code ")).toBeNull()
355-
})
356-
357-
it("returns null for invalid mode slugs", () => {
358-
expect(parseSlashCommand("/invalid help me")).toBeNull()
359-
expect(parseSlashCommand("/nonexistent do something")).toBeNull()
360-
})
361-
362-
it("successfully parses valid commands", () => {
363-
expect(parseSlashCommand("/code help me write tests")).toEqual({
364-
modeSlug: "code",
365-
remainingMessage: "help me write tests",
366-
})
367-
368-
expect(parseSlashCommand("/ask what is typescript?")).toEqual({
369-
modeSlug: "ask",
370-
remainingMessage: "what is typescript?",
371-
})
372-
373-
expect(parseSlashCommand("/architect plan this feature")).toEqual({
374-
modeSlug: "architect",
375-
remainingMessage: "plan this feature",
376-
})
377-
})
378-
379-
it("preserves whitespace in remaining message", () => {
380-
expect(parseSlashCommand("/code help me write tests ")).toEqual({
381-
modeSlug: "code",
382-
remainingMessage: "help me write tests",
383-
})
384-
})
385-
386-
it("handles custom modes", () => {
387-
expect(parseSlashCommand("/custom-mode do something", customModes)).toEqual({
388-
modeSlug: "custom-mode",
389-
remainingMessage: "do something",
390-
})
391-
})
392-
393-
it("returns null for invalid custom mode slugs", () => {
394-
expect(parseSlashCommand("/invalid-custom do something", customModes)).toBeNull()
395-
})
396-
})

src/shared/modes.ts

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -257,36 +257,3 @@ export function getCustomInstructions(modeSlug: string, customModes?: ModeConfig
257257
}
258258
return mode.customInstructions ?? ""
259259
}
260-
261-
// Slash command parsing types and functions
262-
export type SlashCommandResult = {
263-
modeSlug: string
264-
remainingMessage: string
265-
} | null
266-
267-
export function parseSlashCommand(message: string, customModes?: ModeConfig[]): SlashCommandResult {
268-
// Check if message starts with a slash
269-
if (!message.startsWith("/")) {
270-
return null
271-
}
272-
273-
// Extract command (everything between / and first space)
274-
const parts = message.trim().split(/\s+/)
275-
if (parts.length < 2) {
276-
return null // Need both command and message
277-
}
278-
279-
const command = parts[0].substring(1) // Remove leading slash
280-
const remainingMessage = parts.slice(1).join(" ")
281-
282-
// Validate command is a valid mode slug
283-
const mode = getModeBySlug(command, customModes)
284-
if (!mode) {
285-
return null
286-
}
287-
288-
return {
289-
modeSlug: command,
290-
remainingMessage,
291-
}
292-
}

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

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,18 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
179179
return
180180
}
181181

182+
if (type === ContextMenuOptionType.Mode && value) {
183+
// Handle mode selection
184+
setMode(value)
185+
setInputValue("")
186+
setShowContextMenu(false)
187+
vscode.postMessage({
188+
type: "mode",
189+
text: value,
190+
})
191+
return
192+
}
193+
182194
if (
183195
type === ContextMenuOptionType.File ||
184196
type === ContextMenuOptionType.Folder ||
@@ -242,7 +254,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
242254
event.preventDefault()
243255
setSelectedMenuIndex((prevIndex) => {
244256
const direction = event.key === "ArrowUp" ? -1 : 1
245-
const options = getContextMenuOptions(searchQuery, selectedType, queryItems)
257+
const options = getContextMenuOptions(
258+
searchQuery,
259+
selectedType,
260+
queryItems,
261+
getAllModes(customModes),
262+
)
246263
const optionsLength = options.length
247264

248265
if (optionsLength === 0) return prevIndex
@@ -272,9 +289,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
272289
}
273290
if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) {
274291
event.preventDefault()
275-
const selectedOption = getContextMenuOptions(searchQuery, selectedType, queryItems)[
276-
selectedMenuIndex
277-
]
292+
const selectedOption = getContextMenuOptions(
293+
searchQuery,
294+
selectedType,
295+
queryItems,
296+
getAllModes(customModes),
297+
)[selectedMenuIndex]
278298
if (
279299
selectedOption &&
280300
selectedOption.type !== ContextMenuOptionType.URL &&
@@ -340,6 +360,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
340360
setInputValue,
341361
justDeletedSpaceAfterMention,
342362
queryItems,
363+
customModes,
343364
],
344365
)
345366

@@ -360,13 +381,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
360381

361382
setShowContextMenu(showMenu)
362383
if (showMenu) {
363-
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
364-
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
365-
setSearchQuery(query)
366-
if (query.length > 0) {
384+
if (newValue.startsWith("/")) {
385+
// Handle slash command
386+
const query = newValue
387+
setSearchQuery(query)
367388
setSelectedMenuIndex(0)
368389
} else {
369-
setSelectedMenuIndex(3) // Set to "File" option by default
390+
// Existing @ mention handling
391+
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
392+
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
393+
setSearchQuery(query)
394+
if (query.length > 0) {
395+
setSelectedMenuIndex(0)
396+
} else {
397+
setSelectedMenuIndex(3) // Set to "File" option by default
398+
}
370399
}
371400
} else {
372401
setSearchQuery("")
@@ -614,6 +643,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
614643
setSelectedIndex={setSelectedMenuIndex}
615644
selectedType={selectedType}
616645
queryItems={queryItems}
646+
modes={getAllModes(customModes)}
617647
/>
618648
</div>
619649
)}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -878,7 +878,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
878878

879879
const placeholderText = useMemo(() => {
880880
const baseText = task ? "Type a message..." : "Type your task here..."
881-
const contextText = "(@ to add context"
881+
const contextText = "(@ to add context, / to switch modes"
882882
const imageText = shouldDisableImages ? "" : ", hold shift to drag in images"
883883
const helpText = imageText ? `\n${contextText}${imageText})` : `\n${contextText})`
884884
return baseText + helpText

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

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useEffect, useMemo, useRef } from "react"
22
import { ContextMenuOptionType, ContextMenuQueryItem, getContextMenuOptions } from "../../utils/context-mentions"
33
import { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
4+
import { ModeConfig } from "../../../../src/shared/modes"
45

56
interface ContextMenuProps {
67
onSelect: (type: ContextMenuOptionType, value?: string) => void
@@ -10,6 +11,7 @@ interface ContextMenuProps {
1011
setSelectedIndex: (index: number) => void
1112
selectedType: ContextMenuOptionType | null
1213
queryItems: ContextMenuQueryItem[]
14+
modes?: ModeConfig[]
1315
}
1416

1517
const ContextMenu: React.FC<ContextMenuProps> = ({
@@ -20,12 +22,13 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
2022
setSelectedIndex,
2123
selectedType,
2224
queryItems,
25+
modes,
2326
}) => {
2427
const menuRef = useRef<HTMLDivElement>(null)
2528

2629
const filteredOptions = useMemo(
27-
() => getContextMenuOptions(searchQuery, selectedType, queryItems),
28-
[searchQuery, selectedType, queryItems],
30+
() => getContextMenuOptions(searchQuery, selectedType, queryItems, modes),
31+
[searchQuery, selectedType, queryItems, modes],
2932
)
3033

3134
useEffect(() => {
@@ -46,6 +49,25 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
4649

4750
const renderOptionContent = (option: ContextMenuQueryItem) => {
4851
switch (option.type) {
52+
case ContextMenuOptionType.Mode:
53+
return (
54+
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
55+
<span style={{ lineHeight: "1.2" }}>{option.label}</span>
56+
{option.description && (
57+
<span
58+
style={{
59+
opacity: 0.5,
60+
fontSize: "0.9em",
61+
lineHeight: "1.2",
62+
whiteSpace: "nowrap",
63+
overflow: "hidden",
64+
textOverflow: "ellipsis",
65+
}}>
66+
{option.description}
67+
</span>
68+
)}
69+
</div>
70+
)
4971
case ContextMenuOptionType.Problems:
5072
return <span>Problems</span>
5173
case ContextMenuOptionType.URL:
@@ -101,6 +123,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
101123

102124
const getIconForOption = (option: ContextMenuQueryItem): string => {
103125
switch (option.type) {
126+
case ContextMenuOptionType.Mode:
127+
return "symbol-misc"
104128
case ContextMenuOptionType.OpenedFile:
105129
return "window"
106130
case ContextMenuOptionType.File:
@@ -174,15 +198,17 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
174198
overflow: "hidden",
175199
paddingTop: 0,
176200
}}>
177-
<i
178-
className={`codicon codicon-${getIconForOption(option)}`}
179-
style={{
180-
marginRight: "6px",
181-
flexShrink: 0,
182-
fontSize: "14px",
183-
marginTop: 0,
184-
}}
185-
/>
201+
{option.type !== ContextMenuOptionType.Mode && getIconForOption(option) && (
202+
<i
203+
className={`codicon codicon-${getIconForOption(option)}`}
204+
style={{
205+
marginRight: "6px",
206+
flexShrink: 0,
207+
fontSize: "14px",
208+
marginTop: 0,
209+
}}
210+
/>
211+
)}
186212
{renderOptionContent(option)}
187213
</div>
188214
{(option.type === ContextMenuOptionType.File ||

0 commit comments

Comments
 (0)