From ee1ec54eacc4dc044d9e0bd2320a09d30f88cc81 Mon Sep 17 00:00:00 2001 From: Zhang Wei <2662650208@qq.com> Date: Wed, 24 Jun 2026 12:04:18 +0800 Subject: [PATCH] fix(web): prefix non-built-in session skills with skill: in slash menu --- .changeset/fix-web-skill-slash-prefix.md | 5 +++++ apps/kimi-web/src/App.vue | 13 +++++------ apps/kimi-web/src/lib/slashCommands.ts | 28 ++++++++++++++++++++---- apps/kimi-web/test/lib-logic.test.ts | 26 ++++++++++++++++++++++ apps/kimi-web/test/slash-menu.test.ts | 24 ++++++++++++++++++-- 5 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-web-skill-slash-prefix.md diff --git a/.changeset/fix-web-skill-slash-prefix.md b/.changeset/fix-web-skill-slash-prefix.md new file mode 100644 index 000000000..fce7a490b --- /dev/null +++ b/.changeset/fix-web-skill-slash-prefix.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Align the web slash menu with the TUI by prefixing non-built-in session skills with `skill:`, so typing `/skill` lists every available session skill. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 0fa5d7b0c..58fea8c96 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -33,6 +33,7 @@ import { useSidebarLayout } from './composables/useSidebarLayout'; import { useFilePreview, type DetailTarget } from './composables/useFilePreview'; import { useDetailPanel } from './composables/useDetailPanel'; import { useIsMobile } from './composables/useIsMobile'; +import { parseSkillCommand } from './lib/slashCommands'; import type { AppConfig, ThinkingLevel } from './api/types'; const client = useKimiWebClient(); @@ -425,13 +426,11 @@ 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. - const space = cmd.indexOf(' '); - const name = (space === -1 ? cmd : cmd.slice(0, space)).slice(1); - const args = space === -1 ? undefined : cmd.slice(space + 1).trim() || undefined; - if (name) void client.activateSkill(name, args); + // (the user picked `/` or `/skill:` from the menu, or typed + // it with args). The daemon answers an unknown name with skill.not_found, + // surfaced as a warning, so a stray slash is harmless. + const parsed = parseSkillCommand(cmd); + if (parsed) void client.activateSkill(parsed.name, parsed.args); break; } } diff --git a/apps/kimi-web/src/lib/slashCommands.ts b/apps/kimi-web/src/lib/slashCommands.ts index b9fa53d52..7051d3306 100644 --- a/apps/kimi-web/src/lib/slashCommands.ts +++ b/apps/kimi-web/src/lib/slashCommands.ts @@ -69,14 +69,17 @@ export function parseSlash(input: string): { cmd: string; arg: string } | null { /** * Build the full slash-item list: built-in commands followed by the session's - * skills (each shown as `/`). Skills carry their raw description and - * an `isSkill` flag so the caller knows to activate rather than run a command. + * skills. Built-in skills are shown as `/`; all other session skills + * are shown as `/skill:` to match the TUI and make `/skill` a + * reliable way to list every non-built-in skill. 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:${s.name}`, desc: s.description, isSkill: true, // Keep the selected skill in the composer so arguments can be appended. @@ -85,6 +88,23 @@ export function buildSlashItems( return [...SLASH_COMMANDS, ...skillItems]; } +/** + * Parse a skill-activation command of the form `/` or `/skill:` + * (with optional trailing arguments). Returns the underlying skill name and + * any arguments, or `null` if no skill name can be extracted. + * + * The slash menu displays non-built-in session skills as `/skill:` to + * match the TUI. This helper strips that prefix before the skill is activated. + */ +export function parseSkillCommand(cmd: string): { name: string; args?: string } | null { + const space = cmd.indexOf(' '); + let name = (space === -1 ? cmd : cmd.slice(0, space)).slice(1); + if (name.startsWith('skill:')) name = name.slice('skill:'.length); + const args = space === -1 ? undefined : cmd.slice(space + 1).trim() || undefined; + if (!name) return null; + return { name, args }; +} + /** * Filter slash items by a query string. Matches are ranked so exact and prefix * matches come before arbitrary substring matches. If query is empty or just diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index 02a2392b8..d839ecef3 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -5,6 +5,7 @@ import { parseFilePathLinkCandidate, } from '../src/lib/filePathLinks'; import { parseDiff } from '../src/lib/parseDiff'; +import { parseSkillCommand } from '../src/lib/slashCommands'; import { normalizeToolName, toolSummary } from '../src/lib/toolMeta'; describe('parseDiff', () => { @@ -57,6 +58,31 @@ describe('filePathLinks', () => { }); }); +describe('parseSkillCommand', () => { + it('returns null for an empty skill name', () => { + expect(parseSkillCommand('/')).toBeNull(); + }); + + it('parses a bare built-in-style skill command', () => { + expect(parseSkillCommand('/deploy')).toEqual({ name: 'deploy' }); + }); + + it('strips the skill: prefix from non-built-in skill commands', () => { + expect(parseSkillCommand('/skill:deploy')).toEqual({ name: 'deploy' }); + }); + + it('preserves arguments after the skill name', () => { + expect(parseSkillCommand('/skill:deploy production')).toEqual({ + name: 'deploy', + args: 'production', + }); + }); + + it('ignores empty arguments', () => { + expect(parseSkillCommand('/skill:deploy ')).toEqual({ name: 'deploy' }); + }); +}); + describe('toolMeta', () => { it('normalizes common tool aliases', () => { expect(normalizeToolName('WebFetch')).toBe('web_fetch'); diff --git a/apps/kimi-web/test/slash-menu.test.ts b/apps/kimi-web/test/slash-menu.test.ts index 9b1d8657b..94dd1d333 100644 --- a/apps/kimi-web/test/slash-menu.test.ts +++ b/apps/kimi-web/test/slash-menu.test.ts @@ -74,12 +74,32 @@ 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 builtin session skills as /', () => { + const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff', source: 'builtin' } as AppSkill]); slash.update(); const names = slash.items.value.map((i) => i.name); expect(names).toContain('/deploy'); }); + + it('includes non-builtin 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('/skill:deploy'); + }); + + it('filters non-builtin skills when typing /skill', () => { + const { slash } = setup('/skill', [ + { name: 'deploy', description: 'deploy stuff', source: 'project' } as AppSkill, + { name: 'lint', description: 'lint stuff', source: 'user' } as AppSkill, + { name: 'help', description: 'builtin help', source: 'builtin' } as AppSkill, + ]); + slash.update(); + const names = slash.items.value.map((i) => i.name); + expect(names).toContain('/skill:deploy'); + expect(names).toContain('/skill:lint'); + expect(names).not.toContain('/help'); + }); }); describe('useSlashMenu — select', () => {