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/skill-group-selector.md
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reduce the changeset to a single sentence

Rewrite this as one short user-facing sentence stating only the change; the second usage-instruction sentence violates the repository’s required changeset format and will flow into release notes.

AGENTS.md reference: AGENTS.md:L85-L87

Useful? React with 👍 / 👎.

86 changes: 85 additions & 1 deletion apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -424,6 +426,7 @@ const SESSION_REQUIRING_COMMANDS: ReadonlySet<BuiltinSlashCommandName> = new Set
'goal',
'init',
'plan',
'skill',
'swarm',
'undo',
'web',
Expand Down Expand Up @@ -606,8 +609,89 @@ 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<void> {
const busyReason = slashCommandBusyReason({
isStreaming: host.state.appState.streamingPhase !== 'idle',
isCompacting: host.state.appState.isCompacting,
});
if (busyReason !== undefined) {
host.showError(slashBusyMessage('skill', busyReason));
return;
}

let session = host.session;
if (session === undefined) {
session = await ensureSessionForCommand(host);
if (session === undefined) return;
const busyCheck = slashCommandBusyReason({
isStreaming: host.state.appState.streamingPhase !== 'idle',
isCompacting: host.state.appState.isCompacting,
});
if (busyCheck !== undefined) {
host.showError(slashBusyMessage('skill', busyCheck));
return;
}
}

let skills: readonly SkillSummary[] = [];
try {
skills = await session.listSkills();
Comment on lines +648 to +650

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck the busy state after loading skills

Fresh evidence in this revision is the still-uncovered await session.listSkills() after the new initial gate: because slash dispatch is fire-and-forget, another prompt can start a turn while this call is pending. In that case /skill <name> reaches sendSkillActivation without another check, while bare /skill opens its dialog during the active turn; recheck immediately after this await before either branch.

Useful? React with 👍 / 👎.

} catch (error) {
host.showError(formatErrorMessage(error));
return;
}

const activatableSkills = skills.filter(isUserActivatableSkill);
const trimmedArgs = args.trim();

if (
trimmedArgs.length > 0 &&
trimmedArgs !== 'skill' &&
trimmedArgs !== '/skill' &&
trimmedArgs !== 'skills' &&
trimmedArgs !== '/skills'
) {
const spaceIdx = trimmedArgs.search(/\s/);
const firstWord = spaceIdx >= 0 ? trimmedArgs.slice(0, spaceIdx) : trimmedArgs;
const remainingArgs = spaceIdx >= 0 ? trimmedArgs.slice(spaceIdx + 1).trim() : '';

const resolvedName =
resolveSkillCommand(host.skillCommandMap, firstWord) ??
resolveSkillCommand(host.skillCommandMap, trimmedArgs) ??
firstWord;
const targetSkill = activatableSkills.find(
(s) => s.name === resolvedName || s.name === firstWord || s.name === trimmedArgs,
);
if (targetSkill !== undefined) {
const skillArgs =
targetSkill.name === resolvedName || targetSkill.name === firstWord ? remainingArgs : '';
host.sendSkillActivation(session, targetSkill.name, skillArgs);
return;
}
}

const selectedSkill = await runSkillSelector(host, activatableSkills);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass configured skill roots into the selector

When a session uses the supported --skills-dir option with an arbitrary root such as /opt/company-capabilities, this production call omits the roots even though runSkillSelector accepts them. A nested skill such as team/deploy/SKILL.md then cannot be relativized, and because its path contains neither the hard-coded skills nor skillshub marker, it is incorrectly placed in Uncategorized instead of team.

Useful? React with 👍 / 👎.

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, '');
}
}
26 changes: 25 additions & 1 deletion apps/kimi-code/src/tui/commands/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
Expand Down Expand Up @@ -249,3 +250,26 @@ export function runModelSelector(
host.mountEditorReplacement(selector);
});
}

export function runSkillSelector(
host: SlashCommandHost,
skills: readonly SkillSummary[],
skillRoots?: readonly string[],
): Promise<SkillSummary | undefined> {
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);
});
}
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 95,
availability: 'always',
},
{
name: 'skill',
aliases: ['skills'],
description: 'Select skill from hierarchical group selector',
priority: 90,
},
{
name: 'btw',
aliases: [],
Expand Down
Loading