Skip to content
Merged
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/web-skill-slash-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Show session skills in the slash menu as `/skill:<name>` so they are distinguishable from built-in commands; typing the bare skill name still works.
15 changes: 9 additions & 6 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import ServerAuthDialog from './components/ServerAuthDialog.vue';
import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth';
import type { AppConfig, ThinkingLevel } from './api/types';
import { coerceThinkingForModel, commitLevel, segmentsFor } from './lib/modelThinking';
import { stripSkillPrefix } from './lib/slashCommands';
import Button from './components/ui/Button.vue';
import IconButton from './components/ui/IconButton.vue';
import Icon from './components/ui/Icon.vue';
Expand Down Expand Up @@ -486,13 +487,15 @@ function handleCommand(cmd: string): void {
break;
default: {
// Not a built-in command → treat it as a session skill activation
// (the user picked `/<skill>` from the menu, or typed `/<skill> args`).
// The daemon answers an unknown name with skill.not_found, surfaced as a
// warning, so a stray slash is harmless. With no active session, create
// one first (same path as the first prompt) so the activation isn't
// silently dropped on the new-session screen.
// (the user picked `/skill:<skill>` from the menu, or typed
// `/<skill> args`). Strip the `skill:` display prefix — the REST API
// takes the bare skill name. The daemon answers an unknown name with
// skill.not_found, surfaced as a warning, so a stray slash is harmless.
// With no active session, create one first (same path as the first
// prompt) so the activation isn't silently dropped on the new-session
// screen.
const space = cmd.indexOf(' ');
const name = (space === -1 ? cmd : cmd.slice(0, space)).slice(1);
const name = stripSkillPrefix((space === -1 ? cmd : cmd.slice(0, space)).slice(1));
const args = space === -1 ? undefined : cmd.slice(space + 1).trim() || undefined;
if (!name) break;
if (!client.activeSessionId.value && client.activeWorkspaceId.value) {
Expand Down
9 changes: 6 additions & 3 deletions apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import SlashMenu from './SlashMenu.vue';
import MentionMenu from './MentionMenu.vue';
import { buildSlashItems, parseSlash } from '../../lib/slashCommands';
import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands';
import type { FileItem } from './MentionMenu.vue';
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
import type { AppModel, AppSkill, ThinkingLevel } from '../../api/types';
Expand Down Expand Up @@ -304,11 +304,14 @@ function handleSubmit(): void {
// If it's a known slash command, keep the optional tail as command input
// instead of submitting it as normal chat text. This covers `/goal <task>`,
// `/swarm <task>`, `/btw <question>`, slash skills with args, and bare
// commands such as `/model`.
// commands such as `/model`. A hand-typed bare skill name (`/deploy`) also
// resolves to its prefixed menu entry (`/skill:deploy`), mirroring the TUI.
if (trimmed) {
const parsed = parseSlash(trimmed);
const known = parsed
? buildSlashItems(props.skills).some((item) => item.name === parsed.cmd)
? buildSlashItems(props.skills).some(
(item) => item.name === parsed.cmd || item.name === `/${SKILL_COMMAND_PREFIX}${parsed.cmd.slice(1)}`,
)
: false;
if (parsed && known) {
text.value = '';
Expand Down
20 changes: 17 additions & 3 deletions apps/kimi-web/src/lib/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,30 @@ export function parseSlash(input: string): { cmd: string; arg: string } | null {
};
}

/** The prefix marking a slash item as a skill activation (`/skill:<name>`). */
export const SKILL_COMMAND_PREFIX = 'skill:';

/**
* Strip the `skill:` prefix from a slash-command name (with or without the
* leading `/`), returning the bare skill name. Non-prefixed input is returned
* unchanged.
*/
export function stripSkillPrefix(name: string): string {
return name.startsWith(SKILL_COMMAND_PREFIX) ? name.slice(SKILL_COMMAND_PREFIX.length) : name;
}

/**
* Build the full slash-item list: built-in commands followed by the session's
* skills (each shown as `/<skill-name>`). Skills carry their raw description and
* skills. Non-builtin skills are shown as `/skill:<skill-name>` so the user can
* tell them apart from built-in commands (mirroring the TUI); builtin-sourced
* skills keep the bare `/<skill-name>`. Skills carry their raw description and
* an `isSkill` flag so the caller knows to activate rather than run a command.
*/
export function buildSlashItems(
skills: ReadonlyArray<{ name: string; description: string }> = [],
skills: ReadonlyArray<{ name: string; description: string; source?: string }> = [],
): SlashCommand[] {
const skillItems: SlashCommand[] = skills.map((s) => ({
name: `/${s.name}`,
name: s.source === 'builtin' ? `/${s.name}` : `/${SKILL_COMMAND_PREFIX}${s.name}`,
desc: s.description,
isSkill: true,
// Keep the selected skill in the composer so arguments can be appended.
Expand Down
20 changes: 17 additions & 3 deletions apps/kimi-web/test/slash-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,25 @@ describe('useSlashMenu — update', () => {
expect(slash.open.value).toBe(false);
});

it('includes session skills as /<skill-name>', () => {
const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff' } as AppSkill]);
it('includes session skills as /skill:<skill-name>', () => {
const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff', source: 'project' } as AppSkill]);
slash.update();
const names = slash.items.value.map((i) => i.name);
expect(names).toContain('/deploy');
expect(names).toContain('/skill:deploy');
});

it('keeps builtin-sourced skills unprefixed', () => {
const { slash } = setup('/', [{ name: 'update-config', description: 'edit config', source: 'builtin' } as AppSkill]);
slash.update();
const names = slash.items.value.map((i) => i.name);
expect(names).toContain('/update-config');
expect(names).not.toContain('/skill:update-config');
});

it('matches a prefixed skill when filtering by its bare name', () => {
const { slash } = setup('/depl', [{ name: 'deploy', description: 'deploy stuff', source: 'project' } as AppSkill]);
slash.update();
expect(slash.items.value.map((i) => i.name)).toContain('/skill:deploy');
});
});

Expand Down
Loading