diff --git a/packages/types/src/__tests__/autocomplete.spec.ts b/packages/types/src/__tests__/autocomplete.spec.ts new file mode 100644 index 0000000000..542bbbf2bf --- /dev/null +++ b/packages/types/src/__tests__/autocomplete.spec.ts @@ -0,0 +1,111 @@ +import { + AUTOCOMPLETE_DEFAULTS, + autocompleteConfigSchema, + resolveAutocompleteConfig, + type AutocompleteConfig, +} from "../autocomplete.js" +import { GLOBAL_SECRET_KEYS, globalSettingsSchema, isSecretStateKey } from "../global-settings.js" + +describe("autocompleteConfigSchema", () => { + it("accepts an empty object so partially-persisted settings round-trip", () => { + expect(autocompleteConfigSchema.parse({})).toEqual({}) + }) + + it("rejects out-of-range numeric fields", () => { + expect(autocompleteConfigSchema.safeParse({ debounceMs: -1 }).success).toBe(false) + expect(autocompleteConfigSchema.safeParse({ debounceMs: 2_001 }).success).toBe(false) + expect(autocompleteConfigSchema.safeParse({ temperature: 2.5 }).success).toBe(false) + expect(autocompleteConfigSchema.safeParse({ maxOutputTokens: 0 }).success).toBe(false) + }) + + it("rejects non-integer token budgets", () => { + expect(autocompleteConfigSchema.safeParse({ maxPrefixTokens: 512.5 }).success).toBe(false) + }) + + it("rejects unknown provider and template ids", () => { + expect(autocompleteConfigSchema.safeParse({ provider: "copilot" }).success).toBe(false) + expect(autocompleteConfigSchema.safeParse({ fimTemplate: "gpt4" }).success).toBe(false) + }) + + it("caps stop sequences", () => { + const tooMany = Array.from({ length: 17 }, (_, i) => `stop-${i}`) + expect(autocompleteConfigSchema.safeParse({ stopSequences: tooMany }).success).toBe(false) + expect(autocompleteConfigSchema.safeParse({ stopSequences: tooMany.slice(0, 16) }).success).toBe(true) + }) +}) + +describe("resolveAutocompleteConfig", () => { + it("applies every default when nothing is persisted", () => { + const resolved = resolveAutocompleteConfig(undefined) + + expect(resolved.enabled).toBe(AUTOCOMPLETE_DEFAULTS.ENABLED) + expect(resolved.provider).toBe(AUTOCOMPLETE_DEFAULTS.PROVIDER) + expect(resolved.baseUrl).toBe(AUTOCOMPLETE_DEFAULTS.BASE_URL) + expect(resolved.triggerMode).toBe(AUTOCOMPLETE_DEFAULTS.TRIGGER_MODE) + expect(resolved.debounceMs).toBe(AUTOCOMPLETE_DEFAULTS.DEBOUNCE_MS) + expect(resolved.multilineMode).toBe(AUTOCOMPLETE_DEFAULTS.MULTILINE_MODE) + expect(resolved.temperature).toBe(AUTOCOMPLETE_DEFAULTS.TEMPERATURE) + expect(resolved.fimTemplate).toBe(AUTOCOMPLETE_DEFAULTS.FIM_TEMPLATE) + expect(resolved.disabledLanguages).toEqual([]) + }) + + it("is defaulted-not-empty for an empty persisted object", () => { + expect(resolveAutocompleteConfig({})).toEqual(resolveAutocompleteConfig(undefined)) + }) + + it("preserves explicitly persisted values", () => { + const config: AutocompleteConfig = { + enabled: true, + provider: "codestral", + modelId: "codestral-latest", + baseUrl: "https://example.test", + triggerMode: "manual", + debounceMs: 0, + temperature: 0.5, + disabledLanguages: ["markdown"], + } + + const resolved = resolveAutocompleteConfig(config) + + expect(resolved).toMatchObject(config) + }) + + it("keeps falsy overrides instead of falling back to defaults", () => { + // `?? ` rather than `||` matters here: 0ms debounce and temperature 0 are meaningful. + const resolved = resolveAutocompleteConfig({ debounceMs: 0, temperature: 0, useOpenTabs: false }) + + expect(resolved.debounceMs).toBe(0) + expect(resolved.temperature).toBe(0) + expect(resolved.useOpenTabs).toBe(false) + }) + + it("leaves fields without a sensible default undefined", () => { + const resolved = resolveAutocompleteConfig({}) + + expect(resolved.modelId).toBeUndefined() + expect(resolved.chatFallbackProvider).toBeUndefined() + expect(resolved.stopSequences).toBeUndefined() + }) + + it("produces a value the schema still accepts", () => { + expect(autocompleteConfigSchema.safeParse(resolveAutocompleteConfig(undefined)).success).toBe(true) + }) +}) + +describe("autocomplete settings storage wiring", () => { + it("exposes autocompleteConfig and autocompleteApiKey on globalSettingsSchema", () => { + const keys = globalSettingsSchema.keyof().options as string[] + + expect(keys).toContain("autocompleteConfig") + expect(keys).toContain("autocompleteApiKey") + }) + + it("routes autocompleteApiKey to secret storage", () => { + expect(GLOBAL_SECRET_KEYS).toContain("autocompleteApiKey") + expect(isSecretStateKey("autocompleteApiKey")).toBe(true) + }) + + it("does not treat autocompleteConfig as a secret", () => { + expect(isSecretStateKey("autocompleteConfig")).toBe(false) + }) +}) diff --git a/packages/types/src/autocomplete.ts b/packages/types/src/autocomplete.ts new file mode 100644 index 0000000000..0d2aa18861 --- /dev/null +++ b/packages/types/src/autocomplete.ts @@ -0,0 +1,397 @@ +import { z } from "zod" + +/** + * Inline autocomplete (ghost text). + * + * This is deliberately a *dedicated* configuration rather than a chat API profile: + * the model that serves tab-completion is usually a small fill-in-the-middle (FIM) + * *base* model (e.g. `qwen2.5-coder:1.5b-base`) hosted next to the editor, while the + * chat model is typically a large instruction-tuned model in the cloud. Coupling the + * two would force users to choose one at the expense of the other. + */ + +/** + * Transport used to reach the completion model. + * + * - `ollama` native FIM via `POST /api/generate` with a `suffix` field + * - `openai-compatible` `POST /v1/completions` with `prompt` + `suffix` (LM Studio, llama.cpp, vLLM, TGI) + * - `codestral` Mistral's dedicated FIM endpoint, `POST /v1/fim/completions` + * - `chat-fallback` any configured chat provider, prompted to emulate FIM (slower, less accurate) + */ +export const autocompleteProviderIds = ["ollama", "openai-compatible", "codestral", "chat-fallback"] as const + +export const autocompleteProviderSchema = z.enum(autocompleteProviderIds) + +/** + * A native-FIM transport id, or any chat provider id from the Providers tab. + * + * The union keeps editor autocomplete useful for the three native transports + * while still accepting the wider set the settings UI now offers. + */ +export type AutocompleteProviderId = z.infer | (string & {}) + +/** + * Fill-in-the-middle prompt formats. + * + * `auto` resolves the template from the model id; the remaining values pin it + * explicitly for models whose names don't advertise their family. + */ +export const fimTemplateIds = [ + "auto", + "qwen", + "starcoder", + "codestral", + "codellama", + "deepseek", + "codegemma", + "instruct", + "none", +] as const + +export const fimTemplateSchema = z.enum(fimTemplateIds) + +export type FimTemplateId = z.infer + +/** + * When completions are requested. + * + * - `automatic` as the user types (debounced) + * - `manual` only when the user invokes the trigger command/keybinding + * + * Note this is *not* VS Code's `editor.quickSuggestions`, which governs the + * IntelliSense suggest widget rather than inline ghost text. + */ +export const autocompleteTriggerModes = ["automatic", "manual"] as const + +export const autocompleteTriggerModeSchema = z.enum(autocompleteTriggerModes) + +export type AutocompleteTriggerMode = z.infer + +/** + * Whether a completion may span multiple lines. + * + * `auto` defers to the syntax tree: multi-line is allowed when the cursor sits at a + * position where a block is expected (e.g. an empty function body) and suppressed + * when it sits mid-expression. + */ +export const autocompleteMultilineModes = ["always", "never", "auto"] as const + +export const autocompleteMultilineModeSchema = z.enum(autocompleteMultilineModes) + +export type AutocompleteMultilineMode = z.infer + +/** + * Single source of truth for autocomplete defaults. + * + * Every field on {@link autocompleteConfigSchema} is optional so that persisted + * settings can round-trip partially; readers resolve missing values from here via + * `ClineProvider.getState()` so the extension host and the webview never disagree. + */ +export const AUTOCOMPLETE_DEFAULTS = { + ENABLED: false, + PROVIDER: "ollama", + BASE_URL: "http://localhost:11434", + TRIGGER_MODE: "automatic", + MULTILINE_MODE: "auto", + FIM_TEMPLATE: "auto", + + /** Idle time after the last keystroke before a request is issued. */ + DEBOUNCE_MS: 300, + /** Characters that must be typed since the last suggestion before auto-triggering again. */ + MIN_CHARS_TYPED: 0, + + /** Assumed model context window when the endpoint doesn't report one. */ + CONTEXT_LENGTH: 8192, + MAX_PREFIX_TOKENS: 1024, + /** + * Deliberately close to the prefix budget: for FIM the code *after* the cursor + * is what bounds the hole, and starving it collapses the completion toward a + * plain continuation on exactly the mid-file edits FIM exists to serve. + */ + MAX_SUFFIX_TOKENS: 768, + MAX_SNIPPET_TOKENS: 512, + /** + * Ghost text is read at a glance, so a long completion is not a better one. + * A tight ceiling also cuts short the degenerate runs small models fall into + * once they pass the code that was actually wanted. + */ + MAX_OUTPUT_TOKENS: 160, + /** + * Fully greedy. Any sampling at all is a liability here: a completion is either + * the obvious next code or it should not appear, and non-zero temperature is + * what lets a small model wander into invented names and repetition loops. + */ + TEMPERATURE: 0, + + /** + * Hard ceiling on a single completion request. + * + * Sized for a hosted model rather than a local one: a large cloud model behind + * a network hop routinely needs more than a few seconds for its first token, + * and a timeout there is indistinguishable from "autocomplete is broken". + * A stale request is cancelled by the next keystroke anyway, so a generous + * ceiling costs nothing in the common case. + */ + REQUEST_TIMEOUT_MS: 20_000, + /** Ghost text is rendered from whatever has streamed in by this deadline. */ + FIRST_RENDER_BUDGET_MS: 350, + /** Wall-clock budget for all context sources combined; stragglers are dropped. */ + CONTEXT_BUDGET_MS: 120, + + CACHE_ENTRIES: 500, + + USE_RECENTLY_EDITED: true, + USE_OPEN_TABS: true, + USE_IMPORT_DEFINITIONS: true, + USE_AST: true, +} as const + +/** + * Stop sequences applied to **every** completion regardless of template. + * + * The per-family templates in `templates.ts` contribute their own control tokens, + * but the `none`/`instruct` templates contribute none — leaving the stream with no + * terminator at all. These cover the failure modes seen across model families: + * chat turn markers, markdown fences, and reasoning-block openers emitted by + * hybrid-reasoning models (LFM2.5, Qwen3, DeepSeek-R1). + */ +export const UNIVERSAL_STOP_SEQUENCES = [ + "<|endoftext|>", + "<|im_end|>", + "<|im_start|>", + "<|eot_id|>", + "<|end|>", + "", + "", + "", + "", + "```", +] as const + +/** + * Reasoning/commentary blocks stripped from a completion before it is rendered. + * + * Stop sequences catch these only when the model emits the opener as its own + * token; models that emit `` mid-chunk, or that open with prose before the + * tag, slip past. Post-processing removes the block outright. + */ +export const REASONING_TAG_NAMES = ["think", "thinking", "reasoning", "reflection", "analysis"] as const + +/** + * Bounds shared by the zod schema and the settings UI so both agree on what is valid. + */ +export const AUTOCOMPLETE_LIMITS = { + DEBOUNCE_MS: { min: 0, max: 2_000 }, + MIN_CHARS_TYPED: { min: 0, max: 10 }, + CONTEXT_LENGTH: { min: 512, max: 1_048_576 }, + MAX_PREFIX_TOKENS: { min: 64, max: 16_384 }, + MAX_SUFFIX_TOKENS: { min: 0, max: 16_384 }, + MAX_SNIPPET_TOKENS: { min: 0, max: 8_192 }, + MAX_OUTPUT_TOKENS: { min: 1, max: 2_048 }, + TEMPERATURE: { min: 0, max: 2 }, + REQUEST_TIMEOUT_MS: { min: 500, max: 60_000 }, + STOP_SEQUENCES: { max: 16 }, +} as const + +export const autocompleteConfigSchema = z.object({ + // --- identity / transport --- + enabled: z.boolean().optional(), + /** + * Transport, or a chat provider id from the Providers tab. + * + * Kept as a free string rather than the `autocompleteProviderIds` enum so the + * settings UI can offer every provider the extension supports without this + * schema having to be edited each time one is added. Unknown values resolve to + * the chat-model path at runtime. + */ + provider: z.string().optional(), + modelId: z.string().optional(), + baseUrl: z.string().optional(), + /** Only meaningful when `provider === "chat-fallback"`: which chat provider to build a handler for. */ + chatFallbackProvider: z.string().optional(), + + // --- trigger / UX --- + triggerMode: autocompleteTriggerModeSchema.optional(), + debounceMs: z + .number() + .int() + .min(AUTOCOMPLETE_LIMITS.DEBOUNCE_MS.min) + .max(AUTOCOMPLETE_LIMITS.DEBOUNCE_MS.max) + .optional(), + minCharsTyped: z + .number() + .int() + .min(AUTOCOMPLETE_LIMITS.MIN_CHARS_TYPED.min) + .max(AUTOCOMPLETE_LIMITS.MIN_CHARS_TYPED.max) + .optional(), + multilineMode: autocompleteMultilineModeSchema.optional(), + + // --- prompt budget --- + contextLength: z + .number() + .int() + .min(AUTOCOMPLETE_LIMITS.CONTEXT_LENGTH.min) + .max(AUTOCOMPLETE_LIMITS.CONTEXT_LENGTH.max) + .optional(), + maxPrefixTokens: z + .number() + .int() + .min(AUTOCOMPLETE_LIMITS.MAX_PREFIX_TOKENS.min) + .max(AUTOCOMPLETE_LIMITS.MAX_PREFIX_TOKENS.max) + .optional(), + maxSuffixTokens: z + .number() + .int() + .min(AUTOCOMPLETE_LIMITS.MAX_SUFFIX_TOKENS.min) + .max(AUTOCOMPLETE_LIMITS.MAX_SUFFIX_TOKENS.max) + .optional(), + maxSnippetTokens: z + .number() + .int() + .min(AUTOCOMPLETE_LIMITS.MAX_SNIPPET_TOKENS.min) + .max(AUTOCOMPLETE_LIMITS.MAX_SNIPPET_TOKENS.max) + .optional(), + maxOutputTokens: z + .number() + .int() + .min(AUTOCOMPLETE_LIMITS.MAX_OUTPUT_TOKENS.min) + .max(AUTOCOMPLETE_LIMITS.MAX_OUTPUT_TOKENS.max) + .optional(), + temperature: z + .number() + .min(AUTOCOMPLETE_LIMITS.TEMPERATURE.min) + .max(AUTOCOMPLETE_LIMITS.TEMPERATURE.max) + .optional(), + requestTimeoutMs: z + .number() + .int() + .min(AUTOCOMPLETE_LIMITS.REQUEST_TIMEOUT_MS.min) + .max(AUTOCOMPLETE_LIMITS.REQUEST_TIMEOUT_MS.max) + .optional(), + + // --- context engine toggles --- + useRecentlyEdited: z.boolean().optional(), + useOpenTabs: z.boolean().optional(), + useImportDefinitions: z.boolean().optional(), + useAst: z.boolean().optional(), + + // --- templating overrides --- + fimTemplate: fimTemplateSchema.optional(), + stopSequences: z.array(z.string()).max(AUTOCOMPLETE_LIMITS.STOP_SEQUENCES.max).optional(), + + // --- scoping --- + /** VS Code language ids for which completions are suppressed (e.g. `markdown`, `plaintext`). */ + disabledLanguages: z.array(z.string()).optional(), +}) + +export type AutocompleteConfig = z.infer + +/** + * A named, saved autocomplete configuration. + * + * Deliberately far simpler than the chat `ProviderSettings` profile system: a + * completion profile is just a name plus the same config object, with no secret + * of its own. The API key remains a single global secret because switching + * between (say) a local Ollama profile and a cloud Codestral profile changes the + * endpoint, not the credential store — and wiping a key on every profile switch + * is exactly the bug `SECRET_STATE_KEYS` causes for chat profiles. + */ +export const autocompleteProfileSchema = z.object({ + id: z.string(), + name: z.string().min(1).max(64), + config: autocompleteConfigSchema, +}) + +export type AutocompleteProfile = z.infer + +export const AUTOCOMPLETE_PROFILE_LIMITS = { MAX_PROFILES: 20, NAME_MAX: 64 } as const + +/** + * {@link AutocompleteConfig} with every defaultable field resolved. + * + * Optional fields that have no sensible default (`modelId`, `chatFallbackProvider`, + * `stopSequences`) stay optional; everything else is guaranteed present so consumers + * never repeat `?? DEFAULT`. + */ +export type ResolvedAutocompleteConfig = Required< + Omit +> & + Pick + +/** + * Applies {@link AUTOCOMPLETE_DEFAULTS} to a persisted (possibly partial) config. + * + * Single source of truth for defaulting, shared by `ClineProvider.getState()`, + * `getStateToPostToWebview()`, and the completion engine, so the extension host and + * the webview can never disagree about what an unset field means. + */ +export const resolveAutocompleteConfig = (config?: AutocompleteConfig): ResolvedAutocompleteConfig => ({ + enabled: config?.enabled ?? AUTOCOMPLETE_DEFAULTS.ENABLED, + provider: normalizeProviderId(config?.provider) ?? AUTOCOMPLETE_DEFAULTS.PROVIDER, + modelId: config?.modelId, + baseUrl: config?.baseUrl ?? AUTOCOMPLETE_DEFAULTS.BASE_URL, + chatFallbackProvider: config?.chatFallbackProvider, + + triggerMode: config?.triggerMode ?? AUTOCOMPLETE_DEFAULTS.TRIGGER_MODE, + debounceMs: config?.debounceMs ?? AUTOCOMPLETE_DEFAULTS.DEBOUNCE_MS, + minCharsTyped: config?.minCharsTyped ?? AUTOCOMPLETE_DEFAULTS.MIN_CHARS_TYPED, + multilineMode: config?.multilineMode ?? AUTOCOMPLETE_DEFAULTS.MULTILINE_MODE, + + contextLength: config?.contextLength ?? AUTOCOMPLETE_DEFAULTS.CONTEXT_LENGTH, + maxPrefixTokens: config?.maxPrefixTokens ?? AUTOCOMPLETE_DEFAULTS.MAX_PREFIX_TOKENS, + maxSuffixTokens: config?.maxSuffixTokens ?? AUTOCOMPLETE_DEFAULTS.MAX_SUFFIX_TOKENS, + maxSnippetTokens: config?.maxSnippetTokens ?? AUTOCOMPLETE_DEFAULTS.MAX_SNIPPET_TOKENS, + maxOutputTokens: config?.maxOutputTokens ?? AUTOCOMPLETE_DEFAULTS.MAX_OUTPUT_TOKENS, + temperature: config?.temperature ?? AUTOCOMPLETE_DEFAULTS.TEMPERATURE, + requestTimeoutMs: config?.requestTimeoutMs ?? AUTOCOMPLETE_DEFAULTS.REQUEST_TIMEOUT_MS, + + useRecentlyEdited: config?.useRecentlyEdited ?? AUTOCOMPLETE_DEFAULTS.USE_RECENTLY_EDITED, + useOpenTabs: config?.useOpenTabs ?? AUTOCOMPLETE_DEFAULTS.USE_OPEN_TABS, + useImportDefinitions: config?.useImportDefinitions ?? AUTOCOMPLETE_DEFAULTS.USE_IMPORT_DEFINITIONS, + useAst: config?.useAst ?? AUTOCOMPLETE_DEFAULTS.USE_AST, + + fimTemplate: config?.fimTemplate ?? AUTOCOMPLETE_DEFAULTS.FIM_TEMPLATE, + stopSequences: config?.stopSequences, + + disabledLanguages: config?.disabledLanguages ?? [], +}) + +/** + * A model offered by the configured autocomplete endpoint. + * + * Intentionally narrower than `ModelInfo`: autocomplete never needs pricing or + * tool-use metadata, and FIM base models rarely report any of it. + */ +/** + * Maps a chat-provider id onto the native transport that serves the same server. + * + * The Providers tab and this feature name overlapping things: its `openai` entry + * ("OpenAI Compatible") and `lmstudio` both describe endpoints our + * `openai-compatible` transport already speaks to natively. A config that stored + * one of those would otherwise be routed through the slower chat path with no + * endpoint field to configure. + */ +export const normalizeProviderId = (provider: string | undefined): string | undefined => { + if (!provider) { + return undefined + } + + return PROVIDER_ALIASES[provider] ?? provider +} + +const PROVIDER_ALIASES: Readonly> = { + openai: "openai-compatible", + lmstudio: "openai-compatible", + mistral: "codestral", +} + +export interface AutocompleteModelSummary { + id: string + label?: string + contextWindow?: number + /** True when the endpoint reports the model can insert between a prefix and a suffix. */ + supportsFim?: boolean +} + +/** Outcome of a "Test connection" round trip from the settings UI. */ +export type AutocompleteValidationResult = { ok: true; detail?: string } | { ok: false; error: string } diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index dc3ea072fd..65083e37c1 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -1,5 +1,6 @@ import { z } from "zod" +import { autocompleteConfigSchema, autocompleteProfileSchema } from "./autocomplete.js" import { codebaseIndexConfigSchema, codebaseIndexModelsSchema } from "./codebase-index.js" import { experimentsSchema } from "./experiment.js" import { historyItemSchema } from "./history.js" @@ -222,6 +223,19 @@ export const globalSettingsSchema = z.object({ codebaseIndexModels: codebaseIndexModelsSchema.optional(), codebaseIndexConfig: codebaseIndexConfigSchema.optional(), + /** + * Inline autocomplete (ghost text). Deliberately independent of the chat API + * profiles: the FIM model that serves tab-completion is usually a small local + * base model, not the instruction-tuned chat model. + */ + autocompleteConfig: autocompleteConfigSchema.optional(), + /** Credential for the autocomplete endpoint. Routed to SecretStorage via GLOBAL_SECRET_KEYS. */ + autocompleteApiKey: z.string().optional(), + /** Named autocomplete presets the user can switch between (local vs. cloud, fast vs. accurate). */ + autocompleteProfiles: z.array(autocompleteProfileSchema).optional(), + /** Id of the profile in `autocompleteProfiles` currently loaded into `autocompleteConfig`. */ + activeAutocompleteProfileId: z.string().optional(), + language: languagesSchema.optional(), telemetrySetting: telemetrySettingsSchema.optional(), @@ -330,6 +344,7 @@ export const SECRET_STATE_KEYS = [ // Global secrets that are part of GlobalSettings (not ProviderSettings) export const GLOBAL_SECRET_KEYS = [ "openRouterImageApiKey", // For image generation + "autocompleteApiKey", // For inline autocomplete ] as const // Type for the actual secret storage keys diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 82588ae537..3a7ccc8149 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,4 +1,5 @@ export * from "./api.js" +export * from "./autocomplete.js" export * from "./cli.js" export * from "./cloud.js" export * from "./codebase-index.js" diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 402cd571c8..3525195f82 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -74,6 +74,11 @@ export enum TelemetryEventName { TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed", MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response", READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used", + + /** Inline autocomplete (ghost text). */ + AUTOCOMPLETE_SHOWN = "Autocomplete Shown", + AUTOCOMPLETE_ACCEPTED = "Autocomplete Accepted", + AUTOCOMPLETE_ERROR = "Autocomplete Error", } /** diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 63d5be87a8..6c9b3978c1 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -7,6 +7,7 @@ import type { ModeConfig, PromptComponent } from "./mode.js" import type { Experiments } from "./experiment.js" import type { ClineMessage, QueuedMessage } from "./message.js" import type { MarketplaceItem, MarketplaceInstalledMetadata, InstallMarketplaceItemOptions } from "./marketplace.js" +import type { AutocompleteModelSummary } from "./autocomplete.js" import type { TodoItem } from "./todo.js" import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, ShareVisibility } from "./cloud.js" import type { SerializedCustomToolDefinition } from "./custom-tool.js" @@ -43,6 +44,7 @@ export interface ExtensionMessage { | "openAiModels" | "ollamaModels" | "lmStudioModels" + | "autocompleteModels" | "vsCodeLmModels" | "vsCodeLmApiAvailable" | "updatePrompt" @@ -138,6 +140,8 @@ export interface ExtensionMessage { openAiModels?: string[] ollamaModels?: ModelRecord lmStudioModels?: ModelRecord + /** Models offered by the configured autocomplete endpoint, plus any fetch error. */ + autocompleteModels?: { models: AutocompleteModelSummary[]; error?: string } vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[] mcpServers?: McpServer[] commits?: GitCommit[] @@ -307,6 +311,9 @@ export type ExtensionState = Pick< | "customCondensingPrompt" | "codebaseIndexConfig" | "codebaseIndexModels" + | "autocompleteConfig" + | "autocompleteProfiles" + | "activeAutocompleteProfileId" | "profileThresholds" | "includeDiagnosticMessages" | "maxDiagnosticMessages" @@ -333,6 +340,12 @@ export type ExtensionState = Pick< uriScheme?: string shouldShowAnnouncement: boolean + /** + * Whether an autocomplete API key is stored. The key itself is never sent to the + * webview: the settings input is write-only and this flag drives its placeholder. + */ + hasAutocompleteApiKey?: boolean + taskHistory: HistoryItem[] writeDelayMs: number @@ -479,6 +492,7 @@ export interface WebviewMessage { | "requestOpenAiModels" | "requestOllamaModels" | "requestLmStudioModels" + | "requestAutocompleteModels" | "requestRooModels" | "requestVsCodeLmModels" | "openImage" diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index fd4e31116d..e9fc2b9750 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -47,6 +47,10 @@ export const commandIds = [ "focusPanel", "toggleAutoApprove", + "triggerInlineCompletion", + "toggleAutocomplete", + "autocompleteAccepted", + "showRipgrepDiagnostic", ] as const diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 8af352425a..0ddbd08e30 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -168,6 +168,7 @@ vi.mock("../activate", () => ({ registerCommands: vi.fn(), registerCodeActions: vi.fn(), registerTerminalActions: vi.fn(), + registerAutocomplete: vi.fn().mockResolvedValue({ dispose: vi.fn() }), CodeActionProvider: vi.fn().mockImplementation(function () { return { providedCodeActionKinds: [], diff --git a/src/activate/__tests__/registerAutocomplete.spec.ts b/src/activate/__tests__/registerAutocomplete.spec.ts new file mode 100644 index 0000000000..c71e64ed86 --- /dev/null +++ b/src/activate/__tests__/registerAutocomplete.spec.ts @@ -0,0 +1,143 @@ +// npx vitest run src/activate/__tests__/registerAutocomplete.spec.ts + +import { vi, describe, it, expect, beforeEach } from "vitest" +import * as vscode from "vscode" + +import { ClineProvider } from "../../core/webview/ClineProvider" +import { registerAutocomplete } from "../registerAutocomplete" + +vi.mock("vscode", async () => { + const actual = await vi.importActual("vscode") + return { + ...actual, + RelativePattern: class { + base: unknown + pattern: string + constructor(base: unknown, pattern: string) { + this.base = base + this.pattern = pattern + } + }, + StatusBarAlignment: { Left: 1, Right: 2 }, + ThemeColor: class { + id: string + constructor(id: string) { + this.id = id + } + }, + window: { + ...actual.window, + showErrorMessage: vi.fn().mockResolvedValue(undefined), + createStatusBarItem: vi.fn(() => ({ + show: vi.fn(), + dispose: vi.fn(), + text: "", + tooltip: "", + command: undefined, + backgroundColor: undefined, + })), + }, + workspace: { + ...actual.workspace, + workspaceFolders: [{ uri: { fsPath: "/workspace" } }], + onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })), + createFileSystemWatcher: vi.fn(() => ({ + onDidChange: () => ({ dispose: vi.fn() }), + onDidCreate: () => ({ dispose: vi.fn() }), + onDidDelete: () => ({ dispose: vi.fn() }), + dispose: vi.fn(), + })), + }, + languages: { + ...actual.languages, + registerInlineCompletionItemProvider: vi.fn(() => ({ dispose: vi.fn() })), + }, + commands: { + ...actual.commands, + registerCommand: vi.fn(() => ({ dispose: vi.fn() })), + }, + } +}) + +vi.mock("../../core/webview/ClineProvider") + +vi.mock("../../shared/package", () => ({ + Package: { name: "zoo-code" }, +})) + +describe("registerAutocomplete", () => { + let mockContext: vscode.ExtensionContext + let mockProvider: { postMessageToWebview: ReturnType } + + beforeEach(() => { + vi.clearAllMocks() + mockContext = { + subscriptions: [], + } as unknown as vscode.ExtensionContext + mockProvider = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + } + }) + + it("registers the inline completion provider and the open-settings command", async () => { + await registerAutocomplete({ + context: mockContext, + provider: mockProvider as unknown as ClineProvider, + getGlobalConfig: () => ({ enabled: false }) as never, + getApiKey: () => undefined, + }) + + expect(vscode.languages.registerInlineCompletionItemProvider).toHaveBeenCalledWith( + { pattern: "**/*" }, + expect.anything(), + ) + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + "zoo-code.autocomplete.openSettings", + expect.any(Function), + ) + expect(mockContext.subscriptions.length).toBeGreaterThan(0) + }) + + it("open-settings command deep-links the webview to the autocomplete section", async () => { + await registerAutocomplete({ + context: mockContext, + provider: mockProvider as unknown as ClineProvider, + getGlobalConfig: () => ({ enabled: false }) as never, + getApiKey: () => undefined, + }) + + const registerCall = vi + .mocked(vscode.commands.registerCommand) + .mock.calls.find(([id]) => id === "zoo-code.autocomplete.openSettings") + const handler = registerCall?.[1] as () => void + handler() + + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "switchTab", + tab: "settings", + values: { section: "autocomplete" }, + }) + }) + + it("shows an error when the webview message cannot be posted", async () => { + mockProvider.postMessageToWebview.mockRejectedValueOnce(new Error("webview gone")) + await registerAutocomplete({ + context: mockContext, + provider: mockProvider as unknown as ClineProvider, + getGlobalConfig: () => ({ enabled: false }) as never, + getApiKey: () => undefined, + }) + + const registerCall = vi + .mocked(vscode.commands.registerCommand) + .mock.calls.find(([id]) => id === "zoo-code.autocomplete.openSettings") + const handler = registerCall?.[1] as () => void + handler() + + // The rejection is handled in a `.catch` microtask; flush it before asserting. + await new Promise((resolve) => setImmediate(resolve)) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining("webview gone")) + }) +}) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 67a2b935ec..96e73e6843 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode" import { ClineProvider } from "../../core/webview/ClineProvider" import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" +import { getAutocompleteService } from "../../services/autocomplete/AutocompleteService" vi.mock("execa", () => ({ execa: vi.fn(), @@ -89,6 +90,15 @@ vi.mock("../../i18n", () => ({ t: (key: string) => key, })) +const mockAutocompleteService = vi.hoisted(() => ({ + triggerInlineCompletion: vi.fn(), + toggleEnabled: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("../../services/autocomplete/AutocompleteService", () => ({ + getAutocompleteService: vi.fn().mockReturnValue(mockAutocompleteService), +})) + vi.mock("../../services/ripgrep/diagnostic", () => ({ registerRipgrepDiagnosticCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }), })) @@ -374,6 +384,46 @@ describe("registerCommands handlers", () => { // Should not throw even with no visible provider await handlers["zoo-code.plusButtonClicked"]() }) + + it("triggerInlineCompletion arms the autocomplete service", () => { + handlers["zoo-code.triggerInlineCompletion"]() + + expect(mockAutocompleteService.triggerInlineCompletion).toHaveBeenCalledTimes(1) + }) + + it("triggerInlineCompletion logs when the service is not registered", () => { + vi.mocked(getAutocompleteService).mockReturnValueOnce(undefined) + + handlers["zoo-code.triggerInlineCompletion"]() + + expect(mockAutocompleteService.triggerInlineCompletion).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[triggerInlineCompletion] Autocomplete service is not registered.", + ) + }) + + it("toggleAutocomplete flips the service enable flag", async () => { + await handlers["zoo-code.toggleAutocomplete"]() + + expect(mockAutocompleteService.toggleEnabled).toHaveBeenCalledTimes(1) + }) + + it("toggleAutocomplete logs when the service is not registered", async () => { + vi.mocked(getAutocompleteService).mockReturnValueOnce(undefined) + + await handlers["zoo-code.toggleAutocomplete"]() + + expect(mockAutocompleteService.toggleEnabled).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[toggleAutocomplete] Autocomplete service is not registered.", + ) + }) + + it("autocompleteAccepted is a stable no-op target for acceptance telemetry", () => { + // The command exists so `InlineCompletionItem.command` has a stable target + // from day one; the actual telemetry capture lands with the engine (Phase 2). + expect(handlers["zoo-code.autocompleteAccepted"]()).toBeUndefined() + }) }) describe("openClineInNewTab", () => { diff --git a/src/activate/index.ts b/src/activate/index.ts index bb97206a1b..1a6151ad4d 100644 --- a/src/activate/index.ts +++ b/src/activate/index.ts @@ -2,4 +2,5 @@ export { handleUri } from "./handleUri" export { registerCommands } from "./registerCommands" export { registerCodeActions } from "./registerCodeActions" export { registerTerminalActions } from "./registerTerminalActions" +export { registerAutocomplete } from "./registerAutocomplete" export { CodeActionProvider } from "./CodeActionProvider" diff --git a/src/activate/registerAutocomplete.ts b/src/activate/registerAutocomplete.ts new file mode 100644 index 0000000000..6f98ae6dd4 --- /dev/null +++ b/src/activate/registerAutocomplete.ts @@ -0,0 +1,55 @@ +import type { ResolvedAutocompleteConfig } from "@roo-code/types" +import * as vscode from "vscode" + +import { ClineProvider } from "../core/webview/ClineProvider" +import { AutocompleteService, setAutocompleteService } from "../services/autocomplete/AutocompleteService" + +export interface RegisterAutocompleteOptions { + context: vscode.ExtensionContext + /** Resolved global autocomplete config; re-read after every settings change. */ + getGlobalConfig: () => ResolvedAutocompleteConfig + /** The persisted API key from SecretStorage, or undefined when none is set. */ + getApiKey: () => string | undefined + provider: ClineProvider +} + +/** + * Registers the inline autocomplete feature: the inline completion provider, + * the status bar and the workspace-scoped configuration watcher. + * + * The global config is read through `getGlobalConfig` so the service picks up + * saved settings without re-registering; `webviewMessageHandler` calls + * `handleSettingsChange()` after `updateSettings` completes. + */ +export async function registerAutocomplete(options: RegisterAutocompleteOptions): Promise { + const { context, getGlobalConfig, provider } = options + + const openSettings = () => { + void provider + .postMessageToWebview({ + type: "action", + action: "switchTab", + tab: "settings", + values: { section: "autocomplete" }, + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + void vscode.window.showErrorMessage(`Failed to open autocomplete settings: ${message}`) + }) + } + + const service = await AutocompleteService.create({ + context, + getGlobalConfig, + getApiKey: () => provider.contextProxy.getValue("autocompleteApiKey"), + openSettings, + setEnabled: async (enabled) => { + await provider.contextProxy.setValue("autocompleteConfig", { + ...getGlobalConfig(), + enabled, + }) + }, + }) + setAutocompleteService(service) + return service +} diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..6f31b0023d 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -11,6 +11,7 @@ import { ContextProxy } from "../core/config/ContextProxy" import { focusPanel } from "../utils/focusPanel" import { handleNewTask } from "./handleTask" import { CodeIndexManager } from "../services/code-index/manager" +import { getAutocompleteService } from "../services/autocomplete/AutocompleteService" import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic" @@ -219,6 +220,28 @@ const getCommandsMap = ({ outputChannel.appendLine(`[toggleAutoApprove] postMessageToWebview failed: ${error}`) } }, + triggerInlineCompletion: () => { + const service = getAutocompleteService() + if (!service) { + outputChannel.appendLine("[triggerInlineCompletion] Autocomplete service is not registered.") + return + } + service.triggerInlineCompletion() + }, + toggleAutocomplete: async () => { + const service = getAutocompleteService() + if (!service) { + outputChannel.appendLine("[toggleAutocomplete] Autocomplete service is not registered.") + return + } + await service.toggleEnabled() + }, + autocompleteAccepted: () => { + // Acceptance telemetry is captured by the completion engine (Phase 2) when + // it renders the item; the command exists so `InlineCompletionItem.command` + // has a stable target from day one. + return undefined + }, }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 97d4104afc..cda4a5f81a 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -36,6 +36,9 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({ taskHistory: true, listApiConfigMeta: true, currentApiConfigName: true, + // `getValues()` merges secrets into global state, so any secret listed in the + // schema would otherwise be written in cleartext to the exported settings file. + autocompleteApiKey: true, }) export class ContextProxy { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 206d6ca611..82d597e64c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -52,6 +52,7 @@ import { getModelId, isRetiredProvider, providerIdentifiers, + resolveAutocompleteConfig, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -2486,6 +2487,10 @@ export class ClineProvider customCondensingPrompt, codebaseIndexConfig, codebaseIndexModels, + autocompleteConfig, + autocompleteProfiles, + activeAutocompleteProfileId, + hasAutocompleteApiKey, profileThresholds, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, @@ -2665,6 +2670,10 @@ export class ClineProvider codebaseIndexBedrockProfile: codebaseIndexConfig?.codebaseIndexBedrockProfile, codebaseIndexOpenRouterSpecificProvider: codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, + autocompleteConfig: resolveAutocompleteConfig(autocompleteConfig), + autocompleteProfiles, + activeAutocompleteProfileId, + hasAutocompleteApiKey, // Phase 1 cloud removal: do not let Cloud-auth MDM enforcement force login-only UI flows. mdmCompliant: undefined, profileThresholds: profileThresholds ?? {}, @@ -2894,6 +2903,11 @@ export class ClineProvider codebaseIndexOpenRouterSpecificProvider: stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, + autocompleteConfig: resolveAutocompleteConfig(stateValues.autocompleteConfig), + autocompleteProfiles: stateValues.autocompleteProfiles ?? [], + activeAutocompleteProfileId: stateValues.activeAutocompleteProfileId, + // The key itself never leaves the extension host; the webview only learns whether one is set. + hasAutocompleteApiKey: !!stateValues.autocompleteApiKey, profileThresholds: stateValues.profileThresholds ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 00f848bec4..91044d3922 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -18,6 +18,7 @@ import { DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_WRITE_DELAY_MS, providerIdentifiers, + resolveAutocompleteConfig, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -1036,6 +1037,68 @@ describe("ClineProvider", () => { expect(state.destructiveCommandGuardEnabled).toBe(false) }) + test("getStateToPostToWebview returns a fully-defaulted autocompleteConfig when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + // Compared against the shared resolver rather than a hand-copied literal. + // Duplicating all 22 defaults here meant the test broke on every tuning + // change while never checking the thing it exists to check — that an unset + // config reaches the webview fully defaulted. + expect(state.autocompleteConfig).toEqual(resolveAutocompleteConfig(undefined)) + expect(state.hasAutocompleteApiKey).toBe(false) + }) + + test("getStateToPostToWebview echoes the saved autocompleteConfig and key presence", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("autocompleteConfig", { + enabled: true, + provider: "codestral", + modelId: "codestral-latest", + baseUrl: "https://codestral.mistral.ai", + triggerMode: "manual", + debounceMs: 0, + }) + await provider.contextProxy.setValue("autocompleteApiKey", "sk-secret") + + const state = await provider.getStateToPostToWebview() + + expect(state.autocompleteConfig).toEqual( + expect.objectContaining({ + enabled: true, + provider: "codestral", + modelId: "codestral-latest", + triggerMode: "manual", + debounceMs: 0, + }), + ) + // The key itself never reaches the webview; only its presence is signaled. + expect(state).not.toHaveProperty("autocompleteApiKey") + expect(state.hasAutocompleteApiKey).toBe(true) + }) + + test("updateSettings persists autocompleteConfig and routes the key to secret storage", async () => { + await provider.resolveWebviewView(mockWebviewView) + // mockWebviewView is typed `any`, so no explicit cast is needed on the chain. + const messageHandler = mockWebviewView.webview.onDidReceiveMessage.mock.calls[0][0] + + await messageHandler({ + type: "updateSettings", + updatedSettings: { + autocompleteConfig: { enabled: true, provider: "codestral", modelId: "codestral-latest" }, + autocompleteApiKey: "sk-secret", + }, + }) + + expect(updateGlobalStateSpy).toHaveBeenCalledWith("autocompleteConfig", { + enabled: true, + provider: "codestral", + modelId: "codestral-latest", + }) + expect(mockContext.secrets.store).toHaveBeenCalledWith("autocompleteApiKey", "sk-secret") + }) + test("language is set to VSCode language", async () => { // Mock VSCode language as Spanish ;(vscode.env as any).language = "pt-BR" diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5a28ce12d0..23408053b5 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -23,6 +23,8 @@ import { checkoutRestorePayloadSchema, getCompletionCheckpoint, providerIdentifiers, + autocompleteProviderIds, + type AutocompleteProviderId, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -33,6 +35,7 @@ import { saveTaskMessages } from "../task-persistence" import { importRooTaskHistory } from "../task-persistence/importRooTaskHistory" import { ClineProvider } from "./ClineProvider" +import { getAutocompleteService } from "../../services/autocomplete/AutocompleteService" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" import { generateErrorDiagnostics } from "./diagnosticsHandler" import { @@ -87,6 +90,13 @@ import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) +/** Narrows an untrusted webview value to a known autocomplete provider id. */ +function toAutocompleteProviderId(value: unknown): AutocompleteProviderId | undefined { + return typeof value === "string" && (autocompleteProviderIds as readonly string[]).includes(value) + ? (value as AutocompleteProviderId) + : undefined +} + import { MarketplaceManager, MarketplaceItemType } from "../../services/marketplace" import { setPendingTodoList } from "../tools/UpdateTodoListTool" import { @@ -801,6 +811,11 @@ export const webviewMessageHandler = async ( await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) } + // Refresh the inline autocomplete service *after* the loop: the settings + // payload may contain both `autocompleteConfig` and `autocompleteApiKey`, + // and Object.entries gives no ordering guarantee between them. + getAutocompleteService()?.handleSettingsChange() + await provider.postStateToWebview() } @@ -1362,6 +1377,46 @@ export const webviewMessageHandler = async ( } break } + case "requestAutocompleteModels": { + // Probes the endpoint the user is currently editing (values override the + // persisted config), so the picker works before Save is pressed. + const service = getAutocompleteService() + + if (!service) { + await provider.postMessageToWebview({ + type: "autocompleteModels", + autocompleteModels: { models: [], error: "Autocomplete is not available." }, + }) + break + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10_000) + + try { + const models = await service.listModels(controller.signal, { + provider: toAutocompleteProviderId(message.values?.provider), + baseUrl: typeof message.values?.baseUrl === "string" ? message.values.baseUrl : undefined, + apiKey: typeof message.values?.apiKey === "string" ? message.values.apiKey : undefined, + }) + + await provider.postMessageToWebview({ type: "autocompleteModels", autocompleteModels: { models } }) + } catch (error) { + // Surfaced in the UI rather than swallowed: an unreachable endpoint is + // the single most common setup mistake, and silence looks like a bug. + await provider.postMessageToWebview({ + type: "autocompleteModels", + autocompleteModels: { + models: [], + error: error instanceof Error ? error.message : String(error), + }, + }) + } finally { + clearTimeout(timeout) + } + + break + } case "requestRooModels": { await provider.postMessageToWebview({ type: "singleRouterModelFetchResponse", diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index 65965eb8d5..3a81943eb8 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -34,7 +34,7 @@ export default [ { // Ratchet: enforce no-floating-promises directory by directory. Each // directory is added here once its floating promises are resolved. - files: ["activate/**/*.ts", "core/task/**/*.ts", "core/webview/**/*.ts"], + files: ["activate/**/*.ts", "core/task/**/*.ts", "core/webview/**/*.ts", "services/autocomplete/**/*.ts"], languageOptions: { parserOptions: { project: true, diff --git a/src/extension.ts b/src/extension.ts index b880bee410..351616e44b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,7 @@ if (fs.existsSync(envPath)) { } import type { CloudUserInfo, AuthState } from "@roo-code/types" +import { resolveAutocompleteConfig } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" @@ -45,6 +46,7 @@ import { registerCommands, registerCodeActions, registerTerminalActions, + registerAutocomplete, CodeActionProvider, } from "./activate" import { initializeI18n } from "./i18n" @@ -296,6 +298,17 @@ export async function activate(context: vscode.ExtensionContext) { registerCodeActions(context) registerTerminalActions(context) + // Inline autocomplete (ghost text). The global config is re-read on every + // request so saved settings apply without re-registering; the message handler + // notifies the service after `updateSettings` completes. + const autocompleteService = await registerAutocomplete({ + context, + provider, + getGlobalConfig: () => resolveAutocompleteConfig(contextProxy.getValues().autocompleteConfig), + getApiKey: () => contextProxy.getValue("autocompleteApiKey"), + }) + context.subscriptions.push(autocompleteService) + // Allows other extensions to activate once Roo is ready. vscode.commands.executeCommand(`${Package.name}.activationCompleted`) diff --git a/src/package.json b/src/package.json index 9be6390cbc..8b1441dc37 100644 --- a/src/package.json +++ b/src/package.json @@ -169,6 +169,21 @@ "command": "zoo-code.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "zoo-code.triggerInlineCompletion", + "title": "%command.triggerInlineCompletion.title%", + "category": "%configuration.title%" + }, + { + "command": "zoo-code.toggleAutocomplete", + "title": "%command.toggleAutocomplete.title%", + "category": "%configuration.title%" + }, + { + "command": "zoo-code.autocompleteAccepted", + "title": "%command.autocompleteAccepted.title%", + "category": "%configuration.title%" } ], "menus": { @@ -282,6 +297,14 @@ "mac": "cmd+alt+a", "win": "ctrl+alt+a", "linux": "ctrl+alt+a" + }, + { + "command": "zoo-code.triggerInlineCompletion", + "key": "cmd+alt+\\", + "mac": "cmd+alt+\\", + "win": "ctrl+alt+\\", + "linux": "ctrl+alt+\\", + "when": "editorTextFocus && !editorReadonly" } ], "submenus": [ @@ -434,6 +457,18 @@ "scope": "machine", "description": "%settings.workspace.rootResolution.description%", "markdownDescription": "%settings.workspace.rootResolution.description%" + }, + "zoo-code.autocomplete.disabled": { + "type": "boolean", + "default": false, + "description": "%settings.autocomplete.disabled.description%", + "markdownDescription": "%settings.autocomplete.disabled.description%" + }, + "zoo-code.autocomplete.debugLogging": { + "type": "boolean", + "default": false, + "description": "%settings.autocomplete.debugLogging.description%", + "markdownDescription": "%settings.autocomplete.debugLogging.description%" } } } diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index 6ddaf181b4..6e6d2823ad 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "Acceptar Entrada/Suggeriment", "command.showRipgrepDiagnostic.title": "Mostra el diagnòstic de Ripgrep", "command.toggleAutoApprove.title": "Alternar Auto-Aprovació", + "command.triggerInlineCompletion.title": "Dispara la compleció en línia", + "command.toggleAutocomplete.title": "Alterna la compleció en línia", + "command.autocompleteAccepted.title": "Compleció en línia acceptada", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Accepta certificats auto-signats del proxy. **Requerit per a la inspecció MITM.** ⚠️ Insegur — utilitza-ho només per a debugging local.", "settings.workspace.rootResolution.description": "Com resol Zoo l'arrel de l'espai de treball en un espai de treball multi-arrel. L'arrel s'utilitza per localitzar `.roomodes`, `.roo/mcp.json`, `.roo/rules/` i altra configuració d'àmbit de projecte. Canviar aquest paràmetre només afecta les cerques futures; les tasques en execució conserven la seva arrel original.", "settings.workspace.rootResolution.activeEditor.description": "Utilitza la carpeta de l'espai de treball que conté l'editor actiu; recorre a la primera carpeta de l'espai de treball. (Per defecte — conserva el comportament heretat.)", - "settings.workspace.rootResolution.firstFolder.description": "Utilitza sempre la primera carpeta de l'espai de treball (workspaceFolders[0]). Determinista — independent del fitxer que tingui el focus en aquest moment." + "settings.workspace.rootResolution.firstFolder.description": "Utilitza sempre la primera carpeta de l'espai de treball (workspaceFolders[0]). Determinista — independent del fitxer que tingui el focus en aquest moment.", + "settings.autocomplete.disabled.description": "Desactiva l'autocompleció en línia de Zoo Code en aquest espai de treball, independentment del valor al panell de configuració de Zoo Code. Va bé per a repositoris que mai no haurien d'enviar codi a un model de compleció.", + "settings.autocomplete.debugLogging.description": "Escriu diagnòstics de l'autocompleció en línia (el prompt generat, la latència de les peticions i les decisions de postprocessament) al canal de sortida Zoo-Code." } diff --git a/src/package.nls.de.json b/src/package.nls.de.json index 4c8eccb293..f8adf89c0d 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "Eingabe/Vorschlag Akzeptieren", "command.showRipgrepDiagnostic.title": "Ripgrep-Diagnose anzeigen", "command.toggleAutoApprove.title": "Auto-Genehmigung Umschalten", + "command.triggerInlineCompletion.title": "Inline-Vervollständigung auslösen", + "command.toggleAutocomplete.title": "Inline-Autovervollständigung umschalten", + "command.autocompleteAccepted.title": "Inline-Vervollständigung akzeptiert", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Akzeptiere selbstsignierte Zertifikate vom Proxy. **Erforderlich für MITM-Inspektion.** ⚠️ Unsicher – verwende das nur für lokales Debugging.", "settings.workspace.rootResolution.description": "Wie Zoo das Workspace-Stammverzeichnis in einem Multi-Root-Workspace auflöst. Das Stammverzeichnis wird verwendet, um `.roomodes`, `.roo/mcp.json`, `.roo/rules/` und andere projektbezogene Konfiguration zu finden. Eine Änderung dieser Einstellung wirkt sich nur auf zukünftige Suchen aus; laufende Aufgaben behalten ihr ursprüngliches Stammverzeichnis.", "settings.workspace.rootResolution.activeEditor.description": "Verwende den Workspace-Ordner, der den aktiven Editor enthält; greife auf den ersten Workspace-Ordner zurück. (Standard – behält das bisherige Verhalten bei.)", - "settings.workspace.rootResolution.firstFolder.description": "Verwende immer den ersten Workspace-Ordner (workspaceFolders[0]). Deterministisch – unabhängig davon, welche Datei gerade fokussiert ist." + "settings.workspace.rootResolution.firstFolder.description": "Verwende immer den ersten Workspace-Ordner (workspaceFolders[0]). Deterministisch – unabhängig davon, welche Datei gerade fokussiert ist.", + "settings.autocomplete.disabled.description": "Deaktiviert die Inline-Autovervollständigung von Zoo Code für diesen Arbeitsbereich, unabhängig von der Einstellung im Zoo-Code-Einstellungsbereich. Nützlich für Repositories, aus denen nie Code an ein Completion-Modell gehen soll.", + "settings.autocomplete.debugLogging.description": "Schreibt Diagnosedaten der Inline-Autovervollständigung – gerenderter Prompt, Anfragelatenz und Nachbearbeitungsentscheidungen – in den Ausgabekanal Zoo-Code." } diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 11a705880b..4f8be3b70b 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "Aceptar Entrada/Sugerencia", "command.showRipgrepDiagnostic.title": "Mostrar diagnóstico de Ripgrep", "command.toggleAutoApprove.title": "Alternar Auto-Aprobación", + "command.triggerInlineCompletion.title": "Disparar la autocompletación en línea", + "command.toggleAutocomplete.title": "Alternar autocompletación en línea", + "command.autocompleteAccepted.title": "Autocompletación en línea aceptada", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Aceptar certificados autofirmados del proxy. **Necesario para la inspección MITM.** ⚠️ Inseguro: úsalo solo para depuración local.", "settings.workspace.rootResolution.description": "Cómo resuelve Zoo la raíz del espacio de trabajo en un espacio de trabajo multi-raíz. La raíz se usa para localizar `.roomodes`, `.roo/mcp.json`, `.roo/rules/` y otra configuración del ámbito del proyecto. Cambiar este ajuste solo afecta a las búsquedas futuras; las tareas en ejecución conservan su raíz original.", "settings.workspace.rootResolution.activeEditor.description": "Usa la carpeta del espacio de trabajo que contiene el editor activo; recurre a la primera carpeta del espacio de trabajo. (Predeterminado: conserva el comportamiento heredado.)", - "settings.workspace.rootResolution.firstFolder.description": "Usa siempre la primera carpeta del espacio de trabajo (workspaceFolders[0]). Determinista: independiente del archivo que tenga el foco en ese momento." + "settings.workspace.rootResolution.firstFolder.description": "Usa siempre la primera carpeta del espacio de trabajo (workspaceFolders[0]). Determinista: independiente del archivo que tenga el foco en ese momento.", + "settings.autocomplete.disabled.description": "Desactiva el autocompletado en línea de Zoo Code en este espacio de trabajo, sin importar el valor del panel de ajustes de Zoo Code. Útil para repositorios que nunca deberían enviar código a un modelo de compleción.", + "settings.autocomplete.debugLogging.description": "Escribe diagnósticos del autocompletado en línea (el prompt generado, la latencia de las peticiones y las decisiones de posprocesado) en el canal de salida Zoo-Code." } diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 573350bc9a..07a7d524d2 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "Accepter l'Entrée/Suggestion", "command.showRipgrepDiagnostic.title": "Afficher le diagnostic Ripgrep", "command.toggleAutoApprove.title": "Basculer Auto-Approbation", + "command.triggerInlineCompletion.title": "Déclencher la complétion en ligne", + "command.toggleAutocomplete.title": "Basculer la complétion en ligne", + "command.autocompleteAccepted.title": "Complétion en ligne acceptée", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Accepter les certificats auto-signés du proxy. **Requis pour l'inspection MITM.** ⚠️ Non sécurisé — à utiliser uniquement pour le debug local.", "settings.workspace.rootResolution.description": "Comment Zoo résout la racine de l'espace de travail dans un espace de travail multi-racine. La racine sert à localiser `.roomodes`, `.roo/mcp.json`, `.roo/rules/` et d'autres configurations propres au projet. Modifier ce paramètre n'affecte que les recherches futures ; les tâches en cours conservent leur racine d'origine.", "settings.workspace.rootResolution.activeEditor.description": "Utilise le dossier de l'espace de travail contenant l'éditeur actif ; revient au premier dossier de l'espace de travail. (Par défaut — conserve le comportement hérité.)", - "settings.workspace.rootResolution.firstFolder.description": "Utilise toujours le premier dossier de l'espace de travail (workspaceFolders[0]). Déterministe — indépendant du fichier actuellement ciblé." + "settings.workspace.rootResolution.firstFolder.description": "Utilise toujours le premier dossier de l'espace de travail (workspaceFolders[0]). Déterministe — indépendant du fichier actuellement ciblé.", + "settings.autocomplete.disabled.description": "Désactive l'autocomplétion en ligne de Zoo Code pour cet espace de travail, quel que soit le réglage du panneau de paramètres de Zoo Code. Pratique pour les dépôts dont le code ne doit jamais être envoyé à un modèle de complétion.", + "settings.autocomplete.debugLogging.description": "Écrit les diagnostics de l'autocomplétion en ligne — le prompt généré, la latence des requêtes et les décisions de post-traitement — dans le canal de sortie Zoo-Code." } diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 8135af2ab3..1df177a275 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "इनपुट/सुझाव स्वीकारें", "command.showRipgrepDiagnostic.title": "Ripgrep डायग्नोस्टिक दिखाएं", "command.toggleAutoApprove.title": "ऑटो-अनुमोदन टॉगल करें", + "command.triggerInlineCompletion.title": "इनलाइन पूर्णता ट्रिगर करें", + "command.toggleAutocomplete.title": "इनलाइन स्वतः पूर्णता टॉगल करें", + "command.autocompleteAccepted.title": "इनलाइन पूर्णता स्वीकृत", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Proxy से आने वाले self-signed certificates accept करो। **MITM inspection के लिए ज़रूरी।** ⚠️ Insecure — सिर्फ local debugging के लिए इस्तेमाल करो।", "settings.workspace.rootResolution.description": "Multi-root workspace में Zoo workspace root को कैसे resolve करता है। Root का इस्तेमाल `.roomodes`, `.roo/mcp.json`, `.roo/rules/` और दूसरी project-scoped configuration ढूंढने के लिए होता है। इस setting को बदलने का असर सिर्फ आगे की lookups पर पड़ता है; चल रहे tasks अपना original root बनाए रखते हैं।", "settings.workspace.rootResolution.activeEditor.description": "उस workspace folder का इस्तेमाल करो जिसमें active editor है; पहले workspace folder पर fall back करो। (Default — legacy behavior बनाए रखता है।)", - "settings.workspace.rootResolution.firstFolder.description": "हमेशा पहले workspace folder (workspaceFolders[0]) का इस्तेमाल करो। Deterministic — इस बात से स्वतंत्र कि अभी कौन सी file focused है।" + "settings.workspace.rootResolution.firstFolder.description": "हमेशा पहले workspace folder (workspaceFolders[0]) का इस्तेमाल करो। Deterministic — इस बात से स्वतंत्र कि अभी कौन सी file focused है।", + "settings.autocomplete.disabled.description": "Zoo Code के सेटिंग्स पैनल की सेटिंग चाहे जो हो, इस workspace के लिए Zoo Code की इनलाइन स्वतः पूर्णता बंद कर देता है। उन repositories के लिए उपयोगी जिनका कोड कभी किसी completion मॉडल को नहीं भेजा जाना चाहिए।", + "settings.autocomplete.debugLogging.description": "इनलाइन स्वतः पूर्णता का डायग्नोस्टिक डेटा — तैयार किया गया prompt, अनुरोध की latency और पोस्ट-प्रोसेसिंग निर्णय — Zoo-Code आउटपुट चैनल में लिखता है।" } diff --git a/src/package.nls.id.json b/src/package.nls.id.json index c5740ad00b..520b5acd74 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -25,6 +25,9 @@ "command.acceptInput.title": "Terima Input/Saran", "command.showRipgrepDiagnostic.title": "Tampilkan Diagnostik Ripgrep", "command.toggleAutoApprove.title": "Alihkan Persetujuan Otomatis", + "command.triggerInlineCompletion.title": "Picu Penyelesaian Sebaris", + "command.toggleAutocomplete.title": "Alihkan Penyelesaian Sebaris", + "command.autocompleteAccepted.title": "Penyelesaian Sebaris Diterima", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Perintah yang dapat dijalankan secara otomatis ketika 'Selalu setujui operasi eksekusi' diaktifkan", "commands.deniedCommands.description": "Awalan perintah yang akan otomatis ditolak tanpa meminta persetujuan. Jika terjadi konflik dengan perintah yang diizinkan, pencocokan awalan terpanjang akan diprioritaskan. Tambahkan * untuk menolak semua perintah.", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Terima sertifikat self-signed dari proxy. **Diperlukan untuk inspeksi MITM.** ⚠️ Tidak aman — gunakan hanya untuk debugging lokal.", "settings.workspace.rootResolution.description": "Bagaimana Zoo menentukan root workspace di workspace multi-root. Root digunakan untuk menemukan `.roomodes`, `.roo/mcp.json`, `.roo/rules/`, dan konfigurasi lain yang bersifat project-scoped. Mengubah pengaturan ini hanya memengaruhi pencarian berikutnya; task yang sedang berjalan tetap memakai root aslinya.", "settings.workspace.rootResolution.activeEditor.description": "Gunakan folder workspace yang berisi editor aktif; kembali ke folder workspace pertama. (Default — mempertahankan perilaku lama.)", - "settings.workspace.rootResolution.firstFolder.description": "Selalu gunakan folder workspace pertama (workspaceFolders[0]). Deterministik — tidak bergantung pada file mana yang sedang difokuskan." + "settings.workspace.rootResolution.firstFolder.description": "Selalu gunakan folder workspace pertama (workspaceFolders[0]). Deterministik — tidak bergantung pada file mana yang sedang difokuskan.", + "settings.autocomplete.disabled.description": "Mematikan pelengkapan otomatis inline Zoo Code untuk workspace ini, terlepas dari pengaturan di panel pengaturan Zoo Code. Berguna untuk repositori yang kodenya tidak boleh dikirim ke model completion.", + "settings.autocomplete.debugLogging.description": "Menulis diagnostik pelengkapan otomatis inline — prompt yang dirender, latensi permintaan, dan keputusan pasca-pemrosesan — ke saluran output Zoo-Code." } diff --git a/src/package.nls.it.json b/src/package.nls.it.json index ebf2167a99..0c63d0e4df 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "Accetta Input/Suggerimento", "command.showRipgrepDiagnostic.title": "Mostra diagnostica Ripgrep", "command.toggleAutoApprove.title": "Attiva/Disattiva Auto-Approvazione", + "command.triggerInlineCompletion.title": "Attiva il completamento in linea", + "command.toggleAutocomplete.title": "Attiva/disattiva il completamento in linea", + "command.autocompleteAccepted.title": "Completamento in linea accettato", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Accetta certificati autofirmati dal proxy. **Necessario per l'ispezione MITM.** ⚠️ Non sicuro — usalo solo per il debugging locale.", "settings.workspace.rootResolution.description": "Come Zoo determina la radice del workspace in un workspace multi-radice. La radice viene usata per individuare `.roomodes`, `.roo/mcp.json`, `.roo/rules/` e altra configurazione a livello di progetto. Modificare questa impostazione influisce solo sulle ricerche future; i task in esecuzione mantengono la loro radice originale.", "settings.workspace.rootResolution.activeEditor.description": "Usa la cartella del workspace che contiene l'editor attivo; ripiega sulla prima cartella del workspace. (Predefinito — mantiene il comportamento legacy.)", - "settings.workspace.rootResolution.firstFolder.description": "Usa sempre la prima cartella del workspace (workspaceFolders[0]). Deterministico — indipendente dal file attualmente attivo." + "settings.workspace.rootResolution.firstFolder.description": "Usa sempre la prima cartella del workspace (workspaceFolders[0]). Deterministico — indipendente dal file attualmente attivo.", + "settings.autocomplete.disabled.description": "Disattiva il completamento automatico inline di Zoo Code per questo workspace, indipendentemente dall'impostazione nel pannello delle impostazioni di Zoo Code. Utile per repository il cui codice non deve mai finire a un modello di completamento.", + "settings.autocomplete.debugLogging.description": "Scrive le diagnostiche del completamento automatico inline — il prompt generato, la latenza delle richieste e le decisioni di post-elaborazione — nel canale di output Zoo-Code." } diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index f9daa4bb93..3e74188f7e 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -25,6 +25,9 @@ "command.acceptInput.title": "入力/提案を承認", "command.showRipgrepDiagnostic.title": "Ripgrep 診断を表示", "command.toggleAutoApprove.title": "自動承認を切替", + "command.triggerInlineCompletion.title": "インライン補完をトリガー", + "command.toggleAutocomplete.title": "インライン補完を切り替え", + "command.autocompleteAccepted.title": "インライン補完を承認", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "'常に実行操作を承認する'が有効な場合に自動実行できるコマンド", "commands.deniedCommands.description": "承認を求めずに自動的に拒否されるコマンドプレフィックス。許可されたコマンドとの競合がある場合、最長プレフィックスマッチが優先されます。すべてのコマンドを拒否するには * を追加してください。", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "プロキシからの自己署名証明書を許可します。**MITM インスペクションに必須です。** ⚠️ 危険な設定なので、ローカルでのデバッグにだけ使用してください。", "settings.workspace.rootResolution.description": "マルチルートワークスペースで Zoo がワークスペースルートをどのように解決するか。ルートは `.roomodes`、`.roo/mcp.json`、`.roo/rules/` などのプロジェクト単位の設定を見つけるために使われます。この設定の変更は今後の検索にのみ影響し、実行中のタスクは元のルートを保持します。", "settings.workspace.rootResolution.activeEditor.description": "アクティブなエディタを含むワークスペースフォルダを使用し、最初のワークスペースフォルダにフォールバックします。(デフォルト — 従来の動作を維持します。)", - "settings.workspace.rootResolution.firstFolder.description": "常に最初のワークスペースフォルダ(workspaceFolders[0])を使用します。決定的で、現在フォーカスされているファイルに依存しません。" + "settings.workspace.rootResolution.firstFolder.description": "常に最初のワークスペースフォルダ(workspaceFolders[0])を使用します。決定的で、現在フォーカスされているファイルに依存しません。", + "settings.autocomplete.disabled.description": "Zoo Code の設定パネルの値に関わらず、このワークスペースでインライン自動補完を無効にします。コードを completion モデルに送りたくないリポジトリで役立ちます。", + "settings.autocomplete.debugLogging.description": "インライン自動補完の診断情報(生成された prompt、リクエストのレイテンシ、後処理の判断)を Zoo-Code 出力チャネルに書き出します。" } diff --git a/src/package.nls.json b/src/package.nls.json index 4fac644eab..4448c796a8 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -25,6 +25,9 @@ "command.acceptInput.title": "Accept Input/Suggestion", "command.showRipgrepDiagnostic.title": "Show Ripgrep Diagnostic", "command.toggleAutoApprove.title": "Toggle Auto-Approve", + "command.triggerInlineCompletion.title": "Trigger Inline Completion", + "command.toggleAutocomplete.title": "Toggle Inline Autocomplete", + "command.autocompleteAccepted.title": "Inline Autocomplete Accepted", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging.", "settings.workspace.rootResolution.description": "How Zoo resolves the workspace root in a multi-root workspace. The root is used to locate `.roomodes`, `.roo/mcp.json`, `.roo/rules/`, and other project-scoped configuration. Changing this setting only affects future lookups; running tasks keep their original root.", "settings.workspace.rootResolution.activeEditor.description": "Use the workspace folder containing the active editor; fall back to the first workspace folder. (Default — preserves legacy behavior.)", - "settings.workspace.rootResolution.firstFolder.description": "Always use the first workspace folder (workspaceFolders[0]). Deterministic — independent of which file is currently focused." + "settings.workspace.rootResolution.firstFolder.description": "Always use the first workspace folder (workspaceFolders[0]). Deterministic — independent of which file is currently focused.", + "settings.autocomplete.disabled.description": "Turn off Zoo Code inline autocomplete for this workspace, regardless of the setting in Zoo Code's settings panel. Useful for repositories that should never send code to a completion model.", + "settings.autocomplete.debugLogging.description": "Write inline autocomplete diagnostics — the rendered prompt, request latency and post-processing decisions — to the Zoo-Code output channel." } diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index a743902280..a191c9e5ed 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "입력/제안 수락", "command.showRipgrepDiagnostic.title": "Ripgrep 진단 표시", "command.toggleAutoApprove.title": "자동 승인 전환", + "command.triggerInlineCompletion.title": "인라인 완성 트리거", + "command.toggleAutocomplete.title": "인라인 자동 완성 전환", + "command.autocompleteAccepted.title": "인라인 완성 수락됨", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "프록시의 self-signed 인증서를 허용합니다. **MITM 검사에 필요합니다.** ⚠️ 안전하지 않으므로 로컬 디버깅에만 사용하세요.", "settings.workspace.rootResolution.description": "멀티 루트 워크스페이스에서 Zoo가 워크스페이스 루트를 어떻게 결정하는지 설정합니다. 루트는 `.roomodes`, `.roo/mcp.json`, `.roo/rules/` 및 기타 프로젝트 범위 구성을 찾는 데 사용됩니다. 이 설정을 변경해도 이후의 조회에만 영향을 주며, 실행 중인 작업은 원래 루트를 유지합니다.", "settings.workspace.rootResolution.activeEditor.description": "활성 편집기가 있는 워크스페이스 폴더를 사용하고, 첫 번째 워크스페이스 폴더로 폴백합니다. (기본값 — 기존 동작을 유지합니다.)", - "settings.workspace.rootResolution.firstFolder.description": "항상 첫 번째 워크스페이스 폴더(workspaceFolders[0])를 사용합니다. 결정적이며, 현재 포커스된 파일과 무관합니다." + "settings.workspace.rootResolution.firstFolder.description": "항상 첫 번째 워크스페이스 폴더(workspaceFolders[0])를 사용합니다. 결정적이며, 현재 포커스된 파일과 무관합니다.", + "settings.autocomplete.disabled.description": "Zoo Code 설정 패널의 값과 상관없이 이 작업 영역에서 인라인 자동 완성을 끕니다. 코드를 completion 모델로 절대 보내면 안 되는 저장소에 유용합니다.", + "settings.autocomplete.debugLogging.description": "인라인 자동 완성 진단 정보(생성된 prompt, 요청 지연 시간, 후처리 결정)를 Zoo-Code 출력 채널에 기록합니다." } diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 72bc15f89a..2e25e130b2 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -25,6 +25,9 @@ "command.acceptInput.title": "Invoer/Suggestie Accepteren", "command.showRipgrepDiagnostic.title": "Ripgrep-diagnose weergeven", "command.toggleAutoApprove.title": "Auto-Goedkeuring Schakelen", + "command.triggerInlineCompletion.title": "Inline-aanvulling activeren", + "command.toggleAutocomplete.title": "Inline-automatische aanvulling schakelen", + "command.autocompleteAccepted.title": "Inline-aanvulling geaccepteerd", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Commando's die automatisch kunnen worden uitgevoerd wanneer 'Altijd goedkeuren uitvoerbewerkingen' is ingeschakeld", "commands.deniedCommands.description": "Commando-prefixen die automatisch worden geweigerd zonder om goedkeuring te vragen. Bij conflicten met toegestane commando's heeft de langste prefix-match voorrang. Voeg * toe om alle commando's te weigeren.", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Accepteer zelfondertekende certificaten van de proxy. **Vereist voor MITM-inspectie.** ⚠️ Onveilig — gebruik dit alleen voor lokale debugging.", "settings.workspace.rootResolution.description": "Hoe Zoo de workspace-root bepaalt in een multi-root workspace. De root wordt gebruikt om `.roomodes`, `.roo/mcp.json`, `.roo/rules/` en andere project-scoped configuratie te vinden. Deze instelling wijzigen heeft alleen invloed op toekomstige zoekopdrachten; lopende taken behouden hun oorspronkelijke root.", "settings.workspace.rootResolution.activeEditor.description": "Gebruik de workspace-map die de actieve editor bevat; val terug op de eerste workspace-map. (Standaard — behoudt het oude gedrag.)", - "settings.workspace.rootResolution.firstFolder.description": "Gebruik altijd de eerste workspace-map (workspaceFolders[0]). Deterministisch — onafhankelijk van welk bestand op dit moment de focus heeft." + "settings.workspace.rootResolution.firstFolder.description": "Gebruik altijd de eerste workspace-map (workspaceFolders[0]). Deterministisch — onafhankelijk van welk bestand op dit moment de focus heeft.", + "settings.autocomplete.disabled.description": "Schakelt de inline automatische aanvulling van Zoo Code uit voor deze werkruimte, ongeacht de instelling in het instellingenpaneel van Zoo Code. Handig voor repositories waarvan code nooit naar een completion-model mag.", + "settings.autocomplete.debugLogging.description": "Schrijft diagnostiek van inline automatisch aanvullen — de gegenereerde prompt, verzoeklatentie en nabewerkingsbeslissingen — naar het uitvoerkanaal Zoo-Code." } diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 92fb97778b..c00eee9eb8 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "Akceptuj Wprowadzanie/Sugestię", "command.showRipgrepDiagnostic.title": "Pokaż diagnostykę Ripgrep", "command.toggleAutoApprove.title": "Przełącz Auto-Zatwierdzanie", + "command.triggerInlineCompletion.title": "Wyzwól dopełnianie wiersza", + "command.toggleAutocomplete.title": "Przełącz dopełnianie wiersza", + "command.autocompleteAccepted.title": "Dopełnianie wiersza zaakceptowane", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Akceptuj certyfikaty self-signed z proxy. **Wymagane do inspekcji MITM.** ⚠️ Niezabezpieczone — używaj tylko do lokalnego debugowania.", "settings.workspace.rootResolution.description": "Jak Zoo ustala katalog główny workspace w workspace z wieloma katalogami głównymi. Katalog główny służy do lokalizowania `.roomodes`, `.roo/mcp.json`, `.roo/rules/` i innej konfiguracji na poziomie projektu. Zmiana tego ustawienia wpływa tylko na przyszłe wyszukiwania; uruchomione zadania zachowują swój pierwotny katalog główny.", "settings.workspace.rootResolution.activeEditor.description": "Użyj folderu workspace zawierającego aktywny edytor; w razie potrzeby wróć do pierwszego folderu workspace. (Domyślnie — zachowuje dotychczasowe zachowanie.)", - "settings.workspace.rootResolution.firstFolder.description": "Zawsze używaj pierwszego folderu workspace (workspaceFolders[0]). Deterministyczne — niezależne od tego, który plik jest aktualnie aktywny." + "settings.workspace.rootResolution.firstFolder.description": "Zawsze używaj pierwszego folderu workspace (workspaceFolders[0]). Deterministyczne — niezależne od tego, który plik jest aktualnie aktywny.", + "settings.autocomplete.disabled.description": "Wyłącza autouzupełnianie w edytorze dla tego obszaru roboczego, niezależnie od ustawienia w panelu ustawień Zoo Code. Przydatne dla repozytoriów, z których kod nigdy nie powinien trafiać do modelu completion.", + "settings.autocomplete.debugLogging.description": "Zapisuje diagnostykę autouzupełniania w edytorze — wygenerowany prompt, opóźnienie żądań i decyzje przetwarzania końcowego — w kanale wyjściowym Zoo-Code." } diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 872af10e80..046ca6e71b 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "Aceitar Entrada/Sugestão", "command.showRipgrepDiagnostic.title": "Mostrar diagnóstico do Ripgrep", "command.toggleAutoApprove.title": "Alternar Auto-Aprovação", + "command.triggerInlineCompletion.title": "Disparar preenchimento em linha", + "command.toggleAutocomplete.title": "Alternar preenchimento em linha", + "command.autocompleteAccepted.title": "Preenchimento em linha aceito", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Aceitar certificados self-signed do proxy. **Necessário para inspeção MITM.** ⚠️ Inseguro — use apenas para depuração local.", "settings.workspace.rootResolution.description": "Como o Zoo resolve a raiz do workspace em um workspace multi-raiz. A raiz é usada para localizar `.roomodes`, `.roo/mcp.json`, `.roo/rules/` e outras configurações no escopo do projeto. Alterar esta configuração afeta apenas as buscas futuras; as tarefas em execução mantêm sua raiz original.", "settings.workspace.rootResolution.activeEditor.description": "Usa a pasta do workspace que contém o editor ativo; recorre à primeira pasta do workspace. (Padrão — preserva o comportamento legado.)", - "settings.workspace.rootResolution.firstFolder.description": "Usa sempre a primeira pasta do workspace (workspaceFolders[0]). Determinístico — independente de qual arquivo está em foco no momento." + "settings.workspace.rootResolution.firstFolder.description": "Usa sempre a primeira pasta do workspace (workspaceFolders[0]). Determinístico — independente de qual arquivo está em foco no momento.", + "settings.autocomplete.disabled.description": "Desativa o autocompletar inline do Zoo Code neste workspace, independentemente da configuração no painel de configurações do Zoo Code. Útil para repositórios cujo código nunca deve ir para um modelo de completion.", + "settings.autocomplete.debugLogging.description": "Escreve diagnósticos do autocompletar inline — o prompt gerado, a latência das requisições e as decisões de pós-processamento — no canal de saída Zoo-Code." } diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index cb38655945..469a4ff96f 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -25,6 +25,9 @@ "command.acceptInput.title": "Принять ввод/предложение", "command.showRipgrepDiagnostic.title": "Показать диагностику Ripgrep", "command.toggleAutoApprove.title": "Переключить Авто-Подтверждение", + "command.triggerInlineCompletion.title": "Запустить встроенное дополнение", + "command.toggleAutocomplete.title": "Переключить встроенное дополнение", + "command.autocompleteAccepted.title": "Встроенное дополнение принято", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Команды, которые могут быть автоматически выполнены, когда включена опция 'Всегда подтверждать операции выполнения'", "commands.deniedCommands.description": "Префиксы команд, которые будут автоматически отклонены без запроса подтверждения. В случае конфликтов с разрешенными командами приоритет имеет самое длинное совпадение префикса. Добавьте * чтобы отклонить все команды.", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Принимать self-signed сертификаты от прокси. **Требуется для MITM-инспекции.** ⚠️ Небезопасно — используй только для локальной отладки.", "settings.workspace.rootResolution.description": "Как Zoo определяет корень рабочей области в рабочей области с несколькими корнями. Корень используется для поиска `.roomodes`, `.roo/mcp.json`, `.roo/rules/` и другой конфигурации уровня проекта. Изменение этой настройки влияет только на последующие поиски; выполняющиеся задачи сохраняют свой исходный корень.", "settings.workspace.rootResolution.activeEditor.description": "Использовать папку рабочей области, содержащую активный редактор; при необходимости вернуться к первой папке рабочей области. (По умолчанию — сохраняет прежнее поведение.)", - "settings.workspace.rootResolution.firstFolder.description": "Всегда использовать первую папку рабочей области (workspaceFolders[0]). Детерминированно — не зависит от того, какой файл сейчас в фокусе." + "settings.workspace.rootResolution.firstFolder.description": "Всегда использовать первую папку рабочей области (workspaceFolders[0]). Детерминированно — не зависит от того, какой файл сейчас в фокусе.", + "settings.autocomplete.disabled.description": "Отключает встроенное автодополнение Zoo Code для этой рабочей области независимо от значения в панели настроек Zoo Code. Полезно для репозиториев, код которых нельзя отправлять в модель completion.", + "settings.autocomplete.debugLogging.description": "Записывает диагностику встроенного автодополнения — сформированный prompt, задержку запросов и решения постобработки — в канал вывода Zoo-Code." } diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index 7d995723ce..7ee0fa063a 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "Girişi/Öneriyi Kabul Et", "command.showRipgrepDiagnostic.title": "Ripgrep Tanılamasını Göster", "command.toggleAutoApprove.title": "Otomatik Onayı Değiştir", + "command.triggerInlineCompletion.title": "Satır içi tamamlamayı tetikle", + "command.toggleAutocomplete.title": "Satır içi tamamlamayı değiştir", + "command.autocompleteAccepted.title": "Satır içi tamamlama kabul edildi", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Proxy'den gelen self-signed sertifikaları kabul et. **MITM incelemesi için gerekli.** ⚠️ Güvensiz — yalnızca lokal debugging için kullan.", "settings.workspace.rootResolution.description": "Zoo'nun çok köklü bir çalışma alanında çalışma alanı kökünü nasıl belirlediği. Kök; `.roomodes`, `.roo/mcp.json`, `.roo/rules/` ve diğer proje kapsamındaki yapılandırmayı bulmak için kullanılır. Bu ayarı değiştirmek yalnızca sonraki aramaları etkiler; çalışan görevler özgün köklerini korur.", "settings.workspace.rootResolution.activeEditor.description": "Aktif düzenleyiciyi içeren çalışma alanı klasörünü kullan; ilk çalışma alanı klasörüne geri dön. (Varsayılan — eski davranışı korur.)", - "settings.workspace.rootResolution.firstFolder.description": "Her zaman ilk çalışma alanı klasörünü (workspaceFolders[0]) kullan. Deterministik — o anda hangi dosyanın odakta olduğundan bağımsız." + "settings.workspace.rootResolution.firstFolder.description": "Her zaman ilk çalışma alanı klasörünü (workspaceFolders[0]) kullan. Deterministik — o anda hangi dosyanın odakta olduğundan bağımsız.", + "settings.autocomplete.disabled.description": "Zoo Code ayar panelindeki değerden bağımsız olarak, bu çalışma alanında satır içi otomatik tamamlamayı kapatır. Kodu asla bir completion modeline gitmemesi gereken depolar için kullanışlıdır.", + "settings.autocomplete.debugLogging.description": "Satır içi otomatik tamamlama tanılamalarını — oluşturulan prompt, istek gecikmesi ve son işleme kararları — Zoo-Code çıktı kanalına yazar." } diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index b50e4db508..db8dbe401a 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "Chấp Nhận Đầu Vào/Gợi Ý", "command.showRipgrepDiagnostic.title": "Hiển thị chẩn đoán Ripgrep", "command.toggleAutoApprove.title": "Bật/Tắt Tự Động Phê Duyệt", + "command.triggerInlineCompletion.title": "Kích hoạt hoàn thành nội tuyến", + "command.toggleAutocomplete.title": "Chuyển đổi hoàn thành nội tuyến", + "command.autocompleteAccepted.title": "Hoàn thành nội tuyến được chấp nhận", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "Chấp nhận chứng chỉ self-signed từ proxy. **Bắt buộc cho việc kiểm tra MITM.** ⚠️ Không an toàn — chỉ dùng cho debug cục bộ.", "settings.workspace.rootResolution.description": "Cách Zoo xác định gốc workspace trong một workspace nhiều gốc. Gốc được dùng để tìm `.roomodes`, `.roo/mcp.json`, `.roo/rules/` và các cấu hình khác ở phạm vi dự án. Thay đổi cài đặt này chỉ ảnh hưởng đến các lần tra cứu về sau; các tác vụ đang chạy vẫn giữ gốc ban đầu của chúng.", "settings.workspace.rootResolution.activeEditor.description": "Dùng thư mục workspace chứa trình soạn thảo đang hoạt động; quay lại thư mục workspace đầu tiên. (Mặc định — giữ nguyên hành vi cũ.)", - "settings.workspace.rootResolution.firstFolder.description": "Luôn dùng thư mục workspace đầu tiên (workspaceFolders[0]). Mang tính xác định — không phụ thuộc vào tệp nào đang được focus." + "settings.workspace.rootResolution.firstFolder.description": "Luôn dùng thư mục workspace đầu tiên (workspaceFolders[0]). Mang tính xác định — không phụ thuộc vào tệp nào đang được focus.", + "settings.autocomplete.disabled.description": "Tắt tự động hoàn thành nội tuyến của Zoo Code cho không gian làm việc này, bất kể thiết lập trong bảng cài đặt của Zoo Code. Hữu ích cho các kho mà mã không bao giờ được gửi tới mô hình completion.", + "settings.autocomplete.debugLogging.description": "Ghi thông tin chẩn đoán của tự động hoàn thành nội tuyến — prompt đã dựng, độ trễ yêu cầu và các quyết định hậu xử lý — vào kênh output Zoo-Code." } diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 0686d03a14..a1d200c622 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "接受输入/建议", "command.showRipgrepDiagnostic.title": "显示 Ripgrep 诊断", "command.toggleAutoApprove.title": "切换自动批准", + "command.triggerInlineCompletion.title": "触发内联补全", + "command.toggleAutocomplete.title": "切换内联补全", + "command.autocompleteAccepted.title": "内联补全已接受", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "接受来自代理的 self-signed 证书。**MITM 检查所必需。** ⚠️ 不安全——只在本地调试时使用。", "settings.workspace.rootResolution.description": "Zoo 在多根工作区中如何解析工作区根目录。根目录用于定位 `.roomodes`、`.roo/mcp.json`、`.roo/rules/` 以及其他项目范围的配置。更改此设置仅影响后续查找;正在运行的任务会保留其原始根目录。", "settings.workspace.rootResolution.activeEditor.description": "使用包含活动编辑器的工作区文件夹;回退到第一个工作区文件夹。(默认——保留旧行为。)", - "settings.workspace.rootResolution.firstFolder.description": "始终使用第一个工作区文件夹(workspaceFolders[0])。具有确定性——与当前聚焦的文件无关。" + "settings.workspace.rootResolution.firstFolder.description": "始终使用第一个工作区文件夹(workspaceFolders[0])。具有确定性——与当前聚焦的文件无关。", + "settings.autocomplete.disabled.description": "无论 Zoo Code 设置面板中的取值如何,都为当前工作区关闭内联自动补全。适合那些绝不能把代码发给 completion 模型的仓库。", + "settings.autocomplete.debugLogging.description": "将内联自动补全的诊断信息(生成的 prompt、请求延迟以及后处理决策)写入 Zoo-Code 输出通道。" } diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index 8005e0de7f..55a5b3b2c8 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -16,6 +16,9 @@ "command.acceptInput.title": "接受輸入/建議", "command.showRipgrepDiagnostic.title": "顯示 Ripgrep 診斷", "command.toggleAutoApprove.title": "切換自動批准", + "command.triggerInlineCompletion.title": "觸發內聯補全", + "command.toggleAutocomplete.title": "切換內聯補全", + "command.autocompleteAccepted.title": "內聯補全已接受", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", @@ -48,5 +51,7 @@ "settings.debugProxy.tlsInsecure.description": "接受來自代理的 self-signed 憑證。**MITM 檢查所必需。** ⚠️ 不安全——只在本機偵錯時使用。", "settings.workspace.rootResolution.description": "Zoo 在多根工作區中如何解析工作區根目錄。根目錄用於定位 `.roomodes`、`.roo/mcp.json`、`.roo/rules/` 以及其他專案範圍的設定。變更此設定僅影響後續查找;正在執行的工作會保留其原始根目錄。", "settings.workspace.rootResolution.activeEditor.description": "使用包含使用中編輯器的工作區資料夾;回退到第一個工作區資料夾。(預設——保留舊行為。)", - "settings.workspace.rootResolution.firstFolder.description": "一律使用第一個工作區資料夾(workspaceFolders[0])。具決定性——與目前聚焦的檔案無關。" + "settings.workspace.rootResolution.firstFolder.description": "一律使用第一個工作區資料夾(workspaceFolders[0])。具決定性——與目前聚焦的檔案無關。", + "settings.autocomplete.disabled.description": "無論 Zoo Code 設定面板中的值為何,都為目前工作區關閉內嵌自動完成。適合那些絕不能把程式碼送給 completion 模型的儲存庫。", + "settings.autocomplete.debugLogging.description": "將內嵌自動完成的診斷資訊(產生的 prompt、請求延遲以及後處理決策)寫入 Zoo-Code 輸出頻道。" } diff --git a/src/services/autocomplete/AutocompleteLogger.ts b/src/services/autocomplete/AutocompleteLogger.ts new file mode 100644 index 0000000000..d7693a0837 --- /dev/null +++ b/src/services/autocomplete/AutocompleteLogger.ts @@ -0,0 +1,63 @@ +import * as vscode from "vscode" + +/** + * Diagnostic log for the inline-completion pipeline, gated on + * `zoo-code.autocomplete.debugLogging`. + * + * Completions fail silently by design — a provider that returns `undefined` + * looks identical whether it was filtered, cancelled, empty, or errored. Without + * this the only symptom is "no ghost text", which is why misconfiguration is so + * hard to tell apart from a bug. + */ +export class AutocompleteLogger { + private channel: vscode.OutputChannel | undefined + + constructor(private readonly isEnabled: () => boolean) {} + + /** Logs a pipeline event. Cheap no-op when debug logging is off. */ + log(event: string, detail?: Record): void { + if (!this.isEnabled()) { + return + } + + const parts = detail + ? Object.entries(detail) + .map(([key, value]) => `${key}=${format(value)}`) + .join(" ") + : "" + + this.write(`[autocomplete] ${event}${parts ? ` ${parts}` : ""}`) + } + + /** Logs a rendered prompt across multiple lines so it stays readable. */ + logPrompt(label: string, text: string): void { + if (!this.isEnabled()) { + return + } + + this.write(`[autocomplete] ${label} ─────────────`) + this.write(text) + this.write("[autocomplete] ─────────────────────") + } + + dispose(): void { + this.channel?.dispose() + this.channel = undefined + } + + private write(line: string): void { + // Created lazily so a user who never enables debug logging never gets a + // stray output channel in their panel. + this.channel ??= vscode.window.createOutputChannel("Zoo Code Autocomplete") + this.channel.appendLine(line) + } +} + +/** Compact, single-line rendering; strings are quoted so empty values are visible. */ +function format(value: unknown): string { + if (typeof value === "string") { + return JSON.stringify(value.length > 120 ? `${value.slice(0, 120)}…` : value) + } + + return String(value) +} diff --git a/src/services/autocomplete/AutocompleteService.ts b/src/services/autocomplete/AutocompleteService.ts new file mode 100644 index 0000000000..de99103adf --- /dev/null +++ b/src/services/autocomplete/AutocompleteService.ts @@ -0,0 +1,254 @@ +import type { AutocompleteModelSummary, AutocompleteProviderId, ResolvedAutocompleteConfig } from "@roo-code/types" +import { resolveAutocompleteConfig } from "@roo-code/types" +import * as vscode from "vscode" + +import { RooIgnoreController } from "../../core/ignore/RooIgnoreController" +import { AutocompleteLogger } from "./AutocompleteLogger" +import { ContextGatherer } from "./context/ContextGatherer" +import { FileHeaderSource } from "./context/sources/FileHeaderSource" +import { OpenTabsSource } from "./context/sources/OpenTabsSource" +import { AutocompleteConfigService } from "./config/AutocompleteConfigService" +import { CompletionEngine } from "./CompletionEngine" +import { OllamaFimHandler } from "./providers/OllamaFimHandler" +import { OpenAiCompatibleFimHandler } from "./providers/OpenAiCompatibleFimHandler" +import type { FimCompletionHandler } from "./providers/FimCompletionHandler" +import { prefilterDocument, shouldBailForWidget, shouldSuppressAutomaticTrigger } from "./prefilters" +import type { AutocompleteServiceLike, AutocompleteServiceState } from "./types" +import { AutocompleteStatusBar, AUTOCOMPLETE_OPEN_SETTINGS_COMMAND } from "./ui/AutocompleteStatusBar" +import { ZooInlineCompletionProvider } from "./ZooInlineCompletionProvider" + +export interface AutocompleteServiceOptions { + context: vscode.ExtensionContext + /** Freshly resolved global config; re-read whenever settings change. */ + getGlobalConfig: () => ResolvedAutocompleteConfig + /** The persisted API key from SecretStorage, or undefined when none is set. */ + getApiKey: () => string | undefined + /** Opens the settings panel on the autocomplete section. */ + openSettings: () => void + /** Persists the global enable flag (routed through ContextProxy by the caller). */ + setEnabled: (enabled: boolean) => Promise +} + +/** + * Owns the lifecycle of the inline-completion feature: config merge, the + * workspace-level kill switch, the `.rooignore` gate, the status bar and the + * registration of the inline completion provider. + */ +export class AutocompleteService implements AutocompleteServiceLike { + private readonly configService: AutocompleteConfigService + private readonly statusBar: AutocompleteStatusBar + private readonly provider: ZooInlineCompletionProvider + private readonly rooIgnoreController: RooIgnoreController + private readonly context: vscode.ExtensionContext + private readonly openSettingsHandler: () => void + private readonly setEnabledHandler: (enabled: boolean) => Promise + private readonly getApiKey: () => string | undefined + private readonly logger: AutocompleteLogger + private readonly contextGatherer: ContextGatherer + + private constructor(options: AutocompleteServiceOptions) { + this.context = options.context + this.openSettingsHandler = options.openSettings + this.setEnabledHandler = options.setEnabled + this.getApiKey = options.getApiKey + this.configService = new AutocompleteConfigService(options.getGlobalConfig) + this.logger = new AutocompleteLogger(() => this.configService.isDebugLogging()) + // Ordered cheapest-first; the gatherer races them under one budget anyway. + this.contextGatherer = new ContextGatherer([new FileHeaderSource(), new OpenTabsSource()]) + + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "" + this.rooIgnoreController = new RooIgnoreController(cwd) + + const engine = this.buildEngine() + + this.provider = new ZooInlineCompletionProvider({ + getConfig: () => this.configService.getConfig(), + validateAccess: (filePath) => this.rooIgnoreController.validateAccess(filePath), + engine, + logger: this.logger, + }) + + this.statusBar = new AutocompleteStatusBar(this) + } + + /** Builds the completion engine with the handler for the current provider. */ + private buildEngine(): CompletionEngine { + const handler = this.buildHandler() + + return new CompletionEngine({ + getConfig: () => this.configService.getConfig(), + getApiKey: () => this.getApiKey(), + handler, + logger: this.logger, + contextGatherer: this.contextGatherer, + }) + } + + /** + * Resolves the FIM handler for the configured provider. Phase 2 ships Ollama; + * Phase 3 adds OpenAI-compatible (LM Studio / llama.cpp / vLLM). Codestral and + * chat-fallback land later in Phase 3. + */ + private buildHandler(overrides?: { + provider?: AutocompleteProviderId + baseUrl?: string + apiKey?: string + }): FimCompletionHandler { + const config = this.configService.getConfig() + const provider = overrides?.provider ?? config.provider + + // Model listing runs against values the user is still editing, so the + // override wins over persisted config when present. + const getConfig = () => ({ + ...this.configService.getConfig(), + ...(overrides?.baseUrl ? { baseUrl: overrides.baseUrl } : {}), + }) + const getApiKey = () => overrides?.apiKey ?? this.getApiKey() + + if (provider === "ollama") { + return new OllamaFimHandler({ getConfig, getApiKey }) + } + + if (provider === "openai-compatible") { + return new OpenAiCompatibleFimHandler({ getConfig, getApiKey }) + } + + // Any other provider id comes from the Providers tab. Those are reached over + // an OpenAI-compatible surface too, so the same handler serves them once a + // base URL is configured; without one there is nothing to call. + if (this.configService.getConfig().baseUrl) { + return new OpenAiCompatibleFimHandler({ getConfig, getApiKey }) + } + + return NOOP_HANDLER + } + + /** + * Lists the models the configured endpoint offers, so the settings UI can + * present a picker instead of asking the user to type an exact model id. + * + * Builds a handler on demand rather than reusing the engine's: the user is + * usually mid-edit (a base URL they just typed, a provider they just switched + * to) and has not saved yet, so the engine's handler reflects stale config. + */ + async listModels( + signal: AbortSignal, + overrides?: { provider?: AutocompleteProviderId; baseUrl?: string; apiKey?: string }, + ): Promise { + return this.buildHandler(overrides).listModels(signal) + } + + static async create(options: AutocompleteServiceOptions): Promise { + const service = new AutocompleteService(options) + await service.rooIgnoreController.initialize() + service.register() + return service + } + + private register(): void { + this.context.subscriptions.push( + vscode.languages.registerInlineCompletionItemProvider({ pattern: "**/*" }, this.provider), + vscode.commands.registerCommand(AUTOCOMPLETE_OPEN_SETTINGS_COMMAND, () => this.openSettingsHandler()), + ) + + // Workspace-scoped kill switch and debug toggle apply without restart. + this.context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("zoo-code.autocomplete")) { + this.handleSettingsChange() + } + }), + ) + + this.statusBar.show() + } + + /** Re-reads global config and re-renders the status bar after a settings save. */ + handleSettingsChange(): void { + this.statusBar.refresh() + } + + /** Clears the completion cache; call after a provider/model switch so stale entries don't resurface. */ + clearCache(): void { + // The engine owns its own cache; expose this once the cache is a service-level + // field (Phase 3 rebuilds the handler on provider change). For Phase 2 the + // cache keys include modelId, so a model switch naturally misses. + } + + /** + * Manual trigger: arms the provider for the next call and asks VS Code to + * request an inline suggestion. The provider consumes the flag on the next + * `provideInlineCompletionItems` invocation regardless of trigger kind. + */ + triggerInlineCompletion(): void { + this.provider.requestForcedTrigger() + void vscode.commands.executeCommand("editor.action.inlineSuggest.trigger") + } + + /** + * Flips the persisted global enable flag. Persisted through the same path as + * the settings panel so the webview state and the service never disagree. + */ + async toggleEnabled(): Promise { + const config = this.configService.getConfig() + await this.setEnabledHandler(!config.enabled) + this.handleSettingsChange() + } + + /** Public for tests: the config the provider would use right now. */ + getConfig(): ResolvedAutocompleteConfig { + return this.configService.getConfig() + } + + getState(): AutocompleteServiceState { + const config = this.configService.getConfig() + if (!config.enabled) { + const workspace = AutocompleteConfigService.readWorkspaceConfig() + return { enabled: false, reason: workspace.disabled ? "workspace-kill-switch" : "disabled" } + } + return { enabled: true } + } + + dispose(): void { + this.statusBar.dispose() + this.rooIgnoreController.dispose() + } +} + +let autocompleteService: AutocompleteService | undefined + +/** A handler that never produces completions; used until Phase 3 fills in all providers. */ +const NOOP_HANDLER: FimCompletionHandler = { + id: "chat-fallback", + usesNativeFim: false, + supportsStreaming: false, + async *streamFim() { + yield "" + return + }, + async listModels() { + return [] + }, + async validate() { + return { ok: false, error: "This provider is not yet supported for inline completion." } + }, +} + +/** + * Registers the single service instance for the extension host. Only + * `registerAutocomplete` (extension activation) should call this. + */ +export function setAutocompleteService(service: AutocompleteService): void { + autocompleteService = service +} + +/** + * Returns the active service, or `undefined` when the feature was never + * registered (e.g. tests, or activation order edge cases). Consumers guard with + * `?.` — the handler must never crash because autocomplete is unavailable. + */ +export function getAutocompleteService(): AutocompleteService | undefined { + return autocompleteService +} + +export { resolveAutocompleteConfig, prefilterDocument, shouldBailForWidget, shouldSuppressAutomaticTrigger } diff --git a/src/services/autocomplete/CompletionEngine.ts b/src/services/autocomplete/CompletionEngine.ts new file mode 100644 index 0000000000..29dc9596d6 --- /dev/null +++ b/src/services/autocomplete/CompletionEngine.ts @@ -0,0 +1,552 @@ +import type { ResolvedAutocompleteConfig } from "@roo-code/types" +import * as vscode from "vscode" + +import type { AutocompleteLogger } from "./AutocompleteLogger" +import type { ContextGatherer } from "./context/ContextGatherer" +import { CompletionCache, makeCacheKey } from "./cache/CompletionCache" +import { windowDocument } from "./context/windowing" +import { PromptBuilder } from "./prompt/PromptBuilder" +import type { FimCompletionHandler, FimRequest } from "./providers/FimCompletionHandler" +import { StreamPostProcessor } from "./stream/StreamPostProcessor" +import { DEFAULT_TRANSFORMS } from "./stream/transforms" +import { MAX_DOCUMENT_BYTES } from "./constants" + +export interface CompletionEngineOptions { + getConfig: () => ResolvedAutocompleteConfig + getApiKey: () => string | undefined + handler: FimCompletionHandler + cache?: CompletionCache + promptBuilder?: PromptBuilder + postProcessor?: StreamPostProcessor + /** Optional diagnostics; omitted in tests and when debug logging is off. */ + logger?: AutocompleteLogger + /** Cross-file context; omitted in tests that exercise the same-file path. */ + contextGatherer?: ContextGatherer +} + +/** Per-document record of the last produced completion, for minCharsTyped gating. */ +interface LastCompletion { + readonly documentVersion: number + readonly offset: number + readonly textLength: number +} + +/** + * Orchestrates the inline completion pipeline: + * + * ``` + * debounce → cache → windowing → prompt → stream → postprocess → InlineCompletionItem + * ``` + * + * Phase 2 is same-file only (no cross-file snippet sources); the ContextGatherer + * and SnippetSource pipeline arrive in Phase 4 and slot in before `promptBuilder.build`. + */ +export class CompletionEngine { + private readonly getConfig: () => ResolvedAutocompleteConfig + private readonly getApiKey: () => string | undefined + private readonly handler: FimCompletionHandler + private readonly cache: CompletionCache + private readonly promptBuilder: PromptBuilder + private readonly postProcessor: StreamPostProcessor + private readonly logger: AutocompleteLogger | undefined + private readonly contextGatherer: ContextGatherer | undefined + private readonly lastCompletion = new Map() + + constructor(options: CompletionEngineOptions) { + this.getConfig = options.getConfig + this.getApiKey = options.getApiKey + this.handler = options.handler + this.cache = options.cache ?? new CompletionCache() + this.promptBuilder = options.promptBuilder ?? new PromptBuilder() + this.postProcessor = options.postProcessor ?? new StreamPostProcessor(DEFAULT_TRANSFORMS) + this.logger = options.logger + this.contextGatherer = options.contextGatherer + } + + async provideInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + context: vscode.InlineCompletionContext, + token: vscode.CancellationToken, + ): Promise { + const config = this.getConfig() + + // No model configured → nothing to complete. + if (!config.modelId) { + return undefined + } + + // Debounce: wait, then bail if a newer keystroke cancelled us. Implemented + // as `await delay` (not a trailing-edge timer) so the cancellation is cheap. + await delay(config.debounceMs) + + if (token.isCancellationRequested) { + return undefined + } + + // minCharsTyped gate: don't re-trigger until enough has been typed since the last suggestion. + if (!this.meetsMinCharsTyped(document, position, config.minCharsTyped)) { + return undefined + } + + // Large-file guard: never stream a huge document through the pipeline. + if (document.getText().length > MAX_DOCUMENT_BYTES) { + return undefined + } + + const { prefix, suffix } = windowDocument( + document, + position, + config.maxPrefixTokens * 4, + config.maxSuffixTokens * 4, + ) + const key = makeCacheKey(prefix, suffix, config.modelId) + const cached = this.cache.get(key) + + if (cached) { + this.recordCompletion(document, position, cached.text) + return [this.toItem(cached.text, document, position)] + } + + // Typed-prefix continuation: the user typed more; reuse the cached middle. + const continuation = this.cache.getContinuation(prefix, suffix, config.modelId) + + if (continuation !== undefined) { + this.cache.set(key, { prefix, suffix, text: continuation, modelId: config.modelId }) + this.recordCompletion(document, position, continuation) + return [this.toItem(continuation, document, position)] + } + + // Cross-file context. Raced against a wall-clock budget so a slow source + // degrades the suggestion rather than delaying it. + const snippets = this.contextGatherer + ? await this.contextGatherer.gather({ document, position, prefix, suffix }, config, CONTEXT_BUDGET_MS) + : [] + + if (token.isCancellationRequested) { + return undefined + } + + if (snippets.length > 0) { + this.logger?.log("context", { + snippets: snippets.length, + sources: snippets.map((snippet) => snippet.source ?? "?").join(","), + }) + } + + const built = this.promptBuilder.build({ prefix, suffix, snippets, config }) + + const request: FimRequest = { + modelId: config.modelId, + baseUrl: config.baseUrl, + apiKey: this.getApiKey(), + prefix: built.prefix, + suffix: built.suffix, + renderedPrompt: built.renderedPrompt, + stopSequences: built.stopSequences, + temperature: config.temperature, + maxOutputTokens: config.maxOutputTokens, + contextLength: config.contextLength, + requestTimeoutMs: config.requestTimeoutMs, + // `instruct`/`none` templates have no FIM tokens: the handler must send + // the rendered prompt and omit `suffix`, or the model free-runs. + supportsFim: built.supportsFim, + useChatEndpoint: built.useChatEndpoint, + systemPrompt: built.systemPrompt, + signal: toAbortSignal(token), + } + + this.logger?.log("request", { + model: config.modelId, + template: built.templateId, + chat: built.useChatEndpoint, + fim: built.supportsFim, + promptChars: built.promptChars, + stops: built.stopSequences.length, + }) + this.logger?.logPrompt("prompt", built.useChatEndpoint ? built.renderedPrompt : built.prefix) + + const startedAt = Date.now() + let text = "" + + try { + const stream = this.handler.streamFim(request) + const processed = this.postProcessor.process(stream, { + prefix, + suffix, + // A chat model routinely wraps its whole reply in a fence, so on that + // path the fence is a container to unwrap — not a terminator. Leaving + // "```" in the stop set truncated every fenced reply to nothing. + stopSequences: built.useChatEndpoint + ? built.stopSequences.filter((stop) => stop !== "```") + : built.stopSequences, + // A *line* cap, not a token cap. `maxOutputTokens` (256) was being + // passed here, which silently disabled the limit entirely. + maxLines: config.multilineMode === "never" ? 1 : MAX_COMPLETION_LINES, + isChatReply: built.useChatEndpoint, + }) + + for await (const chunk of processed) { + if (token.isCancellationRequested) { + return undefined + } + + text += chunk + } + } catch (error) { + if (isAbortError(error)) { + this.logger?.log("aborted") + return undefined + } + + // VS Code discards provider rejections silently, so an unreachable + // endpoint or a 401 is indistinguishable from "no suggestion" unless + // it is logged here. + this.logger?.log("error", { message: error instanceof Error ? error.message : String(error) }) + + throw error + } + + const rawText = text + + if (built.useChatEndpoint) { + text = unwrapChatCodeReply(text, prefix) + } + + text = text.replace(/\s+$/, "") + + this.logger?.log("response", { + ms: Date.now() - startedAt, + rawChars: rawText.length, + chars: text.length, + text, + }) + + if (text.length === 0) { + // Distinguishes "the model returned nothing" from "post-processing + // removed everything", which are very different bugs. + this.logger?.log(rawText.length === 0 ? "empty-from-model" : "empty-after-postprocessing") + return undefined + } + + // A chat model asked to complete `def is_prime(n` may answer with the whole + // function body, which is a valid *answer* but not a valid *continuation* of + // the cursor line — splicing it in produced `def is_prime(nif n <= 1False…`. + if (!isCoherentContinuation(text, prefix)) { + this.logger?.log("rejected-incoherent", { text }) + return undefined + } + + // A completion that re-declares something already in the buffer is the model + // answering from the whole file rather than the cursor. Left in, it produced + // duplicate `def calculate_mean(numbers):` blocks stacked on each other. + const duplicate = findDuplicateDeclaration(text, prefix, suffix) + + if (duplicate) { + this.logger?.log("rejected-duplicate", { declaration: duplicate }) + return undefined + } + + this.cache.set(key, { prefix, suffix, text, modelId: config.modelId }) + this.recordCompletion(document, position, text) + + return [this.toItem(text, document, position)] + } + + /** + * Builds the InlineCompletionItem with a correct range. + * + * Pure insertion (cursor at a word boundary) → range collapses to the cursor. + * Mid-word (cursor inside a word) → the range covers the word, and the + * already-typed chars are folded into `insertText` so VS Code replaces rather + * than duplicates them (the #1 visible ghost-text bug). + */ + private toItem( + text: string, + document: vscode.TextDocument, + position: vscode.Position, + ): vscode.InlineCompletionItem { + const lineText = document.lineAt(position.line).text + const charAtCursor = lineText[position.character] + + // Cursor at the end of a word (or at a non-word char) is a pure insertion + // point — the next char is not part of the word being completed. + if (!charAtCursor || !WORD_CHAR.test(charAtCursor)) { + return new vscode.InlineCompletionItem(text, new vscode.Range(position, position)) + } + + // Mid-word: walk back to the word start and include the typed chars. + let wordStart = position.character + + while (wordStart > 0 && WORD_CHAR.test(lineText[wordStart - 1])) { + wordStart-- + } + + const typed = lineText.slice(wordStart, position.character) + const range = new vscode.Range(position.line, wordStart, position.line, position.character) + + return new vscode.InlineCompletionItem(typed + text, range) + } + + private meetsMinCharsTyped( + document: vscode.TextDocument, + position: vscode.Position, + minCharsTyped: number, + ): boolean { + if (minCharsTyped <= 0) { + return true + } + + const last = this.lastCompletion.get(document.uri.toString()) + + if (!last || last.documentVersion !== document.version) { + return true + } + + const typed = document.offsetAt(position) - last.offset + + return typed >= minCharsTyped + } + + private recordCompletion(document: vscode.TextDocument, position: vscode.Position, text: string): void { + this.lastCompletion.set(document.uri.toString(), { + documentVersion: document.version, + offset: document.offsetAt(position), + textLength: text.length, + }) + } +} + +/** + * Normalises a chat model's reply into raw insertable code. + * + * Chat-tuned models answer conversationally even under a strict system prompt. + * The recurring shapes, each handled here: + * - the whole answer wrapped in a ```lang fence; + * - the answer restating the prefix (or its last line) before continuing; + * - a leading newline where the cursor sits mid-line. + */ +export function unwrapChatCodeReply(text: string, prefix: string): string { + let result = text + + // Whole-reply fence, with or without a language tag. + const fenced = result.match(/^\s*```[\w+-]*\n([\s\S]*?)(?:\n```|```|$)/) + + if (fenced) { + result = fenced[1] + } + + // Any stray leading fence the regex above didn't span. + result = result.replace(/^\s*```[\w+-]*\n?/, "") + + // The model echoed our own cursor marker. Truncating rather than deleting is + // deliberate: everything after the marker is the model re-emitting context it + // was shown, which is what produced runs of ``. + const marker = result.indexOf(CURSOR_MARKER) + + if (marker !== -1) { + result = result.slice(0, marker) + } + + // The model restated the code we already have. Compare on the trailing run of + // the prefix, since that is all the model was shown of the current line. + const lastLine = prefix.slice(prefix.lastIndexOf("\n") + 1) + + if (lastLine.trim().length > 0 && result.startsWith(lastLine)) { + result = result.slice(lastLine.length) + } else { + const trimmedStart = result.replace(/^[ \t]*\n/, "") + + if (trimmedStart !== result && lastLine.trim().length > 0) { + // A leading blank line before a mid-line cursor would push the + // completion onto the next row, which reads as a duplicate. + result = trimmedStart + } + } + + // The cursor already sits after the line's indentation, but a chat model + // reproduces the indentation it inferred from the surrounding block. Emitting + // both double-indents the first line — very visible in Python. + if (/^[ \t]+$/.test(lastLine) && result.startsWith(lastLine)) { + result = result.slice(lastLine.length) + } + + return result +} + +/** + * Rejects a completion that does not continue the cursor line coherently. + * + * A chat model given `def is_prime(n` often answers with the *body* of the + * function rather than the rest of that line. Splicing that in yields + * `def is_prime(nif n <= 1FalseTrue` — syntactically destroyed code. + * + * The heuristic is narrow on purpose: it only fires when the cursor sits + * mid-expression (an unclosed bracket, or immediately after an identifier + * character) *and* the completion's first line begins with a token that cannot + * legally follow there. Anything ambiguous is allowed through, because a false + * rejection costs a suggestion while a false accept corrupts the buffer. + */ +export function isCoherentContinuation(text: string, prefix: string): boolean { + const lastLine = prefix.slice(prefix.lastIndexOf("\n") + 1) + const trailing = lastLine.trimEnd() + + // Cursor on a blank or indentation-only line: any block-level code is fine. + if (trailing.length === 0) { + return true + } + + const firstLine = text.split("\n", 1)[0].trimStart() + + if (firstLine.length === 0) { + return true + } + + const endsMidToken = /[\w$]$/.test(trailing) + const hasOpenBracket = countUnclosed(trailing) > 0 + + if (!endsMidToken && !hasOpenBracket) { + return true + } + + // A statement keyword cannot continue an identifier or an open argument list. + if (STATEMENT_START.test(firstLine)) { + return false + } + + // Mid-identifier the completion must continue *that* identifier. The tell is + // spacing: a continuation is glued on (`_of_list():`, `():`), whereas a restart + // begins its own word and then assigns or calls — which is how + // `def calculate_mean` + `mean = sum(...)` fused into `calculate_meanmean = …`. + // + // `text` is used rather than `firstLine` because leading whitespace is the + // signal here, and `firstLine` has already been trimmed. + if (endsMidToken && !hasOpenBracket) { + const glued = !/^\s/.test(text) + + return glued && !FRESH_STATEMENT.test(firstLine) + } + + return true +} + +/** Net count of unclosed brackets on a line, ignoring those inside strings. */ +function countUnclosed(line: string): number { + let depth = 0 + let quote: string | undefined + + for (let i = 0; i < line.length; i++) { + const char = line[i] + + if (quote) { + if (char === quote && line[i - 1] !== "\\") { + quote = undefined + } + + continue + } + + if (char === '"' || char === "'" || char === "`") { + quote = char + } else if (char === "(" || char === "[" || char === "{") { + depth++ + } else if (char === ")" || char === "]" || char === "}") { + depth-- + } + } + + return depth +} + +/** + * Returns the name of a declaration the completion re-introduces, if any. + * + * A chat model shown the whole file frequently answers with code it has already + * seen — re-emitting `def calculate_mean(numbers):` below the real one. The + * result compiles but is nonsense, and it is the most visible form of the + * "mixing contexts" failure. + */ +export function findDuplicateDeclaration(text: string, prefix: string, suffix: string): string | undefined { + const surrounding = `${prefix}\n${suffix}` + + for (const line of text.split("\n")) { + const match = DECLARATION.exec(line.trim()) + + if (!match) { + continue + } + + const name = match[2] + // Word-boundary match on the declaring keyword so a mere *call* to the + // function doesn't count as a redeclaration. + const declared = new RegExp(`\\b${match[1]}\\s+${escapeRegExp(name)}\\b`) + + if (declared.test(surrounding)) { + return name + } + } + + return undefined +} + +/** `def name(`, `class Name`, `function name(` — the shapes worth de-duplicating. */ +const DECLARATION = /^(def|class|function)\s+([A-Za-z_$][\w$]*)/ + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +/** + * A completion that opens a *new* statement: an identifier followed by an + * assignment, a call, or member access at the start of the line. Valid code, but + * not a continuation of a half-typed identifier. + */ +const FRESH_STATEMENT = /^[A-Za-z_$][\w$]*\s+=[^=]|^[A-Za-z_$][\w$]*=[^=]/ + +/** Keywords that begin a statement and so cannot continue a partial expression. */ +const STATEMENT_START = + /^(if|for|while|return|def|class|import|from|elif|else|try|except|finally|with|raise|yield|pass|break|continue|const|let|var|function|public|private|switch|case)\b/ + +/** The cursor marker used by the instruct template; must never survive into ghost text. */ +const CURSOR_MARKER = "" + +/** Word-constituent characters used for mid-word range detection. */ +const WORD_CHAR = /[\w$]/ + +/** + * Hard ceiling on completion lines. + * + * Ghost text longer than this is never useful: it is too much to read at a glance + * and almost always means the model has started generating unrelated code. + */ +const MAX_COMPLETION_LINES = 12 + +/** + * Wall-clock budget for all context sources combined. + * + * Deliberately tight: context that arrives after the user has typed another + * character is worthless, so a straggling source is dropped rather than waited on. + */ +const CONTEXT_BUDGET_MS = 120 + +/** Converts a CancellationToken to an AbortSignal the fetch handler can race. */ +function toAbortSignal(token: vscode.CancellationToken): AbortSignal { + const controller = new AbortController() + + if (token.isCancellationRequested) { + controller.abort() + } else { + token.onCancellationRequested(() => controller.abort()) + } + + return controller.signal +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === "AbortError" || (error as { code?: string }).code === "ABORT_ERR") +} + +/** Debounce delay that respects cancellation. */ +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/services/autocomplete/ZooInlineCompletionProvider.ts b/src/services/autocomplete/ZooInlineCompletionProvider.ts new file mode 100644 index 0000000000..8f57b0c424 --- /dev/null +++ b/src/services/autocomplete/ZooInlineCompletionProvider.ts @@ -0,0 +1,106 @@ +import type { ResolvedAutocompleteConfig } from "@roo-code/types" +import * as vscode from "vscode" + +import { MAX_DOCUMENT_BYTES } from "./constants" +import { prefilterDocument, shouldBailForWidget, shouldSuppressAutomaticTrigger } from "./prefilters" +import type { CompletionEngine } from "./CompletionEngine" +import type { AutocompleteLogger } from "./AutocompleteLogger" + +export interface ZooInlineCompletionProviderOptions { + getConfig: () => ResolvedAutocompleteConfig + validateAccess: (filePath: string) => boolean + /** The completion engine that produces ghost text; undefined during tests that only exercise prefilters. */ + engine?: CompletionEngine + /** Optional diagnostics; omitted in tests. */ + logger?: AutocompleteLogger +} + +/** + * v1 inline completion provider. + * + * Prefilters (multi-cursor, disabled, language allowlist, `.rooignore`, + * suggest-widget composition, manual trigger mode) run first; the + * {@link CompletionEngine} then produces the ghost text. The force-flag from the + * manual trigger command bypasses the trigger-mode gate for one request. + */ +export class ZooInlineCompletionProvider implements vscode.InlineCompletionItemProvider { + private readonly getConfig: () => ResolvedAutocompleteConfig + private readonly validateAccess: (filePath: string) => boolean + private readonly engine: CompletionEngine | undefined + private readonly logger: AutocompleteLogger | undefined + private forceRequested = false + + constructor(options: ZooInlineCompletionProviderOptions) { + this.getConfig = options.getConfig + this.validateAccess = options.validateAccess + this.engine = options.engine + this.logger = options.logger + } + + /** + * One-shot override for the manual trigger command: the next provider call is + * treated as user-initiated even when trigger mode is "manual". + */ + requestForcedTrigger(): void { + this.forceRequested = true + } + + provideInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + context: vscode.InlineCompletionContext, + token: vscode.CancellationToken, + ): vscode.ProviderResult { + const config = this.getConfig() + + // Cheap gates first. + if (shouldBailForWidget(context, document)) { + this.logger?.log("skipped", { reason: "suggest-widget" }) + return undefined + } + + const force = this.forceRequested + this.forceRequested = false + + if (!force && shouldSuppressAutomaticTrigger(context.triggerKind, config.triggerMode)) { + return undefined + } + + const cursorCount = this.countSelections(document) + const prefilter = prefilterDocument( + { document, position, cursorCount, languageId: document.languageId }, + config, + this.validateAccess, + ) + + if (!prefilter.ok) { + this.logger?.log("skipped", { reason: prefilter.reason }) + return undefined + } + + if (document.getText().length > MAX_DOCUMENT_BYTES) { + return undefined + } + + // No engine (prefilter-only mode, e.g. early tests) → nothing to show. + if (!this.engine) { + return undefined + } + + return this.engine.provideInlineCompletionItems(document, position, context, token) + } + + /** + * Counts the editor's selections. A plain cursor is exactly one collapsed + * selection; multi-cursor editing (Alt+Click, Cmd+D, column select) produces + * several, and completions are suppressed while those are active. + */ + private countSelections(document: vscode.TextDocument): number { + const editor = vscode.window.activeTextEditor + if (!editor || editor.document.uri.toString() !== document.uri.toString()) { + return 1 + } + + return editor.selections.length + } +} diff --git a/src/services/autocomplete/__tests__/AutocompleteLogger.spec.ts b/src/services/autocomplete/__tests__/AutocompleteLogger.spec.ts new file mode 100644 index 0000000000..58885ad6ed --- /dev/null +++ b/src/services/autocomplete/__tests__/AutocompleteLogger.spec.ts @@ -0,0 +1,115 @@ +import * as vscode from "vscode" + +import { AutocompleteLogger } from "../AutocompleteLogger" + +const appendLine = vi.fn() +const dispose = vi.fn() +const createOutputChannel = vi.fn(() => ({ appendLine, dispose })) + +vi.mock("vscode", async () => { + const actual = await vi.importActual("vscode") + return { + ...actual, + window: { createOutputChannel: (...args: unknown[]) => createOutputChannel(...(args as [])) }, + } +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("AutocompleteLogger", () => { + it("writes nothing at all while debug logging is off", () => { + const logger = new AutocompleteLogger(() => false) + + logger.log("triggered", { reason: "typing" }) + logger.logPrompt("prompt", "some text") + + // Not merely "no lines written" — the channel itself must never be created, + // or every user gets a stray output panel they never asked for. + expect(createOutputChannel).not.toHaveBeenCalled() + expect(appendLine).not.toHaveBeenCalled() + }) + + it("creates the output channel lazily, and only once", () => { + const logger = new AutocompleteLogger(() => true) + + logger.log("first") + logger.log("second") + + expect(createOutputChannel).toHaveBeenCalledTimes(1) + expect(appendLine).toHaveBeenCalledTimes(2) + }) + + it("renders an event with no detail", () => { + const logger = new AutocompleteLogger(() => true) + + logger.log("cancelled") + + expect(appendLine).toHaveBeenCalledWith("[autocomplete] cancelled") + }) + + it("renders detail as key=value pairs", () => { + const logger = new AutocompleteLogger(() => true) + + logger.log("context", { snippets: 3, sources: "open-tabs" }) + + expect(appendLine).toHaveBeenCalledWith('[autocomplete] context snippets=3 sources="open-tabs"') + }) + + it("quotes strings so an empty value stays visible", () => { + const logger = new AutocompleteLogger(() => true) + + logger.log("done", { text: "" }) + + expect(appendLine).toHaveBeenCalledWith('[autocomplete] done text=""') + }) + + it("truncates a long string value", () => { + const logger = new AutocompleteLogger(() => true) + + logger.log("prompt", { body: "x".repeat(200) }) + + const line = appendLine.mock.calls[0][0] as string + + expect(line).toContain("…") + expect(line.length).toBeLessThan(200) + }) + + it("renders non-string values without quoting", () => { + const logger = new AutocompleteLogger(() => true) + + logger.log("state", { enabled: true, count: 0, missing: undefined }) + + expect(appendLine).toHaveBeenCalledWith("[autocomplete] state enabled=true count=0 missing=undefined") + }) + + it("writes a prompt across delimited lines", () => { + const logger = new AutocompleteLogger(() => true) + + logger.logPrompt("rendered", "line one\nline two") + + expect(appendLine).toHaveBeenCalledTimes(3) + expect(appendLine).toHaveBeenNthCalledWith(2, "line one\nline two") + }) + + it("disposes the channel and can be disposed again safely", () => { + const logger = new AutocompleteLogger(() => true) + + logger.log("open") + logger.dispose() + logger.dispose() + + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it("recreates the channel after disposal", () => { + const logger = new AutocompleteLogger(() => true) + + logger.log("before") + logger.dispose() + logger.log("after") + + expect(createOutputChannel).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/services/autocomplete/__tests__/AutocompleteService.spec.ts b/src/services/autocomplete/__tests__/AutocompleteService.spec.ts new file mode 100644 index 0000000000..be191ffcd4 --- /dev/null +++ b/src/services/autocomplete/__tests__/AutocompleteService.spec.ts @@ -0,0 +1,267 @@ +import { resolveAutocompleteConfig, type ResolvedAutocompleteConfig } from "@roo-code/types" + +import { AutocompleteService, type AutocompleteServiceOptions } from "../AutocompleteService" +import { AUTOCOMPLETE_OPEN_SETTINGS_COMMAND } from "../ui/AutocompleteStatusBar" + +const workspaceConfig = { disabled: false, debugLogging: false } + +const statusBarItem = { + show: vi.fn(), + dispose: vi.fn(), + text: "", + tooltip: "", + command: "", + backgroundColor: undefined, +} +type ConfigChangeListener = (event: { affectsConfiguration: (section: string) => boolean }) => void + +const registerInlineCompletionItemProvider = vi.fn((..._args: unknown[]) => ({ dispose: vi.fn() })) +const registerCommand = vi.fn((_id: string, _handler: () => void) => ({ dispose: vi.fn() })) +const executeCommand = vi.fn((..._args: unknown[]) => undefined) +const onDidChangeConfiguration = vi.fn((_listener: ConfigChangeListener) => ({ dispose: vi.fn() })) + +vi.mock("vscode", async () => { + const actual = await vi.importActual("vscode") + return { + ...actual, + StatusBarAlignment: { Left: 1, Right: 2 }, + ThemeColor: class { + constructor(readonly id: string) {} + }, + window: { + createStatusBarItem: () => statusBarItem, + createOutputChannel: () => ({ appendLine: vi.fn(), dispose: vi.fn() }), + visibleTextEditors: [], + }, + languages: { + registerInlineCompletionItemProvider: (...a: unknown[]) => registerInlineCompletionItemProvider(...a), + }, + commands: { + registerCommand: (id: string, handler: () => void) => registerCommand(id, handler), + executeCommand: (...a: unknown[]) => executeCommand(...a), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/ws" } }], + onDidChangeConfiguration: (listener: ConfigChangeListener) => onDidChangeConfiguration(listener), + getConfiguration: () => ({ + get: (key: string, fallback: boolean) => + key === "autocomplete.disabled" + ? workspaceConfig.disabled + : key === "autocomplete.debugLogging" + ? workspaceConfig.debugLogging + : fallback, + }), + asRelativePath: (uri: { fsPath: string }) => uri.fsPath, + }, + } +}) + +// The ignore controller touches the filesystem; the service only awaits its init. +vi.mock("../../../core/ignore/RooIgnoreController", () => ({ + RooIgnoreController: class { + async initialize() {} + validateAccess() { + return true + } + dispose() {} + }, +})) + +const makeOptions = (overrides: Partial<{ enabled: boolean }> = {}) => { + const setEnabled = vi.fn(async () => {}) + const openSettings = vi.fn() + let config: ResolvedAutocompleteConfig = resolveAutocompleteConfig({ enabled: overrides.enabled ?? true }) + + // Kept as its own binding so assertions can read `subscriptions` without + // reaching through the `ExtensionContext` cast. + const subscriptions: { dispose: () => void }[] = [] + + return { + setEnabled, + openSettings, + subscriptions, + setConfig: (next: Partial) => { + config = { ...config, ...next } + }, + options: { + context: { subscriptions } as unknown as AutocompleteServiceOptions["context"], + getGlobalConfig: () => config, + getApiKey: () => undefined, + openSettings, + setEnabled, + }, + } +} + +beforeEach(() => { + workspaceConfig.disabled = false + workspaceConfig.debugLogging = false + vi.clearAllMocks() +}) + +describe("AutocompleteService.create", () => { + it("registers the provider, the settings command and a config listener", async () => { + const { options, subscriptions } = makeOptions() + + const service = await AutocompleteService.create(options) + + expect(registerInlineCompletionItemProvider).toHaveBeenCalledTimes(1) + expect(registerCommand).toHaveBeenCalledWith(AUTOCOMPLETE_OPEN_SETTINGS_COMMAND, expect.any(Function)) + expect(onDidChangeConfiguration).toHaveBeenCalledTimes(1) + expect(subscriptions).toHaveLength(3) + + service.dispose() + }) + + it("shows the status bar once registered", async () => { + const { options } = makeOptions() + + const service = await AutocompleteService.create(options) + + expect(statusBarItem.show).toHaveBeenCalledTimes(1) + + service.dispose() + }) + + it("routes the registered command to the openSettings handler", async () => { + const { options, openSettings } = makeOptions() + + const service = await AutocompleteService.create(options) + const handler = registerCommand.mock.calls[0][1] + handler() + + expect(openSettings).toHaveBeenCalledTimes(1) + + service.dispose() + }) + + it("re-renders the status bar when autocomplete configuration changes", async () => { + const { options } = makeOptions() + const service = await AutocompleteService.create(options) + + const listener = onDidChangeConfiguration.mock.calls[0][0] + + statusBarItem.text = "stale" + listener({ affectsConfiguration: (section: string) => section === "zoo-code.autocomplete" }) + + expect(statusBarItem.text).not.toBe("stale") + + service.dispose() + }) + + it("ignores configuration changes for unrelated sections", async () => { + const { options } = makeOptions() + const service = await AutocompleteService.create(options) + + const listener = onDidChangeConfiguration.mock.calls[0][0] + + statusBarItem.text = "unchanged" + listener({ affectsConfiguration: () => false }) + + expect(statusBarItem.text).toBe("unchanged") + + service.dispose() + }) +}) + +describe("AutocompleteService.getState", () => { + it("reports enabled when the global flag is on", async () => { + const service = await AutocompleteService.create(makeOptions({ enabled: true }).options) + + expect(service.getState()).toEqual({ enabled: true }) + + service.dispose() + }) + + it("reports a plain disabled state when the global flag is off", async () => { + const service = await AutocompleteService.create(makeOptions({ enabled: false }).options) + + expect(service.getState()).toEqual({ enabled: false, reason: "disabled" }) + + service.dispose() + }) + + it("distinguishes the workspace kill switch from a plain disable", async () => { + // The two look identical to the user otherwise, and the kill switch is the + // one a user cannot fix from the settings panel. + workspaceConfig.disabled = true + const service = await AutocompleteService.create(makeOptions({ enabled: true }).options) + + expect(service.getState()).toEqual({ enabled: false, reason: "workspace-kill-switch" }) + + service.dispose() + }) +}) + +describe("AutocompleteService.getConfig", () => { + it("returns the resolved global config", async () => { + const service = await AutocompleteService.create(makeOptions({ enabled: true }).options) + + expect(service.getConfig().enabled).toBe(true) + + service.dispose() + }) + + it("forces enabled off while the workspace kill switch is set", async () => { + workspaceConfig.disabled = true + const service = await AutocompleteService.create(makeOptions({ enabled: true }).options) + + expect(service.getConfig().enabled).toBe(false) + + service.dispose() + }) +}) + +describe("AutocompleteService.toggleEnabled", () => { + it("persists the inverse of the current flag", async () => { + const { options, setEnabled } = makeOptions({ enabled: true }) + const service = await AutocompleteService.create(options) + + await service.toggleEnabled() + + expect(setEnabled).toHaveBeenCalledWith(false) + + service.dispose() + }) + + it("turns the feature back on from a disabled state", async () => { + const { options, setEnabled } = makeOptions({ enabled: false }) + const service = await AutocompleteService.create(options) + + await service.toggleEnabled() + + expect(setEnabled).toHaveBeenCalledWith(true) + + service.dispose() + }) +}) + +describe("AutocompleteService.triggerInlineCompletion", () => { + it("asks VS Code for an inline suggestion", async () => { + const service = await AutocompleteService.create(makeOptions().options) + + service.triggerInlineCompletion() + + expect(executeCommand).toHaveBeenCalledWith("editor.action.inlineSuggest.trigger") + + service.dispose() + }) +}) + +describe("AutocompleteService.dispose", () => { + it("disposes the status bar", async () => { + const service = await AutocompleteService.create(makeOptions().options) + + service.dispose() + + expect(statusBarItem.dispose).toHaveBeenCalledTimes(1) + }) + + it("tolerates clearCache being called at any time", async () => { + const service = await AutocompleteService.create(makeOptions().options) + + expect(() => service.clearCache()).not.toThrow() + + service.dispose() + }) +}) diff --git a/src/services/autocomplete/__tests__/AutocompleteStatusBar.spec.ts b/src/services/autocomplete/__tests__/AutocompleteStatusBar.spec.ts new file mode 100644 index 0000000000..750d5bfab5 --- /dev/null +++ b/src/services/autocomplete/__tests__/AutocompleteStatusBar.spec.ts @@ -0,0 +1,110 @@ +import type { AutocompleteServiceLike } from "../types" +import { AUTOCOMPLETE_OPEN_SETTINGS_COMMAND, AutocompleteStatusBar } from "../ui/AutocompleteStatusBar" + +const item = { + command: undefined as string | undefined, + tooltip: undefined as string | undefined, + text: "", + backgroundColor: undefined as unknown, + show: vi.fn(), + dispose: vi.fn(), +} + +vi.mock("vscode", async () => { + const actual = await vi.importActual("vscode") + return { + ...actual, + StatusBarAlignment: { Left: 1, Right: 2 }, + ThemeColor: class { + constructor(readonly id: string) {} + }, + window: { createStatusBarItem: () => item }, + } +}) + +const serviceWith = (enabled: boolean) => ({ getState: () => ({ enabled }) }) as unknown as AutocompleteServiceLike + +beforeEach(() => { + item.command = undefined + item.tooltip = undefined + item.text = "" + item.backgroundColor = undefined + vi.clearAllMocks() +}) + +describe("AutocompleteStatusBar", () => { + it("stays hidden until show() is called", () => { + new AutocompleteStatusBar(serviceWith(true)) + + expect(item.show).not.toHaveBeenCalled() + }) + + it("wires the click through to the settings command", () => { + // The status bar never mutates persisted state itself; clicking opens settings. + new AutocompleteStatusBar(serviceWith(true)).show() + + expect(item.command).toBe(AUTOCOMPLETE_OPEN_SETTINGS_COMMAND) + expect(item.tooltip).toContain("configure") + expect(item.show).toHaveBeenCalledTimes(1) + }) + + it("renders the enabled state on show", () => { + new AutocompleteStatusBar(serviceWith(true)).show() + + expect(item.text).toBe("$(sparkles) Autocomplete") + expect(item.backgroundColor).toBeUndefined() + }) + + it("renders the disabled state on show", () => { + new AutocompleteStatusBar(serviceWith(false)).show() + + expect(item.text).toBe("$(sparkles) Autocomplete: Off") + }) + + it("re-reads live service state on refresh", () => { + let enabled = false + const bar = new AutocompleteStatusBar({ getState: () => ({ enabled }) } as unknown as AutocompleteServiceLike) + + bar.show() + expect(item.text).toBe("$(sparkles) Autocomplete: Off") + + enabled = true + bar.refresh() + expect(item.text).toBe("$(sparkles) Autocomplete") + }) + + it("shows an error background and icon", () => { + const bar = new AutocompleteStatusBar(serviceWith(true)) + + bar.update("error") + + expect(item.text).toBe("$(error) Autocomplete") + expect(item.backgroundColor).toBeDefined() + }) + + it("clears the error background when returning to ready", () => { + const bar = new AutocompleteStatusBar(serviceWith(true)) + + bar.update("error") + bar.update("ready") + + expect(item.backgroundColor).toBeUndefined() + expect(item.text).toBe("$(sparkles) Autocomplete") + }) + + it("treats an unknown status as off", () => { + const bar = new AutocompleteStatusBar(serviceWith(true)) + + bar.update("error") + bar.update("nonsense" as never) + + expect(item.text).toBe("$(sparkles) Autocomplete: Off") + expect(item.backgroundColor).toBeUndefined() + }) + + it("disposes the underlying item", () => { + new AutocompleteStatusBar(serviceWith(true)).dispose() + + expect(item.dispose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/services/autocomplete/__tests__/CompletionCache.spec.ts b/src/services/autocomplete/__tests__/CompletionCache.spec.ts new file mode 100644 index 0000000000..c3b7a59c55 --- /dev/null +++ b/src/services/autocomplete/__tests__/CompletionCache.spec.ts @@ -0,0 +1,100 @@ +import { CompletionCache, makeCacheKey } from "../cache/CompletionCache" + +describe("CompletionCache", () => { + describe("get / set", () => { + it("returns undefined for a miss", () => { + const cache = new CompletionCache() + expect(cache.get("missing")).toBeUndefined() + }) + + it("returns the stored entry on a hit", () => { + const cache = new CompletionCache() + cache.set("key", { prefix: "p", suffix: "s", text: "completion", modelId: "model" }) + expect(cache.get("key")?.text).toBe("completion") + }) + }) + + describe("LRU eviction", () => { + it("evicts the oldest entry when capacity is exceeded", () => { + const cache = new CompletionCache({ maxEntries: 2 }) + cache.set("a", { prefix: "", suffix: "", text: "A", modelId: "m" }) + cache.set("b", { prefix: "", suffix: "", text: "B", modelId: "m" }) + cache.set("c", { prefix: "", suffix: "", text: "C", modelId: "m" }) + + expect(cache.get("a")).toBeUndefined() + expect(cache.get("b")?.text).toBe("B") + expect(cache.get("c")?.text).toBe("C") + }) + + it("refreshes recency on get (LRU touch)", () => { + const cache = new CompletionCache({ maxEntries: 2 }) + cache.set("a", { prefix: "", suffix: "", text: "A", modelId: "m" }) + cache.set("b", { prefix: "", suffix: "", text: "B", modelId: "m" }) + // Touch "a" so it's more recently used than "b" + void cache.get("a") + cache.set("c", { prefix: "", suffix: "", text: "C", modelId: "m" }) + + expect(cache.get("a")?.text).toBe("A") + expect(cache.get("b")).toBeUndefined() + }) + }) + + describe("getContinuation", () => { + it("returns the trimmed completion when the prefix extends a cached one", () => { + const cache = new CompletionCache() + cache.set("key", { prefix: "function fi", suffix: ") { return", text: "b() { return", modelId: "model" }) + + // User typed "b" after "function fi", new prefix is "function fib" + const result = cache.getContinuation("function fib", ") { return", "model") + expect(result).toBe("() { return") + }) + + it("returns undefined when the typed chars don't match the cached completion", () => { + const cache = new CompletionCache() + cache.set("key", { prefix: "function fi", suffix: ") { return", text: "b() { return", modelId: "model" }) + + // User typed "x" — doesn't match "b..." + expect(cache.getContinuation("function fix", ") { return", "model")).toBeUndefined() + }) + + it("returns undefined when the suffix doesn't align", () => { + const cache = new CompletionCache() + cache.set("key", { prefix: "function fi", suffix: ") { return", text: "b() { return", modelId: "model" }) + + expect(cache.getContinuation("function fib", "different suffix", "model")).toBeUndefined() + }) + + it("returns undefined when the model differs", () => { + const cache = new CompletionCache() + cache.set("key", { prefix: "function fi", suffix: ") { return", text: "b() { return", modelId: "model-a" }) + + expect(cache.getContinuation("function fib", ") { return", "model-b")).toBeUndefined() + }) + + it("returns undefined when the typed prefix equals the cached prefix (no extension)", () => { + const cache = new CompletionCache() + cache.set("key", { prefix: "function fi", suffix: ") { return", text: "b() { return", modelId: "model" }) + + expect(cache.getContinuation("function fi", ") { return", "model")).toBeUndefined() + }) + }) + + describe("clear", () => { + it("removes all entries", () => { + const cache = new CompletionCache() + cache.set("a", { prefix: "", suffix: "", text: "A", modelId: "m" }) + cache.clear() + expect(cache.size).toBe(0) + }) + }) + + describe("makeCacheKey", () => { + it("produces a stable key for the same inputs", () => { + expect(makeCacheKey("prefix", "suffix", "model")).toBe(makeCacheKey("prefix", "suffix", "model")) + }) + + it("produces different keys for different inputs", () => { + expect(makeCacheKey("prefix1", "suffix", "model")).not.toBe(makeCacheKey("prefix2", "suffix", "model")) + }) + }) +}) diff --git a/src/services/autocomplete/__tests__/CompletionEngine.spec.ts b/src/services/autocomplete/__tests__/CompletionEngine.spec.ts new file mode 100644 index 0000000000..5cf6d2cc28 --- /dev/null +++ b/src/services/autocomplete/__tests__/CompletionEngine.spec.ts @@ -0,0 +1,473 @@ +import * as vscode from "vscode" + +vi.mock("vscode", async () => { + const actual = await vi.importActual("vscode") + return { + ...actual, + InlineCompletionTriggerKind: { Invoke: 0, Automatic: 1 }, + window: { ...actual.window, activeTextEditor: null }, + Range: class { + start: { line: number; character: number } + end: { line: number; character: number } + constructor( + startLine: number | { line: number; character: number }, + startChar: number | { line: number; character: number }, + endLine?: number, + endChar?: number, + ) { + if (typeof startLine === "number" && typeof startChar === "number") { + this.start = { line: startLine, character: startChar } + this.end = { line: endLine ?? startLine, character: endChar ?? startChar } + } else { + this.start = startLine as { line: number; character: number } + this.end = startChar as { line: number; character: number } + } + } + }, + InlineCompletionItem: class { + insertText: string + range: vscode.Range | undefined + constructor(insertText: string, range?: vscode.Range) { + this.insertText = insertText + this.range = range + } + }, + CancellationTokenSource: class { + token = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => {} }) } + cancel() { + ;(this.token as { isCancellationRequested: boolean }).isCancellationRequested = true + } + dispose() {} + }, + } +}) + +import { AUTOCOMPLETE_DEFAULTS, type ResolvedAutocompleteConfig } from "@roo-code/types" + +import { + CompletionEngine, + findDuplicateDeclaration, + isCoherentContinuation, + unwrapChatCodeReply, +} from "../CompletionEngine" +import { CompletionCache } from "../cache/CompletionCache" +import { PromptBuilder } from "../prompt/PromptBuilder" +import { StreamPostProcessor } from "../stream/StreamPostProcessor" +import { DEFAULT_TRANSFORMS } from "../stream/transforms" +import type { FimCompletionHandler, FimRequest } from "../providers/FimCompletionHandler" + +const resolvedConfig = (overrides: Partial = {}): ResolvedAutocompleteConfig => ({ + ...AUTOCOMPLETE_DEFAULTS, + enabled: true, + provider: "ollama", + modelId: "qwen2.5-coder:1.5b-base", + baseUrl: "http://localhost:11434", + triggerMode: "automatic", + debounceMs: 0, + minCharsTyped: 0, + multilineMode: "auto", + contextLength: 8192, + maxPrefixTokens: 1024, + maxSuffixTokens: 512, + maxSnippetTokens: 512, + maxOutputTokens: 256, + temperature: 0.01, + requestTimeoutMs: 5000, + useRecentlyEdited: true, + useOpenTabs: true, + useImportDefinitions: true, + useAst: true, + fimTemplate: "auto", + disabledLanguages: [], + ...overrides, +}) + +function makeDocument(content: string): vscode.TextDocument { + const lines = content.split("\n") + return { + getText: (range?: vscode.Range) => { + if (!range) return content + const result: string[] = [] + for (let i = range.start.line; i <= range.end.line; i++) { + if (i >= lines.length) break + let line = lines[i] ?? "" + if (i === range.start.line && i === range.end.line) { + line = line.slice(range.start.character, range.end.character) + } else if (i === range.start.line) { + line = line.slice(range.start.character) + } else if (i === range.end.line) { + line = line.slice(0, range.end.character) + } + result.push(line) + } + return result.join("\n") + }, + lineAt: (line: number | vscode.Position) => { + const lineNum = typeof line === "number" ? line : line.line + return { + text: lines[lineNum] ?? "", + range: new vscode.Range(lineNum, 0, lineNum, (lines[lineNum] ?? "").length), + lineNumber: lineNum, + rangeIncludingLineBreak: new vscode.Range(lineNum, 0, lineNum, (lines[lineNum] ?? "").length + 1), + firstNonWhitespaceCharacterIndex: 0, + isEmptyOrWhitespace: false, + } + }, + lineCount: lines.length, + uri: { fsPath: "/test.ts", toString: () => "file:///test.ts" }, + version: 1, + offsetAt: (pos: vscode.Position) => { + let offset = 0 + for (let i = 0; i < pos.line; i++) { + offset += (lines[i] ?? "").length + 1 + } + return offset + pos.character + }, + } as unknown as vscode.TextDocument +} + +function makeFakeHandler(completion: string): FimCompletionHandler { + return { + id: "ollama", + usesNativeFim: true, + supportsStreaming: true, + async *streamFim(request: FimRequest) { + // Split the completion into chunks to simulate streaming + yield completion + }, + async listModels() { + return [] + }, + async validate() { + return { ok: true } + }, + } +} + +describe("CompletionEngine", () => { + let engine: CompletionEngine + + function makeEngine(handler: FimCompletionHandler, configOverrides: Partial = {}) { + const cache = new CompletionCache() + return new CompletionEngine({ + getConfig: () => resolvedConfig(configOverrides), + getApiKey: () => undefined, + handler, + cache, + promptBuilder: new PromptBuilder(), + postProcessor: new StreamPostProcessor(DEFAULT_TRANSFORMS), + }) + } + + beforeEach(() => { + vi.useRealTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("produces a completion item from a streamed completion", async () => { + const handler = makeFakeHandler("b() { return a + b }") + engine = makeEngine(handler, { debounceMs: 0 }) + + const doc = makeDocument("function add(\n return a + b\n)") + const pos = new vscode.Position(0, 11) // after "function add(" + const context: vscode.InlineCompletionContext = { + triggerKind: vscode.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: undefined, + } + const token = new vscode.CancellationTokenSource().token + + const result = await engine.provideInlineCompletionItems(doc, pos, context, token) + + expect(result).toBeDefined() + expect(result).toHaveLength(1) + expect(result![0].insertText).toContain("b() { return a + b }") + }) + + it("returns undefined when no model is configured", async () => { + const handler = makeFakeHandler("completion") + engine = makeEngine(handler, { modelId: undefined }) + + const doc = makeDocument("hello") + const pos = new vscode.Position(0, 5) + const context: vscode.InlineCompletionContext = { + triggerKind: vscode.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: undefined, + } + const token = new vscode.CancellationTokenSource().token + + const result = await engine.provideInlineCompletionItems(doc, pos, context, token) + expect(result).toBeUndefined() + }) + + it("returns undefined for an empty completion", async () => { + const handler = makeFakeHandler(" ") + engine = makeEngine(handler, { debounceMs: 0 }) + + const doc = makeDocument("hello") + const pos = new vscode.Position(0, 5) + const context: vscode.InlineCompletionContext = { + triggerKind: vscode.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: undefined, + } + const token = new vscode.CancellationTokenSource().token + + const result = await engine.provideInlineCompletionItems(doc, pos, context, token) + expect(result).toBeUndefined() + }) + + it("serves a cached completion on a second identical request", async () => { + let streamCount = 0 + const handler: FimCompletionHandler = { + id: "ollama", + usesNativeFim: true, + supportsStreaming: true, + async *streamFim() { + streamCount++ + yield "completion" + }, + async listModels() { + return [] + }, + async validate() { + return { ok: true } + }, + } + + engine = makeEngine(handler, { debounceMs: 0 }) + + const doc = makeDocument("hello world") + const pos = new vscode.Position(0, 5) + const context: vscode.InlineCompletionContext = { + triggerKind: vscode.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: undefined, + } + const token1 = new vscode.CancellationTokenSource().token + const token2 = new vscode.CancellationTokenSource().token + + await engine.provideInlineCompletionItems(doc, pos, context, token1) + expect(streamCount).toBe(1) + + // Second request with the same prefix/suffix → cache hit + await engine.provideInlineCompletionItems(doc, pos, context, token2) + expect(streamCount).toBe(1) // handler NOT called again + }) + + it("uses a mid-word range when the cursor is inside a word", async () => { + const handler = makeFakeHandler("tion") + engine = makeEngine(handler, { debounceMs: 0 }) + + // Cursor inside "function" at position 4 (func|tion) + const doc = makeDocument("function") + const pos = new vscode.Position(0, 4) + const context: vscode.InlineCompletionContext = { + triggerKind: vscode.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: undefined, + } + const token = new vscode.CancellationTokenSource().token + + const result = await engine.provideInlineCompletionItems(doc, pos, context, token) + + expect(result).toBeDefined() + // insertText includes the already-typed "func" so VS Code replaces the word + expect(result![0].insertText).toBe("function") + // range covers the word from position 0 to 4 + expect(result![0].range?.start.character).toBe(0) + expect(result![0].range?.end.character).toBe(4) + }) + + it("uses pure insertion when the cursor is at a word boundary", async () => { + const handler = makeFakeHandler("(a, b)") + engine = makeEngine(handler, { debounceMs: 0 }) + + const doc = makeDocument("function add") + const pos = new vscode.Position(0, 12) // after "function add" + const context: vscode.InlineCompletionContext = { + triggerKind: vscode.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: undefined, + } + const token = new vscode.CancellationTokenSource().token + + const result = await engine.provideInlineCompletionItems(doc, pos, context, token) + + expect(result).toBeDefined() + expect(result![0].insertText).toBe("(a, b)") + // Pure insertion: range collapses to the cursor + expect(result![0].range?.start.character).toBe(12) + expect(result![0].range?.end.character).toBe(12) + }) + + it("returns undefined when cancelled during debounce", async () => { + const handler = makeFakeHandler("should not appear") + engine = makeEngine(handler, { debounceMs: 100 }) + + const doc = makeDocument("hello") + const pos = new vscode.Position(0, 5) + const context: vscode.InlineCompletionContext = { + triggerKind: vscode.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: undefined, + } + const cts = new vscode.CancellationTokenSource() + + // Start the request; cancel before the debounce window elapses. + vi.useFakeTimers() + const promise = engine.provideInlineCompletionItems(doc, pos, context, cts.token) + cts.cancel() + await vi.advanceTimersByTimeAsync(200) + + const result = await promise + vi.useRealTimers() + expect(result).toBeUndefined() + }) +}) +describe("unwrapChatCodeReply", () => { + it("truncates at an echoed cursor marker", () => { + // Observed leak: `def compute_square_of_number(1616...`. + // Everything from the marker on is the model re-emitting its own input. + expect(unwrapChatCodeReply("n)16161616", "def f(")).toBe("n)") + }) + + it("strips a fenced reply", () => { + expect(unwrapChatCodeReply("```python\nreturn n * n\n```", "def f(n):\n ")).toBe("return n * n") + }) + + it("strips a restated prefix line", () => { + expect(unwrapChatCodeReply("def f(n): return n", "def f(")).toBe("n): return n") + }) + + it("leaves clean code untouched", () => { + expect(unwrapChatCodeReply("n ** 2", "def square(")).toBe("n ** 2") + }) +}) + +describe("chat reply pipeline (regression)", () => { + // The exact failure reported: a fenced multi-line body was truncated to + // nothing because "```" was both a stop sequence and a reasoning-block + // opener, so the stream ended at the fence that *opened* the code. + const RAW = + "```python\n numbers = [1, 4, 9, 16, 25]\n square_sum = sum(num**2 for num in numbers)\n mean = square_sum / len(numbers)\n return mean\n```" + const PREFIX = "# prime stuff\ndef is_prime(n):\n return True\n\ndef calculate_square_mean_of_list():\n " + + async function* once(text: string): AsyncGenerator { + yield text + } + + it("keeps a fenced chat reply intact and unwraps it", async () => { + const processor = new StreamPostProcessor(DEFAULT_TRANSFORMS) + let streamed = "" + + for await (const chunk of processor.process(once(RAW), { + prefix: PREFIX, + suffix: "", + stopSequences: ["<|im_end|>", "<|endoftext|>"], + maxLines: 12, + isChatReply: true, + })) { + streamed += chunk + } + + const final = unwrapChatCodeReply(streamed, PREFIX) + + expect(final).toContain("numbers = [1, 4, 9, 16, 25]") + expect(final).toContain("return mean") + expect(final).not.toContain("```") + // The cursor already sits after the indentation, so the first line must not + // carry it again. + expect(final.startsWith("numbers")).toBe(true) + }) + + it("still stops a non-chat reply at a fence", async () => { + const processor = new StreamPostProcessor(DEFAULT_TRANSFORMS) + let streamed = "" + + for await (const chunk of processor.process(once("x = 1\n```\nprose"), { + prefix: "", + suffix: "", + stopSequences: [], + maxLines: 12, + })) { + streamed += chunk + } + + expect(streamed).toBe("x = 1\n") + }) +}) + +describe("isCoherentContinuation", () => { + it("rejects a function body offered mid-argument-list", () => { + // The reported corruption: typing `def is_prime(n` produced + // `def is_prime(nif n <= 1FalseTrue`. + expect(isCoherentContinuation("if n <= 1:\n return False", "def is_prime(n")).toBe(false) + }) + + it("rejects a statement keyword directly after an identifier", () => { + expect(isCoherentContinuation("return total", "total = coun")).toBe(false) + }) + + it("allows a genuine continuation of an open call", () => { + expect(isCoherentContinuation("umbers)", "count = len(n")).toBe(true) + expect(isCoherentContinuation(") -> bool:", "def is_prime(n")).toBe(true) + }) + + it("allows block-level code on a blank or indented line", () => { + expect(isCoherentContinuation("if not numbers:\n return None", "def mean(x):\n ")).toBe(true) + expect(isCoherentContinuation("return total / count", "")).toBe(true) + }) + + it("allows a statement after a completed statement", () => { + expect(isCoherentContinuation("return True", " return False\n")).toBe(true) + }) + + it("ignores brackets inside string literals", () => { + expect(isCoherentContinuation("return x", 'msg = "a (b"\n')).toBe(true) + }) +}) + +describe("isCoherentContinuation — mid-identifier restarts", () => { + it("rejects a fresh assignment offered mid-identifier", () => { + // Reported: `def calculate_mean` + `mean = sum(...)` fused into + // `def calculate_meanmean = sum(...)`. + expect(isCoherentContinuation("mean = sum(numbers) / len(numbers)", "def calculate_mean")).toBe(false) + }) + + it("allows an identifier-then-call continuation", () => { + // `_of_list():` and `print(x)` are structurally identical, so a rule that + // rejected calls would also reject valid completions of a partly-typed + // name. Assignment is the one shape that reliably signals a restart. + expect(isCoherentContinuation("nt(x)", "def pri")).toBe(true) + }) + + it("still allows genuine identifier continuation", () => { + expect(isCoherentContinuation("_of_list():", "def calculate_mean")).toBe(true) + expect(isCoherentContinuation("():", "def calculate_mean")).toBe(true) + }) + + it("allows assignments when the cursor is not mid-identifier", () => { + expect(isCoherentContinuation("mean = sum(numbers)", " ")).toBe(true) + }) +}) + +describe("findDuplicateDeclaration", () => { + const PREFIX = "def calculate_mean(numbers):\n return sum(numbers) / len(numbers)\n\n" + + it("rejects a re-declared function", () => { + // Reported: duplicate `def calculate_mean(numbers):` blocks stacked up. + expect(findDuplicateDeclaration("def calculate_mean(numbers):\n return 0", PREFIX, "")).toBe( + "calculate_mean", + ) + }) + + it("allows a genuinely new function", () => { + expect(findDuplicateDeclaration("def calculate_median(numbers):\n return 0", PREFIX, "")).toBeUndefined() + }) + + it("does not treat a call as a redeclaration", () => { + expect(findDuplicateDeclaration("result = calculate_mean(values)", PREFIX, "")).toBeUndefined() + }) + + it("checks the suffix as well as the prefix", () => { + expect(findDuplicateDeclaration("class Widget:\n pass", "", "class Widget:\n pass")).toBe("Widget") + }) +}) diff --git a/src/services/autocomplete/__tests__/OllamaFimHandler.spec.ts b/src/services/autocomplete/__tests__/OllamaFimHandler.spec.ts new file mode 100644 index 0000000000..d27782f6d8 --- /dev/null +++ b/src/services/autocomplete/__tests__/OllamaFimHandler.spec.ts @@ -0,0 +1,345 @@ +import type { FimRequest } from "../providers/FimCompletionHandler" +import { OllamaFimHandler } from "../providers/OllamaFimHandler" + +vi.mock("vscode", async () => { + const actual = await vi.importActual("vscode") + return { ...actual, InlineCompletionTriggerKind: { Invoke: 0, Automatic: 1 } } +}) + +function makeReadableStream(chunks: string[]): ReadableStream { + const encoder = new TextEncoder() + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)) + } + controller.close() + }, + }) +} + +function ndjsonStream(items: unknown[]): ReadableStream { + return makeReadableStream(items.map((item) => `${JSON.stringify(item)}\n`)) +} + +function makeRequest(overrides: Partial = {}): FimRequest { + return { + modelId: "qwen2.5-coder:1.5b-base", + baseUrl: "http://localhost:11434", + apiKey: undefined, + prefix: "function fi", + suffix: ") { return a + b }", + renderedPrompt: "function fi) { return a + b }", + supportsFim: true, + useChatEndpoint: false, + stopSequences: ["<|fim_pad|>"], + temperature: 0.01, + maxOutputTokens: 256, + contextLength: 8192, + requestTimeoutMs: 5000, + signal: new AbortController().signal, + ...overrides, + } +} + +describe("OllamaFimHandler", () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + globalThis.fetch = fetchMock as typeof fetch + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("sends a native FIM request with prefix and suffix", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + body: ndjsonStream([ + { response: "b", done: false }, + { response: "()", done: true }, + ]), + }) + + const handler = new OllamaFimHandler({ + getConfig: () => ({ modelId: "qwen2.5-coder:1.5b-base", baseUrl: "http://localhost:11434" }), + getApiKey: () => undefined, + }) + + const chunks: string[] = [] + + for await (const chunk of handler.streamFim(makeRequest())) { + chunks.push(chunk) + } + + expect(chunks.join("")).toBe("b()") + + const call = fetchMock.mock.calls[0] + const url = call[0] + const body = JSON.parse(call[1].body) + + expect(url).toBe("http://localhost:11434/api/generate") + expect(body.model).toBe("qwen2.5-coder:1.5b-base") + expect(body.prompt).toBe("function fi") + expect(body.suffix).toBe(") { return a + b }") + expect(body.stream).toBe(true) + expect(body.options.temperature).toBe(0.01) + expect(body.options.num_predict).toBe(256) + expect(body.options.stop).toEqual(["<|fim_pad|>"]) + }) + + it("parses NDJSON across partial line boundaries", async () => { + // Stream where JSON objects are split across chunks + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"response": "hel')) + controller.enqueue(encoder.encode('lo", "done": false}\n')) + controller.enqueue(encoder.encode('{"response": " world", "done": true}\n')) + controller.close() + }, + }) + + fetchMock.mockResolvedValueOnce({ ok: true, body: stream }) + + const handler = new OllamaFimHandler({ + getConfig: () => ({ modelId: "model", baseUrl: "http://localhost:11434" }), + getApiKey: () => undefined, + }) + + const chunks: string[] = [] + for await (const chunk of handler.streamFim(makeRequest())) { + chunks.push(chunk) + } + expect(chunks.join("")).toBe("hello world") + }) + + it("falls back to raw prompt on 400 'does not support insert'", async () => { + // First call: 400 with the "does not support insert" error + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + text: async () => "model does not support insert mode", + }) + + // Second call (retry): 200 with raw prompt + fetchMock.mockResolvedValueOnce({ + ok: true, + body: ndjsonStream([{ response: "completion", done: true }]), + }) + + const handler = new OllamaFimHandler({ + getConfig: () => ({ modelId: "model", baseUrl: "http://localhost:11434" }), + getApiKey: () => undefined, + }) + + const chunks: string[] = [] + for await (const chunk of handler.streamFim(makeRequest())) { + chunks.push(chunk) + } + expect(chunks.join("")).toBe("completion") + + // Second request should use raw mode + const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body) + expect(retryBody.raw).toBe(true) + expect(retryBody.prompt).toBe("function fi) { return a + b }") + expect(retryBody.suffix).toBeUndefined() + }) + + it("memoises the degraded mode for subsequent requests", async () => { + // First request: 400, triggers fallback + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + text: async () => "does not support insert", + }) + fetchMock.mockResolvedValueOnce({ + ok: true, + body: ndjsonStream([{ response: "x", done: true }]), + }) + + const handler = new OllamaFimHandler({ + getConfig: () => ({ modelId: "model", baseUrl: "http://localhost:11434" }), + getApiKey: () => undefined, + }) + + // Consume first request (triggers fallback + memoisation) + const first: string[] = [] + for await (const chunk of handler.streamFim(makeRequest())) { + first.push(chunk) + } + + // Second request should use raw mode immediately (no 400 retry) + fetchMock.mockResolvedValueOnce({ + ok: true, + body: ndjsonStream([{ response: "y", done: true }]), + }) + const second: string[] = [] + for await (const chunk of handler.streamFim(makeRequest())) { + second.push(chunk) + } + expect(second.join("")).toBe("y") + + // Only 3 fetch calls total (1 failed + 1 retry + 1 direct-raw) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(JSON.parse(fetchMock.mock.calls[2][1].body).raw).toBe(true) + }) + + it("throws on a non-400 error response", async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => "Internal server error", + }) + + const handler = new OllamaFimHandler({ + getConfig: () => ({ modelId: "model", baseUrl: "http://localhost:11434" }), + getApiKey: () => undefined, + }) + + await expect(async () => { + for await (const _chunk of handler.streamFim(makeRequest())) { + // should throw + } + }).rejects.toThrow("Ollama generate failed (500)") + }) + + it("throws on an error field in the stream", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + body: ndjsonStream([{ error: "model not found" }]), + }) + + const handler = new OllamaFimHandler({ + getConfig: () => ({ modelId: "model", baseUrl: "http://localhost:11434" }), + getApiKey: () => undefined, + }) + + await expect(async () => { + for await (const _chunk of handler.streamFim(makeRequest())) { + // should throw + } + }).rejects.toThrow("model not found") + }) + + it("swallows AbortError from a cancelled stream", async () => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"response": "hel')) + // Simulate the reader being cancelled (AbortError) + controller.error(new DOMException("Aborted", "AbortError")) + }, + }) + + fetchMock.mockResolvedValueOnce({ ok: true, body: stream }) + + const handler = new OllamaFimHandler({ + getConfig: () => ({ modelId: "model", baseUrl: "http://localhost:11434" }), + getApiKey: () => undefined, + }) + + // Should not throw — AbortError is swallowed. Partial buffered data is + // dropped: a cancelled request must not yield stale output. + const chunks: string[] = [] + for await (const chunk of handler.streamFim(makeRequest())) { + chunks.push(chunk) + } + expect(chunks.join("")).toBe("") + }) + + describe("listModels", () => { + const handlerFor = (modelId?: string) => + new OllamaFimHandler({ + getConfig: () => ({ modelId, baseUrl: "http://localhost:11434" }), + getApiKey: () => undefined, + }) + + const tagsResponse = (body: unknown) => ({ ok: true, json: async () => body }) + + it("maps the tag list to model summaries", async () => { + fetchMock.mockResolvedValue(tagsResponse({ models: [{ name: "qwen2.5-coder:1.5b-base" }] })) + + const models = await handlerFor().listModels(new AbortController().signal) + + expect(models).toEqual([ + { + id: "qwen2.5-coder:1.5b-base", + label: "qwen2.5-coder:1.5b-base", + contextWindow: undefined, + supportsFim: true, + }, + ]) + }) + + it("drops models that cannot serve completions", async () => { + // An embedding-only model appears in /api/tags but can never complete. + fetchMock.mockResolvedValue( + tagsResponse({ + models: [ + { name: "nomic-embed-text", capabilities: ["embedding"] }, + { name: "qwen2.5-coder:1.5b-base", capabilities: ["completion"] }, + ], + }), + ) + + const models = await handlerFor().listModels(new AbortController().signal) + + expect(models.map((m) => m.id)).toEqual(["qwen2.5-coder:1.5b-base"]) + }) + + it("keeps models that declare no capabilities at all", async () => { + fetchMock.mockResolvedValue(tagsResponse({ models: [{ name: "legacy-model" }] })) + + expect(await handlerFor().listModels(new AbortController().signal)).toHaveLength(1) + }) + + it("returns an empty list when the payload does not match the schema", async () => { + fetchMock.mockResolvedValue(tagsResponse({ unexpected: true })) + + expect(await handlerFor().listModels(new AbortController().signal)).toEqual([]) + }) + + it("throws when the tags endpoint rejects", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500 }) + + await expect(handlerFor().listModels(new AbortController().signal)).rejects.toThrow("500") + }) + }) + + describe("validate", () => { + const handlerFor = (modelId?: string) => + new OllamaFimHandler({ + getConfig: () => ({ modelId, baseUrl: "http://localhost:11434" }), + getApiKey: () => undefined, + }) + + it("succeeds when the configured model is present", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ models: [{ name: "qwen:base" }] }) }) + + const result = await handlerFor("qwen:base").validate(new AbortController().signal) + + expect(result.ok).toBe(true) + }) + + it("fails with a clear message when the model is not pulled", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ models: [{ name: "other" }] }) }) + + const result = await handlerFor("qwen:base").validate(new AbortController().signal) + + expect(result).toEqual({ ok: false, error: expect.stringContaining("was not found") }) + }) + + it("reports a transport failure as the validation error", async () => { + // An unreachable server is the most common misconfiguration; surfacing the + // message is the difference between "broken" and "not running". + fetchMock.mockRejectedValue(new Error("ECONNREFUSED")) + + const result = await handlerFor("qwen:base").validate(new AbortController().signal) + + expect(result).toEqual({ ok: false, error: "ECONNREFUSED" }) + }) + }) +}) diff --git a/src/services/autocomplete/__tests__/OpenAiCompatibleFimHandler.spec.ts b/src/services/autocomplete/__tests__/OpenAiCompatibleFimHandler.spec.ts new file mode 100644 index 0000000000..9f1d8c7f28 --- /dev/null +++ b/src/services/autocomplete/__tests__/OpenAiCompatibleFimHandler.spec.ts @@ -0,0 +1,317 @@ +import type { FimRequest } from "../providers/FimCompletionHandler" +import { OpenAiCompatibleFimHandler } from "../providers/OpenAiCompatibleFimHandler" + +vi.mock("vscode", async () => { + const actual = await vi.importActual("vscode") + return { ...actual, InlineCompletionTriggerKind: { Invoke: 0, Automatic: 1 } } +}) + +const openaiMocks = vi.hoisted(() => ({ + create: vi.fn(), + chatCreate: vi.fn(), + list: vi.fn(), + ctor: vi.fn(), +})) + +vi.mock("openai", () => ({ + default: class { + baseURL: string + apiKey: string + maxRetries: number + completions: { create: typeof openaiMocks.create } + chat: { completions: { create: typeof openaiMocks.chatCreate } } + models: { list: typeof openaiMocks.list } + + constructor(options: { baseURL: string; apiKey: string; maxRetries: number }) { + openaiMocks.ctor(options) + this.baseURL = options.baseURL + this.apiKey = options.apiKey + this.maxRetries = options.maxRetries + this.completions = { create: openaiMocks.create } + this.chat = { completions: { create: openaiMocks.chatCreate } } + this.models = { list: openaiMocks.list } + } + }, +})) + +function makeRequest(overrides: Partial = {}): FimRequest { + return { + modelId: "qwen2.5-coder-1.5b-instruct", + baseUrl: "http://localhost:1234", + apiKey: undefined, + prefix: "function fi", + suffix: ") { return a + b }", + renderedPrompt: "function fi) { return a + b }", + supportsFim: true, + useChatEndpoint: false, + stopSequences: ["<|fim_pad|>"], + temperature: 0.01, + maxOutputTokens: 256, + contextLength: 8192, + requestTimeoutMs: 5000, + signal: new AbortController().signal, + ...overrides, + } +} + +function streamOf(chunks: string[]) { + return { + [Symbol.asyncIterator]: async function* () { + for (const chunk of chunks) { + yield { choices: [{ text: chunk }] } + } + }, + } +} + +describe("OpenAiCompatibleFimHandler", () => { + let handler: OpenAiCompatibleFimHandler + + beforeEach(() => { + vi.clearAllMocks() + handler = new OpenAiCompatibleFimHandler({ + getConfig: () => ({ modelId: "qwen2.5-coder-1.5b-instruct", baseUrl: "http://localhost:1234" }), + getApiKey: () => undefined, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("sends a native FIM request with prefix and suffix", async () => { + openaiMocks.create.mockResolvedValueOnce(streamOf(["b", "()"])) + + const chunks: string[] = [] + for await (const chunk of handler.streamFim(makeRequest())) { + chunks.push(chunk) + } + expect(chunks.join("")).toBe("b()") + + const [params, options] = openaiMocks.create.mock.calls[0] + expect(params.model).toBe("qwen2.5-coder-1.5b-instruct") + expect(params.prompt).toBe("function fi") + expect(params.suffix).toBe(") { return a + b }") + expect(params.stream).toBe(true) + expect(params.max_tokens).toBe(256) + expect(options.signal).toBeDefined() + }) + + it("constructs the client with the noop key and /v1 base URL", async () => { + openaiMocks.create.mockResolvedValueOnce(streamOf(["x"])) + + for await (const _chunk of handler.streamFim(makeRequest())) { + // consume + } + + expect(openaiMocks.ctor).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "http://localhost:1234/v1", + apiKey: "noop", + maxRetries: 0, + }), + ) + }) + + it("retries with the rendered prompt on a 400 suffix rejection", async () => { + const rejection = Object.assign(new Error("suffix not supported"), { status: 400 }) + openaiMocks.create.mockRejectedValueOnce(rejection).mockResolvedValueOnce(streamOf(["completion"])) + + const chunks: string[] = [] + for await (const chunk of handler.streamFim(makeRequest())) { + chunks.push(chunk) + } + expect(chunks.join("")).toBe("completion") + + // Second call uses the rendered prompt and no suffix + const retryParams = openaiMocks.create.mock.calls[1][0] + expect(retryParams.prompt).toBe("function fi) { return a + b }") + expect(retryParams.suffix).toBeUndefined() + }) + + it("memoises the degraded mode per baseUrl", async () => { + const rejection = Object.assign(new Error("suffix not supported"), { status: 422 }) + openaiMocks.create + .mockRejectedValueOnce(rejection) + .mockResolvedValueOnce(streamOf(["x"])) + .mockResolvedValueOnce(streamOf(["y"])) + + // First request: 422 → retry with rendered prompt + for await (const _chunk of handler.streamFim(makeRequest())) { + // consume + } + + // Second request: straight to rendered prompt, no retry + for await (const _chunk of handler.streamFim(makeRequest())) { + // consume + } + + expect(openaiMocks.create).toHaveBeenCalledTimes(3) + expect(openaiMocks.create.mock.calls[2][0].prompt).toBe("function fi) { return a + b }") + }) + + it("swallows AbortError", async () => { + openaiMocks.create.mockRejectedValueOnce(new DOMException("Aborted", "AbortError")) + + const chunks: string[] = [] + for await (const chunk of handler.streamFim(makeRequest())) { + chunks.push(chunk) + } + expect(chunks).toHaveLength(0) + }) + + it("rethrows non-abort, non-suffix errors", async () => { + openaiMocks.create.mockRejectedValueOnce(new Error("network down")) + + await expect(async () => { + for await (const _chunk of handler.streamFim(makeRequest())) { + // should throw + } + }).rejects.toThrow("network down") + }) + + it("lists models from the /v1/models endpoint", async () => { + openaiMocks.list.mockResolvedValueOnce({ + data: [{ id: "qwen2.5-coder-1.5b-instruct" }, { id: "llama-3.2-3b" }], + }) + + const models = await handler.listModels(new AbortController().signal) + expect(models.map((m) => m.id)).toEqual(["qwen2.5-coder-1.5b-instruct", "llama-3.2-3b"]) + }) + + it("validates a known model", async () => { + openaiMocks.list.mockResolvedValueOnce({ + data: [{ id: "qwen2.5-coder-1.5b-instruct" }], + }) + + const result = await handler.validate(new AbortController().signal) + expect(result.ok).toBe(true) + }) + + it("fails validation for an unknown model", async () => { + openaiMocks.list.mockResolvedValueOnce({ + data: [{ id: "other-model" }], + }) + + const result = await handler.validate(new AbortController().signal) + expect(result.ok).toBe(false) + }) + + describe("chat endpoint path", () => { + async function drain(gen: AsyncGenerator): Promise { + let out = "" + for await (const chunk of gen) { + out += chunk + } + return out + } + + function chatStream(deltas: string[]) { + return { + async *[Symbol.asyncIterator]() { + for (const content of deltas) { + yield { choices: [{ delta: { content } }] } + } + }, + } + } + + it("routes an instruction-tuned model through chat completions", async () => { + openaiMocks.chatCreate.mockResolvedValueOnce(chatStream(["a + b"])) + + const text = await drain( + handler.streamFim(makeRequest({ useChatEndpoint: true, systemPrompt: "be terse", supportsFim: false })), + ) + + expect(text).toBe("a + b") + expect(openaiMocks.create).not.toHaveBeenCalled() + }) + + it("sends the system prompt as its own message", async () => { + // In a raw completions prompt the model simply continues the instruction + // text; only a system message is structurally out of band. + openaiMocks.chatCreate.mockResolvedValueOnce(chatStream([""])) + + await drain( + handler.streamFim(makeRequest({ useChatEndpoint: true, systemPrompt: "be terse", supportsFim: false })), + ) + + const payload = openaiMocks.chatCreate.mock.calls[0][0] + expect(payload.messages[0]).toEqual({ role: "system", content: "be terse" }) + expect(payload.messages[1].role).toBe("user") + }) + + it("omits the system message when there is no system prompt", async () => { + openaiMocks.chatCreate.mockResolvedValueOnce(chatStream([""])) + + await drain(handler.streamFim(makeRequest({ useChatEndpoint: true, supportsFim: false }))) + + expect(openaiMocks.chatCreate.mock.calls[0][0].messages).toHaveLength(1) + }) + + it("drops the code-fence stop so a fenced reply is not truncated to nothing", async () => { + openaiMocks.chatCreate.mockResolvedValueOnce(chatStream([""])) + + await drain( + handler.streamFim( + makeRequest({ useChatEndpoint: true, supportsFim: false, stopSequences: ["```", "<|im_end|>"] }), + ), + ) + + expect(openaiMocks.chatCreate.mock.calls[0][0].stop).not.toContain("```") + }) + + it("swallows an abort on the chat path", async () => { + const error = new Error("aborted") + error.name = "AbortError" + openaiMocks.chatCreate.mockRejectedValueOnce(error) + + const text = await drain(handler.streamFim(makeRequest({ useChatEndpoint: true, supportsFim: false }))) + + expect(text).toBe("") + }) + + it("surfaces an auth rejection with an actionable message", async () => { + // Hosted endpoints serve their model catalogue publicly but reject + // completions, so this looks configured while producing nothing. + const error = Object.assign(new Error("unauthorized"), { status: 401 }) + openaiMocks.chatCreate.mockRejectedValueOnce(error) + + await expect( + drain(handler.streamFim(makeRequest({ useChatEndpoint: true, supportsFim: false }))), + ).rejects.toThrow("API key") + }) + }) + + describe("base URL normalization", () => { + it("does not double up a /v1 the user already typed", async () => { + openaiMocks.create.mockResolvedValueOnce({ + async *[Symbol.asyncIterator]() { + yield { choices: [{ text: "" }] } + }, + }) + + const gen = handler.streamFim(makeRequest({ baseUrl: "http://localhost:1234/v1" })) + for await (const _ of gen) { + // drain + } + + expect(openaiMocks.ctor.mock.calls.at(-1)?.[0].baseURL).toBe("http://localhost:1234/v1") + }) + + it("adds a scheme to a bare host", async () => { + openaiMocks.create.mockResolvedValueOnce({ + async *[Symbol.asyncIterator]() { + yield { choices: [{ text: "" }] } + }, + }) + + const gen = handler.streamFim(makeRequest({ baseUrl: "localhost:1234" })) + for await (const _ of gen) { + // drain + } + + expect(openaiMocks.ctor.mock.calls.at(-1)?.[0].baseURL).toBe("http://localhost:1234/v1") + }) + }) +}) diff --git a/src/services/autocomplete/__tests__/OpenTabsSource.spec.ts b/src/services/autocomplete/__tests__/OpenTabsSource.spec.ts new file mode 100644 index 0000000000..f95c1c2abd --- /dev/null +++ b/src/services/autocomplete/__tests__/OpenTabsSource.spec.ts @@ -0,0 +1,141 @@ +import type { ResolvedAutocompleteConfig } from "@roo-code/types" + +import { OpenTabsSource } from "../context/sources/OpenTabsSource" +import type { SnippetSourceInput } from "../context/ContextGatherer" + +const visibleTextEditors: { document: FakeDocument }[] = [] + +vi.mock("vscode", async () => { + const actual = await vi.importActual("vscode") + return { + ...actual, + window: { + get visibleTextEditors() { + return visibleTextEditors + }, + }, + workspace: { + asRelativePath: (uri: { fsPath: string }) => uri.fsPath, + }, + } +}) + +interface FakeDocument { + uri: { toString: () => string; fsPath: string; scheme: string } + languageId: string + getText: () => string +} + +function doc(path: string, languageId: string, text: string, scheme = "file"): FakeDocument { + return { + uri: { toString: () => `${scheme}://${path}`, fsPath: path, scheme }, + languageId, + getText: () => text, + } +} + +function setOpenTabs(...docs: FakeDocument[]) { + visibleTextEditors.length = 0 + visibleTextEditors.push(...docs.map((document) => ({ document }))) +} + +const input = (document: FakeDocument): SnippetSourceInput => + ({ document, position: { line: 0, character: 0 }, prefix: "", suffix: "" }) as unknown as SnippetSourceInput + +const source = new OpenTabsSource() +const signal = () => new AbortController().signal + +beforeEach(() => { + visibleTextEditors.length = 0 +}) + +describe("OpenTabsSource.isEnabled", () => { + it("follows the useOpenTabs config flag", () => { + expect(source.isEnabled({ useOpenTabs: true } as ResolvedAutocompleteConfig)).toBe(true) + expect(source.isEnabled({ useOpenTabs: false } as ResolvedAutocompleteConfig)).toBe(false) + }) +}) + +describe("OpenTabsSource.gather", () => { + const current = doc("/ws/current.ts", "typescript", "const here = 1") + + it("collects top-level declarations from other tabs", async () => { + setOpenTabs(current, doc("/ws/other.ts", "typescript", "export function add(a, b) {\n\treturn a + b\n}")) + + const snippets = await source.gather(input(current), signal()) + + expect(snippets).toHaveLength(1) + expect(snippets[0].content).toContain("export function add(a, b) {") + expect(snippets[0].filePath).toBe("/ws/other.ts") + expect(snippets[0].source).toBe("open-tabs") + }) + + it("excludes the file being edited", async () => { + setOpenTabs(doc("/ws/current.ts", "typescript", "export function here() {}")) + + expect(await source.gather(input(current), signal())).toEqual([]) + }) + + it("excludes files in another language", async () => { + setOpenTabs(current, doc("/ws/data.json", "json", "export function nope() {}")) + + expect(await source.gather(input(current), signal())).toEqual([]) + }) + + it("excludes non-file schemes such as diff and git views", async () => { + setOpenTabs(current, doc("/ws/other.ts", "typescript", "export function nope() {}", "git")) + + expect(await source.gather(input(current), signal())).toEqual([]) + }) + + it("skips a tab with no top-level declarations", async () => { + setOpenTabs(current, doc("/ws/other.ts", "typescript", "\tconst nested = 1\n// a comment")) + + expect(await source.gather(input(current), signal())).toEqual([]) + }) + + it("ignores indented declarations", async () => { + // Nested definitions are implementation detail, not the file's surface. + setOpenTabs(current, doc("/ws/other.ts", "typescript", " function inner() {}\nclass Outer {}")) + + const snippets = await source.gather(input(current), signal()) + + expect(snippets[0].content).toContain("class Outer {}") + expect(snippets[0].content).not.toContain("function inner") + }) + + it("caps the number of tabs consulted", async () => { + const others = Array.from({ length: 9 }, (_, i) => + doc(`/ws/f${i}.ts`, "typescript", `export function f${i}() {}`), + ) + setOpenTabs(current, ...others) + + expect(await source.gather(input(current), signal())).toHaveLength(5) + }) + + it("caps declarations taken from a single tab", async () => { + const many = Array.from({ length: 50 }, (_, i) => `export function f${i}() {}`).join("\n") + setOpenTabs(current, doc("/ws/big.ts", "typescript", many)) + + const snippets = await source.gather(input(current), signal()) + // One header line plus the per-tab declaration cap. + const lines = snippets[0].content.split("\n") + + expect(lines).toHaveLength(31) + }) + + it("stops early when the signal is already aborted", async () => { + setOpenTabs(current, doc("/ws/other.ts", "typescript", "export function add() {}")) + + const controller = new AbortController() + controller.abort() + + expect(await source.gather(input(current), controller.signal)).toEqual([]) + }) + + it("returns nothing when no other tabs are open", async () => { + setOpenTabs(current) + + expect(await source.gather(input(current), signal())).toEqual([]) + }) +}) diff --git a/src/services/autocomplete/__tests__/ZooInlineCompletionProvider.spec.ts b/src/services/autocomplete/__tests__/ZooInlineCompletionProvider.spec.ts new file mode 100644 index 0000000000..c61d1289d7 --- /dev/null +++ b/src/services/autocomplete/__tests__/ZooInlineCompletionProvider.spec.ts @@ -0,0 +1,186 @@ +// npx vitest run src/services/autocomplete/__tests__/ZooInlineCompletionProvider.spec.ts + +import * as vscode from "vscode" + +import type { ResolvedAutocompleteConfig } from "@roo-code/types" + +import { ZooInlineCompletionProvider } from "../ZooInlineCompletionProvider" + +vi.mock("vscode", async () => { + const actual = await vi.importActual("vscode") + return { + ...actual, + InlineCompletionTriggerKind: { Automatic: 0, Invoke: 1 }, + CancellationTokenSource: class { + token = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => {} }) } + cancel() {} + dispose() {} + }, + } +}) + +const resolvedConfig = (overrides: Partial = {}): ResolvedAutocompleteConfig => ({ + enabled: true, + provider: "ollama", + modelId: undefined, + baseUrl: "http://localhost:11434", + chatFallbackProvider: undefined, + triggerMode: "automatic", + debounceMs: 300, + minCharsTyped: 0, + multilineMode: "auto", + contextLength: 8192, + maxPrefixTokens: 1024, + maxSuffixTokens: 512, + maxSnippetTokens: 512, + maxOutputTokens: 256, + temperature: 0.01, + requestTimeoutMs: 5_000, + useRecentlyEdited: true, + useOpenTabs: true, + useImportDefinitions: true, + useAst: true, + fimTemplate: "auto", + stopSequences: undefined, + disabledLanguages: [], + ...overrides, +}) + +const makeDocument = () => + ({ + uri: { fsPath: "/workspace/src/app.ts", toString: () => "file:///workspace/src/app.ts" }, + languageId: "typescript", + getText: () => "function fib() {}\n", + }) as unknown as vscode.TextDocument + +const automaticContext: vscode.InlineCompletionContext = { + triggerKind: vscode.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: undefined, +} + +const InvokeContext: vscode.InlineCompletionContext = { + triggerKind: vscode.InlineCompletionTriggerKind.Invoke, + selectedCompletionInfo: undefined, +} + +describe("ZooInlineCompletionProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns no completion while the feature is disabled", () => { + const provider = new ZooInlineCompletionProvider({ + getConfig: () => resolvedConfig({ enabled: false }), + validateAccess: () => true, + }) + + const result = provider.provideInlineCompletionItems( + makeDocument(), + new vscode.Position(0, 0), + automaticContext, + new vscode.CancellationTokenSource().token, + ) + + expect(result).toBeUndefined() + }) + + it("returns no completion when the document is excluded by .rooignore", () => { + const validateAccess = vi.fn(() => false) + const provider = new ZooInlineCompletionProvider({ + getConfig: () => resolvedConfig(), + validateAccess, + }) + + const result = provider.provideInlineCompletionItems( + makeDocument(), + new vscode.Position(0, 0), + automaticContext, + new vscode.CancellationTokenSource().token, + ) + + expect(result).toBeUndefined() + expect(validateAccess).toHaveBeenCalledWith("/workspace/src/app.ts") + }) + + it("returns no completion while the suggest widget has a selection", () => { + const provider = new ZooInlineCompletionProvider({ + getConfig: () => resolvedConfig(), + validateAccess: () => true, + }) + + const result = provider.provideInlineCompletionItems( + makeDocument(), + new vscode.Position(0, 0), + { + triggerKind: vscode.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: { text: "fib", range: new vscode.Range(0, 0, 0, 3) }, + }, + new vscode.CancellationTokenSource().token, + ) + + expect(result).toBeUndefined() + }) + + it("suppresses automatic triggers in manual mode", () => { + const provider = new ZooInlineCompletionProvider({ + getConfig: () => resolvedConfig({ triggerMode: "manual" }), + validateAccess: () => true, + }) + + const result = provider.provideInlineCompletionItems( + makeDocument(), + new vscode.Position(0, 0), + automaticContext, + new vscode.CancellationTokenSource().token, + ) + + expect(result).toBeUndefined() + }) + + it("allows the Invoke trigger in manual mode", () => { + const provider = new ZooInlineCompletionProvider({ + getConfig: () => resolvedConfig({ triggerMode: "manual" }), + validateAccess: () => true, + }) + + const result = provider.provideInlineCompletionItems( + makeDocument(), + new vscode.Position(0, 0), + InvokeContext, + new vscode.CancellationTokenSource().token, + ) + + // Phase 1 has no engine yet, so even a passing prefilter yields nothing — + // but it must NOT return undefined via the trigger-mode gate. + expect(result).toBeUndefined() + }) + + it("honors a forced trigger even in manual mode with an automatic request", () => { + const provider = new ZooInlineCompletionProvider({ + getConfig: () => resolvedConfig({ triggerMode: "manual" }), + validateAccess: () => true, + }) + + provider.requestForcedTrigger() + + const result = provider.provideInlineCompletionItems( + makeDocument(), + new vscode.Position(0, 0), + automaticContext, + new vscode.CancellationTokenSource().token, + ) + + // The force flag is consumed: no engine exists yet, so nothing is returned, + // but the trigger-mode gate did not reject the call. + expect(result).toBeUndefined() + + // The flag is one-shot: the next automatic call is suppressed again. + const second = provider.provideInlineCompletionItems( + makeDocument(), + new vscode.Position(0, 0), + automaticContext, + new vscode.CancellationTokenSource().token, + ) + expect(second).toBeUndefined() + }) +}) diff --git a/src/services/autocomplete/__tests__/prefilters.spec.ts b/src/services/autocomplete/__tests__/prefilters.spec.ts new file mode 100644 index 0000000000..d2f2b00821 --- /dev/null +++ b/src/services/autocomplete/__tests__/prefilters.spec.ts @@ -0,0 +1,166 @@ +// npx vitest run src/services/autocomplete/__tests__/prefilters.spec.ts + +import * as vscode from "vscode" + +// The shared vscode mock (resolve.alias) has no InlineCompletionTriggerKind; +// augment it locally rather than touching the shared mock used by every suite. +vi.mock("vscode", async () => { + const actual = await vi.importActual("vscode") + return { + ...actual, + InlineCompletionTriggerKind: { Automatic: 0, Invoke: 1 }, + } +}) + +import { AUTOCOMPLETE_DEFAULTS, type AutocompleteConfig } from "@roo-code/types" + +import { MAX_CURSORS } from "../constants" +import { + isLanguageDisabled, + prefilterDocument, + shouldBailForWidget, + shouldSuppressAutomaticTrigger, +} from "../prefilters" +import type { AutocompleteInput } from "../types" + +const makeInput = (overrides: Partial = {}): AutocompleteInput => ({ + document: { + uri: { fsPath: "/workspace/src/app.ts" }, + languageId: "typescript", + } as unknown as vscode.TextDocument, + position: new vscode.Position(0, 0), + cursorCount: 1, + languageId: "typescript", + ...overrides, +}) + +const defaultConfig = (overrides: AutocompleteConfig = {}): AutocompleteConfig => ({ + ...AUTOCOMPLETE_DEFAULTS, + enabled: true, + ...overrides, +}) + +describe("prefilterDocument", () => { + it("accepts a plain cursor in an enabled, allowed language", () => { + const result = prefilterDocument(makeInput(), defaultConfig(), () => true) + expect(result).toEqual({ ok: true }) + }) + + it("rejects multi-cursor editing before any other check", () => { + const result = prefilterDocument( + makeInput({ cursorCount: MAX_CURSORS + 1 }), + defaultConfig({ enabled: true }), + () => true, + ) + expect(result).toEqual({ ok: false, reason: "multi-cursor" }) + }) + + it("rejects when the feature is disabled", () => { + const result = prefilterDocument(makeInput(), defaultConfig({ enabled: false }), () => true) + expect(result).toEqual({ ok: false, reason: "disabled" }) + }) + + it("rejects built-in never-complete languages", () => { + const result = prefilterDocument(makeInput({ languageId: "markdown" }), defaultConfig(), () => true) + expect(result).toEqual({ ok: false, reason: "language" }) + }) + + it("rejects user-disabled languages", () => { + const result = prefilterDocument( + makeInput({ languageId: "vue" }), + defaultConfig({ disabledLanguages: ["vue"] }), + () => true, + ) + expect(result).toEqual({ ok: false, reason: "language" }) + }) + + it("allows a user-disabled language when the list is empty", () => { + const result = prefilterDocument( + makeInput({ languageId: "vue" }), + defaultConfig({ disabledLanguages: [] }), + () => true, + ) + expect(result).toEqual({ ok: true }) + }) + + it("rejects files excluded by .rooignore", () => { + const result = prefilterDocument(makeInput(), defaultConfig(), () => false) + expect(result).toEqual({ ok: false, reason: "rooignore" }) + }) + + it("runs the language check before the rooignore check", () => { + // An ignored file in a never-complete language should report the language + // gate (cheapest check first); the rooignore validator must not be called. + const validateAccess = vi.fn(() => false) + const result = prefilterDocument(makeInput({ languageId: "log" }), defaultConfig(), validateAccess) + expect(result).toEqual({ ok: false, reason: "language" }) + expect(validateAccess).not.toHaveBeenCalled() + }) +}) + +describe("shouldBailForWidget", () => { + /** A document whose text in any range is `text`. */ + const documentWith = (text: string) => ({ getText: () => text }) as unknown as vscode.TextDocument + + const contextWith = (selected?: { text: string; range: vscode.Range }) => + ({ + triggerKind: vscode.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: selected, + }) as unknown as vscode.InlineCompletionContext + + it("bails when the widget would insert text beyond what is typed", () => { + // The widget will replace "foo" with "foobar", so a completion computed + // against the current document is stale. + expect( + shouldBailForWidget( + contextWith({ text: "foobar", range: new vscode.Range(0, 0, 0, 3) }), + documentWith("foo"), + ), + ).toBe(true) + }) + + it("does not bail when the widget selection merely echoes the typed text", () => { + // This is the common case in Python/TypeScript: the widget re-opens on + // nearly every keystroke showing what is already there. Bailing here + // suppressed ghost text permanently. + expect( + shouldBailForWidget( + contextWith({ text: "number", range: new vscode.Range(0, 0, 0, 6) }), + documentWith("number"), + ), + ).toBe(false) + }) + + it("does not bail when nothing is selected in the widget", () => { + expect(shouldBailForWidget(contextWith(undefined), documentWith(""))).toBe(false) + }) +}) + +describe("shouldSuppressAutomaticTrigger", () => { + it("suppresses automatic triggers in manual mode", () => { + expect(shouldSuppressAutomaticTrigger(vscode.InlineCompletionTriggerKind.Automatic, "manual")).toBe(true) + }) + + it("allows Invoke triggers in manual mode", () => { + expect(shouldSuppressAutomaticTrigger(vscode.InlineCompletionTriggerKind.Invoke, "manual")).toBe(false) + }) + + it("allows automatic triggers in automatic mode", () => { + expect(shouldSuppressAutomaticTrigger(vscode.InlineCompletionTriggerKind.Automatic, "automatic")).toBe(false) + }) +}) + +describe("isLanguageDisabled", () => { + it("treats plaintext and markdown as always disabled", () => { + expect(isLanguageDisabled("plaintext", defaultConfig())).toBe(true) + expect(isLanguageDisabled("markdown", defaultConfig())).toBe(true) + }) + + it("treats typescript as enabled", () => { + expect(isLanguageDisabled("typescript", defaultConfig())).toBe(false) + }) + + it("respects the user override list", () => { + expect(isLanguageDisabled("python", defaultConfig({ disabledLanguages: ["python"] }))).toBe(true) + }) +}) diff --git a/src/services/autocomplete/__tests__/streamReaders.spec.ts b/src/services/autocomplete/__tests__/streamReaders.spec.ts new file mode 100644 index 0000000000..ee3f212017 --- /dev/null +++ b/src/services/autocomplete/__tests__/streamReaders.spec.ts @@ -0,0 +1,183 @@ +import { readNdjson, readSse, readText } from "../stream/streamReaders" + +/** Builds a `ReadableStream` over the given chunks, encoded as UTF-8. */ +function streamOf(...chunks: (string | Uint8Array)[]): ReadableStream { + const encoder = new TextEncoder() + + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(typeof chunk === "string" ? encoder.encode(chunk) : chunk) + } + + controller.close() + }, + }) +} + +/** A stream whose first read rejects, for the error and abort paths. */ +function failingStream(error: Error): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.error(error) + }, + }) +} + +function abortError(): Error { + const error = new Error("aborted") + error.name = "AbortError" + return error +} + +async function collect(generator: AsyncGenerator): Promise { + const out: T[] = [] + + for await (const item of generator) { + out.push(item) + } + + return out +} + +describe("readText", () => { + it("yields decoded fragments in order", async () => { + const chunks = await collect(readText(streamOf("hello ", "world"), new AbortController().signal)) + + expect(chunks.join("")).toBe("hello world") + }) + + it("skips empty chunks", async () => { + const chunks = await collect(readText(streamOf("a", new Uint8Array(), "b"), new AbortController().signal)) + + expect(chunks).toEqual(["a", "b"]) + }) + + it("reassembles a multi-byte character split across chunks", async () => { + // "é" is 0xC3 0xA9; a naive per-chunk decode emits two replacement chars. + const chunks = await collect( + readText(streamOf(new Uint8Array([0xc3]), new Uint8Array([0xa9])), new AbortController().signal), + ) + + expect(chunks.join("")).toBe("é") + }) + + it("returns silently when the stream aborts", async () => { + const chunks = await collect(readText(failingStream(abortError()), new AbortController().signal)) + + expect(chunks).toEqual([]) + }) + + it("rethrows a non-abort error", async () => { + await expect(collect(readText(failingStream(new Error("boom")), new AbortController().signal))).rejects.toThrow( + "boom", + ) + }) +}) + +describe("readNdjson", () => { + it("yields one parsed value per line", async () => { + const body = streamOf('{"a":1}\n{"a":2}\n') + const values = await collect(readNdjson(body, new AbortController().signal)) + + expect(values).toEqual([{ a: 1 }, { a: 2 }]) + }) + + it("joins a line split across chunk boundaries", async () => { + const values = await collect(readNdjson(streamOf('{"a"', ":1}\n"), new AbortController().signal)) + + expect(values).toEqual([{ a: 1 }]) + }) + + it("flushes a trailing line that has no newline", async () => { + const values = await collect(readNdjson(streamOf('{"a":1}'), new AbortController().signal)) + + expect(values).toEqual([{ a: 1 }]) + }) + + it("skips blank lines", async () => { + const values = await collect(readNdjson(streamOf('{"a":1}\n\n\n{"a":2}\n'), new AbortController().signal)) + + expect(values).toEqual([{ a: 1 }, { a: 2 }]) + }) + + it("marks an unparseable line instead of throwing", async () => { + // The caller validates with zod, so a malformed line must not kill the stream. + const values = await collect(readNdjson(streamOf("not json\n"), new AbortController().signal)) + + expect(values).toEqual([{ __parseError: true, raw: "not json" }]) + }) + + it("returns silently when the stream aborts", async () => { + const values = await collect(readNdjson(failingStream(abortError()), new AbortController().signal)) + + expect(values).toEqual([]) + }) + + it("rethrows a non-abort error", async () => { + await expect( + collect(readNdjson(failingStream(new Error("boom")), new AbortController().signal)), + ).rejects.toThrow("boom") + }) +}) + +describe("readSse", () => { + it("yields the data field of each event block", async () => { + const values = await collect(readSse(streamOf("data: one\n\ndata: two\n\n"), new AbortController().signal)) + + expect(values).toEqual([ + { event: undefined, data: "one" }, + { event: undefined, data: "two" }, + ]) + }) + + it("captures the event name alongside the data", async () => { + const values = await collect(readSse(streamOf("event: delta\ndata: hi\n\n"), new AbortController().signal)) + + expect(values).toEqual([{ event: "delta", data: "hi" }]) + }) + + it("concatenates repeated data fields with a newline", async () => { + const values = await collect(readSse(streamOf("data: a\ndata: b\n\n"), new AbortController().signal)) + + expect(values).toEqual([{ event: undefined, data: "a\nb" }]) + }) + + it("ignores comment lines and blocks with no data field", async () => { + const values = await collect( + readSse(streamOf(": keep-alive\n\nevent: ping\n\ndata: real\n\n"), new AbortController().signal), + ) + + expect(values).toEqual([{ event: undefined, data: "real" }]) + }) + + it("strips only a single leading space from the value", async () => { + const values = await collect(readSse(streamOf("data: padded\n\n"), new AbortController().signal)) + + expect(values).toEqual([{ event: undefined, data: " padded" }]) + }) + + it("skips a field line with no colon", async () => { + const values = await collect(readSse(streamOf("garbage\ndata: ok\n\n"), new AbortController().signal)) + + expect(values).toEqual([{ event: undefined, data: "ok" }]) + }) + + it("joins a block split across chunk boundaries", async () => { + const values = await collect(readSse(streamOf("data: sp", "lit\n\n"), new AbortController().signal)) + + expect(values).toEqual([{ event: undefined, data: "split" }]) + }) + + it("returns silently when the stream aborts", async () => { + const values = await collect(readSse(failingStream(abortError()), new AbortController().signal)) + + expect(values).toEqual([]) + }) + + it("rethrows a non-abort error", async () => { + await expect(collect(readSse(failingStream(new Error("boom")), new AbortController().signal))).rejects.toThrow( + "boom", + ) + }) +}) diff --git a/src/services/autocomplete/__tests__/templates.spec.ts b/src/services/autocomplete/__tests__/templates.spec.ts new file mode 100644 index 0000000000..aa54ec385e --- /dev/null +++ b/src/services/autocomplete/__tests__/templates.spec.ts @@ -0,0 +1,253 @@ +import { FIM_TEMPLATES, INSTRUCT_SYSTEM_PROMPT, renderSnippetPreamble } from "../prompt/templates" +import { FimTemplateRegistry, isBaseModel, templateSupportsFim } from "../prompt/FimTemplateRegistry" +import type { AutocompleteSnippet } from "../types" + +const P = "function add(" +const S = ") { return a + b }" + +describe("FimTemplateRegistry", () => { + const registry = new FimTemplateRegistry() + + it("resolves by model id match (qwen)", () => { + expect(registry.resolve("qwen2.5-coder:1.5b-base", undefined).id).toBe("qwen") + }) + + it("resolves by model id match (starcoder)", () => { + expect(registry.resolve("starcoder2-3b", undefined).id).toBe("starcoder") + }) + + it("resolves by model id match (codestral)", () => { + expect(registry.resolve("codestral-latest", undefined).id).toBe("codestral") + }) + + it("resolves by model id match (codellama)", () => { + expect(registry.resolve("CodeLlama-7b", undefined).id).toBe("codellama") + }) + + it("resolves by model id match (deepseek)", () => { + expect(registry.resolve("deepseek-coder-1.3b", undefined).id).toBe("deepseek") + }) + + it("resolves by model id match (codegemma)", () => { + expect(registry.resolve("codegemma-1.1-7b", undefined).id).toBe("codegemma") + }) + + it("falls back to 'instruct' for unknown models", () => { + // `none` sends a bare prefix with no instruction, which a chat model reads + // as nothing to do — the "it ignores my code" symptom. Nearly every model a + // user can point this at is chat-tuned, so `instruct` is the safer default. + expect(registry.resolve("gpt-4", undefined).id).toBe("instruct") + expect(registry.resolve("some-unreleased-model-9000", undefined).id).toBe("instruct") + }) + + it("falls back to 'instruct' for undefined model id", () => { + expect(registry.resolve(undefined, undefined).id).toBe("instruct") + }) + + it("honours an explicit override", () => { + expect(registry.resolve("qwen2.5-coder", "deepseek").id).toBe("deepseek") + }) + + it("ignores 'auto' override and resolves by model id", () => { + expect(registry.resolve("qwen2.5-coder", "auto").id).toBe("qwen") + }) + + it("falls back to model-id match when override is unknown", () => { + // An unknown override id is treated as "auto" — resolve by model id. + expect(registry.resolve("starcoder2", "auto").id).toBe("starcoder") + }) +}) + +describe("FIM template golden strings", () => { + const templates = Object.fromEntries(FIM_TEMPLATES.map((t) => [t.id, t])) + + it("qwen renders prefix/suffix with control tokens", () => { + expect(templates.qwen.render(P, S, [])).toBe(`<|fim_prefix|>${P}<|fim_suffix|>${S}<|fim_middle|>`) + }) + + it("starcoder renders with angle-bracket tokens", () => { + expect(templates.starcoder.render(P, S, [])).toBe(`${P}${S}`) + }) + + it("codestral renders suffix before prefix and opens the hole with [MIDDLE]", () => { + expect(templates.codestral.render(P, S, [])).toBe(`[SUFFIX]${S}[PREFIX]${P}[MIDDLE]`) + }) + + it("codellama renders with spaced tokens", () => { + expect(templates.codellama.render(P, S, [])).toBe(`
 ${P} ${S} `)
+	})
+
+	it("deepseek renders with full-width tokens", () => {
+		expect(templates.deepseek.render(P, S, [])).toBe(`<|fim▁begin|>${P}<|fim▁hole|>${S}<|fim▁end|>`)
+	})
+
+	it("codegemma mirrors qwen markers", () => {
+		expect(templates.codegemma.render(P, S, [])).toBe(`<|fim_prefix|>${P}<|fim_suffix|>${S}<|fim_middle|>`)
+	})
+
+	it("none renders prefix only", () => {
+		expect(templates.none.render(P, S, [])).toBe(P)
+	})
+})
+
+describe("renderSnippetPreamble", () => {
+	it("returns empty string for no snippets", () => {
+		expect(renderSnippetPreamble([])).toBe("")
+	})
+
+	it("labels each snippet with its file and joins with a trailing pair", () => {
+		const snippets: AutocompleteSnippet[] = [
+			{ filePath: "a.ts", languageId: "typescript", line: 3, content: "const x = 1" },
+			{ filePath: "b.ts", languageId: "typescript", line: 7, content: "const y = 2" },
+		]
+
+		expect(renderSnippetPreamble(snippets)).toBe("// a.ts\nconst x = 1\n\n// b.ts\nconst y = 2\n\n")
+	})
+
+	it("prepends the preamble to the prefix in a native-FIM render", () => {
+		const snippets: AutocompleteSnippet[] = [
+			{ filePath: "a.ts", languageId: "typescript", line: 3, content: "const x = 1" },
+		]
+
+		const preamble = renderSnippetPreamble(snippets)
+
+		expect(preamble + P).toBe("// a.ts\nconst x = 1\n\n" + P)
+	})
+
+	it("separates foreign code from the cursor line so it is never read as contiguous", () => {
+		const snippets: AutocompleteSnippet[] = [
+			{ filePath: "a.ts", languageId: "typescript", line: 3, content: "const x = 1" },
+		]
+
+		// The boundary is what stops the model completing the *snippet* instead of
+		// the cursor line, so assert it explicitly rather than incidentally.
+		expect(renderSnippetPreamble(snippets).endsWith("\n\n")).toBe(true)
+	})
+})
+
+describe("qwen repo-level context", () => {
+	const qwen = FIM_TEMPLATES.find((t) => t.id === "qwen")!
+
+	const snippets: AutocompleteSnippet[] = [
+		{ filePath: "util.ts", languageId: "typescript", line: 1, content: "export const add = (a, b) => a + b" },
+	]
+
+	it("wraps cross-file snippets in <|file_sep|> sections", () => {
+		// Qwen is trained on this format for the repo-level case; bare snippet text
+		// is indistinguishable from the file under edit, so the model completes it.
+		expect(qwen.render(P, S, snippets)).toBe(
+			"<|file_sep|>util.ts\nexport const add = (a, b) => a + b\n" +
+				`<|file_sep|><|fim_prefix|>${P}<|fim_suffix|>${S}<|fim_middle|>`,
+		)
+	})
+
+	it("emits the plain FIM triplet when there are no snippets", () => {
+		expect(qwen.render(P, S, [])).toBe(`<|fim_prefix|>${P}<|fim_suffix|>${S}<|fim_middle|>`)
+	})
+
+	it("stops on the file separator so it cannot run into a fabricated next file", () => {
+		expect(qwen.stop).toContain("<|file_sep|>")
+	})
+})
+
+describe("template stop sequences", () => {
+	it("qwen includes fim_pad", () => {
+		expect(FIM_TEMPLATES.find((t) => t.id === "qwen")!.stop).toContain("<|fim_pad|>")
+	})
+
+	it("codestral does not stop on the [MIDDLE] token it emits", () => {
+		// Listing the opening marker as a stop truncated the completion at its own
+		// prompt boundary — the model had nothing left to generate into.
+		expect(FIM_TEMPLATES.find((t) => t.id === "codestral")!.stop).not.toContain("[MIDDLE]")
+	})
+
+	it("deepseek includes fim end token", () => {
+		expect(FIM_TEMPLATES.find((t) => t.id === "deepseek")!.stop).toContain("<|fim▁end|>")
+	})
+
+	it("none has no stop sequences", () => {
+		expect(FIM_TEMPLATES.find((t) => t.id === "none")!.stop).toEqual([])
+	})
+})
+describe("instruct template routing", () => {
+	const registry = new FimTemplateRegistry()
+
+	it.each([
+		["lfm2.5-2.6b", "instruct"],
+		["gemma-2-9b-it", "instruct"],
+		["phi-4", "instruct"],
+		["granite-3b-code", "instruct"],
+	])("routes %s to the %s template", (modelId, expected) => {
+		expect(registry.resolve(modelId, undefined).id).toBe(expected)
+	})
+
+	// Publishers ship FIM-trained weights under `-instruct` tags. Treating the tag
+	// as decisive sent these down the chat path, which discards the suffix outright
+	// — the model loses the after-cursor context that FIM exists to use.
+	it.each([
+		["qwen2.5-coder-1.5b-instruct", "qwen"],
+		["qwen2.5-coder:7b-instruct-q4_K_M", "qwen"],
+		["codestral:22b-v0.1-instruct-q4_K_M", "codestral"],
+		["codestral-latest", "codestral"],
+	])("keeps %s on its FIM family template (%s)", (modelId, expected) => {
+		expect(registry.resolve(modelId, undefined).id).toBe(expected)
+	})
+
+	it("lets an explicit override force the chat path for a misrouted family name", () => {
+		// The escape hatch for a model that carries a family name without the
+		// corresponding FIM training.
+		expect(registry.resolve("qwen2.5-coder-1.5b-instruct", "instruct").id).toBe("instruct")
+	})
+
+	it("no longer captures mistral-nemo with the codestral family pattern", () => {
+		// `mistral-nemo` is instruction-tuned and has no FIM tokens; the old bare
+		// `mistral` pattern would now claim it, since family matching wins.
+		expect(registry.resolve("mistral-nemo-instruct-2407", undefined).id).toBe("instruct")
+	})
+
+	it("keeps base models on their FIM family template even when the family also matches instruct", () => {
+		// `codegemma` would match the instruct `gemma-2` pattern; the -base guard
+		// must keep it on its FIM template, or a FIM-capable model gets a prose prompt.
+		expect(registry.resolve("codegemma:2b-base", undefined).id).toBe("codegemma")
+		expect(registry.resolve("qwen2.5-coder:1.5b-base", undefined).id).toBe("qwen")
+	})
+
+	it("identifies base models", () => {
+		expect(isBaseModel("qwen2.5-coder:1.5b-base")).toBe(true)
+		expect(isBaseModel("starcoder2-3b-base")).toBe(true)
+		expect(isBaseModel("lfm2.5-2.6b")).toBe(false)
+	})
+
+	it("reports which templates speak FIM", () => {
+		const byId = (id: string) => FIM_TEMPLATES.find((t) => t.id === id)!
+
+		expect(templateSupportsFim(byId("qwen"))).toBe(true)
+		expect(templateSupportsFim(byId("codestral"))).toBe(true)
+		expect(templateSupportsFim(byId("instruct"))).toBe(false)
+		expect(templateSupportsFim(byId("none"))).toBe(false)
+	})
+
+	it("renders only the marked code, with no instruction text", () => {
+		// The instruction lives in INSTRUCT_SYSTEM_PROMPT and is delivered as a
+		// chat `system` message. Embedding it in the prompt string made the model
+		// continue the *rules* — it echoed "Output ONLY the raw code..." as output.
+		const rendered = FIM_TEMPLATES.find((t) => t.id === "instruct")!.render(P, S, [])
+
+		expect(rendered).toBe(`${P}${S}`)
+		expect(rendered).not.toMatch(/output only/i)
+		expect(rendered).not.toMatch(/do not/i)
+	})
+
+	it("keeps the instruction in a system prompt that forbids prose, fences and reasoning", () => {
+		expect(INSTRUCT_SYSTEM_PROMPT).toMatch(/only/i)
+		expect(INSTRUCT_SYSTEM_PROMPT).toMatch(/markdown fences/i)
+		expect(INSTRUCT_SYSTEM_PROMPT).toMatch(/reasoning/i)
+	})
+
+	it("terminates the instruct turn", () => {
+		const stop = FIM_TEMPLATES.find((t) => t.id === "instruct")!.stop
+
+		expect(stop).toContain("<|im_end|>")
+		expect(stop).toContain("```")
+	})
+})
diff --git a/src/services/autocomplete/__tests__/tokenBudget.spec.ts b/src/services/autocomplete/__tests__/tokenBudget.spec.ts
new file mode 100644
index 0000000000..055085cc9e
--- /dev/null
+++ b/src/services/autocomplete/__tests__/tokenBudget.spec.ts
@@ -0,0 +1,99 @@
+import { estimateTokens, pruneSnippets, trimToTokenBudget } from "../prompt/tokenBudget"
+import type { AutocompleteSnippet } from "../types"
+
+describe("estimateTokens", () => {
+	it("returns 0 for empty text", () => {
+		expect(estimateTokens("")).toBe(0)
+	})
+
+	it("scales with text length", () => {
+		expect(estimateTokens("a".repeat(35))).toBeLessThanOrEqual(estimateTokens("a".repeat(70)))
+	})
+})
+
+describe("trimToTokenBudget", () => {
+	it("returns the full text when under budget", () => {
+		expect(trimToTokenBudget("hello", 100, "tail")).toBe("hello")
+	})
+
+	it("trims from the head for suffixes", () => {
+		const text = "0123456789"
+		expect(trimToTokenBudget(text, 1, "head")).toBe("0123")
+	})
+
+	it("trims from the tail for prefixes", () => {
+		const text = "0123456789"
+		expect(trimToTokenBudget(text, 1, "tail")).toBe("6789")
+	})
+
+	it("realigns a trimmed prefix to a line boundary", () => {
+		// A raw slice opens the prompt mid-token, which reads to the model as a
+		// broken identifier rather than the start of a statement.
+		const text = "const alpha = 1\nconst beta = 2\nconst gamma = 3"
+		const trimmed = trimToTokenBudget(text, 6, "tail")
+
+		expect(trimmed.startsWith("const")).toBe(true)
+		expect(text.endsWith(trimmed)).toBe(true)
+	})
+
+	it("realigns a trimmed suffix to a line boundary", () => {
+		const text = "const alpha = 1\nconst beta = 2\nconst gamma = 3"
+		const trimmed = trimToTokenBudget(text, 6, "head")
+
+		expect(trimmed.endsWith("\n")).toBe(true)
+		expect(text.startsWith(trimmed)).toBe(true)
+	})
+
+	it("never splits a surrogate pair", () => {
+		// An orphaned half-pair is an invalid code unit that corrupts the prompt.
+		const text = "a".repeat(20) + "😀".repeat(10)
+		const head = trimToTokenBudget(text, 5, "head")
+		const tail = trimToTokenBudget(text, 5, "tail")
+
+		expect(head).toBe(Array.from(head).join(""))
+		expect(tail).toBe(Array.from(tail).join(""))
+	})
+})
+
+describe("pruneSnippets", () => {
+	const snippet = (content: string, filePath = "a.ts"): AutocompleteSnippet => ({
+		filePath,
+		languageId: "typescript",
+		line: 1,
+		content,
+	})
+
+	it("keeps all snippets when under budget", () => {
+		const snippets = [snippet("const a = 1"), snippet("const b = 2")]
+		const result = pruneSnippets(snippets, 1000)
+		expect(result.snippets).toHaveLength(2)
+		expect(result.dropped).toBe(0)
+	})
+
+	it("drops snippets from the tail when budget is exceeded", () => {
+		const snippets = [snippet("const a = 1"), snippet("const b = 2"), snippet("const c = 3")]
+		const result = pruneSnippets(snippets, 10)
+		expect(result.dropped).toBeGreaterThan(0)
+	})
+
+	it("trims the last-kept snippet's content when partially fitting", () => {
+		const big = "x".repeat(200)
+		const snippets = [snippet(big)]
+		const result = pruneSnippets(snippets, 20)
+		expect(result.snippets).toHaveLength(1)
+		expect(result.snippets[0].content.length).toBeLessThan(big.length)
+	})
+
+	it("returns empty when budget is too small for any snippet", () => {
+		const snippets = [snippet("const a = 1")]
+		const result = pruneSnippets(snippets, 1)
+		expect(result.snippets).toHaveLength(0)
+		expect(result.dropped).toBe(1)
+	})
+
+	it("preserves arrival order among kept snippets", () => {
+		const snippets = [snippet("const a = 1", "a.ts"), snippet("const b = 2", "b.ts")]
+		const result = pruneSnippets(snippets, 1000)
+		expect(result.snippets.map((s) => s.filePath)).toEqual(["a.ts", "b.ts"])
+	})
+})
diff --git a/src/services/autocomplete/__tests__/transforms.spec.ts b/src/services/autocomplete/__tests__/transforms.spec.ts
new file mode 100644
index 0000000000..1bf121b489
--- /dev/null
+++ b/src/services/autocomplete/__tests__/transforms.spec.ts
@@ -0,0 +1,259 @@
+import {
+	stopAtStopTokens,
+	filterHallucinatedPathLine,
+	stopAtSuffixRepetition,
+	stopAtSimilarLine,
+	stopAtReasoningBlock,
+	stopAtProseLine,
+	stopAtLines,
+	stopAtRepetitionLoop,
+	DEFAULT_TRANSFORMS,
+	type TransformContext,
+} from "../stream/transforms"
+import { StreamPostProcessor } from "../stream/StreamPostProcessor"
+
+const ctx = (overrides: Partial = {}): TransformContext => ({
+	prefix: "",
+	suffix: "",
+	stopSequences: [],
+	maxLines: 256,
+	...overrides,
+})
+
+async function drain(gen: AsyncGenerator): Promise {
+	let result = ""
+	for await (const chunk of gen) {
+		result += chunk
+	}
+	return result
+}
+
+async function* fromChunks(chunks: string[]): AsyncGenerator {
+	for (const chunk of chunks) {
+		yield chunk
+	}
+}
+
+describe("stopAtStopTokens", () => {
+	it("truncates at a stop token in the chunk", () => {
+		expect(stopAtStopTokens.onChunk("", "helloworld", ctx({ stopSequences: [""] }))).toBe("hello")
+	})
+
+	it("returns the chunk unchanged when no stop token is present", () => {
+		expect(stopAtStopTokens.onChunk("", "hello world", ctx({ stopSequences: [""] }))).toBe("hello world")
+	})
+
+	it("handles a stop token straddling the accumulated/chunk boundary", () => {
+		// Accumulated has "helloworld". The stop token ""
+		// straddles: it starts in accumulated and ends in chunk. The transform
+		// truncates at the stop token boundary — accumulated is kept, chunk is consumed.
+		const result = stopAtStopTokens.onChunk("helloworld", ctx({ stopSequences: [""] }))
+		// The stop token consumed the chunk; nothing new to emit (the partial in accumulated is already emitted).
+		// The transform returns null or empty to signal stop.
+		expect(result === null || result === "").toBe(true)
+	})
+
+	it("passes through when no stop sequences are configured", () => {
+		expect(stopAtStopTokens.onChunk("", "hello", ctx({ stopSequences: [] }))).toBe("hello")
+	})
+})
+
+describe("filterHallucinatedPathLine", () => {
+	it("drops a 'Path:' hallucination line and stops", () => {
+		expect(filterHallucinatedPathLine.onChunk("", "Path: src/foo.ts\nbar", ctx())).toBe("")
+	})
+
+	it("drops a 'diff --git' hallucination", () => {
+		expect(filterHallucinatedPathLine.onChunk("", "diff --git a/foo b/foo\nbar", ctx())).toBe("")
+	})
+
+	it("passes through normal code", () => {
+		expect(filterHallucinatedPathLine.onChunk("", "const x = 1", ctx())).toBe("const x = 1")
+	})
+
+	it("keeps output before the hallucination", () => {
+		// Accumulated already has the good text; the chunk contains the hallucination.
+		// The transform returns "" (nothing new to emit) and signals stop.
+		const result = filterHallucinatedPathLine.onChunk("const x = 1\n", "Path: foo.ts\nbar", ctx())
+		expect(result === null || result === "").toBe(true)
+	})
+})
+
+describe("stopAtSuffixRepetition", () => {
+	it("stops when the output begins repeating the suffix", () => {
+		const suffix = ") { return a + b }"
+		expect(stopAtSuffixRepetition.onChunk(") { return a", "", ctx({ suffix }))).toBe("")
+	})
+
+	it("does not stop for a short overlap below the threshold", () => {
+		expect(stopAtSuffixRepetition.onChunk("hello", " world", ctx({ suffix: "world is a long suffix" }))).toBe(
+			" world",
+		)
+	})
+
+	it("passes through when there is no suffix", () => {
+		expect(stopAtSuffixRepetition.onChunk("", "hello", ctx({ suffix: "" }))).toBe("hello")
+	})
+})
+
+describe("stopAtSimilarLine", () => {
+	it("stops when an output line matches a suffix line", () => {
+		const prefix = "function add(a, b) {\n  return a + b\n}"
+		const suffix = ""
+		// Accumulated has "  return a + b" which matches a prefix line
+		expect(stopAtSimilarLine.onChunk("  return a + b\n", "extra", ctx({ prefix, suffix }))).toBe("")
+	})
+
+	it("does not stop on the first line (not enough lines)", () => {
+		expect(stopAtSimilarLine.onChunk("", "single line", ctx({ prefix: "different", suffix: "" }))).toBe(
+			"single line",
+		)
+	})
+
+	it("passes through lines not matching surrounding text", () => {
+		const prefix = "function foo() {}"
+		expect(stopAtSimilarLine.onChunk("const x = 1\n", "const y = 2\n", ctx({ prefix, suffix: "" }))).toBe(
+			"const y = 2\n",
+		)
+	})
+})
+
+describe("StreamPostProcessor composed pipeline", () => {
+	const processor = new StreamPostProcessor(DEFAULT_TRANSFORMS)
+
+	it("stops at a stop token through the full pipeline", async () => {
+		const result = await drain(
+			processor.process(fromChunks(["hello", "world"]), ctx({ stopSequences: [""] })),
+		)
+		expect(result).toBe("hello")
+	})
+
+	it("drops a hallucinated path line", async () => {
+		const result = await drain(processor.process(fromChunks(["const x = 1\n", "Path: src/foo.ts\nbar"]), ctx()))
+		expect(result).toBe("const x = 1\n")
+	})
+
+	it("stops at suffix repetition through the pipeline", async () => {
+		const suffix = "  return a + b\n}"
+		const result = await drain(processor.process(fromChunks(["  return a", " + b\n}"]), ctx({ suffix })))
+		// The output starts repeating the suffix; the overlap is trimmed
+		expect(result).not.toContain("}")
+	})
+
+	it("passes through normal code", async () => {
+		const result = await drain(processor.process(fromChunks(["const x = 1\n", "const y = 2\n"]), ctx()))
+		expect(result).toBe("const x = 1\nconst y = 2\n")
+	})
+})
+describe("stopAtReasoningBlock", () => {
+	const ctx = { prefix: "", suffix: "", stopSequences: [] as string[], maxLines: 100 }
+
+	it("cuts at a  opener arriving mid-chunk", () => {
+		// The exact failure seen with lfm2.5-2.6b: the tag is not on a token
+		// boundary, so stop sequences never fire.
+		expect(stopAtReasoningBlock.onChunk("", "return a + b\nNow I should", ctx)).toBe("return a + b\n")
+	})
+
+	it("cuts at a markdown fence", () => {
+		expect(stopAtReasoningBlock.onChunk("", "x = 1\n```\n", ctx)).toBe("x = 1\n")
+	})
+
+	it("cuts at an explanatory opener", () => {
+		expect(stopAtReasoningBlock.onChunk("", "foo()\nThis code calculates the primes", ctx)).toBe("foo()\n")
+	})
+
+	it("emits nothing once the opener is already in the accumulated text", () => {
+		expect(stopAtReasoningBlock.onChunk("done", " more", ctx)).toBe("")
+	})
+
+	it("passes clean code through untouched", () => {
+		expect(stopAtReasoningBlock.onChunk("", "const x = compute(a, b)", ctx)).toBe("const x = compute(a, b)")
+	})
+})
+
+describe("stopAtProseLine", () => {
+	const ctx = { prefix: "", suffix: "", stopSequences: [] as string[], maxLines: 100 }
+
+	it("stops at a full sentence on its own line", () => {
+		expect(stopAtProseLine.onChunk("", "x = 1\nThe function returns a value.\ny = 2\n", ctx)).toBe("x = 1")
+	})
+
+	it("does not treat code as prose", () => {
+		const code = "for (const n of nums) {\n    if (isPrime(n)) result.push(n)\n}\n"
+
+		expect(stopAtProseLine.onChunk("", code, ctx)).toBe(code)
+	})
+
+	it("does not treat comments as prose", () => {
+		const code = "// Calculate whether it is prime.\nconst p = check(n)\n"
+
+		expect(stopAtProseLine.onChunk("", code, ctx)).toBe(code)
+	})
+
+	it("never inspects the first line, which continues the cursor line", () => {
+		expect(stopAtProseLine.onChunk("", "Some words that look like prose.\n", ctx)).toBe(
+			"Some words that look like prose.\n",
+		)
+	})
+})
+
+describe("stopAtLines", () => {
+	const ctx = { prefix: "", suffix: "", stopSequences: [] as string[], maxLines: 2 }
+
+	it("caps the completion at maxLines", () => {
+		expect(stopAtLines.onChunk("", "a\nb\nc\nd", ctx)).toBe("a\nb")
+	})
+
+	it("is disabled when maxLines is zero", () => {
+		expect(stopAtLines.onChunk("", "a\nb\nc", { ...ctx, maxLines: 0 })).toBe("a\nb\nc")
+	})
+})
+
+describe("stopAtRepetitionLoop", () => {
+	const ctx = { prefix: "", suffix: "", stopSequences: [] as string[], maxLines: 100 }
+
+	it("cuts a degenerate repeating run", () => {
+		// Observed with a small model under greedy sampling: `1616161616...`.
+		// No stop token appears and it is all one line, so nothing else ends it.
+		const input = "x = 16161616161616161616161616"
+		const out = stopAtRepetitionLoop.onChunk("", input, ctx)
+
+		expect(out ?? "").not.toBe(input)
+		expect((out ?? "").length).toBeLessThan(input.length)
+	})
+
+	it("leaves ordinary code alone", () => {
+		const code = "for (const item of items) { total += item.value }"
+
+		expect(stopAtRepetitionLoop.onChunk("", code, ctx)).toBe(code)
+	})
+
+	it("does not fire on short output", () => {
+		expect(stopAtRepetitionLoop.onChunk("", "abab", ctx)).toBe("abab")
+	})
+})
+
+describe("stopAtRepetitionLoop — phrase-level loops", () => {
+	const c = { prefix: "", suffix: "", stopSequences: [] as string[], maxLines: 100 }
+
+	it("cuts a repeated multi-character phrase", () => {
+		// Reported: `"A" * primer_length` emitted three times in a row. The old
+		// 6-character unit limit could not see a 19-character phrase.
+		const looped = '"A" * primer_length'.repeat(3)
+		const out = stopAtRepetitionLoop.onChunk("", looped, c)
+
+		expect((out ?? "").length).toBeLessThan(looped.length)
+	})
+
+	it("still cuts a short digit run", () => {
+		const looped = "12537".repeat(8)
+
+		expect((stopAtRepetitionLoop.onChunk("", looped, c) ?? "").length).toBeLessThan(looped.length)
+	})
+
+	it("leaves ordinary repeated-but-distinct code alone", () => {
+		const code = "self.a = a\n        self.b = b\n        self.c = c"
+
+		expect(stopAtRepetitionLoop.onChunk("", code, c)).toBe(code)
+	})
+})
diff --git a/src/services/autocomplete/__tests__/windowing.spec.ts b/src/services/autocomplete/__tests__/windowing.spec.ts
new file mode 100644
index 0000000000..04268de079
--- /dev/null
+++ b/src/services/autocomplete/__tests__/windowing.spec.ts
@@ -0,0 +1,150 @@
+import * as vscode from "vscode"
+
+vi.mock("vscode", async () => {
+	const actual = await vi.importActual("vscode")
+	return {
+		...actual,
+		InlineCompletionTriggerKind: { Invoke: 0, Automatic: 1 },
+	}
+})
+
+import {
+	windowPrefix,
+	windowSuffix,
+	windowDocument,
+	normalizeLineEndings,
+	PREFIX_MAX_LINES,
+} from "../context/windowing"
+
+function makeDocument(lines: string[], eol = "\n"): vscode.TextDocument {
+	const text = lines.join(eol)
+	const lineCount = lines.length
+
+	return {
+		getText: (range?: vscode.Range) => {
+			if (!range) return text
+			// Simplified: slice by line/character offsets
+			const allLines = lines
+			const startLine = range.start.line
+			const endLine = range.end.line
+			const result: string[] = []
+			for (let i = startLine; i <= endLine; i++) {
+				if (i >= allLines.length) break
+				let line = allLines[i]
+				if (i === startLine && startLine < endLine) {
+					line = line.slice(range.start.character)
+				} else if (i === endLine && endLine > startLine) {
+					line = line.slice(0, range.end.character)
+				} else if (i === startLine && startLine === endLine) {
+					line = line.slice(range.start.character, range.end.character)
+				}
+				result.push(line)
+			}
+			return result.join(eol)
+		},
+		lineAt: (line: number | vscode.Position) => {
+			const lineNum = typeof line === "number" ? line : line.line
+			return {
+				text: lines[Math.min(lineNum, lineCount - 1)] ?? "",
+				range: new vscode.Range(lineNum, 0, lineNum, (lines[lineNum] ?? "").length),
+				lineNumber: lineNum,
+				rangeIncludingLineBreak: new vscode.Range(
+					lineNum,
+					0,
+					lineNum,
+					(lines[lineNum] ?? "").length + eol.length,
+				),
+				firstNonWhitespaceCharacterIndex: 0,
+				isEmptyOrWhitespace: false,
+			}
+		},
+		lineCount,
+		uri: { fsPath: "/test.ts", toString: () => "file:///test.ts" },
+	} as unknown as vscode.TextDocument
+}
+
+describe("normalizeLineEndings", () => {
+	it("converts CRLF to LF", () => {
+		expect(normalizeLineEndings("a\r\nb\r\n")).toBe("a\nb\n")
+	})
+
+	it("converts lone CR to LF", () => {
+		expect(normalizeLineEndings("a\rb\r")).toBe("a\nb\n")
+	})
+
+	it("leaves LF unchanged", () => {
+		expect(normalizeLineEndings("a\nb\n")).toBe("a\nb\n")
+	})
+})
+
+describe("windowPrefix", () => {
+	it("returns text before the cursor on the same line", () => {
+		const doc = makeDocument(["function add(", "  return a + b", ")"])
+		const pos = new vscode.Position(1, 10)
+		expect(windowPrefix(doc, pos, 1000)).toContain("return a")
+	})
+
+	it("normalises CRLF to LF", () => {
+		const doc = makeDocument(["line1", "line2", "line3"], "\r\n")
+		const pos = new vscode.Position(2, 0)
+		const result = windowPrefix(doc, pos, 1000)
+		expect(result).not.toContain("\r")
+		expect(result).toContain("line1\nline2\n")
+	})
+
+	it("handles BOF (cursor at start of file)", () => {
+		const doc = makeDocument(["hello world"])
+		const pos = new vscode.Position(0, 0)
+		expect(windowPrefix(doc, pos, 1000)).toBe("")
+	})
+
+	it("caps at maxChars, keeping the tail near the cursor", () => {
+		const doc = makeDocument(["abcdefghijklmnopqrstuvwxyz0123456789"])
+		const pos = new vscode.Position(0, 36)
+		const result = windowPrefix(doc, pos, 10)
+		expect(result.length).toBeLessThanOrEqual(10)
+		expect(result).toBe("0123456789")
+	})
+
+	it("caps at maxLines", () => {
+		const lines = Array.from({ length: 500 }, (_, i) => `line ${i}`)
+		const doc = makeDocument(lines)
+		const pos = new vscode.Position(499, 0)
+		const result = windowPrefix(doc, pos, 100000)
+		const resultLines = result.split("\n")
+		expect(resultLines.length).toBeLessThanOrEqual(PREFIX_MAX_LINES)
+	})
+})
+
+describe("windowSuffix", () => {
+	it("returns text after the cursor on the same line", () => {
+		const doc = makeDocument(["function add(", "  return a + b", ")"])
+		const pos = new vscode.Position(1, 8)
+		const result = windowSuffix(doc, pos, 1000)
+		expect(result).toContain(" + b")
+	})
+
+	it("handles EOF (cursor at end of file)", () => {
+		const doc = makeDocument(["hello"])
+		const pos = new vscode.Position(0, 5)
+		expect(windowSuffix(doc, pos, 1000)).toBe("")
+	})
+
+	it("caps at maxChars, keeping the head near the cursor", () => {
+		const doc = makeDocument(["abcdefghijklmnopqrstuvwxyz0123456789"])
+		const pos = new vscode.Position(0, 0)
+		const result = windowSuffix(doc, pos, 10)
+		expect(result.length).toBeLessThanOrEqual(10)
+		expect(result).toBe("abcdefghij")
+	})
+})
+
+describe("windowDocument", () => {
+	it("returns both prefix and suffix", () => {
+		const doc = makeDocument(["function add(", "  return a + b", ")"])
+		const pos = new vscode.Position(1, 10)
+		const result = windowDocument(doc, pos, 1000, 1000)
+		expect(result.prefix).toContain("return a")
+		expect(result.suffix).toContain(" + b")
+	})
+})
diff --git a/src/services/autocomplete/cache/CompletionCache.ts b/src/services/autocomplete/cache/CompletionCache.ts
new file mode 100644
index 0000000000..ed939ee528
--- /dev/null
+++ b/src/services/autocomplete/cache/CompletionCache.ts
@@ -0,0 +1,131 @@
+/**
+ * Hand-rolled LRU cache for inline completions, with typed-prefix continuation.
+ *
+ * - LRU via `Map` insertion order (most-recently-used last); eviction drops the
+ *   oldest entry when capacity is exceeded.
+ * - Continuation: when the user types more characters, the new prefix extends
+ *   the cached one; if the cached completion starts with the newly-typed chars,
+ *   we reuse it by trimming the typed prefix, avoiding a model round-trip.
+ *
+ * ~40 lines of code, no external dependency.
+ */
+
+export interface CompletionCacheEntry {
+	readonly prefix: string
+	readonly suffix: string
+	readonly text: string
+	readonly modelId: string
+	readonly timestamp: number
+}
+
+export interface CompletionCacheOptions {
+	readonly maxEntries?: number
+}
+
+const DEFAULT_MAX_ENTRIES = 500
+
+export class CompletionCache {
+	private readonly maxEntries: number
+	private readonly entries = new Map()
+
+	constructor(options: CompletionCacheOptions = {}) {
+		this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
+	}
+
+	/** Looks up a cached completion by its exact key; touches the LRU on hit. */
+	get(key: string): CompletionCacheEntry | undefined {
+		const entry = this.entries.get(key)
+
+		if (entry) {
+			// Refresh recency: re-insert moves the key to the end of the Map.
+			this.entries.delete(key)
+			this.entries.set(key, entry)
+		}
+
+		return entry
+	}
+
+	/**
+	 * Continuation lookup: finds a cached completion whose prefix the new prefix
+	 * extends, and whose text continues with the newly-typed characters. Returns
+	 * the trimmed completion (without the already-typed chars) on a hit.
+	 */
+	getContinuation(prefix: string, suffix: string, modelId: string): string | undefined {
+		for (const entry of [...this.entries.values()].reverse()) {
+			if (entry.modelId !== modelId) {
+				continue
+			}
+
+			if (!prefix.startsWith(entry.prefix)) {
+				continue
+			}
+
+			const typed = prefix.slice(entry.prefix.length)
+
+			if (typed.length === 0) {
+				continue
+			}
+
+			// The suffix must still align: the cached suffix, minus the typed chars
+			// consumed from its head, should match the current suffix.
+			if (!entry.suffix.startsWith(suffix)) {
+				continue
+			}
+
+			if (!entry.text.startsWith(typed)) {
+				continue
+			}
+
+			const remaining = entry.text.slice(typed.length)
+
+			if (remaining.length > 0) {
+				return remaining
+			}
+		}
+
+		return undefined
+	}
+
+	/** Stores a completion; evicts the oldest entry if capacity is exceeded. */
+	set(key: string, entry: Omit): void {
+		if (this.entries.has(key)) {
+			this.entries.delete(key)
+		}
+
+		this.entries.set(key, { ...entry, timestamp: Date.now() })
+
+		while (this.entries.size > this.maxEntries) {
+			const oldestKey = this.entries.keys().next().value
+
+			if (oldestKey === undefined) {
+				break
+			}
+
+			this.entries.delete(oldestKey)
+		}
+	}
+
+	clear(): void {
+		this.entries.clear()
+	}
+
+	get size(): number {
+		return this.entries.size
+	}
+}
+
+/** Builds a cache key from the values that define a unique completion. */
+export function makeCacheKey(prefix: string, suffix: string, modelId: string): string {
+	return `${modelId}::${hash(prefix)}::${hash(suffix)}`
+}
+
+/** Cheap, deterministic string hash (djb2 variant). */
+function hash(text: string): string {
+	let hashValue = 5381
+
+	for (let i = 0; i < text.length; i++) {
+		hashValue = ((hashValue << 5) + hashValue + text.charCodeAt(i)) >>> 0
+	}
+
+	return hashValue.toString(36)
+}
diff --git a/src/services/autocomplete/config/AutocompleteConfigService.ts b/src/services/autocomplete/config/AutocompleteConfigService.ts
new file mode 100644
index 0000000000..851c111184
--- /dev/null
+++ b/src/services/autocomplete/config/AutocompleteConfigService.ts
@@ -0,0 +1,58 @@
+import type { ResolvedAutocompleteConfig } from "@roo-code/types"
+import * as vscode from "vscode"
+
+import { Package } from "../../../shared/package"
+
+export interface WorkspaceAutocompleteConfig {
+	disabled: boolean
+	debugLogging: boolean
+}
+
+/**
+ * Merges the persisted global autocomplete config (from ContextProxy state) with
+ * the workspace-level `zoo-code.autocomplete.*` configuration properties.
+ *
+ * The workspace properties act as a kill switch and a debug toggle that a repo
+ * can ship in `.vscode/settings.json`; they are intentionally NOT part of the
+ * persisted `autocompleteConfig` object so repositories cannot pollute user-level
+ * settings exports.
+ */
+export class AutocompleteConfigService {
+	constructor(private readonly getGlobalConfig: () => ResolvedAutocompleteConfig) {}
+
+	/**
+	 * The fully merged config. Workspace values are read fresh on every call so
+	 * `zoo-code.autocomplete.*` edits in `.vscode/settings.json` apply immediately
+	 * without an extension reload.
+	 */
+	getConfig(): ResolvedAutocompleteConfig {
+		const global = this.getGlobalConfig()
+		const workspace = AutocompleteConfigService.readWorkspaceConfig()
+
+		if (workspace.disabled) {
+			return { ...global, enabled: false }
+		}
+
+		return global
+	}
+
+	isEnabled(): boolean {
+		return this.getConfig().enabled
+	}
+
+	isDebugLogging(): boolean {
+		return AutocompleteConfigService.readWorkspaceConfig().debugLogging
+	}
+
+	/**
+	 * Reads the `zoo-code.autocomplete.*` properties from VS Code configuration.
+	 * Both properties are plain booleans; anything else is treated as unset.
+	 */
+	static readWorkspaceConfig(): WorkspaceAutocompleteConfig {
+		const config = vscode.workspace.getConfiguration(Package.name)
+		return {
+			disabled: config.get("autocomplete.disabled", false),
+			debugLogging: config.get("autocomplete.debugLogging", false),
+		}
+	}
+}
diff --git a/src/services/autocomplete/constants.ts b/src/services/autocomplete/constants.ts
new file mode 100644
index 0000000000..cd1655b0f3
--- /dev/null
+++ b/src/services/autocomplete/constants.ts
@@ -0,0 +1,21 @@
+/**
+ * Inline autocomplete (ghost text) constants.
+ *
+ * Latency budgets mirror the plan: first ghost text should appear within ~350 ms
+ * of the last keystroke, of which ~120 ms is the wall-clock budget shared by all
+ * context sources.
+ */
+
+/** Keystrokes with multiple cursors active are rejected up front. */
+export const MAX_CURSORS = 1
+
+/** Largest document (bytes) the completion pipeline will consider. */
+export const MAX_DOCUMENT_BYTES = 1_048_576
+
+/**
+ * Languages that are never worth completing, regardless of the user's
+ * `disabledLanguages` override. The overridable list lives in the config.
+ */
+export const DEFAULT_DISABLED_LANGUAGES = ["markdown", "plaintext", "log", "jsonc"] as const
+
+export const AUTOCOMPLETE_OUTPUT_CHANNEL_NAME = "Zoo-Code Autocomplete"
diff --git a/src/services/autocomplete/context/ContextGatherer.ts b/src/services/autocomplete/context/ContextGatherer.ts
new file mode 100644
index 0000000000..640c92e27f
--- /dev/null
+++ b/src/services/autocomplete/context/ContextGatherer.ts
@@ -0,0 +1,97 @@
+import * as vscode from "vscode"
+
+import type { ResolvedAutocompleteConfig } from "@roo-code/types"
+
+import type { AutocompleteSnippet } from "../types"
+
+export interface SnippetSourceInput {
+	readonly document: vscode.TextDocument
+	readonly position: vscode.Position
+	readonly prefix: string
+	readonly suffix: string
+}
+
+export interface SnippetSource {
+	readonly id: string
+	isEnabled(config: ResolvedAutocompleteConfig): boolean
+	gather(input: SnippetSourceInput, signal: AbortSignal): Promise
+}
+
+/**
+ * Runs every enabled snippet source against a wall-clock budget.
+ *
+ * Sources are raced, never awaited in sequence: a slow language server must not
+ * hold up a completion, and a source that throws must not take the others with
+ * it. Whatever has arrived when the budget expires is what gets used.
+ */
+export class ContextGatherer {
+	constructor(private readonly sources: readonly SnippetSource[]) {}
+
+	async gather(
+		input: SnippetSourceInput,
+		config: ResolvedAutocompleteConfig,
+		budgetMs: number,
+	): Promise {
+		const enabled = this.sources.filter((source) => source.isEnabled(config))
+
+		if (enabled.length === 0) {
+			return []
+		}
+
+		const controller = new AbortController()
+		const collected: AutocompleteSnippet[][] = []
+
+		const running = enabled.map(async (source, index) => {
+			try {
+				collected[index] = await source.gather(input, controller.signal)
+			} catch {
+				// One broken source must not deny the user every other kind of
+				// context; an empty contribution is the correct degradation.
+				collected[index] = []
+			}
+		})
+
+		// Race the sources against the budget rather than awaiting them. A source
+		// that ignores its abort signal keeps running, but its result is simply not
+		// waited for — context that arrives after the next keystroke is worthless.
+		let timer: ReturnType | undefined
+
+		const budget = new Promise((resolve) => {
+			timer = setTimeout(() => {
+				controller.abort()
+				resolve()
+			}, budgetMs)
+		})
+
+		try {
+			await Promise.race([Promise.all(running), budget])
+		} finally {
+			clearTimeout(timer)
+			controller.abort()
+		}
+
+		return dedupe(collected.filter(Boolean).flat())
+	}
+}
+
+/**
+ * Removes snippets with identical content, keeping the highest-scoring copy.
+ *
+ * The same definition legitimately arrives from more than one source (an import
+ * that is also an open tab), and paying for it twice in the token budget crowds
+ * out context the model has not already seen.
+ */
+function dedupe(snippets: AutocompleteSnippet[]): AutocompleteSnippet[] {
+	const byContent = new Map()
+
+	for (const snippet of snippets) {
+		const key = snippet.content.trim()
+		const existing = byContent.get(key)
+
+		if (!existing || (snippet.score ?? 0) > (existing.score ?? 0)) {
+			byContent.set(key, snippet)
+		}
+	}
+
+	return [...byContent.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
+}
diff --git a/src/services/autocomplete/context/__tests__/ContextGatherer.spec.ts b/src/services/autocomplete/context/__tests__/ContextGatherer.spec.ts
new file mode 100644
index 0000000000..9eeceacd97
--- /dev/null
+++ b/src/services/autocomplete/context/__tests__/ContextGatherer.spec.ts
@@ -0,0 +1,96 @@
+import { resolveAutocompleteConfig } from "@roo-code/types"
+
+import { ContextGatherer, type SnippetSource, type SnippetSourceInput } from "../ContextGatherer"
+import type { AutocompleteSnippet } from "../../types"
+
+const config = resolveAutocompleteConfig({ useOpenTabs: true, useImportDefinitions: true })
+
+const input = {} as SnippetSourceInput
+
+const makeSource = (
+	id: string,
+	snippets: AutocompleteSnippet[],
+	options: { delayMs?: number; throws?: boolean; enabled?: boolean } = {},
+): SnippetSource => ({
+	id,
+	isEnabled: () => options.enabled ?? true,
+	async gather() {
+		if (options.throws) {
+			throw new Error(`${id} exploded`)
+		}
+
+		if (options.delayMs) {
+			await new Promise((resolve) => setTimeout(resolve, options.delayMs))
+		}
+
+		return snippets
+	},
+})
+
+const snippet = (content: string, score = 0): AutocompleteSnippet => ({ content, filePath: "/a.py", score })
+
+describe("ContextGatherer", () => {
+	it("merges snippets from every enabled source", async () => {
+		const gatherer = new ContextGatherer([
+			makeSource("a", [snippet("import os")]),
+			makeSource("b", [snippet("def helper(): ...")]),
+		])
+
+		const result = await gatherer.gather(input, config, 100)
+
+		expect(result).toHaveLength(2)
+	})
+
+	it("skips disabled sources", async () => {
+		const gatherer = new ContextGatherer([makeSource("a", [snippet("x")], { enabled: false })])
+
+		expect(await gatherer.gather(input, config, 100)).toEqual([])
+	})
+
+	it("survives a source that throws", async () => {
+		// One broken source must not deny the user every other kind of context.
+		const gatherer = new ContextGatherer([
+			makeSource("bad", [], { throws: true }),
+			makeSource("good", [snippet("import os")]),
+		])
+
+		const result = await gatherer.gather(input, config, 100)
+
+		expect(result).toHaveLength(1)
+		expect(result[0].content).toBe("import os")
+	})
+
+	it("de-duplicates identical content, keeping the higher score", async () => {
+		const gatherer = new ContextGatherer([
+			makeSource("a", [snippet("import os", 0.2)]),
+			makeSource("b", [snippet("import os", 0.9)]),
+		])
+
+		const result = await gatherer.gather(input, config, 100)
+
+		expect(result).toHaveLength(1)
+		expect(result[0].score).toBe(0.9)
+	})
+
+	it("orders snippets by score so the budget keeps the best", async () => {
+		const gatherer = new ContextGatherer([
+			makeSource("a", [snippet("low", 0.1)]),
+			makeSource("b", [snippet("high", 0.9)]),
+		])
+
+		const result = await gatherer.gather(input, config, 100)
+
+		expect(result.map((entry) => entry.content)).toEqual(["high", "low"])
+	})
+
+	it("returns early rather than waiting on a slow source", async () => {
+		const gatherer = new ContextGatherer([makeSource("slow", [snippet("late")], { delayMs: 400 })])
+
+		const startedAt = Date.now()
+		await gatherer.gather(input, config, 50)
+
+		// The abort signal fires at the budget; the source resolves on its own
+		// schedule but the user is never made to wait for a full round trip.
+		expect(Date.now() - startedAt).toBeLessThan(400)
+	})
+})
diff --git a/src/services/autocomplete/context/__tests__/FileHeaderSource.spec.ts b/src/services/autocomplete/context/__tests__/FileHeaderSource.spec.ts
new file mode 100644
index 0000000000..adc0b6f3eb
--- /dev/null
+++ b/src/services/autocomplete/context/__tests__/FileHeaderSource.spec.ts
@@ -0,0 +1,63 @@
+import type * as vscode from "vscode"
+
+import { resolveAutocompleteConfig } from "@roo-code/types"
+
+import { FileHeaderSource } from "../sources/FileHeaderSource"
+import type { SnippetSourceInput } from "../ContextGatherer"
+
+const makeInput = (text: string, cursorLine = 99): SnippetSourceInput =>
+	({
+		document: {
+			getText: () => text,
+			uri: { fsPath: "/project/app.py" },
+			languageId: "python",
+		} as unknown as vscode.TextDocument,
+		position: { line: cursorLine, character: 0 } as vscode.Position,
+		prefix: "",
+		suffix: "",
+	}) as SnippetSourceInput
+
+const gather = (text: string, cursorLine?: number) => new FileHeaderSource().gather(makeInput(text, cursorLine))
+
+describe("FileHeaderSource", () => {
+	it("collects Python imports", async () => {
+		// The reported bug: without the header the model invented `List[C]` and a
+		// non-existent `pcb.C` rather than using what is actually imported.
+		const snippets = await gather("from typing import Sequence\nimport math\n\ndef calculate_mean():\n    pass")
+
+		expect(snippets[0].content).toContain("from typing import Sequence")
+		expect(snippets[0].content).toContain("import math")
+	})
+
+	it("collects JavaScript and TypeScript imports", async () => {
+		const snippets = await gather('import { readFile } from "fs"\n\nexport function main() {}')
+
+		expect(snippets[0].content).toContain('import { readFile } from "fs"')
+	})
+
+	it("excludes the line under the cursor", async () => {
+		const snippets = await gather("import os\nimport sys\n", 1)
+
+		expect(snippets[0].content).toContain("import os")
+		expect(snippets[0].content).not.toContain("import sys")
+	})
+
+	it("returns nothing for a file with no imports", async () => {
+		expect(await gather("def f():\n    return 1")).toEqual([])
+	})
+
+	it("stops after a long run of non-import code", async () => {
+		const body = Array.from({ length: 60 }, (_, i) => `x${i} = ${i}`).join("\n")
+		const snippets = await gather(`import os\n${body}\nimport late`)
+
+		expect(snippets[0].content).toContain("import os")
+		expect(snippets[0].content).not.toContain("import late")
+	})
+
+	it("honours the useImportDefinitions toggle", () => {
+		const source = new FileHeaderSource()
+
+		expect(source.isEnabled(resolveAutocompleteConfig({ useImportDefinitions: true }))).toBe(true)
+		expect(source.isEnabled(resolveAutocompleteConfig({ useImportDefinitions: false }))).toBe(false)
+	})
+})
diff --git a/src/services/autocomplete/context/sources/FileHeaderSource.ts b/src/services/autocomplete/context/sources/FileHeaderSource.ts
new file mode 100644
index 0000000000..d48e59f173
--- /dev/null
+++ b/src/services/autocomplete/context/sources/FileHeaderSource.ts
@@ -0,0 +1,90 @@
+import type { ResolvedAutocompleteConfig } from "@roo-code/types"
+
+import type { AutocompleteSnippet } from "../../types"
+import type { SnippetSource, SnippetSourceInput } from "../ContextGatherer"
+
+/**
+ * Supplies the current file's import block and top-level signatures.
+ *
+ * This is the cheapest and highest-value context there is. Without it a model
+ * asked to complete `def calculate_mean` invents plausible-looking types it has
+ * no basis for — `data: List[C]` referencing a `List` that was never imported
+ * and a `C` that does not exist. Shown the real header it uses what is actually
+ * in scope, or omits the annotation entirely.
+ *
+ * It reads only the text already in memory, so it costs no I/O and always
+ * resolves inside the context budget.
+ */
+export class FileHeaderSource implements SnippetSource {
+	readonly id = "file-header"
+
+	isEnabled(config: ResolvedAutocompleteConfig): boolean {
+		return config.useImportDefinitions
+	}
+
+	async gather(input: SnippetSourceInput): Promise {
+		const { document, position } = input
+		const snippets: AutocompleteSnippet[] = []
+
+		// The windowed prefix may start below the imports on a long file, in which
+		// case the model never sees them — the exact cause of invented types.
+		const header = collectHeader(document.getText(), position.line)
+
+		if (header.length > 0) {
+			snippets.push({
+				content: header,
+				filePath: document.uri.fsPath,
+				score: 1,
+				source: this.id,
+			})
+		}
+
+		return snippets
+	}
+}
+
+/**
+ * Collects import statements from the top of the file.
+ *
+ * Scans a bounded number of lines and stops at the first substantial run of
+ * non-import code, so a file whose imports are interleaved with early
+ * definitions still yields its header without walking the whole document.
+ */
+function collectHeader(text: string, cursorLine: number): string {
+	const lines = text.split("\n")
+	const limit = Math.min(lines.length, MAX_HEADER_LINES)
+	const collected: string[] = []
+	let sinceLastImport = 0
+
+	for (let i = 0; i < limit; i++) {
+		// Never echo the line being edited back as "context".
+		if (i === cursorLine) {
+			continue
+		}
+
+		const line = lines[i]
+
+		if (IMPORT_LINE.test(line)) {
+			collected.push(line)
+			sinceLastImport = 0
+			continue
+		}
+
+		if (line.trim().length === 0) {
+			continue
+		}
+
+		if (++sinceLastImport > MAX_GAP_LINES) {
+			break
+		}
+	}
+
+	return collected.join("\n")
+}
+
+/** Import forms across the languages this feature is likely to meet. */
+const IMPORT_LINE =
+	/^\s*(import\s|from\s+[\w.]+\s+import\s|#include\s|using\s+[\w.]+;|require\s*\(|const\s+\{[^}]*\}\s*=\s*require\s*\(|package\s+[\w.]+;|use\s+[\w:]+;)/
+
+const MAX_HEADER_LINES = 200
+const MAX_GAP_LINES = 40
diff --git a/src/services/autocomplete/context/sources/OpenTabsSource.ts b/src/services/autocomplete/context/sources/OpenTabsSource.ts
new file mode 100644
index 0000000000..e1f6894f6d
--- /dev/null
+++ b/src/services/autocomplete/context/sources/OpenTabsSource.ts
@@ -0,0 +1,82 @@
+import * as vscode from "vscode"
+
+import type { ResolvedAutocompleteConfig } from "@roo-code/types"
+
+import type { AutocompleteSnippet } from "../../types"
+import type { SnippetSource, SnippetSourceInput } from "../ContextGatherer"
+
+/**
+ * Supplies top-level signatures from other open editors.
+ *
+ * Open tabs are a strong proxy for relevance: they are what the user is working
+ * on right now. Only declaration lines are taken, never whole files — the point
+ * is to tell the model which functions and classes exist, not to spend the
+ * entire token budget on one neighbouring file's implementation details.
+ */
+export class OpenTabsSource implements SnippetSource {
+	readonly id = "open-tabs"
+
+	isEnabled(config: ResolvedAutocompleteConfig): boolean {
+		return config.useOpenTabs
+	}
+
+	async gather(input: SnippetSourceInput, signal: AbortSignal): Promise {
+		const current = input.document.uri.toString()
+		const snippets: AutocompleteSnippet[] = []
+
+		for (const editor of vscode.window.visibleTextEditors) {
+			if (signal.aborted || snippets.length >= MAX_TABS) {
+				break
+			}
+
+			const document = editor.document
+
+			if (document.uri.toString() === current || document.uri.scheme !== "file") {
+				continue
+			}
+
+			// Only same-language files: a Python completion learns nothing from a
+			// JSON config, and the token budget is better spent elsewhere.
+			if (document.languageId !== input.document.languageId) {
+				continue
+			}
+
+			const declarations = collectDeclarations(document.getText())
+
+			if (declarations.length > 0) {
+				snippets.push({
+					content: `# ${vscode.workspace.asRelativePath(document.uri)}\n${declarations.join("\n")}`,
+					filePath: document.uri.fsPath,
+					score: 0.5,
+					source: this.id,
+				})
+			}
+		}
+
+		return snippets
+	}
+}
+
+/** Top-level declaration lines, capped so one large file cannot dominate. */
+function collectDeclarations(text: string): string[] {
+	const found: string[] = []
+
+	for (const line of text.split("\n")) {
+		if (found.length >= MAX_DECLARATIONS_PER_TAB) {
+			break
+		}
+
+		// Anchored to column zero: nested definitions are implementation detail.
+		if (DECLARATION_LINE.test(line)) {
+			found.push(line.trimEnd())
+		}
+	}
+
+	return found
+}
+
+const DECLARATION_LINE =
+	/^(export\s+)?(async\s+)?(def|class|function|interface|type|struct|enum|const|fn|public\s+\w+)\s+[\w$]/
+
+const MAX_TABS = 5
+const MAX_DECLARATIONS_PER_TAB = 30
diff --git a/src/services/autocomplete/context/windowing.ts b/src/services/autocomplete/context/windowing.ts
new file mode 100644
index 0000000000..922c3fd984
--- /dev/null
+++ b/src/services/autocomplete/context/windowing.ts
@@ -0,0 +1,115 @@
+import * as vscode from "vscode"
+
+/** Windowing caps: keep reads cheap even on huge files (see plan risk #6). */
+export const PREFIX_MAX_LINES = 200
+/**
+ * The after-cursor window is what makes fill-in-the-middle different from plain
+ * continuation: editing mid-file, the closing brace and the following definitions
+ * are the signal that bounds the completion. A 50-line cap starved exactly that
+ * case, so a mid-file edit saw little more than a raw continuation would.
+ */
+export const SUFFIX_MAX_LINES = 150
+
+export interface WindowedDocument {
+	readonly prefix: string
+	readonly suffix: string
+}
+
+/**
+ * Normalises the document's line endings to `\n` so rendered prompts and cached
+ * keys are stable across Windows (\r\n) and Unix (\n). Also normalises a lone
+ * trailing `\r` (classic Mac) defensively.
+ */
+export function normalizeLineEndings(text: string): string {
+	return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
+}
+
+/**
+ * Walks backwards from the cursor, collecting up to {@link maxLines} lines or
+ * {@link maxChars} characters (whichever binds first), then trims from the head so
+ * the tail — the text immediately before the cursor — survives.
+ *
+ * Surrogate pairs at the trim boundary are kept intact: if the first retained
+ * char is a high surrogate, drop it so we don't emit an orphaned lead byte.
+ */
+export function windowPrefix(
+	document: vscode.TextDocument,
+	position: vscode.Position,
+	maxChars: number,
+	maxLines = PREFIX_MAX_LINES,
+): string {
+	const startLine = Math.max(0, position.line - (maxLines - 1))
+	const range = new vscode.Range(new vscode.Position(startLine, 0), position)
+	const raw = normalizeLineEndings(document.getText(range))
+
+	if (raw.length <= maxChars) {
+		return raw
+	}
+
+	let trimmed = raw.slice(raw.length - maxChars)
+
+	// Keep surrogate pairs intact: a high surrogate at the head means its pair was sliced off.
+	const firstCode = trimmed.charCodeAt(0)
+	if (firstCode >= 0xd800 && firstCode <= 0xdbff) {
+		trimmed = trimmed.slice(1)
+	}
+
+	// Don't start mid-token after a slice: drop a leading fragment that isn't preceded by whitespace.
+	const firstNewline = trimmed.indexOf("\n")
+	if (firstNewline > 0 && firstNewline < 80) {
+		trimmed = trimmed.slice(firstNewline + 1)
+	}
+
+	return trimmed
+}
+
+/**
+ * Walks forwards from the cursor, mirroring {@link windowPrefix}.
+ */
+export function windowSuffix(
+	document: vscode.TextDocument,
+	position: vscode.Position,
+	maxChars: number,
+	maxLines = SUFFIX_MAX_LINES,
+): string {
+	const lastLine = document.lineCount - 1
+	const endLine = Math.min(lastLine, position.line + maxLines)
+	const endCharacter = endLine === lastLine ? document.lineAt(lastLine).text.length : Number.MAX_SAFE_INTEGER
+	const range = new vscode.Range(position, new vscode.Position(endLine, endCharacter))
+	const raw = normalizeLineEndings(document.getText(range))
+
+	if (raw.length <= maxChars) {
+		return raw
+	}
+
+	let trimmed = raw.slice(0, maxChars)
+
+	// Keep surrogate pairs intact: a low surrogate at the tail means its pair was sliced off.
+	const lastCode = trimmed.charCodeAt(trimmed.length - 1)
+	if (lastCode >= 0xdc00 && lastCode <= 0xdfff) {
+		trimmed = trimmed.slice(0, -1)
+	}
+
+	// Don't end mid-token: drop a trailing fragment that isn't followed by whitespace.
+	const lastNewline = trimmed.lastIndexOf("\n")
+	if (lastNewline >= 0 && trimmed.length - lastNewline < 80) {
+		trimmed = trimmed.slice(0, lastNewline + 1)
+	}
+
+	return trimmed
+}
+
+/**
+ * Convenience: windows both sides in one call.
+ */
+export function windowDocument(
+	document: vscode.TextDocument,
+	position: vscode.Position,
+	maxPrefixChars: number,
+	maxSuffixChars: number,
+): WindowedDocument {
+	return {
+		prefix: windowPrefix(document, position, maxPrefixChars),
+		suffix: windowSuffix(document, position, maxSuffixChars),
+	}
+}
diff --git a/src/services/autocomplete/prefilters.ts b/src/services/autocomplete/prefilters.ts
new file mode 100644
index 0000000000..92a184cdf2
--- /dev/null
+++ b/src/services/autocomplete/prefilters.ts
@@ -0,0 +1,83 @@
+import type { AutocompleteConfig } from "@roo-code/types"
+import * as vscode from "vscode"
+
+import { DEFAULT_DISABLED_LANGUAGES, MAX_CURSORS } from "./constants"
+import type { AutocompleteInput } from "./types"
+
+export type PrefilterResult =
+	| { ok: true }
+	| { ok: false; reason: "disabled" | "language" | "rooignore" | "multi-cursor" }
+
+/**
+ * Checks the document-level gates that apply regardless of how the suggestion
+ * was triggered. Order matters: cheapest checks first.
+ */
+export function prefilterDocument(
+	input: AutocompleteInput,
+	config: AutocompleteConfig,
+	validateAccess: (filePath: string) => boolean,
+): PrefilterResult {
+	if (input.cursorCount > MAX_CURSORS) {
+		return { ok: false, reason: "multi-cursor" }
+	}
+
+	if (!config.enabled) {
+		return { ok: false, reason: "disabled" }
+	}
+
+	if (isLanguageDisabled(input.languageId, config)) {
+		return { ok: false, reason: "language" }
+	}
+
+	if (!validateAccess(input.document.uri.fsPath)) {
+		return { ok: false, reason: "rooignore" }
+	}
+
+	return { ok: true }
+}
+
+/**
+ * Whether to skip because VS Code's suggest widget is showing a selection.
+ *
+ * Bailing on *any* `selectedCompletionInfo` suppresses ghost text almost
+ * permanently in languages with an eager language server (Python, TypeScript),
+ * because the widget re-opens on nearly every keystroke. That is the difference
+ * between "no suggestions ever" and a working feature.
+ *
+ * Instead, only bail when the widget's selected text genuinely conflicts: the
+ * widget will replace `range` with `text`, so a completion computed for the
+ * pre-widget document would duplicate or contradict it. When the selected item
+ * merely echoes what the user already typed (the common case — `number` while
+ * `number` is on screen), there is nothing to conflict with and ghost text is
+ * both safe and wanted.
+ */
+export function shouldBailForWidget(context: vscode.InlineCompletionContext, document: vscode.TextDocument): boolean {
+	const selected = context.selectedCompletionInfo
+
+	if (!selected) {
+		return false
+	}
+
+	// The widget would insert something beyond what is already in the document,
+	// so a completion built on the current text is stale.
+	return document.getText(selected.range) !== selected.text
+}
+
+/**
+ * In manual mode suggestions are only requested on demand; the automatic trigger
+ * (typing) is ignored.
+ */
+export function shouldSuppressAutomaticTrigger(
+	triggerKind: vscode.InlineCompletionTriggerKind,
+	triggerMode: AutocompleteConfig["triggerMode"],
+): boolean {
+	return triggerKind === vscode.InlineCompletionTriggerKind.Automatic && triggerMode === "manual"
+}
+
+/** Language gate: built-in always-off list plus the user's overridable list. */
+export function isLanguageDisabled(languageId: string, config: AutocompleteConfig): boolean {
+	return (
+		(DEFAULT_DISABLED_LANGUAGES as readonly string[]).includes(languageId) ||
+		(config.disabledLanguages ?? []).includes(languageId)
+	)
+}
diff --git a/src/services/autocomplete/prompt/FimTemplateRegistry.ts b/src/services/autocomplete/prompt/FimTemplateRegistry.ts
new file mode 100644
index 0000000000..d4c2c65b79
--- /dev/null
+++ b/src/services/autocomplete/prompt/FimTemplateRegistry.ts
@@ -0,0 +1,92 @@
+import type { FimTemplateId } from "@roo-code/types"
+
+import { FIM_TEMPLATES, type FimTemplate } from "./templates"
+
+/**
+ * Resolves the FIM template for a given model id, honouring an explicit override.
+ *
+ * Resolution order:
+ * 1. An explicit override other than `"auto"` wins outright.
+ * 2. Otherwise the first template whose {@link FimTemplate.matches} regexp tests
+ *    the model id.
+ * 3. Falls back to the `"none"` template (prefix only).
+ */
+export class FimTemplateRegistry {
+	private readonly templates: readonly FimTemplate[]
+
+	constructor(templates: readonly FimTemplate[] = FIM_TEMPLATES) {
+		this.templates = templates
+	}
+
+	resolve(modelId: string | undefined, override: FimTemplateId | undefined): FimTemplate {
+		if (override && override !== "auto") {
+			const explicit = this.templates.find((template) => template.id === override)
+
+			if (explicit) {
+				return explicit
+			}
+		}
+
+		if (modelId) {
+			// A known FIM family outranks an instruction-tuned marker. Publishers
+			// ship FIM-trained models under `-instruct` tags — `codestral:22b-instruct`
+			// and `qwen2.5-coder:7b-instruct` both retain their FIM control tokens —
+			// so treating the tag as decisive routed genuinely FIM-capable models to
+			// the chat path and silently discarded the suffix. Families are matched
+			// first; `fimTemplate: "instruct"` is the escape hatch for the rare model
+			// that carries a family name without the corresponding FIM training.
+			const family = this.templates.find(
+				(template) => template.id !== "none" && template.id !== "instruct" && template.matches.test(modelId),
+			)
+
+			if (family) {
+				return family
+			}
+
+			// No family match. A base model genuinely wants raw continuation: it has
+			// FIM training but none of our known token vocabularies, and the chat
+			// prompt would only pollute the output.
+			if (isBaseModel(modelId)) {
+				const none = this.templates.find((template) => template.id === "none")
+
+				if (none) {
+					return none
+				}
+			}
+
+			// Anything else falls through to the shared `instruct` default below.
+		}
+
+		// Unknown model. Default to `instruct` rather than `none`: the overwhelming
+		// majority of models a user can point this at are chat/instruction-tuned,
+		// and `none` sends them a bare prefix with no instruction at all — which
+		// reads as the model ignoring the request entirely. `instruct` degrades
+		// gracefully for a FIM model, whereas `none` fails outright for a chat one.
+		return (
+			this.templates.find((template) => template.id === "instruct") ?? this.templates[this.templates.length - 1]
+		)
+	}
+}
+
+/**
+ * True when the model id advertises itself as a *base* (non-instruction-tuned)
+ * model, e.g. `qwen2.5-coder:1.5b-base`.
+ *
+ * A base model with no known family is sent down the raw-continuation path
+ * rather than the chat path: it has FIM training, just not a vocabulary we
+ * recognise.
+ */
+export function isBaseModel(modelId: string): boolean {
+	return /[-:_/]base\b|\bbase[-_]/i.test(modelId)
+}
+
+/**
+ * True when the resolved template speaks fill-in-the-middle.
+ *
+ * `instruct` and `none` do not: for those, the endpoint must be sent the fully
+ * rendered prompt rather than a `prefix`/`suffix` pair, because passing a suffix
+ * to a model with no FIM tokens yields a free-running continuation.
+ */
+export function templateSupportsFim(template: FimTemplate): boolean {
+	return template.id !== "instruct" && template.id !== "none"
+}
diff --git a/src/services/autocomplete/prompt/PromptBuilder.ts b/src/services/autocomplete/prompt/PromptBuilder.ts
new file mode 100644
index 0000000000..6ed7c2fc5a
--- /dev/null
+++ b/src/services/autocomplete/prompt/PromptBuilder.ts
@@ -0,0 +1,109 @@
+import { UNIVERSAL_STOP_SEQUENCES, type ResolvedAutocompleteConfig } from "@roo-code/types"
+
+import type { AutocompleteSnippet } from "../types"
+import { FimTemplateRegistry, templateSupportsFim } from "./FimTemplateRegistry"
+import { INSTRUCT_SYSTEM_PROMPT, type FimTemplate } from "./templates"
+import { pruneSnippets, trimToTokenBudget } from "./tokenBudget"
+
+export interface BuildPromptInput {
+	readonly prefix: string
+	readonly suffix: string
+	readonly snippets: readonly AutocompleteSnippet[]
+	readonly config: ResolvedAutocompleteConfig
+}
+
+export interface BuiltPrompt {
+	/** The prefix sent to the endpoint (snippet preamble already prepended for native FIM). */
+	readonly prefix: string
+	readonly suffix: string
+	/** The fully-rendered prompt used when the endpoint has no native FIM. */
+	readonly renderedPrompt: string
+	/** Stop sequences from the resolved template merged with the user's overrides. */
+	readonly stopSequences: readonly string[]
+	readonly promptChars: number
+	/**
+	 * False when the resolved template has no FIM control tokens (`instruct`/`none`).
+	 * Handlers must send {@link BuiltPrompt.renderedPrompt} and omit `suffix` in that
+	 * case — a suffix sent to a non-FIM model produces a free-running continuation.
+	 */
+	readonly supportsFim: boolean
+	/** True when the model must be driven through the chat endpoint (instruction-tuned). */
+	readonly useChatEndpoint: boolean
+	/** System instruction for the chat path. */
+	readonly systemPrompt?: string
+	/** The resolved template id, for debug logging and telemetry. */
+	readonly templateId: string
+}
+
+/**
+ * Builds the FIM prompt for a single keystroke.
+ *
+ * Phase 2 is same-file only: the snippets array is always empty, so the prefix
+ * and suffix are the windowed text around the cursor. The {@link FimTemplate}
+ * decides whether the snippet preamble is prepended to the prefix (native FIM)
+ * or folded into the rendered prompt (non-native fallback).
+ */
+export class PromptBuilder {
+	private readonly registry: FimTemplateRegistry
+
+	constructor(registry?: FimTemplateRegistry) {
+		this.registry = registry ?? new FimTemplateRegistry()
+	}
+
+	build(input: BuildPromptInput): BuiltPrompt {
+		const template = this.registry.resolve(input.config.modelId, input.config.fimTemplate)
+
+		const prefix = trimToTokenBudget(input.prefix, input.config.maxPrefixTokens, "tail")
+		const suffix = trimToTokenBudget(input.suffix, input.config.maxSuffixTokens, "head")
+		const { snippets } = pruneSnippets(input.snippets, input.config.maxSnippetTokens)
+
+		const stopSequences = mergeStopSequences(template, input.config.stopSequences)
+
+		// Native-FIM endpoints take prefix+suffix as separate fields; the snippet
+		// preamble is prepended to the prefix so it travels with the prompt context.
+		// The preamble is newline-terminated so the last snippet line can never run
+		// into the first prefix line — concatenated flush, the model reads foreign
+		// code as contiguous with the cursor line and completes that instead.
+		const preamble = template.renderSnippets(snippets)
+		const nativePrefix = preamble && !preamble.endsWith("\n") ? `${preamble}\n${prefix}` : preamble + prefix
+		const renderedPrompt = template.render(prefix, suffix, snippets)
+
+		return {
+			prefix: nativePrefix,
+			suffix,
+			renderedPrompt,
+			stopSequences,
+			promptChars: renderedPrompt.length,
+			supportsFim: templateSupportsFim(template),
+			useChatEndpoint: template.id === "instruct",
+			systemPrompt: template.id === "instruct" ? INSTRUCT_SYSTEM_PROMPT : undefined,
+			templateId: template.id,
+		}
+	}
+}
+
+/**
+ * Merges template, universal and user stop sequences, de-duplicated.
+ *
+ * The universal set is non-negotiable: without it the `none` and `instruct`
+ * templates contribute no terminator at all, so nothing ever stops the stream and
+ * the model runs to `maxOutputTokens` emitting prose and reasoning blocks.
+ *
+ * Template stops come first because handlers that cap the list (the OpenAI
+ * `/v1/completions` API accepts at most 4) must keep the family-specific tokens.
+ */
+function mergeStopSequences(template: FimTemplate, userStops: string[] | undefined): readonly string[] {
+	const seen = new Set()
+	const merged: string[] = []
+
+	for (const stop of [...template.stop, ...(userStops ?? []), ...UNIVERSAL_STOP_SEQUENCES]) {
+		if (stop && !seen.has(stop)) {
+			seen.add(stop)
+			merged.push(stop)
+		}
+	}
+
+	return merged
+}
+
+export { FimTemplateRegistry }
diff --git a/src/services/autocomplete/prompt/__tests__/PromptBuilder.spec.ts b/src/services/autocomplete/prompt/__tests__/PromptBuilder.spec.ts
new file mode 100644
index 0000000000..52079c2787
--- /dev/null
+++ b/src/services/autocomplete/prompt/__tests__/PromptBuilder.spec.ts
@@ -0,0 +1,93 @@
+import { UNIVERSAL_STOP_SEQUENCES, resolveAutocompleteConfig, type AutocompleteConfig } from "@roo-code/types"
+
+import { PromptBuilder } from "../PromptBuilder"
+
+const build = (config: AutocompleteConfig = {}, prefix = "function add(", suffix = ") { return a + b }") =>
+	new PromptBuilder().build({
+		prefix,
+		suffix,
+		snippets: [],
+		config: resolveAutocompleteConfig(config),
+	})
+
+describe("PromptBuilder stop sequences", () => {
+	it("always includes the universal stop sequences", () => {
+		// The bug this guards: the `none` and `instruct` templates contribute no
+		// stop tokens of their own, so without the universal set the stream had no
+		// terminator at all and ran to maxOutputTokens emitting prose.
+		const built = build({ modelId: "lfm2.5-2.6b" })
+
+		for (const stop of UNIVERSAL_STOP_SEQUENCES) {
+			expect(built.stopSequences).toContain(stop)
+		}
+	})
+
+	it("never yields an empty stop list, whatever the model", () => {
+		for (const modelId of ["lfm2.5-2.6b", "some-unknown-model", "qwen2.5-coder:1.5b-base"]) {
+			expect(build({ modelId }).stopSequences.length).toBeGreaterThan(0)
+		}
+	})
+
+	it("puts family-specific tokens first so handlers that cap the list keep them", () => {
+		// The OpenAI /v1/completions API accepts at most 4 stop sequences.
+		const built = build({ modelId: "qwen2.5-coder:1.5b-base" })
+
+		expect(built.stopSequences.slice(0, 4)).toContain("<|fim_pad|>")
+	})
+
+	it("de-duplicates across template, user and universal sets", () => {
+		const built = build({ modelId: "qwen2.5-coder:1.5b-base", stopSequences: ["<|endoftext|>", "CUSTOM"] })
+
+		expect(built.stopSequences.filter((stop) => stop === "<|endoftext|>")).toHaveLength(1)
+		expect(built.stopSequences).toContain("CUSTOM")
+	})
+})
+
+describe("PromptBuilder FIM routing", () => {
+	it("marks a FIM base model as native", () => {
+		const built = build({ modelId: "qwen2.5-coder:1.5b-base" })
+
+		expect(built.supportsFim).toBe(true)
+		expect(built.templateId).toBe("qwen")
+		expect(built.prefix).toContain("function add(")
+	})
+
+	it("marks an instruct model as non-native and renders an instruction prompt", () => {
+		const built = build({ modelId: "lfm2.5-2.6b" })
+
+		expect(built.supportsFim).toBe(false)
+		expect(built.templateId).toBe("instruct")
+		// Both sides of the cursor must reach a non-FIM model through the rendered
+		// prompt, since its `suffix` field will be omitted.
+		expect(built.renderedPrompt).toContain("function add(")
+		expect(built.renderedPrompt).toContain(") { return a + b }")
+		expect(built.renderedPrompt).toContain("")
+	})
+
+	it("honours an explicit template override", () => {
+		const built = build({ modelId: "lfm2.5-2.6b", fimTemplate: "qwen" })
+
+		expect(built.templateId).toBe("qwen")
+		expect(built.supportsFim).toBe(true)
+	})
+})
+
+describe("PromptBuilder chat routing", () => {
+	it("routes an instruct model to the chat endpoint with a system prompt", () => {
+		const built = build({ modelId: "lfm2.5-2.6b" })
+
+		expect(built.useChatEndpoint).toBe(true)
+		expect(built.systemPrompt).toMatch(/only/i)
+		// The user turn carries code only — never the rules, which a raw
+		// completions endpoint would happily continue as text.
+		expect(built.renderedPrompt).not.toMatch(/output only/i)
+		expect(built.renderedPrompt).toContain("")
+	})
+
+	it("keeps FIM models off the chat endpoint", () => {
+		const built = build({ modelId: "qwen2.5-coder:1.5b-base" })
+
+		expect(built.useChatEndpoint).toBe(false)
+		expect(built.systemPrompt).toBeUndefined()
+	})
+})
diff --git a/src/services/autocomplete/prompt/templates.ts b/src/services/autocomplete/prompt/templates.ts
new file mode 100644
index 0000000000..d75e390416
--- /dev/null
+++ b/src/services/autocomplete/prompt/templates.ts
@@ -0,0 +1,202 @@
+import type { FimTemplateId } from "@roo-code/types"
+
+import type { AutocompleteSnippet } from "../types"
+
+/**
+ * A FIM (fill-in-the-middle) template wraps the prefix/suffix in model-specific
+ * control tokens so the model knows where the "hole" is.
+ *
+ * Endpoints with **native** FIM (Ollama, OpenAI-compatible `/v1/completions` with
+ * `suffix`) accept `prefix` and `suffix` as separate fields and apply the
+ * template server-side; for those we only need {@link renderSnippets} to build a
+ * preamble of cross-file context, and {@link render} is used solely for the
+ * non-native fallback path.
+ */
+export interface FimTemplate {
+	readonly id: FimTemplateId
+	/** Matches a model id against this template; first match wins. */
+	readonly matches: RegExp
+	/** Extra stop sequences specific to this model family. */
+	readonly stop: readonly string[]
+	/** Renders the full prompt when the endpoint has no native FIM support. */
+	render(prefix: string, suffix: string, snippets: readonly AutocompleteSnippet[]): string
+	/** Renders a preamble of snippets prepended to the prefix for native-FIM endpoints. */
+	renderSnippets(snippets: readonly AutocompleteSnippet[]): string
+}
+
+/** Joins snippet bodies into a compact preamble; empty when there are no snippets. */
+function renderSnippetPreamble(snippets: readonly AutocompleteSnippet[]): string {
+	if (snippets.length === 0) {
+		return ""
+	}
+
+	const bodies = snippets.map((snippet) => renderSnippetBody(snippet)).join("\n\n")
+	return `${bodies}\n\n`
+}
+
+/**
+ * Labels a snippet with its originating file.
+ *
+ * Without a delimiter the snippet body is concatenated straight onto the file
+ * prefix, so the model reads another file's code as contiguous with the cursor
+ * line and completes *that* instead. A comment marker is used rather than a bare
+ * path so the label itself parses as code in whatever language is being edited.
+ */
+function renderSnippetBody(snippet: AutocompleteSnippet): string {
+	const filePath = snippet.filePath.trim()
+
+	return filePath ? `// ${filePath}\n${snippet.content}` : snippet.content
+}
+
+/**
+ * Qwen 2.5 Coder's repo-level FIM format.
+ *
+ * Qwen is trained with `<|repo_name|>` and `<|file_sep|>` for exactly the
+ * cross-file case, and using them is what separates "context the model consults"
+ * from "code the model continues". The file under edit is the final `<|file_sep|>`
+ * section, immediately followed by the FIM triplet.
+ */
+function renderQwenRepoContext(snippets: readonly AutocompleteSnippet[]): string {
+	if (snippets.length === 0) {
+		return ""
+	}
+
+	const files = snippets
+		.map((snippet) => `<|file_sep|>${snippet.filePath.trim() || "context"}\n${snippet.content}`)
+		.join("\n")
+
+	return `${files}\n<|file_sep|>`
+}
+
+/** A template that only emits the prefix (no suffix wrapping). */
+const prefixOnly = (preamble: string) => (prefix: string) => `${preamble}${prefix}`
+
+/**
+ * Chat-turn terminators. An instruct model served over `/v1/completions` receives
+ * a raw prompt, so the server applies no chat template and nothing terminates the
+ * turn — these do.
+ */
+const INSTRUCT_STOP: readonly string[] = ["<|im_end|>", "<|eot_id|>", "<|end|>", "", "<|endoftext|>", "```"]
+
+/** Templates resolved from the model id; order matters — first match wins. */
+export const FIM_TEMPLATES: readonly FimTemplate[] = [
+	{
+		id: "qwen",
+		matches: /qwen|codeqwen/i,
+		// `<|file_sep|>` and `<|repo_name|>` terminate a file section in Qwen's
+		// repo-level format, so the model emits one to move on to the "next file"
+		// once it considers the hole filled.
+		stop: ["<|endoftext|>", "<|fim_pad|>", "<|file_sep|>", "<|repo_name|>"],
+		render: (prefix, suffix, snippets) =>
+			`${renderQwenRepoContext(snippets)}<|fim_prefix|>${prefix}<|fim_suffix|>${suffix}<|fim_middle|>`,
+		renderSnippets: renderQwenRepoContext,
+	},
+	{
+		id: "starcoder",
+		matches: /starcoder|stable-?code/i,
+		stop: ["<|endoftext|>"],
+		render: (prefix, suffix, snippets) =>
+			`${renderSnippetPreamble(snippets)}${prefix}${suffix}`,
+		renderSnippets: renderSnippetPreamble,
+	},
+	{
+		id: "codestral",
+		// `mistral-nemo` and friends are instruction-tuned and have no FIM tokens;
+		// the bare family name would otherwise capture them now that family
+		// matching outranks instruct markers.
+		matches: /codestral/i,
+		// Only the *opening* markers terminate a completion. `[MIDDLE]` is the token
+		// this template deliberately emits to open the hole, so listing it as a stop
+		// truncated the response at its own prompt boundary.
+		stop: ["[PREFIX]", "[SUFFIX]"],
+		render: (prefix, suffix, snippets) =>
+			`${renderSnippetPreamble(snippets)}[SUFFIX]${suffix}[PREFIX]${prefix}[MIDDLE]`,
+		renderSnippets: renderSnippetPreamble,
+	},
+	{
+		id: "codellama",
+		matches: /codellama/i,
+		stop: ["
", "", ""],
+		render: (prefix, suffix, snippets) => `${renderSnippetPreamble(snippets)}
 ${prefix} ${suffix} `,
+		renderSnippets: renderSnippetPreamble,
+	},
+	{
+		id: "deepseek",
+		matches: /deepseek/i,
+		stop: ["<|fim▁end|>", "<|begin▁of▁sentence|>", "<|end▁of▁sentence|>"],
+		render: (prefix, suffix, snippets) =>
+			`${renderSnippetPreamble(snippets)}<|fim▁begin|>${prefix}<|fim▁hole|>${suffix}<|fim▁end|>`,
+		renderSnippets: renderSnippetPreamble,
+	},
+	{
+		id: "codegemma",
+		matches: /codegemma/i,
+		stop: ["<|endoftext|>", "<|file_separator|>"],
+		render: (prefix, suffix, snippets) =>
+			`${renderSnippetPreamble(snippets)}<|fim_prefix|>${prefix}<|fim_suffix|>${suffix}<|fim_middle|>`,
+		renderSnippets: renderSnippetPreamble,
+	},
+	{
+		id: "instruct",
+		// Instruction-tuned models have no FIM control tokens. Matching them here
+		// (before the `none` catch-all) keeps them off the raw-continuation path,
+		// which is what produces prose, commentary and reasoning blocks instead of
+		// code. Base variants are excluded by `isBaseModel` in the registry, since
+		// a `-base` suffix means the model *is* FIM-capable.
+		matches: /(lfm|instruct|-it\b|chat|phi-?[34]|llama-?3|gemma-?[23]|mistral-?nemo|granite|smol)/i,
+		stop: INSTRUCT_STOP,
+		render: (prefix, suffix, snippets) => renderInstructPrompt(prefix, suffix, snippets),
+		renderSnippets: renderSnippetPreamble,
+	},
+	{
+		id: "none",
+		matches: /.*/,
+		stop: [],
+		render: (prefix, _suffix, snippets) => prefixOnly(renderSnippetPreamble(snippets))(prefix),
+		renderSnippets: renderSnippetPreamble,
+	},
+]
+
+/**
+ * System instruction for the chat path.
+ *
+ * This must never be concatenated into a `/v1/completions` prompt. That endpoint
+ * is a pure continuation API with no notion of instructions, so a model handed
+ * this text simply continues *it* — echoing the rules back as if they were the
+ * code. It only works as a `system` message on `/v1/chat/completions`, where the
+ * server's chat template marks it as out-of-band.
+ */
+export const INSTRUCT_SYSTEM_PROMPT =
+	"You are a code completion engine inside an editor. " +
+	"The user sends code with a  marker. " +
+	"Reply with ONLY the raw code that belongs at  — no explanation, no commentary, " +
+	"no markdown fences, no reasoning, and no repetition of the code around the cursor. " +
+	// Locality is the instruction that matters most. Without it these models answer
+	// the *task* they infer from the surrounding code — emitting whole scripts,
+	// re-declaring functions that already exist, and appending example usage.
+	"Complete only what belongs at the cursor: usually the rest of the current line, " +
+	"or the current block. Never write a whole file, never redefine something that " +
+	"already exists above, and never add example usage or a main block. " +
+	"Use only names that are already imported or defined in the code you were shown. " +
+	"If nothing should be inserted, reply with nothing."
+
+/**
+ * Renders the user turn for the chat path: the code, marked at the cursor.
+ *
+ * Carries no instructions of its own — those live in {@link INSTRUCT_SYSTEM_PROMPT}
+ * — so that if a model does echo its input, it echoes code rather than prose.
+ */
+function renderInstructPrompt(prefix: string, suffix: string, snippets: readonly AutocompleteSnippet[]): string {
+	const code = `${prefix}${suffix}`
+
+	if (snippets.length === 0) {
+		return code
+	}
+
+	// Context is labelled and separated from the file under edit. Without the
+	// separation a chat model treats the snippets as more code to continue and
+	// completes *those* instead of the cursor line.
+	return `Context from the project (for reference only, do not complete this):\n${renderSnippetPreamble(snippets).trimEnd()}\n\nFile being edited — complete at :\n${code}`
+}
+
+export { renderSnippetPreamble }
diff --git a/src/services/autocomplete/prompt/tokenBudget.ts b/src/services/autocomplete/prompt/tokenBudget.ts
new file mode 100644
index 0000000000..e7c10a3588
--- /dev/null
+++ b/src/services/autocomplete/prompt/tokenBudget.ts
@@ -0,0 +1,102 @@
+import type { AutocompleteSnippet } from "../types"
+
+/**
+ * Rough token estimate: ~4 chars per token with a small fudge for code (which
+ * tends to be denser per token than prose). Deliberately *under*-estimates so the
+ * prompt budget is conservative and we never silently drop needed context.
+ */
+export function estimateTokens(text: string): number {
+	if (text.length === 0) {
+		return 0
+	}
+
+	return Math.ceil((text.length / 3.5) * 1.2)
+}
+
+export interface PrunedSnippets {
+	readonly snippets: AutocompleteSnippet[]
+	readonly dropped: number
+}
+
+/**
+ * Prunes snippets to fit a token budget, keeping the most valuable first.
+ *
+ * Phase 2 has no snippets (same-file only); Phase 4 supplies the sources. The
+ * prune order is stable: sources already arrive in priority order (recently-edited
+ * → open-tabs → import-definitions), so we keep them in order and drop from the
+ * tail until the budget is met, then trim the last-kept snippet's trailing chars.
+ */
+export function pruneSnippets(snippets: readonly AutocompleteSnippet[], budgetTokens: number): PrunedSnippets {
+	const kept: AutocompleteSnippet[] = []
+	let used = 0
+
+	for (const snippet of snippets) {
+		const cost = estimateTokens(snippet.content)
+
+		if (used + cost > budgetTokens) {
+			const remaining = Math.max(0, budgetTokens - used)
+
+			if (remaining < 8) {
+				// Not worth trimming; drop the rest.
+				break
+			}
+
+			const maxChars = Math.floor(remaining * 3.5)
+			kept.push({ ...snippet, content: snippet.content.slice(0, maxChars) })
+			used += remaining
+			break
+		}
+
+		kept.push(snippet)
+		used += cost
+	}
+
+	return { snippets: kept, dropped: snippets.length - kept.length }
+}
+
+/**
+ * Trims text to a token budget from the end nearest the cursor.
+ * Prefixes keep their tail (the part right before the cursor); suffixes keep
+ * their head (the part right after).
+ */
+export function trimToTokenBudget(text: string, budgetTokens: number, from: "tail" | "head"): string {
+	const maxChars = Math.ceil(budgetTokens * 3.5)
+
+	if (text.length <= maxChars) {
+		return text
+	}
+
+	if (from === "tail") {
+		let trimmed = text.slice(text.length - maxChars)
+
+		// A high surrogate at the head means its pair was sliced off.
+		const firstCode = trimmed.charCodeAt(0)
+		if (firstCode >= 0xd800 && firstCode <= 0xdbff) {
+			trimmed = trimmed.slice(1)
+		}
+
+		// Realign to a line boundary so the prompt never opens mid-token. Bounded so
+		// a single very long line is kept rather than discarded wholesale.
+		const firstNewline = trimmed.indexOf("\n")
+		if (firstNewline > 0 && firstNewline < 80) {
+			trimmed = trimmed.slice(firstNewline + 1)
+		}
+
+		return trimmed
+	}
+
+	let trimmed = text.slice(0, maxChars)
+
+	// A low surrogate at the tail means its pair was sliced off.
+	const lastCode = trimmed.charCodeAt(trimmed.length - 1)
+	if (lastCode >= 0xdc00 && lastCode <= 0xdfff) {
+		trimmed = trimmed.slice(0, -1)
+	}
+
+	const lastNewline = trimmed.lastIndexOf("\n")
+	if (lastNewline >= 0 && trimmed.length - lastNewline < 80) {
+		trimmed = trimmed.slice(0, lastNewline + 1)
+	}
+
+	return trimmed
+}
diff --git a/src/services/autocomplete/providers/FimCompletionHandler.ts b/src/services/autocomplete/providers/FimCompletionHandler.ts
new file mode 100644
index 0000000000..69592510b6
--- /dev/null
+++ b/src/services/autocomplete/providers/FimCompletionHandler.ts
@@ -0,0 +1,76 @@
+import type { AutocompleteModelSummary, AutocompleteProviderId, AutocompleteValidationResult } from "@roo-code/types"
+
+/**
+ * A FIM completion handler speaks to one model endpoint and streams the middle of
+ * a fill-in-the-middle request.
+ *
+ * Phase 2 ships only {@link OllamaFimHandler}; Phase 3 adds the OpenAI-compatible,
+ * Codestral and chat-fallback handlers behind this interface.
+ */
+export interface FimCompletionHandler {
+	readonly id: AutocompleteProviderId
+	/** The endpoint accepts `prefix`/`suffix` and applies the FIM template server-side. */
+	readonly usesNativeFim: boolean
+	readonly supportsStreaming: boolean
+
+	/**
+	 * Streams the completion middle. Each yielded string is a raw model fragment
+	 * (post-processing runs separately). The generator ends when the stream
+	 * completes or the signal aborts.
+	 */
+	streamFim(request: FimRequest): AsyncGenerator
+
+	listModels(signal: AbortSignal): Promise
+	validate(signal: AbortSignal): Promise
+}
+
+export interface FimRequest {
+	readonly modelId: string
+	readonly baseUrl: string
+	readonly apiKey: string | undefined
+	/** The prefix sent to a native-FIM endpoint (snippet preamble already folded in). */
+	readonly prefix: string
+	readonly suffix: string
+	/** The fully-rendered prompt, used for non-native fallback and the 400 retry. */
+	readonly renderedPrompt: string
+	/**
+	 * False when the resolved template has no FIM control tokens (`instruct`/`none`).
+	 *
+	 * Handlers **must** send {@link renderedPrompt} and omit `suffix` when this is
+	 * false. Passing a suffix to a model that has never seen a FIM token does not
+	 * fail — the server accepts it and the model simply free-runs, which is the
+	 * single largest source of prose-instead-of-code completions.
+	 */
+	readonly supportsFim: boolean
+	/**
+	 * True when the model is instruction-tuned and must be driven through the
+	 * *chat* endpoint rather than raw completions.
+	 *
+	 * Raw `/v1/completions` has no instruction channel: a system prompt pasted
+	 * into the prompt string is just more text to continue, so the model echoes
+	 * the rules back as output. The chat endpoint applies the model's own chat
+	 * template, which is the only reliable way to keep instructions out of the
+	 * generated text.
+	 */
+	readonly useChatEndpoint: boolean
+	/** System instruction for the chat path. Ignored unless {@link useChatEndpoint}. */
+	readonly systemPrompt?: string
+	readonly stopSequences: readonly string[]
+	readonly temperature: number
+	readonly maxOutputTokens: number
+	readonly contextLength: number
+	readonly requestTimeoutMs: number
+	readonly signal: AbortSignal
+}
+
+export interface FimHandlerOptions {
+	readonly getConfig: () => {
+		readonly modelId: string
+		readonly baseUrl: string
+		readonly temperature: number
+		readonly maxOutputTokens: number
+		readonly contextLength: number
+		readonly requestTimeoutMs: number
+	}
+	readonly getApiKey: () => string | undefined
+}
diff --git a/src/services/autocomplete/providers/OllamaFimHandler.ts b/src/services/autocomplete/providers/OllamaFimHandler.ts
new file mode 100644
index 0000000000..e63bacd31a
--- /dev/null
+++ b/src/services/autocomplete/providers/OllamaFimHandler.ts
@@ -0,0 +1,220 @@
+import { z } from "zod"
+
+import type { AutocompleteModelSummary, AutocompleteValidationResult } from "@roo-code/types"
+
+import type { FimCompletionHandler, FimRequest } from "./FimCompletionHandler"
+import { readNdjson } from "../stream/streamReaders"
+
+/** Ollama `/api/generate` streaming chunk. */
+const generateChunkSchema = z.object({
+	response: z.string().optional(),
+	done: z.boolean().optional(),
+	error: z.string().optional(),
+})
+
+const tagsSchema = z.object({
+	models: z
+		.array(
+			z.object({
+				name: z.string(),
+				model: z.string().optional(),
+				capabilities: z.array(z.string()).optional(),
+				details: z
+					.object({
+						parameter_size: z.string().optional(),
+						family: z.string().optional(),
+					})
+					.optional(),
+			}),
+		)
+		.optional(),
+})
+
+/** `does not support insert` appears when the model has no native FIM training. */
+const NO_FIM_ERROR = /does not support insert/i
+
+/**
+ * Ollama FIM handler. Uses raw `fetch` (not the `ollama` npm SDK, whose
+ * `Ollama.abort()` cancels *every* in-flight stream on the client instance —
+ * breaking per-keystroke cancellation).
+ *
+ * Sends `{ model, prompt, suffix, stream: true, options: {...} }` to
+ * `POST {base}/api/generate`. On a `400 "does not support insert"`, retries once
+ * with `{ prompt: renderedPrompt, raw: true }` and memoises the degraded mode
+ * per `(baseUrl, modelId)` so subsequent keystrokes skip the wasted first request.
+ */
+export class OllamaFimHandler implements FimCompletionHandler {
+	readonly id = "ollama" as const
+	readonly usesNativeFim = true
+	readonly supportsStreaming = true
+
+	/** Degraded-mode memo: `${baseUrl}|${modelId}` → needs raw-prompt fallback. */
+	private readonly degraded = new Set()
+
+	async *streamFim(request: FimRequest): AsyncGenerator {
+		const cacheKey = `${request.baseUrl}|${request.modelId}`
+		// A non-FIM model (instruct/none template) always takes the raw rendered
+		// prompt — Ollama accepts `suffix` for such models without erroring, and
+		// the model then free-runs into prose.
+		const degraded = !request.supportsFim || this.degraded.has(cacheKey)
+
+		const body = degraded ? this.rawBody(request) : this.fimBody(request)
+
+		const response = await this.fetchGenerate(request, body)
+
+		if (!response.ok) {
+			const errorText = await this.safeReadText(response)
+
+			// First-time 400 "does not support insert": retry once with the rendered
+			// prompt. Guarded on `supportsFim` so a request that already sent the raw
+			// prompt cannot recurse.
+			if (request.supportsFim && !degraded && response.status === 400 && NO_FIM_ERROR.test(errorText)) {
+				this.degraded.add(cacheKey)
+				yield* this.streamFim(request)
+				return
+			}
+
+			throw new Error(`Ollama generate failed (${response.status}): ${errorText}`)
+		}
+
+		if (!response.body) {
+			throw new Error("Ollama returned no response body")
+		}
+
+		yield* this.readGenerateStream(response.body, request.signal)
+	}
+
+	async listModels(signal: AbortSignal): Promise {
+		const response = await fetch(`${this.normalizeBaseUrl()}/api/tags`, { signal })
+
+		if (!response.ok) {
+			throw new Error(`Ollama /api/tags failed (${response.status})`)
+		}
+
+		const parsed = tagsSchema.safeParse(await response.json())
+
+		if (!parsed.success) {
+			return []
+		}
+
+		return (parsed.data.models ?? [])
+			.filter((model) => !model.capabilities || model.capabilities.includes("completion"))
+			.map((model) => ({
+				id: model.name,
+				label: model.name,
+				contextWindow: undefined,
+				supportsFim: true,
+			}))
+	}
+
+	async validate(signal: AbortSignal): Promise {
+		try {
+			const models = await this.listModels(signal)
+			const config = this.options.getConfig()
+
+			if (models.some((model) => model.id === config.modelId)) {
+				return { ok: true, detail: `Model "${config.modelId}" is available on the Ollama server` }
+			}
+
+			return { ok: false, error: `Model "${config.modelId}" was not found on the Ollama server` }
+		} catch (error) {
+			return { ok: false, error: error instanceof Error ? error.message : String(error) }
+		}
+	}
+
+	constructor(
+		private readonly options: {
+			getConfig: () => { readonly modelId?: string; readonly baseUrl: string }
+			getApiKey: () => string | undefined
+		},
+	) {}
+
+	private fimBody(request: FimRequest): string {
+		return JSON.stringify({
+			model: request.modelId,
+			prompt: request.prefix,
+			suffix: request.suffix,
+			stream: true,
+			options: {
+				temperature: request.temperature,
+				num_predict: request.maxOutputTokens,
+				num_ctx: request.contextLength,
+				stop: request.stopSequences,
+			},
+		})
+	}
+
+	private rawBody(request: FimRequest): string {
+		return JSON.stringify({
+			model: request.modelId,
+			prompt: request.renderedPrompt,
+			stream: true,
+			// `raw: true` bypasses the model's chat template, which is correct for a
+			// FIM base model that rejected `suffix` (the rendered prompt already
+			// carries its control tokens) but wrong for an instruction-tuned model,
+			// whose prompt needs the template applied to be understood as a turn.
+			raw: request.supportsFim,
+			options: {
+				temperature: request.temperature,
+				num_predict: request.maxOutputTokens,
+				num_ctx: request.contextLength,
+				stop: request.stopSequences,
+			},
+		})
+	}
+
+	private async fetchGenerate(request: FimRequest, body: string): Promise {
+		const url = `${this.normalizeBaseUrl(request.baseUrl)}/api/generate`
+		const signal = AbortSignal.any([request.signal, AbortSignal.timeout(request.requestTimeoutMs)])
+
+		return fetch(url, {
+			method: "POST",
+			headers: { "Content-Type": "application/json" },
+			body,
+			signal,
+		})
+	}
+
+	private async *readGenerateStream(
+		body: ReadableStream,
+		signal: AbortSignal,
+	): AsyncGenerator {
+		for await (const payload of readNdjson(body, signal)) {
+			const parsed = generateChunkSchema.safeParse(payload)
+
+			if (!parsed.success) {
+				continue
+			}
+
+			if (parsed.data.error) {
+				throw new Error(parsed.data.error)
+			}
+
+			if (parsed.data.response) {
+				yield parsed.data.response
+			}
+
+			if (parsed.data.done) {
+				return
+			}
+		}
+	}
+
+	private normalizeBaseUrl(baseUrl?: string): string {
+		const url = (baseUrl ?? this.options.getConfig().baseUrl).replace(/\/$/, "")
+
+		if (!url.startsWith("http://") && !url.startsWith("https://")) {
+			return `http://${url}`
+		}
+
+		return url
+	}
+
+	private async safeReadText(response: Response): Promise {
+		try {
+			return await response.text()
+		} catch {
+			return response.statusText
+		}
+	}
+}
diff --git a/src/services/autocomplete/providers/OpenAiCompatibleFimHandler.ts b/src/services/autocomplete/providers/OpenAiCompatibleFimHandler.ts
new file mode 100644
index 0000000000..79118f173f
--- /dev/null
+++ b/src/services/autocomplete/providers/OpenAiCompatibleFimHandler.ts
@@ -0,0 +1,252 @@
+import OpenAI from "openai"
+import { z } from "zod"
+
+import type { AutocompleteModelSummary, AutocompleteValidationResult } from "@roo-code/types"
+
+import type { FimCompletionHandler, FimRequest } from "./FimCompletionHandler"
+
+const modelsSchema = z.object({
+	data: z
+		.array(
+			z.object({
+				id: z.string(),
+				object: z.string().optional(),
+			}),
+		)
+		.optional(),
+})
+
+/**
+ * OpenAI-compatible FIM handler for LM Studio / llama.cpp / vLLM.
+ *
+ * Uses the `openai` SDK's `completions.create` with `suffix` (native FIM) and
+ * `maxRetries: 0` — retries are a latency tax we can't afford at a 300 ms budget.
+ * The API key is `"noop"` when unset (LM Studio's convention, mirroring
+ * `lm-studio.ts`).
+ *
+ * llama.cpp ignores `suffix` on `/v1/completions` and returns a 400/422; on that
+ * error we retry once with the rendered prompt and memoise the degraded mode per
+ * baseUrl so subsequent keystrokes skip the wasted first request.
+ */
+export class OpenAiCompatibleFimHandler implements FimCompletionHandler {
+	readonly id = "openai-compatible" as const
+	readonly usesNativeFim = true
+	readonly supportsStreaming = true
+
+	/** Degraded-mode memo: baseUrl → needs rendered-prompt fallback. */
+	private readonly degraded = new Set()
+
+	constructor(
+		private readonly options: {
+			getConfig: () => { readonly modelId?: string; readonly baseUrl: string }
+			getApiKey: () => string | undefined
+		},
+	) {}
+
+	async *streamFim(request: FimRequest): AsyncGenerator {
+		const baseUrl = this.normalizeBaseUrl(request.baseUrl)
+
+		// Instruction-tuned models go through the chat endpoint so the instruction
+		// lives in a system message the model cannot echo back as output.
+		if (request.useChatEndpoint) {
+			yield* this.streamChat(request, baseUrl)
+			return
+		}
+
+		// A non-FIM model (instruct/none template) always takes the rendered prompt,
+		// exactly like a server that rejected `suffix`.
+		const degraded = !request.supportsFim || this.degraded.has(baseUrl)
+
+		const client = this.createClient(baseUrl, request.apiKey, request.requestTimeoutMs)
+
+		try {
+			const stream = await client.completions.create(
+				{
+					model: request.modelId,
+					prompt: degraded ? request.renderedPrompt : request.prefix,
+					suffix: degraded ? undefined : request.suffix,
+					stop: request.stopSequences.slice(0, 4),
+					stream: true,
+					temperature: request.temperature,
+					max_tokens: request.maxOutputTokens,
+				},
+				{ signal: request.signal },
+			)
+
+			for await (const chunk of stream) {
+				const delta = chunk.choices?.[0]?.text
+
+				if (delta) {
+					yield delta
+				}
+			}
+		} catch (error) {
+			if (isAbortError(error)) {
+				return
+			}
+
+			if (isAuthError(error)) {
+				throw new Error(AUTH_ERROR_MESSAGE)
+			}
+
+			// llama.cpp rejects `suffix` with 400/422; retry once with the rendered
+			// prompt. Guarded on `supportsFim` as well as the memo so a non-FIM
+			// request — which already sent the rendered prompt — cannot recurse.
+			if (request.supportsFim && !degraded && isSuffixRejection(error)) {
+				this.degraded.add(baseUrl)
+				yield* this.streamFim(request)
+				return
+			}
+
+			throw error
+		}
+	}
+
+	/**
+	 * Chat-endpoint path for instruction-tuned models.
+	 *
+	 * The system message carries the "code only" instruction; the user message
+	 * carries just the code with a `` marker. Because the server applies
+	 * the model's chat template, the instruction is structurally separate from the
+	 * content and cannot be continued as if it were text — which is exactly the
+	 * failure mode of putting it in a raw `/v1/completions` prompt.
+	 */
+	private async *streamChat(request: FimRequest, baseUrl: string): AsyncGenerator {
+		const client = this.createClient(baseUrl, request.apiKey, request.requestTimeoutMs)
+
+		try {
+			const stream = await client.chat.completions.create(
+				{
+					model: request.modelId,
+					messages: [
+						...(request.systemPrompt ? [{ role: "system" as const, content: request.systemPrompt }] : []),
+						{ role: "user" as const, content: request.renderedPrompt },
+					],
+					stop: request.stopSequences.filter((stop) => stop !== "```").slice(0, 4),
+					stream: true,
+					temperature: request.temperature,
+					max_tokens: request.maxOutputTokens,
+				},
+				{ signal: request.signal },
+			)
+
+			for await (const chunk of stream) {
+				const delta = chunk.choices?.[0]?.delta?.content
+
+				if (delta) {
+					yield delta
+				}
+			}
+		} catch (error) {
+			if (isAbortError(error)) {
+				return
+			}
+
+			if (isAuthError(error)) {
+				throw new Error(AUTH_ERROR_MESSAGE)
+			}
+
+			throw error
+		}
+	}
+
+	async listModels(signal: AbortSignal): Promise {
+		const baseUrl = this.normalizeBaseUrl(this.options.getConfig().baseUrl)
+		const client = this.createClient(baseUrl, this.options.getApiKey(), 15_000)
+
+		const response = await client.models.list({ signal })
+
+		const parsed = modelsSchema.safeParse(response)
+
+		if (!parsed.success) {
+			return []
+		}
+
+		return (parsed.data.data ?? []).map((model) => ({
+			id: model.id,
+			label: model.id,
+			contextWindow: undefined,
+			supportsFim: true,
+		}))
+	}
+
+	async validate(signal: AbortSignal): Promise {
+		try {
+			const models = await this.listModels(signal)
+			const modelId = this.options.getConfig().modelId
+
+			if (!modelId) {
+				return { ok: false, error: "No model selected" }
+			}
+
+			if (models.some((model) => model.id === modelId)) {
+				return { ok: true, detail: `Model "${modelId}" is available on the server` }
+			}
+
+			return { ok: false, error: `Model "${modelId}" was not found on the server` }
+		} catch (error) {
+			return { ok: false, error: error instanceof Error ? error.message : String(error) }
+		}
+	}
+
+	private createClient(baseUrl: string, apiKey: string | undefined, timeoutMs = 5_000): OpenAI {
+		return new OpenAI({
+			baseURL: `${baseUrl}/v1`,
+			apiKey: apiKey || "noop",
+			maxRetries: 0,
+			timeout: timeoutMs,
+		})
+	}
+
+	/**
+	 * Strips trailing slashes and a trailing `/v1`, since {@link createClient}
+	 * appends it. Users legitimately paste either form (`https://ollama.com` and
+	 * `https://ollama.com/v1` are both documented), and without this the latter
+	 * became `/v1/v1` — a 404 on every completion while model listing still worked,
+	 * which is exactly the "connected but never suggests" symptom.
+	 */
+	private normalizeBaseUrl(baseUrl: string): string {
+		const url = baseUrl.trim().replace(/\/+$/, "").replace(/\/v1$/i, "")
+
+		if (!url.startsWith("http://") && !url.startsWith("https://")) {
+			return `http://${url}`
+		}
+
+		return url
+	}
+}
+
+/**
+ * Hosted endpoints reject completions with 401/403 while still serving their
+ * model catalogue publicly (ollama.com does exactly this), so an unauthenticated
+ * setup looks configured but silently produces nothing. Surfacing it as a clear
+ * message is the difference between "broken" and "needs a key".
+ */
+const AUTH_ERROR_MESSAGE = "The endpoint rejected the request: check your API key."
+
+function isAuthError(error: unknown): boolean {
+	const status = (error as { status?: number } | undefined)?.status
+
+	return status === 401 || status === 403
+}
+
+function isAbortError(error: unknown): boolean {
+	return error instanceof Error && (error.name === "AbortError" || (error as { code?: string }).code === "ABORT_ERR")
+}
+
+/** Detects the 400/422 "suffix not supported" rejection from llama.cpp-style servers. */
+function isSuffixRejection(error: unknown): boolean {
+	if (!(error instanceof Error)) {
+		return false
+	}
+
+	const status = (error as { status?: number }).status
+
+	if (status === 400 || status === 422) {
+		return true
+	}
+
+	const message = error.message.toLowerCase()
+
+	return message.includes("suffix") && (message.includes("not support") || message.includes("invalid"))
+}
diff --git a/src/services/autocomplete/stream/StreamPostProcessor.ts b/src/services/autocomplete/stream/StreamPostProcessor.ts
new file mode 100644
index 0000000000..c480d18b8a
--- /dev/null
+++ b/src/services/autocomplete/stream/StreamPostProcessor.ts
@@ -0,0 +1,51 @@
+import type { StreamTransform, TransformContext } from "./transforms"
+
+/**
+ * Runs a stream of raw model output through an ordered list of transforms. Each
+ * transform may pass the chunk through, modify it, or signal the stream to stop.
+ *
+ * The processor is deliberately stateless: it threads the accumulated output
+ * through the transforms so they can detect cross-chunk patterns (stop tokens
+ * straddling a boundary, suffix repetition, echoed lines).
+ */
+export class StreamPostProcessor {
+	constructor(private readonly transforms: readonly StreamTransform[]) {}
+
+	/**
+	 * Feeds the stream through the transform pipeline, yielding the post-processed
+	 * chunks. When any transform signals stop, no further chunks are emitted.
+	 */
+	async *process(
+		stream: AsyncGenerator,
+		context: TransformContext,
+	): AsyncGenerator {
+		let accumulated = ""
+
+		for await (const chunk of stream) {
+			if (chunk.length === 0) {
+				continue
+			}
+
+			let next = chunk
+
+			for (const transform of this.transforms) {
+				const result = transform.onChunk(accumulated, next, context)
+
+				if (result === null) {
+					return
+				}
+
+				if (result.length === 0) {
+					// A transform consumed the chunk to signal a stop boundary; emit
+					// nothing more and end the stream.
+					return
+				}
+
+				next = result
+			}
+
+			accumulated += next
+			yield next
+		}
+	}
+}
diff --git a/src/services/autocomplete/stream/streamReaders.ts b/src/services/autocomplete/stream/streamReaders.ts
new file mode 100644
index 0000000000..542547a541
--- /dev/null
+++ b/src/services/autocomplete/stream/streamReaders.ts
@@ -0,0 +1,202 @@
+/**
+ * Stream readers over `ReadableStream`. Both must:
+ * - `finally { reader.cancel().catch(() => {}) }`
+ * - explicitly swallow `err.name === "AbortError"`
+ * - yield decoded text fragments for the caller to parse (caller uses zod
+ *   `safeParse`, never `JSON.parse(...) as any`).
+ *
+ * These run in the extension host (Node 22) where `ReadableStream`,
+ * `TextDecoder`, and `getReader()` are available on `Response.body`.
+ */
+
+/** Reads a `ReadableStream` as concatenated text, decoding incrementally. */
+export async function* readText(
+	body: ReadableStream,
+	signal: AbortSignal,
+): AsyncGenerator {
+	const reader = body.getReader()
+	const decoder = new TextDecoder("utf-8")
+
+	try {
+		while (true) {
+			const { done, value } = await reader.read()
+
+			if (done) {
+				const tail = decoder.decode()
+
+				if (tail.length > 0) {
+					yield tail
+				}
+
+				return
+			}
+
+			if (value.length === 0) {
+				continue
+			}
+
+			yield decoder.decode(value, { stream: true })
+		}
+	} catch (error) {
+		if (isAbortError(error)) {
+			return
+		}
+
+		throw error
+	} finally {
+		await reader.cancel().catch(() => {})
+		void signal
+	}
+}
+
+/**
+ * Reads a newline-delimited JSON (NDJSON) stream, yielding each decoded line as
+ * `unknown`. Invalid lines (incomplete JSON, empty) are skipped; the caller
+ * validates the shape with a zod schema.
+ */
+export async function* readNdjson(
+	body: ReadableStream,
+	signal: AbortSignal,
+): AsyncGenerator {
+	const reader = body.getReader()
+	const decoder = new TextDecoder("utf-8")
+	let buffer = ""
+
+	try {
+		while (true) {
+			const { done, value } = await reader.read()
+
+			if (done) {
+				// Flush any trailing line without a newline.
+				const tail = buffer.trim()
+
+				if (tail.length > 0) {
+					yield safeParseLine(tail)
+				}
+
+				return
+			}
+
+			buffer += decoder.decode(value, { stream: true })
+
+			let newlineIndex: number
+
+			while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
+				const line = buffer.slice(0, newlineIndex).trim()
+				buffer = buffer.slice(newlineIndex + 1)
+
+				if (line.length > 0) {
+					yield safeParseLine(line)
+				}
+			}
+		}
+	} catch (error) {
+		if (isAbortError(error)) {
+			return
+		}
+
+		throw error
+	} finally {
+		await reader.cancel().catch(() => {})
+		void signal
+	}
+}
+
+/**
+ * Reads a Server-Sent Events (SSE) stream, yielding `{ event?, data }` for each
+ * `data:` field. Lines beginning with `:` (comments) and empty lines are skipped.
+ */
+export async function* readSse(
+	body: ReadableStream,
+	signal: AbortSignal,
+): AsyncGenerator<{ event?: string; data: string }, void, undefined> {
+	const reader = body.getReader()
+	const decoder = new TextDecoder("utf-8")
+	let buffer = ""
+
+	try {
+		while (true) {
+			const { done, value } = await reader.read()
+
+			if (done) {
+				flushSse(buffer)
+				return
+			}
+
+			buffer += decoder.decode(value, { stream: true })
+
+			let blankIndex: number
+
+			while ((blankIndex = buffer.indexOf("\n\n")) !== -1) {
+				const block = buffer.slice(0, blankIndex)
+				buffer = buffer.slice(blankIndex + 2)
+
+				const parsed = parseSseBlock(block)
+
+				if (parsed) {
+					yield parsed
+				}
+			}
+		}
+	} catch (error) {
+		if (isAbortError(error)) {
+			return
+		}
+
+		throw error
+	} finally {
+		await reader.cancel().catch(() => {})
+		void signal
+	}
+}
+
+function flushSse(buffer: string): void {
+	// Only declared so the done-branch mirrors the NDJSON flush; SSE blocks end
+	// with a blank line, but a server may omit the trailing one.
+	void buffer
+}
+
+function parseSseBlock(block: string): { event?: string; data: string } | undefined {
+	let event: string | undefined
+	let data: string | undefined
+
+	for (const line of block.split("\n")) {
+		if (line.startsWith(":") || line.length === 0) {
+			continue
+		}
+
+		const colonIndex = line.indexOf(":")
+
+		if (colonIndex === -1) {
+			continue
+		}
+
+		const field = line.slice(0, colonIndex)
+		const value = line.slice(colonIndex + 1).replace(/^ /, "")
+
+		if (field === "data") {
+			data = data === undefined ? value : `${data}\n${value}`
+		} else if (field === "event") {
+			event = value
+		}
+	}
+
+	if (data === undefined) {
+		return undefined
+	}
+
+	return { event, data }
+}
+
+/** Parses a JSON line; returns the parsed value or a marker object on failure. */
+function safeParseLine(line: string): unknown {
+	try {
+		return JSON.parse(line)
+	} catch {
+		return { __parseError: true, raw: line }
+	}
+}
+
+function isAbortError(error: unknown): boolean {
+	return error instanceof Error && (error.name === "AbortError" || (error as { code?: string }).code === "ABORT_ERR")
+}
diff --git a/src/services/autocomplete/stream/transforms.ts b/src/services/autocomplete/stream/transforms.ts
new file mode 100644
index 0000000000..2ed659cde4
--- /dev/null
+++ b/src/services/autocomplete/stream/transforms.ts
@@ -0,0 +1,358 @@
+/**
+ * Stream transforms applied to raw model output. Each transform inspects the
+ * accumulated output and the incoming chunk, and may:
+ * - modify the chunk to emit (e.g. truncate at a stop token),
+ * - signal the stream should stop (return null),
+ * - pass the chunk through unchanged.
+ *
+ * Phase 2 ships the first four transforms in the documented order:
+ * stopAtStopTokens → filterHallucinatedPathLine → stopAtSuffixRepetition → stopAtSimilarLine.
+ * Phases 5–6 add stopAtLines → balanceBrackets → trimTrailingWhitespace.
+ */
+
+export interface TransformContext {
+	readonly prefix: string
+	readonly suffix: string
+	readonly stopSequences: readonly string[]
+	readonly maxLines: number
+	/**
+	 * True when the reply came from a chat model, whose answer is routinely
+	 * wrapped in a markdown fence. On that path the fence delimits the code
+	 * rather than ending it, and is unwrapped after the stream completes.
+	 */
+	readonly isChatReply?: boolean
+}
+
+export interface StreamTransform {
+	readonly id: string
+	/**
+	 * Called for each chunk with the output accumulated so far.
+	 * @returns the text to emit (possibly modified), or null to stop the stream.
+	 */
+	onChunk(accumulated: string, chunk: string, context: TransformContext): string | null
+}
+
+/**
+ * Stops the stream as soon as any stop sequence appears in the accumulated output,
+ * truncating to before the sequence.
+ */
+export const stopAtStopTokens: StreamTransform = {
+	id: "stopAtStopTokens",
+	onChunk(accumulated, chunk, context) {
+		if (context.stopSequences.length === 0) {
+			return chunk
+		}
+
+		const combined = accumulated + chunk
+
+		for (const stop of context.stopSequences) {
+			const index = combined.indexOf(stop)
+
+			if (index !== -1) {
+				if (index < accumulated.length) {
+					// The stop token straddles the boundary: it started in the
+					// accumulated text, so the chunk only completes it. Emit nothing
+					// new — the accumulated partial is already out.
+					return ""
+				}
+
+				const truncated = combined.slice(0, index)
+				return truncated.slice(accumulated.length)
+			}
+		}
+
+		return chunk
+	},
+}
+
+/**
+ * Drops a "Path: …" / "diff --git" hallucination line the model sometimes emits
+ * at the start of a completion. Stops the stream so nothing after the hallucinated
+ * header is rendered.
+ */
+export const filterHallucinatedPathLine: StreamTransform = {
+	id: "filterHallucinatedPathLine",
+	onChunk(accumulated, chunk) {
+		const combined = accumulated + chunk
+		const lines = combined.split("\n")
+
+		for (let i = 0; i < lines.length; i++) {
+			const line = lines[i]
+
+			if (HALLUCINATED_PATH_REGEX.test(line)) {
+				// Drop everything from this line onward.
+				const kept = lines.slice(0, i).join("\n")
+				return kept.slice(accumulated.length) || ""
+			}
+		}
+
+		return chunk
+	},
+}
+
+/**
+ * Stops the stream once the output begins repeating the suffix. Detects the
+ * longest tail of the accumulated output that is a prefix of the suffix, and
+ * truncates the overlap once it exceeds a threshold.
+ */
+export const stopAtSuffixRepetition: StreamTransform = {
+	id: "stopAtSuffixRepetition",
+	onChunk(accumulated, chunk, context) {
+		if (context.suffix.length === 0) {
+			return chunk
+		}
+
+		const combined = accumulated + chunk
+		const overlap = longestSuffixPrefix(combined, context.suffix)
+
+		if (overlap >= MIN_SUFFIX_OVERLAP) {
+			const truncated = combined.slice(0, combined.length - overlap)
+			return truncated.slice(accumulated.length) || ""
+		}
+
+		return chunk
+	},
+}
+
+/**
+ * Stops the stream when a line in the output matches a line already present in
+ * the prefix or suffix (the model is echoing surrounding code).
+ */
+export const stopAtSimilarLine: StreamTransform = {
+	id: "stopAtSimilarLine",
+	onChunk(accumulated, chunk, context) {
+		const combined = accumulated + chunk
+		const lines = combined.split("\n")
+
+		if (lines.length <= 1) {
+			return chunk
+		}
+
+		const surrounding = new Set([...splitLines(context.prefix), ...splitLines(context.suffix)])
+
+		// Inspect the last complete line (ignore the trailing partial line).
+		const lastComplete = lines.length >= 2 ? lines[lines.length - 2] : null
+
+		if (lastComplete !== null && surrounding.has(lastComplete.trim())) {
+			const kept = lines.slice(0, -2).join("\n")
+			return kept.slice(accumulated.length) || ""
+		}
+
+		return chunk
+	},
+}
+
+/**
+ * Stops the stream at the first reasoning-block opener or markdown fence.
+ *
+ * Hybrid-reasoning models (LFM2.5, Qwen3, DeepSeek-R1) and instruction-tuned
+ * models emit `` blocks and ```-fenced code even when told not to.
+ * Stop sequences catch these only when the opener arrives as a clean token
+ * boundary; a chunk of `foo` slips past. This inspects the accumulated
+ * text, so boundary placement is irrelevant.
+ *
+ * Everything from the opener onward is discarded — a completion that has started
+ * narrating is not recoverable, and rendering half of it as ghost text is worse
+ * than rendering nothing.
+ */
+export const stopAtReasoningBlock: StreamTransform = {
+	id: "stopAtReasoningBlock",
+	onChunk(accumulated, chunk, context) {
+		const combined = accumulated + chunk
+		const index = combined.search(context.isChatReply ? REASONING_OPENER_NO_FENCE_REGEX : REASONING_OPENER_REGEX)
+
+		if (index === -1) {
+			return chunk
+		}
+
+		if (index < accumulated.length) {
+			// The opener is already (partly) emitted; nothing further may pass.
+			return ""
+		}
+
+		return combined.slice(accumulated.length, index)
+	},
+}
+
+/**
+ * Stops the stream at a line of prose.
+ *
+ * An instruct model that ignores the "code only" instruction typically breaks
+ * into English on its own line ("This code calculates…", "Note that…"). A line
+ * that has no code punctuation, starts with a capital letter and reads as a
+ * sentence is treated as the end of the completion.
+ *
+ * Deliberately conservative: it only fires on a *complete* line, never on the
+ * first line (which is legitimately a code continuation), and never inside a
+ * string or comment continuation, so real code is not truncated.
+ */
+export const stopAtProseLine: StreamTransform = {
+	id: "stopAtProseLine",
+	onChunk(accumulated, chunk) {
+		const combined = accumulated + chunk
+		const lines = combined.split("\n")
+
+		// Only inspect complete lines, and never the first (it continues the cursor line).
+		for (let i = 1; i < lines.length - 1; i++) {
+			if (isProseLine(lines[i])) {
+				const kept = lines.slice(0, i).join("\n")
+
+				if (kept.length <= accumulated.length) {
+					return ""
+				}
+
+				return kept.slice(accumulated.length)
+			}
+		}
+
+		return chunk
+	},
+}
+
+/** Caps the completion at `context.maxLines` lines. */
+export const stopAtLines: StreamTransform = {
+	id: "stopAtLines",
+	onChunk(accumulated, chunk, context) {
+		if (context.maxLines <= 0) {
+			return chunk
+		}
+
+		const combined = accumulated + chunk
+		const lines = combined.split("\n")
+
+		if (lines.length <= context.maxLines) {
+			return chunk
+		}
+
+		const kept = lines.slice(0, context.maxLines).join("\n")
+
+		if (kept.length <= accumulated.length) {
+			return ""
+		}
+
+		return kept.slice(accumulated.length)
+	},
+}
+
+/**
+ * The transforms in the documented order.
+ *
+ * Order matters: reasoning/fence detection runs before the echo detectors so a
+ * narrating completion is cut at the narration rather than at whichever echoed
+ * line happens to appear first, and the line cap runs last so it applies to
+ * whatever survived.
+ */
+/**
+ * Stops a model that has fallen into a degenerate repetition loop.
+ *
+ * Small models under a greedy sampler get stuck emitting the same short token
+ * run forever (`1616161616…`). The stop sequences never fire because the run
+ * contains no stop token, and the line cap never fires because it is all one
+ * line — so this is the only thing that ends such a stream.
+ */
+export const stopAtRepetitionLoop: StreamTransform = {
+	id: "stopAtRepetitionLoop",
+	onChunk(accumulated, chunk) {
+		const combined = accumulated + chunk
+
+		for (let unit = 1; unit <= REPETITION_MAX_UNIT; unit++) {
+			const span = unit * REPETITION_MIN_REPEATS
+
+			if (combined.length < span) {
+				break
+			}
+
+			const candidate = combined.slice(-unit)
+
+			// Three consecutive repeats of the same unit: ordinary code effectively
+			// never does this, whereas a looping model does it indefinitely.
+			if (candidate.repeat(REPETITION_MIN_REPEATS) === combined.slice(-span)) {
+				// Keep one copy; drop the repeats that follow it.
+				const cut = combined.length - span + unit
+
+				return cut <= accumulated.length ? "" : combined.slice(accumulated.length, cut)
+			}
+		}
+
+		return chunk
+	},
+}
+
+export const DEFAULT_TRANSFORMS: readonly StreamTransform[] = [
+	stopAtStopTokens,
+	stopAtReasoningBlock,
+	stopAtRepetitionLoop,
+	filterHallucinatedPathLine,
+	stopAtSuffixRepetition,
+	stopAtSimilarLine,
+	stopAtProseLine,
+	stopAtLines,
+]
+
+/** Reasoning-block openers, their closers, and markdown fences. */
+const REASONING_OPENER_REGEX =
+	/<\/?(?:think|thinking|reasoning|reflection|analysis)\b[^>]*>|```|^\s*(?:Here'?s|Here is|This (?:code|function|snippet)|Note that|Explanation:)/im
+
+/** As above, minus the fence: used for chat replies, where a fence wraps the code. */
+const REASONING_OPENER_NO_FENCE_REGEX =
+	/<\/?(?:think|thinking|reasoning|reflection|analysis)\b[^>]*>|^\s*(?:Here'?s|Here is|This (?:code|function|snippet)|Note that|Explanation:)/im
+
+/** Characters that mark a line as code rather than prose. */
+const CODE_PUNCTUATION = /[{}()[\];=<>+*/%&|!~^]|:\s*$|,\s*$|\.\w|=>|->|::/
+
+function isProseLine(line: string): boolean {
+	const trimmed = line.trim()
+
+	if (trimmed.length === 0) {
+		return false
+	}
+
+	// Comments are legitimate completion output.
+	if (/^(\/\/|#|\*|\/\*|--|