feat(web): prefix skill slash commands with skill: to distinguish them from built-in commands (#1492)

This commit is contained in:
qer 2026-07-08 15:03:30 +08:00 committed by GitHub
parent 0cc9831a2f
commit b0809ddac8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 54 additions and 15 deletions

View file

@ -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.

View file

@ -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 `/<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) {

View file

@ -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 <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 = '';

View file

@ -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.

View file

@ -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');
});
});