From b0809ddac833d8d920d95187f7ef64f97bafdbc6 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 8 Jul 2026 15:03:30 +0800 Subject: [PATCH] feat(web): prefix skill slash commands with skill: to distinguish them from built-in commands (#1492) --- .changeset/web-skill-slash-prefix.md | 5 +++++ apps/kimi-web/src/App.vue | 15 ++++++++------ .../kimi-web/src/components/chat/Composer.vue | 9 ++++++--- apps/kimi-web/src/lib/slashCommands.ts | 20 ++++++++++++++++--- apps/kimi-web/test/slash-menu.test.ts | 20 ++++++++++++++++--- 5 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 .changeset/web-skill-slash-prefix.md diff --git a/.changeset/web-skill-slash-prefix.md b/.changeset/web-skill-slash-prefix.md new file mode 100644 index 000000000..5ba22488b --- /dev/null +++ b/.changeset/web-skill-slash-prefix.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Show session skills in the slash menu as `/skill:` so they are distinguishable from built-in commands; typing the bare skill name still works. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 22f645ef2..eb9131b48 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -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'; @@ -506,13 +507,15 @@ function handleCommand(cmd: string): void { break; default: { // Not a built-in command → treat it as a session skill activation - // (the user picked `/` from the menu, or typed `/ 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:` from the menu, or typed + // `/ 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) { diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 4f591349a..e8069b0ec 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -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'; @@ -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 `, // `/swarm `, `/btw `, 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 = ''; diff --git a/apps/kimi-web/src/lib/slashCommands.ts b/apps/kimi-web/src/lib/slashCommands.ts index 8eb24ed75..8977f3e24 100644 --- a/apps/kimi-web/src/lib/slashCommands.ts +++ b/apps/kimi-web/src/lib/slashCommands.ts @@ -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:`). */ +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 `/`). Skills carry their raw description and + * skills. Non-builtin skills are shown as `/skill:` so the user can + * tell them apart from built-in commands (mirroring the TUI); builtin-sourced + * skills keep the bare `/`. 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. diff --git a/apps/kimi-web/test/slash-menu.test.ts b/apps/kimi-web/test/slash-menu.test.ts index 9b1d8657b..c270eafb4 100644 --- a/apps/kimi-web/test/slash-menu.test.ts +++ b/apps/kimi-web/test/slash-menu.test.ts @@ -74,11 +74,25 @@ describe('useSlashMenu — update', () => { expect(slash.open.value).toBe(false); }); - it('includes session skills as /', () => { - const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff' } as AppSkill]); + it('includes session skills as /skill:', () => { + 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'); }); });