refactor(web): extract slash-command menu into a composable (#1026)

Move the slash menu's open/items/active state, the filter logic, and item
selection out of Composer into useSlashMenu. The composable takes the text
ref, textarea ref, autosize, a skills getter, and the emit/history-push
callbacks as deps.

The composer keeps the keydown orchestration (arrow keys, Enter/Tab, Escape)
because it also juggles the mention menu and history recall; it consumes the
returned open/items/active refs directly and calls update/select. The
destructured refs are aliased back to the original names so the rest of the
component is unchanged.

Composer.vue: 2058 -> 2035 lines. Adds unit tests for useSlashMenu. No
behavior change.
This commit is contained in:
qer 2026-06-23 18:17:48 +08:00 committed by GitHub
parent 83384ee6d4
commit 318c964f07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 200 additions and 42 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Extract the composer's slash-command menu logic into a reusable composable.

View file

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

View file

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

View file

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