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-mention-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 @-mention menu logic into a reusable composable.
82 changes: 17 additions & 65 deletions apps/kimi-web/src/components/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { modelThinkingAvailability } from '../lib/modelThinking';
import { draftStorageKey, safeGetString, safeRemove, safeSetString } from '../lib/storage';
import { useInputHistory } from '../composables/useInputHistory';
import { useSlashMenu } from '../composables/useSlashMenu';
import { useMentionMenu } from '../composables/useMentionMenu';

// ---------------------------------------------------------------------------
// Attachment state
Expand Down Expand Up @@ -170,72 +171,23 @@ const {
});

// ---------------------------------------------------------------------------
// @-mention menu
// @-mention menu — see useMentionMenu for the implementation. The composer
// keeps the keydown orchestration because it also juggles the slash menu and
// history recall.
// ---------------------------------------------------------------------------

const mentionOpen = ref(false);
const mentionItems = ref<FileItem[]>([]);
const mentionActive = ref(0);
const mentionLoading = ref(false);

// Debounce timer for mention search
let mentionTimer: ReturnType<typeof setTimeout> | null = null;

/** Find the @token under the cursor in the current text value. Returns null if none. */
function getMentionToken(): { token: string; start: number; end: number } | null {
const val = text.value;
const pos = textareaRef.value?.selectionStart ?? val.length;
// Walk backwards from cursor to find the start of a @token
let start = pos - 1;
while (start >= 0 && !/\s/.test(val[start]!)) {
start--;
}
start++;
const tokenPart = val.slice(start, pos);
if (!tokenPart.startsWith('@')) return null;
// The end of the token is where the cursor is (or after the next space)
return { token: tokenPart.slice(1), start, end: pos };
}

function updateMentionMenu(): void {
const mt = getMentionToken();
if (!mt || !props.searchFiles) {
mentionOpen.value = false;
return;
}
const query = mt.token;
if (mentionTimer !== null) clearTimeout(mentionTimer);
mentionTimer = setTimeout(async () => {
mentionLoading.value = true;
mentionOpen.value = true;
mentionActive.value = 0;
try {
const results = await props.searchFiles!(query);
mentionItems.value = results;
} catch {
mentionItems.value = [];
} finally {
mentionLoading.value = false;
}
}, 200);
}

function selectMentionItem(item: FileItem): void {
const mt = getMentionToken();
if (!mt) return;
const val = text.value;
// Replace @query token with the file path
text.value = val.slice(0, mt.start) + item.path + val.slice(mt.end);
mentionOpen.value = false;
void nextTick(() => {
const el = textareaRef.value;
if (!el) return;
const newPos = mt.start + item.path.length;
el.setSelectionRange(newPos, newPos);
el.focus();
autosize();
});
}
const {
open: mentionOpen,
items: mentionItems,
active: mentionActive,
loading: mentionLoading,
update: updateMentionMenu,
select: selectMentionItem,
} = useMentionMenu({
text,
textareaRef,
autosize,
searchFiles: () => props.searchFiles,
});

// ---------------------------------------------------------------------------
// Input event handler — updates both menus
Expand Down
8 changes: 4 additions & 4 deletions apps/kimi-web/src/components/MentionMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
<!-- Popup list of file paths shown when user types @ in the Composer textarea. -->
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { FileItem } from '../types';

export interface FileItem {
path: string;
name: string;
}
// Re-exported for the .vue consumers (Composer / ChatDock / ConversationPane)
// that import FileItem from this component.
export type { FileItem };

