2026-09-20 12:06:22 +03:00
|
|
|
/**
|
|
|
|
|
* Автокомплит композера: поиск контекста триггера (`@`, `#`, `:`, `/`) и
|
|
|
|
|
* список команд чата. Разбор живёт отдельно от компонентов: правило «где
|
|
|
|
|
* вообще уместна подсказка» тестируется без 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;
|
2026-09-20 18:13:04 +03:00
|
|
|
/** Файл картинки кастомного эмодзи: подсказка рисует его вместо символа. */
|
|
|
|
|
emojiFileId?: string | undefined;
|
2026-09-20 12:06:22 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Команда чата: сервер разбирает её сам (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<Record<string, AutocompleteKind>> = {
|
|
|
|
|
'@': '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) };
|
|
|
|
|
}
|