Просмотр исходного кода

Add a UI for managing slash commands (#6286)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Roo Code <[email protected]>
Matt Rubens 5 месяцев назад
Родитель
Сommit
785fcac625
47 измененных файлов с 1174 добавлено и 163 удалено
  1. 166 0
      src/core/webview/webviewMessageHandler.ts
  2. 7 0
      src/i18n/locales/ca/common.json
  3. 7 0
      src/i18n/locales/de/common.json
  4. 7 0
      src/i18n/locales/en/common.json
  5. 7 0
      src/i18n/locales/es/common.json
  6. 7 0
      src/i18n/locales/fr/common.json
  7. 7 0
      src/i18n/locales/hi/common.json
  8. 7 0
      src/i18n/locales/id/common.json
  9. 7 0
      src/i18n/locales/it/common.json
  10. 7 0
      src/i18n/locales/ja/common.json
  11. 7 0
      src/i18n/locales/ko/common.json
  12. 7 0
      src/i18n/locales/nl/common.json
  13. 7 0
      src/i18n/locales/pl/common.json
  14. 7 0
      src/i18n/locales/pt-BR/common.json
  15. 7 0
      src/i18n/locales/ru/common.json
  16. 7 0
      src/i18n/locales/tr/common.json
  17. 7 0
      src/i18n/locales/vi/common.json
  18. 7 0
      src/i18n/locales/zh-CN/common.json
  19. 7 0
      src/i18n/locales/zh-TW/common.json
  20. 2 0
      src/shared/ExtensionMessage.ts
  21. 4 0
      src/shared/WebviewMessage.ts
  22. 37 66
      webview-ui/src/__tests__/command-autocomplete.spec.ts
  23. 33 4
      webview-ui/src/components/chat/ChatTextArea.tsx
  24. 39 9
      webview-ui/src/components/chat/ContextMenu.tsx
  25. 72 0
      webview-ui/src/components/chat/SlashCommandItem.tsx
  26. 203 0
      webview-ui/src/components/chat/SlashCommandsList.tsx
  27. 82 0
      webview-ui/src/components/chat/SlashCommandsPopover.tsx
  28. 19 1
      webview-ui/src/i18n/locales/ca/chat.json
  29. 19 1
      webview-ui/src/i18n/locales/de/chat.json
  30. 19 1
      webview-ui/src/i18n/locales/en/chat.json
  31. 19 1
      webview-ui/src/i18n/locales/es/chat.json
  32. 19 1
      webview-ui/src/i18n/locales/fr/chat.json
  33. 19 1
      webview-ui/src/i18n/locales/hi/chat.json
  34. 19 1
      webview-ui/src/i18n/locales/id/chat.json
  35. 19 1
      webview-ui/src/i18n/locales/it/chat.json
  36. 19 1
      webview-ui/src/i18n/locales/ja/chat.json
  37. 19 1
      webview-ui/src/i18n/locales/ko/chat.json
  38. 19 1
      webview-ui/src/i18n/locales/nl/chat.json
  39. 19 1
      webview-ui/src/i18n/locales/pl/chat.json
  40. 19 1
      webview-ui/src/i18n/locales/pt-BR/chat.json
  41. 19 1
      webview-ui/src/i18n/locales/ru/chat.json
  42. 19 1
      webview-ui/src/i18n/locales/tr/chat.json
  43. 19 1
      webview-ui/src/i18n/locales/vi/chat.json
  44. 19 1
      webview-ui/src/i18n/locales/zh-CN/chat.json
  45. 19 1
      webview-ui/src/i18n/locales/zh-TW/chat.json
  46. 20 28
      webview-ui/src/utils/__tests__/context-mentions.spec.ts
  47. 48 38
      webview-ui/src/utils/context-mentions.ts

+ 166 - 0
src/core/webview/webviewMessageHandler.ts

@@ -2365,6 +2365,7 @@ export const webviewMessageHandler = async (
 				const commandList = commands.map((command) => ({
 					name: command.name,
 					source: command.source,
+					filePath: command.filePath,
 				}))
 
 				await provider.postMessageToWebview({
@@ -2381,5 +2382,170 @@ export const webviewMessageHandler = async (
 			}
 			break
 		}
+		case "openCommandFile": {
+			try {
+				if (message.text) {
+					const { getCommand } = await import("../../services/command/commands")
+					const command = await getCommand(provider.cwd || "", message.text)
+
+					if (command && command.filePath) {
+						openFile(command.filePath)
+					} else {
+						vscode.window.showErrorMessage(t("common:errors.command_not_found", { name: message.text }))
+					}
+				}
+			} catch (error) {
+				provider.log(
+					`Error opening command file: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
+				)
+				vscode.window.showErrorMessage(t("common:errors.open_command_file"))
+			}
+			break
+		}
+		case "deleteCommand": {
+			try {
+				if (message.text && message.values?.source) {
+					const { getCommand } = await import("../../services/command/commands")
+					const command = await getCommand(provider.cwd || "", message.text)
+
+					if (command && command.filePath) {
+						// Delete the command file
+						await fs.unlink(command.filePath)
+						provider.log(`Deleted command file: ${command.filePath}`)
+					} else {
+						vscode.window.showErrorMessage(t("common:errors.command_not_found", { name: message.text }))
+					}
+				}
+			} catch (error) {
+				provider.log(`Error deleting command: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
+				vscode.window.showErrorMessage(t("common:errors.delete_command"))
+			}
+			break
+		}
+		case "createCommand": {
+			try {
+				const source = message.values?.source as "global" | "project"
+				const fileName = message.text // Custom filename from user input
+
+				if (!source) {
+					provider.log("Missing source for createCommand")
+					break
+				}
+
+				// Determine the commands directory based on source
+				let commandsDir: string
+				if (source === "global") {
+					const globalConfigDir = path.join(os.homedir(), ".roo")
+					commandsDir = path.join(globalConfigDir, "commands")
+				} else {
+					// Project commands
+					const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
+					if (!workspaceRoot) {
+						vscode.window.showErrorMessage(t("common:errors.no_workspace_for_project_command"))
+						break
+					}
+					commandsDir = path.join(workspaceRoot, ".roo", "commands")
+				}
+
+				// Ensure the commands directory exists
+				await fs.mkdir(commandsDir, { recursive: true })
+
+				// Use provided filename or generate a unique one
+				let commandName: string
+				if (fileName && fileName.trim()) {
+					let cleanFileName = fileName.trim()
+
+					// Strip leading slash if present
+					if (cleanFileName.startsWith("/")) {
+						cleanFileName = cleanFileName.substring(1)
+					}
+
+					// Remove .md extension if present BEFORE slugification
+					if (cleanFileName.toLowerCase().endsWith(".md")) {
+						cleanFileName = cleanFileName.slice(0, -3)
+					}
+
+					// Slugify the command name: lowercase, replace spaces with dashes, remove special characters
+					commandName = cleanFileName
+						.toLowerCase()
+						.replace(/\s+/g, "-") // Replace spaces with dashes
+						.replace(/[^a-z0-9-]/g, "") // Remove special characters except dashes
+						.replace(/-+/g, "-") // Replace multiple dashes with single dash
+						.replace(/^-|-$/g, "") // Remove leading/trailing dashes
+
+					// Ensure we have a valid command name
+					if (!commandName || commandName.length === 0) {
+						commandName = "new-command"
+					}
+				} else {
+					// Generate a unique command name
+					commandName = "new-command"
+					let counter = 1
+					let filePath = path.join(commandsDir, `${commandName}.md`)
+
+					while (
+						await fs
+							.access(filePath)
+							.then(() => true)
+							.catch(() => false)
+					) {
+						commandName = `new-command-${counter}`
+						filePath = path.join(commandsDir, `${commandName}.md`)
+						counter++
+					}
+				}
+
+				const filePath = path.join(commandsDir, `${commandName}.md`)
+
+				// Check if file already exists
+				if (
+					await fs
+						.access(filePath)
+						.then(() => true)
+						.catch(() => false)
+				) {
+					vscode.window.showErrorMessage(t("common:errors.command_already_exists", { commandName }))
+					break
+				}
+
+				// Create the command file with template content
+				const templateContent = t("common:errors.command_template_content")
+
+				await fs.writeFile(filePath, templateContent, "utf8")
+				provider.log(`Created new command file: ${filePath}`)
+
+				// Open the new file in the editor
+				openFile(filePath)
+
+				// Refresh commands list
+				const { getCommands } = await import("../../services/command/commands")
+				const commands = await getCommands(provider.cwd || "")
+				const commandList = commands.map((command) => ({
+					name: command.name,
+					source: command.source,
+					filePath: command.filePath,
+				}))
+				await provider.postMessageToWebview({
+					type: "commands",
+					commands: commandList,
+				})
+			} catch (error) {
+				provider.log(`Error creating command: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
+				vscode.window.showErrorMessage(t("common:errors.create_command_failed"))
+			}
+			break
+		}
+
+		case "insertTextIntoTextarea": {
+			const text = message.text
+			if (text) {
+				// Send message to insert text into the chat textarea
+				await provider.postMessageToWebview({
+					type: "insertTextIntoTextarea",
+					text: text,
+				})
+			}
+			break
+		}
 	}
 }

+ 7 - 0
src/i18n/locales/ca/common.json

@@ -74,6 +74,13 @@
 		"share_not_enabled": "La compartició de tasques no està habilitada per a aquesta organització.",
 		"share_task_not_found": "Tasca no trobada o accés denegat.",
 		"delete_rules_folder_failed": "Error en eliminar la carpeta de regles: {{rulesFolderPath}}. Error: {{error}}",
+		"command_not_found": "Ordre '{{name}}' no trobada",
+		"open_command_file": "Error en obrir el fitxer d'ordres",
+		"delete_command": "Error en eliminar l'ordre",
+		"no_workspace_for_project_command": "No s'ha trobat cap carpeta d'espai de treball per a l'ordre del projecte",
+		"command_already_exists": "L'ordre \"{{commandName}}\" ja existeix",
+		"create_command_failed": "Error en crear l'ordre",
+		"command_template_content": "Aquesta és una nova ordre slash. Edita aquest fitxer per personalitzar el comportament de l'ordre.",
 		"claudeCode": {
 			"processExited": "El procés Claude Code ha sortit amb codi {{exitCode}}.",
 			"errorOutput": "Sortida d'error: {{output}}",

+ 7 - 0
src/i18n/locales/de/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Aufgabe nicht gefunden oder Zugriff verweigert.",
 		"mode_import_failed": "Fehler beim Importieren des Modus: {{error}}",
 		"delete_rules_folder_failed": "Fehler beim Löschen des Regelordners: {{rulesFolderPath}}. Fehler: {{error}}",
+		"command_not_found": "Befehl '{{name}}' nicht gefunden",
+		"open_command_file": "Fehler beim Öffnen der Befehlsdatei",
+		"delete_command": "Fehler beim Löschen des Befehls",
+		"no_workspace_for_project_command": "Kein Arbeitsbereich-Ordner für Projektbefehl gefunden",
+		"command_already_exists": "Befehl \"{{commandName}}\" existiert bereits",
+		"create_command_failed": "Fehler beim Erstellen des Befehls",
+		"command_template_content": "Dies ist ein neuer Slash-Befehl. Bearbeite diese Datei, um das Befehlsverhalten anzupassen.",
 		"claudeCode": {
 			"processExited": "Claude Code Prozess wurde mit Code {{exitCode}} beendet.",
 			"errorOutput": "Fehlerausgabe: {{output}}",

+ 7 - 0
src/i18n/locales/en/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Task not found or access denied.",
 		"mode_import_failed": "Failed to import mode: {{error}}",
 		"delete_rules_folder_failed": "Failed to delete rules folder: {{rulesFolderPath}}. Error: {{error}}",
+		"command_not_found": "Command '{{name}}' not found",
+		"open_command_file": "Failed to open command file",
+		"delete_command": "Failed to delete command",
+		"no_workspace_for_project_command": "No workspace folder found for project command",
+		"command_already_exists": "Command \"{{commandName}}\" already exists",
+		"create_command_failed": "Failed to create command",
+		"command_template_content": "This is a new slash command. Edit this file to customize the command behavior.",
 		"claudeCode": {
 			"processExited": "Claude Code process exited with code {{exitCode}}.",
 			"errorOutput": "Error output: {{output}}",

+ 7 - 0
src/i18n/locales/es/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Tarea no encontrada o acceso denegado.",
 		"mode_import_failed": "Error al importar el modo: {{error}}",
 		"delete_rules_folder_failed": "Error al eliminar la carpeta de reglas: {{rulesFolderPath}}. Error: {{error}}",
+		"command_not_found": "Comando '{{name}}' no encontrado",
+		"open_command_file": "Error al abrir el archivo de comandos",
+		"delete_command": "Error al eliminar el comando",
+		"no_workspace_for_project_command": "No se encontró carpeta de espacio de trabajo para comando de proyecto",
+		"command_already_exists": "El comando \"{{commandName}}\" ya existe",
+		"create_command_failed": "Error al crear comando",
+		"command_template_content": "Este es un nuevo comando slash. Edita este archivo para personalizar el comportamiento del comando.",
 		"claudeCode": {
 			"processExited": "El proceso de Claude Code terminó con código {{exitCode}}.",
 			"errorOutput": "Salida de error: {{output}}",

+ 7 - 0
src/i18n/locales/fr/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Tâche non trouvée ou accès refusé.",
 		"mode_import_failed": "Échec de l'importation du mode : {{error}}",
 		"delete_rules_folder_failed": "Échec de la suppression du dossier de règles : {{rulesFolderPath}}. Erreur : {{error}}",
+		"command_not_found": "Commande '{{name}}' introuvable",
+		"open_command_file": "Échec de l'ouverture du fichier de commande",
+		"delete_command": "Échec de la suppression de la commande",
+		"no_workspace_for_project_command": "Aucun dossier d'espace de travail trouvé pour la commande de projet",
+		"command_already_exists": "La commande \"{{commandName}}\" existe déjà",
+		"create_command_failed": "Échec de la création de la commande",
+		"command_template_content": "Ceci est une nouvelle commande slash. Modifie ce fichier pour personnaliser le comportement de la commande.",
 		"claudeCode": {
 			"processExited": "Le processus Claude Code s'est terminé avec le code {{exitCode}}.",
 			"errorOutput": "Sortie d'erreur : {{output}}",

+ 7 - 0
src/i18n/locales/hi/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "कार्य नहीं मिला या पहुंच अस्वीकृत।",
 		"mode_import_failed": "मोड आयात करने में विफल: {{error}}",
 		"delete_rules_folder_failed": "नियम फ़ोल्डर हटाने में विफल: {{rulesFolderPath}}। त्रुटि: {{error}}",
+		"command_not_found": "कमांड '{{name}}' नहीं मिला",
+		"open_command_file": "कमांड फ़ाइल खोलने में विफल",
+		"delete_command": "कमांड हटाने में विफल",
+		"no_workspace_for_project_command": "प्रोजेक्ट कमांड के लिए वर्कस्पेस फ़ोल्डर नहीं मिला",
+		"command_already_exists": "कमांड \"{{commandName}}\" पहले से मौजूद है",
+		"create_command_failed": "कमांड बनाने में विफल",
+		"command_template_content": "यह एक नया स्लैश कमांड है। कमांड व्यवहार को कस्टमाइज़ करने के लिए इस फ़ाइल को संपादित करें।",
 		"claudeCode": {
 			"processExited": "Claude Code प्रक्रिया कोड {{exitCode}} के साथ समाप्त हुई।",
 			"errorOutput": "त्रुटि आउटपुट: {{output}}",

+ 7 - 0
src/i18n/locales/id/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Tugas tidak ditemukan atau akses ditolak.",
 		"mode_import_failed": "Gagal mengimpor mode: {{error}}",
 		"delete_rules_folder_failed": "Gagal menghapus folder aturan: {{rulesFolderPath}}. Error: {{error}}",
+		"command_not_found": "Perintah '{{name}}' tidak ditemukan",
+		"open_command_file": "Gagal membuka file perintah",
+		"delete_command": "Gagal menghapus perintah",
+		"no_workspace_for_project_command": "Tidak ditemukan folder workspace untuk perintah proyek",
+		"command_already_exists": "Perintah \"{{commandName}}\" sudah ada",
+		"create_command_failed": "Gagal membuat perintah",
+		"command_template_content": "Ini adalah perintah slash baru. Edit file ini untuk menyesuaikan perilaku perintah.",
 		"claudeCode": {
 			"processExited": "Proses Claude Code keluar dengan kode {{exitCode}}.",
 			"errorOutput": "Output error: {{output}}",

+ 7 - 0
src/i18n/locales/it/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Attività non trovata o accesso negato.",
 		"mode_import_failed": "Importazione della modalità non riuscita: {{error}}",
 		"delete_rules_folder_failed": "Impossibile eliminare la cartella delle regole: {{rulesFolderPath}}. Errore: {{error}}",
+		"command_not_found": "Comando '{{name}}' non trovato",
+		"open_command_file": "Impossibile aprire il file di comando",
+		"delete_command": "Impossibile eliminare il comando",
+		"no_workspace_for_project_command": "Nessuna cartella workspace trovata per il comando di progetto",
+		"command_already_exists": "Il comando \"{{commandName}}\" esiste già",
+		"create_command_failed": "Errore nella creazione del comando",
+		"command_template_content": "Questo è un nuovo comando slash. Modifica questo file per personalizzare il comportamento del comando.",
 		"claudeCode": {
 			"processExited": "Il processo Claude Code è terminato con codice {{exitCode}}.",
 			"errorOutput": "Output di errore: {{output}}",

+ 7 - 0
src/i18n/locales/ja/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "タスクが見つからないか、アクセスが拒否されました。",
 		"mode_import_failed": "モードのインポートに失敗しました:{{error}}",
 		"delete_rules_folder_failed": "ルールフォルダの削除に失敗しました:{{rulesFolderPath}}。エラー:{{error}}",
+		"command_not_found": "コマンド '{{name}}' が見つかりません",
+		"open_command_file": "コマンドファイルを開けませんでした",
+		"delete_command": "コマンドの削除に失敗しました",
+		"no_workspace_for_project_command": "プロジェクトコマンド用のワークスペースフォルダが見つかりません",
+		"command_already_exists": "コマンド \"{{commandName}}\" は既に存在します",
+		"create_command_failed": "コマンドの作成に失敗しました",
+		"command_template_content": "これは新しいスラッシュコマンドです。このファイルを編集してコマンドの動作をカスタマイズしてください。",
 		"claudeCode": {
 			"processExited": "Claude Code プロセスがコード {{exitCode}} で終了しました。",
 			"errorOutput": "エラー出力:{{output}}",

+ 7 - 0
src/i18n/locales/ko/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "작업을 찾을 수 없거나 액세스가 거부되었습니다.",
 		"mode_import_failed": "모드 가져오기 실패: {{error}}",
 		"delete_rules_folder_failed": "규칙 폴더 삭제 실패: {{rulesFolderPath}}. 오류: {{error}}",
+		"command_not_found": "'{{name}}' 명령을 찾을 수 없습니다",
+		"open_command_file": "명령 파일을 열 수 없습니다",
+		"delete_command": "명령 삭제 실패",
+		"no_workspace_for_project_command": "프로젝트 명령용 워크스페이스 폴더를 찾을 수 없습니다",
+		"command_already_exists": "명령 \"{{commandName}}\"이(가) 이미 존재합니다",
+		"create_command_failed": "명령 생성에 실패했습니다",
+		"command_template_content": "이것은 새로운 슬래시 명령입니다. 이 파일을 편집하여 명령 동작을 사용자 정의하세요.",
 		"claudeCode": {
 			"processExited": "Claude Code 프로세스가 코드 {{exitCode}}로 종료되었습니다.",
 			"errorOutput": "오류 출력: {{output}}",

+ 7 - 0
src/i18n/locales/nl/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Taak niet gevonden of toegang geweigerd.",
 		"mode_import_failed": "Importeren van modus mislukt: {{error}}",
 		"delete_rules_folder_failed": "Kan regelmap niet verwijderen: {{rulesFolderPath}}. Fout: {{error}}",
+		"command_not_found": "Opdracht '{{name}}' niet gevonden",
+		"open_command_file": "Kan opdrachtbestand niet openen",
+		"delete_command": "Kan opdracht niet verwijderen",
+		"no_workspace_for_project_command": "Geen werkruimtemap gevonden voor projectopdracht",
+		"command_already_exists": "Opdracht \"{{commandName}}\" bestaat al",
+		"create_command_failed": "Kan opdracht niet aanmaken",
+		"command_template_content": "Dit is een nieuwe slash-opdracht. Bewerk dit bestand om het opdrachtgedrag aan te passen.",
 		"claudeCode": {
 			"processExited": "Claude Code proces beëindigd met code {{exitCode}}.",
 			"errorOutput": "Foutuitvoer: {{output}}",

+ 7 - 0
src/i18n/locales/pl/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Zadanie nie znalezione lub dostęp odmówiony.",
 		"mode_import_failed": "Import trybu nie powiódł się: {{error}}",
 		"delete_rules_folder_failed": "Nie udało się usunąć folderu reguł: {{rulesFolderPath}}. Błąd: {{error}}",
+		"command_not_found": "Polecenie '{{name}}' nie zostało znalezione",
+		"open_command_file": "Nie udało się otworzyć pliku polecenia",
+		"delete_command": "Nie udało się usunąć polecenia",
+		"no_workspace_for_project_command": "Nie znaleziono folderu obszaru roboczego dla polecenia projektu",
+		"command_already_exists": "Polecenie \"{{commandName}}\" już istnieje",
+		"create_command_failed": "Nie udało się utworzyć polecenia",
+		"command_template_content": "To jest nowe polecenie slash. Edytuj ten plik, aby dostosować zachowanie polecenia.",
 		"claudeCode": {
 			"processExited": "Proces Claude Code zakończył się kodem {{exitCode}}.",
 			"errorOutput": "Wyjście błędu: {{output}}",

+ 7 - 0
src/i18n/locales/pt-BR/common.json

@@ -75,6 +75,13 @@
 		"share_task_not_found": "Tarefa não encontrada ou acesso negado.",
 		"mode_import_failed": "Falha ao importar o modo: {{error}}",
 		"delete_rules_folder_failed": "Falha ao excluir pasta de regras: {{rulesFolderPath}}. Erro: {{error}}",
+		"command_not_found": "Comando '{{name}}' não encontrado",
+		"open_command_file": "Falha ao abrir arquivo de comando",
+		"delete_command": "Falha ao excluir comando",
+		"no_workspace_for_project_command": "Nenhuma pasta de workspace encontrada para comando de projeto",
+		"command_already_exists": "Comando \"{{commandName}}\" já existe",
+		"create_command_failed": "Falha ao criar comando",
+		"command_template_content": "Este é um novo comando slash. Edite este arquivo para personalizar o comportamento do comando.",
 		"claudeCode": {
 			"processExited": "O processo Claude Code saiu com código {{exitCode}}.",
 			"errorOutput": "Saída de erro: {{output}}",

+ 7 - 0
src/i18n/locales/ru/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Задача не найдена или доступ запрещен.",
 		"mode_import_failed": "Не удалось импортировать режим: {{error}}",
 		"delete_rules_folder_failed": "Не удалось удалить папку правил: {{rulesFolderPath}}. Ошибка: {{error}}",
+		"command_not_found": "Команда '{{name}}' не найдена",
+		"open_command_file": "Не удалось открыть файл команды",
+		"delete_command": "Не удалось удалить команду",
+		"no_workspace_for_project_command": "Не найдена папка рабочего пространства для команды проекта",
+		"command_already_exists": "Команда \"{{commandName}}\" уже существует",
+		"create_command_failed": "Не удалось создать команду",
+		"command_template_content": "Это новая slash-команда. Отредактируйте этот файл, чтобы настроить поведение команды.",
 		"claudeCode": {
 			"processExited": "Процесс Claude Code завершился с кодом {{exitCode}}.",
 			"errorOutput": "Вывод ошибки: {{output}}",

+ 7 - 0
src/i18n/locales/tr/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Görev bulunamadı veya erişim reddedildi.",
 		"mode_import_failed": "Mod içe aktarılamadı: {{error}}",
 		"delete_rules_folder_failed": "Kurallar klasörü silinemedi: {{rulesFolderPath}}. Hata: {{error}}",
+		"command_not_found": "'{{name}}' komutu bulunamadı",
+		"open_command_file": "Komut dosyası açılamadı",
+		"delete_command": "Komut silinemedi",
+		"no_workspace_for_project_command": "Proje komutu için çalışma alanı klasörü bulunamadı",
+		"command_already_exists": "\"{{commandName}}\" komutu zaten mevcut",
+		"create_command_failed": "Komut oluşturulamadı",
+		"command_template_content": "Bu yeni bir slash komutudur. Komut davranışını özelleştirmek için bu dosyayı düzenleyin.",
 		"claudeCode": {
 			"processExited": "Claude Code işlemi {{exitCode}} koduyla çıktı.",
 			"errorOutput": "Hata çıktısı: {{output}}",

+ 7 - 0
src/i18n/locales/vi/common.json

@@ -71,6 +71,13 @@
 		"share_task_not_found": "Không tìm thấy nhiệm vụ hoặc truy cập bị từ chối.",
 		"mode_import_failed": "Nhập chế độ thất bại: {{error}}",
 		"delete_rules_folder_failed": "Không thể xóa thư mục quy tắc: {{rulesFolderPath}}. Lỗi: {{error}}",
+		"command_not_found": "Không tìm thấy lệnh '{{name}}'",
+		"open_command_file": "Không thể mở tệp lệnh",
+		"delete_command": "Không thể xóa lệnh",
+		"no_workspace_for_project_command": "Không tìm thấy thư mục workspace cho lệnh dự án",
+		"command_already_exists": "Lệnh \"{{commandName}}\" đã tồn tại",
+		"create_command_failed": "Không thể tạo lệnh",
+		"command_template_content": "Đây là một lệnh slash mới. Chỉnh sửa tệp này để tùy chỉnh hành vi của lệnh.",
 		"claudeCode": {
 			"processExited": "Tiến trình Claude Code thoát với mã {{exitCode}}.",
 			"errorOutput": "Đầu ra lỗi: {{output}}",

+ 7 - 0
src/i18n/locales/zh-CN/common.json

@@ -76,6 +76,13 @@
 		"share_task_not_found": "未找到任务或访问被拒绝。",
 		"mode_import_failed": "导入模式失败:{{error}}",
 		"delete_rules_folder_failed": "删除规则文件夹失败:{{rulesFolderPath}}。错误:{{error}}",
+		"command_not_found": "未找到命令 '{{name}}'",
+		"open_command_file": "打开命令文件失败",
+		"delete_command": "删除命令失败",
+		"no_workspace_for_project_command": "未找到项目命令的工作区文件夹",
+		"command_already_exists": "命令 \"{{commandName}}\" 已存在",
+		"create_command_failed": "创建命令失败",
+		"command_template_content": "这是一个新的斜杠命令。编辑此文件以自定义命令行为。",
 		"claudeCode": {
 			"processExited": "Claude Code 进程退出,退出码:{{exitCode}}。",
 			"errorOutput": "错误输出:{{output}}",

+ 7 - 0
src/i18n/locales/zh-TW/common.json

@@ -70,6 +70,13 @@
 		"share_not_enabled": "此組織未啟用工作分享功能。",
 		"share_task_not_found": "未找到工作或存取被拒絕。",
 		"delete_rules_folder_failed": "刪除規則資料夾失敗: {{rulesFolderPath}}。錯誤: {{error}}",
+		"command_not_found": "找不到指令 '{{name}}'",
+		"open_command_file": "開啟指令檔案失敗",
+		"delete_command": "刪除指令失敗",
+		"no_workspace_for_project_command": "找不到專案指令的工作區資料夾",
+		"command_already_exists": "指令 \"{{commandName}}\" 已存在",
+		"create_command_failed": "建立指令失敗",
+		"command_template_content": "這是一個新的斜線指令。編輯此檔案以自訂指令行為。",
 		"claudeCode": {
 			"processExited": "Claude Code 程序退出,退出碼:{{exitCode}}。",
 			"errorOutput": "錯誤輸出:{{output}}",

+ 2 - 0
src/shared/ExtensionMessage.ts

@@ -23,6 +23,7 @@ import type { MarketplaceItem } from "@roo-code/types"
 export interface Command {
 	name: string
 	source: "global" | "project"
+	filePath?: string
 }
 
 // Type for marketplace installed metadata
@@ -116,6 +117,7 @@ export interface ExtensionMessage {
 		| "showDeleteMessageDialog"
 		| "showEditMessageDialog"
 		| "commands"
+		| "insertTextIntoTextarea"
 	text?: string
 	payload?: any // Add a generic payload for now, can refine later
 	action?:

+ 4 - 0
src/shared/WebviewMessage.ts

@@ -202,6 +202,10 @@ export interface WebviewMessage {
 		| "saveCodeIndexSettingsAtomic"
 		| "requestCodeIndexSecretStatus"
 		| "requestCommands"
+		| "openCommandFile"
+		| "deleteCommand"
+		| "createCommand"
+		| "insertTextIntoTextarea"
 	text?: string
 	editedMessageContent?: string
 	tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"

+ 37 - 66
webview-ui/src/__tests__/command-autocomplete.spec.ts

@@ -16,22 +16,18 @@ describe("Command Autocomplete", () => {
 		{ type: ContextMenuOptionType.Problems, value: "problems" },
 	]
 
-	// Mock translation function
-	const mockT = (key: string, options?: { name?: string }) => {
-		if (key === "chat:command.triggerDescription") {
-			return `Trigger the ${options?.name || "command"} command`
-		}
-		return key
-	}
-
 	describe("slash command command suggestions", () => {
 		it('should return all commands when query is just "/"', () => {
-			const options = getContextMenuOptions("/", "/", mockT, null, mockQueryItems, [], [], mockCommands)
+			const options = getContextMenuOptions("/", "/", null, mockQueryItems, [], [], mockCommands)
 
-			expect(options).toHaveLength(5)
-			expect(options.every((option) => option.type === ContextMenuOptionType.Command)).toBe(true)
+			// Should have 6 items: 1 section header + 5 commands
+			expect(options).toHaveLength(6)
 
-			const commandNames = options.map((option) => option.value)
+			// Filter out section headers to check commands
+			const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
+			expect(commandOptions).toHaveLength(5)
+
+			const commandNames = commandOptions.map((option) => option.value)
 			expect(commandNames).toContain("setup")
 			expect(commandNames).toContain("build")
 			expect(commandNames).toContain("deploy")
@@ -40,7 +36,7 @@ describe("Command Autocomplete", () => {
 		})
 
 		it("should filter commands based on fuzzy search", () => {
-			const options = getContextMenuOptions("/set", "/set", mockT, null, mockQueryItems, [], [], mockCommands)
+			const options = getContextMenuOptions("/set", "/set", null, mockQueryItems, [], [], mockCommands)
 
 			// Should match 'setup' (fuzzy search behavior may vary)
 			expect(options.length).toBeGreaterThan(0)
@@ -50,18 +46,17 @@ describe("Command Autocomplete", () => {
 		})
 
 		it("should return commands with correct format", () => {
-			const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], mockCommands)
+			const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], mockCommands)
 
 			const setupOption = options.find((option) => option.value === "setup")
 			expect(setupOption).toBeDefined()
 			expect(setupOption!.type).toBe(ContextMenuOptionType.Command)
-			expect(setupOption!.label).toBe("setup")
-			expect(setupOption!.description).toBe("Trigger the setup command")
-			expect(setupOption!.icon).toBe("$(play)")
+			expect(setupOption!.slashCommand).toBe("/setup")
+			expect(setupOption!.value).toBe("setup")
 		})
 
 		it("should handle empty command list", () => {
-			const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], [])
+			const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], [])
 
 			// Should return NoResults when no commands match
 			expect(options).toHaveLength(1)
@@ -72,7 +67,6 @@ describe("Command Autocomplete", () => {
 			const options = getContextMenuOptions(
 				"/nonexistent",
 				"/nonexistent",
-				mockT,
 				null,
 				mockQueryItems,
 				[],
@@ -86,7 +80,7 @@ describe("Command Autocomplete", () => {
 		})
 
 		it("should not return command suggestions for non-slash queries", () => {
-			const options = getContextMenuOptions("setup", "setup", mockT, null, mockQueryItems, [], [], mockCommands)
+			const options = getContextMenuOptions("setup", "setup", null, mockQueryItems, [], [], mockCommands)
 
 			// Should not contain command options for non-slash queries
 			const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
@@ -100,24 +94,15 @@ describe("Command Autocomplete", () => {
 				{ name: "deploy.prod", source: "global" },
 			]
 
-			const options = getContextMenuOptions(
-				"/setup",
-				"/setup",
-				mockT,
-				null,
-				mockQueryItems,
-				[],
-				[],
-				specialCommands,
-			)
+			const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], specialCommands)
 
 			const setupDevOption = options.find((option) => option.value === "setup-dev")
 			expect(setupDevOption).toBeDefined()
-			expect(setupDevOption!.label).toBe("setup-dev")
+			expect(setupDevOption!.slashCommand).toBe("/setup-dev")
 		})
 
 		it("should handle case-insensitive fuzzy matching", () => {
-			const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], mockCommands)
+			const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], mockCommands)
 
 			const commandNames = options.map((option) => option.value)
 			expect(commandNames).toContain("setup")
@@ -133,7 +118,6 @@ describe("Command Autocomplete", () => {
 			const options = getContextMenuOptions(
 				"/test",
 				"/test",
-				mockT,
 				null,
 				mockQueryItems,
 				[],
@@ -141,12 +125,13 @@ describe("Command Autocomplete", () => {
 				commandsWithSimilarNames,
 			)
 
-			// 'test' should be first due to exact match
-			expect(options[0].value).toBe("test")
+			// 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", mockT, null, mockQueryItems, [], [], mockCommands)
+			const options = getContextMenuOptions("/te", "/te", null, mockQueryItems, [], [], mockCommands)
 
 			// Should match 'test-suite'
 			const commandNames = options.map((option) => option.value)
@@ -173,7 +158,7 @@ describe("Command Autocomplete", () => {
 		] as any[]
 
 		it("should return both modes and commands for slash commands", () => {
-			const options = getContextMenuOptions("/", "/", mockT, 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)
@@ -183,16 +168,7 @@ describe("Command Autocomplete", () => {
 		})
 
 		it("should filter both modes and commands based on query", () => {
-			const options = getContextMenuOptions(
-				"/co",
-				"/co",
-				mockT,
-				null,
-				mockQueryItems,
-				[],
-				mockModes,
-				mockCommands,
-			)
+			const options = getContextMenuOptions("/co", "/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)
@@ -207,28 +183,30 @@ describe("Command Autocomplete", () => {
 
 	describe("command source indication", () => {
 		it("should not expose source information in autocomplete", () => {
-			const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], mockCommands)
+			const options = getContextMenuOptions("/setup", "/setup", null, mockQueryItems, [], [], mockCommands)
 
 			const setupOption = options.find((option) => option.value === "setup")
 			expect(setupOption).toBeDefined()
 
 			// Source should not be exposed in the UI
-			expect(setupOption!.description).not.toContain("project")
-			expect(setupOption!.description).not.toContain("global")
-			expect(setupOption!.description).toBe("Trigger the setup command")
+			if (setupOption!.description) {
+				expect(setupOption!.description).not.toContain("project")
+				expect(setupOption!.description).not.toContain("global")
+				expect(setupOption!.description).toBe("Trigger the setup command")
+			}
 		})
 	})
 
 	describe("edge cases", () => {
 		it("should handle undefined commands gracefully", () => {
-			const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], undefined)
+			const options = getContextMenuOptions("/setup", "/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("", "", mockT, 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)
@@ -240,19 +218,12 @@ describe("Command Autocomplete", () => {
 				{ name: "very-long-command-name-that-exceeds-normal-length", source: "project" },
 			]
 
-			const options = getContextMenuOptions(
-				"/very",
-				"/very",
-				mockT,
-				null,
-				mockQueryItems,
-				[],
-				[],
-				longNameCommands,
-			)
+			const options = getContextMenuOptions("/very", "/very", null, mockQueryItems, [], [], longNameCommands)
 
-			expect(options.length).toBe(1)
-			expect(options[0].value).toBe("very-long-command-name-that-exceeds-normal-length")
+			// Should have 2 items: 1 section header + 1 command
+			expect(options.length).toBe(2)
+			const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
+			expect(commandOptions[0].value).toBe("very-long-command-name-that-exceeds-normal-length")
 		})
 
 		it("should handle commands with numeric names", () => {
@@ -262,7 +233,7 @@ describe("Command Autocomplete", () => {
 				{ name: "123test", source: "project" },
 			]
 
-			const options = getContextMenuOptions("/v", "/v", mockT, null, mockQueryItems, [], [], numericCommands)
+			const options = getContextMenuOptions("/v", "/v", null, mockQueryItems, [], [], numericCommands)
 
 			const commandNames = options.map((option) => option.value)
 			expect(commandNames).toContain("v2-setup")

+ 33 - 4
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -28,6 +28,7 @@ import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
 import ContextMenu from "./ContextMenu"
 import { VolumeX, Image, WandSparkles, SendHorizontal } from "lucide-react"
 import { IndexingStatusBadge } from "./IndexingStatusBadge"
+import { SlashCommandsPopover } from "./SlashCommandsPopover"
 import { cn } from "@/lib/utils"
 import { usePromptHistory } from "./hooks/usePromptHistory"
 import { EditModeControls } from "./EditModeControls"
@@ -145,6 +146,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 					}
 
 					setIsEnhancingPrompt(false)
+				} else if (message.type === "insertTextIntoTextarea") {
+					if (message.text && textAreaRef.current) {
+						// Insert the command text at the current cursor position
+						const textarea = textAreaRef.current
+						const currentValue = inputValue
+						const cursorPos = textarea.selectionStart || 0
+
+						// Check if we need to add a space before the command
+						const textBefore = currentValue.slice(0, cursorPos)
+						const needsSpaceBefore = textBefore.length > 0 && !textBefore.endsWith(" ")
+						const prefix = needsSpaceBefore ? " " : ""
+
+						// Insert the text at cursor position
+						const newValue =
+							currentValue.slice(0, cursorPos) +
+							prefix +
+							message.text +
+							" " +
+							currentValue.slice(cursorPos)
+						setInputValue(newValue)
+
+						// Set cursor position after the inserted text
+						const newCursorPos = cursorPos + prefix.length + message.text.length + 1
+						setTimeout(() => {
+							if (textAreaRef.current) {
+								textAreaRef.current.focus()
+								textAreaRef.current.setSelectionRange(newCursorPos, newCursorPos)
+							}
+						}, 0)
+					}
 				} else if (message.type === "commitSearchResults") {
 					const commits = message.commits.map((commit: any) => ({
 						type: ContextMenuOptionType.Git,
@@ -165,7 +196,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 
 			window.addEventListener("message", messageHandler)
 			return () => window.removeEventListener("message", messageHandler)
-		}, [setInputValue, searchRequestId])
+		}, [setInputValue, searchRequestId, inputValue])
 
 		const [isDraggingOver, setIsDraggingOver] = useState(false)
 		const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
@@ -365,7 +396,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 							const options = getContextMenuOptions(
 								searchQuery,
 								inputValue,
-								t,
 								selectedType,
 								queryItems,
 								fileSearchResults,
@@ -404,7 +434,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 						const selectedOption = getContextMenuOptions(
 							searchQuery,
 							inputValue,
-							t,
 							selectedType,
 							queryItems,
 							fileSearchResults,
@@ -498,7 +527,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 				handleHistoryNavigation,
 				resetHistoryNavigation,
 				commands,
-				t,
 			],
 		)
 
@@ -897,6 +925,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 							</button>
 						</StandardTooltip>
 					)}
+					<SlashCommandsPopover />
 					<IndexingStatusBadge />
 					<StandardTooltip content={t("chat:addImages")}>
 						<button

+ 39 - 9
webview-ui/src/components/chat/ContextMenu.tsx

@@ -1,6 +1,5 @@
 import React, { useEffect, useMemo, useRef, useState } from "react"
 import { getIconForFilePath, getIconUrlByName, getIconForDirectoryPath } from "vscode-material-icons"
-import { useTranslation } from "react-i18next"
 
 import type { ModeConfig } from "@roo-code/types"
 import type { Command } from "@roo/ExtensionMessage"
@@ -43,20 +42,18 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 }) => {
 	const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("")
 	const menuRef = useRef<HTMLDivElement>(null)
-	const { t } = useTranslation()
 
 	const filteredOptions = useMemo(() => {
 		return getContextMenuOptions(
 			searchQuery,
 			inputValue,
-			t,
 			selectedType,
 			queryItems,
 			dynamicSearchResults,
 			modes,
 			commands,
 		)
-	}, [searchQuery, inputValue, t, selectedType, queryItems, dynamicSearchResults, modes, commands])
+	}, [searchQuery, inputValue, selectedType, queryItems, dynamicSearchResults, modes, commands])
 
 	useEffect(() => {
 		if (menuRef.current) {
@@ -82,10 +79,25 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 
 	const renderOptionContent = (option: ContextMenuQueryItem) => {
 		switch (option.type) {
+			case ContextMenuOptionType.SectionHeader:
+				return (
+					<span
+						style={{
+							fontWeight: "bold",
+							fontSize: "0.85em",
+							opacity: 0.8,
+							textTransform: "uppercase",
+							letterSpacing: "0.5px",
+						}}>
+						{option.label}
+					</span>
+				)
 			case ContextMenuOptionType.Mode:
 				return (
 					<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
-						<span style={{ lineHeight: "1.2" }}>{option.label}</span>
+						<div style={{ lineHeight: "1.2" }}>
+							<span>{option.slashCommand}</span>
+						</div>
 						{option.description && (
 							<span
 								style={{
@@ -104,7 +116,9 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 			case ContextMenuOptionType.Command:
 				return (
 					<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
-						<span style={{ lineHeight: "1.2" }}>{option.label}</span>
+						<div style={{ lineHeight: "1.2" }}>
+							<span>{option.slashCommand}</span>
+						</div>
 						{option.description && (
 							<span
 								style={{
@@ -229,7 +243,11 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 	}
 
 	const isOptionSelectable = (option: ContextMenuQueryItem): boolean => {
-		return option.type !== ContextMenuOptionType.NoResults && option.type !== ContextMenuOptionType.URL
+		return (
+			option.type !== ContextMenuOptionType.NoResults &&
+			option.type !== ContextMenuOptionType.URL &&
+			option.type !== ContextMenuOptionType.SectionHeader
+		)
 	}
 
 	return (
@@ -252,8 +270,9 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 					zIndex: 1000,
 					display: "flex",
 					flexDirection: "column",
-					maxHeight: "200px",
+					maxHeight: "300px",
 					overflowY: "auto",
+					overflowX: "hidden",
 				}}>
 				{filteredOptions && filteredOptions.length > 0 ? (
 					filteredOptions.map((option, index) => (
@@ -261,12 +280,20 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 							key={`${option.type}-${option.value || index}`}
 							onClick={() => isOptionSelectable(option) && onSelect(option.type, option.value)}
 							style={{
-								padding: "4px 6px",
+								padding:
+									option.type === ContextMenuOptionType.SectionHeader ? "8px 6px 4px 6px" : "4px 6px",
 								cursor: isOptionSelectable(option) ? "pointer" : "default",
 								color: "var(--vscode-dropdown-foreground)",
 								display: "flex",
 								alignItems: "center",
 								justifyContent: "space-between",
+								position: "relative",
+								...(option.type === ContextMenuOptionType.SectionHeader
+									? {
+											borderBottom: "1px solid var(--vscode-editorGroup-border)",
+											marginBottom: "2px",
+										}
+									: {}),
 								...(index === selectedIndex && isOptionSelectable(option)
 									? {
 											backgroundColor: "var(--vscode-list-activeSelectionBackground)",
@@ -283,6 +310,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 									minWidth: 0,
 									overflow: "hidden",
 									paddingTop: 0,
+									position: "relative",
 								}}>
 								{(option.type === ContextMenuOptionType.File ||
 									option.type === ContextMenuOptionType.Folder ||
@@ -299,9 +327,11 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 									/>
 								)}
 								{option.type !== ContextMenuOptionType.Mode &&
+									option.type !== ContextMenuOptionType.Command &&
 									option.type !== ContextMenuOptionType.File &&
 									option.type !== ContextMenuOptionType.Folder &&
 									option.type !== ContextMenuOptionType.OpenedFile &&
+									option.type !== ContextMenuOptionType.SectionHeader &&
 									getIconForOption(option) && (
 										<i
 											className={`codicon codicon-${getIconForOption(option)}`}

+ 72 - 0
webview-ui/src/components/chat/SlashCommandItem.tsx

@@ -0,0 +1,72 @@
+import React from "react"
+import { Edit, Trash2 } from "lucide-react"
+
+import type { Command } from "@roo/ExtensionMessage"
+
+import { useAppTranslation } from "@/i18n/TranslationContext"
+import { Button, StandardTooltip } from "@/components/ui"
+import { vscode } from "@/utils/vscode"
+
+interface SlashCommandItemProps {
+	command: Command
+	onDelete: (command: Command) => void
+	onClick?: (command: Command) => void
+}
+
+export const SlashCommandItem: React.FC<SlashCommandItemProps> = ({ command, onDelete, onClick }) => {
+	const { t } = useAppTranslation()
+
+	const handleEdit = () => {
+		if (command.filePath) {
+			vscode.postMessage({
+				type: "openFile",
+				text: command.filePath,
+			})
+		} else {
+			// Fallback: request to open command file by name and source
+			vscode.postMessage({
+				type: "openCommandFile",
+				text: command.name,
+				values: { source: command.source },
+			})
+		}
+	}
+
+	const handleDelete = () => {
+		onDelete(command)
+	}
+
+	return (
+		<div className="px-4 py-2 text-sm flex items-center group hover:bg-vscode-list-hoverBackground">
+			{/* Command name - clickable */}
+			<div className="flex-1 min-w-0 cursor-pointer" onClick={() => onClick?.(command)}>
+				<span className="truncate text-vscode-foreground">{command.name}</span>
+			</div>
+
+			{/* Action buttons */}
+			<div className="flex items-center gap-2 ml-2">
+				<StandardTooltip content={t("chat:slashCommands.editCommand")}>
+					<Button
+						variant="ghost"
+						size="icon"
+						tabIndex={-1}
+						onClick={handleEdit}
+						className="size-6 flex items-center justify-center opacity-60 hover:opacity-100">
+						<Edit className="w-4 h-4" />
+					</Button>
+				</StandardTooltip>
+
+				<StandardTooltip content={t("chat:slashCommands.deleteCommand")}>
+					<Button
+						variant="ghost"
+						size="icon"
+						tabIndex={-1}
+						onClick={handleDelete}
+						className="size-6 flex items-center justify-center opacity-60 hover:opacity-100 hover:text-red-400">
+						<Trash2 className="w-4 h-4" />
+					</Button>
+				</StandardTooltip>
+			</div>
+		</div>
+	)
+}

+ 203 - 0
webview-ui/src/components/chat/SlashCommandsList.tsx

@@ -0,0 +1,203 @@
+import React, { useState } from "react"
+import { Plus, Globe, Folder } from "lucide-react"
+
+import type { Command } from "@roo/ExtensionMessage"
+
+import { useAppTranslation } from "@/i18n/TranslationContext"
+import { useExtensionState } from "@/context/ExtensionStateContext"
+import {
+	AlertDialog,
+	AlertDialogAction,
+	AlertDialogCancel,
+	AlertDialogContent,
+	AlertDialogDescription,
+	AlertDialogFooter,
+	AlertDialogHeader,
+	AlertDialogTitle,
+	Button,
+} from "@/components/ui"
+import { vscode } from "@/utils/vscode"
+
+import { SlashCommandItem } from "./SlashCommandItem"
+
+interface SlashCommandsListProps {
+	commands: Command[]
+	onRefresh: () => void
+}
+
+export const SlashCommandsList: React.FC<SlashCommandsListProps> = ({ commands, onRefresh }) => {
+	const { t } = useAppTranslation()
+	const { cwd } = useExtensionState()
+	const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+	const [commandToDelete, setCommandToDelete] = useState<Command | null>(null)
+	const [globalNewName, setGlobalNewName] = useState("")
+	const [workspaceNewName, setWorkspaceNewName] = useState("")
+
+	// Check if we're in a workspace/project
+	const hasWorkspace = Boolean(cwd)
+
+	const handleDeleteClick = (command: Command) => {
+		setCommandToDelete(command)
+		setDeleteDialogOpen(true)
+	}
+
+	const handleDeleteConfirm = () => {
+		if (commandToDelete) {
+			vscode.postMessage({
+				type: "deleteCommand",
+				text: commandToDelete.name,
+				values: { source: commandToDelete.source },
+			})
+			setDeleteDialogOpen(false)
+			setCommandToDelete(null)
+			// Refresh the commands list after deletion
+			setTimeout(onRefresh, 100)
+		}
+	}
+
+	const handleDeleteCancel = () => {
+		setDeleteDialogOpen(false)
+		setCommandToDelete(null)
+	}
+
+	const handleCreateCommand = (source: "global" | "project", name: string) => {
+		if (!name.trim()) return
+
+		// Append .md if not already present
+		const fileName = name.trim().endsWith(".md") ? name.trim() : `${name.trim()}.md`
+
+		vscode.postMessage({
+			type: "createCommand",
+			text: fileName,
+			values: { source },
+		})
+
+		// Clear the input and refresh
+		if (source === "global") {
+			setGlobalNewName("")
+		} else {
+			setWorkspaceNewName("")
+		}
+		setTimeout(onRefresh, 500)
+	}
+
+	const handleCommandClick = (command: Command) => {
+		// Insert the command into the textarea
+		vscode.postMessage({
+			type: "insertTextIntoTextarea",
+			text: `/${command.name}`,
+		})
+	}
+
+	// Group commands by source
+	const globalCommands = commands.filter((cmd) => cmd.source === "global")
+	const projectCommands = commands.filter((cmd) => cmd.source === "project")
+
+	return (
+		<>
+			{/* Commands list */}
+			<div className="max-h-[300px] overflow-y-auto">
+				<div className="py-1">
+					{/* Global Commands Section */}
+					<div className="px-3 py-1.5 text-xs font-medium text-vscode-descriptionForeground flex items-center gap-1.5">
+						<Globe className="w-3 h-3" />
+						{t("chat:slashCommands.globalCommands")}
+					</div>
+					{globalCommands.map((command) => (
+						<SlashCommandItem
+							key={`global-${command.name}`}
+							command={command}
+							onDelete={handleDeleteClick}
+							onClick={handleCommandClick}
+						/>
+					))}
+					{/* New global command input */}
+					<div className="px-4 py-2 flex items-center gap-2 hover:bg-vscode-list-hoverBackground">
+						<input
+							type="text"
+							value={globalNewName}
+							onChange={(e) => setGlobalNewName(e.target.value)}
+							placeholder={t("chat:slashCommands.newGlobalCommandPlaceholder")}
+							className="flex-1 bg-transparent text-vscode-input-foreground placeholder-vscode-input-placeholderForeground border-none outline-none focus:outline-0 text-sm"
+							tabIndex={-1}
+							onKeyDown={(e) => {
+								if (e.key === "Enter") {
+									handleCreateCommand("global", globalNewName)
+								}
+							}}
+						/>
+						<Button
+							variant="ghost"
+							size="icon"
+							onClick={() => handleCreateCommand("global", globalNewName)}
+							disabled={!globalNewName.trim()}
+							className="size-6 flex items-center justify-center opacity-60 hover:opacity-100">
+							<Plus className="w-4 h-4" />
+						</Button>
+					</div>
+
+					{/* Workspace Commands Section - Only show if in a workspace */}
+					{hasWorkspace && (
+						<>
+							<div className="px-3 py-1.5 text-xs font-medium text-vscode-descriptionForeground mt-4 flex items-center gap-1.5">
+								<Folder className="w-3 h-3" />
+								{t("chat:slashCommands.workspaceCommands")}
+							</div>
+							{projectCommands.map((command) => (
+								<SlashCommandItem
+									key={`project-${command.name}`}
+									command={command}
+									onDelete={handleDeleteClick}
+									onClick={handleCommandClick}
+								/>
+							))}
+							{/* New workspace command input */}
+							<div className="px-4 py-2 flex items-center gap-2 hover:bg-vscode-list-hoverBackground">
+								<input
+									type="text"
+									value={workspaceNewName}
+									onChange={(e) => setWorkspaceNewName(e.target.value)}
+									placeholder={t("chat:slashCommands.newWorkspaceCommandPlaceholder")}
+									className="flex-1 bg-transparent text-vscode-input-foreground placeholder-vscode-input-placeholderForeground border-none outline-none focus:outline-0 text-sm"
+									tabIndex={-1}
+									onKeyDown={(e) => {
+										if (e.key === "Enter") {
+											handleCreateCommand("project", workspaceNewName)
+										}
+									}}
+								/>
+								<Button
+									variant="ghost"
+									size="icon"
+									onClick={() => handleCreateCommand("project", workspaceNewName)}
+									disabled={!workspaceNewName.trim()}
+									className="size-6 flex items-center justify-center opacity-60 hover:opacity-100">
+									<Plus className="w-4 h-4" />
+								</Button>
+							</div>
+						</>
+					)}
+				</div>
+			</div>
+
+			<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+				<AlertDialogContent>
+					<AlertDialogHeader>
+						<AlertDialogTitle>{t("chat:slashCommands.deleteDialog.title")}</AlertDialogTitle>
+						<AlertDialogDescription>
+							{t("chat:slashCommands.deleteDialog.description", { name: commandToDelete?.name })}
+						</AlertDialogDescription>
+					</AlertDialogHeader>
+					<AlertDialogFooter>
+						<AlertDialogCancel onClick={handleDeleteCancel}>
+							{t("chat:slashCommands.deleteDialog.cancel")}
+						</AlertDialogCancel>
+						<AlertDialogAction onClick={handleDeleteConfirm}>
+							{t("chat:slashCommands.deleteDialog.confirm")}
+						</AlertDialogAction>
+					</AlertDialogFooter>
+				</AlertDialogContent>
+			</AlertDialog>
+		</>
+	)
+}

+ 82 - 0
webview-ui/src/components/chat/SlashCommandsPopover.tsx

@@ -0,0 +1,82 @@
+import React, { useEffect, useState } from "react"
+import { Zap } from "lucide-react"
+
+import { useAppTranslation } from "@/i18n/TranslationContext"
+import { useExtensionState } from "@/context/ExtensionStateContext"
+import { Button, Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
+import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
+import { cn } from "@/lib/utils"
+import { vscode } from "@/utils/vscode"
+
+import { SlashCommandsList } from "./SlashCommandsList"
+
+interface SlashCommandsPopoverProps {
+	className?: string
+}
+
+export const SlashCommandsPopover: React.FC<SlashCommandsPopoverProps> = ({ className }) => {
+	const { t } = useAppTranslation()
+	const { commands } = useExtensionState()
+	const [isOpen, setIsOpen] = useState(false)
+	const portalContainer = useRooPortal("roo-portal")
+
+	// Request commands when popover opens
+	useEffect(() => {
+		if (isOpen && (!commands || commands.length === 0)) {
+			handleRefresh()
+		}
+	}, [isOpen, commands])
+
+	const handleRefresh = () => {
+		vscode.postMessage({ type: "requestCommands" })
+	}
+
+	const handleOpenChange = (open: boolean) => {
+		setIsOpen(open)
+		if (open) {
+			// Always refresh when opening to get latest commands
+			handleRefresh()
+		}
+	}
+
+	const trigger = (
+		<PopoverTrigger asChild>
+			<Button
+				variant="ghost"
+				size="sm"
+				className={cn(
+					"h-7 w-7 p-0",
+					"text-vscode-foreground opacity-85",
+					"hover:opacity-100 hover:bg-vscode-button-hoverBackground",
+					"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
+					className,
+				)}>
+				<Zap className="w-4 h-4" />
+			</Button>
+		</PopoverTrigger>
+	)
+
+	return (
+		<Popover open={isOpen} onOpenChange={handleOpenChange}>
+			<StandardTooltip content={t("chat:slashCommands.tooltip")}>{trigger}</StandardTooltip>
+
+			<PopoverContent
+				align="start"
+				sideOffset={4}
+				container={portalContainer}
+				className="p-0 overflow-hidden min-w-80 max-w-9/10">
+				<div className="flex flex-col w-full">
+					{/* Header section */}
+					<div className="p-3 border-b border-vscode-dropdown-border">
+						<p className="m-0 text-xs text-vscode-descriptionForeground">
+							{t("chat:slashCommands.description")}
+						</p>
+					</div>
+
+					{/* Commands list */}
+					<SlashCommandsList commands={commands || []} onRefresh={handleRefresh} />
+				</div>
+			</PopoverContent>
+		</Popover>
+	)
+}

+ 19 - 1
webview-ui/src/i18n/locales/ca/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "Atura la síntesi de veu",
 	"typeMessage": "Escriu un missatge...",
 	"typeTask": "Escriu la teva tasca aquí...",
-	"addContext": "@ per afegir context, / per canviar de mode",
+	"addContext": "@ per afegir context, / per a comandes",
 	"dragFiles": "manté premut shift per arrossegar fitxers",
 	"dragFilesImages": "manté premut shift per arrossegar fitxers/imatges",
 	"enhancePromptDescription": "El botó 'Millora la sol·licitud' ajuda a millorar la teva sol·licitud proporcionant context addicional, aclariments o reformulacions. Prova d'escriure una sol·licitud aquí i fes clic al botó de nou per veure com funciona.",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "Activa la comanda {{name}}"
 	},
+	"slashCommands": {
+		"tooltip": "Gestionar ordres de barra",
+		"title": "Ordres de Barra",
+		"description": "Crea ordres de barra personalitzades per accedir ràpidament a indicacions i fluxos de treball utilitzats amb freqüència.",
+		"globalCommands": "Ordres Globals",
+		"workspaceCommands": "Ordres de l'Espai de Treball",
+		"globalCommand": "Ordre global",
+		"editCommand": "Editar ordre",
+		"deleteCommand": "Eliminar ordre",
+		"newGlobalCommandPlaceholder": "Nova ordre global...",
+		"newWorkspaceCommandPlaceholder": "Nova ordre de l'espai de treball...",
+		"deleteDialog": {
+			"title": "Eliminar Ordre",
+			"description": "Estàs segur que vols eliminar l'ordre \"{{name}}\"? Aquesta acció no es pot desfer.",
+			"cancel": "Cancel·lar",
+			"confirm": "Eliminar"
+		}
+	},
 	"queuedMessages": {
 		"title": "Missatges en cua:",
 		"clickToEdit": "Feu clic per editar el missatge"

+ 19 - 1
webview-ui/src/i18n/locales/de/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "Text-in-Sprache beenden",
 	"typeMessage": "Nachricht eingeben...",
 	"typeTask": "Gib deine Aufgabe hier ein...",
-	"addContext": "@ für Kontext, / zum Moduswechsel",
+	"addContext": "@ für Kontext, / für Befehle",
 	"dragFiles": "Shift halten, um Dateien einzufügen",
 	"dragFilesImages": "Shift halten, um Dateien/Bilder einzufügen",
 	"enhancePromptDescription": "Die Schaltfläche 'Prompt verbessern' hilft, deine Anfrage durch zusätzlichen Kontext, Klarstellungen oder Umformulierungen zu verbessern. Versuche, hier eine Anfrage einzugeben und klicke erneut auf die Schaltfläche, um zu sehen, wie es funktioniert.",
@@ -352,6 +352,24 @@
 	"editMessage": {
 		"placeholder": "Bearbeite deine Nachricht..."
 	},
+	"slashCommands": {
+		"tooltip": "Slash-Befehle verwalten",
+		"title": "Slash-Befehle",
+		"description": "Erstelle benutzerdefinierte Slash-Befehle für schnellen Zugriff auf häufig verwendete Prompts und Workflows.",
+		"globalCommands": "Globale Befehle",
+		"workspaceCommands": "Arbeitsbereich-Befehle",
+		"globalCommand": "Globaler Befehl",
+		"editCommand": "Befehl bearbeiten",
+		"deleteCommand": "Befehl löschen",
+		"newGlobalCommandPlaceholder": "Neuer globaler Befehl...",
+		"newWorkspaceCommandPlaceholder": "Neuer Arbeitsbereich-Befehl...",
+		"deleteDialog": {
+			"title": "Befehl löschen",
+			"description": "Bist du sicher, dass du den Befehl \"{{name}}\" löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
+			"cancel": "Abbrechen",
+			"confirm": "Löschen"
+		}
+	},
 	"queuedMessages": {
 		"title": "Warteschlange Nachrichten:",
 		"clickToEdit": "Klicken zum Bearbeiten der Nachricht"

+ 19 - 1
webview-ui/src/i18n/locales/en/chat.json

@@ -128,7 +128,7 @@
 	"stopTts": "Stop text-to-speech",
 	"typeMessage": "Type a message...",
 	"typeTask": "Type your task here...",
-	"addContext": "@ to add context, / to switch modes",
+	"addContext": "@ to add context, / for commands",
 	"dragFiles": "hold shift to drag in files",
 	"dragFilesImages": "hold shift to drag in files/images",
 	"errorReadingFile": "Error reading file:",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "Trigger the {{name}} command"
 	},
+	"slashCommands": {
+		"tooltip": "Manage slash commands",
+		"title": "Slash Commands",
+		"description": "Create custom slash commands for quick access to frequently used prompts and workflows.",
+		"globalCommands": "Global Commands",
+		"workspaceCommands": "Workspace Commands",
+		"globalCommand": "Global command",
+		"editCommand": "Edit command",
+		"deleteCommand": "Delete command",
+		"newGlobalCommandPlaceholder": "New global command...",
+		"newWorkspaceCommandPlaceholder": "New workspace command...",
+		"deleteDialog": {
+			"title": "Delete Command",
+			"description": "Are you sure you want to delete the command \"{{name}}\"? This action cannot be undone.",
+			"cancel": "Cancel",
+			"confirm": "Delete"
+		}
+	},
 	"queuedMessages": {
 		"title": "Queued Messages:",
 		"clickToEdit": "Click to edit message"

+ 19 - 1
webview-ui/src/i18n/locales/es/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "Detener texto a voz",
 	"typeMessage": "Escribe un mensaje...",
 	"typeTask": "Escribe tu tarea aquí...",
-	"addContext": "@ para agregar contexto, / para cambiar modos",
+	"addContext": "@ para agregar contexto, / para comandos",
 	"dragFiles": "mantén shift para arrastrar archivos",
 	"dragFilesImages": "mantén shift para arrastrar archivos/imágenes",
 	"enhancePromptDescription": "El botón 'Mejorar el mensaje' ayuda a mejorar tu petición proporcionando contexto adicional, aclaraciones o reformulaciones. Intenta escribir una petición aquí y haz clic en el botón nuevamente para ver cómo funciona.",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "Activar el comando {{name}}"
 	},
+	"slashCommands": {
+		"tooltip": "Gestionar comandos de barra",
+		"title": "Comandos de Barra",
+		"description": "Crea comandos de barra personalizados para acceder rápidamente a prompts y flujos de trabajo utilizados con frecuencia.",
+		"globalCommands": "Comandos Globales",
+		"workspaceCommands": "Comandos del Espacio de Trabajo",
+		"globalCommand": "Comando global",
+		"editCommand": "Editar comando",
+		"deleteCommand": "Eliminar comando",
+		"newGlobalCommandPlaceholder": "Nuevo comando global...",
+		"newWorkspaceCommandPlaceholder": "Nuevo comando del espacio de trabajo...",
+		"deleteDialog": {
+			"title": "Eliminar Comando",
+			"description": "¿Estás seguro de que quieres eliminar el comando \"{{name}}\"? Esta acción no se puede deshacer.",
+			"cancel": "Cancelar",
+			"confirm": "Eliminar"
+		}
+	},
 	"queuedMessages": {
 		"title": "Mensajes en cola:",
 		"clickToEdit": "Haz clic para editar el mensaje"

+ 19 - 1
webview-ui/src/i18n/locales/fr/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "Arrêter la synthèse vocale",
 	"typeMessage": "Écrivez un message...",
 	"typeTask": "Écrivez votre tâche ici...",
-	"addContext": "@ pour ajouter du contexte, / pour changer de mode",
+	"addContext": "@ pour ajouter du contexte, / pour les commandes",
 	"dragFiles": "maintenir Maj pour glisser des fichiers",
 	"dragFilesImages": "maintenir Maj pour glisser des fichiers/images",
 	"enhancePromptDescription": "Le bouton 'Améliorer la requête' aide à améliorer votre demande en fournissant un contexte supplémentaire, des clarifications ou des reformulations. Essayez de taper une demande ici et cliquez à nouveau sur le bouton pour voir comment cela fonctionne.",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "Déclencher la commande {{name}}"
 	},
+	"slashCommands": {
+		"tooltip": "Gérer les commandes slash",
+		"title": "Commandes Slash",
+		"description": "Créez des commandes slash personnalisées pour accéder rapidement aux prompts et flux de travail fréquemment utilisés.",
+		"globalCommands": "Commandes Globales",
+		"workspaceCommands": "Commandes de l'Espace de Travail",
+		"globalCommand": "Commande globale",
+		"editCommand": "Modifier la commande",
+		"deleteCommand": "Supprimer la commande",
+		"newGlobalCommandPlaceholder": "Nouvelle commande globale...",
+		"newWorkspaceCommandPlaceholder": "Nouvelle commande de l'espace de travail...",
+		"deleteDialog": {
+			"title": "Supprimer la commande",
+			"description": "Êtes-vous sûr de vouloir supprimer la commande \"{{name}}\" ? Cette action ne peut pas être annulée.",
+			"cancel": "Annuler",
+			"confirm": "Supprimer"
+		}
+	},
 	"queuedMessages": {
 		"title": "Messages en file d'attente :",
 		"clickToEdit": "Cliquez pour modifier le message"

+ 19 - 1
webview-ui/src/i18n/locales/hi/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "टेक्स्ट-टू-स्पीच बंद करें",
 	"typeMessage": "एक संदेश लिखें...",
 	"typeTask": "अपना कार्य यहां लिखें...",
-	"addContext": "संदर्भ जोड़ने के लिए @, मोड बदलने के लिए /",
+	"addContext": "संदर्भ जोड़ने के लिए @, कमांड के लिए /",
 	"dragFiles": "फ़ाइलें खींचने के लिए shift दबाकर रखें",
 	"dragFilesImages": "फ़ाइलें/चित्र खींचने के लिए shift दबाकर रखें",
 	"enhancePromptDescription": "'प्रॉम्प्ट बढ़ाएँ' बटन अतिरिक्त संदर्भ, स्पष्टीकरण या पुनर्विचार प्रदान करके आपके अनुरोध को बेहतर बनाने में मदद करता है। यहां अनुरोध लिखकर देखें और यह कैसे काम करता है यह देखने के लिए बटन पर फिर से क्लिक करें।",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "{{name}} कमांड को ट्रिगर करें"
 	},
+	"slashCommands": {
+		"tooltip": "स्लैश कमांड प्रबंधित करें",
+		"title": "स्लैश कमांड",
+		"description": "बार-बार उपयोग किए जाने वाले प्रॉम्प्ट और वर्कफ़्लो तक त्वरित पहुंच के लिए कस्टम स्लैश कमांड बनाएं।",
+		"globalCommands": "वैश्विक कमांड",
+		"workspaceCommands": "कार्यक्षेत्र कमांड",
+		"globalCommand": "वैश्विक कमांड",
+		"editCommand": "कमांड संपादित करें",
+		"deleteCommand": "कमांड हटाएं",
+		"newGlobalCommandPlaceholder": "नया वैश्विक कमांड...",
+		"newWorkspaceCommandPlaceholder": "नया कार्यक्षेत्र कमांड...",
+		"deleteDialog": {
+			"title": "कमांड हटाएं",
+			"description": "क्या आप वाकई \"{{name}}\" कमांड को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
+			"cancel": "रद्द करें",
+			"confirm": "हटाएं"
+		}
+	},
 	"queuedMessages": {
 		"title": "कतार में संदेश:",
 		"clickToEdit": "संदेश संपादित करने के लिए क्लिक करें"

+ 19 - 1
webview-ui/src/i18n/locales/id/chat.json

@@ -131,7 +131,7 @@
 	"stopTts": "Hentikan text-to-speech",
 	"typeMessage": "Ketik pesan...",
 	"typeTask": "Bangun, cari, tanya sesuatu",
-	"addContext": "@ untuk menambah konteks, / untuk ganti mode",
+	"addContext": "@ untuk menambah konteks, / untuk perintah",
 	"dragFiles": "tahan shift untuk drag file",
 	"dragFilesImages": "tahan shift untuk drag file/gambar",
 	"errorReadingFile": "Error membaca file:",
@@ -358,6 +358,24 @@
 	"command": {
 		"triggerDescription": "Jalankan perintah {{name}}"
 	},
+	"slashCommands": {
+		"tooltip": "Kelola perintah slash",
+		"title": "Perintah Slash",
+		"description": "Buat perintah slash kustom untuk akses cepat ke prompt dan alur kerja yang sering digunakan.",
+		"globalCommands": "Perintah Global",
+		"workspaceCommands": "Perintah Workspace",
+		"globalCommand": "Perintah global",
+		"editCommand": "Edit perintah",
+		"deleteCommand": "Hapus perintah",
+		"newGlobalCommandPlaceholder": "Perintah global baru...",
+		"newWorkspaceCommandPlaceholder": "Perintah workspace baru...",
+		"deleteDialog": {
+			"title": "Hapus Perintah",
+			"description": "Apakah Anda yakin ingin menghapus perintah \"{{name}}\"? Tindakan ini tidak dapat dibatalkan.",
+			"cancel": "Batal",
+			"confirm": "Hapus"
+		}
+	},
 	"queuedMessages": {
 		"title": "Pesan Antrian:",
 		"clickToEdit": "Klik untuk mengedit pesan"

+ 19 - 1
webview-ui/src/i18n/locales/it/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "Interrompi sintesi vocale",
 	"typeMessage": "Scrivi un messaggio...",
 	"typeTask": "Scrivi la tua attività qui...",
-	"addContext": "@ per aggiungere contesto, / per cambiare modalità",
+	"addContext": "@ per aggiungere contesto, / per i comandi",
 	"dragFiles": "tieni premuto shift per trascinare file",
 	"dragFilesImages": "tieni premuto shift per trascinare file/immagini",
 	"enhancePromptDescription": "Il pulsante 'Migliora prompt' aiuta a migliorare la tua richiesta fornendo contesto aggiuntivo, chiarimenti o riformulazioni. Prova a digitare una richiesta qui e fai di nuovo clic sul pulsante per vedere come funziona.",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "Attiva il comando {{name}}"
 	},
+	"slashCommands": {
+		"tooltip": "Gestisci comandi slash",
+		"title": "Comandi Slash",
+		"description": "Crea comandi slash personalizzati per accedere rapidamente a prompt e flussi di lavoro utilizzati frequentemente.",
+		"globalCommands": "Comandi Globali",
+		"workspaceCommands": "Comandi dello Spazio di Lavoro",
+		"globalCommand": "Comando globale",
+		"editCommand": "Modifica comando",
+		"deleteCommand": "Elimina comando",
+		"newGlobalCommandPlaceholder": "Nuovo comando globale...",
+		"newWorkspaceCommandPlaceholder": "Nuovo comando dello spazio di lavoro...",
+		"deleteDialog": {
+			"title": "Elimina Comando",
+			"description": "Sei sicuro di voler eliminare il comando \"{{name}}\"? Questa azione non può essere annullata.",
+			"cancel": "Annulla",
+			"confirm": "Elimina"
+		}
+	},
 	"queuedMessages": {
 		"title": "Messaggi in coda:",
 		"clickToEdit": "Clicca per modificare il messaggio"

+ 19 - 1
webview-ui/src/i18n/locales/ja/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "テキスト読み上げを停止",
 	"typeMessage": "メッセージを入力...",
 	"typeTask": "ここにタスクを入力...",
-	"addContext": "コンテキスト追加は@、モード切替は/",
+	"addContext": "コンテキスト追加は@、コマンドは/",
 	"dragFiles": "ファイルをドラッグするにはShiftキーを押したまま",
 	"dragFilesImages": "ファイル/画像をドラッグするにはShiftキーを押したまま",
 	"enhancePromptDescription": "「プロンプトを強化」ボタンは、追加コンテキスト、説明、または言い換えを提供することで、リクエストを改善します。ここにリクエストを入力し、ボタンを再度クリックして動作を確認してください。",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "{{name}}コマンドをトリガー"
 	},
+	"slashCommands": {
+		"tooltip": "スラッシュコマンドを管理",
+		"title": "スラッシュコマンド",
+		"description": "よく使用するプロンプトやワークフローに素早くアクセスするためのカスタムスラッシュコマンドを作成します。",
+		"globalCommands": "グローバルコマンド",
+		"workspaceCommands": "ワークスペースコマンド",
+		"globalCommand": "グローバルコマンド",
+		"editCommand": "コマンドを編集",
+		"deleteCommand": "コマンドを削除",
+		"newGlobalCommandPlaceholder": "新しいグローバルコマンド...",
+		"newWorkspaceCommandPlaceholder": "新しいワークスペースコマンド...",
+		"deleteDialog": {
+			"title": "コマンドを削除",
+			"description": "\"{{name}}\" コマンドを削除してもよろしいですか?この操作は元に戻せません。",
+			"cancel": "キャンセル",
+			"confirm": "削除"
+		}
+	},
 	"queuedMessages": {
 		"title": "キューメッセージ:",
 		"clickToEdit": "クリックしてメッセージを編集"

+ 19 - 1
webview-ui/src/i18n/locales/ko/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "텍스트 음성 변환 중지",
 	"typeMessage": "메시지 입력...",
 	"typeTask": "여기에 작업 입력...",
-	"addContext": "컨텍스트 추가는 @, 모드 전환은 /",
+	"addContext": "컨텍스트 추가는 @, 명령어는 /",
 	"dragFiles": "파일을 드래그하려면 shift 키 누르기",
 	"dragFilesImages": "파일/이미지를 드래그하려면 shift 키 누르기",
 	"enhancePromptDescription": "'프롬프트 향상' 버튼은 추가 컨텍스트, 명확화 또는 재구성을 제공하여 요청을 개선합니다. 여기에 요청을 입력한 다음 버튼을 다시 클릭하여 작동 방식을 확인해보세요.",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "{{name}} 명령 트리거"
 	},
+	"slashCommands": {
+		"tooltip": "슬래시 명령 관리",
+		"title": "슬래시 명령",
+		"description": "자주 사용하는 프롬프트와 워크플로우에 빠르게 액세스할 수 있는 사용자 정의 슬래시 명령을 만듭니다.",
+		"globalCommands": "전역 명령",
+		"workspaceCommands": "작업 공간 명령",
+		"globalCommand": "전역 명령",
+		"editCommand": "명령 편집",
+		"deleteCommand": "명령 삭제",
+		"newGlobalCommandPlaceholder": "새 전역 명령...",
+		"newWorkspaceCommandPlaceholder": "새 작업 공간 명령...",
+		"deleteDialog": {
+			"title": "명령 삭제",
+			"description": "\"{{name}}\" 명령을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
+			"cancel": "취소",
+			"confirm": "삭제"
+		}
+	},
 	"queuedMessages": {
 		"title": "대기열 메시지:",
 		"clickToEdit": "클릭하여 메시지 편집"

+ 19 - 1
webview-ui/src/i18n/locales/nl/chat.json

@@ -117,7 +117,7 @@
 	"stopTts": "Stop tekst-naar-spraak",
 	"typeMessage": "Typ een bericht...",
 	"typeTask": "Typ hier je taak...",
-	"addContext": "@ om context toe te voegen, / om van modus te wisselen",
+	"addContext": "@ om context toe te voegen, / voor commando's",
 	"dragFiles": "houd shift ingedrukt om bestanden te slepen",
 	"dragFilesImages": "houd shift ingedrukt om bestanden/afbeeldingen te slepen",
 	"errorReadingFile": "Fout bij het lezen van bestand:",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "Activeer de {{name}} opdracht"
 	},
+	"slashCommands": {
+		"tooltip": "Slash-opdrachten beheren",
+		"title": "Slash-opdrachten",
+		"description": "Maak aangepaste slash-opdrachten voor snelle toegang tot veelgebruikte prompts en workflows.",
+		"globalCommands": "Globale Opdrachten",
+		"workspaceCommands": "Werkruimte Opdrachten",
+		"globalCommand": "Globale opdracht",
+		"editCommand": "Opdracht bewerken",
+		"deleteCommand": "Opdracht verwijderen",
+		"newGlobalCommandPlaceholder": "Nieuwe globale opdracht...",
+		"newWorkspaceCommandPlaceholder": "Nieuwe werkruimte opdracht...",
+		"deleteDialog": {
+			"title": "Opdracht Verwijderen",
+			"description": "Weet je zeker dat je de opdracht \"{{name}}\" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
+			"cancel": "Annuleren",
+			"confirm": "Verwijderen"
+		}
+	},
 	"queuedMessages": {
 		"title": "Berichten in wachtrij:",
 		"clickToEdit": "Klik om bericht te bewerken"

+ 19 - 1
webview-ui/src/i18n/locales/pl/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "Zatrzymaj syntezę mowy",
 	"typeMessage": "Wpisz wiadomość...",
 	"typeTask": "Wpisz swoje zadanie tutaj...",
-	"addContext": "@ aby dodać kontekst, / aby zmienić tryb",
+	"addContext": "@ aby dodać kontekst, / dla poleceń",
 	"dragFiles": "przytrzymaj shift, aby przeciągnąć pliki",
 	"dragFilesImages": "przytrzymaj shift, aby przeciągnąć pliki/obrazy",
 	"enhancePromptDescription": "Przycisk 'Ulepsz podpowiedź' pomaga ulepszyć Twoją prośbę, dostarczając dodatkowy kontekst, wyjaśnienia lub przeformułowania. Spróbuj wpisać prośbę tutaj i kliknij przycisk ponownie, aby zobaczyć, jak to działa.",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "Uruchom polecenie {{name}}"
 	},
+	"slashCommands": {
+		"tooltip": "Zarządzaj poleceniami slash",
+		"title": "Polecenia Slash",
+		"description": "Twórz niestandardowe polecenia slash dla szybkiego dostępu do często używanych promptów i przepływów pracy.",
+		"globalCommands": "Polecenia Globalne",
+		"workspaceCommands": "Polecenia Obszaru Roboczego",
+		"globalCommand": "Polecenie globalne",
+		"editCommand": "Edytuj polecenie",
+		"deleteCommand": "Usuń polecenie",
+		"newGlobalCommandPlaceholder": "Nowe polecenie globalne...",
+		"newWorkspaceCommandPlaceholder": "Nowe polecenie obszaru roboczego...",
+		"deleteDialog": {
+			"title": "Usuń Polecenie",
+			"description": "Czy na pewno chcesz usunąć polecenie \"{{name}}\"? Tej akcji nie można cofnąć.",
+			"cancel": "Anuluj",
+			"confirm": "Usuń"
+		}
+	},
 	"queuedMessages": {
 		"title": "Wiadomości w kolejce:",
 		"clickToEdit": "Kliknij, aby edytować wiadomość"

+ 19 - 1
webview-ui/src/i18n/locales/pt-BR/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "Parar conversão de texto em fala",
 	"typeMessage": "Digite uma mensagem...",
 	"typeTask": "Digite sua tarefa aqui...",
-	"addContext": "@ para adicionar contexto, / para alternar modos",
+	"addContext": "@ para adicionar contexto, / para comandos",
 	"dragFiles": "segure shift para arrastar arquivos",
 	"dragFilesImages": "segure shift para arrastar arquivos/imagens",
 	"enhancePromptDescription": "O botão 'Aprimorar prompt' ajuda a melhorar seu pedido fornecendo contexto adicional, esclarecimentos ou reformulações. Tente digitar um pedido aqui e clique no botão novamente para ver como funciona.",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "Acionar o comando {{name}}"
 	},
+	"slashCommands": {
+		"tooltip": "Gerenciar comandos de barra",
+		"title": "Comandos de Barra",
+		"description": "Crie comandos de barra personalizados para acesso rápido a prompts e fluxos de trabalho usados com frequência.",
+		"globalCommands": "Comandos Globais",
+		"workspaceCommands": "Comandos do Espaço de Trabalho",
+		"globalCommand": "Comando global",
+		"editCommand": "Editar comando",
+		"deleteCommand": "Excluir comando",
+		"newGlobalCommandPlaceholder": "Novo comando global...",
+		"newWorkspaceCommandPlaceholder": "Novo comando do espaço de trabalho...",
+		"deleteDialog": {
+			"title": "Excluir Comando",
+			"description": "Tem certeza de que deseja excluir o comando \"{{name}}\"? Esta ação não pode ser desfeita.",
+			"cancel": "Cancelar",
+			"confirm": "Excluir"
+		}
+	},
 	"queuedMessages": {
 		"title": "Mensagens na fila:",
 		"clickToEdit": "Clique para editar a mensagem"

+ 19 - 1
webview-ui/src/i18n/locales/ru/chat.json

@@ -117,7 +117,7 @@
 	"stopTts": "Остановить синтез речи",
 	"typeMessage": "Введите сообщение...",
 	"typeTask": "Введите вашу задачу здесь...",
-	"addContext": "@ для добавления контекста, / для смены режима",
+	"addContext": "@ для добавления контекста, / для команд",
 	"dragFiles": "удерживайте shift для перетаскивания файлов",
 	"dragFilesImages": "удерживайте shift для перетаскивания файлов/изображений",
 	"errorReadingFile": "Ошибка чтения файла:",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "Запустить команду {{name}}"
 	},
+	"slashCommands": {
+		"tooltip": "Управление слэш-командами",
+		"title": "Слэш-команды",
+		"description": "Создавайте пользовательские слэш-команды для быстрого доступа к часто используемым промптам и рабочим процессам.",
+		"globalCommands": "Глобальные команды",
+		"workspaceCommands": "Команды рабочего пространства",
+		"globalCommand": "Глобальная команда",
+		"editCommand": "Редактировать команду",
+		"deleteCommand": "Удалить команду",
+		"newGlobalCommandPlaceholder": "Новая глобальная команда...",
+		"newWorkspaceCommandPlaceholder": "Новая команда рабочего пространства...",
+		"deleteDialog": {
+			"title": "Удалить команду",
+			"description": "Вы уверены, что хотите удалить команду \"{{name}}\"? Это действие нельзя отменить.",
+			"cancel": "Отмена",
+			"confirm": "Удалить"
+		}
+	},
 	"queuedMessages": {
 		"title": "Сообщения в очереди:",
 		"clickToEdit": "Нажмите, чтобы редактировать сообщение"

+ 19 - 1
webview-ui/src/i18n/locales/tr/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "Metin okumayı durdur",
 	"typeMessage": "Bir mesaj yazın...",
 	"typeTask": "Görevinizi buraya yazın...",
-	"addContext": "Bağlam eklemek için @, mod değiştirmek için /",
+	"addContext": "Bağlam eklemek için @, komutlar için /",
 	"dragFiles": "dosyaları sürüklemek için shift tuşuna basılı tutun",
 	"dragFilesImages": "dosyaları/resimleri sürüklemek için shift tuşuna basılı tutun",
 	"enhancePromptDescription": "'İstemi geliştir' düğmesi, ek bağlam, açıklama veya yeniden ifade sağlayarak isteğinizi iyileştirmeye yardımcı olur. Buraya bir istek yazıp düğmeye tekrar tıklayarak nasıl çalıştığını görebilirsiniz.",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "{{name}} komutunu tetikle"
 	},
+	"slashCommands": {
+		"tooltip": "Eğik çizgi komutlarını yönet",
+		"title": "Eğik Çizgi Komutları",
+		"description": "Sık kullanılan komut istemleri ve iş akışlarına hızlı erişim için özel eğik çizgi komutları oluşturun.",
+		"globalCommands": "Genel Komutlar",
+		"workspaceCommands": "Çalışma Alanı Komutları",
+		"globalCommand": "Genel komut",
+		"editCommand": "Komutu düzenle",
+		"deleteCommand": "Komutu sil",
+		"newGlobalCommandPlaceholder": "Yeni genel komut...",
+		"newWorkspaceCommandPlaceholder": "Yeni çalışma alanı komutu...",
+		"deleteDialog": {
+			"title": "Komutu Sil",
+			"description": "\"{{name}}\" komutunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
+			"cancel": "İptal",
+			"confirm": "Sil"
+		}
+	},
 	"queuedMessages": {
 		"title": "Sıradaki Mesajlar:",
 		"clickToEdit": "Mesajı düzenlemek için tıkla"

+ 19 - 1
webview-ui/src/i18n/locales/vi/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "Dừng chuyển văn bản thành giọng nói",
 	"typeMessage": "Nhập tin nhắn...",
 	"typeTask": "Nhập nhiệm vụ của bạn tại đây...",
-	"addContext": "@ để thêm ngữ cảnh, / để chuyển chế độ",
+	"addContext": "@ để thêm ngữ cảnh, / cho lệnh",
 	"dragFiles": "giữ shift để kéo tệp",
 	"dragFilesImages": "giữ shift để kéo tệp/hình ảnh",
 	"enhancePromptDescription": "Nút 'Nâng cao yêu cầu' giúp cải thiện yêu cầu của bạn bằng cách cung cấp ngữ cảnh bổ sung, làm rõ hoặc diễn đạt lại. Hãy thử nhập yêu cầu tại đây và nhấp vào nút một lần nữa để xem cách thức hoạt động.",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "Kích hoạt lệnh {{name}}"
 	},
+	"slashCommands": {
+		"tooltip": "Quản lý lệnh gạch chéo",
+		"title": "Lệnh Gạch Chéo",
+		"description": "Tạo lệnh gạch chéo tùy chỉnh để truy cập nhanh vào các lời nhắc và quy trình làm việc thường dùng.",
+		"globalCommands": "Lệnh Toàn Cục",
+		"workspaceCommands": "Lệnh Không Gian Làm Việc",
+		"globalCommand": "Lệnh toàn cục",
+		"editCommand": "Chỉnh sửa lệnh",
+		"deleteCommand": "Xóa lệnh",
+		"newGlobalCommandPlaceholder": "Lệnh toàn cục mới...",
+		"newWorkspaceCommandPlaceholder": "Lệnh không gian làm việc mới...",
+		"deleteDialog": {
+			"title": "Xóa Lệnh",
+			"description": "Bạn có chắc chắn muốn xóa lệnh \"{{name}}\" không? Hành động này không thể hoàn tác.",
+			"cancel": "Hủy",
+			"confirm": "Xóa"
+		}
+	},
 	"queuedMessages": {
 		"title": "Tin nhắn trong hàng đợi:",
 		"clickToEdit": "Nhấp để chỉnh sửa tin nhắn"

+ 19 - 1
webview-ui/src/i18n/locales/zh-CN/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "停止文本转语音",
 	"typeMessage": "输入消息...",
 	"typeTask": "在此处输入您的任务...",
-	"addContext": "@添加上下文,/切换模式",
+	"addContext": "@添加上下文,/输入命令",
 	"dragFiles": "Shift+拖拽文件",
 	"dragFilesImages": "Shift+拖拽文件/图片",
 	"enhancePromptDescription": "'增强提示'按钮通过提供额外上下文、澄清或重新表述来帮助改进您的请求。尝试在此处输入请求,然后再次点击按钮查看其工作原理。",
@@ -352,6 +352,24 @@
 	"editMessage": {
 		"placeholder": "编辑消息..."
 	},
+	"slashCommands": {
+		"tooltip": "管理斜杠命令",
+		"title": "斜杠命令",
+		"description": "创建自定义斜杠命令,快速访问常用提示词和工作流程。",
+		"globalCommands": "全局命令",
+		"workspaceCommands": "工作区命令",
+		"globalCommand": "全局命令",
+		"editCommand": "编辑命令",
+		"deleteCommand": "删除命令",
+		"newGlobalCommandPlaceholder": "新建全局命令...",
+		"newWorkspaceCommandPlaceholder": "新建工作区命令...",
+		"deleteDialog": {
+			"title": "删除命令",
+			"description": "确定要删除命令 \"{{name}}\" 吗?此操作无法撤销。",
+			"cancel": "取消",
+			"confirm": "删除"
+		}
+	},
 	"queuedMessages": {
 		"title": "队列消息:",
 		"clickToEdit": "点击编辑消息"

+ 19 - 1
webview-ui/src/i18n/locales/zh-TW/chat.json

@@ -108,7 +108,7 @@
 	"stopTts": "停止文字轉語音",
 	"typeMessage": "輸入訊息...",
 	"typeTask": "在此處輸入您的工作...",
-	"addContext": "輸入 @ 新增內容,輸入 / 切換模式",
+	"addContext": "輸入 @ 新增內容,輸入 / 執行指令",
 	"dragFiles": "按住 Shift 鍵拖曳檔案",
 	"dragFilesImages": "按住 Shift 鍵拖曳檔案/圖片",
 	"enhancePromptDescription": "「增強提示」按鈕透過提供額外內容、說明或重新表述來幫助改進您的請求。嘗試在此處輸入請求,然後再次點選按鈕以了解其運作方式。",
@@ -352,6 +352,24 @@
 	"command": {
 		"triggerDescription": "觸發 {{name}} 命令"
 	},
+	"slashCommands": {
+		"tooltip": "管理斜線指令",
+		"title": "斜線指令",
+		"description": "建立自訂斜線指令,快速存取常用提示詞和工作流程。",
+		"globalCommands": "全域指令",
+		"workspaceCommands": "工作區指令",
+		"globalCommand": "全域指令",
+		"editCommand": "編輯指令",
+		"deleteCommand": "刪除指令",
+		"newGlobalCommandPlaceholder": "新增全域指令...",
+		"newWorkspaceCommandPlaceholder": "新增工作區指令...",
+		"deleteDialog": {
+			"title": "刪除指令",
+			"description": "確定要刪除指令 \"{{name}}\" 嗎?此動作無法復原。",
+			"cancel": "取消",
+			"confirm": "刪除"
+		}
+	},
 	"queuedMessages": {
 		"title": "佇列訊息:",
 		"clickToEdit": "點擊編輯訊息"

+ 20 - 28
webview-ui/src/utils/__tests__/context-mentions.spec.ts

@@ -194,16 +194,8 @@ describe("getContextMenuOptions", () => {
 		{ path: "/Users/test/project/assets/", type: "folder", label: "assets/" },
 	]
 
-	// Mock translation function
-	const mockT = (key: string, options?: { name?: string }) => {
-		if (key === "chat:command.triggerDescription" && options?.name) {
-			return `Trigger command: ${options.name}`
-		}
-		return key
-	}
-
 	it("should return all option types for empty query", () => {
-		const result = getContextMenuOptions("", "", mockT, null, [])
+		const result = getContextMenuOptions("", "", null, [])
 		expect(result).toHaveLength(6)
 		expect(result.map((item) => item.type)).toEqual([
 			ContextMenuOptionType.Problems,
@@ -216,7 +208,7 @@ describe("getContextMenuOptions", () => {
 	})
 
 	it("should filter by selected type when query is empty", () => {
-		const result = getContextMenuOptions("", "", mockT, ContextMenuOptionType.File, mockQueryItems)
+		const result = getContextMenuOptions("", "", ContextMenuOptionType.File, mockQueryItems)
 		expect(result).toHaveLength(2)
 		expect(result.map((item) => item.type)).toContain(ContextMenuOptionType.File)
 		expect(result.map((item) => item.type)).toContain(ContextMenuOptionType.OpenedFile)
@@ -225,19 +217,19 @@ describe("getContextMenuOptions", () => {
 	})
 
 	it("should match git commands", () => {
-		const result = getContextMenuOptions("git", "git", mockT, null, mockQueryItems)
+		const result = getContextMenuOptions("git", "git", null, mockQueryItems)
 		expect(result[0].type).toBe(ContextMenuOptionType.Git)
 		expect(result[0].label).toBe("Git Commits")
 	})
 
 	it("should match git commit hashes", () => {
-		const result = getContextMenuOptions("abc1234", "abc1234", mockT, null, mockQueryItems)
+		const result = getContextMenuOptions("abc1234", "abc1234", null, mockQueryItems)
 		expect(result[0].type).toBe(ContextMenuOptionType.Git)
 		expect(result[0].value).toBe("abc1234")
 	})
 
 	it("should return NoResults when no matches found", () => {
-		const result = getContextMenuOptions("nonexistent", "nonexistent", mockT, null, mockQueryItems)
+		const result = getContextMenuOptions("nonexistent", "nonexistent", null, mockQueryItems)
 		expect(result).toHaveLength(1)
 		expect(result[0].type).toBe(ContextMenuOptionType.NoResults)
 	})
@@ -258,7 +250,7 @@ describe("getContextMenuOptions", () => {
 			},
 		]
 
-		const result = getContextMenuOptions("test", "test", mockT, null, testItems, mockDynamicSearchResults)
+		const result = getContextMenuOptions("test", "test", null, testItems, mockDynamicSearchResults)
 
 		// Check if opened files and dynamic search results are included
 		expect(result.some((item) => item.type === ContextMenuOptionType.OpenedFile)).toBe(true)
@@ -267,7 +259,7 @@ describe("getContextMenuOptions", () => {
 
 	it("should maintain correct result ordering according to implementation", () => {
 		// Add multiple item types to test ordering
-		const result = getContextMenuOptions("t", "t", mockT, null, mockQueryItems, mockDynamicSearchResults)
+		const result = getContextMenuOptions("t", "t", null, mockQueryItems, mockDynamicSearchResults)
 
 		// Find the different result types
 		const fileResults = result.filter(
@@ -298,7 +290,7 @@ describe("getContextMenuOptions", () => {
 	})
 
 	it("should include opened files when dynamic search results exist", () => {
-		const result = getContextMenuOptions("open", "open", mockT, null, mockQueryItems, mockDynamicSearchResults)
+		const result = getContextMenuOptions("open", "open", null, mockQueryItems, mockDynamicSearchResults)
 
 		// Verify opened files are included
 		expect(result.some((item) => item.type === ContextMenuOptionType.OpenedFile)).toBe(true)
@@ -307,7 +299,7 @@ describe("getContextMenuOptions", () => {
 	})
 
 	it("should include git results when dynamic search results exist", () => {
-		const result = getContextMenuOptions("commit", "commit", mockT, null, mockQueryItems, mockDynamicSearchResults)
+		const result = getContextMenuOptions("commit", "commit", null, mockQueryItems, mockDynamicSearchResults)
 
 		// Verify git results are included
 		expect(result.some((item) => item.type === ContextMenuOptionType.Git)).toBe(true)
@@ -328,7 +320,7 @@ describe("getContextMenuOptions", () => {
 			},
 		]
 
-		const result = getContextMenuOptions("test", "test", mockT, null, mockQueryItems, duplicateSearchResults)
+		const result = getContextMenuOptions("test", "test", null, mockQueryItems, duplicateSearchResults)
 
 		// Count occurrences of src/test.ts in results
 		const duplicateCount = result.filter(
@@ -348,7 +340,6 @@ describe("getContextMenuOptions", () => {
 		const result = getContextMenuOptions(
 			"nonexistentquery123456",
 			"nonexistentquery123456",
-			mockT,
 			null,
 			mockQueryItems,
 			[], // Empty dynamic search results
@@ -396,7 +387,7 @@ describe("getContextMenuOptions", () => {
 		]
 
 		// Get results for "test" query
-		const result = getContextMenuOptions(testQuery, testQuery, mockT, null, testItems, testSearchResults)
+		const result = getContextMenuOptions(testQuery, testQuery, null, testItems, testSearchResults)
 
 		// Verify we have results
 		expect(result.length).toBeGreaterThan(0)
@@ -442,17 +433,18 @@ describe("getContextMenuOptions", () => {
 			},
 		]
 
-		const result = getContextMenuOptions("/co", "/co", mockT, null, [], [], mockModes)
+		const result = getContextMenuOptions("/co", "/co", null, [], [], mockModes)
 
-		// Verify mode results are returned
-		expect(result[0].type).toBe(ContextMenuOptionType.Mode)
-		expect(result[0].value).toBe("code")
+		// Should have section header first, then mode results
+		expect(result[0].type).toBe(ContextMenuOptionType.SectionHeader)
+		expect(result[1].type).toBe(ContextMenuOptionType.Mode)
+		expect(result[1].value).toBe("code")
 	})
 
 	it("should not process slash commands when query starts with slash but inputValue doesn't", () => {
 		// Use a completely non-matching query to ensure we get NoResults
 		// and provide empty query items to avoid any matches
-		const result = getContextMenuOptions("/nonexistentquery", "Hello /code", mockT, null, [], [])
+		const result = getContextMenuOptions("/nonexistentquery", "Hello /code", null, [], [])
 
 		// Should not process as a mode command
 		expect(result[0].type).not.toBe(ContextMenuOptionType.Mode)
@@ -462,7 +454,7 @@ describe("getContextMenuOptions", () => {
 
 	// --- Tests for Escaped Spaces (Focus on how paths are presented) ---
 	it("should return search results with correct labels/descriptions (no escaping needed here)", () => {
-		const options = getContextMenuOptions("@search", "search", mockT, null, mockQueryItems, mockSearchResults)
+		const options = getContextMenuOptions("@search", "search", null, mockQueryItems, mockSearchResults)
 		const fileResult = options.find((o) => o.label === "search result spaces.ts")
 		expect(fileResult).toBeDefined()
 		// Value should be the normalized path, description might be the same or label
@@ -475,7 +467,7 @@ describe("getContextMenuOptions", () => {
 	})
 
 	it("should return query items (like opened files) with correct labels/descriptions", () => {
-		const options = getContextMenuOptions("open", "@open", mockT, null, mockQueryItems, [])
+		const options = getContextMenuOptions("open", "@open", null, mockQueryItems, [])
 		const openedFile = options.find((o) => o.label === "open file.ts")
 		expect(openedFile).toBeDefined()
 		expect(openedFile?.value).toBe("src/open file.ts")
@@ -492,7 +484,7 @@ describe("getContextMenuOptions", () => {
 		]
 
 		// The formatting happens in getContextMenuOptions when converting search results to menu items
-		const formattedItems = getContextMenuOptions("spaces", "@spaces", mockT, null, [], searchResults)
+		const formattedItems = getContextMenuOptions("spaces", "@spaces", null, [], searchResults)
 
 		// Verify we get some results back that aren't "No Results"
 		expect(formattedItems.length).toBeGreaterThan(0)

+ 48 - 38
webview-ui/src/utils/context-mentions.ts

@@ -107,6 +107,7 @@ export enum ContextMenuOptionType {
 	NoResults = "noResults",
 	Mode = "mode", // Add mode type
 	Command = "command", // Add command type
+	SectionHeader = "sectionHeader", // Add section header type
 }
 
 export interface ContextMenuQueryItem {
@@ -115,12 +116,13 @@ export interface ContextMenuQueryItem {
 	label?: string
 	description?: string
 	icon?: string
+	slashCommand?: string
+	secondaryText?: string
 }
 
 export function getContextMenuOptions(
 	query: string,
 	inputValue: string,
-	t: (key: string, options?: { name?: string }) => string,
 	selectedType: ContextMenuOptionType | null = null,
 	queryItems: ContextMenuQueryItem[],
 	dynamicSearchResults: SearchResult[] = [],
@@ -132,7 +134,42 @@ export function getContextMenuOptions(
 		const slashQuery = query.slice(1)
 		const results: ContextMenuQueryItem[] = []
 
-		// Add mode suggestions
+		// Add command suggestions first (prioritize commands at the top)
+		if (commands?.length) {
+			// Create searchable strings array for fzf
+			const searchableCommands = commands.map((command) => ({
+				original: command,
+				searchStr: command.name,
+			}))
+
+			// Initialize fzf instance for fuzzy search
+			const fzf = new Fzf(searchableCommands, {
+				selector: (item) => item.searchStr,
+			})
+
+			// Get fuzzy matching commands
+			const matchingCommands = slashQuery
+				? fzf.find(slashQuery).map((result) => ({
+						type: ContextMenuOptionType.Command,
+						value: result.item.original.name,
+						slashCommand: `/${result.item.original.name}`,
+					}))
+				: commands.map((command) => ({
+						type: ContextMenuOptionType.Command,
+						value: command.name,
+						slashCommand: `/${command.name}`,
+					}))
+
+			if (matchingCommands.length > 0) {
+				results.push({
+					type: ContextMenuOptionType.SectionHeader,
+					label: "Custom Commands",
+				})
+				results.push(...matchingCommands)
+			}
+		}
+
+		// Add mode suggestions second
 		if (modes?.length) {
 			// Create searchable strings array for fzf
 			const searchableItems = modes.map((mode) => ({
@@ -150,50 +187,23 @@ export function getContextMenuOptions(
 				? fzf.find(slashQuery).map((result) => ({
 						type: ContextMenuOptionType.Mode,
 						value: result.item.original.slug,
-						label: result.item.original.name,
+						slashCommand: `/${result.item.original.slug}`,
 						description: getModeDescription(result.item.original),
 					}))
 				: modes.map((mode) => ({
 						type: ContextMenuOptionType.Mode,
 						value: mode.slug,
-						label: mode.name,
+						slashCommand: `/${mode.slug}`,
 						description: getModeDescription(mode),
 					}))
 
-			results.push(...matchingModes)
-		}
-
-		// Add command suggestions
-		if (commands?.length) {
-			// Create searchable strings array for fzf
-			const searchableCommands = commands.map((command) => ({
-				original: command,
-				searchStr: command.name,
-			}))
-
-			// Initialize fzf instance for fuzzy search
-			const fzf = new Fzf(searchableCommands, {
-				selector: (item) => item.searchStr,
-			})
-
-			// Get fuzzy matching commands
-			const matchingCommands = slashQuery
-				? fzf.find(slashQuery).map((result) => ({
-						type: ContextMenuOptionType.Command,
-						value: result.item.original.name,
-						label: result.item.original.name,
-						description: t("chat:command.triggerDescription", { name: result.item.original.name }),
-						icon: "$(play)",
-					}))
-				: commands.map((command) => ({
-						type: ContextMenuOptionType.Command,
-						value: command.name,
-						label: command.name,
-						description: t("chat:command.triggerDescription", { name: command.name }),
-						icon: "$(play)",
-					}))
-
-			results.push(...matchingCommands)
+			if (matchingModes.length > 0) {
+				results.push({
+					type: ContextMenuOptionType.SectionHeader,
+					label: "Modes",
+				})
+				results.push(...matchingModes)
+			}
 		}
 
 		return results.length > 0 ? results : [{ type: ContextMenuOptionType.NoResults }]