feat(coding-agent): added internal URL completion with async handlers

- 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.
This commit is contained in:
can1357
2026-05-30 18:43:00 +02:00
parent 70202360ff
commit ccc727d1bb
15 changed files with 482 additions and 8 deletions
@@ -16,7 +16,7 @@ import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { applyQuery, pathToQuery } from "./json-query";
import { artifactsDirsFromRegistry } from "./registry-helpers";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
/**
* Handler for agent:// URLs.
@@ -126,4 +126,21 @@ export class AgentProtocolHandler implements ProtocolHandler {
notes,
};
}
async complete(): Promise<UrlCompletion[]> {
const ids = new Set<string>();
for (const dir of artifactsDirsFromRegistry()) {
let files: string[];
try {
files = await fs.readdir(dir);
} catch (err) {
if (isEnoent(err)) continue;
throw err;
}
for (const f of files) {
if (f.endsWith(".md")) ids.add(f.slice(0, -3));
}
}
return [...ids].sort().map(value => ({ value }));
}
}
@@ -13,7 +13,7 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { artifactsDirsFromRegistry } from "./registry-helpers";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
export class ArtifactProtocolHandler implements ProtocolHandler {
readonly scheme = "artifact";
@@ -77,4 +77,22 @@ export class ArtifactProtocolHandler implements ProtocolHandler {
sourcePath: foundPath,
};
}
async complete(): Promise<UrlCompletion[]> {
const ids = new Set<string>();
for (const dir of artifactsDirsFromRegistry()) {
let files: string[];
try {
files = await fs.readdir(dir);
} catch (err) {
if (isEnoent(err)) continue;
throw err;
}
for (const f of files) {
const m = f.match(/^(\d+)\./);
if (m) ids.add(m[1]!);
}
}
return [...ids].sort((a, b) => Number(a) - Number(b)).map(value => ({ value }));
}
}
@@ -5,7 +5,7 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../registry/agent-registry";
import { parseInternalUrl } from "./parse";
import { validateRelativePath } from "./skill-protocol";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
export interface LocalProtocolOptions {
getArtifactsDir?: () => string | null;
@@ -246,4 +246,17 @@ export class LocalProtocolHandler implements ProtocolHandler {
notes: ["Use write path local://<file> to persist large intermediate artifacts across turns."],
};
}
async complete(): Promise<UrlCompletion[]> {
const opts = LocalProtocolHandler.resolveOptions();
if (!opts) return [];
const localRoot = path.resolve(resolveLocalRoot(opts));
try {
const files = await listFilesRecursively(localRoot);
return files.map(value => ({ value }));
} catch (err) {
if (isEnoent(err)) return [];
throw err;
}
}
}
@@ -4,7 +4,7 @@ import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
import { getMemoryRoot } from "../memories";
import { AgentRegistry } from "../registry/agent-registry";
import { validateRelativePath } from "./skill-protocol";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
const DEFAULT_MEMORY_FILE = "memory_summary.md";
const MEMORY_NAMESPACE = "root";
@@ -161,4 +161,9 @@ export class MemoryProtocolHandler implements ProtocolHandler {
throw new Error(`Memory file not found: ${url.href}`);
}
async complete(): Promise<UrlCompletion[]> {
if (memoryRootsFromRegistry().length === 0) return [];
return [{ value: MEMORY_NAMESPACE, description: "Project memory summary" }];
}
}
@@ -9,7 +9,7 @@
*/
import * as path from "node:path";
import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
/**
* Handler for omp:// URLs.
@@ -33,6 +33,10 @@ export class OmpProtocolHandler implements ProtocolHandler {
return this.#readDoc(filename, url);
}
async complete(): Promise<UrlCompletion[]> {
return EMBEDDED_DOC_FILENAMES.map(value => ({ value }));
}
async #listDocs(url: InternalUrl): Promise<InternalResource> {
if (EMBEDDED_DOC_FILENAMES.length === 0) {
throw new Error("No documentation files found");
@@ -15,7 +15,7 @@ import { OmpProtocolHandler } from "./omp-protocol";
import { parseInternalUrl } from "./parse";
import { RuleProtocolHandler } from "./rule-protocol";
import { SkillProtocolHandler } from "./skill-protocol";
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext } from "./types";
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
import { VaultProtocolHandler } from "./vault-protocol";
export class InternalUrlRouter {
@@ -66,6 +66,25 @@ export class InternalUrlRouter {
return this.#handlers.has(match[1].toLowerCase());
}
/** Schemes whose handler supports host/path autocomplete. */
completionSchemes(): string[] {
const schemes: string[] = [];
for (const [scheme, handler] of this.#handlers) {
if (handler.complete) schemes.push(scheme);
}
return schemes;
}
/**
* Candidate completions for the host/path portion of `scheme://<query>`.
* Returns `null` when the scheme is unknown or does not support completion.
*/
async complete(scheme: string, query: string): Promise<UrlCompletion[] | null> {
const handler = this.#handlers.get(scheme.toLowerCase());
if (!handler?.complete) return null;
return handler.complete(query);
}
async resolve(input: string, context?: ResolveContext): Promise<InternalResource> {
const parsed = parseInternalUrl(input);
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
@@ -5,7 +5,7 @@
* - rule://<name> - Reads rule content
*/
import { getActiveRules } from "../capability/rule";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
export class RuleProtocolHandler implements ProtocolHandler {
readonly scheme = "rule";
@@ -35,4 +35,11 @@ export class RuleProtocolHandler implements ProtocolHandler {
notes: [],
};
}
async complete(): Promise<UrlCompletion[]> {
return getActiveRules().map(rule => ({
value: rule.name,
...(rule.description ? { description: rule.description } : {}),
}));
}
}
@@ -9,7 +9,7 @@
*/
import * as path from "node:path";
import { getActiveSkills } from "../extensibility/skills";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
function getContentType(filePath: string): InternalResource["contentType"] {
const ext = path.extname(filePath).toLowerCase();
@@ -86,4 +86,11 @@ export class SkillProtocolHandler implements ProtocolHandler {
notes: [],
};
}
async complete(): Promise<UrlCompletion[]> {
return getActiveSkills().map(skill => ({
value: skill.name,
...(skill.description ? { description: skill.description } : {}),
}));
}
}
@@ -32,6 +32,22 @@ export interface InternalResource {
immutable?: boolean;
}
/**
* A single autocomplete candidate for the host/path portion of a `scheme://`
* URL, produced by {@link ProtocolHandler.complete}.
*/
export interface UrlCompletion {
/**
* The text that follows `scheme://` for this candidate (e.g. `humanizer`,
* `subdir/data.json`, `root`). The caller renders it as `scheme://<value>`.
*/
value: string;
/** Human-facing label for the dropdown. Defaults to {@link value}. */
label?: string;
/** Optional one-line description shown beside the candidate. */
description?: string;
}
/**
* Parsed internal URL with preserved host casing.
*/
@@ -107,4 +123,15 @@ export interface ProtocolHandler {
* surfaces a clear "not writable" error when invoked against them.
*/
write?(url: InternalUrl, content: string, context?: WriteContext): Promise<void>;
/**
* Optional autocomplete hook. Returns candidate completions for the
* host/path portion of a `scheme://` URL while the user composes a prompt.
*
* Implementations **MUST** be fast and local — this runs on every keystroke.
* Schemes backed by network or external CLIs (issue://, pr://, vault://,
* mcp://) omit it. The caller fuzzy-filters the returned set against the
* partially typed `query`, so handlers return their full (bounded) candidate
* list; `query` is provided only so handlers can scope expensive enumeration.
*/
complete?(query: string): Promise<UrlCompletion[]>;
}
@@ -0,0 +1,143 @@
/**
* 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,
};
}
@@ -8,6 +8,11 @@ import {
import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
import { isSettingsInitialized, settings } from "../config/settings";
import { applyEmojiCompletion, getEmojiSuggestions, isEmojiPrefix, tryEmojiInlineReplace } from "./emoji-autocomplete";
import {
applyInternalUrlCompletion,
getInternalUrlSuggestions,
isInternalUrlPrefix,
} from "./internal-url-autocomplete";
interface PromptActionDefinition {
id: string;
@@ -128,6 +133,9 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
}
}
const urlSuggestions = await getInternalUrlSuggestions(textBeforeCursor);
if (urlSuggestions) return urlSuggestions;
if (!isSettingsInitialized() || settings.get("emojiAutocomplete")) {
const emojiSuggestions = getEmojiSuggestions(textBeforeCursor);
if (emojiSuggestions) return emojiSuggestions;
@@ -170,6 +178,10 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
};
}
if (isInternalUrlPrefix(prefix)) {
return applyInternalUrlCompletion(lines, cursorLine, cursorCol, item, prefix);
}
if (isEmojiPrefix(prefix)) {
return applyEmojiCompletion(lines, cursorLine, cursorCol, item, prefix);
}