mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
Support for custom slash commands (#6263)
This commit is contained in:
parent
43f649b03f
commit
5d4e3819e4
33 changed files with 1171 additions and 49 deletions
56
src/__tests__/command-integration.spec.ts
Normal file
56
src/__tests__/command-integration.spec.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { getCommands, getCommand, getCommandNames } from "../services/command/commands"
|
||||
import * as path from "path"
|
||||
|
||||
describe("Command Integration Tests", () => {
|
||||
const testWorkspaceDir = path.join(__dirname, "../../")
|
||||
|
||||
it("should discover command files in .roo/commands/", async () => {
|
||||
const commands = await getCommands(testWorkspaceDir)
|
||||
|
||||
// Should be able to discover commands (may be empty in test environment)
|
||||
expect(Array.isArray(commands)).toBe(true)
|
||||
|
||||
// If commands exist, verify they have valid properties
|
||||
commands.forEach((command) => {
|
||||
expect(command.name).toBeDefined()
|
||||
expect(typeof command.name).toBe("string")
|
||||
expect(command.source).toMatch(/^(project|global)$/)
|
||||
expect(command.content).toBeDefined()
|
||||
expect(typeof command.content).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
it("should return command names correctly", async () => {
|
||||
const commandNames = await getCommandNames(testWorkspaceDir)
|
||||
|
||||
// Should return an array (may be empty in test environment)
|
||||
expect(Array.isArray(commandNames)).toBe(true)
|
||||
|
||||
// If command names exist, they should be strings
|
||||
commandNames.forEach((name) => {
|
||||
expect(typeof name).toBe("string")
|
||||
expect(name.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it("should load command content if commands exist", async () => {
|
||||
const commands = await getCommands(testWorkspaceDir)
|
||||
|
||||
if (commands.length > 0) {
|
||||
const firstCommand = commands[0]
|
||||
const loadedCommand = await getCommand(testWorkspaceDir, firstCommand.name)
|
||||
|
||||
expect(loadedCommand).toBeDefined()
|
||||
expect(loadedCommand?.name).toBe(firstCommand.name)
|
||||
expect(loadedCommand?.source).toMatch(/^(project|global)$/)
|
||||
expect(loadedCommand?.content).toBeDefined()
|
||||
expect(typeof loadedCommand?.content).toBe("string")
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle non-existent commands gracefully", async () => {
|
||||
const nonExistentCommand = await getCommand(testWorkspaceDir, "non-existent-command")
|
||||
expect(nonExistentCommand).toBeUndefined()
|
||||
})
|
||||
})
|
||||
307
src/__tests__/command-mentions.spec.ts
Normal file
307
src/__tests__/command-mentions.spec.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { parseMentions } from "../core/mentions"
|
||||
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
|
||||
import { getCommand } from "../services/command/commands"
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("../services/command/commands")
|
||||
vi.mock("../services/browser/UrlContentFetcher")
|
||||
|
||||
const MockedUrlContentFetcher = vi.mocked(UrlContentFetcher)
|
||||
const mockGetCommand = vi.mocked(getCommand)
|
||||
|
||||
describe("Command Mentions", () => {
|
||||
let mockUrlContentFetcher: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a mock UrlContentFetcher instance
|
||||
mockUrlContentFetcher = {
|
||||
launchBrowser: vi.fn(),
|
||||
urlToMarkdown: vi.fn(),
|
||||
closeBrowser: vi.fn(),
|
||||
}
|
||||
|
||||
MockedUrlContentFetcher.mockImplementation(() => mockUrlContentFetcher)
|
||||
})
|
||||
|
||||
// Helper function to call parseMentions with required parameters
|
||||
const callParseMentions = async (text: string) => {
|
||||
return await parseMentions(
|
||||
text,
|
||||
"/test/cwd", // cwd
|
||||
mockUrlContentFetcher, // urlContentFetcher
|
||||
undefined, // fileContextTracker
|
||||
undefined, // rooIgnoreController
|
||||
true, // showRooIgnoredFiles
|
||||
true, // includeDiagnosticMessages
|
||||
50, // maxDiagnosticMessages
|
||||
undefined, // maxReadFileLine
|
||||
)
|
||||
}
|
||||
|
||||
describe("parseMentions with command support", () => {
|
||||
it("should parse command mentions and include content", async () => {
|
||||
const commandContent = "# Setup Environment\n\nRun the following commands:\n```bash\nnpm install\n```"
|
||||
mockGetCommand.mockResolvedValue({
|
||||
name: "setup",
|
||||
content: commandContent,
|
||||
source: "project",
|
||||
filePath: "/project/.roo/commands/setup.md",
|
||||
})
|
||||
|
||||
const input = "/setup Please help me set up the project"
|
||||
const result = await callParseMentions(input)
|
||||
|
||||
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
|
||||
expect(result).toContain('<command name="setup">')
|
||||
expect(result).toContain(commandContent)
|
||||
expect(result).toContain("</command>")
|
||||
expect(result).toContain("Please help me set up the project")
|
||||
})
|
||||
|
||||
it("should handle multiple commands in message", async () => {
|
||||
mockGetCommand
|
||||
.mockResolvedValueOnce({
|
||||
name: "setup",
|
||||
content: "# Setup instructions",
|
||||
source: "project",
|
||||
filePath: "/project/.roo/commands/setup.md",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
name: "deploy",
|
||||
content: "# Deploy instructions",
|
||||
source: "project",
|
||||
filePath: "/project/.roo/commands/deploy.md",
|
||||
})
|
||||
|
||||
// Both commands should be recognized
|
||||
const input = "/setup the project\nThen /deploy later"
|
||||
const result = await callParseMentions(input)
|
||||
|
||||
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
|
||||
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "deploy")
|
||||
expect(mockGetCommand).toHaveBeenCalledTimes(2) // Both commands called
|
||||
expect(result).toContain('<command name="setup">')
|
||||
expect(result).toContain("# Setup instructions")
|
||||
expect(result).toContain('<command name="deploy">')
|
||||
expect(result).toContain("# Deploy instructions")
|
||||
})
|
||||
|
||||
it("should handle non-existent command gracefully", async () => {
|
||||
mockGetCommand.mockResolvedValue(undefined)
|
||||
|
||||
const input = "/nonexistent command"
|
||||
const result = await callParseMentions(input)
|
||||
|
||||
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "nonexistent")
|
||||
expect(result).toContain('<command name="nonexistent">')
|
||||
expect(result).toContain("Command 'nonexistent' not found")
|
||||
expect(result).toContain("</command>")
|
||||
})
|
||||
|
||||
it("should handle command loading errors", async () => {
|
||||
mockGetCommand.mockRejectedValue(new Error("Failed to load command"))
|
||||
|
||||
const input = "/error-command test"
|
||||
const result = await callParseMentions(input)
|
||||
|
||||
expect(result).toContain('<command name="error-command">')
|
||||
expect(result).toContain("Error loading command")
|
||||
expect(result).toContain("</command>")
|
||||
})
|
||||
|
||||
it("should handle command names with hyphens and underscores at start", async () => {
|
||||
mockGetCommand.mockResolvedValue({
|
||||
name: "setup-dev",
|
||||
content: "# Dev setup",
|
||||
source: "project",
|
||||
filePath: "/project/.roo/commands/setup-dev.md",
|
||||
})
|
||||
|
||||
const input = "/setup-dev for the project"
|
||||
const result = await callParseMentions(input)
|
||||
|
||||
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup-dev")
|
||||
expect(result).toContain('<command name="setup-dev">')
|
||||
expect(result).toContain("# Dev setup")
|
||||
})
|
||||
|
||||
it("should preserve command content formatting", async () => {
|
||||
const commandContent = `# Complex Command
|
||||
|
||||
## Step 1
|
||||
Run this command:
|
||||
\`\`\`bash
|
||||
npm install
|
||||
\`\`\`
|
||||
|
||||
## Step 2
|
||||
- Check file1.js
|
||||
- Update file2.ts
|
||||
- Test everything
|
||||
|
||||
> **Note**: This is important!`
|
||||
|
||||
mockGetCommand.mockResolvedValue({
|
||||
name: "complex",
|
||||
content: commandContent,
|
||||
source: "project",
|
||||
filePath: "/project/.roo/commands/complex.md",
|
||||
})
|
||||
|
||||
const input = "/complex command"
|
||||
const result = await callParseMentions(input)
|
||||
|
||||
expect(result).toContain('<command name="complex">')
|
||||
expect(result).toContain("# Complex Command")
|
||||
expect(result).toContain("```bash")
|
||||
expect(result).toContain("npm install")
|
||||
expect(result).toContain("- Check file1.js")
|
||||
expect(result).toContain("> **Note**: This is important!")
|
||||
expect(result).toContain("</command>")
|
||||
})
|
||||
|
||||
it("should handle empty command content", async () => {
|
||||
mockGetCommand.mockResolvedValue({
|
||||
name: "empty",
|
||||
content: "",
|
||||
source: "project",
|
||||
filePath: "/project/.roo/commands/empty.md",
|
||||
})
|
||||
|
||||
const input = "/empty command"
|
||||
const result = await callParseMentions(input)
|
||||
|
||||
expect(result).toContain('<command name="empty">')
|
||||
expect(result).toContain("</command>")
|
||||
// Should still include the command tags even with empty content
|
||||
})
|
||||
})
|
||||
|
||||
describe("command mention regex patterns", () => {
|
||||
it("should match valid command mention patterns anywhere", () => {
|
||||
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
|
||||
|
||||
const validPatterns = ["/setup", "/build-prod", "/test_suite", "/my-command", "/command123"]
|
||||
|
||||
validPatterns.forEach((pattern) => {
|
||||
const match = pattern.match(commandRegex)
|
||||
expect(match).toBeTruthy()
|
||||
expect(match![0]).toBe(pattern)
|
||||
})
|
||||
})
|
||||
|
||||
it("should match command patterns in middle of text", () => {
|
||||
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
|
||||
|
||||
const validPatterns = ["Please /setup", "Run /build now", "Use /deploy here"]
|
||||
|
||||
validPatterns.forEach((pattern) => {
|
||||
const match = pattern.match(commandRegex)
|
||||
expect(match).toBeTruthy()
|
||||
expect(match![0]).toMatch(/^\/[a-zA-Z0-9_\.-]+$/)
|
||||
})
|
||||
})
|
||||
|
||||
it("should match commands at start of new lines", () => {
|
||||
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
|
||||
|
||||
const multilineText = "First line\n/setup the project\nAnother line\n/deploy when ready"
|
||||
const matches = multilineText.match(commandRegex)
|
||||
|
||||
// Should match both commands now
|
||||
expect(matches).toBeTruthy()
|
||||
expect(matches).toHaveLength(2)
|
||||
expect(matches![0]).toBe("/setup")
|
||||
expect(matches![1]).toBe("/deploy")
|
||||
})
|
||||
|
||||
it("should match multiple commands in message", () => {
|
||||
const commandRegex = /(?:^|\s)\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
|
||||
|
||||
const validText = "/setup the project\nThen /deploy later"
|
||||
const matches = validText.match(commandRegex)
|
||||
|
||||
expect(matches).toBeTruthy()
|
||||
expect(matches).toHaveLength(2)
|
||||
expect(matches![0]).toBe("/setup")
|
||||
expect(matches![1]).toBe(" /deploy") // Note: includes leading space
|
||||
})
|
||||
|
||||
it("should not match invalid command patterns", () => {
|
||||
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
|
||||
|
||||
const invalidPatterns = ["/ space", "/with space", "/with/slash", "//double", "/with@symbol"]
|
||||
|
||||
invalidPatterns.forEach((pattern) => {
|
||||
const match = pattern.match(commandRegex)
|
||||
if (match) {
|
||||
// If it matches, it should not be the full invalid pattern
|
||||
expect(match[0]).not.toBe(pattern)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("command mention text transformation", () => {
|
||||
it("should transform command mentions at start of message", async () => {
|
||||
const input = "/setup the project"
|
||||
const result = await callParseMentions(input)
|
||||
|
||||
expect(result).toContain("Command 'setup' (see below for command content)")
|
||||
})
|
||||
|
||||
it("should process multiple commands in message", async () => {
|
||||
mockGetCommand
|
||||
.mockResolvedValueOnce({
|
||||
name: "setup",
|
||||
content: "# Setup instructions",
|
||||
source: "project",
|
||||
filePath: "/project/.roo/commands/setup.md",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
name: "deploy",
|
||||
content: "# Deploy instructions",
|
||||
source: "project",
|
||||
filePath: "/project/.roo/commands/deploy.md",
|
||||
})
|
||||
|
||||
const input = "/setup the project\nThen /deploy later"
|
||||
const result = await callParseMentions(input)
|
||||
|
||||
expect(result).toContain("Command 'setup' (see below for command content)")
|
||||
expect(result).toContain("Command 'deploy' (see below for command content)")
|
||||
})
|
||||
|
||||
it("should match commands anywhere with proper word boundaries", async () => {
|
||||
mockGetCommand.mockResolvedValue({
|
||||
name: "build",
|
||||
content: "# Build instructions",
|
||||
source: "project",
|
||||
filePath: "/project/.roo/commands/build.md",
|
||||
})
|
||||
|
||||
// At the beginning - should match
|
||||
let input = "/build the project"
|
||||
let result = await callParseMentions(input)
|
||||
expect(result).toContain("Command 'build'")
|
||||
|
||||
// After space - should match
|
||||
input = "Please /build and test"
|
||||
result = await callParseMentions(input)
|
||||
expect(result).toContain("Command 'build'")
|
||||
|
||||
// At the end - should match
|
||||
input = "Run the /build"
|
||||
result = await callParseMentions(input)
|
||||
expect(result).toContain("Command 'build'")
|
||||
|
||||
// At start of new line - should match
|
||||
input = "Some text\n/build the project"
|
||||
result = await callParseMentions(input)
|
||||
expect(result).toContain("Command 'build'")
|
||||
})
|
||||
})
|
||||
})
|
||||
97
src/__tests__/commands.spec.ts
Normal file
97
src/__tests__/commands.spec.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
getCommands,
|
||||
getCommand,
|
||||
getCommandNames,
|
||||
getCommandNameFromFile,
|
||||
isMarkdownFile,
|
||||
} from "../services/command/commands"
|
||||
|
||||
describe("Command Utilities", () => {
|
||||
const testCwd = "/test/project"
|
||||
|
||||
describe("getCommandNameFromFile", () => {
|
||||
it("should strip .md extension only", () => {
|
||||
expect(getCommandNameFromFile("my-command.md")).toBe("my-command")
|
||||
expect(getCommandNameFromFile("test.txt")).toBe("test.txt")
|
||||
expect(getCommandNameFromFile("no-extension")).toBe("no-extension")
|
||||
expect(getCommandNameFromFile("multiple.dots.file.md")).toBe("multiple.dots.file")
|
||||
expect(getCommandNameFromFile("api.config.md")).toBe("api.config")
|
||||
expect(getCommandNameFromFile("deploy_prod.md")).toBe("deploy_prod")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isMarkdownFile", () => {
|
||||
it("should identify markdown files correctly", () => {
|
||||
// Markdown files
|
||||
expect(isMarkdownFile("command.md")).toBe(true)
|
||||
expect(isMarkdownFile("my-command.md")).toBe(true)
|
||||
expect(isMarkdownFile("README.MD")).toBe(true)
|
||||
expect(isMarkdownFile("test.Md")).toBe(true)
|
||||
|
||||
// Non-markdown files
|
||||
expect(isMarkdownFile("command.txt")).toBe(false)
|
||||
expect(isMarkdownFile("script.sh")).toBe(false)
|
||||
expect(isMarkdownFile("config.json")).toBe(false)
|
||||
expect(isMarkdownFile("no-extension")).toBe(false)
|
||||
expect(isMarkdownFile("file.md.bak")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCommands", () => {
|
||||
it("should return empty array when no command directories exist", async () => {
|
||||
// This will fail to find directories but should return empty array gracefully
|
||||
const commands = await getCommands(testCwd)
|
||||
expect(Array.isArray(commands)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCommandNames", () => {
|
||||
it("should return empty array when no commands exist", async () => {
|
||||
const names = await getCommandNames(testCwd)
|
||||
expect(Array.isArray(names)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCommand", () => {
|
||||
it("should return undefined for non-existent command", async () => {
|
||||
const result = await getCommand(testCwd, "non-existent")
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("command name extraction edge cases", () => {
|
||||
it("should handle various filename formats", () => {
|
||||
// Files without extensions
|
||||
expect(getCommandNameFromFile("command")).toBe("command")
|
||||
expect(getCommandNameFromFile("my-command")).toBe("my-command")
|
||||
|
||||
// Files with multiple dots - only strip .md extension
|
||||
expect(getCommandNameFromFile("my.complex.command.md")).toBe("my.complex.command")
|
||||
expect(getCommandNameFromFile("v1.2.3.txt")).toBe("v1.2.3.txt")
|
||||
|
||||
// Edge cases
|
||||
expect(getCommandNameFromFile(".")).toBe(".")
|
||||
expect(getCommandNameFromFile("..")).toBe("..")
|
||||
expect(getCommandNameFromFile(".hidden.md")).toBe(".hidden")
|
||||
})
|
||||
})
|
||||
|
||||
describe("command loading behavior", () => {
|
||||
it("should handle multiple calls to getCommands", async () => {
|
||||
const commands1 = await getCommands(testCwd)
|
||||
const commands2 = await getCommands(testCwd)
|
||||
expect(Array.isArray(commands1)).toBe(true)
|
||||
expect(Array.isArray(commands2)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle invalid command names gracefully", async () => {
|
||||
// These should not throw errors
|
||||
expect(await getCommand(testCwd, "")).toBeUndefined()
|
||||
expect(await getCommand(testCwd, " ")).toBeUndefined()
|
||||
expect(await getCommand(testCwd, "non/existent/path")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -4,7 +4,7 @@ import * as path from "path"
|
|||
import * as vscode from "vscode"
|
||||
import { isBinaryFile } from "isbinaryfile"
|
||||
|
||||
import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
|
||||
import { mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
|
||||
|
||||
import { getCommitInfo, getWorkingState } from "../../utils/git"
|
||||
import { getWorkspacePath } from "../../utils/path"
|
||||
|
|
@ -18,6 +18,7 @@ import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
|
|||
import { FileContextTracker } from "../context-tracking/FileContextTracker"
|
||||
|
||||
import { RooIgnoreController } from "../ignore/RooIgnoreController"
|
||||
import { getCommand } from "../../services/command/commands"
|
||||
|
||||
import { t } from "../../i18n"
|
||||
|
||||
|
|
@ -85,7 +86,16 @@ export async function parseMentions(
|
|||
maxReadFileLine?: number,
|
||||
): Promise<string> {
|
||||
const mentions: Set<string> = new Set()
|
||||
let parsedText = text.replace(mentionRegexGlobal, (match, mention) => {
|
||||
const commandMentions: Set<string> = new Set()
|
||||
|
||||
// First pass: extract command mentions (starting with /)
|
||||
let parsedText = text.replace(commandRegexGlobal, (match, commandName) => {
|
||||
commandMentions.add(commandName)
|
||||
return `Command '${commandName}' (see below for command content)`
|
||||
})
|
||||
|
||||
// Second pass: handle regular mentions
|
||||
parsedText = parsedText.replace(mentionRegexGlobal, (match, mention) => {
|
||||
mentions.add(mention)
|
||||
if (mention.startsWith("http")) {
|
||||
return `'${mention}' (see below for site content)`
|
||||
|
|
@ -203,6 +213,20 @@ export async function parseMentions(
|
|||
}
|
||||
}
|
||||
|
||||
// Process command mentions
|
||||
for (const commandName of commandMentions) {
|
||||
try {
|
||||
const command = await getCommand(cwd, commandName)
|
||||
if (command) {
|
||||
parsedText += `\n\n<command name="${commandName}">\n${command.content}\n</command>`
|
||||
} else {
|
||||
parsedText += `\n\n<command name="${commandName}">\nCommand '${commandName}' not found. Available commands can be found in .roo/commands/ or ~/.roo/commands/\n</command>`
|
||||
}
|
||||
} catch (error) {
|
||||
parsedText += `\n\n<command name="${commandName}">\nError loading command '${commandName}': ${error.message}\n</command>`
|
||||
}
|
||||
}
|
||||
|
||||
if (urlMention) {
|
||||
try {
|
||||
await urlContentFetcher.closeBrowser()
|
||||
|
|
|
|||
|
|
@ -2356,5 +2356,30 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
break
|
||||
}
|
||||
case "requestCommands": {
|
||||
try {
|
||||
const { getCommands } = await import("../../services/command/commands")
|
||||
const commands = await getCommands(provider.cwd || "")
|
||||
|
||||
// Convert to the format expected by the frontend
|
||||
const commandList = commands.map((command) => ({
|
||||
name: command.name,
|
||||
source: command.source,
|
||||
}))
|
||||
|
||||
await provider.postMessageToWebview({
|
||||
type: "commands",
|
||||
commands: commandList,
|
||||
})
|
||||
} catch (error) {
|
||||
provider.log(`Error fetching commands: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
|
||||
// Send empty array on error
|
||||
await provider.postMessageToWebview({
|
||||
type: "commands",
|
||||
commands: [],
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
150
src/services/command/commands.ts
Normal file
150
src/services/command/commands.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"
|
||||
|
||||
export interface Command {
|
||||
name: string
|
||||
content: string
|
||||
source: "global" | "project"
|
||||
filePath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available commands from both global and project directories
|
||||
*/
|
||||
export async function getCommands(cwd: string): Promise<Command[]> {
|
||||
const commands = new Map<string, Command>()
|
||||
|
||||
// Scan global commands first
|
||||
const globalDir = path.join(getGlobalRooDirectory(), "commands")
|
||||
await scanCommandDirectory(globalDir, "global", commands)
|
||||
|
||||
// Scan project commands (these override global ones)
|
||||
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "commands")
|
||||
await scanCommandDirectory(projectDir, "project", commands)
|
||||
|
||||
return Array.from(commands.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific command by name (optimized to avoid scanning all commands)
|
||||
*/
|
||||
export async function getCommand(cwd: string, name: string): Promise<Command | undefined> {
|
||||
// Try to find the command directly without scanning all commands
|
||||
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "commands")
|
||||
const globalDir = path.join(getGlobalRooDirectory(), "commands")
|
||||
|
||||
// Check project directory first (project commands override global ones)
|
||||
const projectCommand = await tryLoadCommand(projectDir, name, "project")
|
||||
if (projectCommand) {
|
||||
return projectCommand
|
||||
}
|
||||
|
||||
// Check global directory if not found in project
|
||||
const globalCommand = await tryLoadCommand(globalDir, name, "global")
|
||||
return globalCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to load a specific command from a directory
|
||||
*/
|
||||
async function tryLoadCommand(
|
||||
dirPath: string,
|
||||
name: string,
|
||||
source: "global" | "project",
|
||||
): Promise<Command | undefined> {
|
||||
try {
|
||||
const stats = await fs.stat(dirPath)
|
||||
if (!stats.isDirectory()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Try to find the command file directly
|
||||
const commandFileName = `${name}.md`
|
||||
const filePath = path.join(dirPath, commandFileName)
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
return {
|
||||
name,
|
||||
content: content.trim(),
|
||||
source,
|
||||
filePath,
|
||||
}
|
||||
} catch (error) {
|
||||
// File doesn't exist or can't be read
|
||||
return undefined
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory doesn't exist or can't be read
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get command names for autocomplete
|
||||
*/
|
||||
export async function getCommandNames(cwd: string): Promise<string[]> {
|
||||
const commands = await getCommands(cwd)
|
||||
return commands.map((cmd) => cmd.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a specific command directory
|
||||
*/
|
||||
async function scanCommandDirectory(
|
||||
dirPath: string,
|
||||
source: "global" | "project",
|
||||
commands: Map<string, Command>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const stats = await fs.stat(dirPath)
|
||||
if (!stats.isDirectory()) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && isMarkdownFile(entry.name)) {
|
||||
const filePath = path.join(dirPath, entry.name)
|
||||
const commandName = getCommandNameFromFile(entry.name)
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
|
||||
// Project commands override global ones
|
||||
if (source === "project" || !commands.has(commandName)) {
|
||||
commands.set(commandName, {
|
||||
name: commandName,
|
||||
content: content.trim(),
|
||||
source,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read command file ${filePath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory doesn't exist or can't be read - this is fine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract command name from filename (strip .md extension only)
|
||||
*/
|
||||
export function getCommandNameFromFile(filename: string): string {
|
||||
if (filename.toLowerCase().endsWith(".md")) {
|
||||
return filename.slice(0, -3)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a markdown file
|
||||
*/
|
||||
export function isMarkdownFile(filename: string): boolean {
|
||||
return filename.toLowerCase().endsWith(".md")
|
||||
}
|
||||
|
|
@ -19,6 +19,12 @@ import { Mode } from "./modes"
|
|||
import { RouterModels } from "./api"
|
||||
import type { MarketplaceItem } from "@roo-code/types"
|
||||
|
||||
// Command interface for frontend/backend communication
|
||||
export interface Command {
|
||||
name: string
|
||||
source: "global" | "project"
|
||||
}
|
||||
|
||||
// Type for marketplace installed metadata
|
||||
export interface MarketplaceInstalledMetadata {
|
||||
project: Record<string, { type: string }>
|
||||
|
|
@ -109,6 +115,7 @@ export interface ExtensionMessage {
|
|||
| "codeIndexSecretStatus"
|
||||
| "showDeleteMessageDialog"
|
||||
| "showEditMessageDialog"
|
||||
| "commands"
|
||||
text?: string
|
||||
payload?: any // Add a generic payload for now, can refine later
|
||||
action?:
|
||||
|
|
@ -180,6 +187,7 @@ export interface ExtensionMessage {
|
|||
settings?: any
|
||||
messageTs?: number
|
||||
context?: string
|
||||
commands?: Command[]
|
||||
}
|
||||
|
||||
export type ExtensionState = Pick<
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ export interface WebviewMessage {
|
|||
| "checkRulesDirectoryResult"
|
||||
| "saveCodeIndexSettingsAtomic"
|
||||
| "requestCodeIndexSecretStatus"
|
||||
| "requestCommands"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ export const mentionRegex =
|
|||
/(?<!\\)@((?:\/|\w+:\/\/)(?:[^\s\\]|\\ )+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
|
||||
export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")
|
||||
|
||||
// Regex to match command mentions like /command-name anywhere in text
|
||||
export const commandRegexGlobal = /(?:^|\s)\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
|
||||
|
||||
export interface MentionSuggestion {
|
||||
type: "file" | "folder" | "git" | "problems"
|
||||
label: string
|
||||
|
|
|
|||
271
webview-ui/src/__tests__/command-autocomplete.spec.ts
Normal file
271
webview-ui/src/__tests__/command-autocomplete.spec.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { getContextMenuOptions, ContextMenuOptionType } from "../utils/context-mentions"
|
||||
import type { Command } from "@roo/ExtensionMessage"
|
||||
|
||||
describe("Command Autocomplete", () => {
|
||||
const mockCommands: Command[] = [
|
||||
{ name: "setup", source: "project" },
|
||||
{ name: "build", source: "project" },
|
||||
{ name: "deploy", source: "global" },
|
||||
{ name: "test-suite", source: "project" },
|
||||
{ name: "cleanup_old", source: "global" },
|
||||
]
|
||||
|
||||
const mockQueryItems = [
|
||||
{ type: ContextMenuOptionType.File, value: "/src/app.ts" },
|
||||
{ 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)
|
||||
|
||||
expect(options).toHaveLength(5)
|
||||
expect(options.every((option) => option.type === ContextMenuOptionType.Command)).toBe(true)
|
||||
|
||||
const commandNames = options.map((option) => option.value)
|
||||
expect(commandNames).toContain("setup")
|
||||
expect(commandNames).toContain("build")
|
||||
expect(commandNames).toContain("deploy")
|
||||
expect(commandNames).toContain("test-suite")
|
||||
expect(commandNames).toContain("cleanup_old")
|
||||
})
|
||||
|
||||
it("should filter commands based on fuzzy search", () => {
|
||||
const options = getContextMenuOptions("/set", "/set", mockT, null, mockQueryItems, [], [], mockCommands)
|
||||
|
||||
// Should match 'setup' (fuzzy search behavior may vary)
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
const commandNames = options.map((option) => option.value)
|
||||
expect(commandNames).toContain("setup")
|
||||
// Note: fuzzy search may not match 'test-suite' for 'set' query
|
||||
})
|
||||
|
||||
it("should return commands with correct format", () => {
|
||||
const options = getContextMenuOptions("/setup", "/setup", mockT, 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)")
|
||||
})
|
||||
|
||||
it("should handle empty command list", () => {
|
||||
const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], [])
|
||||
|
||||
// Should return NoResults when no commands match
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
|
||||
})
|
||||
|
||||
it("should handle no matching commands", () => {
|
||||
const options = getContextMenuOptions(
|
||||
"/nonexistent",
|
||||
"/nonexistent",
|
||||
mockT,
|
||||
null,
|
||||
mockQueryItems,
|
||||
[],
|
||||
[],
|
||||
mockCommands,
|
||||
)
|
||||
|
||||
// Should return NoResults when no commands match
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
|
||||
})
|
||||
|
||||
it("should not return command suggestions for non-slash queries", () => {
|
||||
const options = getContextMenuOptions("setup", "setup", mockT, null, mockQueryItems, [], [], mockCommands)
|
||||
|
||||
// Should not contain command options for non-slash queries
|
||||
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
|
||||
expect(commandOptions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle commands with special characters in names", () => {
|
||||
const specialCommands: Command[] = [
|
||||
{ name: "setup-dev", source: "project" },
|
||||
{ name: "test_suite", source: "project" },
|
||||
{ name: "deploy.prod", source: "global" },
|
||||
]
|
||||
|
||||
const options = getContextMenuOptions(
|
||||
"/setup",
|
||||
"/setup",
|
||||
mockT,
|
||||
null,
|
||||
mockQueryItems,
|
||||
[],
|
||||
[],
|
||||
specialCommands,
|
||||
)
|
||||
|
||||
const setupDevOption = options.find((option) => option.value === "setup-dev")
|
||||
expect(setupDevOption).toBeDefined()
|
||||
expect(setupDevOption!.label).toBe("setup-dev")
|
||||
})
|
||||
|
||||
it("should handle case-insensitive fuzzy matching", () => {
|
||||
const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], mockCommands)
|
||||
|
||||
const commandNames = options.map((option) => option.value)
|
||||
expect(commandNames).toContain("setup")
|
||||
})
|
||||
|
||||
it("should prioritize exact matches in fuzzy search", () => {
|
||||
const commandsWithSimilarNames: Command[] = [
|
||||
{ name: "test", source: "project" },
|
||||
{ name: "test-suite", source: "project" },
|
||||
{ name: "integration-test", source: "project" },
|
||||
]
|
||||
|
||||
const options = getContextMenuOptions(
|
||||
"/test",
|
||||
"/test",
|
||||
mockT,
|
||||
null,
|
||||
mockQueryItems,
|
||||
[],
|
||||
[],
|
||||
commandsWithSimilarNames,
|
||||
)
|
||||
|
||||
// 'test' should be first due to exact match
|
||||
expect(options[0].value).toBe("test")
|
||||
})
|
||||
|
||||
it("should handle partial matches correctly", () => {
|
||||
const options = getContextMenuOptions("/te", "/te", mockT, null, mockQueryItems, [], [], mockCommands)
|
||||
|
||||
// Should match 'test-suite'
|
||||
const commandNames = options.map((option) => option.value)
|
||||
expect(commandNames).toContain("test-suite")
|
||||
})
|
||||
})
|
||||
|
||||
describe("command integration with modes", () => {
|
||||
const mockModes = [
|
||||
{
|
||||
name: "Code",
|
||||
slug: "code",
|
||||
description: "Write and edit code",
|
||||
roleDefinition: "You are a code assistant",
|
||||
groups: ["read", "edit"],
|
||||
},
|
||||
{
|
||||
name: "Debug",
|
||||
slug: "debug",
|
||||
description: "Debug applications",
|
||||
roleDefinition: "You are a debug assistant",
|
||||
groups: ["read", "edit"],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
it("should return both modes and commands for slash commands", () => {
|
||||
const options = getContextMenuOptions("/", "/", mockT, null, mockQueryItems, [], mockModes, mockCommands)
|
||||
|
||||
const modeOptions = options.filter((option) => option.type === ContextMenuOptionType.Mode)
|
||||
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
|
||||
|
||||
expect(modeOptions.length).toBe(2)
|
||||
expect(commandOptions.length).toBe(5)
|
||||
})
|
||||
|
||||
it("should filter both modes and commands based on query", () => {
|
||||
const options = getContextMenuOptions(
|
||||
"/co",
|
||||
"/co",
|
||||
mockT,
|
||||
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)
|
||||
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
|
||||
|
||||
expect(modeOptions.length).toBe(1)
|
||||
expect(modeOptions[0].value).toBe("code")
|
||||
// Fuzzy search might match some commands, so we just check it's a reasonable number
|
||||
expect(commandOptions.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command source indication", () => {
|
||||
it("should not expose source information in autocomplete", () => {
|
||||
const options = getContextMenuOptions("/setup", "/setup", mockT, 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")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle undefined commands gracefully", () => {
|
||||
const options = getContextMenuOptions("/setup", "/setup", mockT, 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)
|
||||
|
||||
// Should not return command options for empty query
|
||||
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
|
||||
expect(commandOptions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle very long command names", () => {
|
||||
const longNameCommands: Command[] = [
|
||||
{ name: "very-long-command-name-that-exceeds-normal-length", source: "project" },
|
||||
]
|
||||
|
||||
const options = getContextMenuOptions(
|
||||
"/very",
|
||||
"/very",
|
||||
mockT,
|
||||
null,
|
||||
mockQueryItems,
|
||||
[],
|
||||
[],
|
||||
longNameCommands,
|
||||
)
|
||||
|
||||
expect(options.length).toBe(1)
|
||||
expect(options[0].value).toBe("very-long-command-name-that-exceeds-normal-length")
|
||||
})
|
||||
|
||||
it("should handle commands with numeric names", () => {
|
||||
const numericCommands: Command[] = [
|
||||
{ name: "command1", source: "project" },
|
||||
{ name: "v2-setup", source: "project" },
|
||||
{ name: "123test", source: "project" },
|
||||
]
|
||||
|
||||
const options = getContextMenuOptions("/v", "/v", mockT, null, mockQueryItems, [], [], numericCommands)
|
||||
|
||||
const commandNames = options.map((option) => option.value)
|
||||
expect(commandNames).toContain("v2-setup")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -86,6 +86,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
togglePinnedApiConfig,
|
||||
taskHistory,
|
||||
clineMessages,
|
||||
commands,
|
||||
} = useExtensionState()
|
||||
|
||||
// Find the ID and display text for the currently selected API configuration
|
||||
|
|
@ -273,6 +274,27 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
return
|
||||
}
|
||||
|
||||
if (type === ContextMenuOptionType.Command && value) {
|
||||
// Handle command selection.
|
||||
setSelectedMenuIndex(-1)
|
||||
setInputValue("")
|
||||
setShowContextMenu(false)
|
||||
|
||||
// Insert the command mention into the textarea
|
||||
const commandMention = `/${value}`
|
||||
setInputValue(commandMention + " ")
|
||||
setCursorPosition(commandMention.length + 1)
|
||||
setIntendedCursorPosition(commandMention.length + 1)
|
||||
|
||||
// Focus the textarea
|
||||
setTimeout(() => {
|
||||
if (textAreaRef.current) {
|
||||
textAreaRef.current.focus()
|
||||
}
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
type === ContextMenuOptionType.File ||
|
||||
type === ContextMenuOptionType.Folder ||
|
||||
|
|
@ -302,6 +324,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
insertValue = "terminal"
|
||||
} else if (type === ContextMenuOptionType.Git) {
|
||||
insertValue = value || ""
|
||||
} else if (type === ContextMenuOptionType.Command) {
|
||||
insertValue = value ? `/${value}` : ""
|
||||
}
|
||||
|
||||
const { newValue, mentionIndex } = insertMention(
|
||||
|
|
@ -344,10 +368,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
const options = getContextMenuOptions(
|
||||
searchQuery,
|
||||
inputValue,
|
||||
t,
|
||||
selectedType,
|
||||
queryItems,
|
||||
fileSearchResults,
|
||||
allModes,
|
||||
commands,
|
||||
)
|
||||
const optionsLength = options.length
|
||||
|
||||
|
|
@ -381,10 +407,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
const selectedOption = getContextMenuOptions(
|
||||
searchQuery,
|
||||
inputValue,
|
||||
t,
|
||||
selectedType,
|
||||
queryItems,
|
||||
fileSearchResults,
|
||||
allModes,
|
||||
commands,
|
||||
)[selectedMenuIndex]
|
||||
if (
|
||||
selectedOption &&
|
||||
|
|
@ -475,6 +503,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
fileSearchResults,
|
||||
handleHistoryNavigation,
|
||||
resetHistoryNavigation,
|
||||
commands,
|
||||
t,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -504,10 +534,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
|
||||
if (showMenu) {
|
||||
if (newValue.startsWith("/")) {
|
||||
// Handle slash command.
|
||||
// Handle slash command - request fresh commands
|
||||
const query = newValue
|
||||
setSearchQuery(query)
|
||||
setSelectedMenuIndex(0)
|
||||
// Request commands fresh each time slash menu is shown
|
||||
vscode.postMessage({ type: "requestCommands" })
|
||||
} else {
|
||||
// Existing @ mention handling.
|
||||
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
|
||||
|
|
@ -1245,6 +1277,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
modes={allModes}
|
||||
loading={searchLoading}
|
||||
dynamicSearchResults={fileSearchResults}
|
||||
commands={commands}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
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"
|
||||
|
||||
import {
|
||||
ContextMenuOptionType,
|
||||
|
|
@ -23,6 +25,7 @@ interface ContextMenuProps {
|
|||
modes?: ModeConfig[]
|
||||
loading?: boolean
|
||||
dynamicSearchResults?: SearchResult[]
|
||||
commands?: Command[]
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
|
|
@ -36,13 +39,24 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||
queryItems,
|
||||
modes,
|
||||
dynamicSearchResults = [],
|
||||
commands = [],
|
||||
}) => {
|
||||
const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("")
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
return getContextMenuOptions(searchQuery, inputValue, selectedType, queryItems, dynamicSearchResults, modes)
|
||||
}, [searchQuery, inputValue, selectedType, queryItems, dynamicSearchResults, modes])
|
||||
return getContextMenuOptions(
|
||||
searchQuery,
|
||||
inputValue,
|
||||
t,
|
||||
selectedType,
|
||||
queryItems,
|
||||
dynamicSearchResults,
|
||||
modes,
|
||||
commands,
|
||||
)
|
||||
}, [searchQuery, inputValue, t, selectedType, queryItems, dynamicSearchResults, modes, commands])
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
|
|
@ -87,6 +101,25 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||
)}
|
||||
</div>
|
||||
)
|
||||
case ContextMenuOptionType.Command:
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
|
||||
<span style={{ lineHeight: "1.2" }}>{option.label}</span>
|
||||
{option.description && (
|
||||
<span
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
fontSize: "0.9em",
|
||||
lineHeight: "1.2",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case ContextMenuOptionType.Problems:
|
||||
return <span>Problems</span>
|
||||
case ContextMenuOptionType.Terminal:
|
||||
|
|
@ -163,6 +196,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||
switch (option.type) {
|
||||
case ContextMenuOptionType.Mode:
|
||||
return "symbol-misc"
|
||||
case ContextMenuOptionType.Command:
|
||||
return "play"
|
||||
case ContextMenuOptionType.OpenedFile:
|
||||
return "window"
|
||||
case ContextMenuOptionType.File:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
ORGANIZATION_ALLOW_ALL,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "@roo/ExtensionMessage"
|
||||
import { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata, Command } from "@roo/ExtensionMessage"
|
||||
import { findLastIndex } from "@roo/array"
|
||||
import { McpServer } from "@roo/mcp"
|
||||
import { checkExistKey } from "@roo/checkExistApiConfig"
|
||||
|
|
@ -33,6 +33,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
currentCheckpoint?: string
|
||||
filePaths: string[]
|
||||
openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
|
||||
commands: Command[]
|
||||
organizationAllowList: OrganizationAllowList
|
||||
cloudIsAuthenticated: boolean
|
||||
sharingEnabled: boolean
|
||||
|
|
@ -242,6 +243,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
const [theme, setTheme] = useState<any>(undefined)
|
||||
const [filePaths, setFilePaths] = useState<string[]>([])
|
||||
const [openedTabs, setOpenedTabs] = useState<Array<{ label: string; isActive: boolean; path?: string }>>([])
|
||||
const [commands, setCommands] = useState<Command[]>([])
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
|
||||
const [extensionRouterModels, setExtensionRouterModels] = useState<RouterModels | undefined>(undefined)
|
||||
|
|
@ -308,6 +310,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setOpenedTabs(tabs)
|
||||
break
|
||||
}
|
||||
case "commands": {
|
||||
setCommands(message.commands ?? [])
|
||||
break
|
||||
}
|
||||
case "messageUpdated": {
|
||||
const clineMessage = message.clineMessage!
|
||||
setState((prevState) => {
|
||||
|
|
@ -372,6 +378,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
currentCheckpoint,
|
||||
filePaths,
|
||||
openedTabs,
|
||||
commands,
|
||||
soundVolume: state.soundVolume,
|
||||
ttsSpeed: state.ttsSpeed,
|
||||
fuzzyMatchThreshold: state.fuzzyMatchThreshold,
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ca/chat.json
generated
3
webview-ui/src/i18n/locales/ca/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Edita el teu missatge..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Activa la comanda {{name}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/de/chat.json
generated
3
webview-ui/src/i18n/locales/de/chat.json
generated
|
|
@ -344,6 +344,9 @@
|
|||
"description": "Führe Remote-Agenten in der Cloud aus, greife von überall auf deine Aufgaben zu, arbeite mit anderen zusammen und vieles mehr.",
|
||||
"joinWaitlist": "Tritt der Warteliste bei, um frühen Zugang zu erhalten."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Starte den {{name}} Befehl"
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Bearbeite deine Nachricht..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -346,5 +346,8 @@
|
|||
"title": "Roo Code Cloud is coming soon!",
|
||||
"description": "Run Roomote agents in the cloud, access your tasks from anywhere, collaborate with others, and more.",
|
||||
"joinWaitlist": "Join the waitlist to get early access."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Trigger the {{name}} command"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/es/chat.json
generated
3
webview-ui/src/i18n/locales/es/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Edita tu mensaje..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Activar el comando {{name}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/fr/chat.json
generated
3
webview-ui/src/i18n/locales/fr/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Modifiez votre message..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Déclencher la commande {{name}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/hi/chat.json
generated
3
webview-ui/src/i18n/locales/hi/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "अपना संदेश संपादित करें..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "{{name}} कमांड को ट्रिगर करें"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/id/chat.json
generated
3
webview-ui/src/i18n/locales/id/chat.json
generated
|
|
@ -352,5 +352,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Edit pesan Anda..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Jalankan perintah {{name}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/it/chat.json
generated
3
webview-ui/src/i18n/locales/it/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Modifica il tuo messaggio..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Attiva il comando {{name}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ja/chat.json
generated
3
webview-ui/src/i18n/locales/ja/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "メッセージを編集..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "{{name}}コマンドをトリガー"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ko/chat.json
generated
3
webview-ui/src/i18n/locales/ko/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "메시지 편집..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "{{name}} 명령 트리거"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/nl/chat.json
generated
3
webview-ui/src/i18n/locales/nl/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Bewerk je bericht..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Activeer de {{name}} opdracht"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/pl/chat.json
generated
3
webview-ui/src/i18n/locales/pl/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Edytuj swoją wiadomość..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Uruchom polecenie {{name}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/pt-BR/chat.json
generated
3
webview-ui/src/i18n/locales/pt-BR/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Edite sua mensagem..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Acionar o comando {{name}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ru/chat.json
generated
3
webview-ui/src/i18n/locales/ru/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Редактировать сообщение..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Запустить команду {{name}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/tr/chat.json
generated
3
webview-ui/src/i18n/locales/tr/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Mesajını düzenle..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "{{name}} komutunu tetikle"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/vi/chat.json
generated
3
webview-ui/src/i18n/locales/vi/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Chỉnh sửa tin nhắn của bạn..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "Kích hoạt lệnh {{name}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/zh-CN/chat.json
generated
3
webview-ui/src/i18n/locales/zh-CN/chat.json
generated
|
|
@ -344,6 +344,9 @@
|
|||
"description": "在云端运行远程代理,随时随地访问任务,与他人协作等更多功能。",
|
||||
"joinWaitlist": "加入等待列表获取早期访问权限。"
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "触发 {{name}} 命令"
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "编辑消息..."
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/zh-TW/chat.json
generated
3
webview-ui/src/i18n/locales/zh-TW/chat.json
generated
|
|
@ -346,5 +346,8 @@
|
|||
},
|
||||
"editMessage": {
|
||||
"placeholder": "編輯訊息..."
|
||||
},
|
||||
"command": {
|
||||
"triggerDescription": "觸發 {{name}} 命令"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,8 +194,16 @@ 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("", "", null, [])
|
||||
const result = getContextMenuOptions("", "", mockT, null, [])
|
||||
expect(result).toHaveLength(6)
|
||||
expect(result.map((item) => item.type)).toEqual([
|
||||
ContextMenuOptionType.Problems,
|
||||
|
|
@ -208,7 +216,7 @@ describe("getContextMenuOptions", () => {
|
|||
})
|
||||
|
||||
it("should filter by selected type when query is empty", () => {
|
||||
const result = getContextMenuOptions("", "", ContextMenuOptionType.File, mockQueryItems)
|
||||
const result = getContextMenuOptions("", "", mockT, 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)
|
||||
|
|
@ -217,19 +225,19 @@ describe("getContextMenuOptions", () => {
|
|||
})
|
||||
|
||||
it("should match git commands", () => {
|
||||
const result = getContextMenuOptions("git", "git", null, mockQueryItems)
|
||||
const result = getContextMenuOptions("git", "git", mockT, 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", null, mockQueryItems)
|
||||
const result = getContextMenuOptions("abc1234", "abc1234", mockT, 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", null, mockQueryItems)
|
||||
const result = getContextMenuOptions("nonexistent", "nonexistent", mockT, null, mockQueryItems)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].type).toBe(ContextMenuOptionType.NoResults)
|
||||
})
|
||||
|
|
@ -250,7 +258,7 @@ describe("getContextMenuOptions", () => {
|
|||
},
|
||||
]
|
||||
|
||||
const result = getContextMenuOptions("test", "test", null, testItems, mockDynamicSearchResults)
|
||||
const result = getContextMenuOptions("test", "test", mockT, null, testItems, mockDynamicSearchResults)
|
||||
|
||||
// Check if opened files and dynamic search results are included
|
||||
expect(result.some((item) => item.type === ContextMenuOptionType.OpenedFile)).toBe(true)
|
||||
|
|
@ -259,7 +267,7 @@ describe("getContextMenuOptions", () => {
|
|||
|
||||
it("should maintain correct result ordering according to implementation", () => {
|
||||
// Add multiple item types to test ordering
|
||||
const result = getContextMenuOptions("t", "t", null, mockQueryItems, mockDynamicSearchResults)
|
||||
const result = getContextMenuOptions("t", "t", mockT, null, mockQueryItems, mockDynamicSearchResults)
|
||||
|
||||
// Find the different result types
|
||||
const fileResults = result.filter(
|
||||
|
|
@ -290,7 +298,7 @@ describe("getContextMenuOptions", () => {
|
|||
})
|
||||
|
||||
it("should include opened files when dynamic search results exist", () => {
|
||||
const result = getContextMenuOptions("open", "open", null, mockQueryItems, mockDynamicSearchResults)
|
||||
const result = getContextMenuOptions("open", "open", mockT, null, mockQueryItems, mockDynamicSearchResults)
|
||||
|
||||
// Verify opened files are included
|
||||
expect(result.some((item) => item.type === ContextMenuOptionType.OpenedFile)).toBe(true)
|
||||
|
|
@ -299,7 +307,7 @@ describe("getContextMenuOptions", () => {
|
|||
})
|
||||
|
||||
it("should include git results when dynamic search results exist", () => {
|
||||
const result = getContextMenuOptions("commit", "commit", null, mockQueryItems, mockDynamicSearchResults)
|
||||
const result = getContextMenuOptions("commit", "commit", mockT, null, mockQueryItems, mockDynamicSearchResults)
|
||||
|
||||
// Verify git results are included
|
||||
expect(result.some((item) => item.type === ContextMenuOptionType.Git)).toBe(true)
|
||||
|
|
@ -320,7 +328,7 @@ describe("getContextMenuOptions", () => {
|
|||
},
|
||||
]
|
||||
|
||||
const result = getContextMenuOptions("test", "test", null, mockQueryItems, duplicateSearchResults)
|
||||
const result = getContextMenuOptions("test", "test", mockT, null, mockQueryItems, duplicateSearchResults)
|
||||
|
||||
// Count occurrences of src/test.ts in results
|
||||
const duplicateCount = result.filter(
|
||||
|
|
@ -340,6 +348,7 @@ describe("getContextMenuOptions", () => {
|
|||
const result = getContextMenuOptions(
|
||||
"nonexistentquery123456",
|
||||
"nonexistentquery123456",
|
||||
mockT,
|
||||
null,
|
||||
mockQueryItems,
|
||||
[], // Empty dynamic search results
|
||||
|
|
@ -387,7 +396,7 @@ describe("getContextMenuOptions", () => {
|
|||
]
|
||||
|
||||
// Get results for "test" query
|
||||
const result = getContextMenuOptions(testQuery, testQuery, null, testItems, testSearchResults)
|
||||
const result = getContextMenuOptions(testQuery, testQuery, mockT, null, testItems, testSearchResults)
|
||||
|
||||
// Verify we have results
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
|
|
@ -433,7 +442,7 @@ describe("getContextMenuOptions", () => {
|
|||
},
|
||||
]
|
||||
|
||||
const result = getContextMenuOptions("/co", "/co", null, [], [], mockModes)
|
||||
const result = getContextMenuOptions("/co", "/co", mockT, null, [], [], mockModes)
|
||||
|
||||
// Verify mode results are returned
|
||||
expect(result[0].type).toBe(ContextMenuOptionType.Mode)
|
||||
|
|
@ -443,7 +452,7 @@ describe("getContextMenuOptions", () => {
|
|||
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", null, [], [])
|
||||
const result = getContextMenuOptions("/nonexistentquery", "Hello /code", mockT, null, [], [])
|
||||
|
||||
// Should not process as a mode command
|
||||
expect(result[0].type).not.toBe(ContextMenuOptionType.Mode)
|
||||
|
|
@ -453,7 +462,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", null, mockQueryItems, mockSearchResults)
|
||||
const options = getContextMenuOptions("@search", "search", mockT, 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
|
||||
|
|
@ -466,7 +475,7 @@ describe("getContextMenuOptions", () => {
|
|||
})
|
||||
|
||||
it("should return query items (like opened files) with correct labels/descriptions", () => {
|
||||
const options = getContextMenuOptions("open", "@open", null, mockQueryItems, [])
|
||||
const options = getContextMenuOptions("open", "@open", mockT, null, mockQueryItems, [])
|
||||
const openedFile = options.find((o) => o.label === "open file.ts")
|
||||
expect(openedFile).toBeDefined()
|
||||
expect(openedFile?.value).toBe("src/open file.ts")
|
||||
|
|
@ -483,7 +492,7 @@ describe("getContextMenuOptions", () => {
|
|||
]
|
||||
|
||||
// The formatting happens in getContextMenuOptions when converting search results to menu items
|
||||
const formattedItems = getContextMenuOptions("spaces", "@spaces", null, [], searchResults)
|
||||
const formattedItems = getContextMenuOptions("spaces", "@spaces", mockT, null, [], searchResults)
|
||||
|
||||
// Verify we get some results back that aren't "No Results"
|
||||
expect(formattedItems.length).toBeGreaterThan(0)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Fzf } from "fzf"
|
||||
|
||||
import type { ModeConfig } from "@roo-code/types"
|
||||
import type { Command } from "@roo/ExtensionMessage"
|
||||
|
||||
import { mentionRegex } from "@roo/context-mentions"
|
||||
|
||||
|
|
@ -105,6 +106,7 @@ export enum ContextMenuOptionType {
|
|||
Git = "git",
|
||||
NoResults = "noResults",
|
||||
Mode = "mode", // Add mode type
|
||||
Command = "command", // Add command type
|
||||
}
|
||||
|
||||
export interface ContextMenuQueryItem {
|
||||
|
|
@ -118,43 +120,83 @@ export interface ContextMenuQueryItem {
|
|||
export function getContextMenuOptions(
|
||||
query: string,
|
||||
inputValue: string,
|
||||
t: (key: string, options?: { name?: string }) => string,
|
||||
selectedType: ContextMenuOptionType | null = null,
|
||||
queryItems: ContextMenuQueryItem[],
|
||||
dynamicSearchResults: SearchResult[] = [],
|
||||
modes?: ModeConfig[],
|
||||
commands?: Command[],
|
||||
): ContextMenuQueryItem[] {
|
||||
// Handle slash commands for modes
|
||||
// Handle slash commands for modes and commands
|
||||
if (query.startsWith("/") && inputValue.startsWith("/")) {
|
||||
const modeQuery = query.slice(1)
|
||||
if (!modes?.length) return [{ type: ContextMenuOptionType.NoResults }]
|
||||
const slashQuery = query.slice(1)
|
||||
const results: ContextMenuQueryItem[] = []
|
||||
|
||||
// Create searchable strings array for fzf
|
||||
const searchableItems = modes.map((mode) => ({
|
||||
original: mode,
|
||||
searchStr: mode.name,
|
||||
}))
|
||||
// Add mode suggestions
|
||||
if (modes?.length) {
|
||||
// Create searchable strings array for fzf
|
||||
const searchableItems = modes.map((mode) => ({
|
||||
original: mode,
|
||||
searchStr: mode.name,
|
||||
}))
|
||||
|
||||
// Initialize fzf instance for fuzzy search
|
||||
const fzf = new Fzf(searchableItems, {
|
||||
selector: (item) => item.searchStr,
|
||||
})
|
||||
// Initialize fzf instance for fuzzy search
|
||||
const fzf = new Fzf(searchableItems, {
|
||||
selector: (item) => item.searchStr,
|
||||
})
|
||||
|
||||
// Get fuzzy matching items
|
||||
const matchingModes = modeQuery
|
||||
? fzf.find(modeQuery).map((result) => ({
|
||||
type: ContextMenuOptionType.Mode,
|
||||
value: result.item.original.slug,
|
||||
label: result.item.original.name,
|
||||
description: getModeDescription(result.item.original),
|
||||
}))
|
||||
: modes.map((mode) => ({
|
||||
type: ContextMenuOptionType.Mode,
|
||||
value: mode.slug,
|
||||
label: mode.name,
|
||||
description: getModeDescription(mode),
|
||||
}))
|
||||
// Get fuzzy matching items
|
||||
const matchingModes = slashQuery
|
||||
? fzf.find(slashQuery).map((result) => ({
|
||||
type: ContextMenuOptionType.Mode,
|
||||
value: result.item.original.slug,
|
||||
label: result.item.original.name,
|
||||
description: getModeDescription(result.item.original),
|
||||
}))
|
||||
: modes.map((mode) => ({
|
||||
type: ContextMenuOptionType.Mode,
|
||||
value: mode.slug,
|
||||
label: mode.name,
|
||||
description: getModeDescription(mode),
|
||||
}))
|
||||
|
||||
return matchingModes.length > 0 ? matchingModes : [{ type: ContextMenuOptionType.NoResults }]
|
||||
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)
|
||||
}
|
||||
|
||||
return results.length > 0 ? results : [{ type: ContextMenuOptionType.NoResults }]
|
||||
}
|
||||
|
||||
const workingChanges: ContextMenuQueryItem = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue