From 5a6e17a1b8f0548d4dccf959eef7018c647b614a Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 22:30:47 +0500 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 From be933832a07a70d9f2f48f8bef2fdb6dd1be1bee Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 08:49:41 +0500 Subject: [PATCH 17/43] refactor(config): centralize all tunable constants in src/config.ts Every URL, timeout, limit, storage key, setting key and default now lives in one dependency-free module. Existing modules import from it and re-export the names their callers/tests rely on, so no consumer changed. Also introduced src/utils.ts with the shared primitives that were copy-pasted across modules: isRecord (6 copies), firstString, compactErrorCode, positiveNumber, getErrorMessage, formatUsd, formatTokenCount, formatRelativeTime, escapeHtml, cancellable sleep/sleepWithCancellation. Behavior-preserving cleanups included: - sanitizeToolSchema: removed the useless ternary where both branches were 'object' - AnthropicResponseExtractor no longer mutates the extractor instance as if it were the usage summary (the real summary is already updated by onData) - formatModalityBadges: collapsed two identical Audio branches - isFreeModel/isFreeZenModel/parseToolInput/dataPartToBase64 deduplicated - buildVisionRequestMessage + buildWholeConversationRequest unified - VISION_CAPABLE_MODELS duplicate entries removed - inline-completion debounce re-read live so config changes apply per keystroke - config numeric settings sanitized (strings can no longer reach request bodies) - dead code removed: getOrCreateProfile, formatGoUsageTooltip, formatGoUsageLanguageStatusDetail, createUsageDataPart --- src/autocomplete/context.ts | 14 +- src/autocomplete/engine.ts | 3 +- src/autocomplete/index.ts | 62 ++++-- src/autocomplete/provider.ts | 7 + src/autocomplete/throttle.ts | 7 +- src/chatParts.ts | 4 - src/config.ts | 228 ++++++++++++++++++++ src/extension.ts | 402 +++++++++++++---------------------- src/goUsageSync.ts | 8 +- src/goUsageTracker.ts | 144 +++---------- src/imageNormalizer.ts | 5 +- src/metadata.ts | 31 +-- src/modelLimits.ts | 5 +- src/retry.ts | 27 +-- src/streaming.ts | 42 +--- src/usage.ts | 16 +- src/usageProfile.ts | 30 +-- src/utils.ts | 163 ++++++++++++++ src/visionProxyCache.ts | 6 +- 19 files changed, 675 insertions(+), 529 deletions(-) create mode 100644 src/config.ts create mode 100644 src/utils.ts diff --git a/src/autocomplete/context.ts b/src/autocomplete/context.ts index 06f9295..292671a 100644 --- a/src/autocomplete/context.ts +++ b/src/autocomplete/context.ts @@ -7,9 +7,13 @@ * Pure and unit-tested. */ -export const DEFAULT_PREFIX_LINES = 10; -export const DEFAULT_SUFFIX_CHARS = 300; -export const DEFAULT_MAX_TOKENS = 128; +import { DEFAULT_INLINE_PREFIX_LINES, DEFAULT_INLINE_SUFFIX_CHARS } from "../config"; + +export { + DEFAULT_INLINE_PREFIX_LINES as DEFAULT_PREFIX_LINES, + DEFAULT_INLINE_SUFFIX_CHARS as DEFAULT_SUFFIX_CHARS, + DEFAULT_INLINE_MAX_TOKENS as DEFAULT_MAX_TOKENS, +} from "../config"; export interface CompletionWindowOptions { prefixLines?: number; @@ -22,8 +26,8 @@ export interface CompletionWindow { } 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 prefixLines = options.prefixLines ?? DEFAULT_INLINE_PREFIX_LINES; + const suffixChars = options.suffixChars ?? DEFAULT_INLINE_SUFFIX_CHARS; const prefixStart = Math.max(0, offset - 1); const beforeCursor = text.slice(0, prefixStart + 1); diff --git a/src/autocomplete/engine.ts b/src/autocomplete/engine.ts index d6d619f..0417292 100644 --- a/src/autocomplete/engine.ts +++ b/src/autocomplete/engine.ts @@ -9,8 +9,9 @@ import { buildCompletionPrompt } from "./prompt"; import type { CompletionContext, CompletionEngine, CompletionResult } from "./types"; +import { COMPLETION_REQUEST_TIMEOUT_MS } from "../config"; -export const COMPLETION_REQUEST_TIMEOUT_MS = 3_000; +export { COMPLETION_REQUEST_TIMEOUT_MS } from "../config"; export interface ChatCompletionEngineOptions { /** Gateway chat-completions URL (provider-specific). */ diff --git a/src/autocomplete/index.ts b/src/autocomplete/index.ts index fd8cbb8..78ddd5f 100644 --- a/src/autocomplete/index.ts +++ b/src/autocomplete/index.ts @@ -11,20 +11,39 @@ import * as vscode from "vscode"; import { ChatCompletionEngine } from "./engine"; import { OpenCodeInlineCompletionProvider } from "./provider"; import type { CompletionContext, CompletionEngine, CompletionResult } from "./types"; +import { + CONFIG_SECTION, + DEFAULT_INLINE_DEBOUNCE_MS, + DEFAULT_INLINE_MAX_TOKENS, + DEFAULT_INLINE_MODEL, + DEFAULT_INLINE_PREFIX_LINES, + DEFAULT_INLINE_SUFFIX_CHARS, + DEFAULT_INLINE_TIMEOUT_MS, + INLINE_DEBOUNCE_MS_SETTING, + INLINE_MAX_TOKENS_SETTING, + INLINE_PREFIX_LINES_SETTING, + INLINE_SUGGESTIONS_MODEL_SETTING, + INLINE_SUGGESTIONS_SETTING, + INLINE_SUFFIX_CHARS_SETTING, + INLINE_TIMEOUT_MS_SETTING, +} from "../config"; +import { toFiniteNumber } from "../utils"; -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 { + INLINE_SUGGESTIONS_SETTING, + INLINE_SUGGESTIONS_MODEL_SETTING, + INLINE_DEBOUNCE_MS_SETTING, + INLINE_TIMEOUT_MS_SETTING, + INLINE_MAX_TOKENS_SETTING, + INLINE_PREFIX_LINES_SETTING, + INLINE_SUFFIX_CHARS_SETTING, + DEFAULT_INLINE_MODEL, + DEFAULT_INLINE_DEBOUNCE_MS, + DEFAULT_INLINE_TIMEOUT_MS, + DEFAULT_INLINE_MAX_TOKENS, + DEFAULT_INLINE_PREFIX_LINES, + DEFAULT_INLINE_SUFFIX_CHARS, +} from "../config"; export interface InlineCompletionsDeps { /** Gateway chat-completions URL (Go). */ @@ -35,7 +54,12 @@ export interface InlineCompletionsDeps { } function readSetting(key: string, fallback: T): T { - return vscode.workspace.getConfiguration("opencodego").get(key, fallback); + return vscode.workspace.getConfiguration(CONFIG_SECTION).get(key, fallback); +} + +/** Read a numeric setting, clamped to a sane range (guards against bad config values). */ +function readNumberSetting(key: string, fallback: number, min: number, max: number): number { + return toFiniteNumber(readSetting(key, fallback), fallback, min, max); } export function registerInlineCompletions(context: vscode.ExtensionContext, deps: InlineCompletionsDeps): vscode.Disposable { @@ -57,7 +81,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), + timeoutMs: readNumberSetting(INLINE_TIMEOUT_MS_SETTING, DEFAULT_INLINE_TIMEOUT_MS, 500, 15_000), log: (msg) => { log(msg); }, @@ -71,10 +95,10 @@ export function registerInlineCompletions(context: vscode.ExtensionContext, deps resolveApiKey: deps.resolveApiKey, 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), + resolveDebounceMs: () => readNumberSetting(INLINE_DEBOUNCE_MS_SETTING, DEFAULT_INLINE_DEBOUNCE_MS, 50, 2_000), + resolveMaxTokens: () => readNumberSetting(INLINE_MAX_TOKENS_SETTING, DEFAULT_INLINE_MAX_TOKENS, 16, 1_024), + resolvePrefixLines: () => readNumberSetting(INLINE_PREFIX_LINES_SETTING, DEFAULT_INLINE_PREFIX_LINES, 1, 100), + resolveSuffixChars: () => readNumberSetting(INLINE_SUFFIX_CHARS_SETTING, DEFAULT_INLINE_SUFFIX_CHARS, 0, 5_000), }); const registration = vscode.languages.registerInlineCompletionItemProvider({ pattern: "**" }, provider); diff --git a/src/autocomplete/provider.ts b/src/autocomplete/provider.ts index bd766a9..a534c5a 100644 --- a/src/autocomplete/provider.ts +++ b/src/autocomplete/provider.ts @@ -46,6 +46,13 @@ export class OpenCodeInlineCompletionProvider implements vscode.InlineCompletion return Promise.resolve(undefined); } + // Keep the debounce window live: a config change applies on the next + // keystroke instead of requiring the provider to be recreated. + const debounceMs = this.options.resolveDebounceMs(); + if (debounceMs !== this.debouncer.delayMs) { + this.debouncer.delayMs = debounceMs; + } + const text = document.getText(); const offset = document.offsetAt(position); const { prefix, suffix } = buildCompletionWindow(text, offset, { diff --git a/src/autocomplete/throttle.ts b/src/autocomplete/throttle.ts index ef42cc7..f73f55d 100644 --- a/src/autocomplete/throttle.ts +++ b/src/autocomplete/throttle.ts @@ -9,7 +9,12 @@ export class Debouncer { private timer: ReturnType | undefined; private controller: AbortController | undefined; - constructor(private readonly delayMs: number) {} + /** Current debounce window; may be updated at runtime (config-driven). */ + delayMs: number; + + constructor(delayMs: number) { + this.delayMs = delayMs; + } /** * Schedule `run` after the debounce window. A previous scheduled run is diff --git a/src/chatParts.ts b/src/chatParts.ts index 6bf277c..b3c402b 100644 --- a/src/chatParts.ts +++ b/src/chatParts.ts @@ -5,10 +5,6 @@ export const OPENCODE_USAGE_DATA_MIME = "application/vnd.opencode.usage+json"; export const COPILOT_USAGE_DATA_MIME = "usage"; export const OPENCODE_REASONING_DATA_MIME = "application/vnd.opencode.reasoning+json"; -export function createUsageDataPart(usage: UsageSnapshot): vscode.LanguageModelDataPart | undefined { - return createUsageDataParts(usage)[0]; -} - export function createUsageDataParts(usage: UsageSnapshot): vscode.LanguageModelDataPart[] { if (!hasUsageSnapshot(usage)) { return []; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..28c3aac --- /dev/null +++ b/src/config.ts @@ -0,0 +1,228 @@ +/** + * Central configuration for the extension. + * + * Every tunable value / limit / URL / storage key / default lives here so it + * can be changed in ONE place without hunting through the codebase. Modules + * import from this file; where an existing module historically exported a + * constant (and tests or callers rely on it), the module re-exports it: + * + * import { X } from "./config"; + * export { X } from "./config"; + * + * CONTRACT: this file must stay dependency-free (no imports) so any module + * can import from it without creating import cycles. + */ + +// ─── Extension identity ────────────────────────────────────────────────────── + +/** VS Code extension ID (used for `extensions.supportAgentsWindow.`). */ +export const EXTENSION_ID = "ltmoerdani.opencode-copilot-chat"; +/** SecretStorage key for the API key. */ +export const SECRET_KEY = "opencodego.apiKey"; +/** Client name sent in the `x-opencode-client` header. */ +export const OPEN_CODE_CLIENT = "vscode-copilot-chat"; +/** Fallback only — overridden at runtime from packageJSON.version. */ +export const FALLBACK_USER_AGENT = "opencode-copilot-chat/0.5.2 VSCode"; +/** Configuration section under which all extension settings live. */ +export const CONFIG_SECTION = "opencodego"; + +// ─── Configuration setting keys ────────────────────────────────────────────── + +export const SETTING_ENABLED = "enabled"; +export const SETTING_FREE_ONLY = "freeOnly"; +export const SETTING_AGENTS_WINDOW = "agentsWindow"; +export const SETTING_AUTO_ENABLE_AGENTS_WINDOW = "autoEnableAgentsWindow"; +export const SETTING_SHOW_USAGE_STATUS_BAR = "showUsageStatusBar"; +export const SETTING_SHOW_PROVIDER_PREFIX = "showProviderPrefix"; +export const SETTING_TEMPERATURE = "temperature"; +export const SETTING_MAX_TOKENS = "maxTokens"; +export const SETTING_MAX_INPUT_TOKENS = "maxInputTokens"; +export const SETTING_DEBUG_REASONING = "debugReasoning"; +export const SETTING_REQUEST_TIMEOUT_SECONDS = "requestTimeoutSeconds"; +export const SETTING_STREAM_IDLE_TIMEOUT_SECONDS = "streamIdleTimeoutSeconds"; +export const SETTING_STRIP_THINK_TAGS = "stripThinkTags"; +export const SETTING_VISION_PROXY_WHOLE_CONVERSATION = "visionProxyWholeConversation"; +export const SETTING_THINKING = "thinking"; +export const SETTING_THINKING_DEEPSEEK = "thinking.deepseek"; +export const SETTING_THINKING_GLM = "thinking.glm"; +export const SETTING_THINKING_KIMI = "thinking.kimi"; +export const SETTING_THINKING_MINIMAX = "thinking.minimax"; +export const SETTING_THINKING_OPENAI = "thinking.openai"; +export const SETTING_THINKING_QWEN = "thinking.qwen"; +export const SETTING_THINKING_QWEN_BUDGET = "thinking.qwenBudget"; +export const SETTING_THINKING_MIMO = "thinking.mimo"; + +// ─── Inline completions (issue #49) ───────────────────────────────────────── + +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; + +// ─── Request timeouts (ms) ─────────────────────────────────────────────────── + +export const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; +export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 1000; +export const DEFAULT_REQUEST_TIMEOUT_SECONDS = DEFAULT_REQUEST_TIMEOUT_MS / 1000; +export const DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS = DEFAULT_STREAM_IDLE_TIMEOUT_MS / 1000; +/** Hard ceiling for a single model-list fetch (issue #78). */ +export const MODEL_LIST_FETCH_TIMEOUT_MS = 15_000; +/** Hard timeout for the models.dev metadata refresh. */ +export const MODEL_METADATA_FETCH_TIMEOUT_MS = 10_000; +/** Hard timeout for a single server-usage fetch. */ +export const GO_USAGE_FETCH_TIMEOUT_MS = 10_000; +/** Timeout for the "Test Connection" probe request. */ +export const TEST_CONNECTION_TIMEOUT_MS = 30_000; +/** Per-request timeout for the inline completion engine. */ +export const COMPLETION_REQUEST_TIMEOUT_MS = 3_000; + +// ─── Model-list fetch resilience (issue #78) ───────────────────────────────── + +/** Max retry attempts for transient network failures during model-list fetch. */ +export const MODEL_LIST_FETCH_MAX_RETRIES = 3; +/** Base delay for exponential backoff (500ms, 1s, 2s). */ +export const MODEL_LIST_FETCH_RETRY_BASE_MS = 500; +/** TTL for the last successful model-list snapshot cached in globalState. */ +export const MODEL_LIST_CACHE_TTL_MS = 60 * 60 * 1000; +/** globalState key suffix per vendor; full key = `${base}::`. */ +export const MODEL_LIST_CACHE_KEY_PREFIX = "opencode.modelListCache.v1"; + +// ─── Model metadata (models.dev) ───────────────────────────────────────────── + +export const MODELS_DEV_API_URL = "https://models.dev/api.json"; +export const MODEL_METADATA_REVISION = "session-2026-05-21-b"; +export const MODEL_METADATA_CACHE_KEY = "opencode.modelMetadataCache.v5"; +export const MODEL_METADATA_CACHE_TTL_MS = 1 * 60 * 60 * 1000; +export const DEFAULT_MODEL_CONTEXT_WINDOW = 262144; +export const DEFAULT_MODEL_MAX_OUTPUT_TOKENS = 65536; + +// ─── Output budget / token-estimate margins ────────────────────────────────── + +/** Reserve for UI rendering so the advertised output never claims the full window. */ +export const UI_OUTPUT_TOKEN_RESERVE = 8192; +export const MIN_TOKEN_ESTIMATE_SAFETY_MARGIN = 64; +export const TOKEN_ESTIMATE_SAFETY_RATIO = 0.12; +export const CONTEXT_RETRY_MIN_SAFETY_TOKENS = 256; +export const CONTEXT_RETRY_SAFETY_RATIO = 0.001; + +// ─── Token-count overheads ─────────────────────────────────────────────────── + +export const MESSAGE_TOKEN_OVERHEAD = 4; +export const MESSAGE_NAME_TOKEN_OVERHEAD = 1; +export const TOOL_CALL_TOKEN_OVERHEAD = 10; +export const TOOL_RESULT_TOKEN_OVERHEAD = 6; +export const IMAGE_TOKEN_ESTIMATE = 1024; + +// ─── Image payload limits ──────────────────────────────────────────────────── + +/** Max raw bytes for a single image embedded in a tool result (issue #38). */ +export const MAX_TOOL_RESULT_IMAGE_BYTES = 1_000_000; +/** Max images kept in conversation history before older ones are placeholder-replaced. */ +export const MAX_HISTORY_IMAGES_KEPT = 2; +/** Hard ceiling (base64 chars) for a normalized image attachment. */ +export const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024; +/** Dimension cap for normalized images. */ +export const MAX_IMAGE_WIDTH = 2_000; +export const MAX_IMAGE_HEIGHT = 2_000; + +// ─── Go usage tracking ─────────────────────────────────────────────────────── + +/** OpenCode Go subscription limits in USD (https://opencode.ai/docs/go). */ +export const GO_LIMITS = { + session: 12, // $12 per rolling 5-hour window + weekly: 30, // $30 per week (Mon–Mon UTC) + monthly: 60, // $60 per month (anchor-based) +} as const; +export const FIVE_HOURS_MS = 5 * 60 * 60 * 1000; +export const WEEK_MS = 7 * 24 * 60 * 60 * 1000; +export const GO_USAGE_API_URL = "https://opencode.ai/zen/go/v1/usage"; +/** How long a successful server-usage snapshot is reused before refetching. */ +export const GO_USAGE_SYNC_TTL_MS = 60_000; +export const GO_USAGE_LOG_KEY = "opencodego.usageLog.v1"; +export const GO_USAGE_BASELINE_KEY = "opencodego.usageBaseline.v1"; +export const GO_EVER_TRACKED_KEY = "opencodego.everTracked.v1"; +export const GO_SESSION_COSTS_KEY = "opencodego.sessionCosts.v1"; +export const GO_MAX_LOG_ENTRIES = 2000; +export const GO_SESSION_IDLE_MS = 2 * 60 * 60 * 1000; +export const GO_MAX_SESSIONS = 50; + +// ─── Usage profiles (issue #63) ────────────────────────────────────────────── + +export const PROFILES_REGISTRY_KEY = "opencodego.profiles.v1"; +export const ACTIVE_PROFILE_KEY = "opencodego.activeProfile.v1"; +export const MIGRATED_KEY = "opencodego.migratedTo.v1"; +export const LEGACY_SECRET_KEY = SECRET_KEY; +export const LEGACY_FINGERPRINT = "legacy"; + +// ─── Diagnostics / caches ──────────────────────────────────────────────────── + +export const RECENT_TRANSPORT_SUMMARY_LIMIT = 25; +export const RECENT_TRANSPORT_SUMMARY_STORAGE_PREFIX = "opencode.recentTransportSummaries"; +/** Cap on the per-tool-call reasoning content cache. */ +export const REASONING_CACHE_LIMIT = 500; +/** Cap on the vision-proxy image description cache. */ +export const IMAGE_DESCRIPTION_CACHE_LIMIT = 200; + +// ─── Agents window support (issue #122) ────────────────────────────────────── + +export const AGENT_HOST_BYOK_ENABLED_SETTING = "byokModels.enabled"; +export const SUPPORT_AGENTS_WINDOW_SETTING = "supportAgentsWindow"; +/** How many VS Code minor versions old the agent-host BYOK bridge goes back to. */ +export const AGENT_HOST_BYOK_MINOR_VERSION = 129; +export const AGENTS_BYOK_BRIDGE_STATE_KEY = "opencode.agentsByokBridge.v1"; +export const SUPPORT_AGENTS_WINDOW_STATE_KEY = "opencode.supportAgentsWindow.v1"; + +// ─── Vision proxy (issue #74) ──────────────────────────────────────────────── + +export const VISION_PROXY_MODEL_ID_KEY = "opencodego.visionProxyModelId"; +export const VISION_PROXY_PROMPT_KEY = "opencodego.visionProxyPrompt"; +export const DEFAULT_VISION_PROXY_PROMPT = + "Describe this image in detail so a text-only model can understand what it shows. " + + "Include all visible text, layout, colors, objects, and context."; + +// ─── Transient 5xx retry (retry.ts) ────────────────────────────────────────── + +export const TRANSIENT_5XX_MAX_RETRIES = 2; +export const TRANSIENT_5XX_RETRY_BASE_MS = 1000; +export const TRANSIENT_5XX_RETRY_JITTER_MS = 250; + +// ─── Model classification ──────────────────────────────────────────────────── + +/** Zen free-model IDs that do not end in `-free`. */ +export const FREE_ZEN_MODEL_IDS = new Set(["big-pickle"]); +/** Models removed upstream — always filtered from the picker. */ +export const KNOWN_UNAVAILABLE_MODEL_IDS = new Set(["ring-2.6-1t", "ring-2.6-1t-free", "trinity-large-preview-free"]); + +/** + * Models that live on the OpenCode Zen gateway but with constrained GPU + * capacity. They were re-enabled after a brief shutdown ("Qwen 3.6 Plus — + * free, again. Round 2. We found more GPUs.") so they are NOT deprecated, but + * agentic workloads can still hit 5xx during bursts. Surfaced so users know + * to retry or fall back to another free model. + */ +export const CAPACITY_LIMITED_MODEL_NOTES: Record = { + "qwen3.6-plus-free": + "Free relaunch with limited GPU capacity. Stable for short prompts; bursty traffic or very large tool catalogs may return 5xx - retry or fall back to 'deepseek-v4-flash-free' / 'big-pickle'. Paid 'qwen3.6-plus' has no quota.", +}; + +// ─── Per-family thinking defaults (see thinking.ts for the schema) ─────────── + +export const THINKING_DEFAULTS = { + deepseek: "off", + glm: "off", + kimi: "off", + minimax: "off", + openai: "off", + qwen: "off", + qwenBudget: "auto", + mimo: "off", +} as const; diff --git a/src/extension.ts b/src/extension.ts index 144f732..ad639ac 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -56,6 +56,68 @@ import { calculateModelLimits, type ModelLimits } from "./modelLimits"; import { buildResponsesRequestEnvelope, joinedTextContent, responsesInputItemsFromMessage } from "./responsesRequest"; import { runtimeDiagnosticsLines } from "./runtimeDiagnostics"; import { estimatePromptTokenCount, estimateTokenCount } from "./tokenEstimate"; +import { + AGENTS_BYOK_BRIDGE_STATE_KEY, + AGENT_HOST_BYOK_ENABLED_SETTING, + AGENT_HOST_BYOK_MINOR_VERSION, + CAPACITY_LIMITED_MODEL_NOTES, + CONFIG_SECTION, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS, + DEFAULT_VISION_PROXY_PROMPT, + EXTENSION_ID, + FALLBACK_USER_AGENT, + FREE_ZEN_MODEL_IDS, + IMAGE_TOKEN_ESTIMATE, + KNOWN_UNAVAILABLE_MODEL_IDS, + MAX_HISTORY_IMAGES_KEPT, + MAX_TOOL_RESULT_IMAGE_BYTES, + MESSAGE_NAME_TOKEN_OVERHEAD, + MESSAGE_TOKEN_OVERHEAD, + MODEL_LIST_CACHE_KEY_PREFIX, + MODEL_LIST_CACHE_TTL_MS, + MODEL_LIST_FETCH_MAX_RETRIES, + MODEL_LIST_FETCH_RETRY_BASE_MS, + MODEL_LIST_FETCH_TIMEOUT_MS, + MODEL_METADATA_FETCH_TIMEOUT_MS, + OPEN_CODE_CLIENT, + RECENT_TRANSPORT_SUMMARY_LIMIT, + RECENT_TRANSPORT_SUMMARY_STORAGE_PREFIX, + SECRET_KEY, + SETTING_AGENTS_WINDOW, + SETTING_AUTO_ENABLE_AGENTS_WINDOW, + SETTING_DEBUG_REASONING, + SETTING_ENABLED, + SETTING_FREE_ONLY, + SETTING_MAX_INPUT_TOKENS, + SETTING_MAX_TOKENS, + SETTING_REQUEST_TIMEOUT_SECONDS, + SETTING_SHOW_PROVIDER_PREFIX, + SETTING_SHOW_USAGE_STATUS_BAR, + SETTING_STREAM_IDLE_TIMEOUT_SECONDS, + SETTING_STRIP_THINK_TAGS, + SETTING_TEMPERATURE, + SETTING_THINKING_DEEPSEEK, + SETTING_THINKING_GLM, + SETTING_THINKING_KIMI, + SETTING_THINKING_MIMO, + SETTING_THINKING_MINIMAX, + SETTING_THINKING_OPENAI, + SETTING_THINKING_QWEN, + SETTING_THINKING_QWEN_BUDGET, + SETTING_VISION_PROXY_WHOLE_CONVERSATION, + SUPPORT_AGENTS_WINDOW_SETTING, + SUPPORT_AGENTS_WINDOW_STATE_KEY, + TEST_CONNECTION_TIMEOUT_MS, + THINKING_DEFAULTS, + TOOL_CALL_TOKEN_OVERHEAD, + TOOL_RESULT_TOKEN_OVERHEAD, + VISION_PROXY_MODEL_ID_KEY, + VISION_PROXY_PROMPT_KEY, +} from "./config"; +import { escapeHtml, formatRelativeTime, formatTokenCount, formatUsd, getErrorMessage, isRecord, sleep, toFiniteNumber } from "./utils"; +import { parseToolInput as parseToolInputShared } from "./toolCallAccumulator"; +import { isFreeModel } from "./metadata"; import { formatCacheHitRatio, formatUsageStatusBarText, formatUsageStatusBarTooltip, type UsageSnapshot } from "./usage"; import { @@ -83,10 +145,6 @@ import { type UsageProfile, } from "./usageProfile"; -const SECRET_KEY = "opencodego.apiKey"; -const RECENT_TRANSPORT_SUMMARY_LIMIT = 25; -const RECENT_TRANSPORT_SUMMARY_STORAGE_PREFIX = "opencode.recentTransportSummaries"; - /** * VS Code core settings the extension manages (auto-configures and reverts) * so OpenCode models work in the Agents window (issue #122): @@ -100,14 +158,6 @@ const RECENT_TRANSPORT_SUMMARY_STORAGE_PREFIX = "opencode.recentTransportSummari * vendors are not registered, and neither the model picker nor the * "+ Add Models" list knows OpenCode Go/Zen. */ -const AGENT_HOST_BYOK_ENABLED_SETTING = "byokModels.enabled"; -const SUPPORT_AGENTS_WINDOW_SETTING = "supportAgentsWindow"; -const EXTENSION_ID = "ltmoerdani.opencode-copilot-chat"; -/** How many VS Code versions old the agent-host BYOK bridge goes back to. */ -const AGENT_HOST_BYOK_MINOR_VERSION = 129; -/** globalState keys tracking that the extension enabled each setting itself. */ -const AGENTS_BYOK_BRIDGE_STATE_KEY = "opencode.agentsByokBridge.v1"; -const SUPPORT_AGENTS_WINDOW_STATE_KEY = "opencode.supportAgentsWindow.v1"; let usageStatusBarItem: vscode.StatusBarItem | undefined; let goUsageStatusBarItem: vscode.StatusBarItem | undefined; @@ -246,30 +296,6 @@ interface ProviderDefinition { type ModelEndpointKind = "chat-completions" | "messages" | "responses" | "google"; -const FREE_ZEN_MODEL_IDS = new Set(["big-pickle"]); -const KNOWN_UNAVAILABLE_MODEL_IDS = new Set(["ring-2.6-1t", "ring-2.6-1t-free", "trinity-large-preview-free"]); -const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; -const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 1000; -const OPEN_CODE_CLIENT = "vscode-copilot-chat"; -/** Fallback only — overridden at runtime by {@link getUserAgent} from packageJSON. */ -const FALLBACK_USER_AGENT = "opencode-copilot-chat/0.4.1 VSCode"; - -/** - * Hard ceiling for a single model-list fetch (connect + headers + body). - * - * Without this, undici's default `headersTimeout` (300s) can leave the picker - * stuck for up to 5 minutes on a hung TCP connection (issue #78). - */ -const MODEL_LIST_FETCH_TIMEOUT_MS = 15_000; -/** Max retry attempts for transient network failures during model-list fetch. */ -const MODEL_LIST_FETCH_MAX_RETRIES = 3; -/** Base delay for exponential backoff (500ms, 1s, 2s). */ -const MODEL_LIST_FETCH_RETRY_BASE_MS = 500; -/** TTL for the last successful model-list snapshot cached in globalState. */ -const MODEL_LIST_CACHE_TTL_MS = 60 * 60 * 1000; -/** globalState key suffix per vendor; full key = `${base}::`. */ -const MODEL_LIST_CACHE_KEY_PREFIX = "opencode.modelListCache.v1"; - let cachedUserAgent: string | undefined; /** @@ -322,7 +348,7 @@ function isTransientFetchError(error: unknown): boolean { // Extract HTTP status from either an explicit `.status` field or the // "Model list request failed (NNN): ..." message pattern. const explicitStatus = (error as { status?: number } | undefined)?.status; - const msg = error instanceof Error ? error.message : String(error); + const msg = getErrorMessage(error); const msgMatch = msg.match(/\((\d{3})\)/); const httpStatus = typeof explicitStatus === "number" ? explicitStatus : msgMatch ? Number(msgMatch[1]) : undefined; if (typeof httpStatus === "number") { @@ -332,42 +358,6 @@ function isTransientFetchError(error: unknown): boolean { return false; } -/** - * Promise-based delay that rejects with AbortError if the token fires. - * - * Used to back off between model-list fetch retries without leaking - * CancellationToken subscriptions. - * - * A single subscription suffices for already-cancelled tokens: VS Code's - * cancellation tokens invoke listeners registered after cancellation - * (shortcutEvent), and Promises ignore double settlement. - */ -function sleep(ms: number, token?: vscode.CancellationToken): Promise { - if (token?.isCancellationRequested) { - return Promise.reject(new DOMException("Aborted", "AbortError")); - } - return new Promise((resolve, reject) => { - const state: { timer?: ReturnType; subscription?: vscode.Disposable } = {}; - const finish = (cancelled: boolean) => { - if (state.timer) clearTimeout(state.timer); - state.subscription?.dispose(); - if (cancelled) { - reject(new DOMException("Aborted", "AbortError")); - } else { - resolve(); - } - }; - state.timer = setTimeout(() => { - finish(false); - }, ms); - if (token) { - state.subscription = token.onCancellationRequested(() => { - finish(true); - }); - } - }); -} - /** Create an agent-variant provider definition that inherits URLs, models, and filters from a base. */ function providerVariant( base: ProviderDefinition, @@ -472,7 +462,7 @@ const PROVIDERS: Record = (() "big-pickle", ], filterModel: (modelId) => - vscode.workspace.getConfiguration("opencodego").get("freeOnly", true) + vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_FREE_ONLY, true) ? modelId.endsWith("-free") || FREE_ZEN_MODEL_IDS.has(modelId) : true, }; @@ -589,11 +579,6 @@ type ConfiguredLanguageModelResponseOptions = vscode.ProvideLanguageModelChatRes configuration?: LanguageModelConfiguration; }; -const MESSAGE_TOKEN_OVERHEAD = 4; -const MESSAGE_NAME_TOKEN_OVERHEAD = 1; -const TOOL_CALL_TOKEN_OVERHEAD = 10; -const TOOL_RESULT_TOKEN_OVERHEAD = 6; -const IMAGE_TOKEN_ESTIMATE = 1024; /** * Hard upper limit (in bytes of raw image data) for a single image embedded * in a tool result. MCP screenshots from chrome-devtools-mcp / playwright-mcp @@ -603,7 +588,6 @@ const IMAGE_TOKEN_ESTIMATE = 1024; * failed" rejections from OpenCode Go. Larger images are replaced with a * placeholder text part so the model still knows an image was returned. */ -const MAX_TOOL_RESULT_IMAGE_BYTES = 1_000_000; /** * Maximum number of image attachments (top-level + tool-result combined) to @@ -632,18 +616,6 @@ const MAX_TOOL_RESULT_IMAGE_BYTES = 1_000_000; * still knows a screenshot existed at that point in the conversation (useful * for understanding agent-loop context) without incurring the payload cost. */ -const MAX_HISTORY_IMAGES_KEPT = 2; - -// Models live on the OpenCode Zen gateway but with constrained GPU capacity. -// They were re-enabled by the OpenCode team after a brief shutdown -// ("Qwen 3.6 Plus — free, again. Round 2. We found more GPUs.") so they are -// NOT deprecated, but agentic workloads with long histories or large tool -// catalogs can still hit 5xx during traffic bursts. Surface this so users know -// to retry or fall back to another free model if the request fails. -const CAPACITY_LIMITED_MODEL_NOTES: Record = { - "qwen3.6-plus-free": - "Free relaunch with limited GPU capacity. Stable for short prompts; bursty traffic or very large tool catalogs may return 5xx - retry or fall back to 'deepseek-v4-flash-free' / 'big-pickle'. Paid 'qwen3.6-plus' has no quota.", -}; let modelMetadataSnapshot: CachedModelMetadataSnapshot | undefined; let modelMetadataRefreshPromise: Promise | undefined; @@ -718,8 +690,6 @@ interface AnthropicRequestMessage { content: AnthropicContentBlock[]; } -const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - interface RecentTransportSummary extends TransportRequestSummary { recordedAt: string; endpointKind: string; @@ -779,12 +749,12 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("opencodego.diagnostics", () => goProvider.showDiagnostics()), vscode.commands.registerCommand("opencodego.setApiKey", () => goProvider.setApiKey()), vscode.commands.registerCommand("opencodego.refreshModels", () => goProvider.refreshModels()), - vscode.commands.registerCommand("opencodego.toggleProvider", () => toggleProviderEnabled("opencodego", "OpenCode Go")), + vscode.commands.registerCommand("opencodego.toggleProvider", () => toggleProviderEnabled(GO_VENDOR, "OpenCode Go")), vscode.commands.registerCommand("opencodego.configureUtilityModels", () => configureUtilityModels()), vscode.commands.registerCommand("opencodezen.diagnostics", () => zenProvider.showDiagnostics()), vscode.commands.registerCommand("opencodezen.manage", () => zenProvider.manage()), vscode.commands.registerCommand("opencodezen.refreshModels", () => zenProvider.refreshModels()), - vscode.commands.registerCommand("opencodezen.toggleProvider", () => toggleProviderEnabled("opencodezen", "OpenCode Zen")), + vscode.commands.registerCommand("opencodezen.toggleProvider", () => toggleProviderEnabled(ZEN_VENDOR, "OpenCode Zen")), vscode.commands.registerCommand("opencodego.modelPickerDiagnostics", () => showModelPickerDiagnostics()), vscode.commands.registerCommand("opencodego.setThinkingEffort", () => showThinkingEffortPicker()), vscode.commands.registerCommand("opencodego.showUsageDetails", () => { @@ -812,7 +782,7 @@ export function activate(context: vscode.ExtensionContext) { const sessionItem: vscode.QuickPickItem = { label: `$(comment) Latest Session (est)`, description: `$${sessionCost.cost.toFixed(4)}`, - detail: `${tokens(totalTokens)} tokens · ${String(sessionCost.requests)} requests`, + detail: `${formatTokenCount(totalTokens)} tokens · ${String(sessionCost.requests)} requests`, alwaysShow: true, }; const dailyIdx = items.findIndex((i) => i.kind === vscode.QuickPickItemKind.Separator && i.label === "Daily Summary"); @@ -956,7 +926,7 @@ export function activate(context: vscode.ExtensionContext) { ]; // Agent-host providers for the Copilot Agents window (opt-in via config). - const enableAgents = vscode.workspace.getConfiguration("opencodego").get("agentsWindow", true); + const enableAgents = vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_AGENTS_WINDOW, true); if (enableAgents && (goProviderEnabled || zenProviderEnabled)) { const agentGoProvider = new OpenCodeProvider(context, PROVIDERS[AGENT_GO_VENDOR]); const agentZenProvider = new OpenCodeProvider(context, PROVIDERS[AGENT_ZEN_VENDOR]); @@ -976,17 +946,20 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("opencodego.showUsageStatusBar")) { + if (event.affectsConfiguration(`${CONFIG_SECTION}.${SETTING_SHOW_USAGE_STATUS_BAR}`)) { resetUsageStatusBar(); } - if (event.affectsConfiguration("opencodego.showProviderPrefix")) { + if (event.affectsConfiguration(`${CONFIG_SECTION}.${SETTING_SHOW_PROVIDER_PREFIX}`)) { for (const provider of modelInfoProviders) { provider.notifyModelInfoChanged(); } } - if (event.affectsConfiguration("opencodego.agentsWindow") || event.affectsConfiguration("opencodego.autoEnableAgentsWindow")) { - const agentsWindowEnabled = vscode.workspace.getConfiguration("opencodego").get("agentsWindow", true); - const autoEnabled = vscode.workspace.getConfiguration("opencodego").get("autoEnableAgentsWindow", true); + if ( + event.affectsConfiguration(`${CONFIG_SECTION}.${SETTING_AGENTS_WINDOW}`) || + event.affectsConfiguration(`${CONFIG_SECTION}.${SETTING_AUTO_ENABLE_AGENTS_WINDOW}`) + ) { + const agentsWindowEnabled = vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_AGENTS_WINDOW, true); + const autoEnabled = vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_AUTO_ENABLE_AGENTS_WINDOW, true); if (agentsWindowEnabled && autoEnabled) { void ensureAgentsWindowSupport(context); } else if (!agentsWindowEnabled) { @@ -1029,7 +1002,7 @@ async function configureUtilityModels(): Promise { */ async function toggleProviderEnabled(vendor: string, displayName: string): Promise { const cfg = vscode.workspace.getConfiguration(vendor); - const current = cfg.get("enabled", true); + const current = cfg.get(SETTING_ENABLED, true); const next = !current; await cfg.update("enabled", next, vscode.ConfigurationTarget.Global); @@ -1086,8 +1059,8 @@ function isModernAgentHostVscode(): boolean { * anything was changed. */ async function ensureAgentsWindowSupport(context: vscode.ExtensionContext): Promise { - const opencodeCfg = vscode.workspace.getConfiguration("opencodego"); - if (!opencodeCfg.get("agentsWindow", true) || !opencodeCfg.get("autoEnableAgentsWindow", true)) { + const opencodeCfg = vscode.workspace.getConfiguration(CONFIG_SECTION); + if (!opencodeCfg.get(SETTING_AGENTS_WINDOW, true) || !opencodeCfg.get(SETTING_AUTO_ENABLE_AGENTS_WINDOW, true)) { return; } @@ -1153,7 +1126,7 @@ async function warmModelPickerMetadata(): Promise { ...(vscode.workspace.getConfiguration().get(providerEnabledSetting(GO_VENDOR), true) ? [GO_VENDOR] : []), ...(vscode.workspace.getConfiguration().get(providerEnabledSetting(ZEN_VENDOR), true) ? [ZEN_VENDOR] : []), ]; - if (vscode.workspace.getConfiguration("opencodego").get("agentsWindow", true) && vendors.length > 0) { + if (vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_AGENTS_WINDOW, true) && vendors.length > 0) { vendors.push(AGENT_GO_VENDOR, AGENT_ZEN_VENDOR); } await Promise.allSettled(vendors.map((v) => vscode.lm.selectChatModels({ vendor: v }))); @@ -1161,7 +1134,7 @@ async function warmModelPickerMetadata(): Promise { async function showModelPickerDiagnostics(): Promise { const vendors: string[] = [GO_VENDOR, ZEN_VENDOR, "copilot"]; - if (vscode.workspace.getConfiguration("opencodego").get("agentsWindow", true)) { + if (vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_AGENTS_WINDOW, true)) { vendors.splice(2, 0, AGENT_GO_VENDOR, AGENT_ZEN_VENDOR); } const sections: string[] = []; @@ -1217,7 +1190,7 @@ async function showThinkingEffortPicker(): Promise { placeHolder: `Set ${family.family.label} → Thinking value`, }); if (!choice) return; - const cfg = vscode.workspace.getConfiguration("opencodego.thinking"); + const cfg = vscode.workspace.getConfiguration(`${CONFIG_SECTION}.thinking`); await cfg.update(family.family.key, choice, vscode.ConfigurationTarget.Global); vscode.window.showInformationMessage(`OpenCode Thinking — ${family.family.label}: ${choice}`); } @@ -1237,7 +1210,7 @@ function ensureUsageStatusBar(context: vscode.ExtensionContext): vscode.StatusBa } function shouldShowUsageStatusBar(): boolean { - return vscode.workspace.getConfiguration("opencodego").get("showUsageStatusBar", true); + return vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_SHOW_USAGE_STATUS_BAR, true); } function resetUsageStatusBar(): void { @@ -1364,7 +1337,7 @@ function updateWebviewContent(): void { - OpenCode Usage Summary — ${escapeSvg(profileLabel)} + OpenCode Usage Summary — ${escapeHtml(profileLabel)} + + +
+
+
OC
+
${escapeHtml(profileLabel)} — Usage
+
opencode-copilot-chat · last ${String(chartDays)} days
+
+
+ +
+ +
+
+
+ + + + +
+
+
+
+ +
+
+ +
+ + + `; } -/** One meter section: label + resets, bar, used + percent. */ -function usageCardSectionHtml(label: string, p: _UsageSummary["session"]): string { - const pct = p.percent.toFixed(1); - const width = Math.min(Math.max(p.percent, 0), 100); - return [ - '
', - '
', - `${escapeHtml(label)}`, - `Resets in ${escapeHtml(formatRelativeTime(p.resetsAt))}`, - "
", - `
`, - '
', - `${escapeHtml(`${formatUsd(p.spent)} / ${formatUsd(p.limit)} used`)}`, - `${pct}%`, - "
", - "
", - ].join(""); -} - -/** One stats row: three label-over-value columns. */ -function usageCardStatsHtml(label: string, day: _UsageSummary["today"]): string { - return [ - '
', - `
${escapeHtml(label)}
${escapeHtml(formatUsd(day.cost))}
`, - `
Requests
${formatCount(day.requests)}
`, - `
Tokens
${escapeHtml(formatTokenCount(day.tokens))}
`, - "
", - ].join(""); -} - function buildUsageTooltip(s: ReturnType): vscode.MarkdownString { const md = new vscode.MarkdownString("", true); md.supportHtml = true; @@ -1766,7 +2078,7 @@ ${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", // stable regardless of whether a session is currently active. const deviceRows: Array<[string, number, number, number, number]> = []; if (usageCodebaseRowVisible()) { - deviceRows.push(["Codebase:", s.codebase.cost, s.codebase.requests, s.codebase.tokens, firstRowY]); + deviceRows.push(["Total spend:", s.codebase.cost, s.codebase.requests, s.codebase.tokens, firstRowY]); } const codebaseOffset = usageCodebaseRowVisible() ? 1 : 0; deviceRows.push(["Today:", s.today.cost, s.today.requests, s.today.tokens, firstRowY + codebaseOffset * rowGap]); diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index 75eb549..bf52e55 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -310,7 +310,8 @@ const HISTORY_ROWS_SQL = ` CAST(json_extract(data, '$.tokens.output') AS INTEGER) AS tokensOutput, CAST(json_extract(data, '$.tokens.reasoning') AS INTEGER) AS tokensReasoning, CAST(json_extract(data, '$.tokens.cache.read') AS INTEGER) AS tokensCacheRead, - json_extract(data, '$.path.cwd') AS cwd + json_extract(data, '$.path.cwd') AS cwd, + json_extract(data, '$.modelID') AS modelId FROM message WHERE json_valid(data) AND json_extract(data, '$.providerID') = 'opencode-go' @@ -327,6 +328,8 @@ export interface HistoryRow { tokensCacheRead: number; /** Working directory of the session the message belongs to (OpenCode CLI data). */ cwd?: string; + /** Model that produced the message (OpenCode CLI data). */ + modelId?: string; } /** Non-negative finite integer (tokens can legitimately be 0). */ @@ -378,6 +381,96 @@ export interface UsageDaily { tokens: number; } +/** One day bucket of the usage chart. */ +export interface UsageDayPoint { + /** Unix ms at the START of the day (UTC or local, per the day-boundary setting). */ + dayStart: number; + cost: number; + tokens: number; + requests: number; +} + +/** Per-model usage for a single day (model bar chart). */ +export interface ModelDayUsage { + model: string; + dayStart: number; + cost: number; + tokens: number; + requests: number; +} + +/** Time-series data for the usage panel charts. */ +export interface UsageSeries { + /** Daily totals, oldest → newest. */ + days: UsageDayPoint[]; + /** Per-model-per-day rows (only days with usage are present). */ + byModel: ModelDayUsage[]; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Bucket CLI rows + extension entries into per-day totals and per-model + * per-day rows over the last `days` days (the oldest bucket starts at + * `dayStartMs - (days - 1) * DAY_MS`). Pure so it can be unit-tested. + */ +export function buildUsageSeries( + rows: HistoryRow[], + entries: UsageLogEntry[], + days: number, + dayStartMs: number, + source: UsageTodayYesterdaySource = "auto", +): UsageSeries { + const dayCount = Math.max(1, Math.floor(days)); + const firstDay = dayStartMs - (dayCount - 1) * DAY_MS; + const buckets: UsageDayPoint[] = Array.from({ length: dayCount }, (_, i) => ({ + dayStart: firstDay + i * DAY_MS, + cost: 0, + tokens: 0, + requests: 0, + })); + const byModel = new Map>(); + + const add = (model: string | undefined, timestamp: number, cost: number, tokens: number): void => { + const index = Math.floor((timestamp - firstDay) / DAY_MS); + if (index < 0 || index >= dayCount) return; + const day = buckets[index]; + day.cost += cost; + day.tokens += tokens; + day.requests += 1; + + const modelName = model ?? "unknown"; + let byDay = byModel.get(modelName); + if (!byDay) { + byDay = new Map(); + byModel.set(modelName, byDay); + } + const point = byDay.get(index) ?? { model: modelName, dayStart: day.dayStart, cost: 0, tokens: 0, requests: 0 }; + point.cost += cost; + point.tokens += tokens; + point.requests += 1; + byDay.set(index, point); + }; + + if (source !== "extension") { + for (const row of rows) { + add(row.modelId, row.createdMs, row.cost, row.tokensInput + row.tokensOutput + row.tokensReasoning); + } + } + if (source !== "cli") { + for (const entry of entries) { + add(entry.modelId, entry.timestamp, entry.cost, entry.promptTokens + entry.completionTokens); + } + } + + return { + days: buckets, + byModel: [...byModel.entries()].flatMap(([, byDay]) => + [...byDay.entries()].sort((left, right) => left[0] - right[0]).map(([, point]) => point), + ), + }; +} + /** * The CLI database can be gigabytes large and spawning `sqlite3` is a * synchronous, blocking call — but the usage UI (status bar, tooltip, panel, @@ -424,6 +517,7 @@ function readOpenCodeHistoryUncached(): HistoryRow[] | null { tokensReasoning: positiveNumberish(row.tokensReasoning), tokensCacheRead: positiveNumberish(row.tokensCacheRead), cwd: typeof row.cwd === "string" && row.cwd.trim() ? row.cwd : undefined, + modelId: typeof row.modelId === "string" && row.modelId.trim() ? row.modelId : undefined, })); } catch { return null; @@ -644,6 +738,16 @@ export class GoUsageTracker { return sumDailyUsage(rows, this.entries, dayStartMs, this.todayYesterdaySource()); } + /** + * Time-series data for the usage panel: per-day totals and per-model + * per-day rows over the last `days` days. + */ + getUsageSeries(days: number): UsageSeries { + const nowMs = Date.now(); + const rows = readOpenCodeHistory() ?? []; + return buildUsageSeries(rows, this.entries, days, this.dayStartMs(nowMs), this.todayYesterdaySource()); + } + /** * All-time usage in the CURRENT workspace, derived from the OpenCode CLI * history (`path.cwd` of each session's messages). "Forever" by default — diff --git a/src/test/goUsageTracker.test.ts b/src/test/goUsageTracker.test.ts index 9b84bef..de0aca4 100644 --- a/src/test/goUsageTracker.test.ts +++ b/src/test/goUsageTracker.test.ts @@ -16,6 +16,7 @@ import { } from "../config.js"; import type { GoUsageApiResponse } from "../goUsageSync"; import type { HistoryRow, UsageDaily, UsageLogEntry, UsageSummary } from "../goUsageTracker.js"; +import type { UsageSeries } from "../goUsageTracker.js"; // ── Types (populated by dynamic import in before()) ──────────────────────── @@ -29,6 +30,13 @@ let estimateCost: ( ) => number; let sumDailyUsage: (rows: HistoryRow[], entries: UsageLogEntry[], dayStartMs: number, source?: "auto" | "cli" | "extension") => UsageDaily; +let buildUsageSeries: ( + rows: HistoryRow[], + entries: UsageLogEntry[], + days: number, + dayStartMs: number, + source?: "auto" | "cli" | "extension", +) => UsageSeries; let isCwdInWorkspace: (cwd: string | undefined, workspaceFolders: readonly string[]) => boolean; let normalizeCwd: (value: string) => string; let startOfLocalDay: (nowMs: number) => number; @@ -148,6 +156,7 @@ describe("goUsageTracker", () => { estimateCost = mod.estimateCost; GoUsageTracker = mod.GoUsageTracker as GoUsageTrackerConstructor; sumDailyUsage = mod.sumDailyUsage; + buildUsageSeries = mod.buildUsageSeries; isCwdInWorkspace = mod.isCwdInWorkspace; normalizeCwd = mod.normalizeCwd; startOfLocalDay = mod.startOfLocalDay; @@ -724,3 +733,95 @@ describe("server usage snapshot persistence", () => { assert.ok(summary.weekly.limit > 0); }); }); + +describe("buildUsageSeries", () => { + const now = new Date(); + const dayMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const DAY = 24 * 60 * 60 * 1000; + + const rows: HistoryRow[] = [ + { + createdMs: dayMs - DAY, + cost: 0.1, + tokensInput: 100, + tokensOutput: 50, + tokensReasoning: 0, + tokensCacheRead: 0, + cwd: "/repo", + modelId: "qwen3.6-plus", + }, + { + createdMs: dayMs - DAY + 1000, + cost: 0.2, + tokensInput: 200, + tokensOutput: 100, + tokensReasoning: 0, + tokensCacheRead: 0, + cwd: "/repo", + modelId: "deepseek-v4-flash", + }, + { + createdMs: dayMs, + cost: 0.3, + tokensInput: 300, + tokensOutput: 150, + tokensReasoning: 0, + tokensCacheRead: 0, + cwd: "/repo", + modelId: "qwen3.6-plus", + }, + { + createdMs: dayMs + DAY * 5, + cost: 0.4, + tokensInput: 400, + tokensOutput: 200, + tokensReasoning: 0, + tokensCacheRead: 0, + cwd: "/repo", + modelId: "qwen3.6-plus", + }, + ]; + const entries: UsageLogEntry[] = [ + { timestamp: dayMs, modelId: "glm-5", cost: 0.05, promptTokens: 30, completionTokens: 10, cachedTokens: 0, sessionId: "s1" }, + ]; + + it("buckets rows and entries into per-day totals over the window", () => { + const series = buildUsageSeries(rows, entries, 14, dayMs, "auto"); + assert.equal(series.days.length, 14); + const oldest = series.days[0]; // oldest bucket = dayMs - 13*DAY + assert.equal(oldest.dayStart, dayMs - 13 * DAY); + assert.equal(oldest.cost, 0, "day before any usage stays zero"); + + const yesterday = series.days[13 - 1]; + assert.equal(yesterday.requests, 2); + assert.ok(Math.abs(yesterday.cost - 0.3) < 1e-9); + assert.equal(yesterday.tokens, 450); + + const today = series.days[13]; + assert.equal(today.requests, 2, "row + entry on the last day"); + assert.equal(today.tokens, 450 + 40); + }); + + it("excludes rows outside the window", () => { + const series = buildUsageSeries(rows, [], 3, dayMs, "cli"); + // A 3-day window ending at dayMs covers dayMs-2*DAY .. dayMs; the + // dayMs + DAY*5 row is outside and must be excluded. + const total = series.days.reduce((sum, d) => sum + d.requests, 0); + assert.equal(total, 3, "three rows are inside the 3-day window, the future one is not"); + }); + + it("groups per-model per-day rows with correct totals", () => { + const series = buildUsageSeries(rows, entries, 14, dayMs, "auto"); + const qwen = series.byModel.filter((p) => p.model === "qwen3.6-plus"); + assert.equal(qwen.length, 2, "the dayMs + DAY*5 row is outside the 14-day window ending at dayMs"); + const qwenToday = qwen.find((p) => p.dayStart === dayMs); + assert.equal(qwenToday?.cost, 0.3); + const glm = series.byModel.find((p) => p.model === "glm-5"); + assert.equal(glm?.requests, 1); + }); + + it("cli source ignores extension entries", () => { + const series = buildUsageSeries(rows, entries, 14, dayMs, "cli"); + assert.ok(!series.byModel.some((p) => p.model === "glm-5")); + }); +}); From 7e53726635239b77f14bc4b162763629bf24c76e Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 11:53:10 +0500 Subject: [PATCH 31/43] feat(usage): top-right action buttons, model line charts, refresh command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Set targets / Rename links become styled buttons in the panel's top-right corner, joined by a new Refresh button (opencodego.refreshUsage command — instant manual refresh; the 'auto-refreshes every Ns' note is removed, the background loop still runs). - By-model tab is now a line chart too: overlapping colored lines per model (spend over the chart window) with a ranked legend and hover tooltips per model · day. - Keyboard shortcut removed; 'last N days' dropped from the brand subtitle. - Webview wires the legend element and clears it on the other tabs. --- CHANGELOG.md | 2 +- package.json | 8 --- src/extension.ts | 133 ++++++++++++++++++++++++++++++----------------- 3 files changed, 86 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a332c..ddba6d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Fixed -- **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Total spend, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (horizontal bars stacked per day — hover any segment for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is renamed to **Total spend** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Open the panel instantly with `Ctrl+Shift+Alt+U` (`Cmd+Shift+Alt+U` on macOS) or the existing "Open full usage panel" quick-pick action. +- **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Total spend, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (overlapping colored line charts per model by spend, with a legend and hover tooltips for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is renamed to **Total spend** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Styled action buttons sit in the panel's top-right corner: **Set targets**, **Rename** (when multiple profiles exist) and **Refresh** (instant manual refresh — the auto-refresh note is gone; the background loop still runs at `usageRefreshIntervalSeconds`). - **`[Usage]` Realtime updates.** The status bar, tooltip and usage panel now refresh on a configurable background cadence (`opencodego.usageRefreshIntervalSeconds`, default 60s, min 5s), so terminal-side OpenCode CLI usage, server meters and midnight day rollovers appear without waiting for the next chat request. Changing any usage-view setting repaints the UI immediately, and the refresh interval itself applies live on the next tick. diff --git a/package.json b/package.json index 6d4436f..6c68d23 100644 --- a/package.json +++ b/package.json @@ -61,14 +61,6 @@ ], "main": "./out/extension.js", "contributes": { - "keybindings": [ - { - "command": "opencodego.showUsageDetails", - "key": "ctrl+shift+alt+u", - "mac": "cmd+shift+alt+u", - "when": "!inQuickOpen" - } - ], "commands": [ { "command": "opencodego.manage", diff --git a/src/extension.ts b/src/extension.ts index 3332d47..b9a2d51 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -861,6 +861,9 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("opencodego.showUsageDetails", () => { showUsageWebview(context); }), + vscode.commands.registerCommand("opencodego.refreshUsage", () => { + refreshGoUsageStatusBar(); + }), vscode.commands.registerCommand("opencodego.setUsageTargets", async () => { const tracker = activeGoUsageTracker(); if (!tracker) return; @@ -1514,6 +1517,7 @@ function updateWebviewContent(): void { *{ min-width:0; min-height:0; } .topbar{ grid-area:topbar; display:flex; align-items:center; gap: var(--s); } + .topbar .actions{ margin-left:auto; display:flex; gap: calc(var(--s)/2); flex:none; } .brand{ display:flex; align-items:center; gap: clamp(6px,1vw,10px); min-width:0; } .brand .mark{ width: clamp(20px, 2.4vh, 28px); height: clamp(20px, 2.4vh, 28px); @@ -1581,10 +1585,21 @@ function updateWebviewContent(): void { .ttip b{ color:var(--text-hi); } .ttip .t-sub{ color:var(--text-mid); font-size:10px; margin-top:2px; } - .foot{ display:flex; gap: var(--s); flex:none; align-items:center; } - .foot a{ color:var(--text-mid); font-size: clamp(9.5px, 1.1vh, 11px); text-decoration:none; cursor:pointer; } - .foot a:hover{ color:var(--text-hi); text-decoration:underline; } - .foot .spacer{ flex:1; } + .btn{ + background:var(--bg-2); border:1px solid var(--line); color:var(--text-mid); + font-family:var(--sans); font-size: clamp(9.5px, 1.1vh, 11px); font-weight:600; + padding: clamp(5px, 0.8vh, 8px) clamp(10px, 1.4vh, 14px); + border-radius: calc(var(--radius) - 4px); text-decoration:none; cursor:pointer; + white-space:nowrap; transition: background .15s ease, color .15s ease, border-color .15s ease; + } + .btn:hover{ background:var(--bg-3); color:var(--text-hi); border-color:var(--text-lo); } + .btn.primary{ color:var(--text-hi); border-color:var(--line); } + .btn.primary:hover{ border-color:var(--amber); } + + .legend{ display:flex; gap: var(--s); flex-wrap:wrap; flex:none; align-items:center; } + .legend .l-item{ display:flex; align-items:center; gap:5px; font-size: clamp(9px, 1.05vh, 11px); color:var(--text-mid); white-space:nowrap; } + .legend .l-swatch{ width:14px; height:3px; border-radius:2px; flex:none; } + .legend .l-more{ color:var(--text-lo); font-size: clamp(9px, 1.05vh, 11px); } @media (max-width: 620px){ .rings-row{ flex-wrap:wrap; } @@ -1601,7 +1616,12 @@ function updateWebviewContent(): void {
OC
${escapeHtml(profileLabel)} — Usage
-
opencode-copilot-chat · last ${String(chartDays)} days
+
opencode-copilot-chat
+
+
+ Set targets + ${nonLegacyCount(profilesCache) > 0 ? 'Rename' : ""} + Refresh
@@ -1617,16 +1637,11 @@ function updateWebviewContent(): void {
+
- @@ -1755,13 +1770,13 @@ function updateWebviewContent(): void { svg.appendChild(el('line', { x1: padL, y1: padT + plotH, x2: W - padR, y2: padT + plotH, stroke: '#242a33', 'stroke-width': '1' })); } - function drawModels() { + function drawModelLines() { var W = box.clientWidth, H = box.clientHeight; if (!W || !H) return; svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); clear(); - // per-model totals (spend) to rank bars; segments = days + // per-model daily spend, ranked by total var totals = {}; var perModel = {}; DATA.byModel.forEach(function (p) { @@ -1773,46 +1788,67 @@ function updateWebviewContent(): void { svg.appendChild(el('text', { x: W / 2, y: H / 2, 'text-anchor': 'middle', fill: '#5b6472', 'font-size': '11' }, 'No usage in the last ' + DATA.windowDays + ' days')); return; } - var dayIndex = {}; - DATA.days.forEach(function (d, i) { dayIndex[d.dayStart] = i; }); - var dayCount = DATA.days.length; - var maxTotal = niceMax(Math.max.apply(null, models.map(function (m) { return totals[m]; })) * 1.15); - - var labelW = Math.min(W * 0.34, 160); - var valueW = 56; - var barX = labelW + 12; - var barW = Math.max(20, W - barX - valueW); - var rowH = H / models.length; - - models.forEach(function (model, i) { - var cy = rowH * i + rowH / 2; - var barH = Math.min(rowH * 0.46, 18); - var color = MODEL_COLORS[i % MODEL_COLORS.length]; - svg.appendChild(el('text', { x: labelW, y: cy + 3, 'text-anchor': 'end', fill: '#eef1f5', 'font-size': '10' }, model)); - svg.appendChild(el('rect', { x: barX, y: cy - barH / 2, width: barW, height: barH, rx: 4, fill: 'rgba(255,255,255,0.05)' })); + var series = models.map(function (model) { + return { + model: model, + total: totals[model], + values: DATA.days.map(function (d) { + var hit = null; + (perModel[model] || []).forEach(function (p) { if (p.dayStart === d.dayStart) hit = p; }); + return hit ? hit : { cost: 0, tokens: 0, requests: 0, dayStart: d.dayStart }; + }), + }; + }); + var maxVal = niceMax(Math.max.apply(null, series.map(function (s) { return s.total; })) * 1.15); + + var padL = 44, padR = 10, padT = 12, padB = 24; + var plotW = Math.max(1, W - padL - padR), plotH = Math.max(1, H - padT - padB); + var n = DATA.days.length; + var step = n > 1 ? plotW / (n - 1) : plotW; - // stacked segments, oldest day at the left - var days = perModel[model].slice().sort(function (a, b) { return a.dayStart - b.dayStart; }); - var x = barX; - days.forEach(function (p) { - var segW = Math.max(1, (p.cost / maxTotal) * barW); - var seg = el('rect', { x: x, y: cy - barH / 2, width: segW, height: barH, rx: 2, fill: color, 'fill-opacity': String(0.35 + 0.6 * ((dayIndex[p.dayStart] + 1) / dayCount)) }); - seg.addEventListener('mousemove', function (ev) { + var bands = 4; + for (var i = 0; i <= bands; i++) { + var gy = padT + (plotH * i / bands); + var v = maxVal * (1 - i / bands); + svg.appendChild(el('line', { x1: padL, y1: gy, x2: W - padR, y2: gy, stroke: 'rgba(255,255,255,0.06)', 'stroke-width': '1' })); + svg.appendChild(el('text', { x: padL - 8, y: gy + 3, 'text-anchor': 'end', fill: '#5b6472', 'font-size': '9' }, fmtUsd(v))); + } + var labelEvery = Math.max(1, Math.ceil(n / Math.max(3, Math.floor(plotW / 46)))); + DATA.days.forEach(function (d, i) { + if (i % labelEvery !== 0 && i !== n - 1) return; + svg.appendChild(el('text', { x: padL + step * i, y: H - 6, 'text-anchor': 'middle', fill: '#5b6472', 'font-size': '9' }, dayLabel(d.dayStart))); + }); + + // one overlapping line per model (spend), colored per model + series.forEach(function (s, si) { + var color = MODEL_COLORS[si % MODEL_COLORS.length]; + var pts = s.values.map(function (p, i) { + return { x: padL + step * i, y: padT + plotH - (p.cost / maxVal) * plotH, p: p }; + }); + var d = pts.map(function (p, i) { return (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1); }).join(' '); + svg.appendChild(el('path', { d: d, fill: 'none', stroke: color, 'stroke-width': '1.6', 'stroke-linejoin': 'round', 'stroke-linecap': 'round' })); + pts.forEach(function (p, i) { + if (p.p.cost <= 0) return; + var c = el('circle', { cx: p.x, cy: p.y, r: '7', fill: 'transparent', stroke: 'none' }); + c.addEventListener('mousemove', function (ev) { showTooltip(ev.clientX, ev.clientY, - '' + model + ' · ' + dayLabel(p.dayStart) + '' + fmtUsd(p.cost) + '' + - '
' + fmtCount(p.tokens) + ' tokens · ' + fmtCount(p.requests) + ' requests
'); + '' + s.model + ' · ' + dayLabel(p.p.dayStart) + '' + fmtUsd(p.p.cost) + '' + + '
' + fmtCount(p.p.tokens) + ' tokens · ' + fmtCount(p.p.requests) + ' requests
'); }); - seg.addEventListener('mouseleave', hideTooltip); - svg.appendChild(seg); - x += segW; + c.addEventListener('mouseleave', hideTooltip); + svg.appendChild(c); }); - - var val = el('text', { x: barX + barW + 8, y: cy + 3, 'text-anchor': 'start', fill: '#9aa4b2', 'font-size': '10' }); - val.textContent = fmtUsd(totals[model]); - svg.appendChild(val); }); - svg.appendChild(el('text', { x: barX, y: H - 6, 'text-anchor': 'start', fill: '#5b6472', 'font-size': '9' }, 'Segments = days (hover for model · day breakdown)')); + + svg.appendChild(el('line', { x1: padL, y1: padT + plotH, x2: W - padR, y2: padT + plotH, stroke: '#242a33', 'stroke-width': '1' })); + + // legend (up to 8 models, ranked by total spend) + var legend = document.getElementById('legend'); + legend.innerHTML = series.slice(0, 8).map(function (s, i) { + var color = MODEL_COLORS[i % MODEL_COLORS.length]; + return '' + s.model + ''; + }).join('') + (series.length > 8 ? '+' + (series.length - 8) + ' more' : ''); } function render(key) { @@ -1822,9 +1858,10 @@ function updateWebviewContent(): void { chips.innerHTML = '
Total spend
' + fmtUsd(metricTotal('spend')) + '
' + '
Window
' + DATA.windowDays + ' days
'; - drawModels(); + drawModelLines(); return; } + document.getElementById('legend').innerHTML = ''; var st = DATA.stats; var totalKey = key === 'spend' ? 'total' : key; chips.innerHTML = From 5b6f0a258d00db7e699a45c75439649c6a4ee353 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 12:05:47 +0500 Subject: [PATCH 32/43] fix(usage): working buttons, tab-preserving refresh, clean axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Buttons now talk to the extension via vscode.postMessage (command-URI links were unreliable in the sandboxed webview): Set targets / Rename / Refresh all work. - Refresh no longer reloads the whole page: the first render ships the template once, later refreshes push data through postMessage and the active tab + chart stay in place (rings re-render too). - Extension name removed from the top bar; the brand shows only the profile name. - Legend swatches are now square, sized to the text height, with proper spacing from the left edge. - Y axes use round tick steps (1/2/2.5/5 x 10^n) with the max rounded up to a step multiple — equal, logical gaps everywhere (spend and models pages included; no more 1.25/2.50/3.75 divisions). --- CHANGELOG.md | 2 +- src/extension.ts | 187 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 138 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddba6d0..8f023c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Fixed -- **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Total spend, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (overlapping colored line charts per model by spend, with a legend and hover tooltips for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is renamed to **Total spend** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Styled action buttons sit in the panel's top-right corner: **Set targets**, **Rename** (when multiple profiles exist) and **Refresh** (instant manual refresh — the auto-refresh note is gone; the background loop still runs at `usageRefreshIntervalSeconds`). +- **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Total spend, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (overlapping colored line charts per model by spend, with a legend and hover tooltips for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is renamed to **Total spend** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Styled action buttons sit in the panel's top-right corner: **Set targets**, **Rename** (when multiple profiles exist) and **Refresh** — wired through `vscode.postMessage`, and a refresh keeps the active tab (data is pushed in place, the page is never reloaded; the background loop still runs at `usageRefreshIntervalSeconds`). Chart axes use round tick steps (1/2/2.5/5 × 10ⁿ) so gaps are always equal and clean (no more 1.25/2.50/3.75 divisions). - **`[Usage]` Realtime updates.** The status bar, tooltip and usage panel now refresh on a configurable background cadence (`opencodego.usageRefreshIntervalSeconds`, default 60s, min 5s), so terminal-side OpenCode CLI usage, server meters and midnight day rollovers appear without waiting for the next chat request. Changing any usage-view setting repaints the UI immediately, and the refresh interval itself applies live on the next tick. diff --git a/src/extension.ts b/src/extension.ts index b9a2d51..899dc28 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1413,11 +1413,31 @@ function showUsageWebview(context: vscode.ExtensionContext): void { usageWebviewPanel.onDidDispose( () => { usageWebviewPanel = undefined; + usageWebviewRendered = false; }, null, context.subscriptions, ); + usageWebviewPanel.webview.onDidReceiveMessage( + (message: { type?: string }) => { + switch (message.type) { + case "refresh": + refreshGoUsageStatusBar(); + break; + case "setTargets": + void vscode.commands.executeCommand("opencodego.setUsageTargets"); + break; + case "renameProfile": + void vscode.commands.executeCommand("opencodego.renameActiveProfile"); + break; + } + }, + null, + context.subscriptions, + ); + + usageWebviewRendered = false; updateWebviewContent(); } @@ -1426,18 +1446,18 @@ function jsonForWebview(value: unknown): string { return JSON.stringify(value).replace(/<\//g, "<\\/"); } -function updateWebviewContent(): void { - if (!usageWebviewPanel || !goUsageTracker) return; +/** Whether the usage webview has received its initial HTML (data flows via postMessage after that). */ +let usageWebviewRendered = false; + +/** Build the chart/stat payload shown by the usage webview. */ +function usageWebviewData(): Record | undefined { + if (!goUsageTracker) return undefined; const tracker = activeGoUsageTracker(); - if (!tracker) { - usageWebviewPanel.webview.html = `

No active tracker

`; - return; - } + if (!tracker) return undefined; const s = tracker.getSummary(); const chartDays = vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_USAGE_CHART_DAYS, DEFAULT_USAGE_CHART_DAYS); const series = tracker.getUsageSeries(chartDays); const activeProfile = findProfile(profilesCache, activeProfileFingerprint); - const profileLabel = activeProfile?.label ?? "OpenCode Go"; const showRolling = usageRollingMeterVisible(); const rings = [ @@ -1471,8 +1491,9 @@ function updateWebviewContent(): void { }, ]; - const data = { - profile: profileLabel, + return { + profile: activeProfile?.label ?? "OpenCode Go", + showRename: nonLegacyCount(profilesCache) > 0, rings, stats: { total: { label: "Total spend", cost: s.codebase.cost, tokens: s.codebase.tokens, requests: s.codebase.requests }, @@ -1483,8 +1504,29 @@ function updateWebviewContent(): void { byModel: series.byModel, windowDays: chartDays, }; +} - usageWebviewPanel.webview.html = ` +function updateWebviewContent(): void { + if (!usageWebviewPanel || !goUsageTracker) return; + const data = usageWebviewData(); + if (!data) { + usageWebviewPanel.webview.html = `

No active tracker

`; + usageWebviewRendered = false; + return; + } + + if (!usageWebviewRendered) { + // First paint: render the full page. Later refreshes only push new data + // via postMessage so the user's active tab and chart stay in place. + usageWebviewPanel.webview.html = usageWebviewHtml(String(data.profile)); + usageWebviewRendered = true; + } + void usageWebviewPanel.webview.postMessage({ type: "usage", data }); +} + +function usageWebviewHtml(profileLabel: string): string { + const data = usageWebviewData() ?? {}; + return ` @@ -1596,9 +1638,9 @@ function updateWebviewContent(): void { .btn.primary{ color:var(--text-hi); border-color:var(--line); } .btn.primary:hover{ border-color:var(--amber); } - .legend{ display:flex; gap: var(--s); flex-wrap:wrap; flex:none; align-items:center; } - .legend .l-item{ display:flex; align-items:center; gap:5px; font-size: clamp(9px, 1.05vh, 11px); color:var(--text-mid); white-space:nowrap; } - .legend .l-swatch{ width:14px; height:3px; border-radius:2px; flex:none; } + .legend{ display:flex; gap: clamp(10px, 1.4vh, 16px); flex-wrap:wrap; flex:none; align-items:center; padding: 2px 0 0 2px; } + .legend .l-item{ display:flex; align-items:center; gap:7px; font-size: clamp(10px, 1.2vh, 12px); line-height:1; color:var(--text-mid); white-space:nowrap; } + .legend .l-swatch{ width: clamp(10px, 1.2vh, 12px); height: clamp(10px, 1.2vh, 12px); border-radius:3px; flex:none; } .legend .l-more{ color:var(--text-lo); font-size: clamp(9px, 1.05vh, 11px); } @media (max-width: 620px){ @@ -1606,7 +1648,6 @@ function updateWebviewContent(): void { .ring-card{ flex:1 1 100%; } .panel-top{ flex-wrap:wrap; } .stat-chips{ display:none; } - .brand .sub{ display:none; } } @media (prefers-reduced-motion: reduce){ *{ transition:none !important; animation:none !important; } } @@ -1615,13 +1656,12 @@ function updateWebviewContent(): void {
OC
-
${escapeHtml(profileLabel)} — Usage
-
opencode-copilot-chat
+
${escapeHtml(profileLabel)} — Usage
- Set targets - ${nonLegacyCount(profilesCache) > 0 ? 'Rename' : ""} - Refresh + + +
@@ -1648,6 +1688,7 @@ function updateWebviewContent(): void { From c80125fb895f0f31fa4f1148cc4b247296aab150 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 12:19:05 +0500 Subject: [PATCH 33/43] feat(usage): completion charts, window toggle, whole-chart hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Two new tabs, Suggested and Approved: per-day inline chat-completion counters persisted in globalState (COMPLETION_USAGE_KEY). Suggestions are counted when the provider returns ghost text; acceptances via the runtime-guarded onDidChangeInlineCompletionItems API (didAccept) — pure helpers in autocomplete/usage.ts, unit-tested. - Window button (styled like the others) cycles Week -> 14 days -> Month -> Lifetime; charts re-render for the new window. buildUsageSeries and completionUsageToSeries support days=0 (lifetime, from earliest usage). The panel's Window stat chip is gone; usageChartDays setting allows 0. - Whole-chart hover: moving the cursor anywhere over a chart snaps to the nearest day with a guide line + dots and a cursor-follow tooltip; the By-model tab's tooltip lists every model's spend for that day. - Brand shows only the profile name (no '— Usage'); the OC mark centers its glyphs vertically; legend swatches are square and aligned to the text height; axes keep round equal tick steps. --- CHANGELOG.md | 2 + package.json | 6 +- src/autocomplete/index.ts | 34 +++++ src/autocomplete/provider.ts | 3 + src/autocomplete/usage.ts | 68 ++++++++++ src/config.ts | 6 +- src/extension.ts | 191 ++++++++++++++++++++++++----- src/goUsageTracker.ts | 24 +++- src/test/autocompleteUsage.test.ts | 59 +++++++++ src/test/goUsageTracker.test.ts | 10 ++ 10 files changed, 365 insertions(+), 38 deletions(-) create mode 100644 src/autocomplete/usage.ts create mode 100644 src/test/autocompleteUsage.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f023c6..a866168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Fixed +- **`[Usage]` Panel polish + chat-completion charts.** Hovering anywhere on a chart (not just on points) highlights the nearest day with a guide line + dots and a cursor-follow tooltip; on the By-model tab the tooltip lists every model's spend for that day. Two new tabs — **Suggested** and **Approved** — chart inline chat completions (suggestions shown by the provider and acceptances tracked via the guarded `onDidChangeInlineCompletionItems` API, persisted per day). A **Window** button (alongside Set targets / Rename / Refresh) cycles Week → 14 days → Month → Lifetime and re-renders every chart for the new window (lifetime spans the earliest recorded usage). The panel brand shows only the profile name, the legend swatches are square and text-aligned, and axes keep round equal tick steps. + - **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Total spend, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (overlapping colored line charts per model by spend, with a legend and hover tooltips for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is renamed to **Total spend** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Styled action buttons sit in the panel's top-right corner: **Set targets**, **Rename** (when multiple profiles exist) and **Refresh** — wired through `vscode.postMessage`, and a refresh keeps the active tab (data is pushed in place, the page is never reloaded; the background loop still runs at `usageRefreshIntervalSeconds`). Chart axes use round tick steps (1/2/2.5/5 × 10ⁿ) so gaps are always equal and clean (no more 1.25/2.50/3.75 divisions). - **`[Usage]` Realtime updates.** The status bar, tooltip and usage panel now refresh on a configurable background cadence (`opencodego.usageRefreshIntervalSeconds`, default 60s, min 5s), so terminal-side OpenCode CLI usage, server meters and midnight day rollovers appear without waiting for the next chat request. Changing any usage-view setting repaints the UI immediately, and the refresh interval itself applies live on the next tick. diff --git a/package.json b/package.json index 6c68d23..4171984 100644 --- a/package.json +++ b/package.json @@ -180,9 +180,9 @@ "opencodego.usageChartDays": { "type": "number", "default": 14, - "minimum": 7, - "maximum": 90, - "description": "How many days the usage panel charts cover (daily spend / requests / tokens and the per-model breakdown)." + "minimum": 0, + "maximum": 370, + "description": "How many days the usage panel charts cover (daily spend / requests / tokens, per-model and chat-completion breakdowns). 0 = lifetime. The panel's Window button toggles this live (Week / 14 days / Month / Lifetime)." }, "opencodego.usageDayBoundary": { "type": "string", diff --git a/src/autocomplete/index.ts b/src/autocomplete/index.ts index 78ddd5f..d2f6b96 100644 --- a/src/autocomplete/index.ts +++ b/src/autocomplete/index.ts @@ -12,6 +12,8 @@ import { ChatCompletionEngine } from "./engine"; import { OpenCodeInlineCompletionProvider } from "./provider"; import type { CompletionContext, CompletionEngine, CompletionResult } from "./types"; import { + COMPLETION_USAGE_KEY, + COMPLETION_USAGE_MAX_DAYS, CONFIG_SECTION, DEFAULT_INLINE_DEBOUNCE_MS, DEFAULT_INLINE_MAX_TOKENS, @@ -28,6 +30,7 @@ import { INLINE_TIMEOUT_MS_SETTING, } from "../config"; import { toFiniteNumber } from "../utils"; +import { bumpCompletionUsage, utcDayStart, type CompletionUsageDay } from "./usage"; export { INLINE_SUGGESTIONS_SETTING, @@ -50,6 +53,8 @@ export interface InlineCompletionsDeps { chatCompletionsUrl: string; /** Resolve the API key to use (extension secret / BYOK group key). */ resolveApiKey: () => Promise; + /** Day boundary for the completion counters (defaults to UTC). */ + resolveCompletionDayStart?: () => number; log?: (msg: string) => void; } @@ -69,6 +74,32 @@ export function registerInlineCompletions(context: vscode.ExtensionContext, deps output.appendLine(msg); }; + // Per-day suggestion/acceptance counters for the usage panel charts. + let completionUsage = context.globalState.get(COMPLETION_USAGE_KEY, []); + const dayStart = (): number => deps.resolveCompletionDayStart?.() ?? utcDayStart(Date.now()); + const recordCompletion = (kind: "suggested" | "approved"): void => { + completionUsage = bumpCompletionUsage(completionUsage, dayStart(), kind, COMPLETION_USAGE_MAX_DAYS); + void context.globalState.update(COMPLETION_USAGE_KEY, completionUsage); + }; + + // Acceptance tracking: `onDidChangeInlineCompletionItems` is not in the + // stable API types, so it is guarded at runtime — on hosts that have it, + // `didAccept` reports when the user accepted a ghost-text suggestion. + const onDidChangeInlineCompletionItems = ( + vscode.languages as unknown as { + onDidChangeInlineCompletionItems?: (listener: (event: { didAccept?: boolean }) => unknown) => vscode.Disposable; + } + ).onDidChangeInlineCompletionItems; + if (typeof onDidChangeInlineCompletionItems === "function") { + context.subscriptions.push( + onDidChangeInlineCompletionItems((event) => { + if (event.didAccept) { + recordCompletion("approved"); + } + }), + ); + } + const engine: CompletionEngine = { id: "chat-completions", async complete(ctx: CompletionContext, signal: AbortSignal): Promise { @@ -93,6 +124,9 @@ export function registerInlineCompletions(context: vscode.ExtensionContext, deps const provider = new OpenCodeInlineCompletionProvider({ engine, resolveApiKey: deps.resolveApiKey, + onSuggestion: () => { + recordCompletion("suggested"); + }, isEnabled: () => readSetting(INLINE_SUGGESTIONS_SETTING, false), resolveModelId: () => readSetting(INLINE_SUGGESTIONS_MODEL_SETTING, DEFAULT_INLINE_MODEL), resolveDebounceMs: () => readNumberSetting(INLINE_DEBOUNCE_MS_SETTING, DEFAULT_INLINE_DEBOUNCE_MS, 50, 2_000), diff --git a/src/autocomplete/provider.ts b/src/autocomplete/provider.ts index a534c5a..7119ee4 100644 --- a/src/autocomplete/provider.ts +++ b/src/autocomplete/provider.ts @@ -15,6 +15,8 @@ export interface InlineCompletionProviderOptions { engine: CompletionEngine; /** Resolve the API key (async; the caller owns caching/fallbacks). */ resolveApiKey: () => Promise; + /** Called once when a ghost-text suggestion is actually returned to VS Code. */ + onSuggestion?: () => void; /** Whether suggestions are currently enabled (config-driven). */ isEnabled: () => boolean; /** The model to use for suggestions (config-driven). */ @@ -104,6 +106,7 @@ export class OpenCodeInlineCompletionProvider implements vscode.InlineCompletion return; } finish([new vscode.InlineCompletionItem(result.text, new vscode.Range(position, position))]); + this.options.onSuggestion?.(); }); }); } diff --git a/src/autocomplete/usage.ts b/src/autocomplete/usage.ts new file mode 100644 index 0000000..7a646ee --- /dev/null +++ b/src/autocomplete/usage.ts @@ -0,0 +1,68 @@ +/** + * Per-day chat-completion usage counters (inline suggestions shown vs + * accepted), persisted in globalState. Pure logic so it is unit-testable. + */ + +export interface CompletionUsageDay { + /** Unix ms at the START of the day (same boundary as the usage charts). */ + dayStart: number; + /** Ghost-text suggestions the provider returned to VS Code. */ + suggested: number; + /** Suggestions the user accepted (only counted when the API reports it). */ + approved: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Increment one counter for the given day (mutates and returns `days`). */ +export function bumpCompletionUsage( + days: CompletionUsageDay[], + dayStart: number, + kind: "suggested" | "approved", + maxDays = 370, +): CompletionUsageDay[] { + let day = days.find((d) => d.dayStart === dayStart); + if (!day) { + day = { dayStart, suggested: 0, approved: 0 }; + days.push(day); + if (days.length > maxDays) { + days.splice(0, days.length - maxDays); + } + } + day[kind] += 1; + return days; +} + +/** Start of the UTC day (the default chart boundary). */ +export function utcDayStart(nowMs: number): number { + const d = new Date(nowMs); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +/** + * Align the stored per-day counters to a chart window. `windowDays` 0 = + * lifetime (from the earliest stored day to today). Days without counters + * are emitted as zeros so the line chart always spans the full window. + */ +export function completionUsageToSeries( + days: CompletionUsageDay[], + dayStartMs: number, + windowDays: number, +): { dayStart: number; suggested: number; approved: number }[] { + const byDay = new Map(days.map((d) => [d.dayStart, d])); + let firstDay: number; + if (windowDays > 0) { + firstDay = dayStartMs - (Math.max(1, Math.floor(windowDays)) - 1) * DAY_MS; + } else if (days.length > 0) { + const earliest = Math.min(...days.map((d) => d.dayStart)); + firstDay = dayStartMs - Math.ceil((dayStartMs - earliest) / DAY_MS) * DAY_MS; + } else { + firstDay = dayStartMs; + } + const count = Math.round((dayStartMs - firstDay) / DAY_MS) + 1; + return Array.from({ length: count }, (_, i) => { + const start = firstDay + i * DAY_MS; + const day = byDay.get(start); + return { dayStart: start, suggested: day?.suggested ?? 0, approved: day?.approved ?? 0 }; + }); +} diff --git a/src/config.ts b/src/config.ts index 24fad89..356580c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -184,9 +184,13 @@ export const DEFAULT_USAGE_DAY_BOUNDARY = "utc"; /** How often the usage status bar / panel refresh in the background (seconds). */ export const SETTING_USAGE_REFRESH_INTERVAL_SECONDS = "usageRefreshIntervalSeconds"; export const DEFAULT_USAGE_REFRESH_INTERVAL_SECONDS = 60; -/** How many days the usage panel charts cover. */ +/** How many days the usage panel charts cover (0 = lifetime). */ export const SETTING_USAGE_CHART_DAYS = "usageChartDays"; export const DEFAULT_USAGE_CHART_DAYS = 14; +/** globalState key for the per-day chat-completion usage counters. */ +export const COMPLETION_USAGE_KEY = "opencodego.completionUsage.v1"; +/** How many days of completion history are retained. */ +export const COMPLETION_USAGE_MAX_DAYS = 370; // ─── Usage profiles (issue #63) ────────────────────────────────────────────── diff --git a/src/extension.ts b/src/extension.ts index 899dc28..d887842 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -48,6 +48,7 @@ import { import { providerEnabledSetting } from "./providerEnablement"; import { isInternalDataPart, isReasoningMarkerPart, readReasoningMarker } from "./chatParts"; import { registerInlineCompletions } from "./autocomplete"; +import { completionUsageToSeries, type CompletionUsageDay } from "./autocomplete/usage"; import { getImageDataUrlBase64Bytes, MAX_IMAGE_BASE64_BYTES, normalizeImageDataUrl } from "./imageNormalizer"; import { imageDescriptionKey, lookupImageDescriptions, storeImageDescriptions } from "./visionProxyCache"; import { providerModelDisplayName } from "./modelNames"; @@ -61,6 +62,7 @@ import { AGENT_HOST_BYOK_ENABLED_SETTING, AGENT_HOST_BYOK_MINOR_VERSION, CAPACITY_LIMITED_MODEL_NOTES, + COMPLETION_USAGE_KEY, CONFIG_SECTION, DEFAULT_REQUEST_TIMEOUT_SECONDS, DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS, @@ -1065,6 +1067,11 @@ export function activate(context: vscode.ExtensionContext) { // Any usage-display setting change repaints the status bar / panel // immediately (no waiting for the next request or refresh tick). if (USAGE_DISPLAY_SETTING_KEYS.some((key) => event.affectsConfiguration(`${CONFIG_SECTION}.${key}`))) { + if (event.affectsConfiguration(`${CONFIG_SECTION}.${SETTING_USAGE_CHART_DAYS}`)) { + usageChartWindowDays = vscode.workspace + .getConfiguration(CONFIG_SECTION) + .get(SETTING_USAGE_CHART_DAYS, DEFAULT_USAGE_CHART_DAYS); + } refreshGoUsageStatusBar(); updateWebviewContent(); } @@ -1431,6 +1438,14 @@ function showUsageWebview(context: vscode.ExtensionContext): void { case "renameProfile": void vscode.commands.executeCommand("opencodego.renameActiveProfile"); break; + case "window": { + const days = Number((message as { days?: unknown }).days); + if (Number.isFinite(days) && days >= 0 && days <= 370) { + usageChartWindowDays = days; + updateWebviewContent(); + } + break; + } } }, null, @@ -1448,6 +1463,10 @@ function jsonForWebview(value: unknown): string { /** Whether the usage webview has received its initial HTML (data flows via postMessage after that). */ let usageWebviewRendered = false; +/** Selected chart window in days (0 = lifetime); the webview toggles it via message. */ +let usageChartWindowDays: number = vscode.workspace + .getConfiguration(CONFIG_SECTION) + .get(SETTING_USAGE_CHART_DAYS, DEFAULT_USAGE_CHART_DAYS); /** Build the chart/stat payload shown by the usage webview. */ function usageWebviewData(): Record | undefined { @@ -1455,8 +1474,10 @@ function usageWebviewData(): Record | undefined { const tracker = activeGoUsageTracker(); if (!tracker) return undefined; const s = tracker.getSummary(); - const chartDays = vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_USAGE_CHART_DAYS, DEFAULT_USAGE_CHART_DAYS); - const series = tracker.getUsageSeries(chartDays); + const windowDays = usageChartWindowDays; + const series = tracker.getUsageSeries(windowDays); + const completionDays = contextCompletionUsage(); + const completions = completionUsageToSeries(completionDays, trackerDayStart(tracker), windowDays); const activeProfile = findProfile(profilesCache, activeProfileFingerprint); const showRolling = usageRollingMeterVisible(); @@ -1494,6 +1515,8 @@ function usageWebviewData(): Record | undefined { return { profile: activeProfile?.label ?? "OpenCode Go", showRename: nonLegacyCount(profilesCache) > 0, + windowDays, + completions, rings, stats: { total: { label: "Total spend", cost: s.codebase.cost, tokens: s.codebase.tokens, requests: s.codebase.requests }, @@ -1502,10 +1525,23 @@ function usageWebviewData(): Record | undefined { }, days: series.days, byModel: series.byModel, - windowDays: chartDays, }; } +/** Read the persisted per-day completion counters. */ +function contextCompletionUsage(): CompletionUsageDay[] { + const stored = _extensionContext?.globalState.get(COMPLETION_USAGE_KEY, []); + return Array.isArray(stored) ? stored : []; +} + +/** Day-start used by the current tracker (matches the chart boundary). */ +function trackerDayStart(_tracker: GoUsageTracker): number { + const now = new Date(); + return vscode.workspace.getConfiguration(CONFIG_SECTION).get<"utc" | "local">(SETTING_USAGE_DAY_BOUNDARY, "utc") === "local" + ? new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() + : Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); +} + function updateWebviewContent(): void { if (!usageWebviewPanel || !goUsageTracker) return; const data = usageWebviewData(); @@ -1566,7 +1602,7 @@ function usageWebviewHtml(profileLabel: string): string { border-radius: 7px; background:linear-gradient(135deg, var(--amber), var(--coral)); display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-weight:700; font-size: clamp(9px, 1.2vh, 12px); - color:#141205; flex:none; + line-height:1; color:#141205; flex:none; } .brand .name{ font-weight:700; font-size: clamp(12px, 1.6vh, 15px); letter-spacing:-0.01em; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .brand .sub{ color:var(--text-lo); font-size: clamp(9px, 1.1vh, 11px); font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } @@ -1640,7 +1676,7 @@ function usageWebviewHtml(profileLabel: string): string { .legend{ display:flex; gap: clamp(10px, 1.4vh, 16px); flex-wrap:wrap; flex:none; align-items:center; padding: 2px 0 0 2px; } .legend .l-item{ display:flex; align-items:center; gap:7px; font-size: clamp(10px, 1.2vh, 12px); line-height:1; color:var(--text-mid); white-space:nowrap; } - .legend .l-swatch{ width: clamp(10px, 1.2vh, 12px); height: clamp(10px, 1.2vh, 12px); border-radius:3px; flex:none; } + .legend .l-swatch{ width: clamp(10px, 1.2vh, 12px); height: clamp(10px, 1.2vh, 12px); border-radius:3px; flex:none; display:inline-block; } .legend .l-more{ color:var(--text-lo); font-size: clamp(9px, 1.05vh, 11px); } @media (max-width: 620px){ @@ -1656,9 +1692,10 @@ function usageWebviewHtml(profileLabel: string): string {
OC
-
${escapeHtml(profileLabel)} — Usage
+
${escapeHtml(profileLabel)}
+ @@ -1674,6 +1711,8 @@ function usageWebviewHtml(profileLabel: string): string { + +
@@ -1745,12 +1784,24 @@ function usageWebviewHtml(profileLabel: string): string { return '$' + String(Math.round(v * 100) / 100); } function metricValues(m) { - return DATA.days.map(function (d) { return m === 'spend' ? d.cost : m === 'requests' ? d.requests : d.tokens; }); + if (m === 'spend') return DATA.days.map(function (d) { return d.cost; }); + if (m === 'requests') return DATA.days.map(function (d) { return d.requests; }); + if (m === 'tokens') return DATA.days.map(function (d) { return d.tokens; }); + return DATA.completions.map(function (d) { return m === 'suggested' ? d.suggested : d.approved; }); } function metricFmt(m) { - return m === 'spend' ? fmtUsd : m === 'requests' ? fmtCount : fmtTokens; + if (m === 'spend') return fmtUsd; + return fmtCount; + } + function metricUnit(m) { + if (m === 'spend') return ''; + if (m === 'requests') return ' requests'; + if (m === 'tokens') return ' tokens'; + return m === 'suggested' ? ' suggestions' : ' approvals'; + } + function metricColor(m) { + return m === 'spend' ? '#e3b341' : m === 'requests' ? '#5aa9ff' : m === 'tokens' ? '#3fdbb0' : m === 'suggested' ? '#a98ef9' : '#7fd1a8'; } - function metricUnit(m) { return m === 'spend' ? '' : m === 'requests' ? ' requests' : ' tokens'; } function metricTotal(m) { var vals = metricValues(m); return vals.reduce(function (a, b) { return a + b; }, 0); @@ -1778,7 +1829,7 @@ function usageWebviewHtml(profileLabel: string): string { var maxVal = niceMax(Math.max.apply(null, vals) * 1.15); var axis = axisTicks(maxVal, 4); maxVal = axis.top; - var color = m === 'spend' ? '#e3b341' : m === 'requests' ? '#5aa9ff' : '#3fdbb0'; + var color = metricColor(m); var fmt = metricFmt(m), unit = metricUnit(m); var fmtAxis = m === 'spend' ? fmtAxisUsd : fmt; var n = DATA.days.length; @@ -1814,22 +1865,24 @@ function usageWebviewHtml(profileLabel: string): string { svg.appendChild(el('path', { d: areaD, fill: 'url(#grad)', stroke: 'none' })); svg.appendChild(el('path', { d: pathD, fill: 'none', stroke: color, 'stroke-width': '2', 'stroke-linejoin': 'round', 'stroke-linecap': 'round' })); - // cursor-follow nearest-day tooltip + visible dots - var hits = points.map(function (p, i) { - var c = el('circle', { cx: p.x, cy: p.y, r: '10', fill: 'transparent', stroke: 'none' }); - c.addEventListener('mousemove', function (ev) { showDay(ev, p, i); }); - c.addEventListener('mouseleave', hideTooltip); - svg.appendChild(c); - return c; - }); - function showDay(ev, p, i) { - svg.querySelectorAll('.dot').forEach(function (d) { d.remove(); }); - svg.appendChild(el('circle', { 'class': 'dot', cx: p.x, cy: p.y, r: '3.5', fill: color, stroke: '#161a20', 'stroke-width': '2' })); + // whole-chart hover: nearest day gets a guide line, dot and tooltip + var guide = el('line', { y1: padT, y2: padT + plotH, stroke: 'rgba(255,255,255,0.18)', 'stroke-width': '1', 'stroke-dasharray': '3 3' }); + var dot = el('circle', { r: '3.5', fill: color, stroke: '#161a20', 'stroke-width': '2' }); + var overlay = el('rect', { x: padL, y: padT, width: plotW, height: plotH, fill: 'transparent' }); + overlay.addEventListener('mousemove', function (ev) { + var r = svg.getBoundingClientRect(); + var px = ev.clientX - r.left; + var best = points[0], bd = Infinity; + points.forEach(function (p) { var d = Math.abs(p.x - px); if (d < bd) { bd = d; best = p; } }); + guide.setAttribute('x1', String(best.x)); guide.setAttribute('x2', String(best.x)); + dot.setAttribute('cx', String(best.x)); dot.setAttribute('cy', String(best.y)); + svg.appendChild(guide); svg.appendChild(dot); showTooltip(ev.clientX, ev.clientY, - '' + dayLabel(p.day.dayStart) + '' + fmt(p.v) + '' + unit + - '
' + fmtCount(p.day.tokens) + ' tokens · ' + fmtCount(p.day.requests) + ' requests
'); - } - void hits; + '' + dayLabel(best.day.dayStart) + '' + fmt(best.v) + '' + unit + + '
' + fmtCount(best.day.tokens) + ' tokens · ' + fmtCount(best.day.requests) + ' requests
'); + }); + overlay.addEventListener('mouseleave', function () { guide.remove(); dot.remove(); hideTooltip(); }); + svg.appendChild(overlay); svg.appendChild(el('line', { x1: padL, y1: padT + plotH, x2: W - padR, y2: padT + plotH, stroke: '#242a33', 'stroke-width': '1' })); } @@ -1848,7 +1901,7 @@ function usageWebviewHtml(profileLabel: string): string { }); var models = Object.keys(totals).sort(function (a, b) { return totals[b] - totals[a]; }); if (models.length === 0) { - svg.appendChild(el('text', { x: W / 2, y: H / 2, 'text-anchor': 'middle', fill: '#5b6472', 'font-size': '11' }, 'No usage in the last ' + DATA.windowDays + ' days')); + svg.appendChild(el('text', { x: W / 2, y: H / 2, 'text-anchor': 'middle', fill: '#5b6472', 'font-size': '11' }, 'No model usage in the selected window')); return; } @@ -1906,6 +1959,50 @@ function usageWebviewHtml(profileLabel: string): string { svg.appendChild(el('line', { x1: padL, y1: padT + plotH, x2: W - padR, y2: padT + plotH, stroke: '#242a33', 'stroke-width': '1' })); + // whole-chart hover: nearest day highlights every model's point and + // lists each model's spend for that day + var guide2 = el('line', { y1: padT, y2: padT + plotH, stroke: 'rgba(255,255,255,0.18)', 'stroke-width': '1', 'stroke-dasharray': '3 3' }); + var dots2 = []; + var overlay2 = el('rect', { x: padL, y: padT, width: plotW, height: plotH, fill: 'transparent' }); + var modelPts = []; + series.forEach(function (s, si) { + var color2 = MODEL_COLORS[si % MODEL_COLORS.length]; + s.values.forEach(function (p, i) { + modelPts.push({ + x: padL + step * i, + y: padT + plotH - (p.cost / maxVal) * plotH, + model: s.model, + color: color2, + p: p, + i: i + }); + }); + }); + overlay2.addEventListener('mousemove', function (ev) { + var r = svg.getBoundingClientRect(); + var px = ev.clientX - r.left; + var best = null, bd = Infinity; + modelPts.forEach(function (pt) { var d = Math.abs(pt.x - px); if (d < bd) { bd = d; best = pt; } }); + if (!best) return; + guide2.setAttribute('x1', String(best.x)); guide2.setAttribute('x2', String(best.x)); + svg.appendChild(guide2); + dots2.forEach(function (d2) { d2.remove(); }); + dots2.length = 0; + var lines2 = []; + modelPts.forEach(function (pt) { + if (pt.i !== best.i) return; + var dd = el('circle', { cx: pt.x, cy: pt.y, r: '3.5', fill: pt.color, stroke: '#161a20', 'stroke-width': '2' }); + svg.appendChild(dd); + dots2.push(dd); + if (pt.p.cost > 0) lines2.push('
' + pt.model + ': ' + fmtUsd(pt.p.cost) + '
'); + }); + showTooltip(ev.clientX, ev.clientY, + '' + dayLabel(best.p.dayStart) + '' + + (lines2.length ? lines2.join('') : 'No model spend this day')); + }); + overlay2.addEventListener('mouseleave', function () { guide2.remove(); dots2.forEach(function (d2) { d2.remove(); }); dots2.length = 0; hideTooltip(); }); + svg.appendChild(overlay2); + // legend (up to 8 models, ranked by total spend) var legend = document.getElementById('legend'); legend.innerHTML = series.slice(0, 8).map(function (s, i) { @@ -1919,12 +2016,24 @@ function usageWebviewHtml(profileLabel: string): string { var fmt = metricFmt(key), unit = metricUnit(key); if (key === 'models') { chips.innerHTML = - '
Total spend
' + fmtUsd(metricTotal('spend')) + '
' + - '
Window
' + DATA.windowDays + ' days
'; + '
Total spend
' + fmtUsd(metricTotal('spend')) + '
'; drawModelLines(); return; } document.getElementById('legend').innerHTML = ''; + if (key === 'suggested' || key === 'approved') { + var comp = DATA.completions; + var last = comp.length ? comp[comp.length - 1] : { suggested: 0, approved: 0 }; + var prev = comp.length > 1 ? comp[comp.length - 2] : { suggested: 0, approved: 0 }; + var total = 0; + comp.forEach(function (d) { total += key === 'suggested' ? d.suggested : d.approved; }); + chips.innerHTML = + '
Today
' + fmtCount(key === 'suggested' ? last.suggested : last.approved) + '
' + + '
Yesterday
' + fmtCount(key === 'suggested' ? prev.suggested : prev.approved) + '
' + + '
Total ' + key + '
' + fmtCount(total) + '
'; + drawLine(key); + return; + } var st = DATA.stats; var totalKey = key === 'spend' ? 'total' : key; chips.innerHTML = @@ -1988,13 +2097,37 @@ function usageWebviewHtml(profileLabel: string): string { var msg = ev.data; if (!msg || msg.type !== 'usage') return; DATA = msg.data; - document.getElementById('brandName').textContent = DATA.profile + ' \u2014 Usage'; + document.getElementById('brandName').textContent = DATA.profile; document.getElementById('btnRename').style.display = DATA.showRename ? '' : 'none'; + windowBtn.textContent = windowLabel(DATA.windowDays); refreshBtn.textContent = 'Refresh'; renderRings(); render(current); }); + document.getElementById('brandName').textContent = DATA.profile; + + // window toggle: week -> 14 days -> month -> lifetime + var WINDOWS = [ + { days: 7, label: 'Week' }, + { days: 14, label: '14 days' }, + { days: 30, label: 'Month' }, + { days: 0, label: 'Lifetime' } + ]; + function windowLabel(days) { + for (var i = 0; i < WINDOWS.length; i++) if (WINDOWS[i].days === days) return WINDOWS[i].label; + return days + ' days'; + } + var windowBtn = document.getElementById('btnWindow'); + windowBtn.textContent = windowLabel(DATA.windowDays); + windowBtn.addEventListener('click', function () { + var idx = 0; + for (var i = 0; i < WINDOWS.length; i++) if (WINDOWS[i].days === DATA.windowDays) idx = i; + var next = WINDOWS[(idx + 1) % WINDOWS.length]; + windowBtn.textContent = next.label; + vscode.postMessage({ type: 'window', days: next.days }); + }); + renderRings(); render('spend'); })(); diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index bf52e55..edad637 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -421,9 +421,23 @@ export function buildUsageSeries( dayStartMs: number, source: UsageTodayYesterdaySource = "auto", ): UsageSeries { - const dayCount = Math.max(1, Math.floor(days)); - const firstDay = dayStartMs - (dayCount - 1) * DAY_MS; - const buckets: UsageDayPoint[] = Array.from({ length: dayCount }, (_, i) => ({ + // days > 0: the last `days` days ending at dayStartMs; days <= 0: lifetime + // from the earliest recorded usage to today (aligned to the day grid). + let firstDay: number; + if (days > 0) { + firstDay = dayStartMs - (Math.max(1, Math.floor(days)) - 1) * DAY_MS; + } else { + let earliest = dayStartMs; + if (source !== "extension") { + for (const row of rows) if (row.createdMs < earliest) earliest = row.createdMs; + } + if (source !== "cli") { + for (const entry of entries) if (entry.timestamp < earliest) earliest = entry.timestamp; + } + firstDay = dayStartMs - Math.ceil((dayStartMs - earliest) / DAY_MS) * DAY_MS; + } + const bucketCount = Math.round((dayStartMs - firstDay) / DAY_MS) + 1; + const buckets: UsageDayPoint[] = Array.from({ length: bucketCount }, (_, i) => ({ dayStart: firstDay + i * DAY_MS, cost: 0, tokens: 0, @@ -432,8 +446,8 @@ export function buildUsageSeries( const byModel = new Map>(); const add = (model: string | undefined, timestamp: number, cost: number, tokens: number): void => { - const index = Math.floor((timestamp - firstDay) / DAY_MS); - if (index < 0 || index >= dayCount) return; + const index = Math.round((timestamp - firstDay) / DAY_MS); + if (index < 0 || index >= bucketCount) return; const day = buckets[index]; day.cost += cost; day.tokens += tokens; diff --git a/src/test/autocompleteUsage.test.ts b/src/test/autocompleteUsage.test.ts new file mode 100644 index 0000000..a430ce9 --- /dev/null +++ b/src/test/autocompleteUsage.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { bumpCompletionUsage, completionUsageToSeries, utcDayStart, type CompletionUsageDay } from "../autocomplete/usage.js"; + +const DAY = 24 * 60 * 60 * 1000; + +describe("autocomplete usage — bumpCompletionUsage", () => { + it("increments the right counter for an existing day", () => { + const days: CompletionUsageDay[] = [{ dayStart: 1000, suggested: 2, approved: 1 }]; + bumpCompletionUsage(days, 1000, "suggested"); + bumpCompletionUsage(days, 1000, "approved"); + assert.deepEqual(days, [{ dayStart: 1000, suggested: 3, approved: 2 }]); + }); + + it("creates a day entry on first use and caps retention", () => { + const days: CompletionUsageDay[] = []; + bumpCompletionUsage(days, 500, "suggested", 2); + assert.equal(days.length, 1); + bumpCompletionUsage(days, 1000, "suggested", 2); + bumpCompletionUsage(days, 1500, "suggested", 2); + assert.equal(days.length, 2, "oldest day evicted past the cap"); + assert.equal(days[0].dayStart, 1000); + }); +}); + +describe("autocomplete usage — completionUsageToSeries", () => { + const dayMs = utcDayStart(Date.now()); + + it("emits fixed windows with zero-filled days", () => { + const days: CompletionUsageDay[] = [ + { dayStart: dayMs, suggested: 3, approved: 1 }, + { dayStart: dayMs - DAY, suggested: 5, approved: 2 }, + ]; + const series = completionUsageToSeries(days, dayMs, 7); + assert.equal(series.length, 7); + assert.equal(series[6].suggested, 3); + assert.equal(series[6].approved, 1); + assert.equal(series[5].suggested, 5); + assert.equal(series[0].suggested, 0, "empty days are zero-filled"); + }); + + it("lifetime windows span from the earliest stored day", () => { + const days: CompletionUsageDay[] = [ + { dayStart: dayMs - 5 * DAY, suggested: 1, approved: 0 }, + { dayStart: dayMs, suggested: 4, approved: 2 }, + ]; + const series = completionUsageToSeries(days, dayMs, 0); + assert.equal(series.length, 6); + assert.equal(series[0].suggested, 1); + assert.equal(series[5].suggested, 4); + }); + + it("empty history still yields a single today bucket", () => { + const series = completionUsageToSeries([], dayMs, 0); + assert.equal(series.length, 1); + assert.equal(series[0].dayStart, dayMs); + assert.equal(series[0].suggested, 0); + }); +}); diff --git a/src/test/goUsageTracker.test.ts b/src/test/goUsageTracker.test.ts index de0aca4..287d606 100644 --- a/src/test/goUsageTracker.test.ts +++ b/src/test/goUsageTracker.test.ts @@ -824,4 +824,14 @@ describe("buildUsageSeries", () => { const series = buildUsageSeries(rows, entries, 14, dayMs, "cli"); assert.ok(!series.byModel.some((p) => p.model === "glm-5")); }); + + it("lifetime windows (days=0) span from the earliest usage day", () => { + const series = buildUsageSeries(rows, entries, 0, dayMs, "auto"); + // earliest row = dayMs - DAY → 2 buckets: yesterday + today + assert.equal(series.days.length, 2); + assert.equal(series.days[0].dayStart, dayMs - DAY); + assert.equal(series.days[1].dayStart, dayMs); + assert.equal(series.days[0].requests, 2); + assert.equal(series.days[1].requests, 2); + }); }); From 486d092e6f356046912b4dbdde842f93f964cb30 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 12:30:16 +0500 Subject: [PATCH 34/43] feat(usage): lifetime default, Suggestions/Approved fixes - Default chart window is now Lifetime (usageChartDays default 0). - 'By model' tab renamed to 'Models'; 'Suggested' to 'Suggestions'. - Suggestions/Approved tabs show no stat chips on the right. - Charts use whole-number axes for suggestion/approval counts (never 2.5). - Hover on the completion tabs shows that day's suggestion/approval counts instead of the chat token/request totals. - Fixes the 'undefined suggestions' hover bug: the completion series now shares the exact day buckets of the usage series (explicit first-day alignment) with zero-filling, so every hover point has a value. --- CHANGELOG.md | 2 +- package.json | 2 +- src/autocomplete/usage.ts | 7 ++++- src/config.ts | 2 +- src/extension.ts | 46 +++++++++++++++++++----------- src/test/autocompleteUsage.test.ts | 13 +++++++++ 6 files changed, 52 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a866168..2df56c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Fixed -- **`[Usage]` Panel polish + chat-completion charts.** Hovering anywhere on a chart (not just on points) highlights the nearest day with a guide line + dots and a cursor-follow tooltip; on the By-model tab the tooltip lists every model's spend for that day. Two new tabs — **Suggested** and **Approved** — chart inline chat completions (suggestions shown by the provider and acceptances tracked via the guarded `onDidChangeInlineCompletionItems` API, persisted per day). A **Window** button (alongside Set targets / Rename / Refresh) cycles Week → 14 days → Month → Lifetime and re-renders every chart for the new window (lifetime spans the earliest recorded usage). The panel brand shows only the profile name, the legend swatches are square and text-aligned, and axes keep round equal tick steps. +- **`[Usage]` Panel polish + chat-completion charts.** Hovering anywhere on a chart (not just on points) highlights the nearest day with a guide line + dots and a cursor-follow tooltip; on the Models tab the tooltip lists every model's spend for that day. Two new tabs — **Suggestions** and **Approved** — chart inline chat completions with whole-number axes and honest tooltips (the day's suggestion/approval counts, not chat token totals); both series share the exact same day buckets, so hovers never resolve to undefined. The default chart window is **Lifetime**, switchable live via the **Window** button (Week → 14 days → Month → Lifetime). The panel brand shows only the profile name, the legend swatches are square and text-aligned, and axes keep round equal tick steps. - **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Total spend, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (overlapping colored line charts per model by spend, with a legend and hover tooltips for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is renamed to **Total spend** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Styled action buttons sit in the panel's top-right corner: **Set targets**, **Rename** (when multiple profiles exist) and **Refresh** — wired through `vscode.postMessage`, and a refresh keeps the active tab (data is pushed in place, the page is never reloaded; the background loop still runs at `usageRefreshIntervalSeconds`). Chart axes use round tick steps (1/2/2.5/5 × 10ⁿ) so gaps are always equal and clean (no more 1.25/2.50/3.75 divisions). diff --git a/package.json b/package.json index 4171984..b228d1c 100644 --- a/package.json +++ b/package.json @@ -179,7 +179,7 @@ }, "opencodego.usageChartDays": { "type": "number", - "default": 14, + "default": 0, "minimum": 0, "maximum": 370, "description": "How many days the usage panel charts cover (daily spend / requests / tokens, per-model and chat-completion breakdowns). 0 = lifetime. The panel's Window button toggles this live (Week / 14 days / Month / Lifetime)." diff --git a/src/autocomplete/usage.ts b/src/autocomplete/usage.ts index 7a646ee..585fe00 100644 --- a/src/autocomplete/usage.ts +++ b/src/autocomplete/usage.ts @@ -48,10 +48,15 @@ export function completionUsageToSeries( days: CompletionUsageDay[], dayStartMs: number, windowDays: number, + firstDayOverride?: number, ): { dayStart: number; suggested: number; approved: number }[] { const byDay = new Map(days.map((d) => [d.dayStart, d])); let firstDay: number; - if (windowDays > 0) { + if (firstDayOverride !== undefined) { + // Align with the usage chart's own day range (both series must share the + // exact same buckets, otherwise hover values go undefined). + firstDay = firstDayOverride; + } else if (windowDays > 0) { firstDay = dayStartMs - (Math.max(1, Math.floor(windowDays)) - 1) * DAY_MS; } else if (days.length > 0) { const earliest = Math.min(...days.map((d) => d.dayStart)); diff --git a/src/config.ts b/src/config.ts index 356580c..f0ca714 100644 --- a/src/config.ts +++ b/src/config.ts @@ -186,7 +186,7 @@ export const SETTING_USAGE_REFRESH_INTERVAL_SECONDS = "usageRefreshIntervalSecon export const DEFAULT_USAGE_REFRESH_INTERVAL_SECONDS = 60; /** How many days the usage panel charts cover (0 = lifetime). */ export const SETTING_USAGE_CHART_DAYS = "usageChartDays"; -export const DEFAULT_USAGE_CHART_DAYS = 14; +export const DEFAULT_USAGE_CHART_DAYS = 0; /** globalState key for the per-day chat-completion usage counters. */ export const COMPLETION_USAGE_KEY = "opencodego.completionUsage.v1"; /** How many days of completion history are retained. */ diff --git a/src/extension.ts b/src/extension.ts index d887842..953d73d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1477,7 +1477,15 @@ function usageWebviewData(): Record | undefined { const windowDays = usageChartWindowDays; const series = tracker.getUsageSeries(windowDays); const completionDays = contextCompletionUsage(); - const completions = completionUsageToSeries(completionDays, trackerDayStart(tracker), windowDays); + // The completion series must share the EXACT day buckets of the usage + // series (they can differ in length on lifetime windows), otherwise the + // charts misalign and hovers resolve to undefined values. + const completions = completionUsageToSeries( + completionDays, + trackerDayStart(tracker), + windowDays, + series.days.length > 0 ? series.days[0].dayStart : undefined, + ); const activeProfile = findProfile(profilesCache, activeProfileFingerprint); const showRolling = usageRollingMeterVisible(); @@ -1710,8 +1718,8 @@ function usageWebviewHtml(profileLabel: string): string { - - + +
@@ -1772,8 +1780,10 @@ function usageWebviewHtml(profileLabel: string): string { return cand * mag; } // Round the max up to a multiple of a round step, then emit 0..top ticks. - function axisTicks(maxVal, bands) { + // forceInt keeps counts whole (suggestions/approvals never show 2.5). + function axisTicks(maxVal, bands, forceInt) { var step = niceStep(maxVal / Math.max(1, bands)); + if (forceInt) step = Math.max(1, Math.round(step)); var top = Math.ceil(maxVal / step) * step; var ticks = []; for (var v = top; v > -1e-9; v -= step) ticks.push(Math.round(v * 10000) / 10000); @@ -1787,7 +1797,10 @@ function usageWebviewHtml(profileLabel: string): string { if (m === 'spend') return DATA.days.map(function (d) { return d.cost; }); if (m === 'requests') return DATA.days.map(function (d) { return d.requests; }); if (m === 'tokens') return DATA.days.map(function (d) { return d.tokens; }); - return DATA.completions.map(function (d) { return m === 'suggested' ? d.suggested : d.approved; }); + var vals = (DATA.completions || []).map(function (d) { return m === 'suggested' ? d.suggested : d.approved; }); + // zero-fill so every day bucket has a value (lifetime ranges differ) + while (vals.length < DATA.days.length) vals.push(0); + return vals; } function metricFmt(m) { if (m === 'spend') return fmtUsd; @@ -1827,11 +1840,20 @@ function usageWebviewHtml(profileLabel: string): string { var plotW = Math.max(1, W - padL - padR), plotH = Math.max(1, H - padT - padB); var vals = metricValues(m); var maxVal = niceMax(Math.max.apply(null, vals) * 1.15); - var axis = axisTicks(maxVal, 4); + var isCount = m === 'suggested' || m === 'approved'; + var axis = axisTicks(maxVal, 4, isCount); maxVal = axis.top; var color = metricColor(m); var fmt = metricFmt(m), unit = metricUnit(m); var fmtAxis = m === 'spend' ? fmtAxisUsd : fmt; + function daySub(day) { + if (isCount) { + var c = { suggested: 0, approved: 0 }; + (DATA.completions || []).forEach(function (d) { if (d.dayStart === day.dayStart) c = d; }); + return fmtCount(c.suggested) + ' suggestions · ' + fmtCount(c.approved) + ' approved'; + } + return fmtCount(day.tokens) + ' tokens · ' + fmtCount(day.requests) + ' requests'; + } var n = DATA.days.length; var defs = el('defs', {}); @@ -1879,7 +1901,7 @@ function usageWebviewHtml(profileLabel: string): string { svg.appendChild(guide); svg.appendChild(dot); showTooltip(ev.clientX, ev.clientY, '' + dayLabel(best.day.dayStart) + '' + fmt(best.v) + '' + unit + - '
' + fmtCount(best.day.tokens) + ' tokens · ' + fmtCount(best.day.requests) + ' requests
'); + '
' + daySub(best.day) + '
'); }); overlay.addEventListener('mouseleave', function () { guide.remove(); dot.remove(); hideTooltip(); }); svg.appendChild(overlay); @@ -2022,15 +2044,7 @@ function usageWebviewHtml(profileLabel: string): string { } document.getElementById('legend').innerHTML = ''; if (key === 'suggested' || key === 'approved') { - var comp = DATA.completions; - var last = comp.length ? comp[comp.length - 1] : { suggested: 0, approved: 0 }; - var prev = comp.length > 1 ? comp[comp.length - 2] : { suggested: 0, approved: 0 }; - var total = 0; - comp.forEach(function (d) { total += key === 'suggested' ? d.suggested : d.approved; }); - chips.innerHTML = - '
Today
' + fmtCount(key === 'suggested' ? last.suggested : last.approved) + '
' + - '
Yesterday
' + fmtCount(key === 'suggested' ? prev.suggested : prev.approved) + '
' + - '
Total ' + key + '
' + fmtCount(total) + '
'; + chips.innerHTML = ''; drawLine(key); return; } diff --git a/src/test/autocompleteUsage.test.ts b/src/test/autocompleteUsage.test.ts index a430ce9..9ebd025 100644 --- a/src/test/autocompleteUsage.test.ts +++ b/src/test/autocompleteUsage.test.ts @@ -56,4 +56,17 @@ describe("autocomplete usage — completionUsageToSeries", () => { assert.equal(series[0].dayStart, dayMs); assert.equal(series[0].suggested, 0); }); + + it("honors an explicit first day so both charts share buckets", () => { + const days: CompletionUsageDay[] = [{ dayStart: dayMs, suggested: 2, approved: 1 }]; + // The usage series may start earlier (e.g. 5 days back on lifetime); + // the completion series must span the same range with zero-fills. + const first = dayMs - 5 * DAY; + const series = completionUsageToSeries(days, dayMs, 0, first); + assert.equal(series.length, 6); + assert.equal(series[0].dayStart, first); + assert.equal(series[5].dayStart, dayMs); + assert.equal(series[5].suggested, 2); + assert.equal(series[0].suggested, 0); + }); }); From 46bc8ec27f78ab98864094ba18d3918386fd8bf2 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 12:37:53 +0500 Subject: [PATCH 35/43] fix(usage): track approved completions via insert heuristic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code's stable API has NO inline-completion acceptance event — the previously guarded onDidChangeInlineCompletionItems does not exist, so approvals were always 0. Committing a ghost text is now detected from the document insert: it must start exactly at the suggested position and match the suggested text (multi-character, 30s window, cleared on first match). Pure matcher matchesAcceptance() extracted and unit-tested (275 tests). --- CHANGELOG.md | 2 +- src/autocomplete/index.ts | 47 +++++++++++++++++++----------- src/autocomplete/provider.ts | 4 +-- src/autocomplete/usage.ts | 15 ++++++++++ src/test/autocompleteUsage.test.ts | 26 ++++++++++++++++- 5 files changed, 73 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df56c8..f20d8cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Fixed -- **`[Usage]` Panel polish + chat-completion charts.** Hovering anywhere on a chart (not just on points) highlights the nearest day with a guide line + dots and a cursor-follow tooltip; on the Models tab the tooltip lists every model's spend for that day. Two new tabs — **Suggestions** and **Approved** — chart inline chat completions with whole-number axes and honest tooltips (the day's suggestion/approval counts, not chat token totals); both series share the exact same day buckets, so hovers never resolve to undefined. The default chart window is **Lifetime**, switchable live via the **Window** button (Week → 14 days → Month → Lifetime). The panel brand shows only the profile name, the legend swatches are square and text-aligned, and axes keep round equal tick steps. +- **`[Usage]` Panel polish + chat-completion charts.** Hovering anywhere on a chart (not just on points) highlights the nearest day with a guide line + dots and a cursor-follow tooltip; on the Models tab the tooltip lists every model's spend for that day. Two new tabs — **Suggestions** and **Approved** — chart inline chat completions with whole-number axes and honest tooltips (the day's suggestion/approval counts, not chat token totals); both series share the exact same day buckets, so hovers never resolve to undefined. Acceptances are detected with a bounded heuristic: VS Code's stable API exposes no inline-completion acceptance event, so committing a ghost text is recognized by the document insert starting exactly at the suggested position with a matching multi-character text (30s window, cleared on first match — see `matchesAcceptance`). The default chart window is **Lifetime**, switchable live via the **Window** button (Week → 14 days → Month → Lifetime). The panel brand shows only the profile name, the legend swatches are square and text-aligned, and axes keep round equal tick steps. - **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Total spend, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (overlapping colored line charts per model by spend, with a legend and hover tooltips for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is renamed to **Total spend** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Styled action buttons sit in the panel's top-right corner: **Set targets**, **Rename** (when multiple profiles exist) and **Refresh** — wired through `vscode.postMessage`, and a refresh keeps the active tab (data is pushed in place, the page is never reloaded; the background loop still runs at `usageRefreshIntervalSeconds`). Chart axes use round tick steps (1/2/2.5/5 × 10ⁿ) so gaps are always equal and clean (no more 1.25/2.50/3.75 divisions). diff --git a/src/autocomplete/index.ts b/src/autocomplete/index.ts index d2f6b96..13b9a9b 100644 --- a/src/autocomplete/index.ts +++ b/src/autocomplete/index.ts @@ -30,7 +30,7 @@ import { INLINE_TIMEOUT_MS_SETTING, } from "../config"; import { toFiniteNumber } from "../utils"; -import { bumpCompletionUsage, utcDayStart, type CompletionUsageDay } from "./usage"; +import { bumpCompletionUsage, matchesAcceptance, utcDayStart, type CompletionUsageDay } from "./usage"; export { INLINE_SUGGESTIONS_SETTING, @@ -82,23 +82,30 @@ export function registerInlineCompletions(context: vscode.ExtensionContext, deps void context.globalState.update(COMPLETION_USAGE_KEY, completionUsage); }; - // Acceptance tracking: `onDidChangeInlineCompletionItems` is not in the - // stable API types, so it is guarded at runtime — on hosts that have it, - // `didAccept` reports when the user accepted a ghost-text suggestion. - const onDidChangeInlineCompletionItems = ( - vscode.languages as unknown as { - onDidChangeInlineCompletionItems?: (listener: (event: { didAccept?: boolean }) => unknown) => vscode.Disposable; - } - ).onDidChangeInlineCompletionItems; - if (typeof onDidChangeInlineCompletionItems === "function") { - context.subscriptions.push( - onDidChangeInlineCompletionItems((event) => { - if (event.didAccept) { + // Acceptance tracking: VS Code's stable API exposes NO acceptance event + // for inline completions, so we detect the insert that committing a ghost + // text produces: it starts exactly at the suggested position and matches + // the suggested text. The pending suggestion expires after 30s and is + // cleared on the first match, bounding false positives. + const ACCEPTANCE_WINDOW_MS = 30_000; + let pendingSuggestion: { documentUri: string; position: vscode.Position; text: string; expiresAt: number } | undefined; + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((event) => { + if (!pendingSuggestion) return; + if (Date.now() > pendingSuggestion.expiresAt) { + pendingSuggestion = undefined; + return; + } + if (event.document.uri.toString() !== pendingSuggestion.documentUri) return; + for (const change of event.contentChanges) { + if (change.range.start.isEqual(pendingSuggestion.position) && matchesAcceptance(change.text, pendingSuggestion.text)) { recordCompletion("approved"); + pendingSuggestion = undefined; + return; } - }), - ); - } + } + }), + ); const engine: CompletionEngine = { id: "chat-completions", @@ -124,8 +131,14 @@ export function registerInlineCompletions(context: vscode.ExtensionContext, deps const provider = new OpenCodeInlineCompletionProvider({ engine, resolveApiKey: deps.resolveApiKey, - onSuggestion: () => { + onSuggestion: (text, position, document) => { recordCompletion("suggested"); + pendingSuggestion = { + documentUri: document.uri.toString(), + position, + text, + expiresAt: Date.now() + ACCEPTANCE_WINDOW_MS, + }; }, isEnabled: () => readSetting(INLINE_SUGGESTIONS_SETTING, false), resolveModelId: () => readSetting(INLINE_SUGGESTIONS_MODEL_SETTING, DEFAULT_INLINE_MODEL), diff --git a/src/autocomplete/provider.ts b/src/autocomplete/provider.ts index 7119ee4..82b6382 100644 --- a/src/autocomplete/provider.ts +++ b/src/autocomplete/provider.ts @@ -16,7 +16,7 @@ export interface InlineCompletionProviderOptions { /** Resolve the API key (async; the caller owns caching/fallbacks). */ resolveApiKey: () => Promise; /** Called once when a ghost-text suggestion is actually returned to VS Code. */ - onSuggestion?: () => void; + onSuggestion?: (text: string, position: vscode.Position, document: vscode.TextDocument) => void; /** Whether suggestions are currently enabled (config-driven). */ isEnabled: () => boolean; /** The model to use for suggestions (config-driven). */ @@ -106,7 +106,7 @@ export class OpenCodeInlineCompletionProvider implements vscode.InlineCompletion return; } finish([new vscode.InlineCompletionItem(result.text, new vscode.Range(position, position))]); - this.options.onSuggestion?.(); + this.options.onSuggestion?.(result.text, position, document); }); }); } diff --git a/src/autocomplete/usage.ts b/src/autocomplete/usage.ts index 585fe00..b280632 100644 --- a/src/autocomplete/usage.ts +++ b/src/autocomplete/usage.ts @@ -33,6 +33,21 @@ export function bumpCompletionUsage( return days; } +/** + * Whether a document change counts as accepting a ghost-text suggestion. + * VS Code's stable API exposes no acceptance event, so we detect the insert + * that happens when a suggestion is committed: it starts exactly at the + * suggested position and the inserted text matches the suggestion. + * + * False positives are bounded by requiring a multi-character insert that + * matches the suggestion prefix/suffix — a single keystroke that happens to + * coincide is not counted. + */ +export function matchesAcceptance(changeText: string, pendingText: string): boolean { + if (changeText.length < 2 || !pendingText) return false; + return changeText.startsWith(pendingText) || pendingText.startsWith(changeText); +} + /** Start of the UTC day (the default chart boundary). */ export function utcDayStart(nowMs: number): number { const d = new Date(nowMs); diff --git a/src/test/autocompleteUsage.test.ts b/src/test/autocompleteUsage.test.ts index 9ebd025..a911e75 100644 --- a/src/test/autocompleteUsage.test.ts +++ b/src/test/autocompleteUsage.test.ts @@ -1,6 +1,12 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { bumpCompletionUsage, completionUsageToSeries, utcDayStart, type CompletionUsageDay } from "../autocomplete/usage.js"; +import { + bumpCompletionUsage, + completionUsageToSeries, + matchesAcceptance, + utcDayStart, + type CompletionUsageDay, +} from "../autocomplete/usage.js"; const DAY = 24 * 60 * 60 * 1000; @@ -70,3 +76,21 @@ describe("autocomplete usage — completionUsageToSeries", () => { assert.equal(series[0].suggested, 0); }); }); + +describe("autocomplete usage — matchesAcceptance", () => { + it("matches full and partial commits of the suggestion", () => { + assert.ok(matchesAcceptance("return true;", "return true;")); + assert.ok(matchesAcceptance("return tr", "return true;"), "partial typed commit"); + assert.ok(matchesAcceptance("return true;\n}", "return true;"), "commit plus trailing text"); + }); + + it("rejects single-keystroke edits (manual typing)", () => { + assert.ok(!matchesAcceptance("r", "return true;")); + assert.ok(!matchesAcceptance("", "return true;")); + }); + + it("rejects unrelated edits", () => { + assert.ok(!matchesAcceptance("something else", "return true;")); + assert.ok(!matchesAcceptance("RETURN TRUE;", "return true;"), "case differs"); + }); +}); From aa5a0ac558ababab0194c658f30ddd0a6a8d246d Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 12:42:29 +0500 Subject: [PATCH 36/43] feat(usage): rename Suggestions tab to Suggested --- CHANGELOG.md | 2 +- src/extension.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f20d8cf..6b7f6b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Fixed -- **`[Usage]` Panel polish + chat-completion charts.** Hovering anywhere on a chart (not just on points) highlights the nearest day with a guide line + dots and a cursor-follow tooltip; on the Models tab the tooltip lists every model's spend for that day. Two new tabs — **Suggestions** and **Approved** — chart inline chat completions with whole-number axes and honest tooltips (the day's suggestion/approval counts, not chat token totals); both series share the exact same day buckets, so hovers never resolve to undefined. Acceptances are detected with a bounded heuristic: VS Code's stable API exposes no inline-completion acceptance event, so committing a ghost text is recognized by the document insert starting exactly at the suggested position with a matching multi-character text (30s window, cleared on first match — see `matchesAcceptance`). The default chart window is **Lifetime**, switchable live via the **Window** button (Week → 14 days → Month → Lifetime). The panel brand shows only the profile name, the legend swatches are square and text-aligned, and axes keep round equal tick steps. +- **`[Usage]` Panel polish + chat-completion charts.** Hovering anywhere on a chart (not just on points) highlights the nearest day with a guide line + dots and a cursor-follow tooltip; on the Models tab the tooltip lists every model's spend for that day. Two new tabs — **Suggested** and **Approved** — chart inline chat completions with whole-number axes and honest tooltips (the day's suggestion/approval counts, not chat token totals); both series share the exact same day buckets, so hovers never resolve to undefined. Acceptances are detected with a bounded heuristic: VS Code's stable API exposes no inline-completion acceptance event, so committing a ghost text is recognized by the document insert starting exactly at the suggested position with a matching multi-character text (30s window, cleared on first match — see `matchesAcceptance`). The default chart window is **Lifetime**, switchable live via the **Window** button (Week → 14 days → Month → Lifetime). The panel brand shows only the profile name, the legend swatches are square and text-aligned, and axes keep round equal tick steps. - **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Total spend, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (overlapping colored line charts per model by spend, with a legend and hover tooltips for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is renamed to **Total spend** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Styled action buttons sit in the panel's top-right corner: **Set targets**, **Rename** (when multiple profiles exist) and **Refresh** — wired through `vscode.postMessage`, and a refresh keeps the active tab (data is pushed in place, the page is never reloaded; the background loop still runs at `usageRefreshIntervalSeconds`). Chart axes use round tick steps (1/2/2.5/5 × 10ⁿ) so gaps are always equal and clean (no more 1.25/2.50/3.75 divisions). diff --git a/src/extension.ts b/src/extension.ts index 953d73d..d618c14 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1719,7 +1719,7 @@ function usageWebviewHtml(profileLabel: string): string { - +
From 12c46e4aa7df660cf71465934ae5142fc326fe25 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 12:45:12 +0500 Subject: [PATCH 37/43] feat(usage): rename the workspace row back to Codebase The metric is scoped to the current workspace's all-time usage, so the panel stat chip, quick-pick item and status-bar tooltip row all say 'Codebase' again (the Models-tab chip, which sums the whole selected window, keeps the accurate 'Total spend' label). --- CHANGELOG.md | 2 +- src/extension.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7f6b6..e4e1ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente - **`[Usage]` Panel polish + chat-completion charts.** Hovering anywhere on a chart (not just on points) highlights the nearest day with a guide line + dots and a cursor-follow tooltip; on the Models tab the tooltip lists every model's spend for that day. Two new tabs — **Suggested** and **Approved** — chart inline chat completions with whole-number axes and honest tooltips (the day's suggestion/approval counts, not chat token totals); both series share the exact same day buckets, so hovers never resolve to undefined. Acceptances are detected with a bounded heuristic: VS Code's stable API exposes no inline-completion acceptance event, so committing a ghost text is recognized by the document insert starting exactly at the suggested position with a matching multi-character text (30s window, cleared on first match — see `matchesAcceptance`). The default chart window is **Lifetime**, switchable live via the **Window** button (Week → 14 days → Month → Lifetime). The panel brand shows only the profile name, the legend swatches are square and text-aligned, and axes keep round equal tick steps. -- **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Total spend, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (overlapping colored line charts per model by spend, with a legend and hover tooltips for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is renamed to **Total spend** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Styled action buttons sit in the panel's top-right corner: **Set targets**, **Rename** (when multiple profiles exist) and **Refresh** — wired through `vscode.postMessage`, and a refresh keeps the active tab (data is pushed in place, the page is never reloaded; the background loop still runs at `usageRefreshIntervalSeconds`). Chart axes use round tick steps (1/2/2.5/5 × 10ⁿ) so gaps are always equal and clean (no more 1.25/2.50/3.75 divisions). +- **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Codebase, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (overlapping colored line charts per model by spend, with a legend and hover tooltips for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is labeled **Codebase** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Styled action buttons sit in the panel's top-right corner: **Set targets**, **Rename** (when multiple profiles exist) and **Refresh** — wired through `vscode.postMessage`, and a refresh keeps the active tab (data is pushed in place, the page is never reloaded; the background loop still runs at `usageRefreshIntervalSeconds`). Chart axes use round tick steps (1/2/2.5/5 × 10ⁿ) so gaps are always equal and clean (no more 1.25/2.50/3.75 divisions). - **`[Usage]` Realtime updates.** The status bar, tooltip and usage panel now refresh on a configurable background cadence (`opencodego.usageRefreshIntervalSeconds`, default 60s, min 5s), so terminal-side OpenCode CLI usage, server meters and midnight day rollovers appear without waiting for the next chat request. Changing any usage-view setting repaints the UI immediately, and the refresh interval itself applies live on the next tick. diff --git a/src/extension.ts b/src/extension.ts index d618c14..9fa9474 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -886,7 +886,7 @@ export function activate(context: vscode.ExtensionContext) { // history) — replaces the old "Latest Session (est)" estimate row. if (usageCodebaseRowVisible()) { const codebaseItem: vscode.QuickPickItem = { - label: "$(repo) Total spend", + label: "$(repo) Codebase", description: formatUsd(summary.codebase.cost), detail: `${formatTokenCount(summary.codebase.tokens)} tokens · ${formatCount(summary.codebase.requests)} requests`, alwaysShow: true, @@ -1527,7 +1527,7 @@ function usageWebviewData(): Record | undefined { completions, rings, stats: { - total: { label: "Total spend", cost: s.codebase.cost, tokens: s.codebase.tokens, requests: s.codebase.requests }, + total: { label: "Codebase", cost: s.codebase.cost, tokens: s.codebase.tokens, requests: s.codebase.requests }, today: { label: "Today", cost: s.today.cost, tokens: s.today.tokens, requests: s.today.requests }, yesterday: { label: "Yesterday", cost: s.yesterday.cost, tokens: s.yesterday.tokens, requests: s.yesterday.requests }, }, @@ -2349,7 +2349,7 @@ ${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", // stable regardless of whether a session is currently active. const deviceRows: Array<[string, number, number, number, number]> = []; if (usageCodebaseRowVisible()) { - deviceRows.push(["Total spend:", s.codebase.cost, s.codebase.requests, s.codebase.tokens, firstRowY]); + deviceRows.push(["Codebase:", s.codebase.cost, s.codebase.requests, s.codebase.tokens, firstRowY]); } const codebaseOffset = usageCodebaseRowVisible() ? 1 : 0; deviceRows.push(["Today:", s.today.cost, s.today.requests, s.today.tokens, firstRowY + codebaseOffset * rowGap]); From 599b5d3a06663c6f0f40af696f5eea00ee917c98 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 13:00:16 +0500 Subject: [PATCH 38/43] fix(usage): harden CLI history reads + surface failures The CLI history read silently failed in the extension host (today / yesterday / codebase all showed 0 while the fetched quota worked). The read is now resilient and observable: - sqlite runs with .timeout 5000, a 10s spawn timeout and a 64MB maxBuffer (the full-history JSON can outgrow execFileSync's default 1MB cap), - a second attempt retries transient busy/lock states, - failures are logged with the exact error to the 'OpenCode Go Usage' output channel, - activation logs a startup diagnostic line: 'CLI history available: ...' with today/codebase request counts, so a silent zero state is never mysterious again. --- src/extension.ts | 13 +++++++ src/goUsageTracker.ts | 84 +++++++++++++++++++++++++++---------------- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 9fa9474..d853b00 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -825,6 +825,19 @@ export function activate(context: vscode.ExtensionContext) { ensureUsageStatusBar(context); ensureGoUsageStatusBar(context); + // Startup diagnostic: report whether the CLI history is readable so any + // silent zero-usage state is immediately visible in the usage output + // channel (the tracker also logs the exact failure reason on error). + { + const startupTracker = activeGoUsageTracker(); + if (startupTracker) { + const startupSummary = startupTracker.getSummary(); + goUsageLogChannel.appendLine( + `[go-usage] CLI history available: ${String(startupSummary.sqliteAvailable)} — ` + + `today=${String(startupSummary.today.requests)} req, codebase=${String(startupSummary.codebase.requests)} req`, + ); + } + } // Pull the server-accurate account meters once at startup (TTL-guarded). void (async () => { const apiKey = await context.secrets.get(SECRET_KEY); diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index edad637..0ef8dd6 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -21,7 +21,7 @@ import { GO_SERVER_USAGE_KEY, type UsageTodayYesterdaySource, } from "./config"; -import { formatCount, formatTokenCount, formatUsd, formatRelativeTime } from "./utils"; +import { formatCount, formatTokenCount, formatUsd, formatRelativeTime, getErrorMessage } from "./utils"; export { GO_LIMITS } from "./config"; @@ -493,6 +493,13 @@ export function buildUsageSeries( */ const HISTORY_READ_TTL_MS = 3_000; let historyCache: { rows: HistoryRow[] | null; fetchedAt: number } | undefined; +/** Surfaces CLI-history read failures in the usage output channel. */ +let historyReadDiagnostic: ((message: string) => void) | undefined; + +/** Wire the diagnostic sink (called once per tracker, last one wins). */ +export function setHistoryReadDiagnostic(log: (message: string) => void): void { + historyReadDiagnostic = log; +} function readOpenCodeHistory(): HistoryRow[] | null { const now = Date.now(); @@ -505,37 +512,51 @@ function readOpenCodeHistory(): HistoryRow[] | null { } function readOpenCodeHistoryUncached(): HistoryRow[] | null { - if (!fs.existsSync(OPENCODE_DB_PATH)) return null; - - try { - const result = execFileSync("sqlite3", ["-readonly", "-json", OPENCODE_DB_PATH, HISTORY_ROWS_SQL], { - timeout: 5000, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }); - const rows: unknown = JSON.parse(result); - if (!Array.isArray(rows)) return null; - return rows - .filter((row): row is HistoryRow => { - if (!row || typeof row !== "object") return false; - const candidate = row as Partial; - return ( - typeof candidate.createdMs === "number" && candidate.createdMs > 0 && typeof candidate.cost === "number" && candidate.cost >= 0 - ); - }) - .map((row) => ({ - createdMs: row.createdMs, - cost: row.cost, - tokensInput: positiveNumberish(row.tokensInput), - tokensOutput: positiveNumberish(row.tokensOutput), - tokensReasoning: positiveNumberish(row.tokensReasoning), - tokensCacheRead: positiveNumberish(row.tokensCacheRead), - cwd: typeof row.cwd === "string" && row.cwd.trim() ? row.cwd : undefined, - modelId: typeof row.modelId === "string" && row.modelId.trim() ? row.modelId : undefined, - })); - } catch { + if (!fs.existsSync(OPENCODE_DB_PATH)) { + historyReadDiagnostic?.(`[go-usage] CLI history: database not found at ${OPENCODE_DB_PATH}`); return null; } + + // Two attempts: transient busy/lock states (e.g. the CLI checkpointing a + // large WAL) resolve within the 5s sqlite busy-timeout. + for (let attempt = 0; attempt < 2; attempt++) { + try { + const result = execFileSync("sqlite3", ["-readonly", "-cmd", ".timeout 5000", "-json", OPENCODE_DB_PATH, HISTORY_ROWS_SQL], { + timeout: 10_000, + maxBuffer: 64 * 1024 * 1024, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + const rows: unknown = JSON.parse(result); + if (!Array.isArray(rows)) return null; + return rows + .filter((row): row is HistoryRow => { + if (!row || typeof row !== "object") return false; + const candidate = row as Partial; + return ( + typeof candidate.createdMs === "number" && candidate.createdMs > 0 && typeof candidate.cost === "number" && candidate.cost >= 0 + ); + }) + .map((row) => ({ + createdMs: row.createdMs, + cost: row.cost, + tokensInput: positiveNumberish(row.tokensInput), + tokensOutput: positiveNumberish(row.tokensOutput), + tokensReasoning: positiveNumberish(row.tokensReasoning), + tokensCacheRead: positiveNumberish(row.tokensCacheRead), + cwd: typeof row.cwd === "string" && row.cwd.trim() ? row.cwd : undefined, + modelId: typeof row.modelId === "string" && row.modelId.trim() ? row.modelId : undefined, + })); + } catch (error) { + const message = getErrorMessage(error); + if (attempt === 0) { + historyReadDiagnostic?.(`[go-usage] CLI history read failed (attempt 1): ${message}. Retrying…`); + } else { + historyReadDiagnostic?.(`[go-usage] CLI history read failed: ${message}`); + } + } + } + return null; } // ─── Exported tracker class ────────────────────────────────────────────────── @@ -576,6 +597,9 @@ export class GoUsageTracker { ) { this.log = log; this.costResolver = costResolver; + if (log) { + setHistoryReadDiagnostic(log); + } this.restore(); // Fast startup: show the last successful server snapshot immediately // instead of 0s until the TTL-guarded refetch lands. `serverUsageFetchedAt` From 40b424221f5fd1f6417f8d6815405273d5f16f34 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 13:20:15 +0500 Subject: [PATCH 39/43] fix(usage): read CLI history via node:sqlite, no binary dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the flaky zero-usage: 'sqlite3' resolves to the Android SDK binary (~/Android/Sdk/platform-tools/sqlite3), which is only on PATH when VS Code launches from a shell that exports it — desktop-launched windows got ENOENT and silently showed 0 for Today/Yesterday/Codebase. The history is now read through Node's built-in node:sqlite (DatabaseSync, readOnly) first — no external binary at all — with a retry for busy WAL states; the sqlite3 binary remains as fallback, resolved from PATH plus known absolute locations (system, Homebrew, Android SDK). All failures are logged with the exact error to the 'OpenCode Go Usage' output channel. --- CHANGELOG.md | 2 + src/goUsageTracker.ts | 160 ++++++++++++++++++++++++++++++++---------- 2 files changed, 126 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4e1ad7..a3bb2d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Fixed +- **`[Usage]` SQLite reads no longer depend on the `sqlite3` binary.** The zero-usage mystery was the Android SDK's `sqlite3` (`~/Android/Sdk/platform-tools/sqlite3`) being on the PATH only when VS Code launches from a shell that exports it — desktop-launched windows silently lost all CLI history (Today/Yesterday/Codebase = 0 while the fetched quota kept working). The CLI history is now read through Node's built-in `node:sqlite` first (zero external dependencies, retried twice on busy WAL states), falling back to the `sqlite3` binary resolved from PATH **plus** known locations (system, Homebrew, Android SDK). Failures are logged with the exact error to the "OpenCode Go Usage" output channel. + - **`[Usage]` Panel polish + chat-completion charts.** Hovering anywhere on a chart (not just on points) highlights the nearest day with a guide line + dots and a cursor-follow tooltip; on the Models tab the tooltip lists every model's spend for that day. Two new tabs — **Suggested** and **Approved** — chart inline chat completions with whole-number axes and honest tooltips (the day's suggestion/approval counts, not chat token totals); both series share the exact same day buckets, so hovers never resolve to undefined. Acceptances are detected with a bounded heuristic: VS Code's stable API exposes no inline-completion acceptance event, so committing a ghost text is recognized by the document insert starting exactly at the suggested position with a matching multi-character text (30s window, cleared on first match — see `matchesAcceptance`). The default chart window is **Lifetime**, switchable live via the **Window** button (Week → 14 days → Month → Lifetime). The panel brand shows only the profile name, the legend swatches are square and text-aligned, and axes keep round equal tick steps. - **`[Usage]` Full-page usage panel.** The usage webview is now a complete dashboard (inspired by the reference page): subscription meters as animated rings, stat chips for Today / Yesterday / Codebase, an interactive chart area with Spend / Requests / Tokens tabs (all line charts) and a By-model tab (overlapping colored line charts per model by spend, with a legend and hover tooltips for the model · day · spend/tokens/requests breakdown). Data follows the cursor with a tooltip on every chart element. Chart window: `opencodego.usageChartDays` (default 14 days). The all-time workspace row is labeled **Codebase** everywhere (panel, quick-pick, status-bar tooltip); the old "Top model" / "Models used" chips are gone. Styled action buttons sit in the panel's top-right corner: **Set targets**, **Rename** (when multiple profiles exist) and **Refresh** — wired through `vscode.postMessage`, and a refresh keeps the active tab (data is pushed in place, the page is never reloaded; the background loop still runs at `usageRefreshIntervalSeconds`). Chart axes use round tick steps (1/2/2.5/5 × 10ⁿ) so gaps are always equal and clean (no more 1.25/2.50/3.75 divisions). diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index 0ef8dd6..d66df4c 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -517,45 +517,133 @@ function readOpenCodeHistoryUncached(): HistoryRow[] | null { return null; } - // Two attempts: transient busy/lock states (e.g. the CLI checkpointing a - // large WAL) resolve within the 5s sqlite busy-timeout. - for (let attempt = 0; attempt < 2; attempt++) { - try { - const result = execFileSync("sqlite3", ["-readonly", "-cmd", ".timeout 5000", "-json", OPENCODE_DB_PATH, HISTORY_ROWS_SQL], { - timeout: 10_000, - maxBuffer: 64 * 1024 * 1024, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }); - const rows: unknown = JSON.parse(result); - if (!Array.isArray(rows)) return null; - return rows - .filter((row): row is HistoryRow => { - if (!row || typeof row !== "object") return false; - const candidate = row as Partial; - return ( - typeof candidate.createdMs === "number" && candidate.createdMs > 0 && typeof candidate.cost === "number" && candidate.cost >= 0 - ); - }) - .map((row) => ({ - createdMs: row.createdMs, - cost: row.cost, - tokensInput: positiveNumberish(row.tokensInput), - tokensOutput: positiveNumberish(row.tokensOutput), - tokensReasoning: positiveNumberish(row.tokensReasoning), - tokensCacheRead: positiveNumberish(row.tokensCacheRead), - cwd: typeof row.cwd === "string" && row.cwd.trim() ? row.cwd : undefined, - modelId: typeof row.modelId === "string" && row.modelId.trim() ? row.modelId : undefined, - })); - } catch (error) { - const message = getErrorMessage(error); - if (attempt === 0) { - historyReadDiagnostic?.(`[go-usage] CLI history read failed (attempt 1): ${message}. Retrying…`); - } else { - historyReadDiagnostic?.(`[go-usage] CLI history read failed: ${message}`); + // The `sqlite3` binary may be missing from the extension host's PATH (it is + // often only available from the Android SDK, e.g. launched from a terminal), + // so Node's built-in reader is tried first — zero external dependencies. + const viaNode = readHistoryViaNodeSqlite(); + if (viaNode !== undefined) { + return viaNode; + } + + return readHistoryViaSqliteCli(); +} + +/** Normalize raw rows (shared by both readers). */ +function normalizeHistoryRows(rows: unknown): HistoryRow[] { + if (!Array.isArray(rows)) return []; + return rows + .filter((row): row is HistoryRow => { + if (!row || typeof row !== "object") return false; + const candidate = row as Partial; + return ( + typeof candidate.createdMs === "number" && candidate.createdMs > 0 && typeof candidate.cost === "number" && candidate.cost >= 0 + ); + }) + .map((row) => ({ + createdMs: row.createdMs, + cost: row.cost, + tokensInput: positiveNumberish(row.tokensInput), + tokensOutput: positiveNumberish(row.tokensOutput), + tokensReasoning: positiveNumberish(row.tokensReasoning), + tokensCacheRead: positiveNumberish(row.tokensCacheRead), + cwd: typeof row.cwd === "string" && row.cwd.trim() ? row.cwd : undefined, + modelId: typeof row.modelId === "string" && row.modelId.trim() ? row.modelId : undefined, + })); +} + +/** + * Read the CLI history with Node's built-in `node:sqlite` (no binary on the + * host PATH needed). Returns `undefined` when the module is unavailable on + * this host so the caller can fall back to the `sqlite3` binary. + */ +function readHistoryViaNodeSqlite(): HistoryRow[] | null | undefined { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseSync } = require("node:sqlite") as { + DatabaseSync?: new ( + path: string, + options?: { readOnly?: boolean }, + ) => { + prepare(sql: string): { all(): Record[] }; + close(): void; + }; + }; + if (typeof DatabaseSync !== "function") { + return undefined; + } + // Transient busy/lock states (CLI checkpointing the WAL) resolve quickly. + for (let attempt = 0; attempt < 2; attempt++) { + try { + const db = new DatabaseSync(OPENCODE_DB_PATH, { readOnly: true }); + try { + const rows = db.prepare(HISTORY_ROWS_SQL).all(); + return rows.length > 0 ? normalizeHistoryRows(rows) : null; + } finally { + db.close(); + } + } catch (error) { + const message = getErrorMessage(error); + if (attempt === 0) { + historyReadDiagnostic?.(`[go-usage] node:sqlite read failed (attempt 1): ${message}. Retrying…`); + } else { + historyReadDiagnostic?.(`[go-usage] node:sqlite read failed: ${message}`); + } + } + } + return null; + } catch (error) { + historyReadDiagnostic?.(`[go-usage] node:sqlite unavailable (${getErrorMessage(error)}); falling back to the sqlite3 binary.`); + return undefined; + } +} + +/** + * Candidate `sqlite3` binaries: the PATH-resolved name first, then absolute + * paths from common installs (system, Homebrew, Android SDK) — the Android + * SDK binary is what most dev machines actually have, and it is frequently + * missing from the extension host's PATH. + */ +function sqliteCliCandidates(): string[] { + const home = os.homedir(); + return [ + "sqlite3", + "/usr/bin/sqlite3", + "/usr/local/bin/sqlite3", + "/opt/homebrew/bin/sqlite3", + path.join(home, "Android", "Sdk", "platform-tools", "sqlite3"), + path.join(home, "Library", "Android", "sdk", "platform-tools", "sqlite3"), + ]; +} + +function readHistoryViaSqliteCli(): HistoryRow[] | null { + for (const binary of sqliteCliCandidates()) { + for (let attempt = 0; attempt < 2; attempt++) { + try { + const result = execFileSync(binary, ["-readonly", "-cmd", ".timeout 5000", "-json", OPENCODE_DB_PATH, HISTORY_ROWS_SQL], { + timeout: 10_000, + maxBuffer: 64 * 1024 * 1024, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + const rows: unknown = JSON.parse(result); + return Array.isArray(rows) ? normalizeHistoryRows(rows) : null; + } catch (error) { + const message = getErrorMessage(error); + // ENOENT just means this candidate isn't present — try the next one. + if (attempt === 0 && message.includes("ENOENT")) { + break; + } + if (attempt === 0) { + historyReadDiagnostic?.(`[go-usage] sqlite3 read failed (attempt 1): ${message}. Retrying…`); + } else { + historyReadDiagnostic?.(`[go-usage] sqlite3 read failed (${binary}): ${message}`); + } } } } + historyReadDiagnostic?.( + "[go-usage] CLI history unavailable: no SQLite reader found (node:sqlite missing and no sqlite3 binary on PATH).", + ); return null; } From d1c9c59fb8dfd741f309e5452130b0ef2613f0d2 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 16:26:53 +0500 Subject: [PATCH 40/43] fix(usage): freeOnly filter on the metadata-success path + escape model names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filterAvailableModels now applies definition.filterModel on BOTH the metadata-success and the error path (the success path previously skipped it, so a failed model-list fetch with a successful models.dev metadata fetch could still show paid Zen models to free-only users — flagged by the #138 review). - Model names in the usage panel legend and tooltips are HTML-escaped before innerHTML insertion (cheap insurance, flagged by the #138 review). --- src/extension.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index d853b00..6d234cc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1984,7 +1984,7 @@ function usageWebviewHtml(profileLabel: string): string { var c = el('circle', { cx: p.x, cy: p.y, r: '7', fill: 'transparent', stroke: 'none' }); c.addEventListener('mousemove', function (ev) { showTooltip(ev.clientX, ev.clientY, - '' + s.model + ' · ' + dayLabel(p.p.dayStart) + '' + fmtUsd(p.p.cost) + '' + + '' + esc(s.model) + ' · ' + dayLabel(p.p.dayStart) + '' + fmtUsd(p.p.cost) + '' + '
' + fmtCount(p.p.tokens) + ' tokens · ' + fmtCount(p.p.requests) + ' requests
'); }); c.addEventListener('mouseleave', hideTooltip); @@ -2029,7 +2029,7 @@ function usageWebviewHtml(profileLabel: string): string { var dd = el('circle', { cx: pt.x, cy: pt.y, r: '3.5', fill: pt.color, stroke: '#161a20', 'stroke-width': '2' }); svg.appendChild(dd); dots2.push(dd); - if (pt.p.cost > 0) lines2.push('
' + pt.model + ': ' + fmtUsd(pt.p.cost) + '
'); + if (pt.p.cost > 0) lines2.push('
' + esc(pt.model) + ': ' + fmtUsd(pt.p.cost) + '
'); }); showTooltip(ev.clientX, ev.clientY, '' + dayLabel(best.p.dayStart) + '' + @@ -2042,7 +2042,7 @@ function usageWebviewHtml(profileLabel: string): string { var legend = document.getElementById('legend'); legend.innerHTML = series.slice(0, 8).map(function (s, i) { var color = MODEL_COLORS[i % MODEL_COLORS.length]; - return '' + s.model + ''; + return '' + esc(s.model) + ''; }).join('') + (series.length > 8 ? '+' + (series.length - 8) + ' more' : ''); } @@ -3493,7 +3493,10 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider !KNOWN_UNAVAILABLE_MODEL_IDS.has(modelId) && !shouldHideDeprecatedModel(modelId, this.baseVendor, metadataSnapshot), + (modelId) => + !KNOWN_UNAVAILABLE_MODEL_IDS.has(modelId) && + !shouldHideDeprecatedModel(modelId, this.baseVendor, metadataSnapshot) && + (this.definition.filterModel?.(modelId) ?? true), ); const removedModelIds = uniqueModelIds.filter((modelId) => !filteredModelIds.includes(modelId)); From 5da9b6c401d48e2331bd0a347a9b949850026d35 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 16:30:48 +0500 Subject: [PATCH 41/43] =?UTF-8?q?fix(autocomplete):=20address=20#136=20rev?= =?UTF-8?q?iew=20=E2=80=94=20key=20fallback,=20failure=20logs,=20indentati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveApiKey now uses the same resolution order as the chat path: the active profile's own key first (multi-profile / BYOK-group users), then the extension secret. - The completion engine logs every failure (request error, non-OK status, stream interruption) instead of silently returning nothing — testers can now report exact reasons from the OpenCode Completions output channel. - cleanCompletion strips leading spaces/tabs but preserves leading newlines, so a completion that continues on a nested line keeps its line break (review point 5); covered by a new unit test (276 tests). --- src/autocomplete/engine.ts | 19 +++++++++++++++---- src/extension.ts | 4 +++- src/test/autocomplete.test.ts | 9 ++++++++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/autocomplete/engine.ts b/src/autocomplete/engine.ts index 0417292..53efd86 100644 --- a/src/autocomplete/engine.ts +++ b/src/autocomplete/engine.ts @@ -81,12 +81,15 @@ export class ChatCompletionEngine implements CompletionEngine { body: JSON.stringify(body), signal: AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)]), }); - } catch { + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.log?.(`[completions] request failed: ${reason} (model=${ctx.modelId})`); // Network error or abort — treat as no completion. return { text: undefined, durationMs: Date.now() - started }; } if (!response.ok || !response.body) { + this.log?.(`[completions] non-OK response: HTTP ${String(response.status)} ${response.statusText} (model=${ctx.modelId})`); return { text: undefined, durationMs: Date.now() - started }; } @@ -112,8 +115,10 @@ export class ChatCompletionEngine implements CompletionEngine { } } } - } catch { + } catch (error) { // Aborted or timed out mid-stream — keep what we have. + const reason = error instanceof Error ? error.message : String(error); + this.log?.(`[completions] stream interrupted: ${reason} (model=${ctx.modelId})`); } const text = cleanCompletion(collected); @@ -122,11 +127,17 @@ export class ChatCompletionEngine implements CompletionEngine { } } -/** Trim whitespace/formatting noise from a raw completion. */ +/** + * Trim formatting noise from a raw completion. Fences are removed entirely; + * trailing whitespace is trimmed. Leading spaces/tabs are stripped (the + * cursor already sits on indented code, so a leading space would double it), + * but leading NEWLINES are preserved — a completion that continues on a new + * line (nested blocks) must keep its line break. + */ export function cleanCompletion(raw: string): string { return raw .replace(/^```[a-zA-Z0-9_-]*\s*\n?/, "") .replace(/\n?```\s*$/, "") - .replace(/^\s+/, "") + .replace(/^[ \t]+/, "") .replace(/\s+$/, ""); } diff --git a/src/extension.ts b/src/extension.ts index 6d234cc..66c4d5d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1099,7 +1099,9 @@ export function activate(context: vscode.ExtensionContext) { // `opencodego.inlineSuggestions`; the provider reads the config live. registerInlineCompletions(context, { chatCompletionsUrl: PROVIDERS[GO_VENDOR].chatCompletionsUrl, - resolveApiKey: async () => _extensionContext?.secrets.get(SECRET_KEY), + // Same resolution order as the chat path: the active profile's own key + // first (covers multi-profile / BYOK-group setups), then the secret. + resolveApiKey: async () => profileApiKeys.get(activeProfileFingerprint) ?? _extensionContext?.secrets.get(SECRET_KEY), }); } diff --git a/src/test/autocomplete.test.ts b/src/test/autocomplete.test.ts index ee4703e..57f5e79 100644 --- a/src/test/autocomplete.test.ts +++ b/src/test/autocomplete.test.ts @@ -92,7 +92,14 @@ describe("autocomplete — engine parsing", () => { 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;"); + // leading spaces are stripped, the leading newline is kept + assert.equal(cleanCompletion(" \nconst y = 2;\n "), "\nconst y = 2;"); + }); + + it("cleanCompletion strips leading spaces/tabs but keeps leading newlines", () => { + // A completion continuing on a new (nested) line must keep its line break. + assert.equal(cleanCompletion(" return true;"), "return true;"); + assert.equal(cleanCompletion("\n return inner;\n }"), "\n return inner;\n }"); }); }); From 1c26fd17f2747983ecdd44f28c29306e41667567 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Thu, 13 Aug 2026 16:36:02 +0500 Subject: [PATCH 42/43] fix(models): add qwen3.7-plus to the bundled fallback lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model was already in the settings enum and the tracker's bundled pricing table, but missing from the Go provider's fallback model list and the bundled metadata snapshot — so offline/degraded mode never offered it (#136 review). Now consistent everywhere. --- src/extension.ts | 1 + src/metadata.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index 66c4d5d..ccedda7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -504,6 +504,7 @@ const PROVIDERS: Record = (() "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", + "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "gpt-5.6-luna", diff --git a/src/metadata.ts b/src/metadata.ts index 4fc1627..f8cafd0 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -179,6 +179,7 @@ const MODEL_LIMITS_BY_PROVIDER: Record Date: Thu, 13 Aug 2026 16:50:52 +0500 Subject: [PATCH 43/43] docs(changelog): note autocomplete spend attribution as a known limitation #136 review point 3 asked for at minimum a docs note that completion requests bypass tracker.record(); the Suggested/Approved counters cover counts but USD cost attribution is a planned follow-up. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3bb2d3..fded16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Added +- **`[Autocomplete]` Known limitation (follow-up planned):** inline-completion requests are not yet wired into the Go usage tracker's cost accounting (`tracker.record()` only runs in the chat provider path). The panel does track **Suggested / Approved** counts per day (see the usage dashboard entry), but the USD cost of completions is not attributed until a follow-up ships the transport summary from the completion engine. This is tracked as a documented TODO on #136/#138 rather than an oversight. + - **`[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).