ccc727d1bb
- Added internal URL completion types and router support for async scheme completion handlers. - Implemented `complete()` for local, memory, agent, artifact, omp, rule, and skill handlers with robust completion fallbacks. - Added internal URL autocomplete flow with context extraction, fuzzy matching, ranking, and a 25-item suggestion cap. - Integrated internal URL completion into prompt and editor autocomplete behaviors and added completion tests.
144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
/**
|
|
* Autocomplete for internal-url schemes (skill://, rule://, omp://, local://,
|
|
* memory://, agent://, artifact://) while composing a prompt.
|
|
*
|
|
* Detection here MUST stay in sync with the generic URL-scheme trigger in the
|
|
* TUI editor (`packages/tui/src/components/editor.ts`); the editor fires the
|
|
* popup, this module decides whether there are candidates to show.
|
|
*/
|
|
import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
|
|
import { InternalUrlRouter } from "../internal-urls/router";
|
|
|
|
/** Upper bound on candidates surfaced in the dropdown. */
|
|
const MAX_URL_SUGGESTIONS = 25;
|
|
|
|
/**
|
|
* A URL token ending at the cursor: a known internal scheme followed by one or
|
|
* two slashes and the partially typed host/path. The boundary/rest character
|
|
* classes mirror the editor trigger so both agree on what counts as a token.
|
|
*/
|
|
const URL_TOKEN_RE = /(?:^|[\s"'`(<=])([a-z][a-z0-9+.-]*:\/{1,2}[^\s"'`()<>]*)$/i;
|
|
const SCHEME_SPLIT_RE = /^([a-z][a-z0-9+.-]*):\/{1,2}(.*)$/i;
|
|
|
|
export interface InternalUrlContext {
|
|
/** Lowercased scheme (e.g. `local`). */
|
|
scheme: string;
|
|
/** Text typed after the slashes so far (host + path); may be empty. */
|
|
query: string;
|
|
/** Exact buffer token from its boundary to the cursor (the completion prefix). */
|
|
token: string;
|
|
}
|
|
|
|
// Subsequence fuzzy match: `hum` matches `humanizer`, `lp` matches `local-plan`.
|
|
function fuzzyMatch(query: string, target: string): boolean {
|
|
if (query.length === 0) return true;
|
|
if (query.length > target.length) return false;
|
|
let q = 0;
|
|
for (let t = 0; t < target.length && q < query.length; t += 1) {
|
|
if (query[q] === target[t]) q += 1;
|
|
}
|
|
return q === query.length;
|
|
}
|
|
|
|
// Higher is better: exact > prefix > substring > scattered subsequence.
|
|
function fuzzyScore(query: string, target: string): number {
|
|
if (query.length === 0) return 1;
|
|
if (target === query) return 100;
|
|
if (target.startsWith(query)) return 80;
|
|
if (target.includes(query)) return 60;
|
|
let q = 0;
|
|
let gaps = 0;
|
|
let last = -1;
|
|
for (let t = 0; t < target.length && q < query.length; t += 1) {
|
|
if (query[q] === target[t]) {
|
|
if (last >= 0 && t - last > 1) gaps += 1;
|
|
last = t;
|
|
q += 1;
|
|
}
|
|
}
|
|
if (q !== query.length) return 0;
|
|
return Math.max(1, 40 - gaps * 5);
|
|
}
|
|
|
|
/**
|
|
* Detect a completable internal-url token immediately before the cursor.
|
|
* Returns `null` when the text is not a `scheme://` token whose scheme is
|
|
* registered with a completion-capable handler.
|
|
*/
|
|
export function extractInternalUrlContext(textBeforeCursor: string): InternalUrlContext | null {
|
|
const tokenMatch = URL_TOKEN_RE.exec(textBeforeCursor);
|
|
if (!tokenMatch) return null;
|
|
const token = tokenMatch[1]!;
|
|
const parts = SCHEME_SPLIT_RE.exec(token);
|
|
if (!parts) return null;
|
|
const scheme = parts[1]!.toLowerCase();
|
|
if (!InternalUrlRouter.instance().completionSchemes().includes(scheme)) return null;
|
|
return { scheme, query: parts[2] ?? "", token };
|
|
}
|
|
|
|
/**
|
|
* Suggestions for the internal-url token ending at the cursor, or `null` when
|
|
* the text is not such a token or no candidate matches the typed query.
|
|
*/
|
|
export async function getInternalUrlSuggestions(
|
|
textBeforeCursor: string,
|
|
): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
|
|
const ctx = extractInternalUrlContext(textBeforeCursor);
|
|
if (!ctx) return null;
|
|
|
|
const candidates = await InternalUrlRouter.instance().complete(ctx.scheme, ctx.query);
|
|
if (!candidates || candidates.length === 0) return null;
|
|
|
|
const query = ctx.query.toLowerCase();
|
|
const scored: Array<{ item: AutocompleteItem; score: number }> = [];
|
|
for (const candidate of candidates) {
|
|
const target = candidate.value.toLowerCase();
|
|
if (!fuzzyMatch(query, target)) continue;
|
|
scored.push({
|
|
item: {
|
|
value: `${ctx.scheme}://${candidate.value}`,
|
|
label: candidate.label ?? candidate.value,
|
|
...(candidate.description ? { description: candidate.description } : {}),
|
|
},
|
|
score: fuzzyScore(query, target),
|
|
});
|
|
}
|
|
if (scored.length === 0) return null;
|
|
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return {
|
|
items: scored.slice(0, MAX_URL_SUGGESTIONS).map(entry => entry.item),
|
|
prefix: ctx.token,
|
|
};
|
|
}
|
|
|
|
/** Whether `prefix` (the token a completion was offered for) is an internal-url token. */
|
|
export function isInternalUrlPrefix(prefix: string): boolean {
|
|
return extractInternalUrlContext(prefix) !== null;
|
|
}
|
|
|
|
/**
|
|
* Replace the internal-url token with the selected candidate, appending a
|
|
* trailing space (matching `@` file-reference behavior) so the user can keep
|
|
* typing.
|
|
*/
|
|
export function applyInternalUrlCompletion(
|
|
lines: string[],
|
|
cursorLine: number,
|
|
cursorCol: number,
|
|
item: AutocompleteItem,
|
|
prefix: string,
|
|
): { lines: string[]; cursorLine: number; cursorCol: number } {
|
|
const currentLine = lines[cursorLine] || "";
|
|
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
|
|
const afterCursor = currentLine.slice(cursorCol);
|
|
const insert = `${item.value} `;
|
|
const newLines = [...lines];
|
|
newLines[cursorLine] = beforePrefix + insert + afterCursor;
|
|
return {
|
|
lines: newLines,
|
|
cursorLine,
|
|
cursorCol: beforePrefix.length + insert.length,
|
|
};
|
|
}
|