import { Prec, StateEffect, StateField, Transaction, type EditorState, type Extension, } from "@codemirror/state"; import { Decoration, EditorView, keymap, ViewPlugin, WidgetType, type PluginValue, type ViewUpdate, } from "@codemirror/view"; import { requestCompletion, type CompletionDeps } from "./provider"; export type AutocompletePrefs = CompletionDeps & { enabled: boolean; }; export type AutocompleteContext = { getPrefs: () => AutocompletePrefs; getPath: () => string | null; getLanguage: () => string | null; }; type Suggestion = { from: number; text: string; }; const setSuggestion = StateEffect.define(); const suggestionField = StateField.define({ create: () => null, update(value, tr) { for (const e of tr.effects) { if (e.is(setSuggestion)) return e.value; } if (!value) return value; if (tr.docChanged) { return consumeIfTypedAhead(value, tr); } if (tr.selection) return null; return value; }, }); function consumeIfTypedAhead( current: Suggestion, tr: Transaction, ): Suggestion | null { let consumed: string | null = null; let originDelta = 0; let abort = false; tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { if (abort) return; const ins = inserted.toString(); if (fromA !== toA || fromA !== current.from || !ins) { abort = true; return; } if (current.text.startsWith(ins)) { consumed = ins; originDelta = ins.length; } else { abort = true; } }); if (abort || !consumed) return null; const remaining = current.text.slice((consumed as string).length); if (!remaining) return null; return { from: current.from + originDelta, text: remaining }; } class GhostWidget extends WidgetType { constructor(readonly text: string) { super(); } override eq(other: GhostWidget): boolean { return other.text === this.text; } toDOM(): HTMLElement { const span = document.createElement("span"); span.className = "cm-ai-ghost"; const lines = this.text.split("\n"); lines.forEach((line, i) => { if (i > 0) span.appendChild(document.createElement("br")); span.appendChild(document.createTextNode(line)); }); return span; } override ignoreEvent(): boolean { return true; } } const ghostTheme = EditorView.theme({ ".cm-ai-ghost": { opacity: "0.45", fontStyle: "italic", pointerEvents: "none", }, }); const ghostDecorations = EditorView.decorations.compute( [suggestionField], (state) => { const sug = state.field(suggestionField); if (!sug) return Decoration.none; return Decoration.set([ Decoration.widget({ widget: new GhostWidget(sug.text), side: 1, }).range(sug.from), ]); }, ); const DEBOUNCE_MS = 350; const CHAIN_DELAY_MS = 80; const MIN_PREFIX_CHARS = 2; const MAX_LINES = 6; const CACHE_SIZE = 32; const CACHE_TAIL = 512; const CACHE_HEAD = 128; const PREFIX_WINDOW = 4000; const SUFFIX_WINDOW = 2000; class LRU { private map = new Map(); constructor(private readonly cap: number) {} get(k: K): V | undefined { const v = this.map.get(k); if (v === undefined) return undefined; this.map.delete(k); this.map.set(k, v); return v; } set(k: K, v: V) { if (this.map.has(k)) this.map.delete(k); this.map.set(k, v); if (this.map.size > this.cap) { const first = this.map.keys().next().value; if (first !== undefined) this.map.delete(first); } } clear() { this.map.clear(); } } function suggestionKey(prefix: string, suffix: string, lang: string | null): string { const p = prefix.length > CACHE_TAIL ? prefix.slice(-CACHE_TAIL) : prefix; const s = suffix.length > CACHE_HEAD ? suffix.slice(0, CACHE_HEAD) : suffix; return `${lang ?? ""}${p}${s}`; } function hasProviderKey(prefs: AutocompletePrefs): boolean { if (prefs.provider === "lmstudio") return !!prefs.lmstudioBaseURL; return !!prefs.apiKey; } function shouldTrigger( state: EditorState, prefs: AutocompletePrefs, isManual: boolean, ): boolean { if (!prefs.enabled) return false; if (!hasProviderKey(prefs)) return false; const sel = state.selection.main; if (sel.from !== sel.to) return false; if (isManual) return true; const cursor = sel.from; const doc = state.doc; if (doc.length === 0) return false; // Skip if cursor is in the middle of an identifier — typing ghost mid-word // is the most disruptive failure mode. if (cursor < doc.length) { const next = doc.sliceString(cursor, cursor + 1); if (next && /[\w$]/.test(next)) return false; } // Require some non-whitespace context within the recent prefix window. const recent = doc.sliceString(Math.max(0, cursor - 200), cursor); if (recent.replace(/\s/g, "").length < MIN_PREFIX_CHARS) return false; return true; } class CompletionDriver implements PluginValue { private timer: ReturnType | null = null; private controller: AbortController | null = null; private inflightKey: string | null = null; private cache = new LRU(CACHE_SIZE); constructor( private readonly view: EditorView, private readonly ctx: AutocompleteContext, ) {} update(u: ViewUpdate) { if (!u.docChanged && !u.selectionSet) return; let typed = false; let chained = false; let isDelete = false; let isUndo = false; for (const tr of u.transactions) { const ev = tr.annotation(Transaction.userEvent); if (!ev) continue; if (ev.startsWith("input.complete.ai")) chained = true; else if (ev.startsWith("input")) typed = true; else if (ev.startsWith("delete")) isDelete = true; else if (ev === "undo" || ev === "redo") isUndo = true; } if (isDelete || isUndo) { this.cancelTimer(); this.cancelInFlight(); return; } if (chained) { // After accept/accept-word, fire again with a short delay so the next // suggestion is ready as soon as the user looks up. this.schedule(false, CHAIN_DELAY_MS); return; } if (u.docChanged && typed) { this.schedule(false); return; } if (u.selectionSet && !u.docChanged) { // Pure cursor move — drop pending work, ghost is cleared by the field. this.cancelTimer(); this.cancelInFlight(); } } manualTrigger() { this.schedule(true); } private schedule(isManual: boolean, delayOverride?: number) { this.cancelTimer(); const delay = delayOverride ?? (isManual ? 0 : DEBOUNCE_MS); this.timer = setTimeout(() => void this.fire(isManual), delay); } private cancelTimer() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } private cancelInFlight() { if (this.controller) { this.controller.abort(); this.controller = null; this.inflightKey = null; } } private clearGhost() { if (this.view.state.field(suggestionField)) { this.view.dispatch({ effects: setSuggestion.of(null) }); } } destroy() { this.cancelInFlight(); this.cancelTimer(); } private async fire(isManual: boolean) { const prefs = this.ctx.getPrefs(); const state = this.view.state; if (!shouldTrigger(state, prefs, isManual)) return; const cursor = state.selection.main.from; const doc = state.doc; const prefix = doc.sliceString(Math.max(0, cursor - PREFIX_WINDOW), cursor); const suffix = doc.sliceString( cursor, Math.min(doc.length, cursor + SUFFIX_WINDOW), ); const lang = this.ctx.getLanguage(); const key = suggestionKey(prefix, suffix, lang); const cached = this.cache.get(key); if (cached !== undefined) { this.applyResult(cached, cursor); return; } if (this.inflightKey === key) return; this.cancelInFlight(); const controller = new AbortController(); this.controller = controller; this.inflightKey = key; const signal = controller.signal; let raw = ""; try { raw = await requestCompletion( { prefix, suffix, filename: this.ctx.getPath(), language: lang, }, prefs, signal, ); } catch (err) { if (signal.aborted) return; if (this.controller === controller) { this.controller = null; this.inflightKey = null; } return; } if (signal.aborted) return; if (this.controller === controller) { this.controller = null; this.inflightKey = null; } const trimmed = trimSuggestion(raw, prefix, suffix); // Only cache non-empty: empty often comes from a flaky reasoning-only // response, not from "no completion exists here." Letting it retry next // time is cheaper than persistently showing nothing. if (trimmed) this.cache.set(key, trimmed); this.applyResult(trimmed, cursor); } private applyResult(text: string, cursor: number) { if (!text) { this.clearGhost(); return; } const sel = this.view.state.selection.main; if (sel.from !== cursor || sel.to !== cursor) return; this.view.dispatch({ effects: setSuggestion.of({ from: cursor, text }), }); } } function trimSuggestion(raw: string, prefix: string, suffix: string): string { if (!raw) return ""; let t = raw; // Drop wrapping markdown fences if the model added them. const fence = t.match(/^```[a-zA-Z0-9_-]*\n([\s\S]*?)\n```\s*$/); if (fence) t = fence[1]; t = t.replace(/^<\|cursor\|>/, ""); // Strip prefix-tail overlap: if PREFIX ends with a partial token "te" and // the model returned "test", drop the leading "te" so the ghost shows "st". const tailMatch = prefix.match(/[\w$]+$/); if (tailMatch) { const tail = tailMatch[0]; for (let n = Math.min(tail.length, t.length); n > 0; n--) { if (t.slice(0, n) === tail.slice(tail.length - n)) { t = t.slice(n); break; } } } // Cap to a reasonable line count. const lines = t.split("\n"); if (lines.length > MAX_LINES) t = lines.slice(0, MAX_LINES).join("\n"); // Drop trailing overlap with suffix (model sometimes echoes what's ahead). const maxOverlap = Math.min(t.length, suffix.length); for (let n = maxOverlap; n > 0; n--) { if (t.slice(t.length - n) === suffix.slice(0, n)) { t = t.slice(0, t.length - n); break; } } // Strip leading indent that's already typed on the current line. const lastNl = prefix.lastIndexOf("\n"); const lineSoFar = prefix.slice(lastNl + 1); if (lineSoFar.length > 0 && /^\s+$/.test(lineSoFar) && t.startsWith(lineSoFar)) { t = t.slice(lineSoFar.length); } // If suggestion is just a duplicate of what's already typed on the line, skip. if (lineSoFar && t.trimStart() === lineSoFar.trimStart()) return ""; t = t.replace(/\s+$/, ""); // If PREFIX's last line ends with an opening delimiter (`{`, `[`, `(`, `=>`) // and the suggestion is a body (multi-line OR starts with indent), prepend // a newline so the body doesn't land on the same line as the brace. if ( t && !t.startsWith("\n") && /(?:[{[(]|=>)\s*$/.test(lineSoFar) && (t.includes("\n") || /^\s/.test(t)) ) { t = "\n" + t; } return t; } function acceptSuggestion(view: EditorView): boolean { const sug = view.state.field(suggestionField, false); if (!sug) return false; view.dispatch({ changes: { from: sug.from, to: sug.from, insert: sug.text }, selection: { anchor: sug.from + sug.text.length }, effects: setSuggestion.of(null), userEvent: "input.complete.ai", }); return true; } function acceptWord(view: EditorView): boolean { const sug = view.state.field(suggestionField, false); if (!sug) return false; // Take the next contiguous chunk: leading whitespace + one word OR one // punctuation run. Falls back to whole-suggestion if nothing matches. const m = sug.text.match(/^\s*[\w$]+/) ?? sug.text.match(/^\s*[^\w\s$]+/) ?? sug.text.match(/^\s+/); if (!m) return acceptSuggestion(view); const chunk = m[0]; const remaining = sug.text.slice(chunk.length); view.dispatch({ changes: { from: sug.from, to: sug.from, insert: chunk }, selection: { anchor: sug.from + chunk.length }, effects: setSuggestion.of( remaining ? { from: sug.from + chunk.length, text: remaining } : null, ), userEvent: "input.complete.ai", }); return true; } function dismissSuggestion(view: EditorView): boolean { const sug = view.state.field(suggestionField, false); if (!sug) return false; view.dispatch({ effects: setSuggestion.of(null) }); return true; } export function inlineCompletion(ctx: AutocompleteContext): Extension { const plugin = ViewPlugin.define((view) => new CompletionDriver(view, ctx)); const manualTrigger = (view: EditorView): boolean => { const inst = view.plugin(plugin); if (!inst) return false; inst.manualTrigger(); return true; }; return [ suggestionField, ghostDecorations, ghostTheme, plugin, Prec.highest( keymap.of([ { key: "Tab", run: acceptSuggestion }, { key: "Escape", run: dismissSuggestion }, { key: "Mod-ArrowRight", run: acceptWord }, { key: "Alt-\\", run: manualTrigger }, ]), ), ]; }