mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
83384ee6d4
commit
318c964f07
4 changed files with 200 additions and 42 deletions
5
.changeset/web-extract-slash-menu.md
Normal file
5
.changeset/web-extract-slash-menu.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Extract the composer's slash-command menu logic into a reusable composable.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
72
apps/kimi-web/src/composables/useSlashMenu.ts
Normal file
72
apps/kimi-web/src/composables/useSlashMenu.ts
Normal 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 };
|
||||
}
|
||||
104
apps/kimi-web/test/slash-menu.test.ts
Normal file
104
apps/kimi-web/test/slash-menu.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue