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 6bf367f64b..dfcd539a36 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -54,9 +54,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, @@ -424,6 +426,7 @@ const SESSION_REQUIRING_COMMANDS: ReadonlySet = new Set 'goal', 'init', 'plan', + 'skill', 'swarm', 'undo', 'web', @@ -606,8 +609,99 @@ 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 { + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage('skill', busyReason)); + return; + } + + let session = host.session; + if (session === undefined) { + session = await ensureSessionForCommand(host); + if (session === undefined) return; + const busyCheck = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyCheck !== undefined) { + host.showError(slashBusyMessage('skill', busyCheck)); + return; + } + } + + let skills: readonly SkillSummary[] = []; + try { + skills = await session.listSkills(); + } catch (error) { + host.showError(formatErrorMessage(error)); + return; + } + + // Recheck busy state after loading skills (P1: Block /skill while a turn is active) + const busyCheckAfterLoad = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyCheckAfterLoad !== undefined) { + host.showError(slashBusyMessage('skill', busyCheckAfterLoad)); + return; + } + + const activatableSkills = skills.filter(isUserActivatableSkill); + const trimmedArgs = args.trim(); + + if ( + trimmedArgs.length > 0 && + trimmedArgs !== 'skill' && + trimmedArgs !== '/skill' && + trimmedArgs !== 'skills' && + trimmedArgs !== '/skills' + ) { + 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) { + const busyCheck = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyCheck !== undefined) { + host.showError(slashBusyMessage('skill', busyCheck)); + return; + } + 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..d95d53be0f 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: 'idle-only', + }, { 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..b152551572 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/skill-group-tree.ts @@ -0,0 +1,236 @@ +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[] { + // Precedence Rule 1: Explicit frontmatter `groups` + if (Array.isArray(skill.groups) && skill.groups.length > 0) { + const groups: string[] = []; + for (const rawGroup of skill.groups) { + if (typeof rawGroup !== 'string') continue; + const cleanPath = cleanGroupPath(rawGroup); + if (cleanPath !== undefined && !groups.includes(cleanPath)) { + groups.push(cleanPath); + } + } + if (groups.length > 0) { + return groups; + } + } + + // Precedence 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()); + } + } + } + if (categoryCandidates.length > 0) { + const categories: string[] = []; + for (const cat of categoryCandidates) { + const clean = cleanGroupPath(cat); + if (clean !== undefined && !categories.includes(clean)) { + categories.push(clean); + } + } + if (categories.length > 0) { + return categories; + } + } + + // Precedence Rule 3: Relative parent folder derivation + const folderFallback = deriveFolderGroup(skill.path, skillRoots, skill.name); + if (folderFallback !== undefined) { + const clean = cleanGroupPath(folderFallback); + if (clean !== undefined) { + return [clean]; + } + } + + // Precedence Rule 4: Final fallback to Uncategorized + return ['Uncategorized']; +} + +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' && !s.endsWith('.md')); + if (segments.length >= 2) { + const last = segments[segments.length - 1]; + if (last === skillName || (skillName && last?.toLowerCase() === skillName.toLowerCase())) { + segments.pop(); + } + } + if (segments.length > 0) { + return segments.join('/'); + } + } + } + + const parts = normalizedPath.split(path.sep).filter((p) => p !== ''); + let markerIdx = -1; + const knownMarkers = ['skills', 'skillshub']; + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i]; + if (part && knownMarkers.includes(part)) { + markerIdx = i; + break; + } + } + + if (markerIdx >= 0 && markerIdx + 1 < parts.length) { + const sub = parts.slice(markerIdx + 1).filter((s) => s !== 'SKILL.md' && !s.endsWith('.md')); + if (sub.length >= 2) { + const parentFolders = sub.slice(0, -1); + if (parentFolders.length > 0) { + return parentFolders.join('/'); + } + } + } + + 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..718afa83a6 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts @@ -0,0 +1,322 @@ +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 { renderTabStrip } from '#/tui/utils/tab-strip'; +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 groupPath?: string; + readonly isDescendant?: boolean; + } + | { + readonly kind: 'skill'; + readonly skill: SkillSummary; + readonly label: string; + readonly description: string; + readonly groupPath?: string; + readonly isDescendant?: boolean; + }; + +function countSkillsInTree(node: SkillGroupNode): number { + const seen = new Set(); + function countNode(node: SkillGroupNode): number { + let count = 0; + for (const skill of node.skills) { + if (!seen.has(skill.name)) { + seen.add(skill.name); + count++; + } + } + for (const child of node.childGroups) { + count += countNode(child); + } + return count; + } + return countNode(node); +} + +function collectDescendantSkills( + node: SkillGroupNode, +): Array<{ skill: SkillSummary; groupPath: string }> { + const result: Array<{ skill: SkillSummary; groupPath: string }> = []; + for (const child of node.childGroups) { + for (const skill of child.skills) { + result.push({ skill, groupPath: child.path }); + } + result.push(...collectDescendantSkills(child)); + } + return result; +} + +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 get tabLabels(): readonly string[] { + return ['All', ...this.rootTree.childGroups.map((g) => g.label)]; + } + + private get activeTabIdx(): number { + if (this.currentGroupPath === '') return 0; + const topSegment = this.currentGroupPath.split('/')[0]; + const idx = this.rootTree.childGroups.findIndex( + (g) => g.label === topSegment || g.path === topSegment, + ); + return idx >= 0 ? idx + 1 : 0; + } + + private switchTab(newIdx: number): void { + if (newIdx === 0) { + this.currentGroupPath = ''; + } else { + const group = this.rootTree.childGroups[newIdx - 1]; + if (group !== undefined) { + this.currentGroupPath = group.path; + } + } + this.rebuildList(); + } + + private cycleTab(isShift: boolean): void { + const labels = this.tabLabels; + if (labels.length <= 1) return; + const current = this.activeTabIdx; + const nextIdx = isShift + ? (current - 1 + labels.length) % labels.length + : (current + 1) % labels.length; + this.switchTab(nextIdx); + } + + 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'}`, + groupPath: childGroup.path, + }); + } + + const directSkillNames = new Set(); + for (const skill of currentNode.skills) { + directSkillNames.add(skill.name); + items.push({ + kind: 'skill', + skill, + label: skill.name, + description: skill.description || 'No description provided.', + groupPath: currentNode.path, + }); + } + + const descendantSkills = collectDescendantSkills(currentNode); + for (const { skill, groupPath } of descendantSkills) { + if (!directSkillNames.has(skill.name)) { + directSkillNames.add(skill.name); + items.push({ + kind: 'skill', + skill, + label: skill.name, + description: skill.description || 'No description provided.', + groupPath, + isDescendant: true, + }); + } + } + + this.list = new SearchableList({ + items, + toSearchText: (item) => `${item.label} ${item.description} ${item.groupPath ?? ''}`, + filterItem: (item, query) => query.length > 0 || item.isDescendant !== true, + pageSize: this.opts.pageSize, + searchable: this.opts.searchable ?? true, + }); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift('tab'))) { + const isShift = matchesKey(data, Key.shift('tab')); + this.cycleTab(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; + } + + // Handle left/right arrow keys for pagination (P2: Implement advertised left/right paging keys) + if (matchesKey(data, Key.left)) { + this.list.pageUp(); + return; + } + if (matchesKey(data, Key.right)) { + this.list.pageDown(); + 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 switch group']; + 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(' · ')), + '', + ]; + + const labels = this.tabLabels; + if (labels.length > 1) { + const stripLine = renderTabStrip({ + labels, + activeIndex: this.activeTabIdx, + width, + colors: currentTheme.palette, + }); + lines.push(stripLine, ''); + } + + 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); + if (item.isDescendant && item.groupPath) { + line += ' ' + currentTheme.fg('textMuted', `(${item.groupPath})`); + } + } + 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'; + const pathSuffix = + selected.kind === 'skill' && selected.groupPath ? ` (${selected.groupPath})` : ''; + lines.push(currentTheme.fg('textMuted', ` ${selectedType}: ${selected.label}${pathSuffix}`)); + 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/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 2a28020932..13af6f098b 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -618,7 +618,10 @@ export class CustomEditor extends Editor { textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' ') ) { - trigger(); + const cmdName = textBeforeCursor.trim().slice(1); + if (cmdName !== 'skill' && cmdName !== 'skills') { + trigger(); + } } } } diff --git a/apps/kimi-code/src/tui/utils/searchable-list.ts b/apps/kimi-code/src/tui/utils/searchable-list.ts index 2077033803..56b39a1649 100644 --- a/apps/kimi-code/src/tui/utils/searchable-list.ts +++ b/apps/kimi-code/src/tui/utils/searchable-list.ts @@ -19,6 +19,8 @@ export interface SearchableListOptions { readonly items: readonly T[]; /** Text a list item is fuzzy-matched against. */ readonly toSearchText: (item: T) => string; + /** Optional predicate to pre-filter items based on current query string. */ + readonly filterItem?: (item: T, query: string) => boolean; /** Items per page; defaults to 8. */ readonly pageSize?: number; /** Initial cursor position (clamped to >= 0). */ @@ -40,6 +42,7 @@ export interface SearchableListView { export class SearchableList { private items: readonly T[]; private readonly toSearchText: (item: T) => string; + private readonly filterItem?: (item: T, query: string) => boolean; private readonly pageSize: number; private readonly searchable: boolean; private query = ''; @@ -48,6 +51,7 @@ export class SearchableList { constructor(opts: SearchableListOptions) { this.items = opts.items; this.toSearchText = opts.toSearchText; + this.filterItem = opts.filterItem; this.pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE; this.searchable = opts.searchable ?? false; this.cursor = Math.max(opts.initialIndex ?? 0, 0); @@ -63,8 +67,12 @@ export class SearchableList { } filtered(): readonly T[] { - if (this.query.length === 0) return this.items; - return fuzzyFilter([...this.items], this.query, this.toSearchText); + let pool = this.items; + if (this.filterItem) { + pool = pool.filter((item) => this.filterItem!(item, this.query)); + } + if (this.query.length === 0) return pool; + return fuzzyFilter([...pool], this.query, this.toSearchText); } /** The item under the cursor, clamped into the filtered range. */ @@ -100,6 +108,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..000e4cca3e --- /dev/null +++ b/apps/kimi-code/test/tui/commands/skill-group-tree.test.ts @@ -0,0 +1,140 @@ +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('enforces precedence order and supports multi-level relative folder paths', () => { + const multiFolderSkill = makeSkill('cv_semaphore-ops', { + path: '/home/user/.kimi/skills/cv/ops/semaphore/cv_semaphore-ops/SKILL.md', + }); + const overrideSkill = makeSkill('custom-tool', { + category: 'explicit-cat', + tags: ['ignored-tag1', 'ignored-tag2'], + path: '/home/user/.kimi/skills/folder-cat/custom-tool/SKILL.md', + }); + + const root = buildSkillGroupTree([multiFolderSkill, overrideSkill], { + skillRoots: ['/home/user/.kimi/skills'], + }); + + // Multi-level folder skill creates cv -> ops -> semaphore + const cvNode = findGroupNode(root, 'cv'); + expect(cvNode).toBeDefined(); + + const opsNode = findGroupNode(root, 'cv/ops'); + expect(opsNode).toBeDefined(); + + const semNode = findGroupNode(root, 'cv/ops/semaphore'); + expect(semNode).toBeDefined(); + expect(semNode?.skills.map((s) => s.name)).toEqual(['cv_semaphore-ops']); + + // category takes precedence over relative folder path and tags are ignored + const explicitCatNode = findGroupNode(root, 'explicit-cat'); + expect(explicitCatNode).toBeDefined(); + expect(explicitCatNode?.skills.map((s) => s.name)).toEqual(['custom-tool']); + + expect(findGroupNode(root, 'ignored-tag1')).toBeUndefined(); + expect(findGroupNode(root, 'folder-cat')).toBeUndefined(); + }); + + 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..2aa9ce9520 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/skill-selector.test.ts @@ -0,0 +1,168 @@ +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'); +} + +const ENTER = '\r'; +const ESC = '\x1b'; +const TAB = '\t'; + +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(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(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(ESC); + rendered = text(selector); + expect(rendered).toContain('Skills › cv'); + + // Escape back to root + selector.handleInput(ESC); + rendered = text(selector); + expect(rendered).toContain('Select skill group'); + + // Escape at root cancels + selector.handleInput(ESC); + 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(ENTER); // cv + selector.handleInput(ENTER); // ops + selector.handleInput(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(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 top-level group tabs with Tab key like /model chooser', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const selector = new SkillSelectorComponent({ + skills, + onSelect, + onCancel, + }); + + // Initial state: "All" tab active + let rendered = text(selector); + expect(rendered).toContain('Select skill group'); + + // Press Tab to cycle to next tab ("cv") + selector.handleInput(TAB); + rendered = text(selector); + expect(rendered).toContain('Skills › cv'); + + // Press Tab to cycle to next tab ("Uncategorized") + selector.handleInput(TAB); + rendered = text(selector); + expect(rendered).toContain('Skills › Uncategorized'); + + // Press Tab again to wrap back to "All" tab + selector.handleInput(TAB); + rendered = text(selector); + expect(rendered).toContain('Select skill group'); + }); +}); diff --git a/packages/agent-core-v2/src/app/skillCatalog/types.ts b/packages/agent-core-v2/src/app/skillCatalog/types.ts index 5bb3a55ef3..d1747c9360 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/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; } @@ -35,6 +41,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 { @@ -95,5 +107,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 1ae6239c7a..a7550ca2a7 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -309,6 +309,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, }; }