From 318c964f074123ad228cbddcf7809fa4baaa7fb2 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 18:17:48 +0800 Subject: [PATCH] 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. --- .changeset/web-extract-slash-menu.md | 5 + apps/kimi-web/src/components/Composer.vue | 61 ++++------ apps/kimi-web/src/composables/useSlashMenu.ts | 72 ++++++++++++ apps/kimi-web/test/slash-menu.test.ts | 104 ++++++++++++++++++ 4 files changed, 200 insertions(+), 42 deletions(-) create mode 100644 .changeset/web-extract-slash-menu.md create mode 100644 apps/kimi-web/src/composables/useSlashMenu.ts create mode 100644 apps/kimi-web/test/slash-menu.test.ts diff --git a/.changeset/web-extract-slash-menu.md b/.changeset/web-extract-slash-menu.md new file mode 100644 index 000000000..f99720c05 --- /dev/null +++ b/.changeset/web-extract-slash-menu.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Extract the composer's slash-command menu logic into a reusable composable. diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index f18e47746..fa3bf080f 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -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([]); -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 /). - 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 diff --git a/apps/kimi-web/src/composables/useSlashMenu.ts b/apps/kimi-web/src/composables/useSlashMenu.ts new file mode 100644 index 000000000..1f1bcbcf1 --- /dev/null +++ b/apps/kimi-web/src/composables/useSlashMenu.ts @@ -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; + /** The textarea element, used to focus and place the caret for acceptsInput. */ + textareaRef: Ref; + /** 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([]); + 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 /). + 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 }; +} diff --git a/apps/kimi-web/test/slash-menu.test.ts b/apps/kimi-web/test/slash-menu.test.ts new file mode 100644 index 000000000..9b1d8657b --- /dev/null +++ b/apps/kimi-web/test/slash-menu.test.ts @@ -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; + 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 /', () => { + 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(); + }); +});