Skip to content

Commit 9e48296

Browse files
committed
Add a UI for managing slash commands
1 parent 342ee70 commit 9e48296

File tree

9 files changed

+583
-1
lines changed

9 files changed

+583
-1
lines changed

src/core/webview/webviewMessageHandler.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2365,6 +2365,7 @@ export const webviewMessageHandler = async (
23652365
const commandList = commands.map((command) => ({
23662366
name: command.name,
23672367
source: command.source,
2368+
filePath: command.filePath,
23682369
}))
23692370

23702371
await provider.postMessageToWebview({
@@ -2381,5 +2382,170 @@ export const webviewMessageHandler = async (
23812382
}
23822383
break
23832384
}
2385+
case "openCommandFile": {
2386+
try {
2387+
if (message.text) {
2388+
const { getCommand } = await import("../../services/command/commands")
2389+
const command = await getCommand(provider.cwd || "", message.text)
2390+
2391+
if (command && command.filePath) {
2392+
openFile(command.filePath)
2393+
} else {
2394+
vscode.window.showErrorMessage(t("common:errors.command_not_found", { name: message.text }))
2395+
}
2396+
}
2397+
} catch (error) {
2398+
provider.log(
2399+
`Error opening command file: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
2400+
)
2401+
vscode.window.showErrorMessage(t("common:errors.open_command_file"))
2402+
}
2403+
break
2404+
}
2405+
case "deleteCommand": {
2406+
try {
2407+
if (message.text && message.values?.source) {
2408+
const { getCommand } = await import("../../services/command/commands")
2409+
const command = await getCommand(provider.cwd || "", message.text)
2410+
2411+
if (command && command.filePath) {
2412+
// Delete the command file
2413+
await fs.unlink(command.filePath)
2414+
provider.log(`Deleted command file: ${command.filePath}`)
2415+
} else {
2416+
vscode.window.showErrorMessage(t("common:errors.command_not_found", { name: message.text }))
2417+
}
2418+
}
2419+
} catch (error) {
2420+
provider.log(`Error deleting command: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
2421+
vscode.window.showErrorMessage(t("common:errors.delete_command"))
2422+
}
2423+
break
2424+
}
2425+
case "createCommand": {
2426+
try {
2427+
const source = message.values?.source as "global" | "project"
2428+
const fileName = message.text // Custom filename from user input
2429+
2430+
if (!source) {
2431+
provider.log("Missing source for createCommand")
2432+
break
2433+
}
2434+
2435+
// Determine the commands directory based on source
2436+
let commandsDir: string
2437+
if (source === "global") {
2438+
const globalConfigDir = path.join(os.homedir(), ".roo")
2439+
commandsDir = path.join(globalConfigDir, "commands")
2440+
} else {
2441+
// Project commands
2442+
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
2443+
if (!workspaceRoot) {
2444+
vscode.window.showErrorMessage("No workspace folder found for project command")
2445+
break
2446+
}
2447+
commandsDir = path.join(workspaceRoot, ".roo", "commands")
2448+
}
2449+
2450+
// Ensure the commands directory exists
2451+
await fs.mkdir(commandsDir, { recursive: true })
2452+
2453+
// Use provided filename or generate a unique one
2454+
let commandName: string
2455+
if (fileName && fileName.trim()) {
2456+
let cleanFileName = fileName.trim()
2457+
2458+
// Strip leading slash if present
2459+
if (cleanFileName.startsWith("/")) {
2460+
cleanFileName = cleanFileName.substring(1)
2461+
}
2462+
2463+
// Remove .md extension if present BEFORE slugification
2464+
if (cleanFileName.toLowerCase().endsWith(".md")) {
2465+
cleanFileName = cleanFileName.slice(0, -3)
2466+
}
2467+
2468+
// Slugify the command name: lowercase, replace spaces with dashes, remove special characters
2469+
commandName = cleanFileName
2470+
.toLowerCase()
2471+
.replace(/\s+/g, "-") // Replace spaces with dashes
2472+
.replace(/[^a-z0-9-]/g, "") // Remove special characters except dashes
2473+
.replace(/-+/g, "-") // Replace multiple dashes with single dash
2474+
.replace(/^-|-$/g, "") // Remove leading/trailing dashes
2475+
2476+
// Ensure we have a valid command name
2477+
if (!commandName || commandName.length === 0) {
2478+
commandName = "new-command"
2479+
}
2480+
} else {
2481+
// Generate a unique command name
2482+
commandName = "new-command"
2483+
let counter = 1
2484+
let filePath = path.join(commandsDir, `${commandName}.md`)
2485+
2486+
while (
2487+
await fs
2488+
.access(filePath)
2489+
.then(() => true)
2490+
.catch(() => false)
2491+
) {
2492+
commandName = `new-command-${counter}`
2493+
filePath = path.join(commandsDir, `${commandName}.md`)
2494+
counter++
2495+
}
2496+
}
2497+
2498+
const filePath = path.join(commandsDir, `${commandName}.md`)
2499+
2500+
// Check if file already exists
2501+
if (
2502+
await fs
2503+
.access(filePath)
2504+
.then(() => true)
2505+
.catch(() => false)
2506+
) {
2507+
vscode.window.showErrorMessage(`Command "${commandName}" already exists`)
2508+
break
2509+
}
2510+
2511+
// Create the command file with template content
2512+
const templateContent = "This is a new slash command. Edit this file to customize the command behavior."
2513+
2514+
await fs.writeFile(filePath, templateContent, "utf8")
2515+
provider.log(`Created new command file: ${filePath}`)
2516+
2517+
// Open the new file in the editor
2518+
openFile(filePath)
2519+
2520+
// Refresh commands list
2521+
const { getCommands } = await import("../../services/command/commands")
2522+
const commands = await getCommands(provider.cwd || "")
2523+
const commandList = commands.map((command) => ({
2524+
name: command.name,
2525+
source: command.source,
2526+
filePath: command.filePath,
2527+
}))
2528+
await provider.postMessageToWebview({
2529+
type: "commands",
2530+
commands: commandList,
2531+
})
2532+
} catch (error) {
2533+
provider.log(`Error creating command: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
2534+
vscode.window.showErrorMessage("Failed to create command")
2535+
}
2536+
break
2537+
}
2538+
2539+
case "insertTextIntoTextarea": {
2540+
const text = message.text
2541+
if (text) {
2542+
// Send message to insert text into the chat textarea
2543+
await provider.postMessageToWebview({
2544+
type: "insertTextIntoTextarea",
2545+
text: text,
2546+
})
2547+
}
2548+
break
2549+
}
23842550
}
23852551
}

src/i18n/locales/en/common.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
"share_task_not_found": "Task not found or access denied.",
7272
"mode_import_failed": "Failed to import mode: {{error}}",
7373
"delete_rules_folder_failed": "Failed to delete rules folder: {{rulesFolderPath}}. Error: {{error}}",
74+
"command_not_found": "Command '{{name}}' not found",
75+
"open_command_file": "Failed to open command file",
76+
"delete_command": "Failed to delete command",
7477
"claudeCode": {
7578
"processExited": "Claude Code process exited with code {{exitCode}}.",
7679
"errorOutput": "Error output: {{output}}",

src/shared/ExtensionMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { MarketplaceItem } from "@roo-code/types"
2323
export interface Command {
2424
name: string
2525
source: "global" | "project"
26+
filePath?: string
2627
}
2728

2829
// Type for marketplace installed metadata
@@ -116,6 +117,7 @@ export interface ExtensionMessage {
116117
| "showDeleteMessageDialog"
117118
| "showEditMessageDialog"
118119
| "commands"
120+
| "insertTextIntoTextarea"
119121
text?: string
120122
payload?: any // Add a generic payload for now, can refine later
121123
action?:

src/shared/WebviewMessage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ export interface WebviewMessage {
202202
| "saveCodeIndexSettingsAtomic"
203203
| "requestCodeIndexSecretStatus"
204204
| "requestCommands"
205+
| "openCommandFile"
206+
| "deleteCommand"
207+
| "createCommand"
208+
| "insertTextIntoTextarea"
205209
text?: string
206210
editedMessageContent?: string
207211
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
2828
import ContextMenu from "./ContextMenu"
2929
import { VolumeX, Image, WandSparkles, SendHorizontal } from "lucide-react"
3030
import { IndexingStatusBadge } from "./IndexingStatusBadge"
31+
import { SlashCommandsPopover } from "./SlashCommandsPopover"
3132
import { cn } from "@/lib/utils"
3233
import { usePromptHistory } from "./hooks/usePromptHistory"
3334
import { EditModeControls } from "./EditModeControls"
@@ -145,6 +146,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
145146
}
146147

147148
setIsEnhancingPrompt(false)
149+
} else if (message.type === "insertTextIntoTextarea") {
150+
if (message.text && textAreaRef.current) {
151+
// Insert the command text at the current cursor position
152+
const textarea = textAreaRef.current
153+
const currentValue = inputValue
154+
const cursorPos = textarea.selectionStart || 0
155+
156+
// Check if we need to add a space before the command
157+
const textBefore = currentValue.slice(0, cursorPos)
158+
const needsSpaceBefore = textBefore.length > 0 && !textBefore.endsWith(" ")
159+
const prefix = needsSpaceBefore ? " " : ""
160+
161+
// Insert the text at cursor position
162+
const newValue =
163+
currentValue.slice(0, cursorPos) +
164+
prefix +
165+
message.text +
166+
" " +
167+
currentValue.slice(cursorPos)
168+
setInputValue(newValue)
169+
170+
// Set cursor position after the inserted text
171+
const newCursorPos = cursorPos + prefix.length + message.text.length + 1
172+
setTimeout(() => {
173+
if (textAreaRef.current) {
174+
textAreaRef.current.focus()
175+
textAreaRef.current.setSelectionRange(newCursorPos, newCursorPos)
176+
}
177+
}, 0)
178+
}
148179
} else if (message.type === "commitSearchResults") {
149180
const commits = message.commits.map((commit: any) => ({
150181
type: ContextMenuOptionType.Git,
@@ -165,7 +196,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
165196

166197
window.addEventListener("message", messageHandler)
167198
return () => window.removeEventListener("message", messageHandler)
168-
}, [setInputValue, searchRequestId])
199+
}, [setInputValue, searchRequestId, inputValue])
169200

170201
const [isDraggingOver, setIsDraggingOver] = useState(false)
171202
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
@@ -897,6 +928,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
897928
</button>
898929
</StandardTooltip>
899930
)}
931+
<SlashCommandsPopover />
900932
<IndexingStatusBadge />
901933
<StandardTooltip content={t("chat:addImages")}>
902934
<button
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React from "react"
2+
import { Edit, Trash2 } from "lucide-react"
3+
4+
import type { Command } from "@roo/ExtensionMessage"
5+
6+
import { useAppTranslation } from "@/i18n/TranslationContext"
7+
import { Button, StandardTooltip } from "@/components/ui"
8+
import { vscode } from "@/utils/vscode"
9+
10+
interface SlashCommandItemProps {
11+
command: Command
12+
onDelete: (command: Command) => void
13+
onClick?: (command: Command) => void
14+
}
15+
16+
export const SlashCommandItem: React.FC<SlashCommandItemProps> = ({ command, onDelete, onClick }) => {
17+
const { t } = useAppTranslation()
18+
19+
const handleEdit = () => {
20+
if (command.filePath) {
21+
vscode.postMessage({
22+
type: "openFile",
23+
text: command.filePath,
24+
})
25+
} else {
26+
// Fallback: request to open command file by name and source
27+
vscode.postMessage({
28+
type: "openCommandFile",
29+
text: command.name,
30+
values: { source: command.source },
31+
})
32+
}
33+
}
34+
35+
const handleDelete = () => {
36+
onDelete(command)
37+
}
38+
39+
return (
40+
<div className="px-4 py-2 text-sm flex items-center group hover:bg-vscode-list-hoverBackground">
41+
{/* Command name - clickable */}
42+
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => onClick?.(command)}>
43+
<span className="truncate text-vscode-foreground">{command.name}</span>
44+
</div>
45+
46+
{/* Action buttons */}
47+
<div className="flex items-center gap-2 ml-2">
48+
<StandardTooltip content={t("chat:slashCommands.editCommand")}>
49+
<Button
50+
variant="ghost"
51+
size="icon"
52+
tabIndex={-1}
53+
onClick={handleEdit}
54+
className="size-6 flex items-center justify-center opacity-60 hover:opacity-100">
55+
<Edit className="w-4 h-4" />
56+
</Button>
57+
</StandardTooltip>
58+
59+
<StandardTooltip content={t("chat:slashCommands.deleteCommand")}>
60+
<Button
61+
variant="ghost"
62+
size="icon"
63+
tabIndex={-1}
64+
onClick={handleDelete}
65+
className="size-6 flex items-center justify-center opacity-60 hover:opacity-100 hover:text-red-400">
66+
<Trash2 className="w-4 h-4" />
67+
</Button>
68+
</StandardTooltip>
69+
</div>
70+
</div>
71+
)
72+
}

0 commit comments

Comments
 (0)