diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a34fb817ee2..e881c554a08 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2088,6 +2088,7 @@ export class ClineProvider } })(), debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), + skills: this.skillsManager?.getSkillsForUI() ?? [], } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 35bf08e0486..561d8a727c0 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3029,6 +3029,110 @@ export const webviewMessageHandler = async ( } break } + case "requestSkills": { + try { + const skills = provider.getSkillsManager()?.getSkillsForUI() ?? [] + + await provider.postMessageToWebview({ + type: "skills", + skills: skills, + }) + } catch (error) { + provider.log(`Error fetching skills: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + // Send empty array on error + await provider.postMessageToWebview({ + type: "skills", + skills: [], + }) + } + break + } + case "createSkill": { + try { + const skillName = message.text + const skillSource = message.values?.source as "global" | "project" + + if (!skillName || !skillSource) { + provider.log("Missing skill name or source for createSkill") + break + } + + // Create the skill + const filePath = await provider.getSkillsManager()?.createSkill(skillName, skillSource) + + if (filePath) { + // Open the file in editor + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)) + await vscode.window.showTextDocument(doc) + } + + // Refresh skills list + const skills = provider.getSkillsManager()?.getSkillsForUI() ?? [] + await provider.postMessageToWebview({ + type: "skills", + skills: skills, + }) + } catch (error) { + provider.log(`Error creating skill: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + vscode.window.showErrorMessage( + `Failed to create skill: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } + case "deleteSkill": { + try { + const skillName = message.text + const skillSource = message.values?.source as "global" | "project" + + if (!skillName || !skillSource) { + provider.log("Missing skill name or source for deleteSkill") + break + } + + // Delete the skill + await provider.getSkillsManager()?.deleteSkill(skillName, skillSource) + + // Refresh skills list + const skills = provider.getSkillsManager()?.getSkillsForUI() ?? [] + await provider.postMessageToWebview({ + type: "skills", + skills: skills, + }) + } catch (error) { + provider.log(`Error deleting skill: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + vscode.window.showErrorMessage( + `Failed to delete skill: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } + case "openSkillFile": { + try { + const skillName = message.text + const skillSource = message.values?.source as "global" | "project" + + if (!skillName || !skillSource) { + provider.log("Missing skill name or source for openSkillFile") + break + } + + const filePath = provider.getSkillsManager()?.getSkillFilePath(skillName, skillSource) + + if (filePath) { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)) + await vscode.window.showTextDocument(doc) + } else { + vscode.window.showErrorMessage(`Skill file not found: ${skillName}`) + } + } catch (error) { + provider.log(`Error opening skill file: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + vscode.window.showErrorMessage( + `Failed to open skill file: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } case "insertTextIntoTextarea": { const text = message.text diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 59b50cf1713..821d3416d30 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -8,9 +8,45 @@ import { getGlobalRooDirectory } from "../roo-config" import { directoryExists, fileExists } from "../roo-config" import { SkillMetadata, SkillContent } from "../../shared/skills" import { modes, getAllModes } from "../../shared/modes" +import type { SkillForUI } from "../../shared/ExtensionMessage" // Re-export for convenience -export type { SkillMetadata, SkillContent } +export type { SkillMetadata, SkillContent, SkillForUI } + +/** + * Validation result for skill names + */ +interface ValidationResult { + valid: boolean + error?: string +} + +/** + * Validate skill name according to agentskills.io specification + * @param name - Skill name to validate + * @returns Validation result with error message if invalid + */ +function isValidSkillName(name: string): ValidationResult { + // Length: 1-64 characters + if (name.length < 1 || name.length > 64) { + return { + valid: false, + error: `Skill name must be 1-64 characters (got ${name.length})`, + } + } + + // Pattern: lowercase letters, numbers, hyphens only + // No leading/trailing hyphens, no consecutive hyphens + const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + if (!nameFormat.test(name)) { + return { + valid: false, + error: "Skill name must contain only lowercase letters, numbers, and hyphens (no leading/trailing hyphens, no consecutive hyphens)", + } + } + + return { valid: true } +} export class SkillsManager { private skills: Map = new Map() @@ -239,6 +275,131 @@ export class SkillsManager { } } + /** + * Create a new skill with the given name in the specified location. + * Creates the directory structure and SKILL.md file with template content. + * + * @param name - Skill name (must be valid: 1-64 chars, lowercase, hyphens only) + * @param source - Where to create: "global" or "project" + * @returns Path to created SKILL.md file + * @throws Error if validation fails or skill already exists + */ + async createSkill(name: string, source: "global" | "project"): Promise { + // Validate skill name + const validation = isValidSkillName(name) + if (!validation.valid) { + throw new Error(validation.error) + } + + // Check if skill already exists + const existingKey = this.getSkillKey(name, source) + if (this.skills.has(existingKey)) { + throw new Error(`Skill "${name}" already exists in ${source}`) + } + + // Determine base directory + const baseDir = + source === "global" + ? getGlobalRooDirectory() + : this.providerRef.deref()?.cwd + ? path.join(this.providerRef.deref()!.cwd, ".roo") + : null + + if (!baseDir) { + throw new Error("Cannot create project skill: no project directory available") + } + + // Create skill directory and SKILL.md + const skillsDir = path.join(baseDir, "skills") + const skillDir = path.join(skillsDir, name) + const skillMdPath = path.join(skillDir, "SKILL.md") + + // Create directory structure + await fs.mkdir(skillDir, { recursive: true }) + + // Create title case name for template + const titleCaseName = name + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + + // Create SKILL.md with template + const template = `--- +name: "${name}" +description: "Description of what this skill does" +--- + +# ${titleCaseName} + +## Instructions + +Add your skill instructions here... +` + + await fs.writeFile(skillMdPath, template, "utf-8") + + // Re-discover skills to update the internal cache + await this.discoverSkills() + + return skillMdPath + } + + /** + * Delete an existing skill directory. + * + * @param name - Skill name to delete + * @param source - Where the skill is located + * @throws Error if skill doesn't exist + */ + async deleteSkill(name: string, source: "global" | "project"): Promise { + // Check if skill exists + const skillKey = this.getSkillKey(name, source) + const skill = this.skills.get(skillKey) + + if (!skill) { + throw new Error(`Skill "${name}" not found in ${source}`) + } + + // Get the skill directory (parent of SKILL.md) + const skillDir = path.dirname(skill.path) + + // Delete the entire skill directory + await fs.rm(skillDir, { recursive: true, force: true }) + + // Re-discover skills to update the internal cache + await this.discoverSkills() + } + + /** + * Get all skills formatted for UI display. + * Converts internal SkillMetadata to SkillForUI interface. + * + * @returns Array of skills formatted for UI + */ + getSkillsForUI(): SkillForUI[] { + return Array.from(this.skills.values()).map((skill) => ({ + name: skill.name, + description: skill.description, + source: skill.source, + filePath: skill.path, + mode: skill.mode, + })) + } + + /** + * Get the file path for a skill's SKILL.md file. + * Used for opening in editor. + * + * @param name - Skill name + * @param source - Where the skill is located + * @returns Full path to SKILL.md or undefined if not found + */ + getSkillFilePath(name: string, source: "global" | "project"): string | undefined { + const skillKey = this.getSkillKey(name, source) + const skill = this.skills.get(skillKey) + return skill?.path + } + /** * Get all skills directories to scan, including mode-specific directories. */ diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index 4b6549108bb..c3aa3fe2c84 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -1,16 +1,29 @@ import * as path from "path" // Use vi.hoisted to ensure mocks are available during hoisting -const { mockStat, mockReadFile, mockReaddir, mockHomedir, mockDirectoryExists, mockFileExists, mockRealpath } = - vi.hoisted(() => ({ - mockStat: vi.fn(), - mockReadFile: vi.fn(), - mockReaddir: vi.fn(), - mockHomedir: vi.fn(), - mockDirectoryExists: vi.fn(), - mockFileExists: vi.fn(), - mockRealpath: vi.fn(), - })) +const { + mockStat, + mockReadFile, + mockReaddir, + mockHomedir, + mockDirectoryExists, + mockFileExists, + mockRealpath, + mockMkdir, + mockWriteFile, + mockRm, +} = vi.hoisted(() => ({ + mockStat: vi.fn(), + mockReadFile: vi.fn(), + mockReaddir: vi.fn(), + mockHomedir: vi.fn(), + mockDirectoryExists: vi.fn(), + mockFileExists: vi.fn(), + mockRealpath: vi.fn(), + mockMkdir: vi.fn(), + mockWriteFile: vi.fn(), + mockRm: vi.fn(), +})) // Platform-agnostic test paths // Use forward slashes for consistency, then normalize with path.normalize @@ -28,11 +41,17 @@ vi.mock("fs/promises", () => ({ readFile: mockReadFile, readdir: mockReaddir, realpath: mockRealpath, + mkdir: mockMkdir, + writeFile: mockWriteFile, + rm: mockRm, }, stat: mockStat, readFile: mockReadFile, readdir: mockReaddir, realpath: mockRealpath, + mkdir: mockMkdir, + writeFile: mockWriteFile, + rm: mockRm, })) // Mock os module @@ -827,4 +846,351 @@ description: A test skill expect(skills).toHaveLength(0) }) }) + + describe("createSkill", () => { + it("should create a global skill with valid name", async () => { + const skillName = "my-new-skill" + const expectedDir = p(GLOBAL_ROO_DIR, "skills", skillName) + const expectedMdPath = p(expectedDir, "SKILL.md") + + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockResolvedValue([]) + + const filePath = await skillsManager.createSkill(skillName, "global") + + expect(filePath).toBe(expectedMdPath) + expect(mockMkdir).toHaveBeenCalledWith(expectedDir, { recursive: true }) + expect(mockWriteFile).toHaveBeenCalledWith( + expectedMdPath, + expect.stringContaining(`name: "${skillName}"`), + "utf-8", + ) + expect(mockWriteFile).toHaveBeenCalledWith( + expectedMdPath, + expect.stringContaining("# My New Skill"), + "utf-8", + ) + }) + + it("should create a project skill with valid name", async () => { + const skillName = "project-specific" + const expectedDir = p(PROJECT_DIR, ".roo", "skills", skillName) + const expectedMdPath = p(expectedDir, "SKILL.md") + + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockResolvedValue([]) + + const filePath = await skillsManager.createSkill(skillName, "project") + + expect(filePath).toBe(expectedMdPath) + expect(mockMkdir).toHaveBeenCalledWith(expectedDir, { recursive: true }) + expect(mockWriteFile).toHaveBeenCalledWith( + expectedMdPath, + expect.stringContaining(`name: "${skillName}"`), + "utf-8", + ) + }) + + it("should throw error for invalid skill name (too long)", async () => { + const longName = "a".repeat(65) + + await expect(skillsManager.createSkill(longName, "global")).rejects.toThrow( + "Skill name must be 1-64 characters", + ) + }) + + it("should throw error for invalid skill name (uppercase)", async () => { + await expect(skillsManager.createSkill("MySkill", "global")).rejects.toThrow( + "must contain only lowercase letters", + ) + }) + + it("should throw error for invalid skill name (leading hyphen)", async () => { + await expect(skillsManager.createSkill("-my-skill", "global")).rejects.toThrow( + "must contain only lowercase letters", + ) + }) + + it("should throw error for invalid skill name (trailing hyphen)", async () => { + await expect(skillsManager.createSkill("my-skill-", "global")).rejects.toThrow( + "must contain only lowercase letters", + ) + }) + + it("should throw error for invalid skill name (consecutive hyphens)", async () => { + await expect(skillsManager.createSkill("my--skill", "global")).rejects.toThrow( + "must contain only lowercase letters", + ) + }) + + it("should throw error if skill already exists", async () => { + const skillName = "existing-skill" + const existingDir = p(globalSkillsDir, skillName) + const existingMd = p(existingDir, "SKILL.md") + + // Setup existing skill + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? [skillName] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === existingDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === existingMd) + mockReadFile.mockResolvedValue(`--- +name: ${skillName} +description: Existing skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + await expect(skillsManager.createSkill(skillName, "global")).rejects.toThrow( + `Skill "${skillName}" already exists`, + ) + }) + + it("should throw error when creating project skill without provider cwd", async () => { + // Create manager without cwd + const noCwdProvider = { + cwd: undefined, + customModesManager: { + getCustomModes: vi.fn().mockResolvedValue([]), + } as any, + } + const noCwdManager = new SkillsManager(noCwdProvider as any) + + await expect(noCwdManager.createSkill("test-skill", "project")).rejects.toThrow( + "no project directory available", + ) + + await noCwdManager.dispose() + }) + }) + + describe("deleteSkill", () => { + it("should delete an existing global skill", async () => { + const skillName = "skill-to-delete" + const skillDir = p(globalSkillsDir, skillName) + const skillMd = p(skillDir, "SKILL.md") + + // Setup existing skill + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? [skillName] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue(`--- +name: ${skillName} +description: Skill to delete +--- +Instructions`) + + await skillsManager.discoverSkills() + + // Mock rm and subsequent discovery + mockRm.mockResolvedValue(undefined) + mockReaddir.mockImplementation(async (dir: string) => []) // Empty after deletion + + await skillsManager.deleteSkill(skillName, "global") + + expect(mockRm).toHaveBeenCalledWith(skillDir, { recursive: true, force: true }) + }) + + it("should delete an existing project skill", async () => { + const skillName = "project-skill-delete" + const skillDir = p(projectSkillsDir, skillName) + const skillMd = p(skillDir, "SKILL.md") + + // Setup existing skill + mockDirectoryExists.mockImplementation(async (dir: string) => dir === projectSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === projectSkillsDir ? [skillName] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue(`--- +name: ${skillName} +description: Project skill to delete +--- +Instructions`) + + await skillsManager.discoverSkills() + + mockRm.mockResolvedValue(undefined) + mockReaddir.mockImplementation(async (dir: string) => []) + + await skillsManager.deleteSkill(skillName, "project") + + expect(mockRm).toHaveBeenCalledWith(skillDir, { recursive: true, force: true }) + }) + + it("should throw error if skill doesn't exist", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + await expect(skillsManager.deleteSkill("non-existent", "global")).rejects.toThrow( + 'Skill "non-existent" not found', + ) + }) + }) + + describe("getSkillsForUI", () => { + it("should return empty array when no skills", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + const uiSkills = skillsManager.getSkillsForUI() + + expect(uiSkills).toEqual([]) + }) + + it("should return properly formatted skills array", async () => { + const globalSkillDir = p(globalSkillsDir, "global-skill") + const globalSkillMd = p(globalSkillDir, "SKILL.md") + const projectSkillDir = p(projectSkillsDir, "project-skill") + const projectSkillMd = p(projectSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => + [globalSkillsDir, projectSkillsDir].includes(dir), + ) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) return ["global-skill"] + if (dir === projectSkillsDir) return ["project-skill"] + return [] + }) + mockStat.mockImplementation(async (pathArg: string) => { + if ([globalSkillDir, projectSkillDir].includes(pathArg)) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + mockFileExists.mockResolvedValue(true) + mockReadFile.mockImplementation(async (file: string) => { + if (file === globalSkillMd) { + return `--- +name: global-skill +description: A global skill +--- +Instructions` + } + if (file === projectSkillMd) { + return `--- +name: project-skill +description: A project skill +--- +Instructions` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const uiSkills = skillsManager.getSkillsForUI() + + expect(uiSkills).toHaveLength(2) + expect(uiSkills).toEqual( + expect.arrayContaining([ + { + name: "global-skill", + description: "A global skill", + source: "global", + filePath: globalSkillMd, + mode: undefined, + }, + { + name: "project-skill", + description: "A project skill", + source: "project", + filePath: projectSkillMd, + mode: undefined, + }, + ]), + ) + }) + + it("should include mode field for mode-specific skills", async () => { + const codeSkillDir = p(globalSkillsCodeDir, "code-skill") + const codeSkillMd = p(codeSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsCodeDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsCodeDir ? ["code-skill"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === codeSkillDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + mockFileExists.mockResolvedValue(true) + mockReadFile.mockResolvedValue(`--- +name: code-skill +description: Code mode skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + const uiSkills = skillsManager.getSkillsForUI() + + expect(uiSkills).toHaveLength(1) + expect(uiSkills[0].mode).toBe("code") + }) + }) + + describe("getSkillFilePath", () => { + it("should return file path for existing skill", async () => { + const skillName = "test-skill" + const skillDir = p(globalSkillsDir, skillName) + const skillMd = p(skillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? [skillName] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue(`--- +name: ${skillName} +description: Test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + const filePath = skillsManager.getSkillFilePath(skillName, "global") + + expect(filePath).toBe(skillMd) + }) + + it("should return undefined for non-existent skill", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + const filePath = skillsManager.getSkillFilePath("non-existent", "global") + + expect(filePath).toBeUndefined() + }) + }) }) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 2eec4cb6c88..024b5a88536 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -32,6 +32,15 @@ export interface Command { argumentHint?: string } +// Skill interface for frontend/backend communication +export interface SkillForUI { + name: string + description: string + source: "global" | "project" + filePath: string + mode?: string +} + // Type for marketplace installed metadata export interface MarketplaceInstalledMetadata { project: Record @@ -124,6 +133,7 @@ export interface ExtensionMessage { | "showDeleteMessageDialog" | "showEditMessageDialog" | "commands" + | "skills" | "insertTextIntoTextarea" | "dismissedUpsells" | "organizationSwitchResult" @@ -211,6 +221,7 @@ export interface ExtensionMessage { hasCheckpoint?: boolean context?: string commands?: Command[] + skills?: SkillForUI[] queuedMessages?: QueuedMessage[] list?: string[] // For dismissedUpsells organizationId?: string | null // For organizationSwitchResult @@ -361,6 +372,7 @@ export type ExtensionState = Pick< featureRoomoteControlEnabled: boolean claudeCodeIsAuthenticated?: boolean debug?: boolean + skills?: SkillForUI[] } export interface ClineSayTool { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 4c3e321dea8..b0a8d670f68 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -159,6 +159,10 @@ export interface WebviewMessage { | "openCommandFile" | "deleteCommand" | "createCommand" + | "requestSkills" + | "createSkill" + | "deleteSkill" + | "openSkillFile" | "insertTextIntoTextarea" | "showMdmAuthRequiredNotification" | "imageGenerationSettings" diff --git a/webview-ui/src/components/settings/CommandsAndSkillsSettings.tsx b/webview-ui/src/components/settings/CommandsAndSkillsSettings.tsx new file mode 100644 index 00000000000..bcaf2c6d43e --- /dev/null +++ b/webview-ui/src/components/settings/CommandsAndSkillsSettings.tsx @@ -0,0 +1,311 @@ +import React, { useState, useEffect } from "react" +import { Plus, Globe, Folder, Settings, SquareSlash, Sparkles } from "lucide-react" +import { Trans } from "react-i18next" + +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 { buildDocLink } from "@/utils/docLinks" + +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" +import { SlashCommandItem } from "../chat/SlashCommandItem" +import { SkillsTab } from "./SkillsTab" + +type TabType = "commands" | "skills" + +interface TabButtonProps { + active: boolean + onClick: () => void + children: React.ReactNode + icon: React.ReactNode +} + +const TabButton: React.FC = ({ active, onClick, children, icon }) => { + return ( + + ) +} + +// Extracted slash commands content component +const SlashCommandsContent: React.FC = () => { + const { t } = useAppTranslation() + const { commands, cwd } = useExtensionState() + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [commandToDelete, setCommandToDelete] = useState(null) + const [globalNewName, setGlobalNewName] = useState("") + const [workspaceNewName, setWorkspaceNewName] = useState("") + + // Check if we're in a workspace/project + const hasWorkspace = Boolean(cwd) + + // Request commands when component mounts + useEffect(() => { + handleRefresh() + }, []) + + const handleRefresh = () => { + vscode.postMessage({ type: "requestCommands" }) + } + + 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(handleRefresh, 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(handleRefresh, 500) + } + + const handleCommandClick = (command: Command) => { + // For now, we'll just show the command name - editing functionality can be added later + // This could be enhanced to open the command file in the editor + console.log(`Command clicked: ${command.name} (${command.source})`) + } + + // Group commands by source + const builtInCommands = commands?.filter((cmd) => cmd.source === "built-in") || [] + const globalCommands = commands?.filter((cmd) => cmd.source === "global") || [] + const projectCommands = commands?.filter((cmd) => cmd.source === "project") || [] + + return ( +
+ {/* Description section */} +
+

+ + Docs + + ), + }} + /> +

+
+ + {/* Global Commands Section */} +
+
+ +

{t("chat:slashCommands.globalCommands")}

+
+
+ {globalCommands.map((command) => ( + + ))} + {/* New global command input */} +
+ setGlobalNewName(e.target.value)} + placeholder={t("chat:slashCommands.newGlobalCommandPlaceholder")} + className="flex-1 bg-vscode-input-background text-vscode-input-foreground placeholder-vscode-input-placeholderForeground border border-vscode-input-border rounded px-2 py-1 text-sm focus:outline-none focus:border-vscode-focusBorder" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCreateCommand("global", globalNewName) + } + }} + /> + +
+
+
+ + {/* Workspace Commands Section - Only show if in a workspace */} + {hasWorkspace && ( +
+
+ +

{t("chat:slashCommands.workspaceCommands")}

+
+
+ {projectCommands.map((command) => ( + + ))} + {/* New workspace command input */} +
+ setWorkspaceNewName(e.target.value)} + placeholder={t("chat:slashCommands.newWorkspaceCommandPlaceholder")} + className="flex-1 bg-vscode-input-background text-vscode-input-foreground placeholder-vscode-input-placeholderForeground border border-vscode-input-border rounded px-2 py-1 text-sm focus:outline-none focus:border-vscode-focusBorder" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCreateCommand("project", workspaceNewName) + } + }} + /> + +
+
+
+ )} + + {/* Built-in Commands Section */} + {builtInCommands.length > 0 && ( +
+
+ +

{t("chat:slashCommands.builtInCommands")}

+
+
+ {builtInCommands.map((command) => ( + + ))} +
+
+ )} + + + + + {t("chat:slashCommands.deleteDialog.title")} + + {t("chat:slashCommands.deleteDialog.description", { name: commandToDelete?.name })} + + + + + {t("chat:slashCommands.deleteDialog.cancel")} + + + {t("chat:slashCommands.deleteDialog.confirm")} + + + + +
+ ) +} + +export const CommandsAndSkillsSettings: React.FC = () => { + const { t } = useAppTranslation() + const [activeTab, setActiveTab] = useState("commands") + + return ( +
+ +
+ +
{t("settings:sections.commandsAndSkills")}
+
+
+ +
+ {/* Tab Bar */} +
+ setActiveTab("commands")} + icon={}> + {t("settings:commandsAndSkills.tabCommands")} + + setActiveTab("skills")} + icon={}> + {t("settings:commandsAndSkills.tabSkills")} + +
+ + {/* Tab Content */} + {activeTab === "commands" ? : } +
+
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 6331f13edf9..01bdc49daa0 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -75,7 +75,7 @@ import { LanguageSettings } from "./LanguageSettings" import { About } from "./About" import { Section } from "./Section" import PromptsSettings from "./PromptsSettings" -import { SlashCommandsSettings } from "./SlashCommandsSettings" +import { CommandsAndSkillsSettings } from "./CommandsAndSkillsSettings" import { UISettings } from "./UISettings" import ModesView from "../modes/ModesView" import McpView from "../mcp/McpView" @@ -729,8 +729,8 @@ const SettingsView = forwardRef(({ onDone, t /> )} - {/* Slash Commands Section */} - {activeTab === "slashCommands" && } + {/* Commands & Skills Section */} + {activeTab === "slashCommands" && } {/* Browser Section */} {activeTab === "browser" && ( diff --git a/webview-ui/src/components/settings/SkillItem.tsx b/webview-ui/src/components/settings/SkillItem.tsx new file mode 100644 index 00000000000..b44389b78a1 --- /dev/null +++ b/webview-ui/src/components/settings/SkillItem.tsx @@ -0,0 +1,79 @@ +import React from "react" +import { Edit, Trash2 } from "lucide-react" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { Button, StandardTooltip, Badge } from "@/components/ui" +import { vscode } from "@/utils/vscode" + +export interface SkillForUI { + name: string + description: string + source: "global" | "project" + filePath: string + mode?: string +} + +interface SkillItemProps { + skill: SkillForUI + onDelete: (skill: SkillForUI) => void +} + +export const SkillItem: React.FC = ({ skill, onDelete }) => { + const { t } = useAppTranslation() + + const handleEdit = () => { + vscode.postMessage({ + type: "openSkillFile", + text: skill.name, + values: { source: skill.source }, + }) + } + + const handleDelete = () => { + onDelete(skill) + } + + return ( +
+ {/* Skill name and description */} +
+
+ {skill.name} + {skill.mode && ( + + {skill.mode} + + )} +
+ {skill.description && ( +
{skill.description}
+ )} +
+ + {/* Action buttons */} +
+ + + + + + + +
+
+ ) +} diff --git a/webview-ui/src/components/settings/SkillsTab.tsx b/webview-ui/src/components/settings/SkillsTab.tsx new file mode 100644 index 00000000000..6eb75631d3d --- /dev/null +++ b/webview-ui/src/components/settings/SkillsTab.tsx @@ -0,0 +1,249 @@ +import React, { useState, useEffect, useMemo } from "react" +import { Plus, Globe, Folder } from "lucide-react" + +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 { SkillItem, type SkillForUI } from "./SkillItem" + +// Validation function for skill names +// Must be 1-64 lowercase characters with optional hyphens +// No leading/trailing hyphens, no consecutive hyphens +const validateSkillName = (name: string): boolean => { + const trimmed = name.trim() + if (trimmed.length === 0 || trimmed.length > 64) return false + // Must match backend validation: lowercase letters/numbers, hyphens allowed but no leading/trailing/consecutive + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(trimmed) +} + +export const SkillsTab: React.FC = () => { + const { t } = useAppTranslation() + const { skills, cwd } = useExtensionState() + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [skillToDelete, setSkillToDelete] = useState(null) + const [globalNewName, setGlobalNewName] = useState("") + const [workspaceNewName, setWorkspaceNewName] = useState("") + const [globalNameError, setGlobalNameError] = useState(false) + const [workspaceNameError, setWorkspaceNameError] = useState(false) + + // Check if we're in a workspace/project + const hasWorkspace = Boolean(cwd) + + // Request skills when component mounts + useEffect(() => { + handleRefresh() + }, []) + + const handleRefresh = () => { + vscode.postMessage({ type: "requestSkills" }) + } + + const handleDeleteClick = (skill: SkillForUI) => { + setSkillToDelete(skill) + setDeleteDialogOpen(true) + } + + const handleDeleteConfirm = () => { + if (skillToDelete) { + vscode.postMessage({ + type: "deleteSkill", + text: skillToDelete.name, + values: { source: skillToDelete.source }, + }) + setDeleteDialogOpen(false) + setSkillToDelete(null) + // Refresh the skills list after deletion + setTimeout(handleRefresh, 100) + } + } + + const handleDeleteCancel = () => { + setDeleteDialogOpen(false) + setSkillToDelete(null) + } + + const handleCreateSkill = (source: "global" | "project", name: string) => { + const trimmedName = name.trim() + if (!validateSkillName(trimmedName)) { + if (source === "global") { + setGlobalNameError(true) + } else { + setWorkspaceNameError(true) + } + return + } + + vscode.postMessage({ + type: "createSkill", + text: trimmedName, + values: { source }, + }) + + // Clear the input and refresh + if (source === "global") { + setGlobalNewName("") + setGlobalNameError(false) + } else { + setWorkspaceNewName("") + setWorkspaceNameError(false) + } + setTimeout(handleRefresh, 500) + } + + const handleGlobalNameChange = (value: string) => { + setGlobalNewName(value) + if (globalNameError) { + setGlobalNameError(!validateSkillName(value.trim()) && value.trim().length > 0) + } + } + + const handleWorkspaceNameChange = (value: string) => { + setWorkspaceNewName(value) + if (workspaceNameError) { + setWorkspaceNameError(!validateSkillName(value.trim()) && value.trim().length > 0) + } + } + + // Group skills by source + const globalSkills = useMemo(() => skills?.filter((s) => s.source === "global") || [], [skills]) + const workspaceSkills = useMemo(() => skills?.filter((s) => s.source === "project") || [], [skills]) + + return ( +
+ {/* Global Skills Section */} +
+
+ +

{t("settings:skills.global")}

+
+
+ {globalSkills.length === 0 ? ( +
+ {t("settings:skills.empty")} +
+ ) : ( + globalSkills.map((skill) => ( + + )) + )} + {/* New global skill input */} +
+
+ handleGlobalNameChange(e.target.value)} + placeholder={t("settings:skills.newGlobalPlaceholder")} + className={`flex-1 bg-vscode-input-background text-vscode-input-foreground placeholder-vscode-input-placeholderForeground border rounded px-2 py-1 text-sm focus:outline-none ${ + globalNameError + ? "border-red-500 focus:border-red-500" + : "border-vscode-input-border focus:border-vscode-focusBorder" + }`} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCreateSkill("global", globalNewName) + } + }} + /> + +
+ {globalNameError && ( + {t("settings:skills.invalidName")} + )} +
+
+
+ + {/* Workspace Skills Section - Only show if in a workspace */} + {hasWorkspace && ( +
+
+ +

{t("settings:skills.workspace")}

+
+
+ {workspaceSkills.length === 0 ? ( +
+ {t("settings:skills.empty")} +
+ ) : ( + workspaceSkills.map((skill) => ( + + )) + )} + {/* New workspace skill input */} +
+
+ handleWorkspaceNameChange(e.target.value)} + placeholder={t("settings:skills.newWorkspacePlaceholder")} + className={`flex-1 bg-vscode-input-background text-vscode-input-foreground placeholder-vscode-input-placeholderForeground border rounded px-2 py-1 text-sm focus:outline-none ${ + workspaceNameError + ? "border-red-500 focus:border-red-500" + : "border-vscode-input-border focus:border-vscode-focusBorder" + }`} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCreateSkill("project", workspaceNewName) + } + }} + /> + +
+ {workspaceNameError && ( + {t("settings:skills.invalidName")} + )} +
+
+
+ )} + + + + + {t("settings:skills.deleteDialog.title")} + + {t("settings:skills.deleteDialog.description", { name: skillToDelete?.name })} + + + + + {t("settings:skills.deleteDialog.cancel")} + + + {t("settings:skills.deleteDialog.confirm")} + + + + +
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/CommandsAndSkillsSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CommandsAndSkillsSettings.spec.tsx new file mode 100644 index 00000000000..00ed50352d9 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CommandsAndSkillsSettings.spec.tsx @@ -0,0 +1,108 @@ +import { render, screen, fireEvent } from "@testing-library/react" +import { CommandsAndSkillsSettings } from "../CommandsAndSkillsSettings" + +// Mock the vscode API +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock the translation context +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "settings:sections.commandsAndSkills": "Commands & Skills", + "settings:commandsAndSkills.tabCommands": "Slash Commands", + "settings:commandsAndSkills.tabSkills": "Skills", + "settings:slashCommands.description": "Manage your slash commands", + "settings:skills.global": "Global Skills", + "settings:skills.workspace": "Workspace Skills", + "settings:skills.empty": "No skills configured", + "settings:skills.newGlobalPlaceholder": "Enter skill name", + "settings:skills.newWorkspacePlaceholder": "Enter skill name", + "chat:slashCommands.globalCommands": "Global Commands", + "chat:slashCommands.workspaceCommands": "Workspace Commands", + "chat:slashCommands.builtInCommands": "Built-in Commands", + "chat:slashCommands.newGlobalCommandPlaceholder": "Enter command name", + "chat:slashCommands.newWorkspaceCommandPlaceholder": "Enter command name", + } + return translations[key] || key + }, + }), +})) + +// Mock extension state +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + commands: [], + skills: [], + cwd: "/test/workspace", + }), +})) + +// Mock the docLinks utility +vi.mock("@/utils/docLinks", () => ({ + buildDocLink: (path: string) => `https://docs.example.com/${path}`, +})) + +describe("CommandsAndSkillsSettings", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the section header", () => { + render() + + expect(screen.getByText("Commands & Skills")).toBeInTheDocument() + }) + + it("renders both tab buttons", () => { + render() + + expect(screen.getByText("Slash Commands")).toBeInTheDocument() + expect(screen.getByText("Skills")).toBeInTheDocument() + }) + + it("shows commands tab content by default", () => { + render() + + // Slash Commands tab should be active + expect(screen.getByText("Global Commands")).toBeInTheDocument() + }) + + it("switches to skills tab when clicked", () => { + render() + + // Click on Skills tab + const skillsTab = screen.getByText("Skills") + fireEvent.click(skillsTab) + + // Skills content should be visible + expect(screen.getByText("Global Skills")).toBeInTheDocument() + }) + + it("switches back to commands tab", () => { + render() + + // First switch to Skills + const skillsTab = screen.getByText("Skills") + fireEvent.click(skillsTab) + + // Then switch back to Commands + const commandsTab = screen.getByText("Slash Commands") + fireEvent.click(commandsTab) + + // Commands content should be visible + expect(screen.getByText("Global Commands")).toBeInTheDocument() + }) + + it("shows active state indicator on selected tab", () => { + render() + + // The commands tab should have the active styling + const commandsTab = screen.getByText("Slash Commands").closest("button") + expect(commandsTab?.className).toContain("text-vscode-foreground") + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SkillItem.spec.tsx b/webview-ui/src/components/settings/__tests__/SkillItem.spec.tsx new file mode 100644 index 00000000000..35699ef27e0 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SkillItem.spec.tsx @@ -0,0 +1,105 @@ +import { render, screen, fireEvent } from "@testing-library/react" +import { TooltipProvider } from "@/components/ui" +import { SkillItem, type SkillForUI } from "../SkillItem" + +// Mock the vscode API +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock the translation context +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Wrapper component to provide necessary context +const TestWrapper = ({ children }: { children: React.ReactNode }) => {children} + +const renderWithProviders = (ui: React.ReactElement) => { + return render(ui, { wrapper: TestWrapper }) +} + +describe("SkillItem", () => { + const mockSkill: SkillForUI = { + name: "test-skill", + description: "A test skill description", + source: "global", + filePath: "/path/to/skill", + } + + const mockOnDelete = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders skill name and description", () => { + renderWithProviders() + + expect(screen.getByText("test-skill")).toBeInTheDocument() + expect(screen.getByText("A test skill description")).toBeInTheDocument() + }) + + it("renders mode badge when skill has mode", () => { + const skillWithMode: SkillForUI = { + ...mockSkill, + mode: "code", + } + + renderWithProviders() + + expect(screen.getByText("code")).toBeInTheDocument() + }) + + it("does not render mode badge when skill has no mode", () => { + renderWithProviders() + + // There should be no badge element + expect(screen.queryByText(/^(code|architect|ask|debug)$/)).not.toBeInTheDocument() + }) + + it("calls onDelete when delete button is clicked", () => { + renderWithProviders() + + // Find and click the delete button (second button) + const buttons = screen.getAllByRole("button") + const deleteButton = buttons[1] // Second button is delete + + fireEvent.click(deleteButton) + + expect(mockOnDelete).toHaveBeenCalledWith(mockSkill) + }) + + it("posts message to open skill file when edit button is clicked", async () => { + const { vscode } = await import("@/utils/vscode") + + renderWithProviders() + + // Find and click the edit button (first button) + const buttons = screen.getAllByRole("button") + const editButton = buttons[0] // First button is edit + + fireEvent.click(editButton) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "openSkillFile", + text: "test-skill", + values: { source: "global" }, + }) + }) + + it("renders workspace skill correctly", () => { + const workspaceSkill: SkillForUI = { + ...mockSkill, + source: "project", + } + + renderWithProviders() + + expect(screen.getByText("test-skill")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SkillsTab.spec.tsx b/webview-ui/src/components/settings/__tests__/SkillsTab.spec.tsx new file mode 100644 index 00000000000..d7fe623b6bd --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SkillsTab.spec.tsx @@ -0,0 +1,131 @@ +import { render, screen, fireEvent } from "@testing-library/react" +import { TooltipProvider } from "@/components/ui" +import { SkillsTab } from "../SkillsTab" +import type { SkillForUI } from "../SkillItem" + +// Mock the vscode API +const mockPostMessage = vi.fn() +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: (...args: unknown[]) => mockPostMessage(...args), + }, +})) + +// Mock the translation context +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "settings:skills.global": "Global Skills", + "settings:skills.workspace": "Workspace Skills", + "settings:skills.empty": "No skills configured", + "settings:skills.newGlobalPlaceholder": "Enter skill name", + "settings:skills.newWorkspacePlaceholder": "Enter skill name", + "settings:skills.invalidName": "Invalid name", + "settings:skills.edit": "Edit", + "settings:skills.delete": "Delete", + "settings:skills.deleteDialog.title": "Delete Skill", + "settings:skills.deleteDialog.description": "Are you sure?", + "settings:skills.deleteDialog.cancel": "Cancel", + "settings:skills.deleteDialog.confirm": "Delete", + } + return translations[key] || key + }, + }), +})) + +// Mock extension state +const mockSkills: SkillForUI[] = [ + { name: "global-skill", description: "A global skill", source: "global", filePath: "/global/path" }, + { name: "workspace-skill", description: "A workspace skill", source: "project", filePath: "/workspace/path" }, +] + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + skills: mockSkills, + cwd: "/test/workspace", + }), +})) + +// Wrapper component to provide necessary context +const TestWrapper = ({ children }: { children: React.ReactNode }) => {children} + +const renderWithProviders = (ui: React.ReactElement) => { + return render(ui, { wrapper: TestWrapper }) +} + +describe("SkillsTab", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders global and workspace skills sections", () => { + renderWithProviders() + + expect(screen.getByText("Global Skills")).toBeInTheDocument() + expect(screen.getByText("Workspace Skills")).toBeInTheDocument() + }) + + it("displays skills from context", () => { + renderWithProviders() + + expect(screen.getByText("global-skill")).toBeInTheDocument() + expect(screen.getByText("workspace-skill")).toBeInTheDocument() + }) + + it("requests skills on mount", () => { + renderWithProviders() + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "requestSkills" }) + }) + + it("validates skill name input", () => { + renderWithProviders() + + const inputs = screen.getAllByPlaceholderText("Enter skill name") + const globalInput = inputs[0] + + // Enter invalid name (uppercase) + fireEvent.change(globalInput, { target: { value: "InvalidName" } }) + + // Try to submit with Enter + fireEvent.keyDown(globalInput, { key: "Enter" }) + + // The invalid name message should appear + expect(screen.getByText("Invalid name")).toBeInTheDocument() + }) + + it("creates skill with valid name", () => { + renderWithProviders() + + const inputs = screen.getAllByPlaceholderText("Enter skill name") + const globalInput = inputs[0] + + // Enter valid name + fireEvent.change(globalInput, { target: { value: "valid-skill" } }) + + // Submit with Enter + fireEvent.keyDown(globalInput, { key: "Enter" }) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "createSkill", + text: "valid-skill", + values: { source: "global" }, + }) + }) + + it("shows delete confirmation dialog when delete is clicked", async () => { + renderWithProviders() + + // Find all delete buttons (there should be 2 - one for each skill) + const deleteButtons = screen.getAllByRole("button").filter((btn) => btn.className.includes("hover:text-red")) + + // Click the first delete button + if (deleteButtons.length > 0) { + fireEvent.click(deleteButtons[0]) + + // Dialog should appear + expect(screen.getByText("Delete Skill")).toBeInTheDocument() + } + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 3fe5340bdbc..9693c4b1709 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -15,6 +15,7 @@ import { } from "@roo-code/types" import { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata, Command } from "@roo/ExtensionMessage" +import type { SkillForUI } from "@src/components/settings/SkillItem" import { findLastIndex } from "@roo/array" import { McpServer } from "@roo/mcp" import { checkExistKey } from "@roo/checkExistApiConfig" @@ -38,6 +39,7 @@ export interface ExtensionStateContextType extends ExtensionState { filePaths: string[] openedTabs: Array<{ label: string; isActive: boolean; path?: string }> commands: Command[] + skills: SkillForUI[] organizationAllowList: OrganizationAllowList organizationSettingsVersion: number cloudIsAuthenticated: boolean @@ -283,6 +285,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [filePaths, setFilePaths] = useState([]) const [openedTabs, setOpenedTabs] = useState>([]) const [commands, setCommands] = useState([]) + const [skills, setSkills] = useState([]) const [mcpServers, setMcpServers] = useState([]) const [currentCheckpoint, setCurrentCheckpoint] = useState() const [extensionRouterModels, setExtensionRouterModels] = useState(undefined) @@ -381,6 +384,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setCommands(message.commands ?? []) break } + case "skills": { + setSkills(message.skills ?? []) + break + } case "messageUpdated": { const clineMessage = message.clineMessage! setState((prevState) => { @@ -458,6 +465,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode filePaths, openedTabs, commands, + skills, soundVolume: state.soundVolume, ttsSpeed: state.ttsSpeed, fuzzyMatchThreshold: state.fuzzyMatchThreshold, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 32f6cedf2a3..4d11bbf675b 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -31,6 +31,7 @@ "contextManagement": "Context", "terminal": "Terminal", "slashCommands": "Comandes de barra", + "commandsAndSkills": "Comandes i Habilitats", "prompts": "Indicacions", "ui": "UI", "experimental": "Experimental", @@ -60,6 +61,26 @@ "slashCommands": { "description": "Gestiona les teves comandes de barra per executar ràpidament fluxos de treball i accions personalitzades. Aprèn-ne més" }, + "commandsAndSkills": { + "tabCommands": "Comandes de barra", + "tabSkills": "Habilitats" + }, + "skills": { + "global": "Habilitats globals", + "workspace": "Habilitats de l'espai de treball", + "empty": "No hi ha habilitats configurades", + "newGlobalPlaceholder": "Introdueix el nom de l'habilitat (p. ex., refactoritzar-codi)", + "newWorkspacePlaceholder": "Introdueix el nom de l'habilitat (p. ex., actualitzar-proves)", + "invalidName": "El nom ha de tenir entre 1 i 64 caràcters en minúscules, números i guions, començant per una lletra", + "edit": "Editar habilitat", + "delete": "Eliminar habilitat", + "deleteDialog": { + "title": "Eliminar habilitat", + "description": "Estàs segur que vols eliminar l'habilitat \"{{name}}\"? Aquesta acció no es pot desfer.", + "cancel": "Cancel·lar", + "confirm": "Eliminar" + } + }, "prompts": { "description": "Configura les indicacions de suport utilitzades per a accions ràpides com millorar indicacions, explicar codi i solucionar problemes. Aquestes indicacions ajuden Roo a proporcionar millor assistència per a tasques comunes de desenvolupament." }, diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index f3d7466b8a3..d4a1e86c612 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -31,6 +31,7 @@ "contextManagement": "Kontext", "terminal": "Terminal", "slashCommands": "Slash-Befehle", + "commandsAndSkills": "Befehle & Skills", "prompts": "Eingabeaufforderungen", "ui": "UI", "experimental": "Experimentell", @@ -60,6 +61,26 @@ "slashCommands": { "description": "Verwalte deine Slash-Befehle, um benutzerdefinierte Workflows und Aktionen schnell auszuführen. Mehr erfahren" }, + "commandsAndSkills": { + "tabCommands": "Slash-Befehle", + "tabSkills": "Skills" + }, + "skills": { + "global": "Globale Skills", + "workspace": "Workspace Skills", + "empty": "Keine Skills konfiguriert", + "newGlobalPlaceholder": "Skill-Namen eingeben (z.B. refactor-code)", + "newWorkspacePlaceholder": "Skill-Namen eingeben (z.B. update-tests)", + "invalidName": "Name muss 1-64 Kleinbuchstaben, Zahlen und Bindestriche haben, beginnend mit einem Buchstaben", + "edit": "Skill bearbeiten", + "delete": "Skill löschen", + "deleteDialog": { + "title": "Skill löschen", + "description": "Bist du sicher, dass du den Skill \"{{name}}\" löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.", + "cancel": "Abbrechen", + "confirm": "Löschen" + } + }, "prompts": { "description": "Konfiguriere Support-Prompts, die für schnelle Aktionen wie das Verbessern von Prompts, das Erklären von Code und das Beheben von Problemen verwendet werden. Diese Prompts helfen Roo dabei, bessere Unterstützung für häufige Entwicklungsaufgaben zu bieten." }, diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index b836ecbfc87..41adcabee11 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -30,7 +30,8 @@ "notifications": "Notifications", "contextManagement": "Context", "terminal": "Terminal", - "slashCommands": "Slash Commands", + "slashCommands": "Commands & Skills", + "commandsAndSkills": "Commands & Skills", "prompts": "Prompts", "ui": "UI", "experimental": "Experimental", @@ -60,6 +61,26 @@ "slashCommands": { "description": "Manage your slash commands to quickly execute custom workflows and actions. Learn more" }, + "commandsAndSkills": { + "tabCommands": "Slash Commands", + "tabSkills": "Skills" + }, + "skills": { + "global": "Global Skills", + "workspace": "Workspace Skills", + "empty": "No skills configured", + "newGlobalPlaceholder": "Enter skill name (e.g., refactor-code)", + "newWorkspacePlaceholder": "Enter skill name (e.g., update-tests)", + "invalidName": "Name must be 1-64 lowercase letters, numbers, and hyphens, starting with a letter", + "edit": "Edit Skill", + "delete": "Delete Skill", + "deleteDialog": { + "title": "Delete Skill", + "description": "Are you sure you want to delete the skill \"{{name}}\"? This action cannot be undone.", + "cancel": "Cancel", + "confirm": "Delete" + } + }, "ui": { "collapseThinking": { "label": "Collapse Thinking messages by default", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 011d39df35c..7a34d5e11dc 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -31,6 +31,7 @@ "contextManagement": "Contexto", "terminal": "Terminal", "slashCommands": "Comandos de Barra", + "commandsAndSkills": "Comandos y Habilidades", "prompts": "Indicaciones", "ui": "UI", "experimental": "Experimental", @@ -60,6 +61,26 @@ "slashCommands": { "description": "Gestiona tus comandos de barra para ejecutar rápidamente flujos de trabajo y acciones personalizadas. Saber más" }, + "commandsAndSkills": { + "tabCommands": "Comandos de barra", + "tabSkills": "Habilidades" + }, + "skills": { + "global": "Habilidades globales", + "workspace": "Habilidades del espacio de trabajo", + "empty": "No hay habilidades configuradas", + "newGlobalPlaceholder": "Introduce el nombre de la habilidad (ej. refactorizar-codigo)", + "newWorkspacePlaceholder": "Introduce el nombre de la habilidad (ej. actualizar-pruebas)", + "invalidName": "El nombre debe tener entre 1 y 64 caracteres en minúsculas, números y guiones, comenzando con una letra", + "edit": "Editar habilidad", + "delete": "Eliminar habilidad", + "deleteDialog": { + "title": "Eliminar habilidad", + "description": "¿Estás seguro de que quieres eliminar la habilidad \"{{name}}\"? Esta acción no se puede deshacer.", + "cancel": "Cancelar", + "confirm": "Eliminar" + } + }, "prompts": { "description": "Configura indicaciones de soporte que se utilizan para acciones rápidas como mejorar indicaciones, explicar código y solucionar problemas. Estas indicaciones ayudan a Roo a brindar mejor asistencia para tareas comunes de desarrollo." }, diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 57046c1f9dc..08f3743e1a1 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "Expérimental", "language": "Langue", - "about": "À propos de Roo Code" + "about": "À propos de Roo Code", + "commandsAndSkills": "Commandes et Compétences" }, "about": { "bugReport": { @@ -971,5 +972,25 @@ "label": "Exiger {{primaryMod}}+Entrée pour envoyer les messages", "description": "Lorsqu'activé, tu dois appuyer sur {{primaryMod}}+Entrée pour envoyer des messages au lieu de simplement Entrée" } + }, + "commandsAndSkills": { + "tabCommands": "Commandes slash", + "tabSkills": "Compétences" + }, + "skills": { + "global": "Compétences globales", + "workspace": "Compétences de l'espace de travail", + "empty": "Aucune compétence configurée", + "newGlobalPlaceholder": "Entrer le nom de la compétence (ex. refactoriser-code)", + "newWorkspacePlaceholder": "Entrer le nom de la compétence (ex. mettre-a-jour-tests)", + "invalidName": "Le nom doit contenir 1 à 64 lettres minuscules, chiffres et tirets, en commençant par une lettre", + "edit": "Modifier la compétence", + "delete": "Supprimer la compétence", + "deleteDialog": { + "title": "Supprimer la compétence", + "description": "Es-tu sûr de vouloir supprimer la compétence \"{{name}}\" ? Cette action ne peut pas être annulée.", + "cancel": "Annuler", + "confirm": "Supprimer" + } } } diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 589db4eb044..58cc5f5d8ad 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "प्रायोगिक", "language": "भाषा", - "about": "परिचय" + "about": "परिचय", + "commandsAndSkills": "कमांड और स्किल" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "संदेश भेजने के लिए {{primaryMod}}+Enter की आवश्यकता है", "description": "जब सक्षम हो, तो आपको केवल Enter के बजाय संदेश भेजने के लिए {{primaryMod}}+Enter दबाना होगा" } + }, + "commandsAndSkills": { + "tabCommands": "स्लैश कमांड", + "tabSkills": "स्किल" + }, + "skills": { + "global": "ग्लोबल स्किल", + "workspace": "वर्कस्पेस स्किल", + "empty": "कोई स्किल कॉन्फ़िगर नहीं की गई", + "newGlobalPlaceholder": "स्किल का नाम दर्ज करें (जैसे refactor-code)", + "newWorkspacePlaceholder": "स्किल का नाम दर्ज करें (जैसे update-tests)", + "invalidName": "नाम 1-64 लोअरकेस अक्षर, संख्या और हाइफ़न होना चाहिए, अक्षर से शुरू होना चाहिए", + "edit": "स्किल संपादित करें", + "delete": "स्किल हटाएं", + "deleteDialog": { + "title": "स्किल हटाएं", + "description": "क्या आप वाकई \"{{name}}\" स्किल को हटाना चाहते हैं? इस कार्रवाई को पूर्ववत नहीं किया जा सकता।", + "cancel": "रद्द करें", + "confirm": "हटाएं" + } } } diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 7722518ec13..1d5e78fd514 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "Eksperimental", "language": "Bahasa", - "about": "Tentang Roo Code" + "about": "Tentang Roo Code", + "commandsAndSkills": "Perintah & Keterampilan" }, "about": { "bugReport": { @@ -1001,5 +1002,25 @@ "label": "Memerlukan {{primaryMod}}+Enter untuk mengirim pesan", "description": "Ketika diaktifkan, kamu harus menekan {{primaryMod}}+Enter untuk mengirim pesan alih-alih hanya Enter" } + }, + "commandsAndSkills": { + "tabCommands": "Perintah Garis Miring", + "tabSkills": "Keterampilan" + }, + "skills": { + "global": "Keterampilan Global", + "workspace": "Keterampilan Workspace", + "empty": "Tidak ada keterampilan yang dikonfigurasi", + "newGlobalPlaceholder": "Masukkan nama keterampilan (mis. refactor-code)", + "newWorkspacePlaceholder": "Masukkan nama keterampilan (mis. update-tests)", + "invalidName": "Nama harus terdiri dari 1-64 huruf kecil, angka, dan tanda hubung, dimulai dengan huruf", + "edit": "Edit Keterampilan", + "delete": "Hapus Keterampilan", + "deleteDialog": { + "title": "Hapus Keterampilan", + "description": "Apakah kamu yakin ingin menghapus keterampilan \"{{name}}\"? Tindakan ini tidak dapat dibatalkan.", + "cancel": "Batal", + "confirm": "Hapus" + } } } diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 86269cfc770..cba0d62203a 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "Sperimentale", "language": "Lingua", - "about": "Informazioni su Roo Code" + "about": "Informazioni su Roo Code", + "commandsAndSkills": "Comandi e Competenze" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "Richiedi {{primaryMod}}+Invio per inviare messaggi", "description": "Quando abilitato, devi premere {{primaryMod}}+Invio per inviare messaggi invece di solo Invio" } + }, + "commandsAndSkills": { + "tabCommands": "Comandi slash", + "tabSkills": "Competenze" + }, + "skills": { + "global": "Competenze globali", + "workspace": "Competenze dell'area di lavoro", + "empty": "Nessuna competenza configurata", + "newGlobalPlaceholder": "Inserisci il nome della competenza (es. refactor-code)", + "newWorkspacePlaceholder": "Inserisci il nome della competenza (es. update-tests)", + "invalidName": "Il nome deve essere di 1-64 lettere minuscole, numeri e trattini, iniziando con una lettera", + "edit": "Modifica competenza", + "delete": "Elimina competenza", + "deleteDialog": { + "title": "Elimina competenza", + "description": "Sei sicuro di voler eliminare la competenza \"{{name}}\"? Questa azione non può essere annullata.", + "cancel": "Annulla", + "confirm": "Elimina" + } } } diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 6c347f9444b..5d23cd498b6 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "実験的", "language": "言語", - "about": "Roo Codeについて" + "about": "Roo Codeについて", + "commandsAndSkills": "コマンドとスキル" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "メッセージを送信するには{{primaryMod}}+Enterが必要", "description": "有効にすると、Enterだけでなく{{primaryMod}}+Enterを押してメッセージを送信する必要があります" } + }, + "commandsAndSkills": { + "tabCommands": "スラッシュコマンド", + "tabSkills": "スキル" + }, + "skills": { + "global": "グローバルスキル", + "workspace": "ワークスペーススキル", + "empty": "スキルが設定されていません", + "newGlobalPlaceholder": "スキル名を入力 (例: refactor-code)", + "newWorkspacePlaceholder": "スキル名を入力 (例: update-tests)", + "invalidName": "名前は1〜64文字の小文字、数字、ハイフンで、文字で始まる必要があります", + "edit": "スキルを編集", + "delete": "スキルを削除", + "deleteDialog": { + "title": "スキルを削除", + "description": "スキル \"{{name}}\" を削除してもよろしいですか?この操作は元に戻せません。", + "cancel": "キャンセル", + "confirm": "削除" + } } } diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 9f64ae8f676..209e05fc5a6 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "실험적", "language": "언어", - "about": "Roo Code 정보" + "about": "Roo Code 정보", + "commandsAndSkills": "명령어 및 스킬" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "메시지를 보내려면 {{primaryMod}}+Enter가 필요", "description": "활성화하면 Enter만으로는 안 되고 {{primaryMod}}+Enter를 눌러야 메시지를 보낼 수 있습니다" } + }, + "commandsAndSkills": { + "tabCommands": "슬래시 명령어", + "tabSkills": "스킬" + }, + "skills": { + "global": "전역 스킬", + "workspace": "워크스페이스 스킬", + "empty": "구성된 스킬이 없습니다", + "newGlobalPlaceholder": "스킬 이름 입력 (예: refactor-code)", + "newWorkspacePlaceholder": "스킬 이름 입력 (예: update-tests)", + "invalidName": "이름은 문자로 시작하는 1-64자의 소문자, 숫자, 하이픈이어야 합니다", + "edit": "스킬 편집", + "delete": "스킬 삭제", + "deleteDialog": { + "title": "스킬 삭제", + "description": "스킬 \"{{name}}\"을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "cancel": "취소", + "confirm": "삭제" + } } } diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index e23872b1dc3..99d8b8ad476 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "Experimenteel", "language": "Taal", - "about": "Over Roo Code" + "about": "Over Roo Code", + "commandsAndSkills": "Commando's & Vaardigheden" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "Vereist {{primaryMod}}+Enter om berichten te versturen", "description": "Wanneer ingeschakeld, moet je {{primaryMod}}+Enter indrukken om berichten te versturen in plaats van alleen Enter" } + }, + "commandsAndSkills": { + "tabCommands": "Slash-commando's", + "tabSkills": "Vaardigheden" + }, + "skills": { + "global": "Globale vaardigheden", + "workspace": "Workspace-vaardigheden", + "empty": "Geen vaardigheden geconfigureerd", + "newGlobalPlaceholder": "Voer vaardigheidsnaam in (bijv. refactor-code)", + "newWorkspacePlaceholder": "Voer vaardigheidsnaam in (bijv. update-tests)", + "invalidName": "Naam moet 1-64 kleine letters, cijfers en streepjes zijn, beginnend met een letter", + "edit": "Vaardigheid bewerken", + "delete": "Vaardigheid verwijderen", + "deleteDialog": { + "title": "Vaardigheid verwijderen", + "description": "Weet je zeker dat je de vaardigheid \"{{name}}\" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", + "cancel": "Annuleren", + "confirm": "Verwijderen" + } } } diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 0aae9a09301..aacfdf727d2 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "Eksperymentalne", "language": "Język", - "about": "O Roo Code" + "about": "O Roo Code", + "commandsAndSkills": "Polecenia i Umiejętności" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "Wymagaj {{primaryMod}}+Enter do wysyłania wiadomości", "description": "Po włączeniu musisz nacisnąć {{primaryMod}}+Enter, aby wysłać wiadomości, zamiast tylko Enter" } + }, + "commandsAndSkills": { + "tabCommands": "Polecenia slash", + "tabSkills": "Umiejętności" + }, + "skills": { + "global": "Globalne umiejętności", + "workspace": "Umiejętności workspace", + "empty": "Brak skonfigurowanych umiejętności", + "newGlobalPlaceholder": "Wprowadź nazwę umiejętności (np. refactor-code)", + "newWorkspacePlaceholder": "Wprowadź nazwę umiejętności (np. update-tests)", + "invalidName": "Nazwa musi składać się z 1-64 małych liter, cyfr i myślników, zaczynając od litery", + "edit": "Edytuj umiejętność", + "delete": "Usuń umiejętność", + "deleteDialog": { + "title": "Usuń umiejętność", + "description": "Czy na pewno chcesz usunąć umiejętność \"{{name}}\"? Ta operacja nie może zostać cofnięta.", + "cancel": "Anuluj", + "confirm": "Usuń" + } } } diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index b0d6073f142..21fc6dd5707 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "Experimental", "language": "Idioma", - "about": "Sobre" + "about": "Sobre", + "commandsAndSkills": "Comandos e Habilidades" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "Requer {{primaryMod}}+Enter para enviar mensagens", "description": "Quando ativado, você deve pressionar {{primaryMod}}+Enter para enviar mensagens em vez de apenas Enter" } + }, + "commandsAndSkills": { + "tabCommands": "Comandos de barra", + "tabSkills": "Habilidades" + }, + "skills": { + "global": "Habilidades globais", + "workspace": "Habilidades do workspace", + "empty": "Nenhuma habilidade configurada", + "newGlobalPlaceholder": "Digite o nome da habilidade (ex. refactor-code)", + "newWorkspacePlaceholder": "Digite o nome da habilidade (ex. update-tests)", + "invalidName": "O nome deve ter de 1 a 64 letras minúsculas, números e hífens, começando com uma letra", + "edit": "Editar habilidade", + "delete": "Excluir habilidade", + "deleteDialog": { + "title": "Excluir habilidade", + "description": "Tem certeza de que deseja excluir a habilidade \"{{name}}\"? Esta ação não pode ser desfeita.", + "cancel": "Cancelar", + "confirm": "Excluir" + } } } diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 5655c3408e0..a85ea91fa92 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "Экспериментальное", "language": "Язык", - "about": "О Roo Code" + "about": "О Roo Code", + "commandsAndSkills": "Команды и Навыки" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "Требовать {{primaryMod}}+Enter для отправки сообщений", "description": "Если включено, необходимо нажать {{primaryMod}}+Enter для отправки сообщений вместо простого Enter" } + }, + "commandsAndSkills": { + "tabCommands": "Slash-команды", + "tabSkills": "Навыки" + }, + "skills": { + "global": "Глобальные навыки", + "workspace": "Навыки рабочего пространства", + "empty": "Навыки не настроены", + "newGlobalPlaceholder": "Введите название навыка (например, refactor-code)", + "newWorkspacePlaceholder": "Введите название навыка (например, update-tests)", + "invalidName": "Имя должно состоять из 1-64 строчных букв, цифр и дефисов, начиная с буквы", + "edit": "Редактировать навык", + "delete": "Удалить навык", + "deleteDialog": { + "title": "Удалить навык", + "description": "Вы уверены, что хотите удалить навык \"{{name}}\"? Это действие нельзя отменить.", + "cancel": "Отмена", + "confirm": "Удалить" + } } } diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 1e3d128ec51..88ebd65ae1b 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "Deneysel", "language": "Dil", - "about": "Roo Code Hakkında" + "about": "Roo Code Hakkında", + "commandsAndSkills": "Komutlar ve Yetenekler" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "Mesaj göndermek için {{primaryMod}}+Enter gerekli", "description": "Etkinleştirildiğinde, sadece Enter yerine mesaj göndermek için {{primaryMod}}+Enter'a basmalısınız" } + }, + "commandsAndSkills": { + "tabCommands": "Slash komutları", + "tabSkills": "Yetenekler" + }, + "skills": { + "global": "Global yetenekler", + "workspace": "Workspace yetenekleri", + "empty": "Yapılandırılmış yetenek yok", + "newGlobalPlaceholder": "Yetenek adını gir (örn. refactor-code)", + "newWorkspacePlaceholder": "Yetenek adını gir (örn. update-tests)", + "invalidName": "Ad, bir harfle başlayan 1-64 küçük harf, sayı ve tire olmalıdır", + "edit": "Yeteneği düzenle", + "delete": "Yeteneği sil", + "deleteDialog": { + "title": "Yeteneği sil", + "description": "\"{{name}}\" yeteneğini silmek istediğinden emin misin? Bu işlem geri alınamaz.", + "cancel": "İptal", + "confirm": "Sil" + } } } diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index d7b3a6f63bc..91bdd3c3efb 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "Thử nghiệm", "language": "Ngôn ngữ", - "about": "Giới thiệu" + "about": "Giới thiệu", + "commandsAndSkills": "Lệnh và Kỹ năng" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "Yêu cầu {{primaryMod}}+Enter để gửi tin nhắn", "description": "Khi được bật, bạn phải nhấn {{primaryMod}}+Enter để gửi tin nhắn thay vì chỉ nhấn Enter" } + }, + "commandsAndSkills": { + "tabCommands": "Lệnh gạch chéo", + "tabSkills": "Kỹ năng" + }, + "skills": { + "global": "Kỹ năng toàn cục", + "workspace": "Kỹ năng workspace", + "empty": "Không có kỹ năng được cấu hình", + "newGlobalPlaceholder": "Nhập tên kỹ năng (vd: refactor-code)", + "newWorkspacePlaceholder": "Nhập tên kỹ năng (vd: update-tests)", + "invalidName": "Tên phải có 1-64 chữ cái thường, số và dấu gạch ngang, bắt đầu bằng chữ cái", + "edit": "Chỉnh sửa kỹ năng", + "delete": "Xóa kỹ năng", + "deleteDialog": { + "title": "Xóa kỹ năng", + "description": "Bạn có chắc chắn muốn xóa kỹ năng \"{{name}}\" không? Hành động này không thể hoàn tác.", + "cancel": "Hủy", + "confirm": "Xóa" + } } } diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 69edd6ca813..a32be37a4af 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "实验性", "language": "语言", - "about": "关于 Roo Code" + "about": "关于 Roo Code", + "commandsAndSkills": "命令和技能" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "需要 {{primaryMod}}+Enter 发送消息", "description": "启用后,必须按 {{primaryMod}}+Enter 发送消息,而不仅仅是 Enter" } + }, + "commandsAndSkills": { + "tabCommands": "Slash 命令", + "tabSkills": "技能" + }, + "skills": { + "global": "全局技能", + "workspace": "工作区技能", + "empty": "未配置技能", + "newGlobalPlaceholder": "输入技能名称(例如 refactor-code)", + "newWorkspacePlaceholder": "输入技能名称(例如 update-tests)", + "invalidName": "名称必须是 1-64 个小写字母、数字和连字符,以字母开头", + "edit": "编辑技能", + "delete": "删除技能", + "deleteDialog": { + "title": "删除技能", + "description": "确定要删除技能 \"{{name}}\" 吗?此操作不可撤销。", + "cancel": "取消", + "confirm": "删除" + } } } diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index c9e6bff2cdb..634cbf01685 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -35,7 +35,8 @@ "ui": "UI", "experimental": "實驗性", "language": "語言", - "about": "關於 Roo Code" + "about": "關於 Roo Code", + "commandsAndSkills": "指令與技能" }, "about": { "bugReport": { @@ -972,5 +973,25 @@ "label": "需要 {{primaryMod}}+Enter 傳送訊息", "description": "啟用後,必須按 {{primaryMod}}+Enter 傳送訊息,而不只是 Enter" } + }, + "commandsAndSkills": { + "tabCommands": "Slash 指令", + "tabSkills": "技能" + }, + "skills": { + "global": "全域技能", + "workspace": "工作區技能", + "empty": "未設定技能", + "newGlobalPlaceholder": "輸入技能名稱(例如 refactor-code)", + "newWorkspacePlaceholder": "輸入技能名稱(例如 update-tests)", + "invalidName": "名稱必須為 1-64 個小寫字母、數字和連字號,以字母開頭", + "edit": "編輯技能", + "delete": "刪除技能", + "deleteDialog": { + "title": "刪除技能", + "description": "確定要刪除技能 \"{{name}}\" 嗎?此操作無法復原。", + "cancel": "取消", + "confirm": "刪除" + } } } diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index 7521c473ea1..643c8baa6f7 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -88,6 +88,7 @@ --vscode-input-border, transparent ); /* Some themes don't have a border color, so we can fallback to transparent */ + --color-vscode-input-placeholderForeground: var(--vscode-input-placeholderForeground); --color-vscode-focusBorder: var(--vscode-focusBorder);