diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0db5d0a52..62da9b451 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,12 @@ # Changelog ## [Unreleased] + ### Added +- Added prompt-mode autocomplete for supported internal URL schemes (`skill://`, `rule://`, `agent://`, `artifact://`, `local://`, `memory://`, and `omp://`) so typing those tokens now suggests existing resources as completion candidates +- Added fuzzy matching and ranked suggestion ordering for internal URL completion, including rule and skill descriptions, with accepted completion replacing just the typed token and inserting the chosen URL followed by a space +- Changed internal URL completions now include nested `local://` path suggestions from the configured local workspace - Added Mnemosyne memory inference model selection with an online mode or local transformers.js options (`qwen3-1.7b`, `gemma-3-1b`, `qwen2.5-1.5b`, `lfm2-1.2b`) so memory extraction and consolidation can run via the shared tiny-model worker - Changed memory tiny-model handling to route local memory prompts through the same queueed tiny-model worker pipeline with bounded completion output - Added a Providers → Tiny Model setting for session titles, defaulting to the online `pi/smol` path with five optional local CPU transformers.js models. A local model — and the one-time `@huggingface/transformers` runtime install in compiled binaries — is downloaded and loaded only when explicitly selected (or via `omp tiny-models download`); the default online path never spawns the title worker for inference. Selecting a local model adds a delayed `pi/smol` fallback so titles never block, plus in-chat download progress. diff --git a/packages/coding-agent/src/internal-urls/agent-protocol.ts b/packages/coding-agent/src/internal-urls/agent-protocol.ts index 197f2d931..00add4d66 100644 --- a/packages/coding-agent/src/internal-urls/agent-protocol.ts +++ b/packages/coding-agent/src/internal-urls/agent-protocol.ts @@ -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 { + const ids = new Set(); + 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 })); + } } diff --git a/packages/coding-agent/src/internal-urls/artifact-protocol.ts b/packages/coding-agent/src/internal-urls/artifact-protocol.ts index 61c2dee75..5b28467b5 100644 --- a/packages/coding-agent/src/internal-urls/artifact-protocol.ts +++ b/packages/coding-agent/src/internal-urls/artifact-protocol.ts @@ -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 { + const ids = new Set(); + 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 })); + } } diff --git a/packages/coding-agent/src/internal-urls/local-protocol.ts b/packages/coding-agent/src/internal-urls/local-protocol.ts index 12c9cb94c..f16fe064c 100644 --- a/packages/coding-agent/src/internal-urls/local-protocol.ts +++ b/packages/coding-agent/src/internal-urls/local-protocol.ts @@ -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:// to persist large intermediate artifacts across turns."], }; } + + async complete(): Promise { + 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; + } + } } diff --git a/packages/coding-agent/src/internal-urls/memory-protocol.ts b/packages/coding-agent/src/internal-urls/memory-protocol.ts index 3652dd99d..40d6e56ef 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -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 { + if (memoryRootsFromRegistry().length === 0) return []; + return [{ value: MEMORY_NAMESPACE, description: "Project memory summary" }]; + } } diff --git a/packages/coding-agent/src/internal-urls/omp-protocol.ts b/packages/coding-agent/src/internal-urls/omp-protocol.ts index 17f8ecb50..a36c29edf 100644 --- a/packages/coding-agent/src/internal-urls/omp-protocol.ts +++ b/packages/coding-agent/src/internal-urls/omp-protocol.ts @@ -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 { + return EMBEDDED_DOC_FILENAMES.map(value => ({ value })); + } + async #listDocs(url: InternalUrl): Promise { if (EMBEDDED_DOC_FILENAMES.length === 0) { throw new Error("No documentation files found"); diff --git a/packages/coding-agent/src/internal-urls/router.ts b/packages/coding-agent/src/internal-urls/router.ts index 9131f5824..8672b8da0 100644 --- a/packages/coding-agent/src/internal-urls/router.ts +++ b/packages/coding-agent/src/internal-urls/router.ts @@ -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://`. + * Returns `null` when the scheme is unknown or does not support completion. + */ + async complete(scheme: string, query: string): Promise { + const handler = this.#handlers.get(scheme.toLowerCase()); + if (!handler?.complete) return null; + return handler.complete(query); + } + async resolve(input: string, context?: ResolveContext): Promise { const parsed = parseInternalUrl(input); const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); diff --git a/packages/coding-agent/src/internal-urls/rule-protocol.ts b/packages/coding-agent/src/internal-urls/rule-protocol.ts index 92f97bb9c..e30b4e555 100644 --- a/packages/coding-agent/src/internal-urls/rule-protocol.ts +++ b/packages/coding-agent/src/internal-urls/rule-protocol.ts @@ -5,7 +5,7 @@ * - rule:// - 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 { + return getActiveRules().map(rule => ({ + value: rule.name, + ...(rule.description ? { description: rule.description } : {}), + })); + } } diff --git a/packages/coding-agent/src/internal-urls/skill-protocol.ts b/packages/coding-agent/src/internal-urls/skill-protocol.ts index 8bbcdbb72..2671755c6 100644 --- a/packages/coding-agent/src/internal-urls/skill-protocol.ts +++ b/packages/coding-agent/src/internal-urls/skill-protocol.ts @@ -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 { + return getActiveSkills().map(skill => ({ + value: skill.name, + ...(skill.description ? { description: skill.description } : {}), + })); + } } diff --git a/packages/coding-agent/src/internal-urls/types.ts b/packages/coding-agent/src/internal-urls/types.ts index 40efec558..62015d376 100644 --- a/packages/coding-agent/src/internal-urls/types.ts +++ b/packages/coding-agent/src/internal-urls/types.ts @@ -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: 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; + /** + * 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; } diff --git a/packages/coding-agent/src/modes/internal-url-autocomplete.ts b/packages/coding-agent/src/modes/internal-url-autocomplete.ts new file mode 100644 index 000000000..b6049286c --- /dev/null +++ b/packages/coding-agent/src/modes/internal-url-autocomplete.ts @@ -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, + }; +} diff --git a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts index 2dc6211e9..1ec0dcc9c 100644 --- a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts +++ b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts @@ -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); } diff --git a/packages/coding-agent/test/modes/internal-url-autocomplete.test.ts b/packages/coding-agent/test/modes/internal-url-autocomplete.test.ts new file mode 100644 index 000000000..f12bb73c0 --- /dev/null +++ b/packages/coding-agent/test/modes/internal-url-autocomplete.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { Rule } from "../../src/capability/rule"; +import { resetActiveRulesForTests, setActiveRules } from "../../src/capability/rule"; +import type { Skill } from "../../src/extensibility/skills"; +import { resetActiveSkillsForTests, setActiveSkills } from "../../src/extensibility/skills"; +import { InternalUrlRouter } from "../../src/internal-urls/router"; +import { + applyInternalUrlCompletion, + extractInternalUrlContext, + getInternalUrlSuggestions, + isInternalUrlPrefix, +} from "../../src/modes/internal-url-autocomplete"; + +function skill(name: string, description = ""): Skill { + return { name, description, filePath: `/skills/${name}/SKILL.md`, baseDir: `/skills/${name}`, source: "test" }; +} + +function rule(name: string, description?: string): Rule { + return { + name, + path: `/rules/${name}.md`, + content: `# ${name}`, + ...(description ? { description } : {}), + _source: { provider: "test", providerName: "Test", path: `/rules/${name}.md`, level: "project" }, + }; +} + +describe("internal-url-autocomplete", () => { + beforeEach(() => { + setActiveSkills([skill("humanizer", "Remove AI tells"), skill("react", "React UI"), skill("tla", "TLA+ specs")]); + setActiveRules([rule("python", "robomp rules"), rule("style")]); + }); + + afterEach(() => { + resetActiveSkillsForTests(); + resetActiveRulesForTests(); + }); + + describe("extractInternalUrlContext", () => { + it("detects a bare scheme with the full :// typed", () => { + expect(extractInternalUrlContext("local://")).toEqual({ scheme: "local", query: "", token: "local://" }); + }); + + it("treats a single slash as the same in-progress token (preserving exact text)", () => { + expect(extractInternalUrlContext("local:/")).toEqual({ scheme: "local", query: "", token: "local:/" }); + }); + + it("captures the host/path query and the boundary-delimited token", () => { + expect(extractInternalUrlContext("look at skill://hum")).toEqual({ + scheme: "skill", + query: "hum", + token: "skill://hum", + }); + }); + + it("keeps nested paths in the query", () => { + expect(extractInternalUrlContext("local://dir/file.json")).toMatchObject({ + scheme: "local", + query: "dir/file.json", + }); + }); + + it("ignores schemes with no completion handler (http/https)", () => { + expect(extractInternalUrlContext("https://example.com/x")).toBeNull(); + }); + + it("does not fire on a bare colon in prose", () => { + expect(extractInternalUrlContext("note: hello")).toBeNull(); + expect(extractInternalUrlContext("TODO: ship it")).toBeNull(); + }); + + it("requires at least one slash after the colon", () => { + expect(extractInternalUrlContext("local:")).toBeNull(); + }); + }); + + describe("getInternalUrlSuggestions", () => { + it("lists every skill for a bare skill:// and prefixes the scheme", async () => { + const result = await getInternalUrlSuggestions("skill://"); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe("skill://"); + expect(result!.items.map(i => i.value).sort()).toEqual(["skill://humanizer", "skill://react", "skill://tla"]); + }); + + it("fuzzy-filters candidates by the typed query", async () => { + const result = await getInternalUrlSuggestions("skill://hum"); + expect(result!.items.map(i => i.value)).toEqual(["skill://humanizer"]); + }); + + it("ranks an exact/prefix match ahead of a scattered subsequence", async () => { + // "ra" is a prefix of "react" (score 80) and a subsequence of "humanizer" (lower). + const result = await getInternalUrlSuggestions("skill://ra"); + expect(result!.items[0]!.value).toBe("skill://react"); + }); + + it("carries the candidate description through", async () => { + const result = await getInternalUrlSuggestions("rule://python"); + expect(result!.items[0]).toMatchObject({ value: "rule://python", description: "robomp rules" }); + }); + + it("returns null when no candidate matches", async () => { + expect(await getInternalUrlSuggestions("skill://zzzzz")).toBeNull(); + }); + + it("returns null for schemes without a completion handler", async () => { + expect(await getInternalUrlSuggestions("issue://")).toBeNull(); + }); + }); + + describe("router.complete dispatch", () => { + it("returns candidates for a completion-capable scheme", async () => { + const candidates = await InternalUrlRouter.instance().complete("rule", ""); + expect(candidates?.map(c => c.value).sort()).toEqual(["python", "style"]); + }); + + it("returns null for a known scheme that opted out of completion", async () => { + expect(await InternalUrlRouter.instance().complete("issue", "")).toBeNull(); + expect(await InternalUrlRouter.instance().complete("pr", "")).toBeNull(); + }); + + it("returns null for an unknown scheme", async () => { + expect(await InternalUrlRouter.instance().complete("bogus", "")).toBeNull(); + }); + + it("exposes the completion-capable schemes", () => { + const schemes = InternalUrlRouter.instance().completionSchemes().sort(); + expect(schemes).toEqual(["agent", "artifact", "local", "memory", "omp", "rule", "skill"]); + }); + }); + + describe("applyInternalUrlCompletion", () => { + it("replaces the token in place and appends a trailing space", () => { + const line = "look at skill://hum"; + const result = applyInternalUrlCompletion( + [line], + 0, + line.length, + { value: "skill://humanizer", label: "humanizer" }, + "skill://hum", + ); + expect(result.lines[0]).toBe("look at skill://humanizer "); + expect(result.cursorCol).toBe("look at skill://humanizer ".length); + }); + + it("preserves text after the cursor", () => { + const line = "skill://hum and more"; + const cursorCol = "skill://hum".length; + const result = applyInternalUrlCompletion( + [line], + 0, + cursorCol, + { value: "skill://humanizer", label: "humanizer" }, + "skill://hum", + ); + expect(result.lines[0]).toBe("skill://humanizer and more"); + }); + }); + + describe("isInternalUrlPrefix", () => { + it("recognizes a completion prefix token", () => { + expect(isInternalUrlPrefix("skill://hum")).toBe(true); + expect(isInternalUrlPrefix("local://")).toBe(true); + }); + + it("rejects non-url prefixes", () => { + expect(isInternalUrlPrefix("@src/foo")).toBe(false); + expect(isInternalUrlPrefix("/model")).toBe(false); + }); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d8c43c01a..63bfb190d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Added autocomplete triggering for internal URL scheme tokens such as `local://` and `skill://` while typing in the editor ### Fixed diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 12258b76f..4a34f3372 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1571,6 +1571,10 @@ export class Editor implements Component, Focusable { else if (textBeforeCursor.match(/(?:^|[\s([{>]):[a-zA-Z0-9_+-]*$/)) { this.#tryTriggerAutocomplete(); } + // Check if we're typing an internal URL scheme (e.g. local://, skill://) + else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) { + this.#tryTriggerAutocomplete(); + } } } else { this.#debouncedUpdateAutocomplete(); @@ -1762,6 +1766,10 @@ export class Editor implements Component, Focusable { else if (textBeforeCursor.match(/#[^\s#]*$/)) { this.#tryTriggerAutocomplete(); } + // internal URL scheme context (e.g. local://, skill://) + else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) { + this.#tryTriggerAutocomplete(); + } } } @@ -1913,6 +1921,8 @@ export class Editor implements Component, Focusable { this.#tryTriggerAutocomplete(); } else if (textBeforeCursor.match(/#[^\s#]*$/)) { this.#tryTriggerAutocomplete(); + } else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) { + this.#tryTriggerAutocomplete(); } } } @@ -2232,6 +2242,10 @@ export class Editor implements Component, Focusable { else if (textBeforeCursor.match(/#[^\s#]*$/)) { this.#tryTriggerAutocomplete(); } + // internal URL scheme context (e.g. local://, skill://) + else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) { + this.#tryTriggerAutocomplete(); + } } } @@ -2463,6 +2477,17 @@ export class Editor implements Component, Focusable { } // Autocomplete methods + /** + * Whether the text ending at the cursor looks like a `scheme://` URL token. + * Generic by design: any scheme triggers a suggestion fetch and the active + * provider decides whether it has candidates (returning none is a no-op). + * MUST stay in sync with the token grammar in coding-agent's + * `internal-url-autocomplete.ts`. + */ + #textTriggersUrlAutocomplete(textBeforeCursor: string): boolean { + return /(?:^|[\s"'`(<=])[a-z][a-z0-9+.-]*:\/{1,2}[^\s"'`()<>]*$/i.test(textBeforeCursor); + } + async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise { if (!this.#autocompleteProvider) return; // Check if we should trigger file completion on Tab