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/fix-web-skill-slash-prefix.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 6 additions & 7 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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 ServerAuthDialog from './components/ServerAuthDialog.vue';
import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth';
import type { AppConfig, ThinkingLevel } from './api/types';
Expand Down Expand Up @@ -444,13 +445,11 @@ 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.
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 `/<skill>` or `/skill:<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;
}
}
Expand Down
28 changes: 24 additions & 4 deletions apps/kimi-web/src/lib/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<skill-name>`). 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 `/<skill-name>`; all other session skills
* are shown as `/skill:<skill-name>` 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}`,
Comment thread
djfch marked this conversation as resolved.
desc: s.description,
isSkill: true,
// Keep the selected skill in the composer so arguments can be appended.
Expand All @@ -85,6 +88,23 @@ export function buildSlashItems(
return [...SLASH_COMMANDS, ...skillItems];
}

/**
* Parse a skill-activation command of the form `/<name>` or `/skill:<name>`
* (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:<name>` 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
Expand Down
26 changes: 26 additions & 0 deletions apps/kimi-web/test/lib-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
parseFilePathLinkCandidate,
} from '../src/lib/filePathLinks';
import { parseDiff } from '../src/lib/parseDiff';
import { parseSkillCommand } from '../src/lib/slashCommands';
import { buildDiffLines } from '../src/lib/diffLines';
import { buildEditDiffLines } from '../src/lib/toolDiff';
import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync';
Expand Down Expand Up @@ -126,6 +127,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');
Expand Down
24 changes: 22 additions & 2 deletions apps/kimi-web/test/slash-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,32 @@ 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 builtin session skills as /<skill-name>', () => {
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:<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('/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', () => {
Expand Down