diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index b4e63f8775b..a5404acec52 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -21,6 +21,15 @@ import type { McpServer } from "./mcp.js" import type { ModelRecord, RouterModels } from "./model.js" import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js" +// Skill interface for frontend/backend communication +export interface SkillForUI { + name: string + description: string + source: "global" | "project" + filePath: string + mode?: string +} + /** * ExtensionMessage * Extension -> Webview | CLI diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 33fa12ca78c..a535f58c011 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2217,6 +2217,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 ce4646418bf..d57d67b2954 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3158,6 +3158,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/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index d86007e80fe..b08371af96e 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -28,6 +28,7 @@ import { Server, Users2, ArrowLeft, + Sparkles, } from "lucide-react" import { @@ -76,6 +77,7 @@ import { About } from "./About" import { Section } from "./Section" import PromptsSettings from "./PromptsSettings" import { SlashCommandsSettings } from "./SlashCommandsSettings" +import { SkillsSettings } from "./SkillsSettings" import { UISettings } from "./UISettings" import ModesView from "../modes/ModesView" import McpView from "../mcp/McpView" @@ -97,6 +99,7 @@ export const sectionNames = [ "providers", "autoApprove", "slashCommands", + "skills", "browser", "checkpoints", "notifications", @@ -525,6 +528,7 @@ const SettingsView = forwardRef(({ onDone, t { id: "mcp", icon: Server }, { id: "autoApprove", icon: CheckCheck }, { id: "slashCommands", icon: SquareSlash }, + { id: "skills", icon: Sparkles }, { id: "browser", icon: SquareMousePointer }, { id: "checkpoints", icon: GitBranch }, { id: "notifications", icon: Bell }, @@ -811,8 +815,11 @@ const SettingsView = forwardRef(({ onDone, t /> )} - {/* Slash Commands Section */} - {renderTab === "slashCommands" && } + {/* Slash Commands Section */} + {renderTab === "slashCommands" && } + + {/* Skills Section */} + {renderTab === "skills" && } {/* Browser Section */} {renderTab === "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/SkillsSettings.tsx b/webview-ui/src/components/settings/SkillsSettings.tsx new file mode 100644 index 00000000000..8c07434b1c5 --- /dev/null +++ b/webview-ui/src/components/settings/SkillsSettings.tsx @@ -0,0 +1,27 @@ +import React from "react" +import { Sparkles } from "lucide-react" + +import { useAppTranslation } from "@/i18n/TranslationContext" + +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" +import { SkillsTab } from "./SkillsTab" + +export const SkillsSettings: React.FC = () => { + const { t } = useAppTranslation() + + return ( +
+ +
+ +
{t("settings:sections.skills")}
+
+
+ +
+ +
+
+ ) +} 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__/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__/SkillsSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/SkillsSettings.spec.tsx new file mode 100644 index 00000000000..f627e913826 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SkillsSettings.spec.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react" +import { SkillsSettings } from "../SkillsSettings" + +// 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.skills": "Skills", + "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", + } + return translations[key] || key + }, + }), +})) + +// Mock extension state +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + skills: [], + cwd: "/test/workspace", + }), +})) + +describe("SkillsSettings", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the section header", () => { + render() + + expect(screen.getByText("Skills")).toBeInTheDocument() + }) + + it("renders the skills tab content", () => { + render() + + expect(screen.getByText("Global Skills")).toBeInTheDocument() + }) + + it("shows workspace skills when in workspace", () => { + render() + + expect(screen.getByText("Workspace Skills")).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/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 5e37dc1a7df..be8241caafc 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -69,6 +69,22 @@ "slashCommands": { "description": "Gestiona les teves comandes de barra per executar ràpidament fluxos de treball i accions personalitzades. Aprèn-ne més" }, + "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 5b9568f30e2..32fb50a4d02 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -69,6 +69,22 @@ "slashCommands": { "description": "Verwalte deine Slash-Befehle, um benutzerdefinierte Workflows und Aktionen schnell auszuführen. Mehr erfahren" }, + "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 bce0ccfbd93..f4b2d820dde 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -36,6 +36,7 @@ "contextManagement": "Context", "terminal": "Terminal", "slashCommands": "Slash Commands", + "skills": "Skills", "prompts": "Prompts", "ui": "UI", "experimental": "Experimental", @@ -69,6 +70,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 a7a1bb71c60..348fe6d1e5c 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -69,6 +69,22 @@ "slashCommands": { "description": "Gestiona tus comandos de barra para ejecutar rápidamente flujos de trabajo y acciones personalizadas. Saber más" }, + "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 4982b67030c..913e98d8755 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -1010,5 +1010,21 @@ "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" } + }, + "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 030e920a03f..095ef5c1456 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -1011,5 +1011,21 @@ "label": "संदेश भेजने के लिए {{primaryMod}}+Enter की आवश्यकता है", "description": "जब सक्षम हो, तो आपको केवल Enter के बजाय संदेश भेजने के लिए {{primaryMod}}+Enter दबाना होगा" } + }, + "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 1ee8bdd64c0..9dca052cd3c 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -1040,5 +1040,21 @@ "label": "Memerlukan {{primaryMod}}+Enter untuk mengirim pesan", "description": "Ketika diaktifkan, kamu harus menekan {{primaryMod}}+Enter untuk mengirim pesan alih-alih hanya Enter" } + }, + "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 9fb92674447..62f7a3fb7ce 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -1011,5 +1011,21 @@ "label": "Richiedi {{primaryMod}}+Invio per inviare messaggi", "description": "Quando abilitato, devi premere {{primaryMod}}+Invio per inviare messaggi invece di solo Invio" } + }, + "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 999bb640d0c..576a0f0671d 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -1011,5 +1011,21 @@ "label": "メッセージを送信するには{{primaryMod}}+Enterが必要", "description": "有効にすると、Enterだけでなく{{primaryMod}}+Enterを押してメッセージを送信する必要があります" } + }, + "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 428461b75ae..0f0a90a81c4 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -1011,5 +1011,21 @@ "label": "메시지를 보내려면 {{primaryMod}}+Enter가 필요", "description": "활성화하면 Enter만으로는 안 되고 {{primaryMod}}+Enter를 눌러야 메시지를 보낼 수 있습니다" } + }, + "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 b1bb0b80dcc..df83b75be03 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -1011,5 +1011,21 @@ "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" } + }, + "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 910f116c143..ad4416a7097 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -1011,5 +1011,21 @@ "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" } + }, + "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 7a47c0f5923..d5cf78edfbb 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -1011,5 +1011,21 @@ "label": "Requer {{primaryMod}}+Enter para enviar mensagens", "description": "Quando ativado, você deve pressionar {{primaryMod}}+Enter para enviar mensagens em vez de apenas Enter" } + }, + "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 a1141bc9d6a..aa65b8d59e0 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -1011,5 +1011,21 @@ "label": "Требовать {{primaryMod}}+Enter для отправки сообщений", "description": "Если включено, необходимо нажать {{primaryMod}}+Enter для отправки сообщений вместо простого Enter" } + }, + "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 51f4e01cdad..b124fc0526e 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -1011,5 +1011,21 @@ "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" } + }, + "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 b2761fec8a4..bdb5f2533af 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -1011,5 +1011,21 @@ "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" } + }, + "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 7dce71a42d7..f4f042238ef 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -1011,5 +1011,21 @@ "label": "需要 {{primaryMod}}+Enter 发送消息", "description": "启用后,必须按 {{primaryMod}}+Enter 发送消息,而不仅仅是 Enter" } + }, + "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 c40be1d11a8..95e1dd14297 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -1011,5 +1011,21 @@ "label": "需要 {{primaryMod}}+Enter 傳送訊息", "description": "啟用後,必須按 {{primaryMod}}+Enter 傳送訊息,而不只是 Enter" } + }, + "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 28dbf06aef1..4f3de40c1d3 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);