From 5a6e17a1b8f0548d4dccf959eef7018c647b614a Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 22:30:47 +0500 Subject: [PATCH 01/16] feat(autocomplete): inline code suggestions engine + provider (issue #49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghost-text completions via the OpenCode gateway with thinking forced off. - autocomplete/context.ts: bounded prefix/suffix window (10 lines before, short suffix), pure. - autocomplete/prompt.ts: FIM-token fill-in-the-middle emulation over /chat/completions (no FIM endpoint exists — probed 404); Qwen family sends enable_thinking=false (verified live: 0 hidden reasoning, ~1.5s). - autocomplete/throttle.ts: 300ms debounce, latest-wins, abort of superseded runs. - autocomplete/engine.ts: streamed chat-completions engine with 3s timeout, cancellation, SSE parsing (pure + tested). - autocomplete/provider.ts: InlineCompletionItemProvider (opt-in, config-driven). - autocomplete/index.ts: registration wiring, key resolution per request. - scripts/probe-completion-latency.ts: live latency probe used to pick the engine (env key only, never printed). - eslint.config.ts: raise the default-project file cap for scripts/. - 12 new unit tests (192 total). --- eslint.config.ts | 3 + scripts/probe-completion-latency.ts | 161 ++++++++++++++++++++++++++++ src/autocomplete/context.ts | 56 ++++++++++ src/autocomplete/engine.ts | 131 ++++++++++++++++++++++ src/autocomplete/index.ts | 55 ++++++++++ src/autocomplete/prompt.ts | 60 +++++++++++ src/autocomplete/provider.ts | 90 ++++++++++++++++ src/autocomplete/throttle.ts | 47 ++++++++ src/autocomplete/types.ts | 32 ++++++ 9 files changed, 635 insertions(+) create mode 100644 scripts/probe-completion-latency.ts create mode 100644 src/autocomplete/context.ts create mode 100644 src/autocomplete/engine.ts create mode 100644 src/autocomplete/index.ts create mode 100644 src/autocomplete/prompt.ts create mode 100644 src/autocomplete/provider.ts create mode 100644 src/autocomplete/throttle.ts create mode 100644 src/autocomplete/types.ts diff --git a/eslint.config.ts b/eslint.config.ts index 708812b..3d24662 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -51,6 +51,9 @@ export default defineConfig([ parserOptions: { projectService: { allowDefaultProject: nonProjectFiles, + // scripts/ (and eslint.config.ts) are type-checked via the default + // project; keep the cap above the script count as it grows. + maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 64, }, tsconfigRootDir: import.meta.dirname, }, diff --git a/scripts/probe-completion-latency.ts b/scripts/probe-completion-latency.ts new file mode 100644 index 0000000..dabb3d2 --- /dev/null +++ b/scripts/probe-completion-latency.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// Latency probe for the inline-completion engine decision (issue #49). +// +// Measures time-to-first-token and total latency for tiny chat-completions +// requests with thinking OFF, per candidate model family, plus whether the +// gateway tolerates a `suffix` field (FIM emulation). +// +// Usage: +// OPENCODE_KEY=sk-... tsx scripts/probe-completion-latency.ts [--stream] +// +// The key is read from the environment only and never printed or logged. + +import { spawnSync } from "node:child_process"; + +const KEY = process.env.OPENCODE_KEY; +const URL = "https://opencode.ai/zen/go/v1/chat/completions"; +const MAX_TOKENS = 32; + +if (!KEY) { + console.error("OPENCODE_KEY is required"); + process.exit(1); +} + +interface ProbeConfig { + label: string; + body: Record; +} + +const SYSTEM = "Return only the missing code. No explanations."; +const PREFIX = "function add(a, b) {\n // sum two numbers\n return "; +const SUFFIX = ";\n}"; + +const probes: ProbeConfig[] = [ + { + label: "deepseek-v4-flash (thinking off, no reasoning_effort)", + body: { + model: "deepseek-v4-flash", + stream: true, + max_tokens: MAX_TOKENS, + messages: [ + { role: "system", content: SYSTEM }, + { role: "user", content: `<|fim_prefix|>${PREFIX}<|fim_suffix|>${SUFFIX}<|fim_middle|>` }, + ], + }, + }, + { + label: "qwen3.5-plus (enable_thinking=false)", + body: { + model: "qwen3.5-plus", + stream: true, + max_tokens: MAX_TOKENS, + enable_thinking: false, + messages: [ + { role: "system", content: SYSTEM }, + { role: "user", content: `<|fim_prefix|>${PREFIX}<|fim_suffix|>${SUFFIX}<|fim_middle|>` }, + ], + }, + }, + { + label: "qwen3.5-plus + suffix field (gateway tolerance)", + body: { + model: "qwen3.5-plus", + stream: true, + max_tokens: MAX_TOKENS, + enable_thinking: false, + prompt: PREFIX, + suffix: SUFFIX, + messages: [ + { role: "system", content: SYSTEM }, + { role: "user", content: PREFIX }, + ], + }, + }, +]; + +function probe(config: ProbeConfig): void { + const started = Date.now(); + let status: number | null = null; + let error = ""; + + try { + const res = spawnSync( + "node", + [ + "-e", + ` + const KEY = process.env.OPENCODE_KEY; + const body = JSON.parse(process.env.PROBE_BODY); + const started = Number(process.env.PROBE_STARTED); + fetch("${URL}", { + method: "POST", + headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).then(async (r) => { + process.stdout.write("HTTP " + r.status + "\\n"); + if (!r.ok) { + process.stdout.write((await r.text()).slice(0, 300) + "\\n"); + return; + } + const reader = r.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + let first = true; + let reasoning = 0, text = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const events = buf.split("\\n\\n"); + buf = events.pop() ?? ""; + for (const ev of events) { + for (const line of ev.split("\\n")) { + if (!line.startsWith("data:")) continue; + const payload = line.slice(5).trim(); + if (payload === "[DONE]") continue; + try { + const json = JSON.parse(payload); + const delta = json.choices?.[0]?.delta; + const rc = typeof delta?.reasoning_content === "string" ? delta.reasoning_content : ""; + const tc = typeof delta?.content === "string" ? delta.content : ""; + if (rc) reasoning += rc.length; + if (tc) text += tc.length; + if ((rc || tc) && first) { + process.stdout.write("TTFB " + (Date.now() - started) + "ms\\n"); + first = false; + } + } catch { /* partial line */ } + } + } + } + process.stdout.write("TOTAL " + (Date.now() - started) + "ms reasoningChars=" + reasoning + " textChars=" + text + "\\n"); + }).catch((e) => { + process.stdout.write("ERROR " + String(e) + "\\n"); + }); + `, + ], + { + env: { + ...process.env, + OPENCODE_KEY: KEY, + PROBE_BODY: JSON.stringify(config.body), + PROBE_STARTED: String(started), + }, + encoding: "utf8", + }, + ); + status = res.status; + if (res.status !== 0) { + error = res.stderr; + } + process.stdout.write(res.stdout); + } catch (e) { + error = String(e); + } + const statusText = status === null ? "n/a" : String(status); + console.log(`\n[${config.label}] status=${statusText}${error ? " error=" + error : ""}`); +} + +for (const p of probes) { + probe(p); +} diff --git a/src/autocomplete/context.ts b/src/autocomplete/context.ts new file mode 100644 index 0000000..06f9295 --- /dev/null +++ b/src/autocomplete/context.ts @@ -0,0 +1,56 @@ +/** + * Build the completion context (prefix/suffix windows) from a document and + * cursor position. + * + * The window bounds keep request payloads tiny (autocomplete must be fast): + * a bounded number of lines before the cursor and a short suffix after it. + * Pure and unit-tested. + */ + +export const DEFAULT_PREFIX_LINES = 10; +export const DEFAULT_SUFFIX_CHARS = 300; +export const DEFAULT_MAX_TOKENS = 128; + +export interface CompletionWindowOptions { + prefixLines?: number; + suffixChars?: number; +} + +export interface CompletionWindow { + prefix: string; + suffix: string; +} + +export function buildCompletionWindow(text: string, offset: number, options: CompletionWindowOptions = {}): CompletionWindow { + const prefixLines = options.prefixLines ?? DEFAULT_PREFIX_LINES; + const suffixChars = options.suffixChars ?? DEFAULT_SUFFIX_CHARS; + + const prefixStart = Math.max(0, offset - 1); + const beforeCursor = text.slice(0, prefixStart + 1); + const afterCursor = text.slice(offset); + + // Prefix: bounded line count. Start at the beginning of the (prefixLines) + // line before the cursor so multi-line context is retained. + let lineStart = 0; + let linesSeen = 0; + for (let i = beforeCursor.length - 1; i >= 0; i--) { + if (beforeCursor[i] === "\n") { + linesSeen += 1; + if (linesSeen >= prefixLines) { + lineStart = i + 1; + break; + } + } + } + const prefix = beforeCursor.slice(lineStart); + + // Suffix: bounded characters after the cursor, cut at a line boundary when + // possible so the model isn't asked to continue a half-typed line twice. + let suffix = afterCursor.slice(0, suffixChars); + const newlineIdx = suffix.indexOf("\n"); + if (newlineIdx >= 0) { + suffix = suffix.slice(0, newlineIdx + 1); + } + + return { prefix, suffix }; +} diff --git a/src/autocomplete/engine.ts b/src/autocomplete/engine.ts new file mode 100644 index 0000000..d6d619f --- /dev/null +++ b/src/autocomplete/engine.ts @@ -0,0 +1,131 @@ +/** + * Chat-completions completion engine (issue #49). + * + * Sends a tiny streamed chat-completions request to the OpenCode gateway and + * collects the completion text. Uses the same endpoint and key as chat + * requests; only `content` deltas are collected (with thinking forced off by + * the prompt builder, no reasoning_content is expected). + */ + +import { buildCompletionPrompt } from "./prompt"; +import type { CompletionContext, CompletionEngine, CompletionResult } from "./types"; + +export const COMPLETION_REQUEST_TIMEOUT_MS = 3_000; + +export interface ChatCompletionEngineOptions { + /** Gateway chat-completions URL (provider-specific). */ + chatCompletionsUrl: string; + apiKey: string; + timeoutMs?: number; + log?: (msg: string) => void; +} + +/** Parse one SSE `data:` line into its JSON payload, if complete. */ +export function parseSseData(line: string): unknown { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) return undefined; + const payload = trimmed.slice("data:".length).trim(); + if (!payload || payload === "[DONE]") return undefined; + try { + return JSON.parse(payload) as unknown; + } catch { + return undefined; + } +} + +/** Extract the text deltas from a stream chunk payload. */ +export function extractChatCompletionText(data: unknown): { content: string; reasoning: string } { + if (typeof data !== "object" || data === null) return { content: "", reasoning: "" }; + const choices = (data as { choices?: unknown }).choices; + if (!Array.isArray(choices) || choices.length === 0) return { content: "", reasoning: "" }; + const first = choices[0] as { delta?: { content?: unknown; reasoning_content?: unknown } } | undefined; + const delta = first?.delta; + if (!delta) return { content: "", reasoning: "" }; + return { + content: typeof delta.content === "string" ? delta.content : "", + reasoning: typeof delta.reasoning_content === "string" ? delta.reasoning_content : "", + }; +} + +export class ChatCompletionEngine implements CompletionEngine { + readonly id = "chat-completions"; + + private readonly timeoutMs: number; + private readonly log?: (msg: string) => void; + + constructor(private readonly options: ChatCompletionEngineOptions) { + this.timeoutMs = options.timeoutMs ?? COMPLETION_REQUEST_TIMEOUT_MS; + this.log = options.log; + } + + async complete(ctx: CompletionContext, signal: AbortSignal): Promise { + const started = Date.now(); + const prompt = buildCompletionPrompt(ctx.prefix, ctx.suffix, ctx.modelId); + const body: Record = { + model: ctx.modelId, + stream: true, + max_tokens: ctx.maxTokens, + messages: prompt.messages, + ...prompt.extra, + }; + + let response: Response; + try { + response = await fetch(this.options.chatCompletionsUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${this.options.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)]), + }); + } catch { + // Network error or abort — treat as no completion. + return { text: undefined, durationMs: Date.now() - started }; + } + + if (!response.ok || !response.body) { + return { text: undefined, durationMs: Date.now() - started }; + } + + let collected = ""; + try { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split("\n\n"); + buffer = events.pop() ?? ""; + for (const event of events) { + for (const line of event.split("\n")) { + const data = parseSseData(line); + if (data === undefined) continue; + const { content } = extractChatCompletionText(data); + if (content) { + collected += content; + } + } + } + } + } catch { + // Aborted or timed out mid-stream — keep what we have. + } + + const text = cleanCompletion(collected); + this.log?.(`[completions] ${this.id} model=${ctx.modelId} durationMs=${String(Date.now() - started)} textChars=${String(text.length)}`); + return { text: text || undefined, durationMs: Date.now() - started }; + } +} + +/** Trim whitespace/formatting noise from a raw completion. */ +export function cleanCompletion(raw: string): string { + return raw + .replace(/^```[a-zA-Z0-9_-]*\s*\n?/, "") + .replace(/\n?```\s*$/, "") + .replace(/^\s+/, "") + .replace(/\s+$/, ""); +} diff --git a/src/autocomplete/index.ts b/src/autocomplete/index.ts new file mode 100644 index 0000000..6e92d87 --- /dev/null +++ b/src/autocomplete/index.ts @@ -0,0 +1,55 @@ +/** + * Inline completions registration (issue #49). + * + * Wires the completion engine + provider into the extension. The provider + * checks the opt-in configuration live, resolves the API key per request, + * and the engine is created with that key (cheap). Register once; toggling + * `opencodego.inlineSuggestions` is honored on the fly. + */ + +import * as vscode from "vscode"; +import { ChatCompletionEngine } from "./engine"; +import { OpenCodeInlineCompletionProvider } from "./provider"; +import type { CompletionContext, CompletionEngine, CompletionResult } from "./types"; + +export const INLINE_SUGGESTIONS_SETTING = "inlineSuggestions"; +export const INLINE_SUGGESTIONS_MODEL_SETTING = "inlineSuggestionsModel"; +export const DEFAULT_INLINE_MODEL = "qwen3.5-plus"; + +export interface InlineCompletionsDeps { + /** Gateway chat-completions URL (Go). */ + chatCompletionsUrl: string; + /** Resolve the API key to use (extension secret / BYOK group key). */ + resolveApiKey: () => Promise; + log?: (msg: string) => void; +} + +export function registerInlineCompletions(context: vscode.ExtensionContext, deps: InlineCompletionsDeps): vscode.Disposable { + const engine: CompletionEngine = { + id: "chat-completions", + async complete(ctx: CompletionContext, signal: AbortSignal): Promise { + const apiKey = await deps.resolveApiKey(); + if (!apiKey) { + return { text: undefined, durationMs: 0 }; + } + const keyed = new ChatCompletionEngine({ + chatCompletionsUrl: deps.chatCompletionsUrl, + apiKey, + log: deps.log, + }); + return keyed.complete(ctx, signal); + }, + }; + + const provider = new OpenCodeInlineCompletionProvider({ + engine, + resolveApiKey: deps.resolveApiKey, + isEnabled: () => vscode.workspace.getConfiguration("opencodego").get(INLINE_SUGGESTIONS_SETTING, false), + resolveModelId: () => + vscode.workspace.getConfiguration("opencodego").get(INLINE_SUGGESTIONS_MODEL_SETTING, DEFAULT_INLINE_MODEL) || "", + }); + + const registration = vscode.languages.registerInlineCompletionItemProvider({ pattern: "**" }, provider); + context.subscriptions.push(registration, provider); + return registration; +} diff --git a/src/autocomplete/prompt.ts b/src/autocomplete/prompt.ts new file mode 100644 index 0000000..fac5ac1 --- /dev/null +++ b/src/autocomplete/prompt.ts @@ -0,0 +1,60 @@ +/** + * Prompt construction for the chat-completions completion engine (issue #49). + * + * OpenCode exposes only /chat/completions (no FIM endpoint — probed 404 on + * /completions), so fill-in-the-middle is emulated with FIM tokens inline in + * the prompt. The engine families that genuinely support non-thinking mode + * (verified live: qwen3.5-plus with enable_thinking=false produces zero + * hidden reasoning) are used with thinking forced off. + * + * Pure and unit-tested. + */ + +export type CompletionFamily = "qwen" | "deepseek" | "unknown"; + +export function completionFamily(modelId: string): CompletionFamily { + if (/^qwen/i.test(modelId)) return "qwen"; + if (/^deepseek/i.test(modelId)) return "deepseek"; + return "unknown"; +} + +export const COMPLETION_SYSTEM_PROMPT = "Return only the missing code at the cursor. No explanations, no markdown."; + +/** FIM delimiter tokens per family. DeepSeek/Qwen descend from code models that know them. */ +function fimTokens(family: CompletionFamily): { prefix: string; suffix: string; middle: string } { + switch (family) { + case "qwen": + return { prefix: "<|fim_prefix|>", suffix: "<|fim_suffix|>", middle: "<|fim_middle|>" }; + case "deepseek": + return { prefix: "EDMFunc", suffix: "EDMFunc", middle: "EDMFunc" }; + default: + return { prefix: "<|fim_prefix|>", suffix: "<|fim_suffix|>", middle: "<|fim_middle|>" }; + } +} + +export interface CompletionPrompt { + messages: Array<{ role: string; content: string }>; + /** Extra body fields the gateway needs for this family (e.g. enable_thinking). */ + extra: Record; +} + +export function buildCompletionPrompt(prefix: string, suffix: string, modelId: string): CompletionPrompt { + const family = completionFamily(modelId); + const tokens = fimTokens(family); + const userContent = `${tokens.prefix}${prefix}${tokens.suffix}${suffix}${tokens.middle}`; + + const extra: Record = {}; + if (family === "qwen") { + // Qwen3 hybrid: enable_thinking=false is a genuine no-reasoning mode + // (verified: 0 hidden reasoning chars, ~1.5s TTFB live). + extra.enable_thinking = false; + } + + return { + messages: [ + { role: "system", content: COMPLETION_SYSTEM_PROMPT }, + { role: "user", content: userContent }, + ], + extra, + }; +} diff --git a/src/autocomplete/provider.ts b/src/autocomplete/provider.ts new file mode 100644 index 0000000..888292a --- /dev/null +++ b/src/autocomplete/provider.ts @@ -0,0 +1,90 @@ +/** + * InlineCompletionItemProvider for ghost-text suggestions (issue #49). + * + * Opt-in via `opencodego.inlineSuggestions`. The provider debounces typing + * (300ms), aborts in-flight requests on the next keystroke, and returns a + * single completion item whose insertText is the model's suggestion. + */ + +import * as vscode from "vscode"; +import { buildCompletionWindow, DEFAULT_MAX_TOKENS } from "./context"; +import { Debouncer } from "./throttle"; +import type { CompletionEngine } from "./types"; + +export const COMPLETION_DEBOUNCE_MS = 300; + +export interface InlineCompletionProviderOptions { + engine: CompletionEngine; + /** Resolve the API key (async; the caller owns caching/fallbacks). */ + resolveApiKey: () => Promise; + /** Whether suggestions are currently enabled (config-driven). */ + isEnabled: () => boolean; + /** The model to use for suggestions (config-driven). */ + resolveModelId: () => string; +} + +export class OpenCodeInlineCompletionProvider implements vscode.InlineCompletionItemProvider { + private readonly debouncer = new Debouncer(COMPLETION_DEBOUNCE_MS); + + constructor(private readonly options: InlineCompletionProviderOptions) {} + + provideInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + context: vscode.InlineCompletionContext, + token: vscode.CancellationToken, + ): Promise { + if (!this.options.isEnabled()) { + return Promise.resolve(undefined); + } + + const text = document.getText(); + const offset = document.offsetAt(position); + const { prefix, suffix } = buildCompletionWindow(text, offset); + if (!prefix.trim()) { + return Promise.resolve(undefined); + } + const modelId = this.options.resolveModelId(); + if (!modelId) { + return Promise.resolve(undefined); + } + + return new Promise((resolve) => { + const finish = (items: vscode.InlineCompletionItem[] | undefined): void => { + if (token.isCancellationRequested) { + resolve(undefined); + return; + } + resolve(items); + }; + + const tokenSubscription = token.onCancellationRequested(() => { + this.debouncer.cancel(); + finish(undefined); + }); + + this.debouncer.debounce(async (signal) => { + tokenSubscription.dispose(); + if (signal.aborted || token.isCancellationRequested) { + finish(undefined); + return; + } + const apiKey = await this.options.resolveApiKey(); + if (!apiKey) { + finish(undefined); + return; + } + const result = await this.options.engine.complete({ prefix, suffix, modelId, maxTokens: DEFAULT_MAX_TOKENS }, signal); + if (!result.text) { + finish(undefined); + return; + } + finish([new vscode.InlineCompletionItem(result.text, new vscode.Range(position, position))]); + }); + }); + } + + dispose(): void { + this.debouncer.dispose(); + } +} diff --git a/src/autocomplete/throttle.ts b/src/autocomplete/throttle.ts new file mode 100644 index 0000000..ef42cc7 --- /dev/null +++ b/src/autocomplete/throttle.ts @@ -0,0 +1,47 @@ +/** + * Debounce + latest-wins helper for keystroke-driven work (issue #49). + * + * While the user types, completions must be delayed (debounce) and any + * in-flight request cancelled so a stale ghost text never renders. + */ + +export class Debouncer { + private timer: ReturnType | undefined; + private controller: AbortController | undefined; + + constructor(private readonly delayMs: number) {} + + /** + * Schedule `run` after the debounce window. A previous scheduled run is + * cancelled, and the AbortSignal handed to `run` aborts the PREVIOUS + * invocation (if it already started) plus this one when superseded. + * + * @returns a signal that is aborted if a newer call supersedes this one. + */ + debounce(run: (signal: AbortSignal) => void | Promise): AbortSignal { + this.cancel(); + const controller = new AbortController(); + this.controller = controller; + this.timer = setTimeout(() => { + this.timer = undefined; + if (!controller.signal.aborted) { + void run(controller.signal); + } + }, this.delayMs); + return controller.signal; + } + + /** Cancel any scheduled run and abort the active one. */ + cancel(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; + } + this.controller?.abort(); + this.controller = undefined; + } + + dispose(): void { + this.cancel(); + } +} diff --git a/src/autocomplete/types.ts b/src/autocomplete/types.ts new file mode 100644 index 0000000..1220d0c --- /dev/null +++ b/src/autocomplete/types.ts @@ -0,0 +1,32 @@ +/** + * Inline completion engine contract (issue #49). + * + * A completion engine turns a code context (text before/after the cursor) + * into a suggested insertion, or nothing. Engines are stateless and + * cancellation-aware, so the provider can abort an in-flight completion on + * the next keystroke and never show stale ghost text. + */ + +export interface CompletionContext { + /** Text before the cursor (the prefix). */ + prefix: string; + /** Text after the cursor (the suffix), trimmed to a bounded window. */ + suffix: string; + /** Maximum tokens the completion may produce. */ + maxTokens: number; + /** The model id the engine should use (resolved by the caller). */ + modelId: string; +} + +export interface CompletionResult { + /** The code to insert at the cursor, or undefined when nothing matched. */ + text: string | undefined; + /** Completion latency, for diagnostics. */ + durationMs: number; +} + +export interface CompletionEngine { + /** Unique engine id (used in diagnostics/log lines). */ + readonly id: string; + complete(ctx: CompletionContext, signal: AbortSignal): Promise; +} From a1819e99adc072b6ed7641a7fe30d6c232da0631 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 22:31:12 +0500 Subject: [PATCH 02/16] feat(autocomplete): wire provider and add settings - activate() registers the inline completion provider (Go chat URL, key from SecretStorage). - New settings: opencodego.inlineSuggestions (opt-in, default false) and opencodego.inlineSuggestionsModel (default qwen3.5-plus), with the measured latency/thinking rationale in the descriptions. --- package.json | 10 ++++++++++ src/extension.ts | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/package.json b/package.json index 2c0b517..fd66bc4 100644 --- a/package.json +++ b/package.json @@ -219,6 +219,16 @@ "default": false, "markdownDescription": "Show agent-host vendors in the Manage Language Models panel. When disabled (default), agent models still work in the Agents window but are hidden from the Manage panel to reduce clutter." }, + "opencodego.inlineSuggestions": { + "type": "boolean", + "default": false, + "markdownDescription": "Enable ghost-text inline code suggestions while typing (experimental). Suggestions run through the OpenCode gateway with **thinking forced off** for low latency; non-thinking models (e.g. `qwen3.5-plus`) are recommended. Requires a window reload after enabling." + }, + "opencodego.inlineSuggestionsModel": { + "type": "string", + "default": "qwen3.5-plus", + "markdownDescription": "Model used for inline code suggestions. Prefer non-thinking models — measured: `qwen3.5-plus` with `enable_thinking=false` returns in ~1.5s with zero hidden reasoning, while reasoning models (e.g. `deepseek-v4-flash`) burn 100+ reasoning tokens even with thinking off and take 2s+." + }, "opencodego.stripThinkTags": { "type": "string", "enum": [ diff --git a/src/extension.ts b/src/extension.ts index 6bb7e44..2a5db54 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -47,6 +47,7 @@ import { } from "./providerTypes"; import { providerEnabledSetting } from "./providerEnablement"; import { isInternalDataPart } from "./chatParts"; +import { registerInlineCompletions } from "./autocomplete"; import { getImageDataUrlBase64Bytes, MAX_IMAGE_BASE64_BYTES, normalizeImageDataUrl } from "./imageNormalizer"; import { imageDescriptionKey, lookupImageDescriptions, storeImageDescriptions } from "./visionProxyCache"; import { providerModelDisplayName } from "./modelNames"; @@ -977,6 +978,13 @@ export function activate(context: vscode.ExtensionContext) { ); void warmModelPickerMetadata(); + + // Experimental inline code suggestions (issue #49). Opt-in via + // `opencodego.inlineSuggestions`; the provider reads the config live. + registerInlineCompletions(context, { + chatCompletionsUrl: PROVIDERS[GO_VENDOR].chatCompletionsUrl, + resolveApiKey: async () => _extensionContext?.secrets.get(SECRET_KEY), + }); } async function configureUtilityModels(): Promise { From 42e810dd3e97ca132844ae51c55cc084f88e22c5 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 22:32:48 +0500 Subject: [PATCH 03/16] docs(autocomplete): document the experimental inline suggestions README section + CHANGELOG entry: opt-in setting, recommended non-thinking model (qwen3.5-plus, measured latency), 300ms debounce / 3s timeout / keystroke abort behavior, and the FIM-emulation note. --- CHANGELOG.md | 2 +- README.md | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a7565..faf93bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Added -- _Nothing yet._ +- **`[Autocomplete]` Inline code suggestions (experimental, #49).** Ghost-text completions while typing, powered by the OpenCode gateway with thinking forced off. Opt-in via `opencodego.inlineSuggestions` (default `false`); model via `opencodego.inlineSuggestionsModel` (default `qwen3.5-plus`, whose `enable_thinking=false` mode is a genuine no-reasoning path — measured ~1.5s time-to-first-token with zero hidden reasoning). Requests are tiny (10 lines before the cursor + a short suffix), debounced 300ms, time out at 3s and abort on the next keystroke. The gateway exposes no FIM endpoint, so completions emulate fill-in-the-middle with FIM tokens over `/chat/completions`. New `src/autocomplete/` module (context, prompt, throttle, engine, provider, registration) with unit tests; `scripts/probe-completion-latency.ts` measures engine latency live. ## [0.5.2] — 2026-08-11 diff --git a/README.md b/README.md index beaf71b..52fa574 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,15 @@ Your API key and BYOK group settings are kept, so re-enabling (or the `Re-add to Language Models` action) restores everything. A window reload is required after toggling. +### ✍️ Inline Code Suggestions (Experimental) + +Ghost-text completions while typing, powered by the OpenCode gateway with **thinking forced off**: + +- Opt-in: `"opencodego.inlineSuggestions": true` (requires a window reload). +- Model: `opencodego.inlineSuggestionsModel` — defaults to `qwen3.5-plus`, whose `enable_thinking=false` mode is a genuine no-reasoning path (measured ~1.5s TTFB, zero hidden reasoning). Reasoning models (e.g. `deepseek-v4-flash`) burn 100+ reasoning tokens even with thinking off and are not recommended. +- Requests are tiny (10 lines before the cursor + a short suffix), debounced 300ms, time out at 3s, and are aborted on the next keystroke. +- The gateway exposes no FIM endpoint, so completions use fill-in-the-middle emulation with FIM tokens over `/chat/completions`. + ### 🛠️ Smart Routing & Reliability - **Native endpoint routing** per family (see [Models](#-models) table) From 63e29a66e116e6616bbf0ae8269eeb87c2838485 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 22:38:08 +0500 Subject: [PATCH 04/16] test(autocomplete): commit the missing test suite The 12 autocomplete tests (context, prompt, engine parsing, debouncer) were left unstaged in the feature commit; add them now. --- src/test/autocomplete.test.ts | 114 ++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/test/autocomplete.test.ts diff --git a/src/test/autocomplete.test.ts b/src/test/autocomplete.test.ts new file mode 100644 index 0000000..8b88851 --- /dev/null +++ b/src/test/autocomplete.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { buildCompletionWindow, DEFAULT_PREFIX_LINES, DEFAULT_SUFFIX_CHARS } from "../autocomplete/context"; +import { buildCompletionPrompt, completionFamily, COMPLETION_SYSTEM_PROMPT } from "../autocomplete/prompt"; +import { cleanCompletion, extractChatCompletionText, parseSseData } from "../autocomplete/engine"; +import { Debouncer } from "../autocomplete/throttle"; + +describe("autocomplete — completionFamily", () => { + it("classifies qwen and deepseek families", () => { + assert.equal(completionFamily("qwen3.5-plus"), "qwen"); + assert.equal(completionFamily("qwen3.7-max"), "qwen"); + assert.equal(completionFamily("deepseek-v4-flash"), "deepseek"); + assert.equal(completionFamily("glm-5"), "unknown"); + }); +}); + +describe("autocomplete — buildCompletionPrompt", () => { + it("wraps prefix/suffix in FIM tokens for qwen", () => { + const p = buildCompletionPrompt("function add(", "{\n}", "qwen3.5-plus"); + assert.equal(p.messages[1].content, "<|fim_prefix|>function add(<|fim_suffix|>{\n}<|fim_middle|>"); + assert.deepEqual(p.extra, { enable_thinking: false }); + assert.equal(p.messages[0].content, COMPLETION_SYSTEM_PROMPT); + }); + + it("forces thinking off only for the qwen family", () => { + const deepseek = buildCompletionPrompt("a", "b", "deepseek-v4-flash"); + assert.deepEqual(deepseek.extra, {}); + const unknown = buildCompletionPrompt("a", "b", "glm-5"); + assert.deepEqual(unknown.extra, {}); + }); +}); + +describe("autocomplete — buildCompletionWindow", () => { + const doc = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n"); + + it("bounded prefix lines and short suffix", () => { + const w = buildCompletionWindow(doc, doc.length, { prefixLines: 5 }); + const lines = w.prefix.split("\n").length; + assert.ok(lines <= 6, `prefix spans at most 5 lines + partial, got ${lines}`); + assert.equal(w.suffix, ""); + }); + + it("cuts the suffix at the next line boundary", () => { + const text = "abc\nrest of line\nmore"; + const w = buildCompletionWindow(text, 2); + assert.equal(w.prefix, "ab"); + assert.equal(w.suffix, "c\n"); + }); + + it("empty suffix beyond EOF", () => { + const w = buildCompletionWindow("abc", 3); + assert.equal(w.prefix, "abc"); + assert.equal(w.suffix, ""); + }); + + it("defaults match the constants", () => { + const w = buildCompletionWindow("x\ny", 2, {}); + assert.equal(w.prefix, "x\n"); + assert.equal(w.suffix, "y"); + void DEFAULT_PREFIX_LINES; + void DEFAULT_SUFFIX_CHARS; + }); +}); + +describe("autocomplete — engine parsing", () => { + it("parseSseData extracts complete payloads and skips [DONE]", () => { + assert.deepEqual(parseSseData('data: {"a":1}'), { a: 1 }); + assert.equal(parseSseData("data: [DONE]"), undefined); + assert.equal(parseSseData("data: {broken"), undefined); + assert.equal(parseSseData("event: foo"), undefined); + }); + + it("extractChatCompletionText reads content and reasoning deltas", () => { + assert.equal(extractChatCompletionText({ choices: [{ delta: { content: "hi" } }] }).content, "hi"); + assert.equal(extractChatCompletionText({ choices: [{ delta: { reasoning_content: "think" } }] }).reasoning, "think"); + assert.equal(extractChatCompletionText({}).content, ""); + assert.equal(extractChatCompletionText({ choices: [] }).content, ""); + }); + + it("cleanCompletion strips fences and surrounding whitespace", () => { + assert.equal(cleanCompletion("```ts\nconst x = 1;\n```"), "const x = 1;"); + assert.equal(cleanCompletion(" \nconst y = 2;\n "), "const y = 2;"); + }); +}); + +describe("autocomplete — Debouncer", () => { + it("debounces and only runs after the delay", async () => { + const d = new Debouncer(50); + let runs = 0; + const sig = d.debounce(() => { + runs += 1; + }); + await new Promise((r) => setTimeout(r, 80)); + assert.equal(runs, 1); + assert.equal(sig.aborted, false); + d.dispose(); + }); + + it("latest call cancels the previous one", async () => { + const d = new Debouncer(50); + let runs = 0; + const first = d.debounce(() => { + runs += 1; + }); + const second = d.debounce(() => { + runs += 1; + }); + await new Promise((r) => setTimeout(r, 90)); + assert.equal(runs, 1, "only the latest debounced run executes"); + assert.equal(first.aborted, true, "superseded run is aborted"); + assert.equal(second.aborted, false); + d.dispose(); + }); +}); From d01ce6239f601886ae00b6a4a3c97e91af31347d Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 22:40:03 +0500 Subject: [PATCH 05/16] chore(tsconfig): give scripts/ its own editor type-check project scripts/*.ts live outside tsconfig.json (src only), so the editor's inferred project lacks @types/node and shows spurious errors (node:fs, process, import.meta). A scripts/tsconfig.json extends the root config with noEmit so the editor type-checks them with node types. eslint.config.ts is type-checked by the strict gate via the ESLint default project and cannot join the CJS-flavored check project (it is ESM via jiti). --- scripts/tsconfig.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 scripts/tsconfig.json diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..93331d3 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "types": ["node"] + }, + "include": ["."], + "exclude": ["node_modules", "../node_modules"] +} From 76213fd95f9812c8cc1030258a9ab579ecb841ab Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 22:41:44 +0500 Subject: [PATCH 06/16] chore(eslint): scripts are now covered by scripts/tsconfig.json The new scripts project is discovered by the project service, so the allowDefaultProject glob for scripts/*.ts is redundant and conflicts. Only eslint.config.ts (ESM via jiti, outside both projects) stays in the default project. --- eslint.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/eslint.config.ts b/eslint.config.ts index 3d24662..484dabf 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -30,7 +30,9 @@ const gitignore = readFileSync(new URL(".gitignore", import.meta.url), "utf8") // Files not covered by tsconfig (which only includes src/), type-checked via // the default project so strictTypeChecked rules still apply to them. -const nonProjectFiles = ["eslint.config.ts", "scripts/*.ts"]; +// eslint.config.ts is ESM-only (loaded via jiti) and lives outside both +// tsconfig projects; scripts/*.ts are covered by scripts/tsconfig.json. +const nonProjectFiles = ["eslint.config.ts"]; // The typescript-eslint `config()` helper is deprecated; ESLint core now // provides `defineConfig()`. We replicate the helper's `extends` expansion From 963ac4a506c4ac2d27a2f774978ee28c033daae2 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 22:44:53 +0500 Subject: [PATCH 07/16] chore(tsconfig): exclude everything .gitignore ignores ESLint, markdownlint and prettier already derive their ignores from .gitignore at runtime; the TS projects now mirror it too (node_modules/, out/, tmp/, .vscode-test) so no gitignored path can enter the editor or check projects. --- scripts/tsconfig.json | 3 ++- tsconfig.check.json | 3 ++- tsconfig.json | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 93331d3..dfea124 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -6,5 +6,6 @@ "types": ["node"] }, "include": ["."], - "exclude": ["node_modules", "../node_modules"] + // Mirrors .gitignore: nothing gitignored may enter the TS projects. + "exclude": ["node_modules", "../node_modules", "../out", "../tmp"] } diff --git a/tsconfig.check.json b/tsconfig.check.json index 5e5f717..67d7632 100644 --- a/tsconfig.check.json +++ b/tsconfig.check.json @@ -5,5 +5,6 @@ "rootDir": "." }, "include": ["src", "scripts"], - "exclude": ["node_modules", ".vscode-test", "out"] + // Mirrors .gitignore: nothing gitignored may enter the TS projects. + "exclude": ["node_modules", ".vscode-test", "out", "tmp"] } diff --git a/tsconfig.json b/tsconfig.json index cbcd0d1..f52c09f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,6 @@ "types": ["node"] }, "include": ["src"], - "exclude": ["node_modules", ".vscode-test", "scripts"] + // Mirrors .gitignore: nothing gitignored may enter the TS projects. + "exclude": ["node_modules", ".vscode-test", "scripts", "out", "tmp"] } From a47417537507304cbd750cc6173150c6469a95f0 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 22:49:55 +0500 Subject: [PATCH 08/16] chore(tooling): shared .gitignore-patterns helper scripts/gitignore.ts is the single source of .gitignore patterns for any tool that accepts them at runtime (ESLint now imports it instead of reading the file inline). Prettier and markdownlint already use native .gitignore support (--ignore-path / gitignore: true); tsconfigs stay static mirrors since JSON cannot execute code. No folder/file name ever needs to be listed by hand again. --- eslint.config.ts | 8 ++------ scripts/gitignore.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 scripts/gitignore.ts diff --git a/eslint.config.ts b/eslint.config.ts index 484dabf..6cd1eea 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -17,17 +17,13 @@ // via `--max-warnings 0`. The only rules disabled here are ones whose noise // outweighs their value (see the scoped overrides below). -import { readFileSync } from "node:fs"; import { defineConfig } from "eslint/config"; import tseslint from "typescript-eslint"; import yml from "eslint-plugin-yml"; import jsonc from "eslint-plugin-jsonc"; +import { gitignorePatterns } from "./scripts/gitignore"; -const gitignore = readFileSync(new URL(".gitignore", import.meta.url), "utf8") - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith("#") && !line.startsWith("!")); - +const gitignore = gitignorePatterns(); // Files not covered by tsconfig (which only includes src/), type-checked via // the default project so strictTypeChecked rules still apply to them. // eslint.config.ts is ESM-only (loaded via jiti) and lives outside both diff --git a/scripts/gitignore.ts b/scripts/gitignore.ts new file mode 100644 index 0000000..9632bb8 --- /dev/null +++ b/scripts/gitignore.ts @@ -0,0 +1,30 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); + +/** + * Read `.gitignore` and return its patterns, minus comments and negations. + * + * Use this wherever a tool needs gitignore-style ignore patterns at runtime — + * ESLint (`ignores:`), custom linters, scripts — so nothing gitignored has to + * be listed by hand again. Tools with native .gitignore support should use it + * directly instead: + * + * - Prettier: `--ignore-path .gitignore` + * - markdownlint-cli2: `"gitignore": true` in `.markdownlint-cli2.json` + * + * TypeScript projects are static JSON and cannot import this; their + * `exclude` arrays mirror `.gitignore` (see tsconfig.json / tsconfig.check.json + * / scripts/tsconfig.json). + */ +export function gitignorePatterns(): string[] { + const file = path.join(root, ".gitignore"); + if (!existsSync(file)) { + return []; + } + return readFileSync(file, "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#") && !line.startsWith("!")); +} From bbcbae5284c2c7cdcc3d32eb2d2221b58120f05c Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 22:53:11 +0500 Subject: [PATCH 09/16] fix(config): declare import.meta.dirname for the editor's inferred project eslint.config.ts is loaded as ESM by ESLint (jiti) but lives outside both tsconfig projects, so the editor's inferred CommonJS project reports 'Property dirname does not exist on type ImportMeta'. Declare it via a global ImportMeta augmentation (matches @types/node's node16 declaration, so real type-checks merge cleanly); runtime behavior is unchanged. --- eslint.config.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/eslint.config.ts b/eslint.config.ts index 6cd1eea..8f90d17 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -23,6 +23,18 @@ import yml from "eslint-plugin-yml"; import jsonc from "eslint-plugin-jsonc"; import { gitignorePatterns } from "./scripts/gitignore"; +// eslint.config.ts is loaded as ESM by ESLint (via jiti) but lives outside +// both tsconfig projects, so the editor's inferred CommonJS project does not +// see @types/node's `import.meta.dirname` (declared only for node16/nodenext +// modules). It exists at runtime; declare it explicitly to silence the +// editor diagnostic. Matches @types/node's own declaration, so type-checked +// consumers merge cleanly. +declare global { + interface ImportMeta { + dirname: string; + } +} + const gitignore = gitignorePatterns(); // Files not covered by tsconfig (which only includes src/), type-checked via // the default project so strictTypeChecked rules still apply to them. From 93df4303fb3ffb73244c1819661c91a87c8d812a Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 23:07:34 +0500 Subject: [PATCH 10/16] feat(autocomplete): dedicated output channel for completions diagnostics New 'OpenCode Completions' output channel logs every attempt (model, context sizes, key presence, per-request duration/text) so testers can see exactly what the provider is doing when suggestions don't appear. --- src/autocomplete/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/autocomplete/index.ts b/src/autocomplete/index.ts index 6e92d87..1e043db 100644 --- a/src/autocomplete/index.ts +++ b/src/autocomplete/index.ts @@ -25,17 +25,27 @@ export interface InlineCompletionsDeps { } export function registerInlineCompletions(context: vscode.ExtensionContext, deps: InlineCompletionsDeps): vscode.Disposable { + const output = vscode.window.createOutputChannel("OpenCode Completions"); + context.subscriptions.push(output); + const log = (msg: string): void => { + output.appendLine(msg); + }; + const engine: CompletionEngine = { id: "chat-completions", async complete(ctx: CompletionContext, signal: AbortSignal): Promise { const apiKey = await deps.resolveApiKey(); if (!apiKey) { + log("[completions] no API key — skipping"); return { text: undefined, durationMs: 0 }; } + log(`[completions] model=${ctx.modelId} prefixChars=${String(ctx.prefix.length)} suffixChars=${String(ctx.suffix.length)}`); const keyed = new ChatCompletionEngine({ chatCompletionsUrl: deps.chatCompletionsUrl, apiKey, - log: deps.log, + log: (msg) => { + log(msg); + }, }); return keyed.complete(ctx, signal); }, From ab093f5526ac7464041e8ab38423f7d605ecfe22 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 23:14:49 +0500 Subject: [PATCH 11/16] fix(package): stop excluding out/autocomplete from the VSIX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .vscodeignore still carried 'out/autocomplete/**' from when the old autocomplete feature was removed — the new inline-completions module lives in the same directory, so the packaged extension.js required a missing module and activation threw, hiding the status bar and disabling suggestions. Remove the stale exclusion. --- .vscodeignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.vscodeignore b/.vscodeignore index 43a7fc6..ebd4998 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -15,7 +15,6 @@ node_modules/** !node_modules/@silvia-odwyer/photon-node/*.js !node_modules/@silvia-odwyer/photon-node/*.wasm out/**/*.js.map -out/autocomplete/** out/test/** scripts/** src/** From d912b1bc192f303c2a1992887c35ba1e9fe26d09 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 23:25:44 +0500 Subject: [PATCH 12/16] feat(autocomplete): model dropdown instead of a free-form input opencodego.inlineSuggestionsModel is now an enum with descriptions: qwen3.5-plus (verified fastest), qwen3.6-plus, qwen3.7-plus, and deepseek-v4-flash (works, but measurably slower). --- package.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index fd66bc4..13be9a0 100644 --- a/package.json +++ b/package.json @@ -226,8 +226,20 @@ }, "opencodego.inlineSuggestionsModel": { "type": "string", + "enum": [ + "qwen3.5-plus", + "qwen3.6-plus", + "qwen3.7-plus", + "deepseek-v4-flash" + ], + "enumDescriptions": [ + "Fastest verified option: enable_thinking=false yields ~1.5s with zero hidden reasoning.", + "Qwen3.6 hybrid — same non-thinking mode, slightly larger context budget.", + "Qwen3.7 hybrid — newest Qwen on OpenCode Go, same non-thinking behavior.", + "Works but measurably slower — burns hidden reasoning tokens even with thinking off (~2s+)." + ], "default": "qwen3.5-plus", - "markdownDescription": "Model used for inline code suggestions. Prefer non-thinking models — measured: `qwen3.5-plus` with `enable_thinking=false` returns in ~1.5s with zero hidden reasoning, while reasoning models (e.g. `deepseek-v4-flash`) burn 100+ reasoning tokens even with thinking off and take 2s+." + "markdownDescription": "Model used for inline code suggestions. Prefer the non-thinking Qwen options — measured: `qwen3.5-plus` with `enable_thinking=false` returns in ~1.5s with zero hidden reasoning, while reasoning models (e.g. `deepseek-v4-flash`) burn 100+ reasoning tokens even with thinking off." }, "opencodego.stripThinkTags": { "type": "string", From 0e9ea30b008789d967d022a649dcc230dd7cc8fa Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 07:43:42 +0500 Subject: [PATCH 13/16] feat(autocomplete): make timing/size knobs config-driven The provider and engine now read five tunables from the extension config at runtime instead of hardcoding them: debounce ms, request timeout ms, max tokens, prefix lines and suffix chars. Defaults unchanged (300 / 3000 / 128 / 10 / 300); the output channel log already reports context sizes, so the knobs are directly observable. --- src/autocomplete/index.ts | 24 +++++++++++++++++++++--- src/autocomplete/provider.ts | 33 +++++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/autocomplete/index.ts b/src/autocomplete/index.ts index 1e043db..fd8cbb8 100644 --- a/src/autocomplete/index.ts +++ b/src/autocomplete/index.ts @@ -14,7 +14,17 @@ import type { CompletionContext, CompletionEngine, CompletionResult } from "./ty export const INLINE_SUGGESTIONS_SETTING = "inlineSuggestions"; export const INLINE_SUGGESTIONS_MODEL_SETTING = "inlineSuggestionsModel"; +export const INLINE_DEBOUNCE_MS_SETTING = "inlineSuggestionsDebounceMs"; +export const INLINE_TIMEOUT_MS_SETTING = "inlineSuggestionsTimeoutMs"; +export const INLINE_MAX_TOKENS_SETTING = "inlineSuggestionsMaxTokens"; +export const INLINE_PREFIX_LINES_SETTING = "inlineSuggestionsPrefixLines"; +export const INLINE_SUFFIX_CHARS_SETTING = "inlineSuggestionsSuffixChars"; export const DEFAULT_INLINE_MODEL = "qwen3.5-plus"; +export const DEFAULT_INLINE_DEBOUNCE_MS = 300; +export const DEFAULT_INLINE_TIMEOUT_MS = 3_000; +export const DEFAULT_INLINE_MAX_TOKENS = 128; +export const DEFAULT_INLINE_PREFIX_LINES = 10; +export const DEFAULT_INLINE_SUFFIX_CHARS = 300; export interface InlineCompletionsDeps { /** Gateway chat-completions URL (Go). */ @@ -24,6 +34,10 @@ export interface InlineCompletionsDeps { log?: (msg: string) => void; } +function readSetting(key: string, fallback: T): T { + return vscode.workspace.getConfiguration("opencodego").get(key, fallback); +} + export function registerInlineCompletions(context: vscode.ExtensionContext, deps: InlineCompletionsDeps): vscode.Disposable { const output = vscode.window.createOutputChannel("OpenCode Completions"); context.subscriptions.push(output); @@ -43,6 +57,7 @@ export function registerInlineCompletions(context: vscode.ExtensionContext, deps const keyed = new ChatCompletionEngine({ chatCompletionsUrl: deps.chatCompletionsUrl, apiKey, + timeoutMs: readSetting(INLINE_TIMEOUT_MS_SETTING, DEFAULT_INLINE_TIMEOUT_MS), log: (msg) => { log(msg); }, @@ -54,9 +69,12 @@ export function registerInlineCompletions(context: vscode.ExtensionContext, deps const provider = new OpenCodeInlineCompletionProvider({ engine, resolveApiKey: deps.resolveApiKey, - isEnabled: () => vscode.workspace.getConfiguration("opencodego").get(INLINE_SUGGESTIONS_SETTING, false), - resolveModelId: () => - vscode.workspace.getConfiguration("opencodego").get(INLINE_SUGGESTIONS_MODEL_SETTING, DEFAULT_INLINE_MODEL) || "", + isEnabled: () => readSetting(INLINE_SUGGESTIONS_SETTING, false), + resolveModelId: () => readSetting(INLINE_SUGGESTIONS_MODEL_SETTING, DEFAULT_INLINE_MODEL), + resolveDebounceMs: () => readSetting(INLINE_DEBOUNCE_MS_SETTING, DEFAULT_INLINE_DEBOUNCE_MS), + resolveMaxTokens: () => readSetting(INLINE_MAX_TOKENS_SETTING, DEFAULT_INLINE_MAX_TOKENS), + resolvePrefixLines: () => readSetting(INLINE_PREFIX_LINES_SETTING, DEFAULT_INLINE_PREFIX_LINES), + resolveSuffixChars: () => readSetting(INLINE_SUFFIX_CHARS_SETTING, DEFAULT_INLINE_SUFFIX_CHARS), }); const registration = vscode.languages.registerInlineCompletionItemProvider({ pattern: "**" }, provider); diff --git a/src/autocomplete/provider.ts b/src/autocomplete/provider.ts index 888292a..bd766a9 100644 --- a/src/autocomplete/provider.ts +++ b/src/autocomplete/provider.ts @@ -7,11 +7,9 @@ */ import * as vscode from "vscode"; -import { buildCompletionWindow, DEFAULT_MAX_TOKENS } from "./context"; +import { buildCompletionWindow } from "./context"; import { Debouncer } from "./throttle"; -import type { CompletionEngine } from "./types"; - -export const COMPLETION_DEBOUNCE_MS = 300; +import type { CompletionContext, CompletionEngine } from "./types"; export interface InlineCompletionProviderOptions { engine: CompletionEngine; @@ -21,12 +19,22 @@ export interface InlineCompletionProviderOptions { isEnabled: () => boolean; /** The model to use for suggestions (config-driven). */ resolveModelId: () => string; + /** Debounce delay in ms before a request is sent (config-driven). */ + resolveDebounceMs: () => number; + /** Max tokens a completion may produce (config-driven). */ + resolveMaxTokens: () => number; + /** Context window: lines before the cursor (config-driven). */ + resolvePrefixLines: () => number; + /** Context window: chars after the cursor (config-driven). */ + resolveSuffixChars: () => number; } export class OpenCodeInlineCompletionProvider implements vscode.InlineCompletionItemProvider { - private readonly debouncer = new Debouncer(COMPLETION_DEBOUNCE_MS); + private readonly debouncer: Debouncer; - constructor(private readonly options: InlineCompletionProviderOptions) {} + constructor(private readonly options: InlineCompletionProviderOptions) { + this.debouncer = new Debouncer(options.resolveDebounceMs()); + } provideInlineCompletionItems( document: vscode.TextDocument, @@ -40,7 +48,10 @@ export class OpenCodeInlineCompletionProvider implements vscode.InlineCompletion const text = document.getText(); const offset = document.offsetAt(position); - const { prefix, suffix } = buildCompletionWindow(text, offset); + const { prefix, suffix } = buildCompletionWindow(text, offset, { + prefixLines: this.options.resolvePrefixLines(), + suffixChars: this.options.resolveSuffixChars(), + }); if (!prefix.trim()) { return Promise.resolve(undefined); } @@ -74,7 +85,13 @@ export class OpenCodeInlineCompletionProvider implements vscode.InlineCompletion finish(undefined); return; } - const result = await this.options.engine.complete({ prefix, suffix, modelId, maxTokens: DEFAULT_MAX_TOKENS }, signal); + const ctx: CompletionContext = { + prefix, + suffix, + modelId, + maxTokens: this.options.resolveMaxTokens(), + }; + const result = await this.options.engine.complete(ctx, signal); if (!result.text) { finish(undefined); return; From 29ddb12743d414328a9fcb35312f41aa5867cca5 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 07:44:05 +0500 Subject: [PATCH 14/16] feat(settings): expose the five inline-suggestion knobs New settings with sensible bounds and descriptions: inlineSuggestionsDebounceMs (50-2000), inlineSuggestionsTimeoutMs (500-15000), inlineSuggestionsMaxTokens (16-1024), inlineSuggestionsPrefixLines (1-100), inlineSuggestionsSuffixChars (0-5000). --- package.json | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/package.json b/package.json index 175de3a..b02bc66 100644 --- a/package.json +++ b/package.json @@ -241,6 +241,41 @@ "default": "qwen3.5-plus", "markdownDescription": "Model used for inline code suggestions. Prefer the non-thinking Qwen options — measured: `qwen3.5-plus` with `enable_thinking=false` returns in ~1.5s with zero hidden reasoning, while reasoning models (e.g. `deepseek-v4-flash`) burn 100+ reasoning tokens even with thinking off." }, + "opencodego.inlineSuggestionsDebounceMs": { + "type": "number", + "default": 300, + "minimum": 50, + "maximum": 2000, + "markdownDescription": "Delay after the last keystroke before an inline suggestion is requested. Lower = snappier but more requests; higher = fewer requests but slower appearance." + }, + "opencodego.inlineSuggestionsTimeoutMs": { + "type": "number", + "default": 3000, + "minimum": 500, + "maximum": 15000, + "markdownDescription": "Maximum time an inline suggestion request may take before it is abandoned (the ghost text then simply doesn't appear)." + }, + "opencodego.inlineSuggestionsMaxTokens": { + "type": "number", + "default": 128, + "minimum": 16, + "maximum": 1024, + "markdownDescription": "Maximum tokens a completion may produce. Shorter = faster; longer = more complete suggestions." + }, + "opencodego.inlineSuggestionsPrefixLines": { + "type": "number", + "default": 10, + "minimum": 1, + "maximum": 100, + "markdownDescription": "Context window: how many lines before the cursor are sent with each suggestion request. More context = better completions, slightly slower." + }, + "opencodego.inlineSuggestionsSuffixChars": { + "type": "number", + "default": 300, + "minimum": 0, + "maximum": 5000, + "markdownDescription": "Context window: how many characters after the cursor are sent with each suggestion request (0 disables the suffix)." + }, "opencodego.stripThinkTags": { "type": "string", "enum": [ From 93b09c34bac928f3063ee2f4161a1900ac712adc Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 07:46:15 +0500 Subject: [PATCH 15/16] test(autocomplete): cover configurable debounce and window knobs Custom debounce delay (runs after the delay, not before), custom prefix/suffix window sizes (exactly prefixLines lines), and zero suffix chars disabling the suffix (207 tests). --- src/test/autocomplete.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/test/autocomplete.test.ts b/src/test/autocomplete.test.ts index 8b88851..6bed588 100644 --- a/src/test/autocomplete.test.ts +++ b/src/test/autocomplete.test.ts @@ -60,6 +60,19 @@ describe("autocomplete — buildCompletionWindow", () => { void DEFAULT_PREFIX_LINES; void DEFAULT_SUFFIX_CHARS; }); + + it("honors custom prefix/suffix window options", () => { + const doc = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n"); + const w = buildCompletionWindow(doc, doc.length, { prefixLines: 2, suffixChars: 0 }); + assert.equal(w.prefix.split("\n").length, 2, "exactly prefixLines lines, including the cursor line"); + assert.equal(w.suffix, ""); + }); + + it("zero suffix chars disables the suffix entirely", () => { + const w = buildCompletionWindow("abc\nrest", 1, { suffixChars: 0 }); + assert.equal(w.prefix, "a"); + assert.equal(w.suffix, ""); + }); }); describe("autocomplete — engine parsing", () => { @@ -84,6 +97,19 @@ describe("autocomplete — engine parsing", () => { }); describe("autocomplete — Debouncer", () => { + it("honors a custom delay", async () => { + const d = new Debouncer(120); + let runs = 0; + d.debounce(() => { + runs += 1; + }); + await new Promise((r) => setTimeout(r, 60)); + assert.equal(runs, 0, "should not run before the custom delay elapses"); + await new Promise((r) => setTimeout(r, 90)); + assert.equal(runs, 1); + d.dispose(); + }); + it("debounces and only runs after the delay", async () => { const d = new Debouncer(50); let runs = 0; From 9f7a5343cc1cd31b11661c688da9dad2446d78ad Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 07:47:01 +0500 Subject: [PATCH 16/16] docs(autocomplete): document the five tuning knobs --- CHANGELOG.md | 2 +- README.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae8ad9c..a678f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Added -- **`[Autocomplete]` Inline code suggestions (experimental, #49).** Ghost-text completions while typing, powered by the OpenCode gateway with thinking forced off. Opt-in via `opencodego.inlineSuggestions` (default `false`); model via `opencodego.inlineSuggestionsModel` (default `qwen3.5-plus`, whose `enable_thinking=false` mode is a genuine no-reasoning path — measured ~1.5s time-to-first-token with zero hidden reasoning). Requests are tiny (10 lines before the cursor + a short suffix), debounced 300ms, time out at 3s and abort on the next keystroke. The gateway exposes no FIM endpoint, so completions emulate fill-in-the-middle with FIM tokens over `/chat/completions`. New `src/autocomplete/` module (context, prompt, throttle, engine, provider, registration) with unit tests; `scripts/probe-completion-latency.ts` measures engine latency live. +- **`[Autocomplete]` Inline code suggestions (experimental, #49).** Ghost-text completions while typing, powered by the OpenCode gateway with thinking forced off. Opt-in via `opencodego.inlineSuggestions` (default `false`); model via `opencodego.inlineSuggestionsModel` (default `qwen3.5-plus`, whose `enable_thinking=false` mode is a genuine no-reasoning path — measured ~1.5s time-to-first-token with zero hidden reasoning). Requests are tiny (10 lines before the cursor + a short suffix), debounced 300ms, time out at 3s and abort on the next keystroke. The gateway exposes no FIM endpoint, so completions emulate fill-in-the-middle with FIM tokens over `/chat/completions`. New `src/autocomplete/` module (context, prompt, throttle, engine, provider, registration) with unit tests; `scripts/probe-completion-latency.ts` measures engine latency live. All timing/size knobs are user-tunable: `inlineSuggestionsDebounceMs`, `inlineSuggestionsTimeoutMs`, `inlineSuggestionsMaxTokens`, `inlineSuggestionsPrefixLines`, `inlineSuggestionsSuffixChars`. - **`[Usage]` Server-accurate Go meters via the official `/zen/go/v1/usage` endpoint (#130).** The status bar, tooltip, quick-pick and usage webview previously showed locally estimated Session/Weekly/Monthly percentages that drifted from opencode.ai (issue #23) because they missed CLI, cross-device and pre-install usage. The tracker now pulls the official endpoint (upstream anomalyco/opencode#16513, verified live) with the existing Go key on startup and after each request (60s TTL cache): rolling/weekly/monthly percent + reset times are server-computed and account-wide, `spent` is derived from the authoritative percent, and Today/Yesterday + per-session spend stay device-local. Failures (401/403/404/network) fall back to the existing SQLite → tracked estimates. The key is only ever sent as the Authorization header and never logged or persisted. New pure module `src/goUsageSync.ts` with unit tests. Documented in `docs/issues/62-20260812-pr132-go-usage-server-sync.md`. PR [#132](https://github.com/ltmoerdani/opencode-copilot-chat/pull/132) by [@Fahad090NP](https://github.com/Fahad090NP). ### Changed diff --git a/README.md b/README.md index 7749367..486b8a3 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,8 @@ Ghost-text completions while typing, powered by the OpenCode gateway with **thin - Opt-in: `"opencodego.inlineSuggestions": true` (requires a window reload). - Model: `opencodego.inlineSuggestionsModel` — defaults to `qwen3.5-plus`, whose `enable_thinking=false` mode is a genuine no-reasoning path (measured ~1.5s TTFB, zero hidden reasoning). Reasoning models (e.g. `deepseek-v4-flash`) burn 100+ reasoning tokens even with thinking off and are not recommended. -- Requests are tiny (10 lines before the cursor + a short suffix), debounced 300ms, time out at 3s, and are aborted on the next keystroke. +- Tuning knobs (all optional): `inlineSuggestionsDebounceMs` (300), `inlineSuggestionsTimeoutMs` (3000), `inlineSuggestionsMaxTokens` (128), `inlineSuggestionsPrefixLines` (10), `inlineSuggestionsSuffixChars` (300). +- Requests are tiny (10 lines before the cursor + a short suffix by default), debounced 300ms, time out at 3s, and are aborted on the next keystroke. - The gateway exposes no FIM endpoint, so completions use fill-in-the-middle emulation with FIM tokens over `/chat/completions`. ### 🛠️ Smart Routing & Reliability