/** * Автокомплит композера: поиск контекста триггера (`@`, `#`, `:`, `/`) и * список команд чата. Разбор живёт отдельно от компонентов: правило «где * вообще уместна подсказка» тестируется без DOM, а `Composer` только рисует * список и вставляет выбранное. * * Контекст обязателен: `@` в середине слова (`почта@пример`) и любой триггер * внутри `` `кода` `` или блока ``` — обычный текст, а `/` — команда только в * самом начале сообщения. */ /** Вид подсказки. */ export type AutocompleteKind = 'mention' | 'channel' | 'emoji' | 'command'; /** Найденный контекст подсказки. */ export interface AutocompleteTrigger { kind: AutocompleteKind; /** Смещение символа-триггера в тексте. */ start: number; /** Позиция каретки: конец заменяемого запроса. */ end: number; /** Текст между триггером и кареткой (`bo` в `@bo`). */ query: string; } /** Вариант подсказки: то, что видит пользователь, и то, что вставляется. */ export interface AutocompleteItem { /** Стабильный ключ варианта. */ id: string; kind: AutocompleteKind; /** Текст, который вставляется вместо запроса вместе с триггером. */ insert: string; /** Основная подпись. */ label: string; /** Дополнительная подпись: логин, код эмодзи или описание команды. */ hint?: string | undefined; /** Имя для аватара-заглушки (есть только у участников). */ avatarName?: string | undefined; avatarFileId?: string | undefined; /** Файл картинки кастомного эмодзи: подсказка рисует его вместо символа. */ emojiFileId?: string | undefined; } /** Команда чата: сервер разбирает её сам (AGENT.md §7.6). */ export interface ChatCommand { /** Команда со слэшем — ровно то, что вставляется в поле. */ command: string; /** Ключ перевода описания. */ descriptionKey: string; } /** Команды, которые понимает сервер; порядок — как в подсказке. */ export const CHAT_COMMANDS: readonly ChatCommand[] = [ { command: '/me', descriptionKey: 'chat.composer.commands.me' }, { command: '/whisper', descriptionKey: 'chat.composer.commands.whisper' }, { command: '/scream', descriptionKey: 'chat.composer.commands.scream' }, { command: '/ls', descriptionKey: 'chat.composer.commands.ls' }, ]; /** Триггеры подсказок: символ — вид. */ const TRIGGERS: Readonly> = { '@': 'mention', '#': 'channel', ':': 'emoji', }; /** Что допустимо в запросе после триггера (пробел закрывает подсказку). */ const QUERY_CHAR = /[\p{L}\p{N}_.+-]/u; /** Строка-ограждение блока кода. */ function isFence(line: string): boolean { return line.trim().startsWith('```'); } /** * Каретка внутри кода: строка блока кода, незакрытое ограждение или * непарная `` ` `` в текущей строке. Внутри кода подсказки не показываем — * там `:`, `@` и `#` обычный текст. */ export function insideCode(value: string, caret: number): boolean { const lines = value.slice(0, caret).split('\n'); const current = lines[lines.length - 1] ?? ''; let fenced = false; for (const line of lines.slice(0, -1)) { if (isFence(line)) { fenced = !fenced; } } if (fenced || isFence(current)) { return true; } const ticks = (current.match(/`/gu) ?? []).length; return ticks % 2 === 1; } /** * Контекст подсказки по тексту и позиции каретки. `null` — подсказку * показывать не нужно (нет триггера, он внутри слова или внутри кода). */ export function detectAutocomplete(value: string, caret: number): AutocompleteTrigger | null { if (caret < 0 || caret > value.length) { return null; } if (insideCode(value, caret)) { return null; } const before = value.slice(0, caret); // Команда: `/` только первым символом сообщения. `//` экранирует слэш — // это обычный текст, сервер команду не разбирает. if (before.startsWith('/')) { if (before.startsWith('//')) { return null; } const query = before.slice(1); return /^[\p{L}\p{N}_-]*$/u.test(query) ? { kind: 'command', start: 0, end: caret, query } : null; } // Остальные триггеры: отступаем от каретки по символам запроса и смотрим, // что стоит перед ними. let index = caret; while (index > 0 && QUERY_CHAR.test(value[index - 1] ?? '')) { index -= 1; } const triggerIndex = index - 1; if (triggerIndex < 0) { return null; } const kind = TRIGGERS[value[triggerIndex] ?? '']; if (kind === undefined) { return null; } const previous = triggerIndex === 0 ? '' : (value[triggerIndex - 1] ?? ''); if (previous !== '' && !/\s/u.test(previous)) { return null; } return { kind, start: triggerIndex, end: caret, query: value.slice(index, caret) }; }