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-extract-slash-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Extract the composer's slash-command menu logic into a reusable composable.
61 changes: 19 additions & 42 deletions apps/kimi-web/src/components/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ 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 type { SlashCommand } from '../lib/slashCommands';
import { buildSlashItems, filterCommands, parseSlash } from '../lib/slashCommands';
import { buildSlashItems, parseSlash } 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';
import { modelThinkingAvailability } from '../lib/modelThinking';
import { draftStorageKey, safeGetString, safeRemove, safeSetString } from '../lib/storage';
import { useInputHistory } from '../composables/useInputHistory';
import { useSlashMenu } from '../composables/useSlashMenu';

// ---------------------------------------------------------------------------
// Attachment state
Expand Down Expand Up @@ -150,47 +150,24 @@ watch(
const history = useInputHistory({ text, textareaRef, autosize });

// ---------------------------------------------------------------------------
// Slash-command menu
// Slash-command menu — see useSlashMenu for the implementation. The composer
// keeps the keydown orchestration (arrow keys / Enter / Escape) because it also
// juggles the mention menu and history recall.
// ---------------------------------------------------------------------------

const slashOpen = ref(false);
const slashItems = ref<SlashCommand[]>([]);
const slashActive = ref(0);

function updateSlashMenu(): void {
const val = text.value;
// Only show if the value starts with / and has no space yet (single token)
if (val.startsWith('/') && !val.includes(' ')) {
// Built-in commands + the active session's skills (shown as /<skill-name>).
slashItems.value = filterCommands(val, buildSlashItems(props.skills));
slashActive.value = 0;
slashOpen.value = slashItems.value.length > 0;
} else {
slashOpen.value = false;
}
}

function selectSlashCommand(item: SlashCommand): void {
slashOpen.value = false;
if (item.acceptsInput) {
text.value = `${item.name} `;
void nextTick(() => {
const el = textareaRef.value;
if (!el) return;
const pos = text.value.length;
el.setSelectionRange(pos, pos);
el.focus();
autosize();
});
return;
}
text.value = '';
// Menu-selected bare commands (e.g. /model, /login) reach here directly and
// never go through handleSubmit, so record them for ↑/↓ recall too. acceptsInput
// commands are pushed later by handleSubmit with their argument.
history.push(item.name);
emit('command', item.name);
}
const {
open: slashOpen,
items: slashItems,
active: slashActive,
update: updateSlashMenu,
select: selectSlashCommand,
} = useSlashMenu({
text,
textareaRef,
autosize,
skills: () => props.skills,
emitCommand: (cmd) => emit('command', cmd),
historyPush: (entry) => history.push(entry),
});

// ---------------------------------------------------------------------------
// @-mention menu
Expand Down
72 changes: 72 additions & 0 deletions apps/kimi-web/src/composables/useSlashMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// apps/kimi-web/src/composables/useSlashMenu.ts
import { nextTick, ref, type Ref } from 'vue';
import type { AppSkill } from '../api/types';
import { buildSlashItems, filterCommands, type SlashCommand } from '../lib/slashCommands';

export interface SlashMenuDeps {
/** The live composer text — drives filtering and is rewritten on select. */
text: Ref<string>;
/** The textarea element, used to focus and place the caret for acceptsInput. */
textareaRef: Ref<HTMLTextAreaElement | null>;
/** Re-fit the textarea after its text changes. */
autosize: () => void;
/** Current session skills (getter, so the menu stays reactive). */
skills: () => AppSkill[];
/** Emit a chosen slash command up to the parent. */
emitCommand: (cmd: string) => void;
/** Record a sent command for ↑/↓ recall. */
historyPush: (entry: string) => void;
}

/**
* `/` slash-command menu: filtering, keyboard navigation state, and selection.
*
* The composer keeps the keydown orchestration (arrow keys, Enter/Tab, Escape)
* because it also juggles the mention menu and history recall; this composable
* owns the menu's open/items/active state, the filter logic, and what happens
* when an item is chosen.
*/
export function useSlashMenu(deps: SlashMenuDeps) {
const { text, textareaRef, autosize, skills, emitCommand, historyPush } = deps;

const open = ref(false);
const items = ref<SlashCommand[]>([]);
const active = ref(0);

function update(): void {
const val = text.value;
// Only show if the value starts with `/` and has no space yet (single token).
if (val.startsWith('/') && !val.includes(' ')) {
// Built-in commands + the active session's skills (shown as /<skill-name>).
items.value = filterCommands(val, buildSlashItems(skills()));
active.value = 0;
open.value = items.value.length > 0;
} else {
open.value = false;
}
}

function select(item: SlashCommand): void {
open.value = false;
if (item.acceptsInput) {
text.value = `${item.name} `;
void nextTick(() => {
const el = textareaRef.value;
if (!el) return;
const pos = text.value.length;
el.setSelectionRange(pos, pos);
el.focus();
autosize();
});
return;
}
text.value = '';
// Menu-selected bare commands (e.g. /model, /login) reach here directly and
// never go through handleSubmit, so record them for recall too. acceptsInput
// commands are pushed later by handleSubmit together with their argument.
historyPush(item.name);
emitCommand(item.name);
}

return { open, items, active, update, select };
}
104 changes: 104 additions & 0 deletions apps/kimi-web/test/slash-menu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { nextTick, ref, type Ref } from 'vue';
import type { AppSkill } from '../src/api/types';
import { useSlashMenu } from '../src/composables/useSlashMenu';

interface MockTextarea {
value: string;
selectionStart: number;
setSelectionRange: (start: number, end: number) => void;
focus: () => void;
}

function setup(initialText = '', skills: AppSkill[] = []) {
const textarea: MockTextarea = {
value: initialText,
selectionStart: 0,
setSelectionRange(start: number) {
this.selectionStart = start;
},
focus: () => {},
};
const text = ref(initialText);
const textareaRef = ref(textarea as unknown as HTMLTextAreaElement) as Ref<HTMLTextAreaElement | null>;
const emitted: string[] = [];
const pushed: string[] = [];
const slash = useSlashMenu({
text,
textareaRef,
autosize: () => {},
skills: () => skills,
emitCommand: (cmd) => emitted.push(cmd),
historyPush: (entry) => pushed.push(entry),
});
return { text, textarea, emitted, pushed, slash };
}

describe('useSlashMenu — update', () => {
it('stays closed for empty text', () => {
const { slash } = setup('');
slash.update();
expect(slash.open.value).toBe(false);
});

it('opens and lists commands for a lone slash', () => {
const { slash } = setup('/');
slash.update();
expect(slash.open.value).toBe(true);
expect(slash.items.value.length).toBeGreaterThan(0);
expect(slash.active.value).toBe(0);
});

it('filters to matching commands', () => {
const { slash } = setup('/mod');
slash.update();
expect(slash.open.value).toBe(true);
expect(slash.items.value.map((i) => i.name)).toContain('/model');
});

it('closes when nothing matches', () => {
const { slash } = setup('/zzzznotacommand');
slash.update();
expect(slash.open.value).toBe(false);
});

it('closes once the token contains a space', () => {
const { slash } = setup('/goal some task');
slash.update();
expect(slash.open.value).toBe(false);
});

it('closes for text that does not start with a slash', () => {
const { slash } = setup('hello');
slash.update();
expect(slash.open.value).toBe(false);
});

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

describe('useSlashMenu — select', () => {
it('non-acceptsInput: clears text, pushes history, emits the command', () => {
const { text, emitted, pushed, slash } = setup('/model');
slash.select({ name: '/model', desc: '' });
expect(text.value).toBe('');
expect(pushed).toEqual(['/model']);
expect(emitted).toEqual(['/model']);
expect(slash.open.value).toBe(false);
});

it('acceptsInput: keeps the command in the box and does not emit yet', async () => {
const { text, emitted, pushed, slash } = setup('/goal');
slash.select({ name: '/goal', desc: '', acceptsInput: true });
expect(text.value).toBe('/goal ');
expect(emitted).toEqual([]);
expect(pushed).toEqual([]);
expect(slash.open.value).toBe(false);
await nextTick();
});
});
Loading