From da9a578d2dd236c5d73ab6728883777991dff3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E8=BE=B0?= Date: Tue, 26 May 2026 19:24:10 +0800 Subject: [PATCH] feat(tui): add /skills command to display available skills --- .changeset/add-skills-command.md | 5 + apps/kimi-code/src/tui/commands/registry.ts | 7 ++ .../tui/components/messages/skills-panel.ts | 94 +++++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 25 +++++ .../test/tui/commands/registry.test.ts | 1 + .../test/tui/commands/resolve.test.ts | 5 + .../components/messages/skills-panel.test.ts | 73 ++++++++++++++ docs/en/reference/slash-commands.md | 1 + docs/zh/reference/slash-commands.md | 1 + 9 files changed, 212 insertions(+) create mode 100644 .changeset/add-skills-command.md create mode 100644 apps/kimi-code/src/tui/components/messages/skills-panel.ts create mode 100644 apps/kimi-code/test/tui/components/messages/skills-panel.test.ts diff --git a/.changeset/add-skills-command.md b/.changeset/add-skills-command.md new file mode 100644 index 0000000..ea05a7a --- /dev/null +++ b/.changeset/add-skills-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add `/skills` slash command to display available skills in a grouped table. diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 83bece5..6b64c48 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -70,6 +70,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'skills', + aliases: [], + description: 'Show available skills', + priority: 60, + availability: 'always', + }, { name: 'compact', aliases: [], diff --git a/apps/kimi-code/src/tui/components/messages/skills-panel.ts b/apps/kimi-code/src/tui/components/messages/skills-panel.ts new file mode 100644 index 0000000..0b37905 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/skills-panel.ts @@ -0,0 +1,94 @@ +import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface SkillsReportOptions { + readonly colors: ColorPalette; + readonly skills: readonly SkillSummary[]; +} + +type Source = SkillSummary['source']; + +const SOURCE_ORDER: readonly Source[] = ['builtin', 'project', 'user', 'extra']; + +const SOURCE_LABEL: Record = { + builtin: 'Built-in', + project: 'Project', + user: 'User', + extra: 'Extra', +}; + +const SOURCE_PRIORITY: Record = { + builtin: 0, + project: 1, + user: 2, + extra: 3, +}; + +function sortSkills(skills: readonly SkillSummary[]): SkillSummary[] { + return skills.toSorted( + (a, b) => + SOURCE_PRIORITY[a.source] - SOURCE_PRIORITY[b.source] || + a.name.localeCompare(b.name), + ); +} + +function groupBySource( + skills: readonly SkillSummary[], +): Map { + const groups = new Map(); + for (const skill of skills) { + const list = groups.get(skill.source) ?? []; + list.push(skill); + groups.set(skill.source, list); + } + return groups; +} + +function buildSkillLines( + skills: readonly SkillSummary[], + value: (text: string) => string, + dim: (text: string) => string, + nameWidth: number, + typeWidth: number, +): string[] { + return skills.map((skill) => { + const name = skill.name.length > nameWidth ? skill.name.slice(0, nameWidth - 1) + '…' : skill.name; + const type = (skill.type ?? '-').padEnd(typeWidth).slice(0, typeWidth); + const desc = skill.description || '-'; + return ` ${value(name.padEnd(nameWidth))} ${dim(type)} ${value(desc)}`; + }); +} + +export function buildSkillsReportLines(options: SkillsReportOptions): string[] { + const { colors, skills } = options; + const accent = chalk.hex(colors.primary).bold; + const value = chalk.hex(colors.text); + const muted = chalk.hex(colors.textDim); + const dim = chalk.hex(colors.textDim); + + if (skills.length === 0) { + return [muted(' No skills available.')]; + } + + const sorted = sortSkills(skills); + const groups = groupBySource(sorted); + + const nameWidth = Math.min(24, Math.max('Name'.length, ...skills.map((s) => s.name.length))); + const typeWidth = Math.min(12, Math.max('Type'.length, ...skills.map((s) => (s.type ?? '-').length))); + + const lines: string[] = [ + ` ${muted('Name'.padEnd(nameWidth))} ${muted('Type'.padEnd(typeWidth))} ${muted('Description')}`, + ]; + + for (const source of SOURCE_ORDER) { + const group = groups.get(source); + if (group === undefined || group.length === 0) continue; + + lines.push(` ${accent(SOURCE_LABEL[source])}`); + lines.push(...buildSkillLines(group, value, dim, nameWidth, typeWidth)); + } + + return lines; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 10fb186..3d515a4 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -73,6 +73,7 @@ import type { Session, SessionMetaUpdatedEvent, SessionStatus, + SkillSummary, SessionUsage, SkillActivatedEvent, SubagentCompletedEvent, @@ -155,6 +156,7 @@ import { AgentGroupComponent } from './components/messages/agent-group'; import { AssistantMessageComponent } from './components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; import { buildMcpStatusReportLines } from './components/messages/mcp-status-panel'; +import { buildSkillsReportLines } from './components/messages/skills-panel'; import { ReadGroupComponent } from './components/messages/read-group'; import { SkillActivationComponent } from './components/messages/skill-activation'; import { @@ -1405,6 +1407,9 @@ export class KimiTUI { case 'mcp': void this.showMcpServers(); return; + case 'skills': + void this.showSkills(); + return; case 'editor': await this.handleEditorCommand(args, {}); return; @@ -4737,6 +4742,26 @@ export class KimiTUI { this.state.ui.requestRender(); } + // Loads and renders available skills. + private async showSkills(): Promise { + let skills: readonly SkillSummary[]; + try { + skills = await this.requireSession().listSkills(); + } catch (error) { + this.showError(`Failed to load skills: ${formatErrorMessage(error)}`); + return; + } + + const lines = buildSkillsReportLines({ + colors: this.state.theme.colors, + skills, + }); + const title = skills.length > 0 ? ` Skills (${skills.length}) ` : ' Skills '; + const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, title); + this.state.transcriptContainer.addChild(panel); + this.state.ui.requestRender(); + } + // Loads per-session usage and captures displayable errors. private async loadSessionUsageReport(): Promise { try { diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 3685857..e32a059 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -93,6 +93,7 @@ describe('built-in slash command registry', () => { 'plan', 'sessions', 'settings', + 'skills', 'status', 'theme', 'title', diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index ae043d5..ff09ba4 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -66,6 +66,11 @@ describe('resolveSlashCommandInput', () => { name: 'mcp', args: '', }); + expect(resolve('/skills', { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'skills', + args: '', + }); }); it('blocks plan clear while compacting because it is idle-only', () => { diff --git a/apps/kimi-code/test/tui/components/messages/skills-panel.test.ts b/apps/kimi-code/test/tui/components/messages/skills-panel.test.ts new file mode 100644 index 0000000..1c5d0c4 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/skills-panel.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSkillsReportLines } from '#/tui/components/messages/skills-panel'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +const mockSkills = [ + { name: 'gen-changesets', description: 'Generate changesets', path: '/a', source: 'builtin' as const, type: 'inline' }, + { name: 'gen-docs', description: 'Update docs after code changes', path: '/b', source: 'builtin' as const, type: 'inline' }, + { name: 'my-project-skill', description: 'Custom project workflow', path: '/c', source: 'project' as const, type: 'flow' }, + { name: 'user-snippet', description: 'Personal snippet', path: '/d', source: 'user' as const }, + { name: 'extra-helper', description: 'Extra helper skill', path: '/e', source: 'extra' as const, type: 'prompt' }, +]; + +describe('skills panel report lines', () => { + it('renders a grouped table with aligned columns', () => { + const lines = buildSkillsReportLines({ + colors: darkColors, + skills: mockSkills, + }).map(strip); + + const output = lines.join('\n'); + + // Global header + expect(output).toContain('Name'); + expect(output).toContain('Type'); + expect(output).toContain('Description'); + + // Group labels + expect(output).toContain('Built-in'); + expect(output).toContain('Project'); + expect(output).toContain('User'); + expect(output).toContain('Extra'); + + // Skill rows + expect(output).toContain('gen-changesets'); + expect(output).toContain('gen-docs'); + expect(output).toContain('my-project-skill'); + expect(output).toContain('user-snippet'); + expect(output).toContain('extra-helper'); + + // Empty groups should be omitted — verify only one Project row exists + const projectMatches = output.split('Project').length - 1; + expect(projectMatches).toBe(1); + }); + + it('renders empty state when no skills are available', () => { + const lines = buildSkillsReportLines({ + colors: darkColors, + skills: [], + }).map(strip); + + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('No skills available'); + }); + + it('skips empty source groups', () => { + const lines = buildSkillsReportLines({ + colors: darkColors, + skills: [{ name: 'only-one', description: 'Only skill', path: '/x', source: 'builtin' as const }], + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Built-in'); + expect(output).toContain('only-one'); + expect(output).not.toContain('Project'); + expect(output).not.toContain('User'); + expect(output).not.toContain('Extra'); + }); +}); diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index b8aed90..4f438ec 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -52,6 +52,7 @@ Some commands are only available in the idle state. Running them while the sessi | `/usage` | — | Show token usage, context consumption, and quota information. | Yes | | `/status` | — | Show the current session runtime status, including version, model, working directory, and permission mode. | Yes | | `/mcp` | — | List the MCP servers in the current session and their connection status. | Yes | +| `/skills` | — | Show all available skills grouped by source (built-in, project, user, extra). | Yes | | `/version` | — | Show the Kimi Code CLI version number. | Yes | | `/feedback` | — | Submit feedback to help improve Kimi Code CLI. | Yes | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index ddecaed..ea018a2 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -52,6 +52,7 @@ | `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 | | `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 | | `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 | +| `/skills` | — | 展示所有可用 Skill,按来源分组(内置、项目、用户、扩展)。 | 是 | | `/version` | — | 显示 Kimi Code CLI 版本号。 | 是 | | `/feedback` | — | 提交反馈以改进 Kimi Code CLI。 | 是 |