feat: add mode dropdown to change skill mode dynamically (#10513) (#11102)

Co-authored-by: Sannidhya <sann@Sannidhyas-MacBook-Pro.local>
This commit is contained in:
SannidhyaSah 2026-01-31 10:52:49 +05:30 committed by GitHub
parent 3400499917
commit 16fbabf2a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 899 additions and 37 deletions

View file

@ -604,6 +604,7 @@ export interface WebviewMessage {
| "requestSkills"
| "createSkill"
| "deleteSkill"
| "moveSkill"
| "openSkillFile"
text?: string
editedMessageContent?: string
@ -639,8 +640,9 @@ export interface WebviewMessage {
timeout?: number
payload?: WebViewMessagePayload
source?: "global" | "project" | "built-in"
skillName?: string // For skill operations (createSkill, deleteSkill, openSkillFile)
skillMode?: string // For skill operations (mode restriction)
skillName?: string // For skill operations (createSkill, deleteSkill, moveSkill, openSkillFile)
skillMode?: string // For skill operations (current mode restriction)
newSkillMode?: string // For moveSkill (target mode)
skillDescription?: string // For createSkill (skill description)
requestId?: string
ids?: string[]

View file

@ -26,7 +26,9 @@ vi.mock("../../../i18n", () => ({
"skills:errors.missing_create_fields": "Missing required fields: skillName, source, or skillDescription",
"skills:errors.manager_unavailable": "Skills manager not available",
"skills:errors.missing_delete_fields": "Missing required fields: skillName or source",
"skills:errors.missing_move_fields": "Missing required fields: skillName or source",
"skills:errors.skill_not_found": `Skill "${params?.name}" not found`,
"skills:errors.cannot_modify_builtin": "Built-in skills cannot be created, deleted, or moved",
}
return translations[key] || key
},
@ -34,7 +36,13 @@ vi.mock("../../../i18n", () => ({
import * as vscode from "vscode"
import { openFile } from "../../../integrations/misc/open-file"
import { handleRequestSkills, handleCreateSkill, handleDeleteSkill, handleOpenSkillFile } from "../skillsMessageHandler"
import {
handleRequestSkills,
handleCreateSkill,
handleDeleteSkill,
handleMoveSkill,
handleOpenSkillFile,
} from "../skillsMessageHandler"
describe("skillsMessageHandler", () => {
const mockLog = vi.fn()
@ -42,6 +50,7 @@ describe("skillsMessageHandler", () => {
const mockGetSkillsMetadata = vi.fn()
const mockCreateSkill = vi.fn()
const mockDeleteSkill = vi.fn()
const mockMoveSkill = vi.fn()
const mockGetSkill = vi.fn()
const createMockProvider = (hasSkillsManager: boolean = true): ClineProvider => {
@ -50,6 +59,7 @@ describe("skillsMessageHandler", () => {
getSkillsMetadata: mockGetSkillsMetadata,
createSkill: mockCreateSkill,
deleteSkill: mockDeleteSkill,
moveSkill: mockMoveSkill,
getSkill: mockGetSkill,
}
: undefined
@ -253,6 +263,95 @@ describe("skillsMessageHandler", () => {
})
})
describe("handleMoveSkill", () => {
it("moves a skill successfully", async () => {
const provider = createMockProvider(true)
mockMoveSkill.mockResolvedValue(undefined)
mockGetSkillsMetadata.mockReturnValue([mockSkills[0]])
const result = await handleMoveSkill(provider, {
type: "moveSkill",
skillName: "test-skill",
source: "global",
skillMode: undefined,
newSkillMode: "code",
} as WebviewMessage)
expect(result).toEqual([mockSkills[0]])
expect(mockMoveSkill).toHaveBeenCalledWith("test-skill", "global", undefined, "code")
expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [mockSkills[0]] })
})
it("moves a skill from one mode to another", async () => {
const provider = createMockProvider(true)
mockMoveSkill.mockResolvedValue(undefined)
mockGetSkillsMetadata.mockReturnValue([mockSkills[1]])
const result = await handleMoveSkill(provider, {
type: "moveSkill",
skillName: "project-skill",
source: "project",
skillMode: "code",
newSkillMode: "architect",
} as WebviewMessage)
expect(result).toEqual([mockSkills[1]])
expect(mockMoveSkill).toHaveBeenCalledWith("project-skill", "project", "code", "architect")
})
it("returns undefined when required fields are missing", async () => {
const provider = createMockProvider(true)
const result = await handleMoveSkill(provider, {
type: "moveSkill",
skillName: "test-skill",
// missing source
} as WebviewMessage)
expect(result).toBeUndefined()
expect(mockLog).toHaveBeenCalledWith("Error moving skill: Missing required fields: skillName or source")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
"Failed to move skill: Missing required fields: skillName or source",
)
})
it("returns undefined when skills manager is not available", async () => {
const provider = createMockProvider(false)
const result = await handleMoveSkill(provider, {
type: "moveSkill",
skillName: "test-skill",
source: "global",
newSkillMode: "code",
} as WebviewMessage)
expect(result).toBeUndefined()
expect(mockLog).toHaveBeenCalledWith("Error moving skill: Skills manager not available")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
"Failed to move skill: Skills manager not available",
)
})
it("returns undefined when trying to move a built-in skill", async () => {
const provider = createMockProvider(true)
const result = await handleMoveSkill(provider, {
type: "moveSkill",
skillName: "test-skill",
source: "built-in",
newSkillMode: "code",
} as WebviewMessage)
expect(result).toBeUndefined()
expect(mockLog).toHaveBeenCalledWith(
"Error moving skill: Built-in skills cannot be created, deleted, or moved",
)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
"Failed to move skill: Built-in skills cannot be created, deleted, or moved",
)
})
})
describe("handleOpenSkillFile", () => {
it("opens a skill file successfully", async () => {
const provider = createMockProvider(true)

View file

@ -111,6 +111,47 @@ export async function handleDeleteSkill(
}
}
/**
* Handles the moveSkill message - moves a skill to a different mode
*/
export async function handleMoveSkill(
provider: ClineProvider,
message: WebviewMessage,
): Promise<SkillMetadata[] | undefined> {
try {
const skillName = message.skillName
const source = message.source
const currentMode = message.skillMode
const newMode = message.newSkillMode
if (!skillName || !source) {
throw new Error(t("skills:errors.missing_move_fields"))
}
// Built-in skills cannot be moved
if (source === "built-in") {
throw new Error(t("skills:errors.cannot_modify_builtin"))
}
const skillsManager = provider.getSkillsManager()
if (!skillsManager) {
throw new Error(t("skills:errors.manager_unavailable"))
}
await skillsManager.moveSkill(skillName, source, currentMode, newMode)
// Send updated skills list
const skills = skillsManager.getSkillsMetadata()
await provider.postMessageToWebview({ type: "skills", skills })
return skills
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
provider.log(`Error moving skill: ${errorMessage}`)
vscode.window.showErrorMessage(`Failed to move skill: ${errorMessage}`)
return undefined
}
}
/**
* Handles the openSkillFile message - opens a skill file in the editor
*/

View file

@ -32,7 +32,13 @@ import { ClineProvider } from "./ClineProvider"
import { BrowserSessionPanelManager } from "./BrowserSessionPanelManager"
import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler"
import { generateErrorDiagnostics } from "./diagnosticsHandler"
import { handleRequestSkills, handleCreateSkill, handleDeleteSkill, handleOpenSkillFile } from "./skillsMessageHandler"
import {
handleRequestSkills,
handleCreateSkill,
handleDeleteSkill,
handleMoveSkill,
handleOpenSkillFile,
} from "./skillsMessageHandler"
import { changeLanguage, t } from "../../i18n"
import { Package } from "../../shared/package"
import { type RouterName, toRouterName } from "../../shared/api"
@ -2982,6 +2988,10 @@ export const webviewMessageHandler = async (
await handleDeleteSkill(provider, message)
break
}
case "moveSkill": {
await handleMoveSkill(provider, message)
break
}
case "openSkillFile": {
await handleOpenSkillFile(provider, message)
break

View file

@ -7,6 +7,7 @@
"already_exists": "L'habilitat \"{{name}}\" ja existeix a {{path}}",
"not_found": "No s'ha trobat l'habilitat \"{{name}}\" a {{source}}{{modeInfo}}",
"missing_create_fields": "Falten camps obligatoris: skillName, source o skillDescription",
"missing_move_fields": "Falten camps obligatoris: skillName o source",
"manager_unavailable": "El gestor d'habilitats no està disponible",
"missing_delete_fields": "Falten camps obligatoris: skillName o source",
"skill_not_found": "No s'ha trobat l'habilitat \"{{name}}\"",

View file

@ -7,6 +7,7 @@
"already_exists": "Skill \"{{name}}\" existiert bereits unter {{path}}",
"not_found": "Skill \"{{name}}\" nicht gefunden in {{source}}{{modeInfo}}",
"missing_create_fields": "Erforderliche Felder fehlen: skillName, source oder skillDescription",
"missing_move_fields": "Erforderliche Felder fehlen: skillName oder source",
"manager_unavailable": "Skill-Manager nicht verfügbar",
"missing_delete_fields": "Erforderliche Felder fehlen: skillName oder source",
"skill_not_found": "Skill \"{{name}}\" nicht gefunden",

View file

@ -7,10 +7,11 @@
"already_exists": "Skill \"{{name}}\" already exists at {{path}}",
"not_found": "Skill \"{{name}}\" not found in {{source}}{{modeInfo}}",
"missing_create_fields": "Missing required fields: skillName, source, or skillDescription",
"missing_move_fields": "Missing required fields: skillName or source",
"manager_unavailable": "Skills manager not available",
"missing_delete_fields": "Missing required fields: skillName or source",
"skill_not_found": "Skill \"{{name}}\" not found",
"cannot_modify_builtin": "Built-in skills cannot be created or deleted",
"cannot_modify_builtin": "Built-in skills cannot be created, deleted, or moved",
"cannot_open_builtin": "Built-in skills cannot be opened as files"
}
}

View file

@ -7,6 +7,7 @@
"already_exists": "La habilidad \"{{name}}\" ya existe en {{path}}",
"not_found": "No se encontró la habilidad \"{{name}}\" en {{source}}{{modeInfo}}",
"missing_create_fields": "Faltan campos obligatorios: skillName, source o skillDescription",
"missing_move_fields": "Faltan campos obligatorios: skillName o source",
"manager_unavailable": "El gestor de habilidades no está disponible",
"missing_delete_fields": "Faltan campos obligatorios: skillName o source",
"skill_not_found": "No se encontró la habilidad \"{{name}}\"",

View file

@ -7,6 +7,7 @@
"already_exists": "La compétence \"{{name}}\" existe déjà à {{path}}",
"not_found": "Compétence \"{{name}}\" introuvable dans {{source}}{{modeInfo}}",
"missing_create_fields": "Champs obligatoires manquants : skillName, source ou skillDescription",
"missing_move_fields": "Champs obligatoires manquants : skillName ou source",
"manager_unavailable": "Le gestionnaire de compétences n'est pas disponible",
"missing_delete_fields": "Champs obligatoires manquants : skillName ou source",
"skill_not_found": "Compétence \"{{name}}\" introuvable",

View file

@ -7,6 +7,7 @@
"already_exists": "स्किल \"{{name}}\" पहले से {{path}} पर मौजूद है",
"not_found": "स्किल \"{{name}}\" {{source}}{{modeInfo}} में नहीं मिला",
"missing_create_fields": "आवश्यक फ़ील्ड गायब हैं: skillName, source, या skillDescription",
"missing_move_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source",
"manager_unavailable": "स्किल मैनेजर उपलब्ध नहीं है",
"missing_delete_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source",
"skill_not_found": "स्किल \"{{name}}\" नहीं मिला",

View file

@ -7,6 +7,7 @@
"already_exists": "Skill \"{{name}}\" sudah ada di {{path}}",
"not_found": "Skill \"{{name}}\" tidak ditemukan di {{source}}{{modeInfo}}",
"missing_create_fields": "Bidang wajib tidak ada: skillName, source, atau skillDescription",
"missing_move_fields": "Bidang wajib tidak ada: skillName atau source",
"manager_unavailable": "Manajer skill tidak tersedia",
"missing_delete_fields": "Bidang wajib tidak ada: skillName atau source",
"skill_not_found": "Skill \"{{name}}\" tidak ditemukan",

View file

@ -7,6 +7,7 @@
"already_exists": "La skill \"{{name}}\" esiste già in {{path}}",
"not_found": "Skill \"{{name}}\" non trovata in {{source}}{{modeInfo}}",
"missing_create_fields": "Campi obbligatori mancanti: skillName, source o skillDescription",
"missing_move_fields": "Campi obbligatori mancanti: skillName o source",
"manager_unavailable": "Il gestore delle skill non è disponibile",
"missing_delete_fields": "Campi obbligatori mancanti: skillName o source",
"skill_not_found": "Skill \"{{name}}\" non trovata",

View file

@ -7,6 +7,7 @@
"already_exists": "スキル「{{name}}」は既に{{path}}に存在します",
"not_found": "スキル「{{name}}」が{{source}}{{modeInfo}}に見つかりません",
"missing_create_fields": "必須フィールドが不足していますskillName、source、またはskillDescription",
"missing_move_fields": "必須フィールドが不足していますskillNameまたはsource",
"manager_unavailable": "スキルマネージャーが利用できません",
"missing_delete_fields": "必須フィールドが不足していますskillNameまたはsource",
"skill_not_found": "スキル「{{name}}」が見つかりません",

View file

@ -7,6 +7,7 @@
"already_exists": "스킬 \"{{name}}\"이(가) 이미 {{path}}에 존재합니다",
"not_found": "{{source}}{{modeInfo}}에서 스킬 \"{{name}}\"을(를) 찾을 수 없습니다",
"missing_create_fields": "필수 필드 누락: skillName, source 또는 skillDescription",
"missing_move_fields": "필수 필드 누락: skillName 또는 source",
"manager_unavailable": "스킬 관리자를 사용할 수 없습니다",
"missing_delete_fields": "필수 필드 누락: skillName 또는 source",
"skill_not_found": "스킬 \"{{name}}\"을(를) 찾을 수 없습니다",

View file

@ -7,6 +7,7 @@
"already_exists": "Vaardigheid \"{{name}}\" bestaat al op {{path}}",
"not_found": "Vaardigheid \"{{name}}\" niet gevonden in {{source}}{{modeInfo}}",
"missing_create_fields": "Vereiste velden ontbreken: skillName, source of skillDescription",
"missing_move_fields": "Vereiste velden ontbreken: skillName of source",
"manager_unavailable": "Vaardigheidenbeheerder niet beschikbaar",
"missing_delete_fields": "Vereiste velden ontbreken: skillName of source",
"skill_not_found": "Vaardigheid \"{{name}}\" niet gevonden",

View file

@ -7,6 +7,7 @@
"already_exists": "Umiejętność \"{{name}}\" już istnieje w {{path}}",
"not_found": "Nie znaleziono umiejętności \"{{name}}\" w {{source}}{{modeInfo}}",
"missing_create_fields": "Brakuje wymaganych pól: skillName, source lub skillDescription",
"missing_move_fields": "Brakuje wymaganych pól: skillName lub source",
"manager_unavailable": "Menedżer umiejętności niedostępny",
"missing_delete_fields": "Brakuje wymaganych pól: skillName lub source",
"skill_not_found": "Nie znaleziono umiejętności \"{{name}}\"",

View file

@ -7,6 +7,7 @@
"already_exists": "A habilidade \"{{name}}\" já existe em {{path}}",
"not_found": "Habilidade \"{{name}}\" não encontrada em {{source}}{{modeInfo}}",
"missing_create_fields": "Campos obrigatórios ausentes: skillName, source ou skillDescription",
"missing_move_fields": "Campos obrigatórios ausentes: skillName ou source",
"manager_unavailable": "Gerenciador de habilidades não disponível",
"missing_delete_fields": "Campos obrigatórios ausentes: skillName ou source",
"skill_not_found": "Habilidade \"{{name}}\" não encontrada",

View file

@ -7,6 +7,7 @@
"already_exists": "Навык \"{{name}}\" уже существует в {{path}}",
"not_found": "Навык \"{{name}}\" не найден в {{source}}{{modeInfo}}",
"missing_create_fields": "Отсутствуют обязательные поля: skillName, source или skillDescription",
"missing_move_fields": "Отсутствуют обязательные поля: skillName или source",
"manager_unavailable": "Менеджер навыков недоступен",
"missing_delete_fields": "Отсутствуют обязательные поля: skillName или source",
"skill_not_found": "Навык \"{{name}}\" не найден",

View file

@ -7,6 +7,7 @@
"already_exists": "\"{{name}}\" becerisi zaten {{path}} konumunda mevcut",
"not_found": "\"{{name}}\" becerisi {{source}}{{modeInfo}} içinde bulunamadı",
"missing_create_fields": "Gerekli alanlar eksik: skillName, source veya skillDescription",
"missing_move_fields": "Gerekli alanlar eksik: skillName veya source",
"manager_unavailable": "Beceri yöneticisi kullanılamıyor",
"missing_delete_fields": "Gerekli alanlar eksik: skillName veya source",
"skill_not_found": "\"{{name}}\" becerisi bulunamadı",

View file

@ -7,6 +7,7 @@
"already_exists": "Kỹ năng \"{{name}}\" đã tồn tại tại {{path}}",
"not_found": "Không tìm thấy kỹ năng \"{{name}}\" trong {{source}}{{modeInfo}}",
"missing_create_fields": "Thiếu các trường bắt buộc: skillName, source hoặc skillDescription",
"missing_move_fields": "Thiếu các trường bắt buộc: skillName hoặc source",
"manager_unavailable": "Trình quản lý kỹ năng không khả dụng",
"missing_delete_fields": "Thiếu các trường bắt buộc: skillName hoặc source",
"skill_not_found": "Không tìm thấy kỹ năng \"{{name}}\"",

View file

@ -7,6 +7,7 @@
"already_exists": "技能 \"{{name}}\" 已存在于 {{path}}",
"not_found": "在 {{source}}{{modeInfo}} 中未找到技能 \"{{name}}\"",
"missing_create_fields": "缺少必填字段skillName、source 或 skillDescription",
"missing_move_fields": "缺少必填字段skillName 或 source",
"manager_unavailable": "技能管理器不可用",
"missing_delete_fields": "缺少必填字段skillName 或 source",
"skill_not_found": "未找到技能 \"{{name}}\"",

View file

@ -7,6 +7,7 @@
"already_exists": "技能「{{name}}」已存在於 {{path}}",
"not_found": "在 {{source}}{{modeInfo}} 中找不到技能「{{name}}」",
"missing_create_fields": "缺少必填欄位skillName、source 或 skillDescription",
"missing_move_fields": "缺少必填欄位skillName 或 source",
"manager_unavailable": "技能管理器無法使用",
"missing_delete_fields": "缺少必填欄位skillName 或 source",
"skill_not_found": "找不到技能「{{name}}」",

View file

@ -400,6 +400,77 @@ Add your skill instructions here.
await this.discoverSkills()
}
/**
* Move a skill to a different mode
* @param name - Skill name to move
* @param source - Where the skill is located ("global" or "project")
* @param currentMode - Current mode (undefined for generic skills)
* @param newMode - Target mode (undefined for generic skills)
*/
async moveSkill(
name: string,
source: "global" | "project",
currentMode: string | undefined,
newMode: string | undefined,
): Promise<void> {
// Don't move if source and destination are the same
if (currentMode === newMode) {
return
}
// Find the skill at its current location
const skill = this.getSkill(name, source, currentMode)
if (!skill) {
const modeInfo = currentMode ? ` (mode: ${currentMode})` : ""
throw new Error(t("skills:errors.not_found", { name, source, modeInfo }))
}
// Determine base directory
let baseDir: string
if (source === "global") {
baseDir = getGlobalRooDirectory()
} else {
const provider = this.providerRef.deref()
if (!provider?.cwd) {
throw new Error(t("skills:errors.no_workspace"))
}
baseDir = path.join(provider.cwd, ".roo")
}
// Determine source and destination directories
const sourceDirName = currentMode ? `skills-${currentMode}` : "skills"
const destDirName = newMode ? `skills-${newMode}` : "skills"
const sourceDir = path.join(baseDir, sourceDirName, name)
const destSkillsDir = path.join(baseDir, destDirName)
const destDir = path.join(destSkillsDir, name)
const destSkillMdPath = path.join(destDir, "SKILL.md")
// Check if skill already exists at destination
if (await fileExists(destSkillMdPath)) {
throw new Error(t("skills:errors.already_exists", { name, path: destSkillMdPath }))
}
// Ensure destination skills directory exists
await fs.mkdir(destSkillsDir, { recursive: true })
// Move the skill directory
await fs.rename(sourceDir, destDir)
// Clean up empty source skills directory
const sourceSkillsDir = path.join(baseDir, sourceDirName)
try {
const entries = await fs.readdir(sourceSkillsDir)
if (entries.length === 0) {
await fs.rmdir(sourceSkillsDir)
}
} catch {
// Ignore errors - directory might not exist or have permission issues
}
// Refresh skills list
await this.discoverSkills()
}
/**
* Get all skills directories to scan, including mode-specific directories.
*/

View file

@ -12,6 +12,8 @@ const {
mockMkdir,
mockWriteFile,
mockRm,
mockRename,
mockRmdir,
} = vi.hoisted(() => ({
mockStat: vi.fn(),
mockReadFile: vi.fn(),
@ -23,6 +25,8 @@ const {
mockMkdir: vi.fn(),
mockWriteFile: vi.fn(),
mockRm: vi.fn(),
mockRename: vi.fn(),
mockRmdir: vi.fn(),
}))
// Platform-agnostic test paths
@ -44,6 +48,8 @@ vi.mock("fs/promises", () => ({
mkdir: mockMkdir,
writeFile: mockWriteFile,
rm: mockRm,
rename: mockRename,
rmdir: mockRmdir,
},
stat: mockStat,
readFile: mockReadFile,
@ -52,6 +58,8 @@ vi.mock("fs/promises", () => ({
mkdir: mockMkdir,
writeFile: mockWriteFile,
rm: mockRm,
rename: mockRename,
rmdir: mockRmdir,
}))
// Mock os module
@ -1134,4 +1142,402 @@ Instructions`)
await expect(skillsManager.deleteSkill("non-existent", "global")).rejects.toThrow("not found")
})
})
describe("moveSkill", () => {
it("should move a skill from generic to mode-specific directory", async () => {
const sourceDir = p(globalSkillsDir, "test-skill")
const testSkillMd = p(sourceDir, "SKILL.md")
const destDir = p(GLOBAL_ROO_DIR, "skills-code", "test-skill")
const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-code")
// Setup: skill exists in generic skills directory
mockDirectoryExists.mockImplementation(async (dir: string) => {
return dir === globalSkillsDir
})
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
mockReaddir.mockImplementation(async (dir: string) => {
if (dir === globalSkillsDir) {
return ["test-skill"]
}
return []
})
mockStat.mockImplementation(async (pathArg: string) => {
if (pathArg === sourceDir) {
return { isDirectory: () => true }
}
throw new Error("Not found")
})
mockFileExists.mockImplementation(async (file: string) => {
// Skill exists in source
if (file === testSkillMd) return true
// Skill does not exist in destination
if (file === p(destDir, "SKILL.md")) return false
return false
})
mockReadFile.mockResolvedValue(`---
name: test-skill
description: A test skill
---
Instructions`)
mockMkdir.mockResolvedValue(undefined)
mockRename.mockResolvedValue(undefined)
await skillsManager.discoverSkills()
// Verify skill exists
expect(skillsManager.getSkill("test-skill", "global")).toBeDefined()
// Move the skill to code mode
await skillsManager.moveSkill("test-skill", "global", undefined, "code")
expect(mockMkdir).toHaveBeenCalledWith(destSkillsDir, { recursive: true })
expect(mockRename).toHaveBeenCalledWith(sourceDir, destDir)
})
it("should move a skill from one mode to another", async () => {
const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code")
const sourceDir = p(sourceSkillsDir, "test-skill")
const testSkillMd = p(sourceDir, "SKILL.md")
const destDir = p(GLOBAL_ROO_DIR, "skills-architect", "test-skill")
const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-architect")
// Setup: skill exists in code mode directory
mockDirectoryExists.mockImplementation(async (dir: string) => {
return dir === sourceSkillsDir
})
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
mockReaddir.mockImplementation(async (dir: string) => {
if (dir === sourceSkillsDir) {
return ["test-skill"]
}
return []
})
mockStat.mockImplementation(async (pathArg: string) => {
if (pathArg === sourceDir) {
return { isDirectory: () => true }
}
throw new Error("Not found")
})
mockFileExists.mockImplementation(async (file: string) => {
// Skill exists in source
if (file === testSkillMd) return true
// Skill does not exist in destination
if (file === p(destDir, "SKILL.md")) return false
return false
})
mockReadFile.mockResolvedValue(`---
name: test-skill
description: A test skill
---
Instructions`)
mockMkdir.mockResolvedValue(undefined)
mockRename.mockResolvedValue(undefined)
await skillsManager.discoverSkills()
// Verify skill exists with mode
expect(skillsManager.getSkill("test-skill", "global", "code")).toBeDefined()
// Move the skill to architect mode
await skillsManager.moveSkill("test-skill", "global", "code", "architect")
expect(mockMkdir).toHaveBeenCalledWith(destSkillsDir, { recursive: true })
expect(mockRename).toHaveBeenCalledWith(sourceDir, destDir)
})
it("should move a skill from mode-specific to generic directory", async () => {
const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code")
const sourceDir = p(sourceSkillsDir, "test-skill")
const testSkillMd = p(sourceDir, "SKILL.md")
const destDir = p(globalSkillsDir, "test-skill")
// Setup: skill exists in code mode directory
mockDirectoryExists.mockImplementation(async (dir: string) => {
return dir === sourceSkillsDir
})
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
mockReaddir.mockImplementation(async (dir: string) => {
if (dir === sourceSkillsDir) {
return ["test-skill"]
}
return []
})
mockStat.mockImplementation(async (pathArg: string) => {
if (pathArg === sourceDir) {
return { isDirectory: () => true }
}
throw new Error("Not found")
})
mockFileExists.mockImplementation(async (file: string) => {
// Skill exists in source
if (file === testSkillMd) return true
// Skill does not exist in destination
if (file === p(destDir, "SKILL.md")) return false
return false
})
mockReadFile.mockResolvedValue(`---
name: test-skill
description: A test skill
---
Instructions`)
mockMkdir.mockResolvedValue(undefined)
mockRename.mockResolvedValue(undefined)
await skillsManager.discoverSkills()
// Verify skill exists with mode
expect(skillsManager.getSkill("test-skill", "global", "code")).toBeDefined()
// Move the skill to generic (no mode)
await skillsManager.moveSkill("test-skill", "global", "code", undefined)
expect(mockMkdir).toHaveBeenCalledWith(globalSkillsDir, { recursive: true })
expect(mockRename).toHaveBeenCalledWith(sourceDir, destDir)
})
it("should not do anything when source and destination modes are the same", async () => {
mockDirectoryExists.mockImplementation(async (dir: string) => {
return dir === globalSkillsDir
})
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
mockReaddir.mockImplementation(async (dir: string) => {
if (dir === globalSkillsDir) {
return ["test-skill"]
}
return []
})
const testSkillDir = p(globalSkillsDir, "test-skill")
mockStat.mockImplementation(async (pathArg: string) => {
if (pathArg === testSkillDir) {
return { isDirectory: () => true }
}
throw new Error("Not found")
})
mockFileExists.mockImplementation(async (file: string) => {
return file === p(testSkillDir, "SKILL.md")
})
mockReadFile.mockResolvedValue(`---
name: test-skill
description: A test skill
---
Instructions`)
await skillsManager.discoverSkills()
// Try to move skill to the same mode (undefined -> undefined)
await skillsManager.moveSkill("test-skill", "global", undefined, undefined)
// Should not call rename
expect(mockRename).not.toHaveBeenCalled()
})
it("should throw error if skill does not exist", async () => {
mockDirectoryExists.mockResolvedValue(false)
mockRealpath.mockImplementation(async (p: string) => p)
mockReaddir.mockResolvedValue([])
await skillsManager.discoverSkills()
await expect(skillsManager.moveSkill("non-existent", "global", undefined, "code")).rejects.toThrow(
"not found",
)
})
it("should throw error if skill already exists at destination", async () => {
const sourceDir = p(globalSkillsDir, "test-skill")
const testSkillMd = p(sourceDir, "SKILL.md")
const destDir = p(GLOBAL_ROO_DIR, "skills-code", "test-skill")
const destSkillMd = p(destDir, "SKILL.md")
// Setup: skill exists in both locations
mockDirectoryExists.mockImplementation(async (dir: string) => {
return dir === globalSkillsDir
})
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
mockReaddir.mockImplementation(async (dir: string) => {
if (dir === globalSkillsDir) {
return ["test-skill"]
}
return []
})
mockStat.mockImplementation(async (pathArg: string) => {
if (pathArg === sourceDir) {
return { isDirectory: () => true }
}
throw new Error("Not found")
})
mockFileExists.mockImplementation(async (file: string) => {
// Skill exists in both source and destination
if (file === testSkillMd) return true
if (file === destSkillMd) return true
return false
})
mockReadFile.mockResolvedValue(`---
name: test-skill
description: A test skill
---
Instructions`)
await skillsManager.discoverSkills()
await expect(skillsManager.moveSkill("test-skill", "global", undefined, "code")).rejects.toThrow(
"already exists",
)
})
it("should clean up empty source skills directory after moving", async () => {
const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code")
const sourceDir = p(sourceSkillsDir, "test-skill")
const testSkillMd = p(sourceDir, "SKILL.md")
const destDir = p(GLOBAL_ROO_DIR, "skills-architect", "test-skill")
const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-architect")
// Setup: skill exists in code mode directory
mockDirectoryExists.mockImplementation(async (dir: string) => {
return dir === sourceSkillsDir
})
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
// Track readdir calls - return skill for discovery, empty for cleanup check
let readdirCallCount = 0
mockReaddir.mockImplementation(async (dir: string) => {
if (dir === sourceSkillsDir) {
readdirCallCount++
// First call is for discovery, return the skill
// Second call is for cleanup check after move, return empty
if (readdirCallCount === 1) {
return ["test-skill"]
}
return []
}
return []
})
mockStat.mockImplementation(async (pathArg: string) => {
if (pathArg === sourceDir) {
return { isDirectory: () => true }
}
throw new Error("Not found")
})
mockFileExists.mockImplementation(async (file: string) => {
// Skill exists in source
if (file === testSkillMd) return true
// Skill does not exist in destination
if (file === p(destDir, "SKILL.md")) return false
return false
})
mockReadFile.mockResolvedValue(`---
name: test-skill
description: A test skill
---
Instructions`)
mockMkdir.mockResolvedValue(undefined)
mockRename.mockResolvedValue(undefined)
mockRmdir.mockResolvedValue(undefined)
await skillsManager.discoverSkills()
// Move the skill to architect mode
await skillsManager.moveSkill("test-skill", "global", "code", "architect")
// Verify empty directory was cleaned up
expect(mockRmdir).toHaveBeenCalledWith(sourceSkillsDir)
})
it("should not clean up source skills directory if it still has other skills", async () => {
const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code")
const sourceDir = p(sourceSkillsDir, "test-skill")
const testSkillMd = p(sourceDir, "SKILL.md")
const destDir = p(GLOBAL_ROO_DIR, "skills-architect", "test-skill")
const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-architect")
// Setup: skill exists in code mode directory along with another skill
mockDirectoryExists.mockImplementation(async (dir: string) => {
return dir === sourceSkillsDir
})
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
// Track readdir calls - return skill for discovery, non-empty for cleanup check
let readdirCallCount = 0
mockReaddir.mockImplementation(async (dir: string) => {
if (dir === sourceSkillsDir) {
readdirCallCount++
// First call is for discovery
if (readdirCallCount === 1) {
return ["test-skill", "another-skill"]
}
// Second call for cleanup - still has another skill
return ["another-skill"]
}
return []
})
mockStat.mockImplementation(async (pathArg: string) => {
if (pathArg === sourceDir || pathArg === p(sourceSkillsDir, "another-skill")) {
return { isDirectory: () => true }
}
throw new Error("Not found")
})
mockFileExists.mockImplementation(async (file: string) => {
// Skill exists in source
if (file === testSkillMd) return true
if (file === p(sourceSkillsDir, "another-skill", "SKILL.md")) return true
// Skill does not exist in destination
if (file === p(destDir, "SKILL.md")) return false
return false
})
mockReadFile.mockResolvedValue(`---
name: test-skill
description: A test skill
---
Instructions`)
mockMkdir.mockResolvedValue(undefined)
mockRename.mockResolvedValue(undefined)
mockRmdir.mockResolvedValue(undefined)
await skillsManager.discoverSkills()
// Move the skill to architect mode
await skillsManager.moveSkill("test-skill", "global", "code", "architect")
// Verify directory was NOT cleaned up (still has other skills)
expect(mockRmdir).not.toHaveBeenCalled()
})
})
})

View file

@ -1,10 +1,17 @@
import React from "react"
import React, { useCallback, useMemo } from "react"
import { Edit, Trash2 } from "lucide-react"
import type { SkillMetadata } from "@roo-code/types"
import { getAllModes } from "@roo/modes"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Button, StandardTooltip } from "@/components/ui"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { Button, StandardTooltip, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui"
import { vscode } from "@/utils/vscode"
// Sentinel value for "Any mode" since Radix Select doesn't allow empty string values
const MODE_ANY = "__any__"
interface SkillItemProps {
skill: SkillMetadata
@ -14,6 +21,40 @@ interface SkillItemProps {
export const SkillItem: React.FC<SkillItemProps> = ({ skill, onEdit, onDelete }) => {
const { t } = useAppTranslation()
const { customModes } = useExtensionState()
// Get available modes for the dropdown (built-in + custom modes)
const availableModes = useMemo(() => {
return getAllModes(customModes).map((m) => ({ slug: m.slug, name: m.name }))
}, [customModes])
// Current mode value for the select (using sentinel for "Any mode")
const currentModeValue = skill.mode || MODE_ANY
// Handle mode change
const handleModeChange = useCallback(
(newModeValue: string) => {
const newMode = newModeValue === MODE_ANY ? undefined : newModeValue
// Don't do anything if mode hasn't changed
if (newMode === skill.mode) {
return
}
// Send message to move skill to new mode
vscode.postMessage({
type: "moveSkill",
skillName: skill.name,
source: skill.source,
skillMode: skill.mode,
newSkillMode: newMode,
})
},
[skill.name, skill.source, skill.mode],
)
// Built-in skills cannot change mode
const isBuiltIn = skill.source === "built-in"
return (
<div className="px-4 py-2 text-sm flex items-center group hover:bg-vscode-list-hoverBackground">
@ -21,17 +62,37 @@ export const SkillItem: React.FC<SkillItemProps> = ({ skill, onEdit, onDelete })
<div className="flex-1 min-w-0 cursor-pointer" onClick={onEdit}>
<div className="flex items-center gap-2">
<span className="truncate text-vscode-foreground">{skill.name}</span>
{skill.mode && (
<span className="px-1.5 py-0.5 text-xs rounded bg-vscode-badge-background text-vscode-badge-foreground shrink-0">
{skill.mode}
</span>
)}
</div>
{skill.description && (
<div className="text-xs text-vscode-descriptionForeground truncate mt-0.5">{skill.description}</div>
)}
</div>
{/* Mode dropdown */}
<div className="ml-2 shrink-0">
{isBuiltIn ? (
<span className="px-1.5 py-0.5 text-xs rounded bg-vscode-badge-background text-vscode-badge-foreground">
{skill.mode || t("settings:skills.modeAny")}
</span>
) : (
<StandardTooltip content={t("settings:skills.changeMode")}>
<Select value={currentModeValue} onValueChange={handleModeChange}>
<SelectTrigger className="h-6 w-auto min-w-[80px] max-w-[120px] text-xs px-2 py-0.5 border-none bg-vscode-badge-background text-vscode-badge-foreground hover:bg-vscode-button-hoverBackground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MODE_ANY}>{t("settings:skills.modeAny")}</SelectItem>
{availableModes.map((m) => (
<SelectItem key={m.slug} value={m.slug}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</StandardTooltip>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 ml-2">
<StandardTooltip content={t("settings:skills.editSkill")}>
@ -45,16 +106,18 @@ export const SkillItem: React.FC<SkillItemProps> = ({ skill, onEdit, onDelete })
</Button>
</StandardTooltip>
<StandardTooltip content={t("settings:skills.deleteSkill")}>
<Button
variant="ghost"
size="icon"
tabIndex={-1}
onClick={onDelete}
className="size-6 flex items-center justify-center opacity-60 hover:opacity-100 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</Button>
</StandardTooltip>
{!isBuiltIn && (
<StandardTooltip content={t("settings:skills.deleteSkill")}>
<Button
variant="ghost"
size="icon"
tabIndex={-1}
onClick={onDelete}
className="size-6 flex items-center justify-center opacity-60 hover:opacity-100 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</Button>
</StandardTooltip>
)}
</div>
</div>
)

View file

@ -3,6 +3,7 @@ import { render, screen, fireEvent } from "@/utils/test-utils"
import type { SkillMetadata } from "@roo-code/types"
import { SkillItem } from "../SkillItem"
import { vscode } from "@/utils/vscode"
// Mock vscode
vi.mock("@/utils/vscode", () => ({
@ -14,11 +15,35 @@ vi.mock("@/utils/vscode", () => ({
// Mock the translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
t: (key: string) => {
const translations: Record<string, string> = {
"settings:skills.editSkill": "Edit skill",
"settings:skills.deleteSkill": "Delete skill",
"settings:skills.changeMode": "Change mode",
"settings:skills.modeAny": "Any mode",
}
return translations[key] || key
},
}),
}))
// Mock UI components
// Mock getAllModes
vi.mock("@roo/modes", () => ({
getAllModes: () => [
{ slug: "code", name: "💻 Code" },
{ slug: "architect", name: "🏗️ Architect" },
{ slug: "ask", name: "❓ Ask" },
],
}))
// Mock useExtensionState
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
customModes: [],
}),
}))
// Mock UI components - need to support Select components
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick, className, tabIndex }: any) => (
<button onClick={onClick} className={className} tabIndex={tabIndex} data-testid="button">
@ -30,6 +55,26 @@ vi.mock("@/components/ui", () => ({
{children}
</div>
),
Select: ({ children, value, onValueChange }: any) => (
<div data-testid="select" data-value={value}>
{children}
<button data-testid="select-change-button" onClick={() => onValueChange("code")}>
Change to code
</button>
</div>
),
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div data-testid="select-item" data-value={value}>
{children}
</div>
),
SelectTrigger: ({ children, className }: any) => (
<button data-testid="select-trigger" className={className}>
{children}
</button>
),
SelectValue: () => <span data-testid="select-value">Value</span>,
}))
const mockSkill: SkillMetadata = {
@ -47,6 +92,13 @@ const mockSkillWithMode: SkillMetadata = {
mode: "architect",
}
const mockBuiltInSkill: SkillMetadata = {
name: "built-in-skill",
description: "A built-in skill",
path: "<built-in:test>",
source: "built-in",
}
describe("SkillItem", () => {
const mockOnEdit = vi.fn()
const mockOnDelete = vi.fn()
@ -67,18 +119,33 @@ describe("SkillItem", () => {
expect(screen.getByText("A test skill description")).toBeInTheDocument()
})
it("renders mode badge when skill has mode", () => {
render(<SkillItem skill={mockSkillWithMode} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
expect(screen.getByText("architect")).toBeInTheDocument()
})
it("does not render mode badge when skill has no mode", () => {
it("renders mode dropdown for non-built-in skills", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
// Should not have any mode badge
const container = screen.getByText("test-skill").parentElement
expect(container?.querySelector(".bg-vscode-badge-background")).toBeNull()
expect(screen.getByTestId("select")).toBeInTheDocument()
})
it("renders mode dropdown with correct current value", () => {
render(<SkillItem skill={mockSkillWithMode} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
const select = screen.getByTestId("select")
expect(select).toHaveAttribute("data-value", "architect")
})
it("renders mode dropdown with __any__ for skills without mode", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
const select = screen.getByTestId("select")
expect(select).toHaveAttribute("data-value", "__any__")
})
it("does not render mode dropdown for built-in skills", () => {
render(<SkillItem skill={mockBuiltInSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
// Should not have a select element
expect(screen.queryByTestId("select")).not.toBeInTheDocument()
// Should have a static badge instead
expect(screen.getByText("Any mode")).toBeInTheDocument()
})
it("calls onEdit when edit button is clicked", () => {
@ -91,16 +158,24 @@ describe("SkillItem", () => {
expect(mockOnEdit).toHaveBeenCalledTimes(1)
})
it("calls onDelete when delete button is clicked", () => {
it("calls onDelete when delete button is clicked for non-built-in skills", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
const buttons = screen.getAllByTestId("button")
// Second button is delete
// Find the delete button (second one for non-built-in)
fireEvent.click(buttons[1])
expect(mockOnDelete).toHaveBeenCalledTimes(1)
})
it("does not render delete button for built-in skills", () => {
render(<SkillItem skill={mockBuiltInSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
// Should only have 1 button (edit) for built-in skills
const buttons = screen.getAllByTestId("button")
expect(buttons).toHaveLength(1)
})
it("calls onEdit when clicking on skill name area", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
@ -110,6 +185,38 @@ describe("SkillItem", () => {
expect(mockOnEdit).toHaveBeenCalledTimes(1)
})
it("sends moveSkill message when mode is changed", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
// Simulate mode change
const changeButton = screen.getByTestId("select-change-button")
fireEvent.click(changeButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "moveSkill",
skillName: "test-skill",
source: "project",
skillMode: undefined,
newSkillMode: "code",
})
})
it("sends moveSkill message with correct current mode", () => {
render(<SkillItem skill={mockSkillWithMode} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
// Simulate mode change
const changeButton = screen.getByTestId("select-change-button")
fireEvent.click(changeButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "moveSkill",
skillName: "mode-specific-skill",
source: "global",
skillMode: "architect",
newSkillMode: "code",
})
})
it("renders without description when not provided", () => {
const skillWithoutDescription: SkillMetadata = {
name: "no-desc-skill",
@ -132,10 +239,19 @@ describe("SkillItem", () => {
expect(itemDiv).toHaveClass("hover:bg-vscode-list-hoverBackground")
})
it("renders both edit and delete buttons", () => {
it("renders edit and delete buttons for non-built-in skills", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
const buttons = screen.getAllByTestId("button")
expect(buttons).toHaveLength(2)
})
it("includes available modes in the dropdown", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
// Check that select items are rendered
const selectItems = screen.getAllByTestId("select-item")
// Should have "Any mode" + 3 modes (code, architect, ask)
expect(selectItems).toHaveLength(4)
})
})

View file

@ -1003,6 +1003,8 @@
"addSkill": "Afegir Skill",
"editSkill": "Editar skill",
"deleteSkill": "Eliminar skill",
"changeMode": "Canvia mode",
"modeAny": "Qualsevol mode",
"deleteDialog": {
"title": "Eliminar Skill",
"description": "Estàs segur que vols eliminar la skill \"{{name}}\"? Aquesta acció no es pot desfer.",

View file

@ -1003,6 +1003,8 @@
"addSkill": "Skill hinzufügen",
"editSkill": "Skill bearbeiten",
"deleteSkill": "Skill löschen",
"changeMode": "Modus ändern",
"modeAny": "Beliebiger Modus",
"deleteDialog": {
"title": "Skill löschen",
"description": "Sind Sie sicher, dass Sie die Skill \"{{name}}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",

View file

@ -80,6 +80,8 @@
"addSkill": "Add Skill",
"editSkill": "Edit skill",
"deleteSkill": "Delete skill",
"changeMode": "Change mode",
"modeAny": "Any mode",
"deleteDialog": {
"title": "Delete Skill",
"description": "Are you sure you want to delete the skill \"{{name}}\"? This action cannot be undone.",

View file

@ -1003,6 +1003,8 @@
"addSkill": "Añadir Skill",
"editSkill": "Editar skill",
"deleteSkill": "Eliminar skill",
"changeMode": "Cambiar modo",
"modeAny": "Cualquier modo",
"deleteDialog": {
"title": "Eliminar Skill",
"description": "¿Estás seguro de que quieres eliminar la skill \"{{name}}\"? Esta acción no se puede deshacer.",

View file

@ -1003,6 +1003,8 @@
"addSkill": "Ajouter une Skill",
"editSkill": "Modifier la skill",
"deleteSkill": "Supprimer la skill",
"changeMode": "Changer de mode",
"modeAny": "N'importe quel mode",
"deleteDialog": {
"title": "Supprimer la Skill",
"description": "Êtes-vous sûr de vouloir supprimer la skill \"{{name}}\" ? Cette action ne peut pas être annulée.",

View file

@ -1004,6 +1004,8 @@
"addSkill": "Skill जोड़ें",
"editSkill": "Skill संपादित करें",
"deleteSkill": "Skill हटाएं",
"changeMode": "मोड बदलें",
"modeAny": "कोई भी मोड",
"deleteDialog": {
"title": "Skill हटाएं",
"description": "क्या आप वाकई skill \"{{name}}\" को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",

View file

@ -1033,6 +1033,8 @@
"addSkill": "Tambahkan Skill",
"editSkill": "Edit skill",
"deleteSkill": "Hapus skill",
"changeMode": "Ubah mode",
"modeAny": "Mode apa saja",
"deleteDialog": {
"title": "Hapus Skill",
"description": "Apakah Anda yakin ingin menghapus skill \"{{name}}\"? Tindakan ini tidak dapat dibatalkan.",

View file

@ -1004,6 +1004,8 @@
"addSkill": "Aggiungi Skill",
"editSkill": "Modifica skill",
"deleteSkill": "Elimina skill",
"changeMode": "Cambia modalità",
"modeAny": "Qualsiasi modalità",
"deleteDialog": {
"title": "Elimina Skill",
"description": "Sei sicuro di voler eliminare la skill \"{{name}}\"? Questa azione non può essere annullata.",

View file

@ -1004,6 +1004,8 @@
"addSkill": "スキルを追加",
"editSkill": "スキルを編集",
"deleteSkill": "スキルを削除",
"changeMode": "モードを変更",
"modeAny": "任意のモード",
"deleteDialog": {
"title": "スキルを削除",
"description": "スキル「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",

View file

@ -1004,6 +1004,8 @@
"addSkill": "스킬 추가",
"editSkill": "스킬 편집",
"deleteSkill": "스킬 삭제",
"changeMode": "모드 변경",
"modeAny": "모든 모드",
"deleteDialog": {
"title": "스킬 삭제",
"description": "스킬 \"{{name}}\"을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",

View file

@ -1004,6 +1004,8 @@
"addSkill": "Skill toevoegen",
"editSkill": "Skill bewerken",
"deleteSkill": "Skill verwijderen",
"changeMode": "Modus wijzigen",
"modeAny": "Elke modus",
"deleteDialog": {
"title": "Skill verwijderen",
"description": "Weet u zeker dat u de skill \"{{name}}\" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",

View file

@ -1004,6 +1004,8 @@
"addSkill": "Dodaj Umiejętność",
"editSkill": "Edytuj umiejętność",
"deleteSkill": "Usuń umiejętność",
"changeMode": "Zmień tryb",
"modeAny": "Dowolny tryb",
"deleteDialog": {
"title": "Usuń Umiejętność",
"description": "Czy na pewno chcesz usunąć umiejętność \"{{name}}\"? Tej akcji nie można cofnąć.",

View file

@ -1004,6 +1004,8 @@
"addSkill": "Adicionar Skill",
"editSkill": "Editar skill",
"deleteSkill": "Excluir skill",
"changeMode": "Alterar modo",
"modeAny": "Qualquer modo",
"deleteDialog": {
"title": "Excluir Skill",
"description": "Tem certeza de que deseja excluir a skill \"{{name}}\"? Esta ação não pode ser desfeita.",

View file

@ -1004,6 +1004,8 @@
"addSkill": "Добавить Навык",
"editSkill": "Редактировать навык",
"deleteSkill": "Удалить навык",
"changeMode": "Изменить режим",
"modeAny": "Любой режим",
"deleteDialog": {
"title": "Удалить Навык",
"description": "Вы уверены, что хотите удалить навык \"{{name}}\"? Это действие нельзя отменить.",

View file

@ -1004,6 +1004,8 @@
"addSkill": "Beceri Ekle",
"editSkill": "Beceriyi düzenle",
"deleteSkill": "Beceriyi sil",
"changeMode": "Modu değiştir",
"modeAny": "Herhangi bir mod",
"deleteDialog": {
"title": "Beceriyi Sil",
"description": "\"{{name}}\" becerisini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",

View file

@ -1004,6 +1004,8 @@
"addSkill": "Thêm Skill",
"editSkill": "Chỉnh sửa skill",
"deleteSkill": "Xóa skill",
"changeMode": "Thay đổi chế độ",
"modeAny": "Bất kỳ chế độ nào",
"deleteDialog": {
"title": "Xóa Skill",
"description": "Bạn có chắc chắn muốn xóa skill \"{{name}}\" không? Hành động này không thể hoàn tác.",

View file

@ -1004,6 +1004,8 @@
"addSkill": "添加技能",
"editSkill": "编辑技能",
"deleteSkill": "删除技能",
"changeMode": "更改模式",
"modeAny": "任意模式",
"deleteDialog": {
"title": "删除技能",
"description": "您确定要删除技能\"{{name}}\"吗?此操作无法撤销。",

View file

@ -1011,6 +1011,8 @@
"addSkill": "新增技能",
"editSkill": "編輯技能",
"deleteSkill": "刪除技能",
"changeMode": "變更模式",
"modeAny": "任意模式",
"deleteDialog": {
"title": "刪除技能",
"description": "您確定要刪除技能「{{name}}」嗎?此動作無法復原。",