Files
openchamber/packages/ui/src/components/chat/composer/language/triggers.ts
T

117 lines
4.2 KiB
TypeScript
Raw Normal View History

/**
* Which autocomplete a caret position asks for.
*
* The composer has four pickers (command, skill, snippet, file/agent mention)
* and the rule that opens each of them used to be inlined in a single 90-line
* `updateAutocompleteState` callback, duplicating the boundary logic that
* `scanPrefixTokens` and `scanMentions` already own. This module answers the
* one question the composer actually asks — "given the text and the caret,
* what should be open?" — as a pure function, so the editor layer only has to
* report the caret and render the result.
*
* Exactly one trigger can be active, and order matters: the command palette
* (a leading `/`) outranks the inline skill picker, which outranks snippets,
* which outrank mentions. That precedence is the previous behavior, preserved.
*/
import {
getFileMentionAutocompleteQuery,
type FileMentionAutocompleteInputSource,
} from '../../fileMentionAutocompleteState';
export type AutocompleteKind = 'command' | 'skill' | 'snippet' | 'mention';
export interface AutocompleteTrigger {
kind: AutocompleteKind;
/** Text typed after the sigil, used to filter the picker. */
query: string;
}
export interface TriggerContext {
/** Shell mode (`!cmd`) disables every picker. */
inputMode: 'normal' | 'shell';
/** Whether the change that moved the caret came from a paste. */
inputSource?: FileMentionAutocompleteInputSource;
/** The text that change inserted, when known. */
insertedText?: string;
}
/**
* A sigil opens a picker only at a word boundary — the start of the text or
* directly after whitespace. This mirrors `scanPrefixTokens`, but works
* backwards from the caret because the token is still being typed.
*/
const isWordBoundaryBefore = (text: string, index: number): boolean =>
index <= 0 || /\s/.test(text[index - 1]);
/**
* The command palette is reserved for a `/` in the very first column, with the
* caret still inside the command word and no argument typed yet. Once a space
* appears the message is a command invocation, not a search.
*/
function matchCommandPalette(value: string, cursorPosition: number): AutocompleteTrigger | null {
if (!value.startsWith('/')) return null;
const firstSpace = value.indexOf(' ');
if (firstSpace !== -1) return null;
const firstNewline = value.indexOf('\n');
const commandEnd = firstNewline === -1 ? value.length : firstNewline;
if (cursorPosition > commandEnd) return null;
return { kind: 'command', query: value.substring(1, commandEnd) };
}
/**
* An inline `/skill` or `#snippet` still being typed: the nearest sigil before
* the caret, at a word boundary, with no separator between it and the caret.
*/
function matchInlineToken(
value: string,
cursorPosition: number,
sigil: '/' | '#',
kind: AutocompleteKind,
): AutocompleteTrigger | null {
const textBeforeCursor = value.substring(0, cursorPosition);
const sigilIndex = textBeforeCursor.lastIndexOf(sigil);
if (sigilIndex === -1) return null;
if (!isWordBoundaryBefore(textBeforeCursor, sigilIndex)) return null;
const query = textBeforeCursor.substring(sigilIndex + 1);
if (query.includes(' ') || query.includes('\n')) return null;
return { kind, query };
}
/**
* Resolve the single autocomplete that the caret asks for, or null when none
* applies. Pure: the caller supplies the text and caret, and decides what to
* do with the answer.
*/
export function resolveAutocompleteTrigger(
value: string,
cursorPosition: number,
context: TriggerContext,
): AutocompleteTrigger | null {
if (context.inputMode === 'shell') return null;
return matchCommandPalette(value, cursorPosition)
?? matchInlineToken(value, cursorPosition, '/', 'skill')
?? matchInlineToken(value, cursorPosition, '#', 'snippet')
?? matchMention(value, cursorPosition, context);
}
function matchMention(
value: string,
cursorPosition: number,
context: TriggerContext,
): AutocompleteTrigger | null {
const query = getFileMentionAutocompleteQuery({
value,
cursorPosition,
inputSource: context.inputSource,
insertedText: context.insertedText,
});
return query === null ? null : { kind: 'mention', query };
}