121 lines
3.3 KiB
TypeScript
121 lines
3.3 KiB
TypeScript
import { compareMatchScores, type MatchScore, scoreTextFields } from "@/utils/score-match";
|
|
|
|
interface CommandAutocompleteEntry {
|
|
command: {
|
|
name: string;
|
|
aliases?: readonly string[];
|
|
kind?: string;
|
|
};
|
|
}
|
|
|
|
interface InlineSkillCommandEntry extends CommandAutocompleteEntry {
|
|
source: "provider" | "client";
|
|
}
|
|
|
|
export type SlashCommandPosition = "start" | "inline";
|
|
|
|
export interface SlashCommandRange {
|
|
start: number;
|
|
end: number;
|
|
query: string;
|
|
position: SlashCommandPosition;
|
|
}
|
|
|
|
interface FindActiveSlashCommandInput {
|
|
text: string;
|
|
cursorIndex: number;
|
|
}
|
|
|
|
interface ApplySlashCommandReplacementInput {
|
|
text: string;
|
|
command: SlashCommandRange;
|
|
commandName: string;
|
|
}
|
|
|
|
interface ScoredCommandAutocompleteEntry<TEntry> {
|
|
entry: TEntry;
|
|
score: MatchScore;
|
|
}
|
|
|
|
function scoreCommandAutocompleteEntry(
|
|
entry: CommandAutocompleteEntry,
|
|
query: string,
|
|
): MatchScore | null {
|
|
return scoreTextFields(query, [entry.command.name, ...(entry.command.aliases ?? [])]);
|
|
}
|
|
|
|
export function filterAndRankCommandAutocompleteEntries<TEntry extends CommandAutocompleteEntry>(
|
|
entries: readonly TEntry[],
|
|
query: string,
|
|
): TEntry[] {
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
if (normalizedQuery.length === 0) {
|
|
return [...entries];
|
|
}
|
|
|
|
const scoredEntries: ScoredCommandAutocompleteEntry<TEntry>[] = [];
|
|
for (const entry of entries) {
|
|
const score = scoreCommandAutocompleteEntry(entry, normalizedQuery);
|
|
if (score) {
|
|
scoredEntries.push({ entry, score });
|
|
}
|
|
}
|
|
|
|
scoredEntries.sort((a, b) => {
|
|
const scoreComparison = compareMatchScores(a.score, b.score);
|
|
if (scoreComparison !== 0) {
|
|
return scoreComparison;
|
|
}
|
|
return a.entry.command.name.localeCompare(b.entry.command.name);
|
|
});
|
|
|
|
return scoredEntries.map((scored) => scored.entry);
|
|
}
|
|
|
|
export function filterInlineSkillCommandEntries<TEntry extends InlineSkillCommandEntry>(
|
|
entries: readonly TEntry[],
|
|
): TEntry[] {
|
|
return entries.filter((entry) => entry.source === "provider" && entry.command.kind === "skill");
|
|
}
|
|
|
|
const INVALID_SLASH_COMMAND_QUERY_CHARS = /[/\s\n\r\t"']/;
|
|
|
|
export function findActiveSlashCommand(
|
|
input: FindActiveSlashCommandInput,
|
|
): SlashCommandRange | null {
|
|
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length));
|
|
const beforeCursor = input.text.slice(0, clampedCursor);
|
|
|
|
for (
|
|
let slashIndex = beforeCursor.lastIndexOf("/");
|
|
slashIndex >= 0;
|
|
slashIndex = slashIndex === 0 ? -1 : beforeCursor.lastIndexOf("/", slashIndex - 1)
|
|
) {
|
|
const previousCharacter = slashIndex > 0 ? input.text[slashIndex - 1] : "";
|
|
if (previousCharacter && !/\s/.test(previousCharacter)) {
|
|
continue;
|
|
}
|
|
|
|
const query = beforeCursor.slice(slashIndex + 1);
|
|
if (INVALID_SLASH_COMMAND_QUERY_CHARS.test(query)) {
|
|
continue;
|
|
}
|
|
|
|
return {
|
|
start: slashIndex,
|
|
end: clampedCursor,
|
|
query,
|
|
position: slashIndex === 0 ? "start" : "inline",
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function applySlashCommandReplacement(input: ApplySlashCommandReplacementInput): string {
|
|
const before = input.text.slice(0, input.command.start);
|
|
const after = input.text.slice(input.command.end);
|
|
const replacement = `${before}/${input.commandName}${after}`;
|
|
return input.command.end === input.text.length ? `${replacement} ` : replacement;
|
|
}
|