Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/loaded-skills-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add a /skills command that searches and inspects skills loaded in the current session.
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 60,
availability: 'always',
},
{
name: 'skills',
aliases: [],
description: 'Show loaded skills and slash commands',
priority: 60,
availability: 'always',
},
{
name: 'status',
aliases: [],
Expand Down
91 changes: 91 additions & 0 deletions apps/kimi-code/src/tui/components/messages/skills-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { isAbsolute, relative } from 'node:path';

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[];
readonly workDir: string;
}

type Colorize = (text: string) => string;

const SOURCE_GROUPS: ReadonlyArray<{
readonly source: SkillSummary['source'];
readonly label: string;
}> = [
{ source: 'project', label: 'Project' },
{ source: 'user', label: 'User' },
{ source: 'extra', label: 'Extra' },
{ source: 'builtin', label: 'Built-in' },
];

export function formatSkillType(skill: SkillSummary): string {
return skill.type ?? 'prompt';
}

export function formatSkillCapability(skill: SkillSummary): string {
const parts = [`type: ${formatSkillType(skill)}`];
if (skill.disableModelInvocation === true) parts.push('manual only');
return parts.join(' | ');
}

export function displaySkillPath(path: string, workDir: string): string {
if (!isAbsolute(path) || workDir.length === 0) return path;
const relativePath = relative(workDir, path);
if (
relativePath.length === 0 ||
relativePath.startsWith('..') ||
isAbsolute(relativePath)
) {
return path;
}
return relativePath;
}

function appendSkillLines(
lines: string[],
skills: readonly SkillSummary[],
workDir: string,
accent: Colorize,
value: Colorize,
muted: Colorize,
): void {
for (const group of SOURCE_GROUPS) {
const groupSkills = skills.filter((skill) => skill.source === group.source);
if (groupSkills.length === 0) continue;

if (lines.length > 0) lines.push('');
lines.push(accent(group.label));

for (const skill of groupSkills) {
const description = skill.description.trim();
lines.push(` ${value(skill.name)} ${muted(formatSkillCapability(skill))}`);
if (description.length > 0) {
lines.push(` ${description}`);
}
lines.push(` ${muted('path:')} ${displaySkillPath(skill.path, workDir)}`);
}
}
}

