diff --git a/.changeset/skill-group-selector.md b/.changeset/skill-group-selector.md new file mode 100644 index 0000000000..36b41a679c --- /dev/null +++ b/.changeset/skill-group-selector.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add hierarchical group navigation selector for the /skill command. Run /skill to open the interactive selector. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 08138365bf..57d6339610 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -49,9 +49,11 @@ import { type BuiltinSlashCommandName, } from './registry'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; -import type { SkillListSession } from './skills'; +import { isUserActivatableSkill, type SkillListSession } from './skills'; +import { runSkillSelector } from './prompts'; import { canRestoreSubmittedInput, + resolveSkillCommand, resolveSlashCommandInput, slashBusyMessage, slashCommandBusyReason, @@ -346,6 +348,7 @@ const SESSION_REQUIRING_COMMANDS: ReadonlySet = new Set 'goal', 'init', 'plan', + 'skill', 'swarm', 'undo', 'web', @@ -528,8 +531,58 @@ async function handleBuiltInSlashCommand( case 'web': await handleWebCommand(host); return; + case 'skill': + await handleSkillCommand(host, args); + return; default: host.showError(`Unknown slash command: /${String(name)}`); return; } } + +async function handleSkillCommand( + host: SlashCommandHost, + args: string, +): Promise { + let session = host.session; + if (session === undefined) { + session = await ensureSessionForCommand(host); + if (session === undefined) return; + } + + let skills: readonly SkillSummary[] = []; + try { + skills = await session.listSkills(); + } catch (error) { + host.showError(formatErrorMessage(error)); + return; + } + + const activatableSkills = skills.filter(isUserActivatableSkill); + const trimmedArgs = args.trim(); + + if (trimmedArgs.length > 0) { + const spaceIdx = trimmedArgs.search(/\s/); + const firstWord = spaceIdx >= 0 ? trimmedArgs.slice(0, spaceIdx) : trimmedArgs; + const remainingArgs = spaceIdx >= 0 ? trimmedArgs.slice(spaceIdx + 1).trim() : ''; + + const resolvedName = + resolveSkillCommand(host.skillCommandMap, firstWord) ?? + resolveSkillCommand(host.skillCommandMap, trimmedArgs) ?? + firstWord; + const targetSkill = activatableSkills.find( + (s) => s.name === resolvedName || s.name === firstWord || s.name === trimmedArgs, + ); + if (targetSkill !== undefined) { + const skillArgs = + targetSkill.name === resolvedName || targetSkill.name === firstWord ? remainingArgs : ''; + host.sendSkillActivation(session, targetSkill.name, skillArgs); + return; + } + } + + const selectedSkill = await runSkillSelector(host, activatableSkills); + if (selectedSkill !== undefined) { + host.sendSkillActivation(session, selectedSkill.name, ''); + } +} diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index cbfc33072f..63c0d55f48 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -14,9 +14,10 @@ import type { import { ApiKeyInputDialogComponent, type ApiKeyInputResult } from '../components/dialogs/api-key-input-dialog'; import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/choice-picker'; -import { FeedbackInputDialogComponent, type FeedbackInputDialogResult } from '../components/dialogs/feedback-input-dialog'; import { ModelSelectorComponent } from '../components/dialogs/model-selector'; import { PlatformSelectorComponent } from '../components/dialogs/platform-selector'; +import { SkillSelectorComponent } from '../components/dialogs/skill-selector'; +import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; import type { SlashCommandHost } from './dispatch'; export function promptPlatformSelection(host: SlashCommandHost): Promise { @@ -249,3 +250,26 @@ export function runModelSelector( host.mountEditorReplacement(selector); }); } + +export function runSkillSelector( + host: SlashCommandHost, + skills: readonly SkillSummary[], + skillRoots?: readonly string[], +): Promise { + return new Promise((resolve) => { + const selector = new SkillSelectorComponent({ + skills, + skillRoots, + searchable: true, + onSelect: (skill) => { + host.restoreEditor(); + resolve(skill); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(selector); + }); +} diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index d87e74b75d..44c6bcb55c 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -206,6 +206,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 95, availability: 'always', }, + { + name: 'skill', + aliases: ['skills'], + description: 'Select skill from hierarchical group selector', + priority: 90, + availability: 'always', + }, { name: 'btw', aliases: [], diff --git a/apps/kimi-code/src/tui/commands/skill-group-tree.ts b/apps/kimi-code/src/tui/commands/skill-group-tree.ts new file mode 100644 index 0000000000..e012039bac --- /dev/null +++ b/apps/kimi-code/src/tui/commands/skill-group-tree.ts @@ -0,0 +1,248 @@ +import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; +import path from 'pathe'; + +export interface SkillGroupNode { + readonly path: string; + readonly label: string; + readonly childGroups: readonly SkillGroupNode[]; + readonly skills: readonly SkillSummary[]; +} + +export interface BuildSkillGroupTreeOptions { + readonly skillRoots?: readonly string[]; +} + +interface InternalGroupData { + readonly path: string; + readonly label: string; + readonly directSkills: Map; + readonly childPaths: Set; +} + +export function buildSkillGroupTree( + skills: readonly SkillSummary[], + options: BuildSkillGroupTreeOptions = {}, +): SkillGroupNode { + const groupsMap = new Map(); + const topLevelPaths = new Set(); + + const getOrCreateGroup = (groupPath: string): InternalGroupData => { + const existing = groupsMap.get(groupPath); + if (existing !== undefined) return existing; + + const segments = groupPath.split('/').filter((s) => s.trim() !== ''); + const label = segments[segments.length - 1] ?? groupPath; + + const groupData: InternalGroupData = { + path: groupPath, + label, + directSkills: new Map(), + childPaths: new Set(), + }; + groupsMap.set(groupPath, groupData); + + if (segments.length > 1) { + const parentPath = segments.slice(0, -1).join('/'); + const parentGroup = getOrCreateGroup(parentPath); + parentGroup.childPaths.add(groupPath); + } else { + topLevelPaths.add(groupPath); + } + + return groupData; + }; + + for (const skill of skills) { + const assignedPaths = resolveGroupPathsForSkill(skill, options.skillRoots); + for (const gPath of assignedPaths) { + const groupNode = getOrCreateGroup(gPath); + if (!groupNode.directSkills.has(skill.name)) { + groupNode.directSkills.set(skill.name, skill); + } + } + } + + const buildNode = (groupPath: string): SkillGroupNode => { + const groupData = groupsMap.get(groupPath); + if (groupData === undefined) { + return { + path: groupPath, + label: groupPath, + childGroups: [], + skills: [], + }; + } + + const sortedChildren = Array.from(groupData.childPaths) + .map((cp) => buildNode(cp)) + .sort((a, b) => a.label.localeCompare(b.label)); + + const sortedSkills = Array.from(groupData.directSkills.values()).sort((a, b) => + a.name.localeCompare(b.name), + ); + + return { + path: groupData.path, + label: groupData.label, + childGroups: sortedChildren, + skills: sortedSkills, + }; + }; + + const topLevelNodes = Array.from(topLevelPaths) + .map((tp) => buildNode(tp)) + .sort((a, b) => { + // Put 'Uncategorized' at the end of top-level list + if (a.path === 'Uncategorized') return 1; + if (b.path === 'Uncategorized') return -1; + return a.label.localeCompare(b.label); + }); + + return { + path: '', + label: 'Root', + childGroups: topLevelNodes, + skills: [], + }; +} + +export function findGroupNode( + node: SkillGroupNode, + targetPath: string, +): SkillGroupNode | undefined { + if (node.path === targetPath) return node; + for (const child of node.childGroups) { + const found = findGroupNode(child, targetPath); + if (found !== undefined) return found; + } + return undefined; +} + +function cleanGroupPath(rawPath: string): string | undefined { + const segments = rawPath + .split('/') + .map((s) => s.trim()) + .filter((s) => s !== ''); + return segments.length > 0 ? segments.join('/') : undefined; +} + +function resolveGroupPathsForSkill( + skill: SkillSummary, + skillRoots: readonly string[] = [], +): readonly string[] { + const resultGroups: string[] = []; + + // Rule 1: Explicit frontmatter `groups` + if (Array.isArray(skill.groups) && skill.groups.length > 0) { + for (const rawGroup of skill.groups) { + if (typeof rawGroup !== 'string') continue; + const cleanPath = cleanGroupPath(rawGroup); + if (cleanPath !== undefined && !resultGroups.includes(cleanPath)) { + resultGroups.push(cleanPath); + } + } + } + + // Rule 2: Explicit `category` or `categories` + const categoryCandidates: string[] = []; + if (typeof skill.category === 'string' && skill.category.trim() !== '') { + categoryCandidates.push(skill.category.trim()); + } + if (Array.isArray(skill.categories)) { + for (const cat of skill.categories) { + if (typeof cat === 'string' && cat.trim() !== '') { + categoryCandidates.push(cat.trim()); + } + } + } + for (const cat of categoryCandidates) { + const clean = cleanGroupPath(cat); + if (clean !== undefined && !resultGroups.includes(clean)) { + resultGroups.push(clean); + } + } + + // Rule 3: `tags` frontmatter field + if (Array.isArray(skill.tags) && skill.tags.length > 0) { + for (const tag of skill.tags) { + if (typeof tag !== 'string') continue; + const clean = cleanGroupPath(tag); + if (clean !== undefined && !resultGroups.includes(clean)) { + resultGroups.push(clean); + } + } + } + + // Rule 4: Relative parent folder derivation + const folderFallback = deriveFolderGroup(skill.path, skillRoots, skill.name); + if (folderFallback !== undefined) { + const clean = cleanGroupPath(folderFallback); + if (clean !== undefined && !resultGroups.includes(clean)) { + resultGroups.push(clean); + } + } + + // Rule 5: Hyphenated or underscore skill name namespace prefix fallback + if (resultGroups.length === 0 && skill.name) { + const namespaceGroup = deriveNamespaceGroup(skill.name); + if (namespaceGroup !== undefined) { + resultGroups.push(namespaceGroup); + } + } + + // Rule 6: Final fallback to Uncategorized + if (resultGroups.length === 0) { + return ['Uncategorized']; + } + + return resultGroups; +} + +function deriveNamespaceGroup(skillName: string): string | undefined { + if (!skillName) return undefined; + const parts = skillName.split(/[-_]/).map((p) => p.trim()).filter((p) => p !== ''); + if (parts.length >= 2 && parts[0] !== undefined && parts[0].length > 0) { + return parts[0]; + } + return undefined; +} + +function deriveFolderGroup( + skillPath: string, + skillRoots: readonly string[], + skillName?: string, +): string | undefined { + if (!skillPath) return undefined; + const normalizedPath = path.resolve(skillPath); + + for (const root of skillRoots) { + const normalizedRoot = path.resolve(root); + if (normalizedPath.startsWith(normalizedRoot)) { + const rel = path.relative(normalizedRoot, normalizedPath); + const segments = rel.split(path.sep).filter((s) => s !== '' && s !== 'SKILL.md'); + if (segments.length >= 2) { + // e.g. ["security", "owasp-audit"] -> "security" + return segments[0]; + } + } + } + + // General fallback for paths containing /skills/ folder + const parts = normalizedPath.split(path.sep); + const skillsIdx = parts.lastIndexOf('skills'); + if (skillsIdx >= 0 && skillsIdx + 2 < parts.length) { + const parentDir = parts[skillsIdx + 1]; + const itemDir = parts[skillsIdx + 2]; + if ( + parentDir !== undefined && + parentDir !== '' && + !parentDir.endsWith('.md') && + parentDir !== skillName && + itemDir !== undefined + ) { + return parentDir; + } + } + + return undefined; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts b/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts new file mode 100644 index 0000000000..64f6180bc1 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts @@ -0,0 +1,243 @@ +import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { SearchableList } from '#/tui/utils/searchable-list'; +import { + buildSkillGroupTree, + findGroupNode, + type SkillGroupNode, +} from '../../commands/skill-group-tree'; + +export interface SkillSelectorOptions { + readonly skills: readonly SkillSummary[]; + readonly skillRoots?: readonly string[]; + readonly title?: string; + readonly searchable?: boolean; + readonly pageSize?: number; + readonly onSelect: (skill: SkillSummary) => void; + readonly onCancel: () => void; +} + +export type SkillSelectorItem = + | { + readonly kind: 'group'; + readonly node: SkillGroupNode; + readonly label: string; + readonly description: string; + } + | { + readonly kind: 'skill'; + readonly skill: SkillSummary; + readonly label: string; + readonly description: string; + }; + +function countSkillsInTree(node: SkillGroupNode): number { + let count = node.skills.length; + for (const child of node.childGroups) { + count += countSkillsInTree(child); + } + return count; +} + +export class SkillSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: SkillSelectorOptions; + private readonly rootTree: SkillGroupNode; + private currentGroupPath: string = ''; + private list!: SearchableList; + + constructor(opts: SkillSelectorOptions) { + super(); + this.opts = opts; + this.rootTree = buildSkillGroupTree(opts.skills, { skillRoots: opts.skillRoots }); + this.rebuildList(); + } + + private rebuildList(): void { + const currentNode = findGroupNode(this.rootTree, this.currentGroupPath) ?? this.rootTree; + const items: SkillSelectorItem[] = []; + + for (const childGroup of currentNode.childGroups) { + const skillCount = countSkillsInTree(childGroup); + items.push({ + kind: 'group', + node: childGroup, + label: childGroup.label, + description: `${String(skillCount)} skill${skillCount === 1 ? '' : 's'}`, + }); + } + + for (const skill of currentNode.skills) { + items.push({ + kind: 'skill', + skill, + label: skill.name, + description: skill.description || 'No description provided.', + }); + } + + this.list = new SearchableList({ + items, + toSearchText: (item) => `${item.label} ${item.description}`, + pageSize: this.opts.pageSize, + searchable: this.opts.searchable ?? true, + }); + } + + private cycleGroupSelection(isShift: boolean): void { + const view = this.list.view(); + const items = view.items; + if (items.length === 0) return; + + const groupIndices: number[] = []; + for (let i = 0; i < items.length; i++) { + if (items[i]?.kind === 'group') { + groupIndices.push(i); + } + } + + if (groupIndices.length === 0) return; + + const currentIndex = view.selectedIndex; + let targetIndex: number; + + const k = groupIndices.indexOf(currentIndex); + if (k >= 0) { + if (isShift) { + targetIndex = groupIndices[(k - 1 + groupIndices.length) % groupIndices.length] ?? 0; + } else { + targetIndex = groupIndices[(k + 1) % groupIndices.length] ?? 0; + } + } else { + targetIndex = isShift ? (groupIndices[groupIndices.length - 1] ?? 0) : (groupIndices[0] ?? 0); + } + + this.list.setSelectedIndex(targetIndex); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift('tab'))) { + const isShift = matchesKey(data, Key.shift('tab')); + this.cycleGroupSelection(isShift); + return; + } + + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) return; + if (this.currentGroupPath !== '') { + const segments = this.currentGroupPath.split('/'); + segments.pop(); + this.currentGroupPath = segments.join('/'); + this.rebuildList(); + return; + } + this.opts.onCancel(); + return; + } + + const isSpace = matchesKey(data, Key.space) || printableChar(data) === ' '; + if (matchesKey(data, Key.enter) || (isSpace && this.opts.searchable !== true)) { + const selected = this.list.selected(); + if (selected === undefined) return; + + if (selected.kind === 'group') { + this.currentGroupPath = selected.node.path; + this.rebuildList(); + } else { + this.opts.onSelect(selected.skill); + } + return; + } + + this.list.handleKey(data); + } + + override render(width: number): string[] { + const searchable = this.opts.searchable !== false; + const view = this.list.view(); + const items = view.items; + + const titleText = + this.opts.title ?? + (this.currentGroupPath === '' + ? 'Select skill group' + : `Skills › ${this.currentGroupPath.split('/').join(' › ')}`); + + const titleSuffix = + searchable && view.query.length === 0 + ? currentTheme.fg('textMuted', ' (type to search)') + : ''; + + const hintParts = ['↑↓ navigate', 'Tab jump groups']; + if (view.page.pageCount > 1) hintParts.push('←→ page'); + hintParts.push('Enter select', 'Esc back/cancel'); + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${titleText}`) + titleSuffix, + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), + '', + ]; + + if (searchable && view.query.length > 0) { + lines.push(currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query)); + } + + if (items.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No matches')); + } else { + for (let i = view.page.start; i < view.page.end; i++) { + const item = items[i]; + if (item === undefined) continue; + const isSelected = i === view.selectedIndex; + const pointer = isSelected ? SELECT_POINTER : ' '; + + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); + if (item.kind === 'group') { + const groupLabel = `${item.label}/`; + line += isSelected + ? currentTheme.boldFg('primary', groupLabel) + : currentTheme.fg('primary', groupLabel); + line += ' ' + currentTheme.fg('textMuted', `(${item.description})`); + } else { + line += isSelected + ? currentTheme.boldFg('primary', item.label) + : currentTheme.fg('text', item.label); + } + lines.push(line); + } + } + + lines.push(''); + + // Footer preview for currently selected item + const selected = this.list.selected(); + if (selected !== undefined) { + const selectedType = selected.kind === 'group' ? 'Group' : 'Skill'; + lines.push(currentTheme.fg('textMuted', ` ${selectedType}: ${selected.label}`)); + lines.push(currentTheme.fg('text', ` ${selected.description}`)); + lines.push(''); + } + + if (view.page.pageCount > 1) { + lines.push( + currentTheme.fg( + 'textMuted', + ` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`, + ), + ); + } + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/utils/searchable-list.ts b/apps/kimi-code/src/tui/utils/searchable-list.ts index 2077033803..01ef501c67 100644 --- a/apps/kimi-code/src/tui/utils/searchable-list.ts +++ b/apps/kimi-code/src/tui/utils/searchable-list.ts @@ -100,6 +100,15 @@ export class SearchableList { this.cursor = Math.min(Math.max(0, this.filtered().length - 1), this.cursor + this.pageSize); } + setSelectedIndex(index: number): void { + const len = this.filtered().length; + if (len === 0) { + this.cursor = 0; + return; + } + this.cursor = Math.max(0, Math.min(index, len - 1)); + } + /** Clears the active query and resets the cursor. Returns whether a query was cleared. */ clearQuery(): boolean { if (this.query.length === 0) return false; diff --git a/apps/kimi-code/test/tui/commands/skill-group-tree.test.ts b/apps/kimi-code/test/tui/commands/skill-group-tree.test.ts new file mode 100644 index 0000000000..dd4ffb93f9 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/skill-group-tree.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; +import { buildSkillGroupTree, findGroupNode } from '../../../src/tui/commands/skill-group-tree'; + +function makeSkill(name: string, overrides: Partial = {}): SkillSummary { + return { + name, + description: `${name} description`, + path: `/test/skills/${name}/SKILL.md`, + source: 'user', + type: 'prompt', + ...overrides, + }; +} + +describe('skill-group-tree', () => { + it('builds group tree from explicit groups metadata', () => { + const sshOps = makeSkill('cv_ssh-ops', { + groups: ['cv', 'cv/ops'], + }); + const semaphoreOps = makeSkill('cv_semaphore-ops', { + groups: ['cv', 'cv/ops', 'cv/ops/semaphore'], + }); + + const root = buildSkillGroupTree([sshOps, semaphoreOps]); + + const cvNode = findGroupNode(root, 'cv'); + expect(cvNode).toBeDefined(); + expect(cvNode?.label).toBe('cv'); + expect(cvNode?.skills.map((s) => s.name)).toEqual(['cv_semaphore-ops', 'cv_ssh-ops']); + + const opsNode = findGroupNode(root, 'cv/ops'); + expect(opsNode).toBeDefined(); + expect(opsNode?.label).toBe('ops'); + expect(opsNode?.skills.map((s) => s.name)).toEqual(['cv_semaphore-ops', 'cv_ssh-ops']); + + const semNode = findGroupNode(root, 'cv/ops/semaphore'); + expect(semNode).toBeDefined(); + expect(semNode?.label).toBe('semaphore'); + expect(semNode?.skills.map((s) => s.name)).toEqual(['cv_semaphore-ops']); + }); + + it('implies parent groups when only child group path is specified', () => { + const skill = makeSkill('deep-skill', { + groups: ['a/b/c'], + }); + const root = buildSkillGroupTree([skill]); + + const a = findGroupNode(root, 'a'); + expect(a).toBeDefined(); + expect(a?.childGroups.map((g) => g.label)).toEqual(['b']); + expect(a?.skills).toEqual([]); + + const b = findGroupNode(root, 'a/b'); + expect(b).toBeDefined(); + expect(b?.childGroups.map((g) => g.label)).toEqual(['c']); + expect(b?.skills).toEqual([]); + + const c = findGroupNode(root, 'a/b/c'); + expect(c).toBeDefined(); + expect(c?.skills.map((s) => s.name)).toEqual(['deep-skill']); + }); + + it('falls back to category when groups are absent', () => { + const deploySkill = makeSkill('deploy-app', { category: 'deploy' }); + const root = buildSkillGroupTree([deploySkill]); + + const node = findGroupNode(root, 'deploy'); + expect(node).toBeDefined(); + expect(node?.skills.map((s) => s.name)).toEqual(['deploy-app']); + }); + + it('falls back to relative folder path when groups and category are absent', () => { + const secSkill = makeSkill('owasp-audit', { + path: '/home/user/.kimi/skills/security/owasp-audit/SKILL.md', + }); + const root = buildSkillGroupTree([secSkill], { skillRoots: ['/home/user/.kimi/skills'] }); + + const secNode = findGroupNode(root, 'security'); + expect(secNode).toBeDefined(); + expect(secNode?.skills.map((s) => s.name)).toEqual(['owasp-audit']); + }); + + it('falls back to Uncategorized when no group/category/folder is present', () => { + const flatSkill = makeSkill('flat-skill', { + path: '/SKILL.md', + }); + const root = buildSkillGroupTree([flatSkill]); + + const uncatNode = findGroupNode(root, 'Uncategorized'); + expect(uncatNode).toBeDefined(); + expect(uncatNode?.skills.map((s) => s.name)).toEqual(['flat-skill']); + }); + + it('derives groups from tags and hyphenated skill name namespace fallback', () => { + const taggedSkill = makeSkill('custom-tool', { tags: ['security', 'audit'] }); + const winPrivEsc = makeSkill('windows-privilege-escalation', { path: '/SKILL.md' }); + + const root = buildSkillGroupTree([taggedSkill, winPrivEsc]); + + const secNode = findGroupNode(root, 'security'); + expect(secNode).toBeDefined(); + expect(secNode?.skills.map((s) => s.name)).toEqual(['custom-tool']); + + const winNode = findGroupNode(root, 'windows'); + expect(winNode).toBeDefined(); + expect(winNode?.skills.map((s) => s.name)).toEqual(['windows-privilege-escalation']); + }); + + it('preserves deterministic alphabetical ordering of groups and skills', () => { + const bSkill = makeSkill('b_skill', { category: 'ops' }); + const aSkill = makeSkill('a_skill', { category: 'ops' }); + const cSkill = makeSkill('c_skill', { category: 'dev' }); + + const root = buildSkillGroupTree([bSkill, aSkill, cSkill]); + + expect(root.childGroups.map((g) => g.label)).toEqual(['dev', 'ops']); + const opsNode = findGroupNode(root, 'ops'); + expect(opsNode?.skills.map((s) => s.name)).toEqual(['a_skill', 'b_skill']); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/skill-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/skill-selector.test.ts new file mode 100644 index 0000000000..41f92723fd --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/skill-selector.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; +import { Key } from '@moonshot-ai/pi-tui'; +import { SkillSelectorComponent } from '../../../../src/tui/components/dialogs/skill-selector'; + +function makeSkill(name: string, overrides: Partial = {}): SkillSummary { + return { + name, + description: `${name} description`, + path: `/test/skills/${name}/SKILL.md`, + source: 'user', + type: 'prompt', + ...overrides, + }; +} + +function text(component: SkillSelectorComponent, width = 120): string { + return component.render(width).join('\n'); +} + +describe('SkillSelectorComponent', () => { + const sshOps = makeSkill('cv_ssh-ops', { + groups: ['cv', 'cv/ops'], + description: 'SSH operations', + }); + const semaphoreOps = makeSkill('cv_semaphore-ops', { + groups: ['cv', 'cv/ops', 'cv/ops/semaphore'], + description: 'Semaphore operations', + }); + const flatSkill = makeSkill('flat-skill', { + description: 'Flat skill without group', + }); + + const skills = [sshOps, semaphoreOps, flatSkill]; + + it('renders root group level', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const selector = new SkillSelectorComponent({ + skills, + onSelect, + onCancel, + }); + + const rendered = text(selector); + expect(rendered).toContain('Select skill group'); + expect(rendered).toContain('cv'); + expect(rendered).toContain('Uncategorized'); + }); + + it('drills down into group on Enter and goes back on Escape', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const selector = new SkillSelectorComponent({ + skills, + onSelect, + onCancel, + }); + + // Enter on 'cv' + selector.handleInput(Key.enter); + let rendered = text(selector); + expect(rendered).toContain('Skills › cv'); + expect(rendered).toContain('ops'); + expect(rendered).toContain('cv_ssh-ops'); + expect(rendered).toContain('cv_semaphore-ops'); + + // Enter on 'ops' + selector.handleInput(Key.enter); + rendered = text(selector); + expect(rendered).toContain('Skills › cv › ops'); + expect(rendered).toContain('semaphore'); + expect(rendered).toContain('cv_ssh-ops'); + + // Escape back to 'cv' + selector.handleInput(Key.escape); + rendered = text(selector); + expect(rendered).toContain('Skills › cv'); + + // Escape back to root + selector.handleInput(Key.escape); + rendered = text(selector); + expect(rendered).toContain('Select skill group'); + + // Escape at root cancels + selector.handleInput(Key.escape); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('selects a skill on Enter and calls onSelect', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const selector = new SkillSelectorComponent({ + skills, + onSelect, + onCancel, + }); + + // Drill down: Root -> cv -> ops -> semaphore + selector.handleInput(Key.enter); // cv + selector.handleInput(Key.enter); // ops + selector.handleInput(Key.enter); // semaphore + + const rendered = text(selector); + expect(rendered).toContain('Skills › cv › ops › semaphore'); + expect(rendered).toContain('cv_semaphore-ops'); + expect(rendered).toContain('Semaphore operations'); + + // Press Enter on cv_semaphore-ops + selector.handleInput(Key.enter); + expect(onSelect).toHaveBeenCalledWith(semaphoreOps); + }); + + it('filters items with search query', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const selector = new SkillSelectorComponent({ + skills, + searchable: true, + onSelect, + onCancel, + }); + + // Type search 'uncat' at root + selector.handleInput('u'); + selector.handleInput('n'); + selector.handleInput('c'); + selector.handleInput('a'); + selector.handleInput('t'); + + const rendered = text(selector); + expect(rendered).toContain('Uncategorized'); + expect(rendered).not.toContain(' cv\n'); + }); + + it('cycles through group items with Tab key', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const selector = new SkillSelectorComponent({ + skills, + onSelect, + onCancel, + }); + + // Root items: cv/ and Uncategorized/ + let rendered = text(selector); + expect(rendered).toContain('❯ cv/'); + + // Press Tab to cycle to next group (Uncategorized/) + selector.handleInput(Key.tab); + rendered = text(selector); + expect(rendered).toContain('❯ Uncategorized/'); + + // Press Tab again to wrap back to cv/ + selector.handleInput(Key.tab); + rendered = text(selector); + expect(rendered).toContain('❯ cv/'); + }); +}); diff --git a/packages/agent-core-v2/src/app/skillCatalog/types.ts b/packages/agent-core-v2/src/app/skillCatalog/types.ts index 2342d095c1..7d38d9859e 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/types.ts @@ -23,6 +23,12 @@ export interface SkillMetadata { readonly isSubSkill?: boolean | undefined; readonly safe?: boolean | undefined; readonly arguments?: readonly unknown[] | string | undefined; + readonly category?: string | undefined; + readonly categories?: readonly string[] | string | undefined; + readonly issuer?: string | undefined; + readonly collection?: string | undefined; + readonly groups?: readonly string[] | undefined; + readonly tags?: readonly string[] | undefined; readonly [key: string]: unknown; } @@ -48,6 +54,12 @@ export interface SkillSummary { readonly type?: string | undefined; readonly disableModelInvocation?: boolean | undefined; readonly isSubSkill?: boolean | undefined; + readonly category?: string | undefined; + readonly categories?: readonly string[] | undefined; + readonly issuer?: string | undefined; + readonly collection?: string | undefined; + readonly groups?: readonly string[] | undefined; + readonly tags?: readonly string[] | undefined; } export interface SkillRoot { @@ -108,5 +120,15 @@ export function summarizeSkill(skill: SkillDefinition): SkillSummary { type: skill.metadata.type, disableModelInvocation: skill.metadata.disableModelInvocation, isSubSkill: skill.metadata.isSubSkill, + category: typeof skill.metadata.category === 'string' && skill.metadata.category.trim() !== '' ? skill.metadata.category.trim() : undefined, + categories: Array.isArray(skill.metadata.categories) + ? skill.metadata.categories.filter((c): c is string => typeof c === 'string' && c.trim() !== '') + : typeof skill.metadata.categories === 'string' && skill.metadata.categories.trim() !== '' + ? [skill.metadata.categories.trim()] + : undefined, + issuer: typeof skill.metadata.issuer === 'string' && skill.metadata.issuer.trim() !== '' ? skill.metadata.issuer.trim() : undefined, + collection: typeof skill.metadata.collection === 'string' && skill.metadata.collection.trim() !== '' ? skill.metadata.collection.trim() : undefined, + groups: Array.isArray(skill.metadata.groups) ? skill.metadata.groups.filter((g): g is string => typeof g === 'string' && g.trim() !== '') : undefined, + tags: Array.isArray(skill.metadata.tags) ? skill.metadata.tags.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : undefined, }; } diff --git a/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts b/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts index 116dcc156f..32a8e2f68e 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts @@ -85,4 +85,29 @@ describe('parseSkillText', () => { expect(skill.mermaid).toBe('graph TD'); expect(skill.d2).toBe('x -> y'); }); + + it('parses category, issuer, collection, groups, and tags frontmatter fields', () => { + const skill = parseSkillText({ + skillMdPath: '/skills/cv_ssh-ops/SKILL.md', + skillDirName: 'cv_ssh-ops', + source: 'user', + text: [ + '---', + 'name: cv_ssh-ops', + 'description: SSH ops', + 'category: ops', + 'issuer: creatiVision', + 'collection: cv-infrastructure', + 'groups: [cv, cv/ops]', + 'tags: [cv, ssh]', + '---', + 'body', + ].join('\n'), + }); + expect(skill.metadata.category).toBe('ops'); + expect(skill.metadata.issuer).toBe('creatiVision'); + expect(skill.metadata.collection).toBe('cv-infrastructure'); + expect(skill.metadata.groups).toEqual(['cv', 'cv/ops']); + expect(skill.metadata.tags).toEqual(['cv', 'ssh']); + }); }); diff --git a/packages/agent-core-v2/test/app/skillCatalog/types.test.ts b/packages/agent-core-v2/test/app/skillCatalog/types.test.ts index 8d45873caf..e5be4a9cc9 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/types.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/types.test.ts @@ -49,4 +49,33 @@ describe('skill/types', () => { isSubSkill: false, }); }); + + it('summarizeSkill projects optional category, issuer, collection, groups, tags', () => { + const skill: SkillDefinition = { + name: 'cv_ssh-ops', + description: 'SSH ops', + path: '/skills/cv_ssh-ops', + source: 'user', + metadata: { + type: 'prompt', + category: 'ops', + issuer: 'creatiVision', + collection: 'cv-infrastructure', + groups: ['cv', 'cv/ops'], + tags: ['cv', 'ssh'], + }, + } as SkillDefinition; + expect(summarizeSkill(skill)).toEqual({ + name: 'cv_ssh-ops', + description: 'SSH ops', + path: '/skills/cv_ssh-ops', + source: 'user', + type: 'prompt', + category: 'ops', + issuer: 'creatiVision', + collection: 'cv-infrastructure', + groups: ['cv', 'cv/ops'], + tags: ['cv', 'ssh'], + }); + }); }); diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 48cca77c95..0d60aec168 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -307,6 +307,12 @@ export interface SkillSummary { readonly type?: string | undefined; readonly disableModelInvocation?: boolean | undefined; readonly isSubSkill?: boolean | undefined; + readonly category?: string | undefined; + readonly categories?: readonly string[] | undefined; + readonly issuer?: string | undefined; + readonly collection?: string | undefined; + readonly groups?: readonly string[] | undefined; + readonly tags?: readonly string[] | undefined; } export interface ActivateSkillPayload { diff --git a/packages/agent-core/src/skill/types.ts b/packages/agent-core/src/skill/types.ts index 98a1928acb..ec7e702b41 100644 --- a/packages/agent-core/src/skill/types.ts +++ b/packages/agent-core/src/skill/types.ts @@ -9,6 +9,12 @@ export interface SkillMetadata { readonly isSubSkill?: boolean | undefined; readonly safe?: boolean | undefined; readonly arguments?: readonly unknown[] | string | undefined; + readonly category?: string | undefined; + readonly categories?: readonly string[] | string | undefined; + readonly issuer?: string | undefined; + readonly collection?: string | undefined; + readonly groups?: readonly string[] | undefined; + readonly tags?: readonly string[] | undefined; readonly [key: string]: unknown; } @@ -33,6 +39,12 @@ export interface SkillSummary { readonly type?: string | undefined; readonly disableModelInvocation?: boolean | undefined; readonly isSubSkill?: boolean | undefined; + readonly category?: string | undefined; + readonly categories?: readonly string[] | undefined; + readonly issuer?: string | undefined; + readonly collection?: string | undefined; + readonly groups?: readonly string[] | undefined; + readonly tags?: readonly string[] | undefined; } export interface SkillRoot { @@ -90,5 +102,15 @@ export function summarizeSkill(skill: SkillDefinition): SkillSummary { type: skill.metadata.type, disableModelInvocation: skill.metadata.disableModelInvocation, isSubSkill: skill.metadata.isSubSkill, + category: typeof skill.metadata.category === 'string' && skill.metadata.category.trim() !== '' ? skill.metadata.category.trim() : undefined, + categories: Array.isArray(skill.metadata.categories) + ? skill.metadata.categories.filter((c): c is string => typeof c === 'string' && c.trim() !== '') + : typeof skill.metadata.categories === 'string' && skill.metadata.categories.trim() !== '' + ? [skill.metadata.categories.trim()] + : undefined, + issuer: typeof skill.metadata.issuer === 'string' && skill.metadata.issuer.trim() !== '' ? skill.metadata.issuer.trim() : undefined, + collection: typeof skill.metadata.collection === 'string' && skill.metadata.collection.trim() !== '' ? skill.metadata.collection.trim() : undefined, + groups: Array.isArray(skill.metadata.groups) ? skill.metadata.groups.filter((g): g is string => typeof g === 'string' && g.trim() !== '') : undefined, + tags: Array.isArray(skill.metadata.tags) ? skill.metadata.tags.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : undefined, }; }