Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5a6e17a
feat(autocomplete): inline code suggestions engine + provider (issue …
Fahad090NP Aug 12, 2026
a1819e9
feat(autocomplete): wire provider and add settings
Fahad090NP Aug 12, 2026
42e810d
docs(autocomplete): document the experimental inline suggestions
Fahad090NP Aug 12, 2026
63e29a6
test(autocomplete): commit the missing test suite
Fahad090NP Aug 12, 2026
d01ce62
chore(tsconfig): give scripts/ its own editor type-check project
Fahad090NP Aug 12, 2026
76213fd
chore(eslint): scripts are now covered by scripts/tsconfig.json
Fahad090NP Aug 12, 2026
963ac4a
chore(tsconfig): exclude everything .gitignore ignores
Fahad090NP Aug 12, 2026
a474175
chore(tooling): shared .gitignore-patterns helper
Fahad090NP Aug 12, 2026
bbcbae5
fix(config): declare import.meta.dirname for the editor's inferred pr…
Fahad090NP Aug 12, 2026
93df430
feat(autocomplete): dedicated output channel for completions diagnostics
Fahad090NP Aug 12, 2026
ab093f5
fix(package): stop excluding out/autocomplete from the VSIX
Fahad090NP Aug 12, 2026
d912b1b
feat(autocomplete): model dropdown instead of a free-form input
Fahad090NP Aug 12, 2026
9857690
Merge branch 'main' into feat/inline-completions
Fahad090NP Aug 13, 2026
0e9ea30
feat(autocomplete): make timing/size knobs config-driven
Fahad090NP Aug 13, 2026
29ddb12
feat(settings): expose the five inline-suggestion knobs
Fahad090NP Aug 13, 2026
93b09c3
test(autocomplete): cover configurable debounce and window knobs
Fahad090NP Aug 13, 2026
9f7a534
docs(autocomplete): document the five tuning knobs
Fahad090NP Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +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. 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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,16 @@ 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.
- 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

- **Native endpoint routing** per family (see [Models](#-models) table)
Expand Down
25 changes: 19 additions & 6 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,30 @@
// 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("!"));
// 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.
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
Expand All @@ -51,6 +61,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,
},
Expand Down
57 changes: 57 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,63 @@
"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",
"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 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": [
Expand Down
30 changes: 30 additions & 0 deletions scripts/gitignore.ts
Original file line number Diff line number Diff line change
@@ -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("!"));
}
161 changes: 161 additions & 0 deletions scripts/probe-completion-latency.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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);
}
11 changes: 11 additions & 0 deletions scripts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "..",
"types": ["node"]
},
"include": ["."],
// Mirrors .gitignore: nothing gitignored may enter the TS projects.
"exclude": ["node_modules", "../node_modules", "../out", "../tmp"]
}
Loading
Loading