export function buildSkillsReportLines(options: SkillsReportOptions): string[] {
const colors = options.colors;
const accent = chalk.hex(colors.primary).bold;
const value = chalk.hex(colors.text);
const muted = chalk.hex(colors.textDim);
const skills = [...options.skills].toSorted(
(a, b) => a.source.localeCompare(b.source) || a.name.localeCompare(b.name),
);

if (skills.length === 0) {
return [accent('Skills'), muted(' No skills loaded for this session.')];
}

const lines: string[] = [];
appendSkillLines(lines, skills, options.workDir, accent, value, muted);
return lines;
}
91 changes: 91 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import type {
SessionStatus,
SessionUsage,
SkillActivatedEvent,
SkillSummary,
SubagentCompletedEvent,
SubagentFailedEvent,
SubagentSpawnedEvent,
Expand Down Expand Up @@ -158,6 +159,11 @@ import { BackgroundAgentStatusComponent } from './components/messages/background
import { buildMcpStatusReportLines } from './components/messages/mcp-status-panel';
import { ReadGroupComponent } from './components/messages/read-group';
import { SkillActivationComponent } from './components/messages/skill-activation';
import {
buildSkillsReportLines,
displaySkillPath,
formatSkillCapability,
} from './components/messages/skills-panel';
import {
NoticeMessageComponent,
StatusMessageComponent,
Expand Down Expand Up @@ -408,6 +414,42 @@ export interface TUIState {
queuedMessages: QueuedMessage[];
}

const SKILL_SOURCE_LABELS = {
project: 'Project',
user: 'User',
extra: 'Extra',
builtin: 'Built-in',
} satisfies Record<SkillSummary['source'], string>;

const SKILL_SOURCE_ORDER = {
project: 0,
user: 1,
extra: 2,
builtin: 3,
} satisfies Record<SkillSummary['source'], number>;

function buildSkillChoiceOptions(
skills: readonly SkillSummary[],
workDir: string,
): readonly ChoiceOption[] {
return [...skills]
.toSorted((a, b) => {
const sourceDiff = SKILL_SOURCE_ORDER[a.source] - SKILL_SOURCE_ORDER[b.source];
return sourceDiff === 0 ? a.name.localeCompare(b.name) : sourceDiff;
})
.map((skill) => {
const description = skill.description.trim();
const path = displaySkillPath(skill.path, workDir);
const details = `${SKILL_SOURCE_LABELS[skill.source]} · ${formatSkillCapability(skill)}`;
return {
value: skill.name,
label: `${skill.name} ${details}`,
description:
description.length > 0 ? `${description} path: ${path}` : `path: ${path}`,
};
});
}

// Builds the app-state snapshot used before a session is attached.
function createInitialAppState(input: KimiTUIStartupInput): AppState {
const startupPermission: PermissionMode = input.cliOptions.yolo ? 'yolo' : 'manual';
Expand Down Expand Up @@ -1541,6 +1583,9 @@ export class KimiTUI {
case 'usage':
void this.showUsage();
return;
case 'skills':
void this.showSkills();
return;
case 'status':
void this.showStatusReport();
return;
Expand Down Expand Up @@ -4823,6 +4868,52 @@ export class KimiTUI {
this.state.ui.requestRender();
}

// Loads and renders skills visible to the current session.
private async showSkills(): Promise<void> {
let skills: readonly SkillSummary[];
try {
skills = await this.requireSession().listSkills();
} catch (error) {
this.showError(`Failed to load skills: ${formatErrorMessage(error)}`);
return;
}

if (skills.length > 0) {
this.showSkillsPicker(skills);
return;
}

const lines = buildSkillsReportLines({
colors: this.state.theme.colors,
skills,
workDir: this.state.appState.workDir,
});
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();
}

private showSkillsPicker(skills: readonly SkillSummary[]): void {
const options = buildSkillChoiceOptions(skills, this.state.appState.workDir);
const picker = new ChoicePickerComponent({
title: `Skills (${skills.length})`,
hint: 'type to filter · ↑↓ navigate · Enter insert command · Esc close',
options,
colors: this.state.theme.colors,
searchable: true,
onSelect: (skillName) => {
this.restoreEditor();
this.state.editor.setText(`/skill:${skillName} `);
this.state.ui.requestRender();
},
onCancel: () => {
this.restoreEditor();
},
});
this.mountEditorReplacement(picker);
}

// Loads and renders current runtime status.
private async showStatusReport(): Promise<void> {
const [runtimeStatus, managedUsage] = await Promise.all([
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/tui/commands/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe('built-in slash command registry', () => {
'plan',
'sessions',
'settings',
'skills',
'status',
'theme',
'title',
Expand Down
99 changes: 99 additions & 0 deletions apps/kimi-code/test/tui/components/messages/skills-panel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk';
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, '');
}

function skill(input: {
readonly name: string;
readonly source: SkillSummary['source'];
readonly description?: string;
readonly type?: string;
readonly disableModelInvocation?: boolean;
}): SkillSummary {
return {
name: input.name,
description: input.description ?? `${input.name} description`,
path: `/skills/${input.name}/SKILL.md`,
source: input.source,
type: input.type,
disableModelInvocation: input.disableModelInvocation,
};
}

describe('skills panel report lines', () => {
it('groups loaded skills and shows paths, types, descriptions, and manual-only state', () => {
const lines = buildSkillsReportLines({
colors: darkColors,
workDir: '/workspace/project',
skills: [
skill({
name: 'mcp-config',
source: 'builtin',
type: 'inline',
disableModelInvocation: true,
}),
skill({ name: 'review', source: 'project', type: 'prompt' }),
skill({ name: 'deploy', source: 'user', type: 'flow', description: 'Deploy the app' }),
],
}).map(strip);

const output = lines.join('\n');
expect(output).toContain('Project');
expect(output).toContain('review type: prompt');
expect(output).toContain('path: /skills/review/SKILL.md');
expect(output).toContain('User');
expect(output).toContain('deploy type: flow');
expect(output).toContain('Deploy the app');
expect(output).toContain('Built-in');
expect(output).toContain('mcp-config type: inline | manual only');
expect(output).not.toContain('command:');
});

it('renders skills without an explicit type as prompt skills', () => {
const lines = buildSkillsReportLines({
colors: darkColors,
workDir: '/workspace/project',
skills: [skill({ name: 'review', source: 'project' })],
}).map(strip);

const output = lines.join('\n');
expect(output).toContain('review type: prompt');
});

it('renders the empty loaded skills state', () => {
const lines = buildSkillsReportLines({
colors: darkColors,
skills: [],
workDir: '/workspace/project',
}).map(strip);

expect(lines).toContain('Skills');
expect(lines).toContain(' No skills loaded for this session.');
});

it('renders paths under the current workdir as relative paths', () => {
const lines = buildSkillsReportLines({
colors: darkColors,
workDir: '/workspace/project',
skills: [
{
...skill({ name: 'local', source: 'project', type: 'prompt' }),
path: '/workspace/project/.agents/skills/local/SKILL.md',
},
{
...skill({ name: 'external', source: 'extra', type: 'prompt' }),
path: '/workspace/shared/external/SKILL.md',
},
],
}).map(strip);

const output = lines.join('\n');
expect(output).toContain('path: .agents/skills/local/SKILL.md');
expect(output).toContain('path: /workspace/shared/external/SKILL.md');
});
});
Loading
Loading