const props = defineProps<{
items: FileItem[];
Expand Down
98 changes: 98 additions & 0 deletions apps/kimi-web/src/composables/useMentionMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// apps/kimi-web/src/composables/useMentionMenu.ts
import { nextTick, ref, type Ref } from 'vue';
import type { FileItem } from '../types';

export interface MentionMenuDeps {
/** The live composer text — the @token is read from it and rewritten on select. */
text: Ref<string>;
/** The textarea element, used to read the caret and place it after insertion. */
textareaRef: Ref<HTMLTextAreaElement | null>;
/** Re-fit the textarea after its text changes. */
autosize: () => void;
/** File search for the @-query (getter; undefined disables the menu). */
searchFiles: () => ((q: string) => Promise<FileItem[]>) | undefined;
}

interface MentionToken {
token: string;
start: number;
end: number;
}

/**
* `@` file-mention menu: token detection, debounced search, keyboard navigation
* state, and insertion.
*
* The composer keeps the keydown orchestration (arrow keys, Enter/Tab, Escape)
* because it also juggles the slash menu and history recall; this composable
* owns the menu's open/items/active/loading state and the search/insert logic.
*/
export function useMentionMenu(deps: MentionMenuDeps) {
const { text, textareaRef, autosize, searchFiles } = deps;

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

// Debounce timer for the search.
let timer: ReturnType<typeof setTimeout> | null = null;

/** Find the @token under the cursor in the current text value. Returns null if none. */
function getMentionToken(): MentionToken | null {
const val = text.value;
const pos = textareaRef.value?.selectionStart ?? val.length;
// Walk backwards from the cursor to find the start of a @token.
let start = pos - 1;
while (start >= 0 && !/\s/.test(val[start]!)) {
start--;
}
start++;
const tokenPart = val.slice(start, pos);
if (!tokenPart.startsWith('@')) return null;
// The end of the token is where the cursor is (or after the next space).
return { token: tokenPart.slice(1), start, end: pos };
}

function update(): void {
const mt = getMentionToken();
const search = searchFiles();
if (!mt || !search) {
open.value = false;
return;
}
const query = mt.token;
if (timer !== null) clearTimeout(timer);
timer = setTimeout(async () => {
loading.value = true;
open.value = true;
active.value = 0;
try {
items.value = await search(query);
} catch {
items.value = [];
} finally {
loading.value = false;
}
}, 200);
}

function select(item: FileItem): void {
const mt = getMentionToken();
if (!mt) return;
const val = text.value;
// Replace the @query token with the file path.
text.value = val.slice(0, mt.start) + item.path + val.slice(mt.end);
open.value = false;
void nextTick(() => {
const el = textareaRef.value;
if (!el) return;
const newPos = mt.start + item.path.length;
el.setSelectionRange(newPos, newPos);
el.focus();
autosize();
});
}

return { open, items, active, loading, update, select };
}
6 changes: 6 additions & 0 deletions apps/kimi-web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export interface FileData {
lineCount?: number;
}

/** A file entry shown in the composer's @-mention menu. */
export interface FileItem {
path: string;
name: string;
}

export interface Session {
id: string;
title: string;
Expand Down
97 changes: 97 additions & 0 deletions apps/kimi-web/test/mention-menu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick, ref, type Ref } from 'vue';
import { useMentionMenu } from '../src/composables/useMentionMenu';
import type { FileItem } from '../src/types';

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

function setup(initialText = '', searchFiles?: (q: string) => Promise<FileItem[]>) {
const textarea: MockTextarea = {
value: initialText,
// Caret defaults to the end of the text.
selectionStart: initialText.length,
setSelectionRange(start: number) {
this.selectionStart = start;
},
focus: () => {},
};
const text = ref(initialText);
const textareaRef = ref(textarea as unknown as HTMLTextAreaElement) as Ref<HTMLTextAreaElement | null>;
const mention = useMentionMenu({
text,
textareaRef,
autosize: () => {},
searchFiles: () => searchFiles,
});
return { text, textarea, mention };
}

describe('useMentionMenu — update', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('stays closed when there is no @token', async () => {
const searchFiles = vi.fn().mockResolvedValue([]);
const { mention } = setup('hello', searchFiles);
mention.update();
await vi.advanceTimersByTimeAsync(200);
expect(mention.open.value).toBe(false);
expect(searchFiles).not.toHaveBeenCalled();
});

it('stays closed when searchFiles is not provided', async () => {
const { mention } = setup('@a');
mention.update();
await vi.advanceTimersByTimeAsync(200);
expect(mention.open.value).toBe(false);
});

it('opens with search results after the debounce', async () => {
const searchFiles = vi.fn().mockResolvedValue([{ path: 'src/a.ts', name: 'a.ts' }]);
const { mention } = setup('@a', searchFiles);
mention.update();
expect(mention.open.value).toBe(false); // debounced, not yet
await vi.advanceTimersByTimeAsync(200);
expect(searchFiles).toHaveBeenCalledWith('a');
expect(mention.open.value).toBe(true);
expect(mention.items.value).toEqual([{ path: 'src/a.ts', name: 'a.ts' }]);
expect(mention.loading.value).toBe(false);
expect(mention.active.value).toBe(0);
});

it('clears items and stops loading when the search throws', async () => {
const searchFiles = vi.fn().mockRejectedValue(new Error('boom'));
const { mention } = setup('@a', searchFiles);
mention.update();
await vi.advanceTimersByTimeAsync(200);
expect(mention.items.value).toEqual([]);
expect(mention.loading.value).toBe(false);
});
});

describe('useMentionMenu — select', () => {
it('replaces the @token with the chosen path', async () => {
const { text, textarea, mention } = setup('hello @a');
textarea.value = 'hello @a';
mention.select({ path: 'src/a.ts', name: 'a.ts' });
expect(text.value).toBe('hello src/a.ts');
expect(mention.open.value).toBe(false);
await nextTick();
});

it('is a no-op when there is no @token', () => {
const { text, mention } = setup('hello');
mention.select({ path: 'src/a.ts', name: 'a.ts' });
expect(text.value).toBe('hello');
});
});
Loading