117 lines
4.2 KiB
TypeScript
117 lines
4.2 KiB
TypeScript
/**
|
|||
|
|
* 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 };
|
||
|
|
}
|