Merge pull request #771 from RooVetGit/slash_command_autocomplete

Slash command autocomplete
This commit is contained in:
Matt Rubens 2025-02-05 00:22:04 -05:00 committed by GitHub
commit 2141d8492f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 129 additions and 162 deletions

View file

@ -52,7 +52,7 @@ import { parseMentions } from "./mentions"
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
import { formatResponse } from "./prompts/responses"
import { SYSTEM_PROMPT } from "./prompts/system"
import { modes, defaultModeSlug, getModeBySlug, parseSlashCommand } from "../shared/modes"
import { modes, defaultModeSlug, getModeBySlug } from "../shared/modes"
import { truncateHalfConversation } from "./sliding-window"
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
import { detectCodeOmission } from "../integrations/editor/detect-omission"
@ -77,29 +77,6 @@ export class Cline {
private terminalManager: TerminalManager
private urlContentFetcher: UrlContentFetcher
private browserSession: BrowserSession
/**
* Processes a message for slash commands and handles mode switching if needed.
* @param message The message to process
* @returns The processed message with slash command removed if one was present
*/
private async handleSlashCommand(message: string): Promise<string> {
if (!message) return message
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
const slashCommand = parseSlashCommand(message, customModes)
if (slashCommand) {
// Switch mode before processing the remaining message
const provider = this.providerRef.deref()
if (provider) {
await provider.handleModeSwitch(slashCommand.modeSlug)
return slashCommand.remainingMessage
}
}
return message
}
private didEditFile: boolean = false
customInstructions?: string
diffStrategy?: DiffStrategy
@ -378,11 +355,6 @@ export class Cline {
}
async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) {
// Process slash command if present
if (text) {
text = await this.handleSlashCommand(text)
}
this.askResponse = askResponse
this.askResponseText = text
this.askResponseImages = images
@ -465,22 +437,6 @@ export class Cline {
this.apiConversationHistory = []
await this.providerRef.deref()?.postStateToWebview()
// Check for slash command if task is provided
if (task) {
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
const slashCommand = parseSlashCommand(task, customModes)
if (slashCommand) {
// Switch mode before processing the remaining message
const provider = this.providerRef.deref()
if (provider) {
await provider.handleModeSwitch(slashCommand.modeSlug)
// Update task to be just the remaining message
task = slashCommand.remainingMessage
}
}
}
await this.say("text", task, images)
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)

View file

@ -1,4 +1,4 @@
import { isToolAllowedForMode, FileRestrictionError, ModeConfig, parseSlashCommand } from "../modes"
import { isToolAllowedForMode, FileRestrictionError, ModeConfig } from "../modes"
describe("isToolAllowedForMode", () => {
const customModes: ModeConfig[] = [
@ -332,65 +332,3 @@ describe("FileRestrictionError", () => {
expect(error.name).toBe("FileRestrictionError")
})
})
describe("parseSlashCommand", () => {
const customModes: ModeConfig[] = [
{
slug: "custom-mode",
name: "Custom Mode",
roleDefinition: "Custom role",
groups: ["read"],
},
]
it("returns null for non-slash messages", () => {
expect(parseSlashCommand("hello world")).toBeNull()
expect(parseSlashCommand("code help me")).toBeNull()
})
it("returns null for incomplete commands", () => {
expect(parseSlashCommand("/")).toBeNull()
expect(parseSlashCommand("/code")).toBeNull()
expect(parseSlashCommand("/code ")).toBeNull()
})
it("returns null for invalid mode slugs", () => {
expect(parseSlashCommand("/invalid help me")).toBeNull()
expect(parseSlashCommand("/nonexistent do something")).toBeNull()
})
it("successfully parses valid commands", () => {
expect(parseSlashCommand("/code help me write tests")).toEqual({
modeSlug: "code",
remainingMessage: "help me write tests",
})
expect(parseSlashCommand("/ask what is typescript?")).toEqual({
modeSlug: "ask",
remainingMessage: "what is typescript?",
})
expect(parseSlashCommand("/architect plan this feature")).toEqual({
modeSlug: "architect",
remainingMessage: "plan this feature",
})
})
it("preserves whitespace in remaining message", () => {
expect(parseSlashCommand("/code help me write tests ")).toEqual({
modeSlug: "code",
remainingMessage: "help me write tests",
})
})
it("handles custom modes", () => {
expect(parseSlashCommand("/custom-mode do something", customModes)).toEqual({
modeSlug: "custom-mode",
remainingMessage: "do something",
})
})
it("returns null for invalid custom mode slugs", () => {
expect(parseSlashCommand("/invalid-custom do something", customModes)).toBeNull()
})
})

View file

@ -257,36 +257,3 @@ export function getCustomInstructions(modeSlug: string, customModes?: ModeConfig
}
return mode.customInstructions ?? ""
}
// Slash command parsing types and functions
export type SlashCommandResult = {
modeSlug: string
remainingMessage: string
} | null
export function parseSlashCommand(message: string, customModes?: ModeConfig[]): SlashCommandResult {
// Check if message starts with a slash
if (!message.startsWith("/")) {
return null
}
// Extract command (everything between / and first space)
const parts = message.trim().split(/\s+/)
if (parts.length < 2) {
return null // Need both command and message
}
const command = parts[0].substring(1) // Remove leading slash
const remainingMessage = parts.slice(1).join(" ")
// Validate command is a valid mode slug
const mode = getModeBySlug(command, customModes)
if (!mode) {
return null
}
return {
modeSlug: command,
remainingMessage,
}
}

View file

@ -179,6 +179,18 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
}
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<HTMLTextAreaElement, ChatTextAreaProps>(
setInputValue,
justDeletedSpaceAfterMention,
queryItems,
customModes,
],
)
@ -360,13 +381,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
setSelectedIndex={setSelectedMenuIndex}
selectedType={selectedType}
queryItems={queryItems}
modes={getAllModes(customModes)}
/>
</div>
)}

View file

@ -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

View file

@ -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<ContextMenuProps> = ({
@ -20,12 +22,13 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
setSelectedIndex,
selectedType,
queryItems,
modes,
}) => {
const menuRef = useRef<HTMLDivElement>(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<ContextMenuProps> = ({
const renderOptionContent = (option: ContextMenuQueryItem) => {
switch (option.type) {
case ContextMenuOptionType.Mode:
return (
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
<span style={{ lineHeight: "1.2" }}>{option.label}</span>
{option.description && (
<span
style={{
opacity: 0.5,
fontSize: "0.9em",
lineHeight: "1.2",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{option.description}
</span>
)}
</div>
)
case ContextMenuOptionType.Problems:
return <span>Problems</span>
case ContextMenuOptionType.URL:
@ -101,6 +123,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
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<ContextMenuProps> = ({
overflow: "hidden",
paddingTop: 0,
}}>
<i
className={`codicon codicon-${getIconForOption(option)}`}
style={{
marginRight: "6px",
flexShrink: 0,
fontSize: "14px",
marginTop: 0,
}}
/>
{option.type !== ContextMenuOptionType.Mode && getIconForOption(option) && (
<i
className={`codicon codicon-${getIconForOption(option)}`}
style={{
marginRight: "6px",
flexShrink: 0,
fontSize: "14px",
marginTop: 0,
}}
/>
)}
{renderOptionContent(option)}
</div>
{(option.type === ContextMenuOptionType.File ||

View file

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