From e2d10f19d75b3fb1ec80c84e48db32cb0643f6a2 Mon Sep 17 00:00:00 2001 From: mb Date: Sun, 16 Aug 2026 16:08:32 +0200 Subject: [PATCH 1/4] feat: add hierarchical skill group selector --- .changeset/skill-group-selector.md | 5 + apps/kimi-code/src/tui/commands/dispatch.ts | 55 ++++- apps/kimi-code/src/tui/commands/prompts.ts | 26 ++- apps/kimi-code/src/tui/commands/registry.ts | 7 + .../src/tui/commands/skill-group-tree.ts | 195 +++++++++++++++++ .../tui/components/dialogs/skill-selector.ts | 206 ++++++++++++++++++ .../tui/commands/skill-group-tree.test.ts | 106 +++++++++ .../components/dialogs/skill-selector.test.ts | 135 ++++++++++++ .../src/app/skillCatalog/types.ts | 15 ++ .../test/app/skillCatalog/parser.test.ts | 25 +++ .../test/app/skillCatalog/types.test.ts | 29 +++ packages/agent-core/src/rpc/core-api.ts | 5 + packages/agent-core/src/skill/types.ts | 15 ++ 13 files changed, 822 insertions(+), 2 deletions(-) create mode 100644 .changeset/skill-group-selector.md create mode 100644 apps/kimi-code/src/tui/commands/skill-group-tree.ts create mode 100644 apps/kimi-code/src/tui/components/dialogs/skill-selector.ts create mode 100644 apps/kimi-code/test/tui/commands/skill-group-tree.test.ts create mode 100644 apps/kimi-code/test/tui/components/dialogs/skill-selector.test.ts 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..13466edc6a 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,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..69f88212dd --- /dev/null +++ b/apps/kimi-code/src/tui/commands/skill-group-tree.ts @@ -0,0 +1,195 @@ +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 resolveGroupPathsForSkill( + skill: SkillSummary, + skillRoots: readonly string[] = [], +): readonly string[] { + // Rule 1: groups metadata + if (Array.isArray(skill.groups) && skill.groups.length > 0) { + const validGroups: string[] = []; + for (const rawGroup of skill.groups) { + if (typeof rawGroup !== 'string') continue; + const segments = rawGroup.split('/').map((s) => s.trim()).filter((s) => s !== ''); + if (segments.length > 0) { + const cleanPath = segments.join('/'); + if (!validGroups.includes(cleanPath)) { + validGroups.push(cleanPath); + } + } + } + if (validGroups.length > 0) return validGroups; + } + + // Rule 2: category metadata + if (typeof skill.category === 'string' && skill.category.trim() !== '') { + const cat = skill.category.trim(); + return [cat]; + } + + // Rule 3: relative parent folder + const folderFallback = deriveFolderGroup(skill.path, skillRoots, skill.name); + if (folderFallback !== undefined) { + return [folderFallback]; + } + + // Rule 4: 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'); + 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..4c61c1de98 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts @@ -0,0 +1,206 @@ +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, + }); + } + + handleInput(data: string): void { + 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']; + 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/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..7fc32eba88 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/skill-group-tree.test.ts @@ -0,0 +1,106 @@ +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('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..f1d9c844b7 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/skill-selector.test.ts @@ -0,0 +1,135 @@ +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'); + }); +}); diff --git a/packages/agent-core-v2/src/app/skillCatalog/types.ts b/packages/agent-core-v2/src/app/skillCatalog/types.ts index 5bb3a55ef3..14b0a447dd 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/types.ts @@ -9,6 +9,11 @@ export interface SkillMetadata { readonly isSubSkill?: boolean | undefined; readonly safe?: boolean | undefined; readonly arguments?: readonly unknown[] | string | undefined; + readonly category?: 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 +40,11 @@ export interface SkillSummary { readonly type?: string | undefined; readonly disableModelInvocation?: boolean | undefined; readonly isSubSkill?: boolean | undefined; + readonly category?: 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 +105,10 @@ 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, + 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..05e7b87d9c 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -309,6 +309,11 @@ export interface SkillSummary { readonly type?: string | undefined; readonly disableModelInvocation?: boolean | undefined; readonly isSubSkill?: boolean | undefined; + readonly category?: 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..68b360ce09 100644 --- a/packages/agent-core/src/skill/types.ts +++ b/packages/agent-core/src/skill/types.ts @@ -9,6 +9,11 @@ export interface SkillMetadata { readonly isSubSkill?: boolean | undefined; readonly safe?: boolean | undefined; readonly arguments?: readonly unknown[] | string | undefined; + readonly category?: 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 +38,11 @@ export interface SkillSummary { readonly type?: string | undefined; readonly disableModelInvocation?: boolean | undefined; readonly isSubSkill?: boolean | undefined; + readonly category?: 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 +100,10 @@ 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, + 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, }; } From d8fa805ded5003ca4eaa2496f078fc048e91fe1a Mon Sep 17 00:00:00 2001 From: mb Date: Sun, 16 Aug 2026 17:17:16 +0200 Subject: [PATCH 2/4] feat: add tab key group jumping and multi-source skill group resolution --- .../src/tui/commands/skill-group-tree.ts | 85 +++++++++++++++---- .../tui/components/dialogs/skill-selector.ts | 39 ++++++++- .../src/tui/utils/searchable-list.ts | 9 ++ .../tui/commands/skill-group-tree.test.ts | 15 ++++ .../components/dialogs/skill-selector.test.ts | 46 ++++++++-- .../src/app/skillCatalog/types.ts | 7 ++ packages/agent-core/src/rpc/core-api.ts | 1 + packages/agent-core/src/skill/types.ts | 7 ++ 8 files changed, 183 insertions(+), 26 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/skill-group-tree.ts b/apps/kimi-code/src/tui/commands/skill-group-tree.ts index 69f88212dd..a11cc7feae 100644 --- a/apps/kimi-code/src/tui/commands/skill-group-tree.ts +++ b/apps/kimi-code/src/tui/commands/skill-group-tree.ts @@ -118,40 +118,93 @@ export function findGroupNode( 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[] { - // Rule 1: groups metadata + const resultGroups: string[] = []; + + // Rule 1: Explicit frontmatter `groups` if (Array.isArray(skill.groups) && skill.groups.length > 0) { - const validGroups: string[] = []; for (const rawGroup of skill.groups) { if (typeof rawGroup !== 'string') continue; - const segments = rawGroup.split('/').map((s) => s.trim()).filter((s) => s !== ''); - if (segments.length > 0) { - const cleanPath = segments.join('/'); - if (!validGroups.includes(cleanPath)) { - validGroups.push(cleanPath); - } + const cleanPath = cleanGroupPath(rawGroup); + if (cleanPath !== undefined && !resultGroups.includes(cleanPath)) { + resultGroups.push(cleanPath); } } - if (validGroups.length > 0) return validGroups; } - // Rule 2: category metadata + // Rule 2: Explicit `category` or `categories` + const categoryCandidates: string[] = []; if (typeof skill.category === 'string' && skill.category.trim() !== '') { - const cat = skill.category.trim(); - return [cat]; + 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 3: relative parent folder + // Rule 4: Relative parent folder derivation const folderFallback = deriveFolderGroup(skill.path, skillRoots, skill.name); if (folderFallback !== undefined) { - return [folderFallback]; + 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 4: Uncategorized - return ['Uncategorized']; + // 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( diff --git a/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts b/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts index 4c61c1de98..64f6180bc1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts @@ -94,7 +94,44 @@ export class SkillSelectorComponent extends Container implements Focusable { }); } + 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 !== '') { @@ -141,7 +178,7 @@ export class SkillSelectorComponent extends Container implements Focusable { ? currentTheme.fg('textMuted', ' (type to search)') : ''; - const hintParts = ['↑↓ navigate']; + const hintParts = ['↑↓ navigate', 'Tab jump groups']; if (view.page.pageCount > 1) hintParts.push('←→ page'); hintParts.push('Enter select', 'Esc back/cancel'); 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 index 7fc32eba88..72a00e1880 100644 --- a/apps/kimi-code/test/tui/commands/skill-group-tree.test.ts +++ b/apps/kimi-code/test/tui/commands/skill-group-tree.test.ts @@ -92,6 +92,21 @@ describe('skill-group-tree', () => { expect(uncatNode?.skills.map((s) => s.name)).toEqual(['flat-skill']); }); + it('derives groups from tags and underscore 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' }); 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 index f1d9c844b7..9ef24ae50f 100644 --- a/apps/kimi-code/test/tui/components/dialogs/skill-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/skill-selector.test.ts @@ -18,6 +18,10 @@ 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'], @@ -58,7 +62,7 @@ describe('SkillSelectorComponent', () => { }); // Enter on 'cv' - selector.handleInput(Key.enter); + selector.handleInput(ENTER); let rendered = text(selector); expect(rendered).toContain('Skills › cv'); expect(rendered).toContain('ops'); @@ -66,24 +70,24 @@ describe('SkillSelectorComponent', () => { expect(rendered).toContain('cv_semaphore-ops'); // Enter on 'ops' - selector.handleInput(Key.enter); + 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(Key.escape); + selector.handleInput(ESC); rendered = text(selector); expect(rendered).toContain('Skills › cv'); // Escape back to root - selector.handleInput(Key.escape); + selector.handleInput(ESC); rendered = text(selector); expect(rendered).toContain('Select skill group'); // Escape at root cancels - selector.handleInput(Key.escape); + selector.handleInput(ESC); expect(onCancel).toHaveBeenCalledTimes(1); }); @@ -97,9 +101,9 @@ describe('SkillSelectorComponent', () => { }); // Drill down: Root -> cv -> ops -> semaphore - selector.handleInput(Key.enter); // cv - selector.handleInput(Key.enter); // ops - selector.handleInput(Key.enter); // semaphore + selector.handleInput(ENTER); // cv + selector.handleInput(ENTER); // ops + selector.handleInput(ENTER); // semaphore const rendered = text(selector); expect(rendered).toContain('Skills › cv › ops › semaphore'); @@ -107,7 +111,7 @@ describe('SkillSelectorComponent', () => { expect(rendered).toContain('Semaphore operations'); // Press Enter on cv_semaphore-ops - selector.handleInput(Key.enter); + selector.handleInput(ENTER); expect(onSelect).toHaveBeenCalledWith(semaphoreOps); }); @@ -132,4 +136,28 @@ describe('SkillSelectorComponent', () => { 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(TAB); + rendered = text(selector); + expect(rendered).toContain('❯ Uncategorized/'); + + // Press Tab again to wrap back to cv/ + selector.handleInput(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 14b0a447dd..d1747c9360 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/types.ts @@ -10,6 +10,7 @@ export interface SkillMetadata { 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; @@ -41,6 +42,7 @@ export interface SkillSummary { 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; @@ -106,6 +108,11 @@ export function summarizeSkill(skill: SkillDefinition): SkillSummary { 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, diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 05e7b87d9c..a7550ca2a7 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -310,6 +310,7 @@ export interface SkillSummary { 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; diff --git a/packages/agent-core/src/skill/types.ts b/packages/agent-core/src/skill/types.ts index 68b360ce09..ec7e702b41 100644 --- a/packages/agent-core/src/skill/types.ts +++ b/packages/agent-core/src/skill/types.ts @@ -10,6 +10,7 @@ export interface SkillMetadata { 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; @@ -39,6 +40,7 @@ export interface SkillSummary { 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; @@ -101,6 +103,11 @@ export function summarizeSkill(skill: SkillDefinition): SkillSummary { 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, From bbabfebb881055740150ebbb4787c6e157df216d Mon Sep 17 00:00:00 2001 From: mb Date: Mon, 17 Aug 2026 07:57:25 +0200 Subject: [PATCH 3/4] feat(tui): improve skill selector with tab strip switching and direct invocation --- apps/kimi-code/src/tui/commands/dispatch.ts | 8 +- .../src/tui/commands/skill-group-tree.ts | 116 +++++++--------- .../tui/components/dialogs/skill-selector.ts | 129 +++++++++++++----- .../tui/components/editor/custom-editor.ts | 5 +- .../src/tui/utils/searchable-list.ts | 12 +- .../tui/commands/skill-group-tree.test.ts | 39 ++++-- .../components/dialogs/skill-selector.test.ts | 19 ++- 7 files changed, 208 insertions(+), 120 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 13466edc6a..558d5cbc43 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -639,7 +639,13 @@ async function handleSkillCommand( const activatableSkills = skills.filter(isUserActivatableSkill); const trimmedArgs = args.trim(); - if (trimmedArgs.length > 0) { + 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() : ''; diff --git a/apps/kimi-code/src/tui/commands/skill-group-tree.ts b/apps/kimi-code/src/tui/commands/skill-group-tree.ts index a11cc7feae..b152551572 100644 --- a/apps/kimi-code/src/tui/commands/skill-group-tree.ts +++ b/apps/kimi-code/src/tui/commands/skill-group-tree.ts @@ -130,20 +130,22 @@ function resolveGroupPathsForSkill( skill: SkillSummary, skillRoots: readonly string[] = [], ): readonly string[] { - const resultGroups: string[] = []; - - // Rule 1: Explicit frontmatter `groups` + // 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 && !resultGroups.includes(cleanPath)) { - resultGroups.push(cleanPath); + if (cleanPath !== undefined && !groups.includes(cleanPath)) { + groups.push(cleanPath); } } + if (groups.length > 0) { + return groups; + } } - // Rule 2: Explicit `category` or `categories` + // Precedence Rule 2: Explicit `category` or `categories` const categoryCandidates: string[] = []; if (typeof skill.category === 'string' && skill.category.trim() !== '') { categoryCandidates.push(skill.category.trim()); @@ -155,61 +157,35 @@ function resolveGroupPathsForSkill( } } } - 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); + 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; + } } - // Rule 4: Relative parent folder derivation + // 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 && !resultGroups.includes(clean)) { - resultGroups.push(clean); + if (clean !== undefined) { + return [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; + // Precedence Rule 4: Final fallback to Uncategorized + return ['Uncategorized']; } function deriveFolderGroup( skillPath: string, - skillRoots: readonly string[], + skillRoots: readonly string[] = [], skillName?: string, ): string | undefined { if (!skillPath) return undefined; @@ -219,30 +195,42 @@ function deriveFolderGroup( 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'); + const segments = rel + .split(path.sep) + .filter((s) => s !== '' && s !== 'SKILL.md' && !s.endsWith('.md')); if (segments.length >= 2) { - // e.g. ["security", "owasp-audit"] -> "security" - return segments[0]; + const last = segments[segments.length - 1]; + if (last === skillName || (skillName && last?.toLowerCase() === skillName.toLowerCase())) { + segments.pop(); + } + } + if (segments.length > 0) { + return segments.join('/'); } } } - // 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; + 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 index 64f6180bc1..5ca39209be 100644 --- a/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/skill-selector.ts @@ -11,6 +11,7 @@ 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, @@ -33,12 +34,16 @@ export type SkillSelectorItem = 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 { @@ -49,6 +54,19 @@ function countSkillsInTree(node: SkillGroupNode): number { return count; } +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; @@ -63,6 +81,41 @@ export class SkillSelectorComponent extends Container implements Focusable { 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[] = []; @@ -74,61 +127,50 @@ export class SkillSelectorComponent extends Container implements Focusable { 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}`, + 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, }); } - 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); + this.cycleTab(isShift); return; } @@ -178,7 +220,7 @@ export class SkillSelectorComponent extends Container implements Focusable { ? currentTheme.fg('textMuted', ' (type to search)') : ''; - const hintParts = ['↑↓ navigate', 'Tab jump groups']; + const hintParts = ['↑↓ navigate', 'Tab switch group']; if (view.page.pageCount > 1) hintParts.push('←→ page'); hintParts.push('Enter select', 'Esc back/cancel'); @@ -189,6 +231,17 @@ export class SkillSelectorComponent extends Container implements Focusable { '', ]; + 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)); } @@ -213,6 +266,9 @@ export class SkillSelectorComponent extends Container implements Focusable { 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); } @@ -224,7 +280,9 @@ export class SkillSelectorComponent extends Container implements Focusable { const selected = this.list.selected(); if (selected !== undefined) { const selectedType = selected.kind === 'group' ? 'Group' : 'Skill'; - lines.push(currentTheme.fg('textMuted', ` ${selectedType}: ${selected.label}`)); + 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(''); } @@ -241,3 +299,4 @@ export class SkillSelectorComponent extends Container implements Focusable { 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 01ef501c67..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. */ 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 index 72a00e1880..000e4cca3e 100644 --- a/apps/kimi-code/test/tui/commands/skill-group-tree.test.ts +++ b/apps/kimi-code/test/tui/commands/skill-group-tree.test.ts @@ -92,19 +92,38 @@ describe('skill-group-tree', () => { expect(uncatNode?.skills.map((s) => s.name)).toEqual(['flat-skill']); }); - it('derives groups from tags and underscore skill name namespace fallback', () => { - const taggedSkill = makeSkill('custom-tool', { tags: ['security', 'audit'] }); - const winPrivEsc = makeSkill('windows_privilege_escalation', { path: '/SKILL.md' }); + 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([taggedSkill, winPrivEsc]); + const root = buildSkillGroupTree([multiFolderSkill, overrideSkill], { + skillRoots: ['/home/user/.kimi/skills'], + }); - const secNode = findGroupNode(root, 'security'); - expect(secNode).toBeDefined(); - expect(secNode?.skills.map((s) => s.name)).toEqual(['custom-tool']); + // 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']); - const winNode = findGroupNode(root, 'windows'); - expect(winNode).toBeDefined(); - expect(winNode?.skills.map((s) => s.name)).toEqual(['windows_privilege_escalation']); + expect(findGroupNode(root, 'ignored-tag1')).toBeUndefined(); + expect(findGroupNode(root, 'folder-cat')).toBeUndefined(); }); it('preserves deterministic alphabetical ordering of groups and skills', () => { 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 index 9ef24ae50f..2aa9ce9520 100644 --- a/apps/kimi-code/test/tui/components/dialogs/skill-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/skill-selector.test.ts @@ -137,7 +137,7 @@ describe('SkillSelectorComponent', () => { expect(rendered).not.toContain(' cv\n'); }); - it('cycles through group items with Tab key', () => { + 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({ @@ -146,18 +146,23 @@ describe('SkillSelectorComponent', () => { onCancel, }); - // Root items: cv/ and Uncategorized/ + // Initial state: "All" tab active let rendered = text(selector); - expect(rendered).toContain('❯ cv/'); + 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 group (Uncategorized/) + // Press Tab to cycle to next tab ("Uncategorized") selector.handleInput(TAB); rendered = text(selector); - expect(rendered).toContain('❯ Uncategorized/'); + expect(rendered).toContain('Skills › Uncategorized'); - // Press Tab again to wrap back to cv/ + // Press Tab again to wrap back to "All" tab selector.handleInput(TAB); rendered = text(selector); - expect(rendered).toContain('❯ cv/'); + expect(rendered).toContain('Select skill group'); }); }); From 818a05269d3178c3e1ce9be95543a57305929bfb Mon Sep 17 00:00:00 2001 From: mb Date: Tue, 18 Aug 2026 01:22:48 +0200 Subject: [PATCH 4/4] fix(tui): busy-gate /skill command while turn is active --- apps/kimi-code/src/tui/commands/dispatch.ts | 25 +++++++++++++++++++++ apps/kimi-code/src/tui/commands/registry.ts | 1 - 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 558d5cbc43..0a50accc97 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -622,10 +622,27 @@ 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[] = []; @@ -667,6 +684,14 @@ async function handleSkillCommand( 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/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 44c6bcb55c..10cc249d94 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -211,7 +211,6 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: ['skills'], description: 'Select skill from hierarchical group selector', priority: 90, - availability: 'always', }, { name: 'btw',