From fe60a467d1e102b0580ca8d7515404b96683e4d7 Mon Sep 17 00:00:00 2001 From: Firas Trabelsi Date: Tue, 26 May 2026 09:17:53 -0700 Subject: [PATCH 01/33] Add Mercury Next Edit (NES) routed through the kilo gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @markijbema's architectural feedback on #10536: moves the HTTP edit-completion call to the gateway, removes the standalone API-key setting, and aligns with the provider/model selection design introduced in #10559. Wire-level changes ------------------ * New `/kilo/edit` endpoint added to the opencode HttpApi contract (`packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts`) and mirrored into `packages/kilo-gateway/src/server/routes.ts` for the hono surface. SDK regenerated; `client.kilo.edit({ content, provider, model, maxTokens })` is now available. * `packages/kilo-gateway/src/edit.ts` — `EditTarget` resolver mirroring the FIM pattern. Only the Inception provider is wired today (Mistral doesn't expose a comparable surface); Kilo Gateway has a placeholder branch that returns 400 until a server-side proxy exists. * `packages/kilo-gateway/src/server/edit.ts` — `createEditHandler` reads the Inception BYOK key from `Auth.get("inception")` and falls back to `INCEPTION_API_KEY` from env, exactly like the FIM handler. * The gateway unwraps Mercury's triple-backtick fence (and any `<|code_to_edit|>` sentinels) server-side so the VSCode response is just the rewritten code. * `AutocompleteModelDef` gains an optional `kind: "fim" | "edit"` discriminator; new entry `inception/mercury-next-edit` (label "Mercury Next Edit") sets `kind: "edit"` and shares the wire model `mercury-edit-2`. VSCode-side ----------- * `MercuryEditProvider` no longer does its own HTTP — it's now a thin wrapper around `client.kilo.edit(...)` via `KiloConnectionService`. * Dropped `kilo-code.new.autocomplete.nextEdit.apiKey` and `.nextEdit.baseUrl` settings. Auth and routing live in the gateway. * The `AutocompleteServiceManager` dispatch now switches on the model's `kind` field (set by `getAutocompleteModel(provider, model)`) instead of string-comparing a model id, matching Mark's provider+model split. * The `NextEditInlineCompletionProvider`, `NextEditSuggestionManager`, prompt template, parser, editable-region selector, edit-history tracker, recently-viewed-snippets adapter, and decoration-based jump-to-edit UX remain in the VSCode extension since they need editor-specific APIs (`InlineCompletionItem`, `TextEditorDecorationType`, keybinding context keys). Bot review nits resolved ------------------------ * `INLINE_COMPLETION_ACCEPTED_COMMAND` renamed `kilocode.*` → `kilo-code.*` to match the project convention. * `kilo-code.next-edit.acceptOrJump` and `.dismiss` now declared in `contributes.commands` so VS Code can resolve them in the palette. * `disposeLog()` wired into `AutocompleteServiceManager.dispose()` so the dedicated "Kilo Code · Next Edit" OutputChannel doesn't leak. * Per-keystroke "skip — no API key resolved" log removed (the entire API-key code path is gone). Tests ----- * `bun run check-types:extension` clean * `bun run lint src` clean * `bun test src/services/autocomplete/next-edit/__tests__/` — 23/23 pass Docs ---- * The partner walkthrough at `packages/kilo-vscode/docs/mercury-next-edit-testing.html` and the 20-test playground under `packages/kilo-vscode/docs/nes-examples/` survive from the prior iteration. The walkthrough's "Install the PR locally" section still applies (the model dropdown choice is now "Mercury Next Edit (Inception)" — the API-key step is gone since BYOK is plumbed through the gateway's Auth store). Known follow-ups (not in this commit) ------------------------------------- * `FileIgnoreController` plumbing through the NES context builder so `.env`-style files don't get sent. Hook point identified in `NextEditInlineCompletionProvider.buildRequestContext`. * Settings UI changes in the webview to expose Mercury Next Edit as a selectable provider/model pair alongside the FIM entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kilo-gateway/package.json | 1 + packages/kilo-gateway/src/autocomplete.ts | 20 + packages/kilo-gateway/src/edit.ts | 32 + packages/kilo-gateway/src/server/edit.ts | 113 +++ packages/kilo-gateway/src/server/routes.ts | 42 + .../docs/mercury-next-edit-testing.html | 790 ++++++++++++++++++ .../nes-examples/01_finish_function_body.py | 4 + .../nes-examples/02_pattern_continuation.py | 3 + .../docs/nes-examples/03_typo_completion.py | 5 + .../docs/nes-examples/04_loop_body.py | 5 + .../docs/nes-examples/05_class_method.py | 12 + .../07_multiline_rename_refactor.py | 12 + .../08_mixed_insert_and_replace.py | 4 + .../nes-examples/10_mid_token_completion.py | 7 + .../nes-examples/11_fill_sibling_method.py | 21 + .../12_type_annotation_insertion.py | 10 + .../nes-examples/13_docstring_generation.py | 11 + .../docs/nes-examples/14_no_op_suppression.py | 13 + .../docs/nes-examples/INSTRUCTIONS.md | 201 +++++ .../docs/nes-examples/go_07_error_handling.go | 21 + .../docs/nes-examples/go_08_struct_method.go | 22 + .../nes-examples/go_09_goroutine_channel.go | 15 + .../docs/nes-examples/js_07_async_await.js | 15 + .../docs/nes-examples/js_08_express_route.js | 22 + .../docs/nes-examples/md_07_prose_negative.md | 10 + .../docs/nes-examples/rs_07_match_arms.rs | 25 + .../docs/nes-examples/rs_08_result_chain.rs | 14 + .../docs/nes-examples/rs_09_lifetimes.rs | 14 + .../docs/nes-examples/sql_07_join.sql | 9 + .../docs/nes-examples/sql_08_where_filter.sql | 4 + .../nes-examples/ts_07_array_transform.ts | 17 + .../docs/nes-examples/ts_08_param_types.ts | 19 + .../docs/nes-examples/ts_09_jsx_handler.tsx | 20 + packages/kilo-vscode/package.json | 20 + .../AutocompleteServiceManager.ts | 75 +- .../AutocompleteInlineCompletionProvider.ts | 2 +- .../src/services/autocomplete/index.ts | 28 + .../next-edit/MercuryEditProvider.ts | 94 +++ .../NextEditInlineCompletionProvider.ts | 335 ++++++++ .../next-edit/NextEditSuggestionManager.ts | 334 ++++++++ .../__tests__/editCompletionParser.spec.ts | 26 + .../__tests__/editableRegion.spec.ts | 37 + .../__tests__/mercuryPromptTemplate.spec.ts | 125 +++ .../__tests__/recentSnippetsAdapter.spec.ts | 39 + .../autocomplete/next-edit/constants.ts | 37 + .../next-edit/editCompletionParser.ts | 33 + .../next-edit/editHistoryTracker.ts | 106 +++ .../autocomplete/next-edit/editableRegion.ts | 43 + .../services/autocomplete/next-edit/log.ts | 39 + .../next-edit/mercuryPromptTemplate.ts | 95 +++ .../next-edit/recentSnippetsAdapter.ts | 45 + .../services/autocomplete/next-edit/types.ts | 26 + .../server/httpapi/groups/kilo-gateway.ts | 36 + .../server/httpapi/handlers/kilo-gateway.ts | 80 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 45 + packages/sdk/js/src/v2/gen/types.gen.ts | 241 +++--- 56 files changed, 3364 insertions(+), 110 deletions(-) create mode 100644 packages/kilo-gateway/src/edit.ts create mode 100644 packages/kilo-gateway/src/server/edit.ts create mode 100644 packages/kilo-vscode/docs/mercury-next-edit-testing.html create mode 100644 packages/kilo-vscode/docs/nes-examples/01_finish_function_body.py create mode 100644 packages/kilo-vscode/docs/nes-examples/02_pattern_continuation.py create mode 100644 packages/kilo-vscode/docs/nes-examples/03_typo_completion.py create mode 100644 packages/kilo-vscode/docs/nes-examples/04_loop_body.py create mode 100644 packages/kilo-vscode/docs/nes-examples/05_class_method.py create mode 100644 packages/kilo-vscode/docs/nes-examples/07_multiline_rename_refactor.py create mode 100644 packages/kilo-vscode/docs/nes-examples/08_mixed_insert_and_replace.py create mode 100644 packages/kilo-vscode/docs/nes-examples/10_mid_token_completion.py create mode 100644 packages/kilo-vscode/docs/nes-examples/11_fill_sibling_method.py create mode 100644 packages/kilo-vscode/docs/nes-examples/12_type_annotation_insertion.py create mode 100644 packages/kilo-vscode/docs/nes-examples/13_docstring_generation.py create mode 100644 packages/kilo-vscode/docs/nes-examples/14_no_op_suppression.py create mode 100644 packages/kilo-vscode/docs/nes-examples/INSTRUCTIONS.md create mode 100644 packages/kilo-vscode/docs/nes-examples/go_07_error_handling.go create mode 100644 packages/kilo-vscode/docs/nes-examples/go_08_struct_method.go create mode 100644 packages/kilo-vscode/docs/nes-examples/go_09_goroutine_channel.go create mode 100644 packages/kilo-vscode/docs/nes-examples/js_07_async_await.js create mode 100644 packages/kilo-vscode/docs/nes-examples/js_08_express_route.js create mode 100644 packages/kilo-vscode/docs/nes-examples/md_07_prose_negative.md create mode 100644 packages/kilo-vscode/docs/nes-examples/rs_07_match_arms.rs create mode 100644 packages/kilo-vscode/docs/nes-examples/rs_08_result_chain.rs create mode 100644 packages/kilo-vscode/docs/nes-examples/rs_09_lifetimes.rs create mode 100644 packages/kilo-vscode/docs/nes-examples/sql_07_join.sql create mode 100644 packages/kilo-vscode/docs/nes-examples/sql_08_where_filter.sql create mode 100644 packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts create mode 100644 packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts create mode 100644 packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editCompletionParser.spec.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editableRegion.spec.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/mercuryPromptTemplate.spec.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/recentSnippetsAdapter.spec.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/editCompletionParser.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/editableRegion.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/mercuryPromptTemplate.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/types.ts diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 8520685d83..2412a4f876 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -19,6 +19,7 @@ ".": "./src/index.ts", "./autocomplete": "./src/autocomplete.ts", "./fim": "./src/fim.ts", + "./edit": "./src/edit.ts", "./tui": "./src/tui.ts" }, "files": [ diff --git a/packages/kilo-gateway/src/autocomplete.ts b/packages/kilo-gateway/src/autocomplete.ts index 561da4b09e..451eb91dd9 100644 --- a/packages/kilo-gateway/src/autocomplete.ts +++ b/packages/kilo-gateway/src/autocomplete.ts @@ -18,6 +18,12 @@ export interface AutocompleteModelDef { readonly directProvider?: DirectAutocompleteProviderID /** FIM request temperature. */ readonly temperature: number + /** + * Which gateway endpoint this model targets. Defaults to "fim" if omitted + * (back-compat with existing entries). Models with `kind: "edit"` route + * through `/kilo/edit` and use Mercury's Next Edit pipeline. + */ + readonly kind?: "fim" | "edit" } const models: AutocompleteModelDef[] = [ @@ -59,6 +65,20 @@ const models: AutocompleteModelDef[] = [ directProvider: "inception", temperature: 0, }, + { + // Same wire-level model as `mercury-edit-2`, but routed through the + // Mercury Next Edit endpoint instead of FIM. Picked by users who want + // multi-line next-edit predictions with the jump-to-edit UX. + id: "inception/mercury-next-edit", + modelID: "mercury-next-edit", + label: "Mercury Next Edit", + providerID: "inception", + provider: "Inception", + requestModel: "mercury-edit-2", + directProvider: "inception", + temperature: 0, + kind: "edit", + }, ] export const AUTOCOMPLETE_MODELS: readonly AutocompleteModelDef[] = models diff --git a/packages/kilo-gateway/src/edit.ts b/packages/kilo-gateway/src/edit.ts new file mode 100644 index 0000000000..a244107f79 --- /dev/null +++ b/packages/kilo-gateway/src/edit.ts @@ -0,0 +1,32 @@ +import { getAutocompleteModel, type DirectAutocompleteProviderID } from "./autocomplete.js" + +/** + * Env var(s) consulted as a fallback for BYOK keys when the provider hasn't + * been authenticated via the gateway's Auth store. Mirrors `DIRECT_FIM_ENV`. + */ +export const DIRECT_EDIT_ENV: Record = { + mistral: ["MISTRAL_API_KEY"], + inception: ["INCEPTION_API_KEY"], +} + +export type EditTarget = + | { provider: "inception"; model: string; url: string } + | { provider: "kilo"; model: string; url: string } + +const INCEPTION_EDIT_URL = "https://api.inceptionlabs.ai/v1/edit/completions" + +/** + * Pick the upstream edit endpoint for a (provider, model) pair. Only Inception + * is wired up today — Mercury is the only model family with a documented + * /v1/edit/completions endpoint. Mistral does not expose a comparable surface. + */ +export function resolveEditTarget(provider?: string, model?: string): EditTarget { + const info = getAutocompleteModel(provider, model) + if (info.directProvider === "inception") { + return { provider: "inception", model: info.requestModel, url: INCEPTION_EDIT_URL } + } + // Kilo Gateway does not currently proxy an edit endpoint; callers should + // fall back to FIM. We still return a kilo target so the handler can surface + // a 400 rather than silently routing somewhere unexpected. + return { provider: "kilo", model: info.requestModel, url: "" } +} diff --git a/packages/kilo-gateway/src/server/edit.ts b/packages/kilo-gateway/src/server/edit.ts new file mode 100644 index 0000000000..eaea04e012 --- /dev/null +++ b/packages/kilo-gateway/src/server/edit.ts @@ -0,0 +1,113 @@ +import { DIRECT_EDIT_ENV, resolveEditTarget, type EditTarget } from "../edit.js" +import type { AuthStore } from "./handlers.js" + +type Auth = Pick + +const EDIT_TIMEOUT_MS = 30_000 +const MAX_TOKENS_DEFAULT = 512 + +async function getProviderKey(Auth: Auth, provider: "inception" | "mistral"): Promise { + const auth = await Auth.get(provider) + if (auth?.type === "api") return auth.key + return DIRECT_EDIT_ENV[provider].map((key) => process.env[key]).find(Boolean) +} + +/** + * Extract the rewritten code from Mercury's reply. Mercury always wraps the + * editable region in a triple-backtick fence, sometimes with a language tag + * and sometimes with `<|code_to_edit|>` markers inside. Mirrors the parser the + * VSCode side used to run; doing it gateway-side keeps the Mercury contract + * in one place. + */ +function extractFencedBody(message: string): string { + if (!message) return "" + const fenceOpen = message.indexOf("```") + if (fenceOpen === -1) return message + const afterFenceOpen = message.indexOf("\n", fenceOpen + 3) + if (afterFenceOpen === -1) return "" + const fenceClose = message.lastIndexOf("```") + if (fenceClose <= afterFenceOpen) return "" + let body = message.slice(afterFenceOpen + 1, fenceClose) + if (body.endsWith("\n")) body = body.slice(0, -1) + body = body.replace(/^<\|code_to_edit\|>\n?/, "") + body = body.replace(/\n?<\|\/code_to_edit\|>$/, "") + return body +} + +interface UpstreamResponse { + choices?: Array<{ message?: { content?: string } }> + usage?: { prompt_tokens?: number; completion_tokens?: number } +} + +export function createEditHandler(Auth: Auth) { + return async (c: any) => { + const { content, provider, model, maxTokens } = c.req.valid("json") + const target = resolveEditTarget(provider, model) + + if (target.provider !== "inception") { + return c.json({ error: "Next Edit currently requires the Inception provider (mercury-edit-2)." }, 400 as any) + } + + const token = await getProviderKey(Auth, target.provider) + if (!token) { + return c.json({ error: `Missing ${target.provider} provider API key` }, 401 as any) + } + + const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(EDIT_TIMEOUT_MS)]) + console.info(`[EDIT] request provider=${target.provider} model=${target.model} url=${target.url} chars=${content.length}`) + + let response: Response + try { + response = await fetch(target.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + signal, + body: JSON.stringify({ + model: target.model, + max_tokens: maxTokens ?? MAX_TOKENS_DEFAULT, + // Mercury rejects role:"system" on this endpoint — must be a single + // user message. See the integration's constants.ts for context. + messages: [{ role: "user", content }], + }), + }) + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + return c.json({ error: "Edit request timed out" }, 504 as any) + } + if (signal.aborted) return c.json({ error: "Edit request canceled" }, 499 as any) + throw err + } + + if (!response.ok) { + const text = await safeText(response) + return c.json({ error: `Edit request failed: ${response.status} ${text}` }, response.status as any) + } + + const json = (await response.json()) as UpstreamResponse + const replyContent = json.choices?.[0]?.message?.content ?? "" + const body = extractFencedBody(replyContent) + return c.json({ + content: body, + usage: json.usage + ? { + prompt_tokens: json.usage.prompt_tokens, + completion_tokens: json.usage.completion_tokens, + } + : undefined, + }) + } +} + +async function safeText(res: Response): Promise { + try { + return await res.text() + } catch { + return "" + } +} + +// Re-export the target type for tests + the opencode handler +export type { EditTarget } diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 6b21154591..8e27aac021 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -11,6 +11,7 @@ import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/con import { buildKiloHeaders } from "../headers.js" import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js" import { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "../cloud-sessions.js" +import { createEditHandler } from "./edit.js" import { createFimHandler } from "./fim.js" import { GatewayError, @@ -112,6 +113,16 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { currentOrgId: z.string().nullable(), }) + const EditCompletionResponse = z.object({ + content: z.string(), + usage: z + .object({ + prompt_tokens: z.number().optional(), + completion_tokens: z.number().optional(), + }) + .optional(), + }) + const FimStreamChunk = z.object({ choices: z .array( @@ -325,6 +336,37 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { ), createFimHandler(Auth), ) + .post( + "/edit", + describeRoute({ + summary: "Next Edit completion", + description: + "Proxy a Mercury-style Next Edit request. The user supplies the already-templated " + + "sentinel-tagged prompt in `content`; the gateway forwards to the upstream edit endpoint.", + operationId: "kilo.edit", + responses: { + 200: { + description: "Next Edit completion", + content: { + "application/json": { + schema: resolver(EditCompletionResponse), + }, + }, + }, + ...errors(400, 401), + }, + }), + validator( + "json", + z.object({ + content: z.string(), + provider: z.string().optional(), + model: z.string().optional(), + maxTokens: z.number().optional(), + }), + ), + createEditHandler(Auth), + ) .post( "/audio/transcriptions", describeRoute({ diff --git a/packages/kilo-vscode/docs/mercury-next-edit-testing.html b/packages/kilo-vscode/docs/mercury-next-edit-testing.html new file mode 100644 index 0000000000..ba66680952 --- /dev/null +++ b/packages/kilo-vscode/docs/mercury-next-edit-testing.html @@ -0,0 +1,790 @@ + + + + +Mercury Next Edit — Testing Playground (Kilo Code) + + + + +
+ +

Mercury Next Edit — Testing Playground

+

A walk-through guide for Kilo Code reviewers to validate the new Next Edit Suggestion integration powered by Mercury Edit 2 from Inception Labs.

+ +
+

On this page

+ +
+ +

What is Mercury Next Edit?

+

Mercury Edit 2 is a code-edit model from Inception Labs. Unlike FIM completion, it predicts the user's next multi-line edit given the current file, cursor position, and recent edit history. It typically responds in under 250 ms.

+

This PR adds Mercury Next Edit as a new, opt-in autocomplete option in Kilo Code. It lives alongside everything that's already shipping — Codestral FIM and Mercury Edit 2 via the Kilo gateway are bit-for-bit unchanged. Selecting Mercury Next Edit (Inception) from the model dropdown switches to a separate render pipeline:

+
    +
  • Same-line predictions render as inline ghost text (just like FIM — Tab accepts).
  • +
  • Off-cursor predictions render as a decoration (red strikethrough + green ghost annotation) at the predicted edit location. First Tab teleports the cursor there; second Tab applies.
  • +
  • After any accept, the integration immediately re-triggers Mercury so the user can walk a refactor with repeated Tab presses ("Tab-Tab-Tab").
  • +
+ +

Install the PR locally

+

You'll need to pull this PR's branch and run the extension in a development VSCode window ("Extension Development Host"). The whole loop is about 3 minutes once you have the prerequisites.

+ +

Prerequisites

+
    +
  • VSCode ≥ 1.105.1 (matches kilocode's engines.vscode)
  • +
  • Bun ≥ 1.3.13 (the build script checks the version) — install via brew install bun or bun.sh
  • +
  • GitHub CLI (gh) — optional but makes the PR checkout one command
  • +
  • An Inception API key — create one at platform.inceptionlabs.ai if you don't already have one
  • +
+ +

1. Check out the PR branch

+

From an empty directory:

+
gh repo clone Kilo-Org/kilocode
+cd kilocode
+gh pr checkout 10536
+

Or without gh:

+
git clone https://github.com/Kilo-Org/kilocode.git
+cd kilocode
+git fetch origin pull/10536/head:mercury-next-edit-integration
+git checkout mercury-next-edit-integration
+ +

2. Install dependencies

+
bun install
+

(First install pulls the full monorepo — takes 30–60 seconds.)

+ +

3. Start the dev build

+
cd packages/kilo-vscode
+bun run watch
+

Leave that terminal running. It rebuilds the extension on every save and runs the TypeScript compiler in watch mode.

+ +

4. Open kilocode in VSCode and launch the Extension Development Host

+

From a separate terminal (or your IDE launcher):

+
code /path/to/kilocode
+

Inside that VSCode window, press F5 (or Run → Start Debugging). A second VSCode window opens, titled [Extension Development Host]. That window has this PR's build of the kilocode extension loaded.

+ +
+

Pre-push turbo typecheck may fail on packages that need Java (JetBrains plugin). That's environment, not code — not relevant to the NES feature. Use --no-verify on any local pushes if you hit it.

+
+ +

5. Open the test playground

+

In the Dev Host window: File → Open Folder… → choose packages/kilo-vscode/docs/nes-examples/ inside this same repo. That gives you the 20 self-contained test files described below.

+ +

6. Configure NES

+

Settings (Cmd+,) in the Dev Host, search kilo-code.new.autocomplete:

+
    +
  • modelMercury Next Edit (Inception)not "Mercury Edit 2", which is the classic FIM-via-gateway option
  • +
  • nextEdit.apiKey → paste your sk_… Inception API key (or set INCEPTION_API_KEY env before launching)
  • +
  • enableAutoTrigger → ✓ (already the default)
  • +
+ +

7. Watch the pipeline live

+

In the Dev Host: View → Output → in the dropdown, select "Kilo Code · Next Edit". Every request, response, and render decision is logged here with timestamps. Keep this panel visible while testing — it's the single best diagnostic.

+ +

You're set. Skip to the test cases below.

+ +

Enabling the feature (settings reference)

+

In VSCode Settings (Cmd+,), search kilo-code.new.autocomplete:

+ + + + + + + +
SettingValue
modelMercury Next Edit (Inception)not "Mercury Edit 2", which is the original FIM-via-gateway option
nextEdit.apiKeyyour Inception API key (sk_...); also accepts INCEPTION_API_KEY env var
enableAutoTrigger✓ (default)
nextEdit.baseUrl(optional) override the API base, defaults to https://api.inceptionlabs.ai/v1
nextEdit.debug(optional) mirror diagnostic logs to DevTools console
+

To watch the pipeline live: View → Output in the Dev Host, choose the "Kilo Code · Next Edit" channel.

+ +

How the integration works

+

The AutocompleteServiceManager instantiates both providers up front. Provider registration with vscode.languages.registerInlineCompletionItemProvider is driven by the configured model:

+
    +
  • inception/mercury-next-editNES provider (this PR's new pipeline)
  • +
  • anything else → classic FIM provider (unchanged)
  • +
+

The NES provider, per keystroke:

+
    +
  1. Debounces 250 ms (skipped for explicit invocations).
  2. +
  3. Builds a Mercury prompt: current file + cursor + an editable region [cursor − 5, cursor + 10] + 3–5 recently-viewed-snippet ranges (from the shared RecentlyVisitedRangesService) + the last 5 debounced unidiffs (from a new per-file EditHistoryTracker).
  4. +
  5. Sends a single role: "user" message to POST /v1/edit/completions with max_tokens: 512.
  6. +
  7. Parses the triple-backtick fenced reply, strips Mercury's sentinel tokens, computes the minimal line-diff against the current document.
  8. +
  9. Branches: same-line diff → InlineCompletionItem; off-cursor diff → NextEditSuggestionManager with a decoration + Tab/Esc keybinding gated on a context flag (kilo-code.nextEdit.hasPendingSuggestion).
  10. +
+ +

Test cases

+

Each test below is a self-contained file at packages/kilo-vscode/docs/nes-examples/ in this repo. Open that folder in the Extension Development Host (step 5 above), then work through the cases. Place your cursor where indicated, wait ~300 ms idle, and observe.

+

Tip: keep this page open in a separate window from the Dev Host — the descriptions below would otherwise leak into Mercury's prompt context and bias the test.

+ +

Render-path legend:

+

+ same-line ghost appears as inline ghost text at the cursor (Tab accepts) · + off-cursor decoration renders away from the cursor; first Tab jumps, second Tab applies · + suppressed negative case — nothing should render +

+ +

Python — core tests

+ +
+

01 — Finish a recursive function body same-line

+
def factorial(n):
+    if n <= 1:
+        return 1
+
+
+
Cursor
+

The empty indented line at the end of factorial (column 4).

+
Expected
+

Ghost text proposing the recursive case (e.g. return n * factorial(n - 1)). Tab accepts.

+
+ +
+

02 — Pattern continuation same-line

+
COLOR_RED = "#ff0000"
+COLOR_GREEN = "#00ff00"
+COLOR_BLUE =
+
Cursor
+

End of line 3 (right after =).

+
Expected
+

Ghost text appending a hex color like "#0000ff".

+
+ +
+

03 — Mid-identifier completion same-line

+
def calculate_total(items):
+    total = 0
+    for item in items:
+        total += item.price
+    return tot
+
Cursor
+

End of file (after return tot).

+
Expected
+

Ghost text completing the identifier (likely altotal).

+
+ +
+

04 — Loop body inference same-line

+
def calculate_total(items):
+    total = 0
+    for item in items:
+
+    return total
+
Cursor
+

The empty indented line inside the for loop (column 8).

+
Expected
+

Ghost text proposing the accumulator update.

+
+ +
+

05 — Sibling method body same-line

+
class Stack:
+    def __init__(self):
+        self.items = []
+
+    def push(self, item):
+        self.items.append(item)
+
+    def pop(self):
+
+
+    def peek(self):
+        return self.items[-1] if self.items else None
+
Cursor
+

Empty indented line inside pop (column 8).

+
Expected
+

Ghost text proposing a body consistent with the symmetric push.

+
+ +

Python — advanced

+ +
+

07 — Multi-line rename refactor off-cursor

+
def compute_user_score(u, w):
+    base = u * 10
+    bonus = w * 5
+    penalty = u - w
+    return base + bonus - penalty
+
+
+def compute_user_score(user_id, weight):
+    base = u * 10
+    bonus = w * 5
+    penalty = u - w
+    return base + bonus - penalty
+
Cursor
+

End of the renamed signature line (def compute_user_score(user_id, weight):).

+
Expected
+

Strikethrough on the body lines below + ghost showing the renamed body. First Tab jumps, second applies.

+
+ +
+

08 — Mixed insert + replace off-cursor

+
def sum_prices(items):
+    total = 0
+    for item in items:
+    return total
+
Cursor
+

End of total = 0.

+
Expected
+

Decoration on the broken for-loop area showing the corrected body (insertion + replacement combined).

+
+ +
+

10 — Mid-token completion same-line

+
def fibonacci(n):
+    if n <= 1:
+        return n
+    return fibonacci(n - 1) + fibonacci(n - 2)
+
+
+result = fib
+
Cursor
+

End of the file (after result = fib).

+
Expected
+

Ghost text extending the identifier and supplying a call, e.g. onacci(10).

+
+ +
+

11 — Stub method with implemented siblings same-line

+
class Queue:
+    def __init__(self):
+        self.items = []
+
+    def enqueue(self, item):
+        self.items.append(item)
+
+    def peek(self):
+        return self.items[0] if self.items else None
+
+    def size(self):
+        return len(self.items)
+
+    def is_empty(self):
+        return not self.items
+
+    def dequeue(self):
+
+
+    def clear(self):
+        self.items.clear()
+
Cursor
+

Empty indented line inside dequeue (column 8).

+
Expected
+

Ghost text proposing a FIFO pop, e.g. return self.items.pop(0).

+
+ +
+

12 — Type annotation insertion same-line / off-cursor

+
def multiply(a: int, b: int) -> int:
+    return a * b
+
+
+def subtract(a: int, b: int) -> int:
+    return a - b
+
+
+def add(a, b):
+    return a + b
+
Cursor
+

End of def add(a, b): (the only un-annotated function).

+
Expected
+

Strikethrough on the signature line + ghost showing the typed version (def add(a: int, b: int) -> int:). May render same-line or off-cursor depending on where on the line you clicked.

+
+ +
+

13 — Docstring generation same-line

+
import datetime
+
+
+def parse_iso_datetime(s):
+    """Parse an ISO 8601 datetime string into a datetime.datetime."""
+    return datetime.datetime.fromisoformat(s)
+
+
+def parse_iso_date(s):
+
+    return datetime.date.fromisoformat(s)
+
Cursor
+

Empty indented line under def parse_iso_date(s): (column 4).

+
Expected
+

Ghost text inserting a one-line docstring matching the sibling's style.

+
+ +
+

14 — No-op suppression suppressed

+
def add(a: int, b: int) -> int:
+    """Return the sum of two integers."""
+    return a + b
+
+
+def multiply(a: int, b: int) -> int:
+    """Return the product of two integers."""
+    return a * b
+
+
+def subtract(a: int, b: int) -> int:
+    """Return a minus b."""
+    return a - b
+
Cursor
+

End of return a + b.

+
Expected
+

Nothing. The code is already correct — either Mercury returns an identical reply or our suppression branch drops the proposal. Channel should show "no-op" or skip lines, never a render.

+
Failure mode
+

Any visible suggestion that just replays the existing code is a false positive worth reporting.

+
+ +

TypeScript

+ +
+

ts_07 — Array transform completion same-line

+
interface User {
+    id: number;
+    name: string;
+    active: boolean;
+}
+
+function getActiveUserNames(users: User[]): string[] {
+    return users
+}
+
+const sample: User[] = [
+    { id: 1, name: "ada", active: true },
+    { id: 2, name: "lin", active: false },
+    { id: 3, name: "rin", active: true },
+];
+
+console.log(getActiveUserNames(sample));
+
Cursor
+

End of return users inside getActiveUserNames.

+
Expected
+

Ghost text completing the chain, e.g. .filter(u => u.active).map(u => u.name).

+
+ +
+

ts_08 — Param type annotations off-cursor

+
function double(x: number): number {
+    return x * 2;
+}
+
+function add(a, b) {
+    return a + b;
+}
+
+function negate(x: number): number {
+    return -x;
+}
+
+function main(): void {
+    console.log(double(3));
+    console.log(add(2, 4));
+    console.log(negate(7));
+}
+
+main();
+
Cursor
+

End of file (after main();).

+
Expected
+

Decoration on the add(a, b) signature proposing the typed version.

+
+ +
+

ts_09 — React event handler same-line

+
declare const React: {
+    useState: <T>(initial: T) => [T, (next: T) => void];
+};
+
+function Counter(): JSX.Element {
+    const [count, setCount] = React.useState<number>(0);
+
+    function handleClick() {
+
+    }
+
+    return (
+        <div>
+            <p>Count: {count}</p>
+            <button onClick={handleClick}>Increment</button>
+        </div>
+    );
+}
+
+export default Counter;
+
Cursor
+

Empty indented line inside handleClick (column 4).

+
Expected
+

Ghost text incrementing count via setCount.

+
+ +

Go

+ +
+

go_07 — Error handling block same-line

+
package main
+
+import (
+	"fmt"
+	"os"
+)
+
+func loadConfig(path string) ([]byte, error) {
+	data, err := os.ReadFile(path)
+
+	return data, nil
+}
+
+func main() {
+	cfg, err := loadConfig("config.json")
+	if err != nil {
+		fmt.Println("error:", err)
+		return
+	}
+	fmt.Println(string(cfg))
+}
+
Cursor
+

Empty line right after data, err := os.ReadFile(path).

+
Expected
+

Ghost text proposing the canonical if err != nil { return nil, err }.

+
+ +
+

go_08 — Struct method body same-line

+
package main
+
+import "fmt"
+
+type Rectangle struct {
+	Width  float64
+	Height float64
+}
+
+func (r Rectangle) Perimeter() float64 {
+	return 2 * (r.Width + r.Height)
+}
+
+func (r Rectangle) Area() float64 {
+
+}
+
+func main() {
+	r := Rectangle{Width: 3, Height: 4}
+	fmt.Println("perimeter:", r.Perimeter())
+	fmt.Println("area:", r.Area())
+}
+
Cursor
+

Empty indented line inside Area().

+
Expected
+

Ghost text computing area from Width and Height.

+
+ +
+

go_09 — Goroutine + channel same-line

+
package main
+
+import "fmt"
+
+func main() {
+	ch := make(chan int)
+
+	go func() {
+
+	}()
+
+	for v := range ch {
+		fmt.Println("got:", v)
+	}
+}
+
Cursor
+

Empty indented line inside the goroutine.

+
Expected
+

Ghost text producing values onto the channel and closing it.

+
+ +

Rust

+ +
+

rs_07 — Match-arm completion same-line

+
enum Shape {
+    Circle(f64),
+    Square(f64),
+    Rectangle(f64, f64),
+    Triangle(f64, f64),
+}
+
+fn area(s: &Shape) -> f64 {
+    match s {
+        Shape::Circle(r) => std::f64::consts::PI * r * r,
+        Shape::Square(side) => side * side,
+
+    }
+}
+
+fn main() {
+    let shapes = vec![
+        Shape::Circle(1.0),
+        Shape::Rectangle(2.0, 3.0),
+        Shape::Triangle(4.0, 5.0),
+    ];
+    for s in &shapes {
+        println!("area = {}", area(s));
+    }
+}
+
Cursor
+

Empty indented line inside the match body, after the Square arm.

+
Expected
+

Ghost text adding the missing Rectangle and Triangle arms.

+
+ +
+

rs_08 — Result/Option chaining same-line

+
fn parse_int(s: &str) -> Option<i32> {
+    let n = s.trim()
+    Some(n * 2)
+}
+
+fn main() {
+    let inputs = ["  21  ", "not-a-number", "10"];
+    for s in &inputs {
+        match parse_int(s) {
+            Some(v) => println!("{} -> {}", s, v),
+            None => println!("{} -> skipped", s),
+        }
+    }
+}
+
Cursor
+

End of let n = s.trim() (no semicolon yet).

+
Expected
+

Ghost text continuing the chain into a parsed i32.

+
+ +
+

rs_09 — Lifetime annotations off-cursor

+
fn longest(a: &str, b: &str) -> &str {
+    if a.len() >= b.len() {
+        a
+    } else {
+        b
+    }
+}
+
+fn main() {
+    let s1 = String::from("hello world");
+    let s2 = String::from("hi");
+    let out = longest(&s1, &s2);
+    println!("longest = {}", out);
+}
+
Cursor
+

End of file.

+
Expected
+

Decoration on the fn longest signature proposing lifetime annotations.

+
+ +

JavaScript

+ +
+

js_07 — Async/await fetch same-line

+
async function fetchUser(id) {
+    try {
+
+    } catch (err) {
+        console.error("fetchUser failed", err);
+        return null;
+    }
+}
+
+async function main() {
+    const user = await fetchUser(42);
+    console.log("user:", user);
+}
+
+main();
+
Cursor
+

Empty indented line inside the try { block (column 8).

+
Expected
+

Ghost text completing the fetch + json parse.

+
+ +
+

js_08 — Express GET handler same-line

+
const app = {
+    get: (_path, _handler) => app,
+    post: (_path, _handler) => app,
+    listen: (_port, cb) => cb && cb(),
+};
+
+const users = [
+    { id: 1, name: "ada" },
+    { id: 2, name: "lin" },
+];
+
+app.get("/users/:id", (req, res) => {
+
+});
+
+app.post("/users", (req, res) => {
+    const user = { id: users.length + 1, name: req.body.name };
+    users.push(user);
+    res.status(201).json(user);
+});
+
+app.listen(3000, () => console.log("listening on :3000"));
+
Cursor
+

Empty indented line inside the GET handler (column 4).

+
Expected
+

Ghost text proposing a get-by-id (lookup, 404, json response).

+
+ +

SQL

+ +
+

sql_07 — Missing JOIN same-line

+
SELECT
+    c.name,
+    SUM(o.total) AS total_spent
+FROM orders o
+
+WHERE o.created_at >= '2026-01-01'
+GROUP BY c.name
+ORDER BY total_spent DESC
+LIMIT 10;
+
Cursor
+

End of the line FROM orders o.

+
Expected
+

Ghost text completing the JOIN against customers.

+
+ +
+

sql_08 — WHERE filter same-line

+
SELECT id, email
+FROM users
+WHERE
+ORDER BY last_login_at DESC;
+
Cursor
+

End of the bare WHERE line.

+
Expected
+

Ghost text proposing a predicate.

+
+ +

Markdown (negative case)

+ +
+

md_07 — Prose should stay quiet suppressed

+
# Mercury Edit 2 — Quick Notes
+
+Mercury Edit 2 is a small, fast model trained to predict the user's
+next single edit given the current file, cursor position, and recent
+edit history. It targets latency under 200 ms on typical files and
+returns a unified-diff-like patch scoped to a window around the cursor.
+
+Unlike chat-style completions, the model is biased toward minimal,
+local changes — finishing a function body, fixing a typo, propagating
+a rename — rather than generating new files from scratch.
+
Cursor
+

End of the last sentence.

+
Expected
+

Nothing. If Mercury does propose a prose continuation it counts as a soft fail — we don't want a code model writing README content.

+
+ +

Troubleshooting

+
+

If nothing happens when you type, open View → Output → "Kilo Code · Next Edit" and watch the log. The pipeline is verbose enough that 90% of issues are obvious from the first few lines.

+
+ + + + + + + + + +
SymptomLikely causeFix
No log lines at allWrong model selected, or Dev Host wasn't reloaded after rebuildCmd+R in the Dev Host; confirm model = Mercury Next Edit (Inception)
skip — no API key resolvedSetting not savedRe-paste the key in nextEdit.apiKey, press Enter, reload
<- 401 UnauthorizedWrong key or wrong tierVerify the key at platform.inceptionlabs.ai
<- 400 Bad RequestPrompt-shape regression (we shouldn't ship this, but if it happens during dev)Capture the response body from the channel and ping the integration owner
Suggestion shown for a wrong-looking modelSelecting "Mercury Edit 2" routes through the classic FIM provider, not NES — that's by design (the old behavior is preserved)Switch to "Mercury Next Edit (Inception)" to use the new pipeline
Inline ghost text never appears, but logs show RENDERAnother extension (Copilot, Tabnine) is winning the inline-completion raceTemporarily disable conflicting extensions in the Dev Host
+ +

Feedback we'd love

+
    +
  • Where the prediction was wrong but the UX was correct. Note the file + cursor position + what Mercury proposed. Helps us tune the model.
  • +
  • Where the UX got in the way. Tab semantics, decoration appearance, chained-prediction timing, anything that felt clumsy compared to other NES products you've used.
  • +
  • Performance regressions in classic FIM autocomplete. The PR is supposed to leave the classic path untouched — if Codestral or Mercury Edit 2 (FIM) feel different in this build, that's a regression we want to know about.
  • +
  • Things you tried that aren't in this doc. The 20 tests are a starting point, not a contract. Real codebases will be different.
  • +
+ + + +
+ + diff --git a/packages/kilo-vscode/docs/nes-examples/01_finish_function_body.py b/packages/kilo-vscode/docs/nes-examples/01_finish_function_body.py new file mode 100644 index 0000000000..939b104488 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/01_finish_function_body.py @@ -0,0 +1,4 @@ +def factorial(n): + if n <= 1: + return 1 + diff --git a/packages/kilo-vscode/docs/nes-examples/02_pattern_continuation.py b/packages/kilo-vscode/docs/nes-examples/02_pattern_continuation.py new file mode 100644 index 0000000000..87d3616b27 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/02_pattern_continuation.py @@ -0,0 +1,3 @@ +COLOR_RED = "#ff0000" +COLOR_GREEN = "#00ff00" +COLOR_BLUE = diff --git a/packages/kilo-vscode/docs/nes-examples/03_typo_completion.py b/packages/kilo-vscode/docs/nes-examples/03_typo_completion.py new file mode 100644 index 0000000000..932190bfe8 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/03_typo_completion.py @@ -0,0 +1,5 @@ +def calculate_total(items): + total = 0 + for item in items: + total += item.price + return tot diff --git a/packages/kilo-vscode/docs/nes-examples/04_loop_body.py b/packages/kilo-vscode/docs/nes-examples/04_loop_body.py new file mode 100644 index 0000000000..7d7c9629b8 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/04_loop_body.py @@ -0,0 +1,5 @@ +def calculate_total(items): + total = 0 + for item in items: + + return total diff --git a/packages/kilo-vscode/docs/nes-examples/05_class_method.py b/packages/kilo-vscode/docs/nes-examples/05_class_method.py new file mode 100644 index 0000000000..2613e953eb --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/05_class_method.py @@ -0,0 +1,12 @@ +class Stack: + def __init__(self): + self.items = [] + + def push(self, item): + self.items.append(item) + + def pop(self): + + + def peek(self): + return self.items[-1] if self.items else None diff --git a/packages/kilo-vscode/docs/nes-examples/07_multiline_rename_refactor.py b/packages/kilo-vscode/docs/nes-examples/07_multiline_rename_refactor.py new file mode 100644 index 0000000000..7846ab08f4 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/07_multiline_rename_refactor.py @@ -0,0 +1,12 @@ +def compute_user_score(u, w): + base = u * 10 + bonus = w * 5 + penalty = u - w + return base + bonus - penalty + + +def compute_user_score(user_id, weight): + base = u * 10 + bonus = w * 5 + penalty = u - w + return base + bonus - penalty diff --git a/packages/kilo-vscode/docs/nes-examples/08_mixed_insert_and_replace.py b/packages/kilo-vscode/docs/nes-examples/08_mixed_insert_and_replace.py new file mode 100644 index 0000000000..94d9332e06 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/08_mixed_insert_and_replace.py @@ -0,0 +1,4 @@ +def sum_prices(items): + total = 0 + for item in items: + return total diff --git a/packages/kilo-vscode/docs/nes-examples/10_mid_token_completion.py b/packages/kilo-vscode/docs/nes-examples/10_mid_token_completion.py new file mode 100644 index 0000000000..5139ba755d --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/10_mid_token_completion.py @@ -0,0 +1,7 @@ +def fibonacci(n): + if n <= 1: + return n + return fibonacci(n - 1) + fibonacci(n - 2) + + +result = fib diff --git a/packages/kilo-vscode/docs/nes-examples/11_fill_sibling_method.py b/packages/kilo-vscode/docs/nes-examples/11_fill_sibling_method.py new file mode 100644 index 0000000000..f2c4845e20 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/11_fill_sibling_method.py @@ -0,0 +1,21 @@ +class Queue: + def __init__(self): + self.items = [] + + def enqueue(self, item): + self.items.append(item) + + def peek(self): + return self.items[0] if self.items else None + + def size(self): + return len(self.items) + + def is_empty(self): + return not self.items + + def dequeue(self): + + + def clear(self): + self.items.clear() diff --git a/packages/kilo-vscode/docs/nes-examples/12_type_annotation_insertion.py b/packages/kilo-vscode/docs/nes-examples/12_type_annotation_insertion.py new file mode 100644 index 0000000000..8013be0a2a --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/12_type_annotation_insertion.py @@ -0,0 +1,10 @@ +def multiply(a: int, b: int) -> int: + return a * b + + +def subtract(a: int, b: int) -> int: + return a - b + + +def add(a, b): + return a + b diff --git a/packages/kilo-vscode/docs/nes-examples/13_docstring_generation.py b/packages/kilo-vscode/docs/nes-examples/13_docstring_generation.py new file mode 100644 index 0000000000..928cbb322b --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/13_docstring_generation.py @@ -0,0 +1,11 @@ +import datetime + + +def parse_iso_datetime(s): + """Parse an ISO 8601 datetime string into a datetime.datetime.""" + return datetime.datetime.fromisoformat(s) + + +def parse_iso_date(s): + + return datetime.date.fromisoformat(s) diff --git a/packages/kilo-vscode/docs/nes-examples/14_no_op_suppression.py b/packages/kilo-vscode/docs/nes-examples/14_no_op_suppression.py new file mode 100644 index 0000000000..81d91f5554 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/14_no_op_suppression.py @@ -0,0 +1,13 @@ +def add(a: int, b: int) -> int: + """Return the sum of two integers.""" + return a + b + + +def multiply(a: int, b: int) -> int: + """Return the product of two integers.""" + return a * b + + +def subtract(a: int, b: int) -> int: + """Return a minus b.""" + return a - b diff --git a/packages/kilo-vscode/docs/nes-examples/INSTRUCTIONS.md b/packages/kilo-vscode/docs/nes-examples/INSTRUCTIONS.md new file mode 100644 index 0000000000..bc5f8822d0 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/INSTRUCTIONS.md @@ -0,0 +1,201 @@ +# NES Test Playground — Instructions + +These tests are designed so that the source files contain **no hints** about what Mercury is supposed to predict. All cursor placements and expected behaviors live here. Don't open this file inside the Dev Host while testing — keep it in a separate window so the model can't see it. + +## One-time setup + +1. **`bun run watch`** is already running for the kilocode extension. +2. In the kilocode VSCode window, press **F5** → opens the **Extension Development Host**. +3. In the Dev Host: `File → Open Folder…` → `packages/kilo-vscode/docs/nes-examples/` inside this repo. +4. Open Settings (`Cmd+,`), confirm: + - `kilo-code.new.autocomplete.enableAutoTrigger` → ✓ (default true) + - `kilo-code.new.autocomplete.model` → **Mercury Next Edit (Inception)** ← *NOT* "Mercury Edit 2", which is the classic FIM option + - `kilo-code.new.autocomplete.nextEdit.apiKey` → your `sk_...` key + - VSCode global `editor.inlineSuggest.enabled` → ✓ +5. To watch the pipeline live: `View → Output` → pick the **"Kilo Code · Next Edit"** channel. + +## Conventions used below + +- **Cursor placement**: where to click in the file before waiting. +- **Expected (editor)**: what should appear on screen. +- **Expected (channel)**: a stripped-down line you should see in the Next Edit output channel. +- **Path**: which NES rendering path this exercises — same-line ghost / off-cursor replace / off-cursor insert / suppressed. + +After each test, **don't accept** if you want to re-run it — the suggestion will edit the file. Either `Cmd+Z` after accept, or just navigate to the next test file. + +--- + +## Core tests (Python) + +### 01 — Finish a function body *(path: same-line insert)* +- **File**: `01_finish_function_body.py` +- **Cursor**: the empty indented line at the end of `factorial` (column 4). +- **Expected editor**: ghost text proposing the recursive case. +- **Expected channel**: `diff at lines [N..N], cursor at line N`, then `RENDER`. + +### 02 — Pattern continuation *(path: same-line ghost)* +- **File**: `02_pattern_continuation.py` +- **Cursor**: end of the last line (after `COLOR_BLUE = `). +- **Expected editor**: ghost text appending a hex color. +- **Expected channel**: `diff at lines [N..N], cursor at line N`, then `RENDER`. + +### 03 — Mid-identifier completion *(path: same-line ghost)* +- **File**: `03_typo_completion.py` +- **Cursor**: end of the file (after `return tot`). +- **Expected editor**: ghost text completing the identifier. +- **Path**: same-line. + +### 04 — Loop body inference *(path: same-line insert)* +- **File**: `04_loop_body.py` +- **Cursor**: the empty indented line inside the `for` loop (column 8). +- **Expected editor**: ghost text proposing the accumulator update. + +### 05 — Sibling method body *(path: same-line insert)* +- **File**: `05_class_method.py` +- **Cursor**: empty indented line inside `pop` (column 8). +- **Expected editor**: ghost text proposing the pop body. + +--- + +## Advanced Python tests + +### 07 — Multi-line rename refactor *(path: off-cursor replace)* +- **File**: `07_multiline_rename_refactor.py` +- **Cursor**: end of the line with `def compute_user_score(user_id, weight):` (the renamed signature). The body below still uses the old `u` / `w` names. +- **Expected editor**: strikethrough on the body lines + ghost showing the renamed body. +- **Tab**: jump, then apply. + +### 08 — Mixed insert + replace *(path: off-cursor replace, multi-line)* +- **File**: `08_mixed_insert_and_replace.py` +- **Cursor**: end of line `total = 0`. +- **Expected editor**: a decoration spanning the for-loop area showing the corrected accumulator body. The proposed text is longer than the original. + +### 10 — Mid-token completion *(path: same-line ghost)* +- **File**: `10_mid_token_completion.py` +- **Cursor**: end of the file (after `result = fib`). +- **Expected editor**: ghost extending the identifier and supplying a call. + +### 11 — Stub method with implemented siblings *(path: same-line insert)* +- **File**: `11_fill_sibling_method.py` +- **Cursor**: empty indented line inside `dequeue` (column 8). +- **Expected editor**: ghost text filling in the FIFO body. + +### 12 — Type annotation insertion *(path: same-line replace OR off-cursor replace)* +- **File**: `12_type_annotation_insertion.py` +- **Cursor**: on line `def add(a, b):` (anywhere on that line works; end-of-line is easiest). +- **Expected editor**: strikethrough + ghost showing the typed signature. May render as inline ghost depending on where you place the cursor on the line. + +### 13 — Docstring generation *(path: same-line insert)* +- **File**: `13_docstring_generation.py` +- **Cursor**: empty indented line directly under `def parse_iso_date(s):` (column 4). +- **Expected editor**: ghost text starting with `"""` and a one-line description. + +### 14 — No-op suppression (NEGATIVE) *(path: suppressed)* +- **File**: `14_no_op_suppression.py` +- **Cursor**: end of `return a + b`. +- **Expected editor**: NOTHING. No ghost, no decoration. +- **Expected channel**: either `identical replacement — no-op` or no `RENDER` line. +- **Fail mode**: any visible suggestion is a false positive. + +--- + +## TypeScript + +### ts_07 — Array transform *(same-line)* +- **File**: `ts_07_array_transform.ts` +- **Cursor**: end of `return users` inside `getActiveUserNames`. +- **Expected editor**: ghost text completing a `.filter(...).map(...)` chain. + +### ts_08 — Param type annotations *(off-cursor replace)* +- **File**: `ts_08_param_types.ts` +- **Cursor**: end of file (after `main();`). +- **Expected editor**: strikethrough on `add(a, b)` signature + ghost showing the typed version. + +### ts_09 — React event handler *(same-line insert)* +- **File**: `ts_09_jsx_handler.tsx` +- **Cursor**: empty indented line inside `handleClick` (column 4). +- **Expected editor**: ghost text incrementing `count`. + +--- + +## Go + +### go_07 — Error handling *(same-line insert, multi-line)* +- **File**: `go_07_error_handling.go` +- **Cursor**: empty line right after `data, err := os.ReadFile(path)`. +- **Expected editor**: ghost text proposing the canonical `if err != nil { return nil, err }` block. + +### go_08 — Struct method body *(same-line insert)* +- **File**: `go_08_struct_method.go` +- **Cursor**: empty indented line inside `Area()`. +- **Expected editor**: ghost text computing area from `Width` and `Height`. + +### go_09 — Goroutine + channel *(same-line insert, multi-line)* +- **File**: `go_09_goroutine_channel.go` +- **Cursor**: empty indented line inside the goroutine. +- **Expected editor**: ghost text producing values onto the channel and closing it. + +--- + +## Rust + +### rs_07 — Match-arm completion *(same-line)* +- **File**: `rs_07_match_arms.rs` +- **Cursor**: empty indented line inside the `match s {` body, after the `Square` arm (column 8). +- **Expected editor**: ghost text proposing the missing `Rectangle` and `Triangle` arms. + +### rs_08 — Result chaining *(same-line ghost)* +- **File**: `rs_08_result_chain.rs` +- **Cursor**: end of the line `let n = s.trim()` (no semicolon yet). +- **Expected editor**: ghost text continuing the chain into a parsed `i32`. + +### rs_09 — Lifetime annotation *(off-cursor replace)* +- **File**: `rs_09_lifetimes.rs` +- **Cursor**: end of the file (after `main`'s closing `}`). +- **Expected editor**: strikethrough on the `fn longest(...)` signature + ghost showing the lifetime-annotated version. + +--- + +## JavaScript + +### js_07 — Async/await *(same-line insert, multi-line)* +- **File**: `js_07_async_await.js` +- **Cursor**: empty indented line inside the `try {` block (column 8). +- **Expected editor**: ghost text completing fetch + json parse. + +### js_08 — Express route handler *(same-line insert, multi-line)* +- **File**: `js_08_express_route.js` +- **Cursor**: empty indented line inside the GET handler (column 4). +- **Expected editor**: ghost text implementing get-by-id (lookup, 404, json response). + +--- + +## SQL + +### sql_07 — JOIN clause *(same-line ghost)* +- **File**: `sql_07_join.sql` +- **Cursor**: end of the line `FROM orders o`. +- **Expected editor**: ghost text completing the JOIN against `customers`. + +### sql_08 — WHERE filter *(same-line ghost)* +- **File**: `sql_08_where_filter.sql` +- **Cursor**: end of the bare `WHERE` line. +- **Expected editor**: ghost text proposing a predicate. + +--- + +## Markdown (negative) + +### md_07 — Prose, should stay quiet *(suppressed)* +- **File**: `md_07_prose_negative.md` +- **Cursor**: end of the last sentence. +- **Expected editor**: NOTHING (ideally). If Mercury does propose a continuation of the prose, note it as a soft fail — code models writing your README isn't the v0 product. + +--- + +## Troubleshooting + +- **No log lines appearing**: confirm the output channel is "Kilo Code · Next Edit". Also confirm you reloaded the Dev Host after rebuilding. +- **`[NES] skip — no API key resolved`**: setting wasn't saved. Re-paste the key, hit Enter, reload. +- **`[NES] <- 400`**: regression on prompt shape — capture the body in the channel and ping the integration owner. +- **Visible suggestion that's not in this doc**: write it down. Unexpected wins (or false positives) are the most useful signal. diff --git a/packages/kilo-vscode/docs/nes-examples/go_07_error_handling.go b/packages/kilo-vscode/docs/nes-examples/go_07_error_handling.go new file mode 100644 index 0000000000..96d50b1e93 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/go_07_error_handling.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "os" +) + +func loadConfig(path string) ([]byte, error) { + data, err := os.ReadFile(path) + + return data, nil +} + +func main() { + cfg, err := loadConfig("config.json") + if err != nil { + fmt.Println("error:", err) + return + } + fmt.Println(string(cfg)) +} diff --git a/packages/kilo-vscode/docs/nes-examples/go_08_struct_method.go b/packages/kilo-vscode/docs/nes-examples/go_08_struct_method.go new file mode 100644 index 0000000000..dbfb598a33 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/go_08_struct_method.go @@ -0,0 +1,22 @@ +package main + +import "fmt" + +type Rectangle struct { + Width float64 + Height float64 +} + +func (r Rectangle) Perimeter() float64 { + return 2 * (r.Width + r.Height) +} + +func (r Rectangle) Area() float64 { + +} + +func main() { + r := Rectangle{Width: 3, Height: 4} + fmt.Println("perimeter:", r.Perimeter()) + fmt.Println("area:", r.Area()) +} diff --git a/packages/kilo-vscode/docs/nes-examples/go_09_goroutine_channel.go b/packages/kilo-vscode/docs/nes-examples/go_09_goroutine_channel.go new file mode 100644 index 0000000000..4fe79b9fe8 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/go_09_goroutine_channel.go @@ -0,0 +1,15 @@ +package main + +import "fmt" + +func main() { + ch := make(chan int) + + go func() { + + }() + + for v := range ch { + fmt.Println("got:", v) + } +} diff --git a/packages/kilo-vscode/docs/nes-examples/js_07_async_await.js b/packages/kilo-vscode/docs/nes-examples/js_07_async_await.js new file mode 100644 index 0000000000..30e4f0ba51 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/js_07_async_await.js @@ -0,0 +1,15 @@ +async function fetchUser(id) { + try { + + } catch (err) { + console.error("fetchUser failed", err); + return null; + } +} + +async function main() { + const user = await fetchUser(42); + console.log("user:", user); +} + +main(); diff --git a/packages/kilo-vscode/docs/nes-examples/js_08_express_route.js b/packages/kilo-vscode/docs/nes-examples/js_08_express_route.js new file mode 100644 index 0000000000..75e3f2bcc9 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/js_08_express_route.js @@ -0,0 +1,22 @@ +const app = { + get: (_path, _handler) => app, + post: (_path, _handler) => app, + listen: (_port, cb) => cb && cb(), +}; + +const users = [ + { id: 1, name: "ada" }, + { id: 2, name: "lin" }, +]; + +app.get("/users/:id", (req, res) => { + +}); + +app.post("/users", (req, res) => { + const user = { id: users.length + 1, name: req.body.name }; + users.push(user); + res.status(201).json(user); +}); + +app.listen(3000, () => console.log("listening on :3000")); diff --git a/packages/kilo-vscode/docs/nes-examples/md_07_prose_negative.md b/packages/kilo-vscode/docs/nes-examples/md_07_prose_negative.md new file mode 100644 index 0000000000..3751a203cf --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/md_07_prose_negative.md @@ -0,0 +1,10 @@ +# Mercury Edit 2 — Quick Notes + +Mercury Edit 2 is a small, fast model trained to predict the user's +next single edit given the current file, cursor position, and recent +edit history. It targets latency under 200 ms on typical files and +returns a unified-diff-like patch scoped to a window around the cursor. + +Unlike chat-style completions, the model is biased toward minimal, +local changes — finishing a function body, fixing a typo, propagating +a rename — rather than generating new files from scratch. diff --git a/packages/kilo-vscode/docs/nes-examples/rs_07_match_arms.rs b/packages/kilo-vscode/docs/nes-examples/rs_07_match_arms.rs new file mode 100644 index 0000000000..e666f246a0 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/rs_07_match_arms.rs @@ -0,0 +1,25 @@ +enum Shape { + Circle(f64), + Square(f64), + Rectangle(f64, f64), + Triangle(f64, f64), +} + +fn area(s: &Shape) -> f64 { + match s { + Shape::Circle(r) => std::f64::consts::PI * r * r, + Shape::Square(side) => side * side, + + } +} + +fn main() { + let shapes = vec![ + Shape::Circle(1.0), + Shape::Rectangle(2.0, 3.0), + Shape::Triangle(4.0, 5.0), + ]; + for s in &shapes { + println!("area = {}", area(s)); + } +} diff --git a/packages/kilo-vscode/docs/nes-examples/rs_08_result_chain.rs b/packages/kilo-vscode/docs/nes-examples/rs_08_result_chain.rs new file mode 100644 index 0000000000..2c51c0c617 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/rs_08_result_chain.rs @@ -0,0 +1,14 @@ +fn parse_int(s: &str) -> Option { + let n = s.trim() + Some(n * 2) +} + +fn main() { + let inputs = [" 21 ", "not-a-number", "10"]; + for s in &inputs { + match parse_int(s) { + Some(v) => println!("{} -> {}", s, v), + None => println!("{} -> skipped", s), + } + } +} diff --git a/packages/kilo-vscode/docs/nes-examples/rs_09_lifetimes.rs b/packages/kilo-vscode/docs/nes-examples/rs_09_lifetimes.rs new file mode 100644 index 0000000000..8686874970 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/rs_09_lifetimes.rs @@ -0,0 +1,14 @@ +fn longest(a: &str, b: &str) -> &str { + if a.len() >= b.len() { + a + } else { + b + } +} + +fn main() { + let s1 = String::from("hello world"); + let s2 = String::from("hi"); + let out = longest(&s1, &s2); + println!("longest = {}", out); +} diff --git a/packages/kilo-vscode/docs/nes-examples/sql_07_join.sql b/packages/kilo-vscode/docs/nes-examples/sql_07_join.sql new file mode 100644 index 0000000000..b692494f24 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/sql_07_join.sql @@ -0,0 +1,9 @@ +SELECT + c.name, + SUM(o.total) AS total_spent +FROM orders o + +WHERE o.created_at >= '2026-01-01' +GROUP BY c.name +ORDER BY total_spent DESC +LIMIT 10; diff --git a/packages/kilo-vscode/docs/nes-examples/sql_08_where_filter.sql b/packages/kilo-vscode/docs/nes-examples/sql_08_where_filter.sql new file mode 100644 index 0000000000..ed2fd4a6a7 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/sql_08_where_filter.sql @@ -0,0 +1,4 @@ +SELECT id, email +FROM users +WHERE +ORDER BY last_login_at DESC; diff --git a/packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts b/packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts new file mode 100644 index 0000000000..1cd15cd17b --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts @@ -0,0 +1,17 @@ +interface User { + id: number; + name: string; + active: boolean; +} + +function getActiveUserNames(users: User[]): string[] { + return users +} + +const sample: User[] = [ + { id: 1, name: "ada", active: true }, + { id: 2, name: "lin", active: false }, + { id: 3, name: "rin", active: true }, +]; + +console.log(getActiveUserNames(sample)); diff --git a/packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts b/packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts new file mode 100644 index 0000000000..c114bc3f3c --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts @@ -0,0 +1,19 @@ +function double(x: number): number { + return x * 2; +} + +function add(a, b) { + return a + b; +} + +function negate(x: number): number { + return -x; +} + +function main(): void { + console.log(double(3)); + console.log(add(2, 4)); + console.log(negate(7)); +} + +main(); diff --git a/packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx b/packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx new file mode 100644 index 0000000000..8d8481f2e4 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx @@ -0,0 +1,20 @@ +declare const React: { + useState: (initial: T) => [T, (next: T) => void]; +}; + +function Counter(): JSX.Element { + const [count, setCount] = React.useState(0); + + function handleClick() { + + } + + return ( +
+

Count: {count}

+ +
+ ); +} + +export default Counter; diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index bcb8a931e3..b9b03b843c 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -194,6 +194,16 @@ "title": "Cancel Suggested Edits", "category": "Kilo Code" }, + { + "command": "kilo-code.next-edit.acceptOrJump", + "title": "Next Edit: Accept or Jump to Suggested Edit", + "category": "Kilo Code" + }, + { + "command": "kilo-code.next-edit.dismiss", + "title": "Next Edit: Dismiss Pending Suggestion", + "category": "Kilo Code" + }, { "command": "kilo-code.new.agentManager.previousSession", "title": "Agent Manager: Previous Session", @@ -756,6 +766,16 @@ "key": "ctrl+l", "mac": "cmd+l", "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && kilocode.autocomplete.enableSmartInlineTaskKeybinding && github.copilot.completions.enabled" + }, + { + "command": "kilo-code.next-edit.acceptOrJump", + "key": "tab", + "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && !suggestWidgetVisible && kilo-code.nextEdit.hasPendingSuggestion" + }, + { + "command": "kilo-code.next-edit.dismiss", + "key": "escape", + "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && kilo-code.nextEdit.hasPendingSuggestion" } ], "configuration": { diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index d3d0d67c85..30bcb3db1b 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -6,6 +6,9 @@ import { AutocompleteStatusBar } from "./AutocompleteStatusBar" import { AutocompleteCodeActionProvider } from "./AutocompleteCodeActionProvider" import { AutocompleteInlineCompletionProvider } from "./classic-auto-complete/AutocompleteInlineCompletionProvider" import { AutocompleteTelemetry } from "./classic-auto-complete/AutocompleteTelemetry" +import { NextEditInlineCompletionProvider } from "./next-edit/NextEditInlineCompletionProvider" +import { NextEditSuggestionManager } from "./next-edit/NextEditSuggestionManager" +import { toMercuryRecentSnippets } from "./next-edit/recentSnippetsAdapter" import type { KiloConnectionService } from "../cli-backend" import { hasValidCredentials } from "./fim" import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models" @@ -61,7 +64,10 @@ export class AutocompleteServiceManager { // VSCode Providers public readonly codeActionProvider: AutocompleteCodeActionProvider public readonly inlineCompletionProvider: AutocompleteInlineCompletionProvider + public readonly nextEditProvider: NextEditInlineCompletionProvider + public readonly nextEditSuggestionManager: NextEditSuggestionManager private inlineCompletionProviderDisposable: vscode.Disposable | null = null + private inlineCompletionProviderKind: "classic" | "next-edit" | null = null private unsubscribeState: (() => void) | null = null private unsubscribeEvent: (() => void) | null = null @@ -91,6 +97,36 @@ export class AutocompleteServiceManager { (status) => this.handleFatalAutocompleteError(status), ) + this.nextEditSuggestionManager = new NextEditSuggestionManager() + this.nextEditProvider = new NextEditInlineCompletionProvider({ + connectionService, + suggestionManager: this.nextEditSuggestionManager, + getRecentlyViewedSnippets: () => { + // Reuse the LRU populated by the classic provider — keeps a single + // RecentlyVisitedRangesService instance instead of double-tracking. + const raw = this.inlineCompletionProvider.recentlyVisitedRangesService.getSnippets() + return toMercuryRecentSnippets(raw) + }, + onFatalError: (status) => this.handleFatalAutocompleteError(status), + onSuggestion: (event) => { + const eventName = + event.status === "error" + ? TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_FAILED + : event.shown + ? TelemetryEventName.AUTOCOMPLETE_LLM_SUGGESTION_RETURNED + : TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_COMPLETED + TelemetryProxy.capture(eventName, { + mode: "next-edit", + model: "inception/mercury-next-edit", + latencyMs: event.latencyMs, + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + shown: event.shown, + errorStatus: event.errorStatus, + }) + }, + }) + // Reload when CLI backend connection state changes so autocomplete // picks up the connected state even if it wasn't ready at startup. // Also reset error backoff — a reconnect may mean the user re-authenticated @@ -136,25 +172,43 @@ export class AutocompleteServiceManager { */ private async ensureInlineCompletionProviderRegistration() { const shouldBeRegistered = (this.settings?.enableAutoTrigger ?? false) && !this.isSnoozed() - const isRegistered = this.inlineCompletionProviderDisposable !== null + const info = getAutocompleteModel(this.settings?.provider, this.settings?.model) + const desiredKind: "classic" | "next-edit" = info.kind === "edit" ? "next-edit" : "classic" - // Already in the correct state — nothing to do - if (shouldBeRegistered === isRegistered) { - return + // Mode change while still enabled requires a swap: tear down the old + // registration so the new provider takes over. + if ( + shouldBeRegistered && + this.inlineCompletionProviderKind !== null && + this.inlineCompletionProviderKind !== desiredKind + ) { + this.inlineCompletionProviderDisposable?.dispose() + this.inlineCompletionProviderDisposable = null + this.inlineCompletionProviderKind = null } + const isRegistered = this.inlineCompletionProviderDisposable !== null + if (shouldBeRegistered === isRegistered) return + if (!shouldBeRegistered) { this.inlineCompletionProviderDisposable!.dispose() this.inlineCompletionProviderDisposable = null + this.inlineCompletionProviderKind = null return } - // Register classic provider (tracked via this.inlineCompletionProviderDisposable, - // not context.subscriptions, so re-registration on reconnect doesn't leak) + const provider: vscode.InlineCompletionItemProvider = + desiredKind === "next-edit" ? this.nextEditProvider : this.inlineCompletionProvider this.inlineCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider( { scheme: "file" }, - this.inlineCompletionProvider, + provider, ) + this.inlineCompletionProviderKind = desiredKind + } + + /** Which provider is currently registered (`null` if none). */ + public get currentMode(): "classic" | "next-edit" | null { + return this.inlineCompletionProviderKind } public async disable() { @@ -410,10 +464,17 @@ export class AutocompleteServiceManager { if (this.inlineCompletionProviderDisposable) { this.inlineCompletionProviderDisposable.dispose() this.inlineCompletionProviderDisposable = null + this.inlineCompletionProviderKind = null } // Dispose inline completion provider resources this.inlineCompletionProvider.dispose() + this.nextEditProvider.dispose() + this.nextEditSuggestionManager.dispose() + + // Drop the dedicated Next Edit OutputChannel so it doesn't leak across + // extension reloads. + void import("./next-edit/log").then((m) => m.disposeLog()).catch(() => undefined) // Clear singleton instance AutocompleteServiceManager._instance = null diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index 32ee332e67..33cf8f16ea 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -111,7 +111,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple private connectionService: KiloConnectionService private costTrackingCallback: CostTrackingCallback private getSettings: () => AutocompleteServiceSettings | null - private recentlyVisitedRangesService: RecentlyVisitedRangesService + public readonly recentlyVisitedRangesService: RecentlyVisitedRangesService private recentlyEditedTracker: RecentlyEditedTracker private debounceTimer: NodeJS.Timeout | null = null /** The pending request associated with the current debounce timer (if any) */ diff --git a/packages/kilo-vscode/src/services/autocomplete/index.ts b/packages/kilo-vscode/src/services/autocomplete/index.ts index 9b8a22eba1..f207191b52 100644 --- a/packages/kilo-vscode/src/services/autocomplete/index.ts +++ b/packages/kilo-vscode/src/services/autocomplete/index.ts @@ -1,6 +1,13 @@ import * as vscode from "vscode" import { AutocompleteServiceManager } from "./AutocompleteServiceManager" import { ensureBackendForAutocomplete } from "./ensure-backend" +import { nesLog } from "./next-edit/log" +import { INLINE_COMPLETION_ACCEPTED_COMMAND as NEXT_EDIT_ACCEPTED_COMMAND } from "./next-edit/NextEditInlineCompletionProvider" +import { + NEXT_EDIT_ACCEPT_OR_JUMP_COMMAND, + NEXT_EDIT_DISMISS_COMMAND, + chainNextPrediction, +} from "./next-edit/NextEditSuggestionManager" import type { KiloConnectionService } from "../cli-backend" export const registerAutocompleteProvider = ( @@ -42,6 +49,27 @@ export const registerAutocompleteProvider = ( await autocompleteManager.disable() }), ) + // Fired by VSCode when the user accepts a Next Edit same-line ghost. Chains + // the next prediction so users can walk a refactor with repeated Tabs. + context.subscriptions.push( + vscode.commands.registerCommand(NEXT_EDIT_ACCEPTED_COMMAND, () => { + nesLog("suggestion accepted") + if (autocompleteManager.currentMode === "next-edit") chainNextPrediction() + }), + ) + // Tab handler for off-cursor pending suggestions: first press teleports the + // cursor to the predicted edit, second press applies. + context.subscriptions.push( + vscode.commands.registerCommand(NEXT_EDIT_ACCEPT_OR_JUMP_COMMAND, async () => { + await autocompleteManager.nextEditSuggestionManager.acceptOrJump() + }), + ) + // Esc handler: dismiss the pending suggestion without applying. + context.subscriptions.push( + vscode.commands.registerCommand(NEXT_EDIT_DISMISS_COMMAND, () => { + autocompleteManager.nextEditSuggestionManager.clear() + }), + ) // Register AutocompleteServiceManager Code Actions context.subscriptions.push( diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts new file mode 100644 index 0000000000..bfc5ebf733 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts @@ -0,0 +1,94 @@ +import type { KiloConnectionService } from "../../cli-backend" +import { nesLog, nesWarn } from "./log" +import { buildMercuryEditPrompt } from "./mercuryPromptTemplate" +import type { MercuryEditRequestContext, MercuryEditSuggestion } from "./types" + +const MERCURY_MAX_TOKENS = 512 +const PROVIDER_ID = "inception" +const MODEL_ID = "mercury-next-edit" + +export interface MercuryEditProviderOptions { + connectionService: KiloConnectionService + /** AbortSignal for cancellation (cursor moves, escape, etc.). */ + signal?: AbortSignal +} + +/** + * Thin wrapper around the SDK's `client.kilo.edit(...)` SSE endpoint. + * The gateway (in `packages/kilo-gateway/src/server/edit.ts`) handles auth, + * routing to Mercury's `/v1/edit/completions`, and unwrapping the + * triple-backtick fence from the model response — so the VSCode side only + * deals in already-parsed code. + */ +export class MercuryEditProvider { + constructor(private readonly options: MercuryEditProviderOptions) {} + + async suggest(ctx: MercuryEditRequestContext): Promise { + const userContent = buildMercuryEditPrompt(ctx) + const start = Date.now() + nesLog( + `-> /kilo/edit model=${MODEL_ID} promptChars=${userContent.length} region=[${ctx.editableRegionStartLine},${ctx.editableRegionEndLine}] diffs=${ctx.editDiffHistory.length} snippets=${ctx.recentlyViewedSnippets.length}`, + ) + + const client = await this.options.connectionService.getClientAsync() + try { + const { data, error } = await client.kilo.edit( + { + content: userContent, + provider: PROVIDER_ID, + model: MODEL_ID, + maxTokens: MERCURY_MAX_TOKENS, + }, + { signal: this.options.signal, throwOnError: false }, + ) + const latencyMs = Date.now() - start + if (error) { + const status = typeof (error as any)?.status === "number" ? (error as any).status : null + nesWarn(`<- error ${status ?? "?"} (${latencyMs}ms): ${safeStringify(error)}`) + throw new MercuryEditError(`Edit request failed: ${safeStringify(error)}`, status) + } + return this.parseSuccess(ctx, data, latencyMs) + } catch (err) { + if ((err as Error)?.name === "AbortError") throw err + if (err instanceof MercuryEditError) throw err + const msg = err instanceof Error ? err.message : String(err) + nesWarn(`<- transport error: ${msg}`) + throw new MercuryEditError(`Edit request failed: ${msg}`, null) + } + } + + private parseSuccess( + ctx: MercuryEditRequestContext, + data: { content?: string; usage?: { prompt_tokens?: number; completion_tokens?: number } } | undefined, + latencyMs: number, + ): MercuryEditSuggestion | null { + const replacement = data?.content ?? null + const usage = data?.usage + nesLog(`<- ok (${latencyMs}ms) tokens=${usage?.completion_tokens ?? "?"} parsedChars=${replacement?.length ?? 0}`) + if (replacement === null || replacement.length === 0) return null + return { + replacement, + editableRegionStartLine: ctx.editableRegionStartLine, + editableRegionEndLine: ctx.editableRegionEndLine, + latencyMs, + inputTokens: usage?.prompt_tokens, + outputTokens: usage?.completion_tokens, + } + } +} + +export class MercuryEditError extends Error { + constructor(message: string, public readonly status: number | null) { + super(message) + this.name = "MercuryEditError" + } +} + +function safeStringify(value: unknown): string { + try { + if (typeof value === "string") return value + return JSON.stringify(value) + } catch { + return String(value) + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts new file mode 100644 index 0000000000..3f00a3c3ed --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -0,0 +1,335 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../../cli-backend" +import { computeEditableRegion } from "./editableRegion" +import { EditHistoryTracker } from "./editHistoryTracker" +import { nesLog } from "./log" +import { MercuryEditError, MercuryEditProvider } from "./MercuryEditProvider" +import type { NextEditSuggestionManager } from "./NextEditSuggestionManager" +import type { MercuryEditRequestContext, MercuryRecentSnippet } from "./types" + +const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilo-code.autocomplete.next-edit.accepted" +const DEFAULT_DEBOUNCE_MS = 250 + +export interface NextEditProviderDeps { + /** Routes Mercury calls through the local Kilo gateway (handles auth + BYOK). */ + connectionService: KiloConnectionService + /** Optional source of recently-viewed snippets (kilocode's VisibleCodeTracker can adapt to this). */ + getRecentlyViewedSnippets?: (document: vscode.TextDocument) => MercuryRecentSnippet[] + /** Telemetry hook fired on every suggestion result. */ + onSuggestion?: (event: NextEditSuggestionEvent) => void + onFatalError?: (status: number | null) => void + /** Stash for diffs that don't land on the cursor's line — rendered as a jump affordance. */ + suggestionManager?: NextEditSuggestionManager +} + +export interface NextEditSuggestionEvent { + shown: boolean + latencyMs: number + status: "ok" | "no-replacement" | "error" + errorStatus?: number + inputTokens?: number + outputTokens?: number +} + +export class NextEditInlineCompletionProvider implements vscode.InlineCompletionItemProvider, vscode.Disposable { + private readonly editHistoryTracker: EditHistoryTracker + private debounceTimer: NodeJS.Timeout | null = null + private currentAbort: AbortController | null = null + + constructor(private readonly deps: NextEditProviderDeps) { + this.editHistoryTracker = new EditHistoryTracker() + } + + dispose(): void { + this.editHistoryTracker.dispose() + if (this.debounceTimer) clearTimeout(this.debounceTimer) + this.currentAbort?.abort() + } + + async provideInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + context: vscode.InlineCompletionContext, + token: vscode.CancellationToken, + ): Promise { + if (document.uri.scheme !== "file") return undefined + if (this.deps.suggestionManager?.isPending()) return undefined + + const isExplicit = context.triggerKind === vscode.InlineCompletionTriggerKind.Invoke + if (!isExplicit) { + await this.debounce(DEFAULT_DEBOUNCE_MS, token) + if (token.isCancellationRequested) return undefined + } + + const abort = this.swapAbortController(token) + const ctx = this.buildRequestContext(document, position) + const provider = new MercuryEditProvider({ + connectionService: this.deps.connectionService, + signal: abort.signal, + }) + + try { + const suggestion = await provider.suggest(ctx) + if (!suggestion || token.isCancellationRequested) { + this.deps.onSuggestion?.({ shown: false, latencyMs: 0, status: "no-replacement" }) + return undefined + } + return this.toCompletionItems(document, position, suggestion) + } catch (err) { + return this.handleError(err) + } + } + + private swapAbortController(token: vscode.CancellationToken): AbortController { + this.currentAbort?.abort() + const abort = new AbortController() + this.currentAbort = abort + token.onCancellationRequested(() => abort.abort()) + return abort + } + + private buildRequestContext(document: vscode.TextDocument, position: vscode.Position): MercuryEditRequestContext { + const { startLine, endLine } = computeEditableRegion({ + cursorLine: position.line, + totalLines: document.lineCount, + }) + this.editHistoryTracker.flush(document) + return { + currentFilePath: document.uri.fsPath, + currentFileContent: document.getText(), + cursorLine: position.line, + cursorCharacter: position.character, + editableRegionStartLine: startLine, + editableRegionEndLine: endLine, + recentlyViewedSnippets: this.deps.getRecentlyViewedSnippets?.(document) ?? [], + editDiffHistory: this.editHistoryTracker.getRecentDiffs(), + } + } + + private toCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + suggestion: { replacement: string; editableRegionStartLine: number; editableRegionEndLine: number; latencyMs: number; inputTokens?: number; outputTokens?: number }, + ): vscode.InlineCompletionItem[] | undefined { + const endLine = Math.min(suggestion.editableRegionEndLine, document.lineCount - 1) + const fullRange = new vscode.Range( + new vscode.Position(suggestion.editableRegionStartLine, 0), + document.lineAt(endLine).range.end, + ) + const currentText = document.getText(fullRange) + if (currentText === suggestion.replacement) { + this.deps.onSuggestion?.({ + shown: false, + latencyMs: suggestion.latencyMs, + status: "no-replacement", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + return undefined + } + + // Trim to minimal diff: skip identical leading and trailing lines. + const currentLines = currentText.split("\n") + const proposedLines = suggestion.replacement.split("\n") + let prefixLines = 0 + while ( + prefixLines < currentLines.length && + prefixLines < proposedLines.length && + currentLines[prefixLines] === proposedLines[prefixLines] + ) + prefixLines++ + let suffixLines = 0 + while ( + suffixLines < currentLines.length - prefixLines && + suffixLines < proposedLines.length - prefixLines && + currentLines[currentLines.length - 1 - suffixLines] === proposedLines[proposedLines.length - 1 - suffixLines] + ) + suffixLines++ + + const diffStartLineInFile = suggestion.editableRegionStartLine + prefixLines + const diffEndLineInFile = suggestion.editableRegionStartLine + currentLines.length - 1 - suffixLines + const trimmedReplacement = proposedLines.slice(prefixLines, proposedLines.length - suffixLines).join("\n") + + nesLog(`diff at lines [${diffStartLineInFile}..${diffEndLineInFile}], cursor at line ${position.line}, ${trimmedReplacement.length} chars`) + + // VSCode's inline ghost text only renders when the diff starts on the cursor's line. + // For off-cursor diffs, stash the suggestion in the manager — it renders a + // decoration-based "jump to next edit" affordance and Tab handles the move/apply. + const isPureInsertion = diffEndLineInFile < diffStartLineInFile + if (isPureInsertion || diffStartLineInFile !== position.line) { + this.stashOffCursorSuggestion(document, diffStartLineInFile, diffEndLineInFile, trimmedReplacement, isPureInsertion, suggestion) + return undefined + } + // Same-line diff: clear any prior off-cursor pending state so we don't render + // two competing affordances. + this.deps.suggestionManager?.clear() + + // Same-line diff: build a range that starts at the cursor's exact position + // and provide insertText for everything from the cursor onward. + const cursorLineText = document.lineAt(position.line).text + const cursorLineCurrent = cursorLineText.slice(position.character) + const cursorLineProposed = proposedLines[prefixLines] + // Guard: if the proposal has fewer lines than the prefix consumed (a pure + // deletion at the trim seam), there's no cursor-line replacement to show. + if (cursorLineProposed === undefined) { + nesLog(`skipping render — proposal has no line at the cursor's index after trim`) + this.deps.onSuggestion?.({ + shown: false, + latencyMs: suggestion.latencyMs, + status: "no-replacement", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + return undefined + } + if (!cursorLineProposed.startsWith(cursorLineText.slice(0, position.character))) { + // The model wants to change characters BEFORE the cursor on the same line — + // can't render that as ghost text either. Skip for v0. + nesLog(`skipping render — diff edits characters before cursor on its line`) + this.deps.onSuggestion?.({ + shown: false, + latencyMs: suggestion.latencyMs, + status: "no-replacement", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + return undefined + } + const insertText = [cursorLineProposed.slice(position.character), ...proposedLines.slice(prefixLines + 1, proposedLines.length - suffixLines)].join("\n") + const renderEndLine = pickRenderEndLine(document, position.line, diffEndLineInFile, insertText) + const renderRange = new vscode.Range(position, new vscode.Position(renderEndLine, document.lineAt(renderEndLine).range.end.character)) + // Compute the existing text from cursor → end of diff region for sanity. + const _existingFromCursor = document.getText(renderRange) + if (_existingFromCursor === cursorLineCurrent && cursorLineCurrent === insertText) { + nesLog(`post-trim no-op`) + return undefined + } + + const item = new vscode.InlineCompletionItem(insertText, renderRange, { + command: INLINE_COMPLETION_ACCEPTED_COMMAND, + title: "Next Edit Accepted", + }) + nesLog(`RENDER range=[${renderRange.start.line}:${renderRange.start.character}..${renderRange.end.line}:${renderRange.end.character}] insertChars=${insertText.length}`) + this.deps.onSuggestion?.({ + shown: true, + latencyMs: suggestion.latencyMs, + status: "ok", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + return [item] + } + + private stashOffCursorSuggestion( + document: vscode.TextDocument, + diffStartLine: number, + diffEndLine: number, + trimmedReplacement: string, + isPureInsertion: boolean, + suggestion: { latencyMs: number; inputTokens?: number; outputTokens?: number }, + ): void { + const mgr = this.deps.suggestionManager + if (!mgr) { + // Manager wasn't wired — fall through silently. The classic path + // already covers same-line completions; this branch only matters in + // tests or misconfigured embeds. + this.deps.onSuggestion?.({ + shown: false, + latencyMs: suggestion.latencyMs, + status: "no-replacement", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + return + } + if (isPureInsertion) { + // The original text we snapshot must come from the line VSCode will see + // when the user later accepts. For mid-file inserts that's `diffStartLine` + // (the line that gets pushed down). For EOF inserts (diffStartLine === + // lineCount) there is no such line; fall back to lineCount-1 (the last + // line, which will sit just above the inserted content). The + // SuggestionManager's drift guard knows to compare against this anchor. + const isEof = diffStartLine >= document.lineCount + const anchorLine = isEof + ? Math.max(0, document.lineCount - 1) + : Math.max(0, Math.min(diffStartLine, document.lineCount - 1)) + mgr.setPending({ + kind: "insert", + document, + diffStartLine, + diffEndLine: diffStartLine, + replacement: trimmedReplacement + "\n", + originalText: document.lineAt(anchorLine).text, + }) + nesLog(`insert suggestion stashed at line ${diffStartLine} (anchor=${anchorLine}, eof=${isEof}, ${trimmedReplacement.length} chars)`) + } else { + const originalRange = new vscode.Range( + new vscode.Position(diffStartLine, 0), + new vscode.Position(diffEndLine, document.lineAt(diffEndLine).range.end.character), + ) + mgr.setPending({ + kind: "replace", + document, + diffStartLine, + diffEndLine, + replacement: trimmedReplacement, + originalText: document.getText(originalRange), + }) + nesLog(`replace suggestion stashed at lines [${diffStartLine}..${diffEndLine}]`) + } + this.deps.onSuggestion?.({ + shown: true, + latencyMs: suggestion.latencyMs, + status: "ok", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + } + + private handleError(err: unknown): undefined { + if ((err as Error)?.name === "AbortError") return undefined + const status = err instanceof MercuryEditError ? err.status : null + this.deps.onSuggestion?.({ + shown: false, + latencyMs: 0, + status: "error", + errorStatus: status ?? undefined, + }) + if (status === 401 || status === 402) this.deps.onFatalError?.(status) + return undefined + } + + private debounce(ms: number, token: vscode.CancellationToken): Promise { + if (this.debounceTimer) clearTimeout(this.debounceTimer) + return new Promise((resolve) => { + this.debounceTimer = setTimeout(resolve, ms) + token.onCancellationRequested(() => { + if (this.debounceTimer) clearTimeout(this.debounceTimer) + resolve() + }) + }) + } +} + +export { INLINE_COMPLETION_ACCEPTED_COMMAND } + +/** + * VSCode's inline ghost text silently fails to render when the completion's + * range crosses a line boundary but the insert text has no newline (typical + * when Mercury implicitly drops a trailing blank line as file-end + * normalization). When that happens — and the lines past the cursor are + * blank — cap the range at the cursor's line so the ghost renders cleanly. + */ +function pickRenderEndLine( + document: vscode.TextDocument, + cursorLine: number, + diffEndLine: number, + insertText: string, +): number { + if (diffEndLine <= cursorLine) return diffEndLine + if (insertText.includes("\n")) return diffEndLine + for (let l = cursorLine + 1; l <= diffEndLine; l++) { + if (document.lineAt(l).text.trim() !== "") return diffEndLine + } + return cursorLine +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts new file mode 100644 index 0000000000..be00e64f47 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -0,0 +1,334 @@ +import * as vscode from "vscode" +import { nesLog } from "./log" + +const PENDING_CONTEXT_KEY = "kilo-code.nextEdit.hasPendingSuggestion" + +export type PendingNextEdit = + | { + kind: "replace" + document: vscode.TextDocument + /** Inclusive start line of the lines being replaced. */ + diffStartLine: number + /** Inclusive end line of the lines being replaced. */ + diffEndLine: number + /** New text to substitute for [diffStartLine, diffEndLine]. */ + replacement: string + /** Snapshot of the original text — used to detect drift. */ + originalText: string + } + | { + kind: "insert" + document: vscode.TextDocument + /** Existing line BEFORE which the new content will be inserted. */ + diffStartLine: number + /** Same as diffStartLine for hint/jump-target purposes. */ + diffEndLine: number + /** Lines to insert. Must end with a newline so existing content gets pushed down. */ + replacement: string + /** Snapshot of the surrounding (single) line — used as a soft drift guard. */ + originalText: string + } + +/** + * Holds the currently-pending out-of-cursor NES suggestion and renders a + * jump-to-next-edit affordance via editor decorations. Same-line diffs are + * still handled by `InlineCompletionItem` (faster, native ghost text) — this + * manager is for everything else. + * + * Lifecycle: at most one pending suggestion at a time. A pending suggestion + * is cleared when the user accepts, dismisses, edits inside the diff range, + * or moves to a different document. + */ +export class NextEditSuggestionManager implements vscode.Disposable { + private pending: PendingNextEdit | null = null + private readonly subscriptions: vscode.Disposable[] = [] + + private readonly removedLineDecoration: vscode.TextEditorDecorationType + private readonly proposedLineDecoration: vscode.TextEditorDecorationType + private readonly hintDecoration: vscode.TextEditorDecorationType + + constructor() { + // Tints + strikethrough on the lines that will be replaced or removed. + this.removedLineDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + backgroundColor: new vscode.ThemeColor("diffEditor.removedLineBackground"), + overviewRulerColor: new vscode.ThemeColor("editorInfo.foreground"), + overviewRulerLane: vscode.OverviewRulerLane.Left, + textDecoration: "line-through; opacity: 0.65;", + }) + // Inline `after` text showing the proposed replacement line. + this.proposedLineDecoration = vscode.window.createTextEditorDecorationType({ + after: { + margin: "0 0 0 2em", + color: new vscode.ThemeColor("editorInfo.foreground"), + fontStyle: "italic", + }, + }) + // The one-line user-facing hint. + this.hintDecoration = vscode.window.createTextEditorDecorationType({ + after: { + margin: "0 0 0 2em", + color: new vscode.ThemeColor("editorCodeLens.foreground"), + fontStyle: "italic", + }, + }) + + // Dismiss when the document or selection moves in ways that invalidate + // the prediction. + this.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((e) => { + const p = this.pending + if (!p) return + if (e.document !== p.document) return + // For "insert" we just confirm the anchor line is still there with its + // original content; for "replace" we re-check the full range. + let stillValid = true + try { + if (p.kind === "replace") { + const text = e.document.getText( + new vscode.Range( + new vscode.Position(p.diffStartLine, 0), + new vscode.Position(p.diffEndLine, e.document.lineAt(p.diffEndLine).range.end.character), + ), + ) + stillValid = text === p.originalText + } else { + // Insert mode: only invalidate if the anchor line shifted. + const anchorLine = Math.min(p.diffStartLine, e.document.lineCount - 1) + const anchorText = e.document.lineAt(anchorLine).text + stillValid = anchorText === p.originalText + } + } catch { + stillValid = false + } + if (!stillValid) this.clear() + }), + vscode.window.onDidChangeActiveTextEditor(() => this.clear()), + // When the cursor moves (e.g., post-jump), refresh the hint so it + // flips between "Tab to jump" and "Tab to apply". + vscode.window.onDidChangeTextEditorSelection((e) => { + if (!this.pending) return + if (e.textEditor.document !== this.pending.document) return + this.renderDecorations(this.pending) + }), + ) + } + + public isPending(): boolean { + return this.pending !== null + } + + public getPending(): PendingNextEdit | null { + return this.pending + } + + public setPending(p: PendingNextEdit): void { + this.clearDecorations() + this.pending = p + void vscode.commands.executeCommand("setContext", PENDING_CONTEXT_KEY, true) + // Hide any in-flight inline suggestion so it can't compete with our Tab handler. + void vscode.commands.executeCommand("editor.action.inlineSuggest.hide") + this.renderDecorations(p) + } + + public clear(): void { + if (!this.pending) return + this.pending = null + this.clearDecorations() + void vscode.commands.executeCommand("setContext", PENDING_CONTEXT_KEY, false) + } + + /** Tab handler — accept if cursor near the diff, else jump. */ + public async acceptOrJump(): Promise { + const p = this.pending + if (!p) return + const editor = vscode.window.activeTextEditor + if (!editor || editor.document !== p.document) { + this.clear() + return + } + const cursor = editor.selection.active + const inside = + p.kind === "replace" + ? cursor.line >= p.diffStartLine && cursor.line <= p.diffEndLine + : cursor.line === p.diffStartLine || cursor.line === p.diffStartLine - 1 + if (inside) { + await this.applyPending() + } else { + const targetLine = Math.min(p.diffStartLine, Math.max(0, p.document.lineCount - 1)) + const targetChar = p.document.lineAt(targetLine).firstNonWhitespaceCharacterIndex + const target = new vscode.Position(targetLine, targetChar) + editor.selection = new vscode.Selection(target, target) + editor.revealRange(new vscode.Range(target, target), vscode.TextEditorRevealType.InCenterIfOutsideViewport) + nesLog(`jumped cursor ${cursor.line} -> ${target.line} (pending diff at [${p.diffStartLine}..${p.diffEndLine}])`) + // Refresh hint immediately so "Tab to apply" is shown. + this.renderDecorations(p) + } + } + + private async applyPending(): Promise { + const p = this.pending + if (!p) return + const editor = vscode.window.activeTextEditor + if (!editor || editor.document !== p.document) { + this.clear() + return + } + // Snapshot what we're about to do, then nuke pending state so the upcoming + // document change doesn't re-enter via the invalidation listener. + this.clearDecorations() + this.pending = null + void vscode.commands.executeCommand("setContext", PENDING_CONTEXT_KEY, false) + + let ok = false + if (p.kind === "insert") { + const pos = new vscode.Position(p.diffStartLine, 0) + ok = await editor.edit((b) => b.insert(pos, p.replacement)) + nesLog(`applied insert at line ${pos.line} (${p.replacement.length} chars, ok=${ok})`) + } else { + const range = new vscode.Range( + new vscode.Position(p.diffStartLine, 0), + new vscode.Position(p.diffEndLine, p.document.lineAt(p.diffEndLine).range.end.character), + ) + const currentInDoc = editor.document.getText(range) + if (currentInDoc !== p.originalText) { + nesLog(`document drifted since suggestion was made — dropping range [${p.diffStartLine}..${p.diffEndLine}]`) + return + } + ok = await editor.edit((b) => b.replace(range, p.replacement)) + nesLog(`applied replace at lines [${p.diffStartLine}..${p.diffEndLine}] (ok=${ok})`) + } + if (ok) chainNextPrediction() + } + + private renderDecorations(p: PendingNextEdit): void { + // Same document can be open in multiple splits — paint all of them so the + // user sees the decoration regardless of which split has focus. + const editors = vscode.window.visibleTextEditors.filter((e) => e.document === p.document) + if (editors.length === 0) return + + const removedRanges: vscode.Range[] = [] + const proposedAnnotations: vscode.DecorationOptions[] = [] + + if (p.kind === "replace") { + const originalLines = p.originalText.split("\n") + const proposedLines = p.replacement.split("\n") + const minLen = Math.min(originalLines.length, proposedLines.length) + for (let i = 0; i < minLen; i++) { + if (originalLines[i] === proposedLines[i]) continue + const lineNo = p.diffStartLine + i + const lineRange = p.document.lineAt(lineNo).range + removedRanges.push(lineRange) + proposedAnnotations.push({ + range: new vscode.Range(lineRange.end, lineRange.end), + renderOptions: { after: { contentText: `→ ${visualize(proposedLines[i])}` } }, + }) + } + // Pure deletions inside a replace + for (let i = minLen; i < originalLines.length; i++) { + const lineNo = p.diffStartLine + i + const lineRange = p.document.lineAt(lineNo).range + removedRanges.push(lineRange) + proposedAnnotations.push({ + range: new vscode.Range(lineRange.end, lineRange.end), + renderOptions: { after: { contentText: `→ (removed)` } }, + }) + } + // Additions inside a replace — anchor on last shared line + if (proposedLines.length > originalLines.length) { + const tailLineNo = p.diffStartLine + originalLines.length - 1 + const safeLine = Math.max(p.diffStartLine, Math.min(tailLineNo, p.diffEndLine)) + const tailRange = p.document.lineAt(safeLine).range + const added = proposedLines.slice(originalLines.length).map(visualize).join(" ⏎ ") + proposedAnnotations.push({ + range: new vscode.Range(tailRange.end, tailRange.end), + renderOptions: { after: { contentText: `+ ${added}` } }, + }) + } + } else { + // Pure insertion: anchor the ghost text on the existing line, no strikethrough. + const anchorLine = Math.min(p.diffStartLine, p.document.lineCount - 1) + const safeAnchor = Math.max(0, anchorLine) + const anchorRange = p.document.lineAt(safeAnchor).range + // Strip the trailing \n we appended for insertion semantics, then show each + // inserted line collapsed with a small separator. + const lines = p.replacement.replace(/\n$/, "").split("\n").map(visualize) + const inserted = lines.join(" ⏎ ") + proposedAnnotations.push({ + range: new vscode.Range(anchorRange.end, anchorRange.end), + renderOptions: { after: { contentText: `+ ${inserted}` } }, + }) + } + + // Hint anchor + cursor check use the active editor if it's one of ours, + // else fall back to the first visible editor for this document. + const active = vscode.window.activeTextEditor + const referenceEditor = + active && editors.includes(active) ? active : editors[0] + const hintAnchor = Math.min(p.diffStartLine, p.document.lineCount - 1) + const hintLineEnd = p.document.lineAt(Math.max(0, hintAnchor)).range.end + const cursor = referenceEditor.selection.active + const cursorAtDiff = + p.kind === "replace" + ? cursor.line >= p.diffStartLine && cursor.line <= p.diffEndLine + : cursor.line === p.diffStartLine || cursor.line === p.diffStartLine - 1 + const hintText = cursorAtDiff + ? " ↳ Tab to apply · Esc to dismiss" + : " ↳ Tab to jump here · Esc to dismiss" + const hintOptions: vscode.DecorationOptions[] = [ + { + range: new vscode.Range(hintLineEnd, hintLineEnd), + renderOptions: { after: { contentText: hintText } }, + }, + ] + + for (const editor of editors) { + editor.setDecorations(this.removedLineDecoration, removedRanges) + editor.setDecorations(this.proposedLineDecoration, proposedAnnotations) + editor.setDecorations(this.hintDecoration, hintOptions) + } + } + + private clearDecorations(): void { + for (const editor of vscode.window.visibleTextEditors) { + editor.setDecorations(this.removedLineDecoration, []) + editor.setDecorations(this.proposedLineDecoration, []) + editor.setDecorations(this.hintDecoration, []) + } + } + + public dispose(): void { + this.clear() + for (const s of this.subscriptions) s.dispose() + this.subscriptions.length = 0 + this.removedLineDecoration.dispose() + this.proposedLineDecoration.dispose() + this.hintDecoration.dispose() + } +} + +/** + * Re-invoke VSCode's inline-suggest UI after an accept so the provider fires + * again and surfaces the next prediction without the user having to type. + * This is the "Tab-Tab-Tab" walk-through-a-refactor UX from Cursor. + * + * A short delay lets the document change settle before we re-enter + * `provideInlineCompletionItems`, and gives the user a moment to abandon the + * chain by typing or moving the cursor. + */ +export function chainNextPrediction(delayMs = 60): void { + setTimeout(() => { + void vscode.commands.executeCommand("editor.action.inlineSuggest.trigger") + }, delayMs) +} + +function visualize(line: string): string { + // VSCode after-text decorations don't support newlines — collapse just in case. + // Also surface leading whitespace explicitly so it isn't visually swallowed. + const collapsed = line.replace(/\s+$/g, "").replace(/^\t+/, (t) => " ".repeat(t.length)) + return collapsed.length > 120 ? collapsed.slice(0, 117) + "…" : collapsed +} + +export const NEXT_EDIT_ACCEPT_OR_JUMP_COMMAND = "kilo-code.next-edit.acceptOrJump" +export const NEXT_EDIT_DISMISS_COMMAND = "kilo-code.next-edit.dismiss" +export const NEXT_EDIT_PENDING_CONTEXT_KEY = PENDING_CONTEXT_KEY diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editCompletionParser.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editCompletionParser.spec.ts new file mode 100644 index 0000000000..5d340487e5 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editCompletionParser.spec.ts @@ -0,0 +1,26 @@ +import { parseMercuryEditReply } from "../editCompletionParser" + +describe("parseMercuryEditReply", () => { + it("extracts the fenced body when the model returns plain triple backticks", () => { + const reply = "Some preamble\n```\nfunction foo() {\n return 1\n}\n```\n" + expect(parseMercuryEditReply(reply)).toBe("function foo() {\n return 1\n}") + }) + + it("extracts the body when the fence has a language tag", () => { + const reply = "```typescript\nconst x = 1\n```" + expect(parseMercuryEditReply(reply)).toBe("const x = 1") + }) + + it("strips Mercury <|code_to_edit|> sentinels when the model includes them", () => { + const reply = "```\n<|code_to_edit|>\nconst x = 2\n<|/code_to_edit|>\n```" + expect(parseMercuryEditReply(reply)).toBe("const x = 2") + }) + + it("returns null when no fenced block is present", () => { + expect(parseMercuryEditReply("just text, no fence")).toBeNull() + }) + + it("returns null on an empty string", () => { + expect(parseMercuryEditReply("")).toBeNull() + }) +}) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editableRegion.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editableRegion.spec.ts new file mode 100644 index 0000000000..d11f3543c1 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editableRegion.spec.ts @@ -0,0 +1,37 @@ +import { MAX_EDITABLE_REGION_LINES } from "../constants" +import { computeEditableRegion } from "../editableRegion" + +describe("computeEditableRegion", () => { + it("returns the default [-5, +10] window around the cursor", () => { + const r = computeEditableRegion({ cursorLine: 20, totalLines: 100 }) + expect(r.startLine).toBe(15) + expect(r.endLine).toBe(30) + }) + + it("clips at file start", () => { + const r = computeEditableRegion({ cursorLine: 2, totalLines: 50 }) + expect(r.startLine).toBe(0) + expect(r.endLine).toBe(12) + }) + + it("clips at file end", () => { + const r = computeEditableRegion({ cursorLine: 49, totalLines: 50 }) + expect(r.endLine).toBe(49) + expect(r.startLine).toBe(44) + }) + + it("caps the region at MAX_EDITABLE_REGION_LINES", () => { + const r = computeEditableRegion({ + cursorLine: 100, + totalLines: 1000, + topMargin: 100, + bottomMargin: 100, + }) + expect(r.endLine - r.startLine + 1).toBeLessThanOrEqual(MAX_EDITABLE_REGION_LINES) + }) + + it("handles an empty document gracefully", () => { + const r = computeEditableRegion({ cursorLine: 0, totalLines: 0 }) + expect(r).toEqual({ startLine: 0, endLine: 0 }) + }) +}) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/mercuryPromptTemplate.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/mercuryPromptTemplate.spec.ts new file mode 100644 index 0000000000..c01c4890fa --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/mercuryPromptTemplate.spec.ts @@ -0,0 +1,125 @@ +import { + MERCURY_CODE_TO_EDIT_CLOSE, + MERCURY_CODE_TO_EDIT_OPEN, + MERCURY_CURRENT_FILE_CONTENT_CLOSE, + MERCURY_CURRENT_FILE_CONTENT_OPEN, + MERCURY_CURSOR, + MERCURY_EDIT_DIFF_HISTORY_CLOSE, + MERCURY_EDIT_DIFF_HISTORY_OPEN, + MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_CLOSE, + MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN, + MERCURY_UNIQUE_TOKEN, +} from "../constants" +import { + buildMercuryEditPrompt, + currentFileContentBlock, + editDiffHistoryBlock, + recentlyViewedSnippetsBlock, +} from "../mercuryPromptTemplate" + +describe("mercuryPromptTemplate", () => { + describe("recentlyViewedSnippetsBlock", () => { + it("wraps in open/close sentinels even when empty", () => { + const out = recentlyViewedSnippetsBlock([]) + expect(out.startsWith(MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN)).toBe(true) + expect(out.endsWith(MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_CLOSE)).toBe(true) + }) + + it("emits one inner block per snippet with the file-path header", () => { + const out = recentlyViewedSnippetsBlock([ + { filepath: "src/a.ts", content: "const a = 1" }, + { filepath: "src/b.ts", content: "const b = 2" }, + ]) + expect(out).toContain("code_snippet_file_path: src/a.ts") + expect(out).toContain("code_snippet_file_path: src/b.ts") + expect(out).toContain("const a = 1") + expect(out).toContain("const b = 2") + }) + }) + + describe("currentFileContentBlock", () => { + it("inserts <|cursor|> at the right character and wraps the editable region", () => { + const file = ["function foo() {", " return 1", "}"].join("\n") + const out = currentFileContentBlock("src/foo.ts", file, 1, 1, 1, 2) + expect(out).toContain(MERCURY_CURRENT_FILE_CONTENT_OPEN) + expect(out).toContain(MERCURY_CURRENT_FILE_CONTENT_CLOSE) + expect(out).toContain("current_file_path: src/foo.ts") + expect(out).toContain(` ${MERCURY_CURSOR}return 1`) + // Open marker precedes the editable region's first line; close marker follows it. + const openIdx = out.indexOf(MERCURY_CODE_TO_EDIT_OPEN) + const lineIdx = out.indexOf("return 1") + const closeIdx = out.indexOf(MERCURY_CODE_TO_EDIT_CLOSE) + expect(openIdx).toBeGreaterThan(-1) + expect(closeIdx).toBeGreaterThan(openIdx) + expect(lineIdx).toBeGreaterThan(openIdx) + expect(lineIdx).toBeLessThan(closeIdx) + }) + + it("clamps an out-of-range cursor instead of throwing", () => { + const file = "only-line" + const out = currentFileContentBlock("p.ts", file, 0, 0, 0, 9999) + expect(out).toContain(`only-line${MERCURY_CURSOR}`) + }) + }) + + describe("editDiffHistoryBlock", () => { + it("strips the createPatch index+separator lines from each diff", () => { + const fakeDiff = ["Index: foo.ts", "===", "@@ -1,1 +1,1 @@", "-old", "+new"].join("\n") + const out = editDiffHistoryBlock([fakeDiff]) + expect(out).toContain("@@ -1,1 +1,1 @@") + expect(out).not.toContain("Index: foo.ts") + expect(out).not.toContain("===") + expect(out.startsWith(MERCURY_EDIT_DIFF_HISTORY_OPEN)).toBe(true) + expect(out.endsWith(MERCURY_EDIT_DIFF_HISTORY_CLOSE)).toBe(true) + }) + + it("separates multiple diffs with a blank line so Mercury parses them as distinct hunks", () => { + const diff1 = ["Index: a.ts", "===", "@@ -1,1 +1,1 @@", "-a", "+aa"].join("\n") + const diff2 = ["Index: b.ts", "===", "@@ -2,1 +2,1 @@", "-b", "+bb"].join("\n") + const out = editDiffHistoryBlock([diff1, diff2]) + // Both hunk headers should appear separated by a blank line. + const idx1 = out.indexOf("@@ -1,1 +1,1 @@") + const idx2 = out.indexOf("@@ -2,1 +2,1 @@") + expect(idx1).toBeGreaterThan(-1) + expect(idx2).toBeGreaterThan(idx1) + const between = out.slice(idx1, idx2) + // The body between the two hunk headers must contain at least one empty line. + expect(between).toContain("\n\n") + }) + }) + + describe("buildMercuryEditPrompt", () => { + it("assembles all three blocks in the documented order", () => { + const out = buildMercuryEditPrompt({ + currentFilePath: "p.ts", + currentFileContent: "a\nb\nc", + cursorLine: 1, + cursorCharacter: 0, + editableRegionStartLine: 1, + editableRegionEndLine: 1, + recentlyViewedSnippets: [], + editDiffHistory: [], + }) + const snippetsIdx = out.indexOf(MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN) + const fileIdx = out.indexOf(MERCURY_CURRENT_FILE_CONTENT_OPEN) + const diffIdx = out.indexOf(MERCURY_EDIT_DIFF_HISTORY_OPEN) + expect(snippetsIdx).toBeGreaterThan(-1) + expect(fileIdx).toBeGreaterThan(snippetsIdx) + expect(diffIdx).toBeGreaterThan(fileIdx) + }) + + it("trails the user prompt with the NES unique token so Mercury recognises the call as next-edit", () => { + const out = buildMercuryEditPrompt({ + currentFilePath: "p.ts", + currentFileContent: "a\nb", + cursorLine: 0, + cursorCharacter: 0, + editableRegionStartLine: 0, + editableRegionEndLine: 1, + recentlyViewedSnippets: [], + editDiffHistory: [], + }) + expect(out.endsWith(MERCURY_UNIQUE_TOKEN)).toBe(true) + }) + }) +}) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/recentSnippetsAdapter.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/recentSnippetsAdapter.spec.ts new file mode 100644 index 0000000000..828f5ac09d --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/recentSnippetsAdapter.spec.ts @@ -0,0 +1,39 @@ +import { toMercuryRecentSnippets } from "../recentSnippetsAdapter" + +describe("toMercuryRecentSnippets", () => { + it("returns an empty array when no snippets are supplied", () => { + expect(toMercuryRecentSnippets([])).toEqual([]) + }) + + it("caps the number of snippets at 5", () => { + const snippets = Array.from({ length: 12 }, (_, i) => ({ + filepath: `file://${i}.ts`, + content: `const x${i} = ${i}`, + })) + const out = toMercuryRecentSnippets(snippets) + expect(out.length).toBe(5) + }) + + it("reverses input order (service returns newest→oldest, Mercury wants oldest→newest)", () => { + const out = toMercuryRecentSnippets([ + { filepath: "a.ts", content: "newest" }, + { filepath: "b.ts", content: "middle" }, + { filepath: "c.ts", content: "oldest" }, + ]) + expect(out.map((s) => s.content)).toEqual(["oldest", "middle", "newest"]) + }) + + it("trims content above 20 lines to a centered window", () => { + const content = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n") + const [snippet] = toMercuryRecentSnippets([{ filepath: "x.ts", content }]) + const lines = snippet.content.split("\n") + expect(lines.length).toBe(20) + // Center: lines should be drawn from somewhere in the middle of the input. + expect(lines[0]).toMatch(/^line[12]\d$/) + }) + + it("passes through filepath verbatim when not a parsable URI", () => { + const [out] = toMercuryRecentSnippets([{ filepath: "not a uri", content: "x" }]) + expect(out.filepath).toBe("not a uri") + }) +}) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts new file mode 100644 index 0000000000..a2c3fa4ae4 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts @@ -0,0 +1,37 @@ +/** + * Sentinel tokens used to template the prompt for Mercury Edit 2 via the + * Inception `/v1/edit/completions` endpoint. The tag set is defined by the + * model and must be reproduced verbatim — see + * https://docs.inceptionlabs.ai/capabilities/next-edit + */ + +export const MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN = "<|recently_viewed_code_snippets|>" +export const MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_CLOSE = "<|/recently_viewed_code_snippets|>" +export const MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_OPEN = "<|recently_viewed_code_snippet|>" +export const MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_CLOSE = "<|/recently_viewed_code_snippet|>" +export const MERCURY_CURRENT_FILE_CONTENT_OPEN = "<|current_file_content|>" +export const MERCURY_CURRENT_FILE_CONTENT_CLOSE = "<|/current_file_content|>" +export const MERCURY_CODE_TO_EDIT_OPEN = "<|code_to_edit|>" +export const MERCURY_CODE_TO_EDIT_CLOSE = "<|/code_to_edit|>" +export const MERCURY_EDIT_DIFF_HISTORY_OPEN = "<|edit_diff_history|>" +export const MERCURY_EDIT_DIFF_HISTORY_CLOSE = "<|/edit_diff_history|>" +export const MERCURY_CURSOR = "<|cursor|>" + +export const MERCURY_EDIT_MODEL_ID = "mercury-edit-2" +export const INCEPTION_API_BASE_URL = "https://api.inceptionlabs.ai/v1" +export const INCEPTION_EDIT_PATH = "/edit/completions" + +/** Token Mercury Edit uses to distinguish next-edit calls from regular chat. */ +export const MERCURY_UNIQUE_TOKEN = "<|!@#IS_NEXT_EDIT!@#|>" + +// Note: the /v1/edit/completions endpoint accepts only a `role: "user"` +// message — Mercury bakes the system prompt in server-side. Do not send a +// client-side system prompt; the endpoint returns 400 if you do. + +/** + * Per docs: editable region size dominates output latency. Centering around + * the cursor with [-5, +10] is the recommended starting point. + */ +export const DEFAULT_EDITABLE_REGION_TOP_MARGIN = 5 +export const DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN = 10 +export const MAX_EDITABLE_REGION_LINES = 25 diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editCompletionParser.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editCompletionParser.ts new file mode 100644 index 0000000000..99623afdf7 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editCompletionParser.ts @@ -0,0 +1,33 @@ +import { MERCURY_CODE_TO_EDIT_CLOSE, MERCURY_CODE_TO_EDIT_OPEN } from "./constants" + +/** + * Mercury Edit 2 returns the rewritten editable region wrapped in a triple-backtick + * fence. The system prompt asks the model to include `<|code_to_edit|>` markers, + * so we strip those as well when present. + */ +export function parseMercuryEditReply(message: string): string | null { + if (!message) return null + + const fenceOpen = message.indexOf("```") + if (fenceOpen === -1) return null + // Skip past the opening fence + optional language tag + newline. + const afterFenceOpen = message.indexOf("\n", fenceOpen + 3) + if (afterFenceOpen === -1) return null + + const fenceClose = message.lastIndexOf("```") + if (fenceClose <= afterFenceOpen) return null + + let body = message.slice(afterFenceOpen + 1, fenceClose) + // Trim a single trailing newline if the model added one before the fence. + if (body.endsWith("\n")) body = body.slice(0, -1) + + // Strip Mercury's `<|code_to_edit|>` markers when included. + body = body.replace(new RegExp(`^${escape(MERCURY_CODE_TO_EDIT_OPEN)}\\n?`), "") + body = body.replace(new RegExp(`\\n?${escape(MERCURY_CODE_TO_EDIT_CLOSE)}$`), "") + + return body +} + +function escape(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts new file mode 100644 index 0000000000..710da5132c --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts @@ -0,0 +1,106 @@ +import { createPatch } from "diff" +import * as vscode from "vscode" + +const DEFAULT_DEBOUNCE_MS = 1500 +const DEFAULT_MAX_DIFFS = 5 + +/** + * Per-file snapshot tracker that emits range-based unidiffs after a short + * idle window — matching the Mercury docs' guidance: "if a user made multiple + * modifications in the same area, combine them into a single unidiff rather + * than many granular diffs." + * + * Diffs are produced lazily; the tracker holds the previously-emitted state + * per file and computes the diff against the current document content when + * the debounce fires. + */ +export class EditHistoryTracker implements vscode.Disposable { + private readonly snapshots = new Map() + private readonly pendingTimers = new Map() + private readonly diffs: string[] = [] + private readonly subscriptions: vscode.Disposable[] = [] + + constructor( + private readonly options: { debounceMs?: number; maxDiffs?: number } = {}, + ) { + const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS + + this.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((event) => { + if (event.document.uri.scheme !== "file") return + if (event.contentChanges.length === 0) return + this.scheduleSnapshotDiff(event.document, debounceMs) + }), + ) + this.subscriptions.push( + vscode.workspace.onDidCloseTextDocument((doc) => { + const key = doc.uri.fsPath + const t = this.pendingTimers.get(key) + if (t) clearTimeout(t) + this.pendingTimers.delete(key) + this.snapshots.delete(key) + }), + ) + } + + /** + * Force the pending diff (if any) for `document` to be emitted now. Call + * this immediately before building a request so the freshest user edit + * makes it into the prompt. + */ + public flush(document: vscode.TextDocument): void { + const key = document.uri.fsPath + const t = this.pendingTimers.get(key) + if (t) clearTimeout(t) + this.pendingTimers.delete(key) + this.emitDiffNow(document) + } + + /** Oldest → newest, matching the Mercury prompt-history convention. */ + public getRecentDiffs(): string[] { + return [...this.diffs] + } + + public dispose(): void { + for (const t of this.pendingTimers.values()) clearTimeout(t) + this.pendingTimers.clear() + for (const s of this.subscriptions) s.dispose() + this.subscriptions.length = 0 + } + + private scheduleSnapshotDiff(document: vscode.TextDocument, debounceMs: number): void { + const key = document.uri.fsPath + if (!this.snapshots.has(key)) { + // Seed the snapshot lazily — the first change is lost (we never saw + // the pre-edit state) but every subsequent edit window produces a + // useful diff. + this.snapshots.set(key, document.getText()) + return + } + const existing = this.pendingTimers.get(key) + if (existing) clearTimeout(existing) + const timer = setTimeout(() => { + this.pendingTimers.delete(key) + this.emitDiffNow(document) + }, debounceMs) + this.pendingTimers.set(key, timer) + } + + private emitDiffNow(document: vscode.TextDocument): void { + const key = document.uri.fsPath + const previous = this.snapshots.get(key) + if (previous === undefined) return + const current = document.getText() + if (current === previous) return + + const filename = vscode.workspace.asRelativePath(document.uri, false) + const patch = createPatch(filename, previous, current, undefined, undefined, { context: 1 }) + // `createPatch` returns "" for identical inputs; guard anyway. + if (patch && patch.trim().length > 0) { + this.diffs.push(patch) + const maxDiffs = this.options.maxDiffs ?? DEFAULT_MAX_DIFFS + if (this.diffs.length > maxDiffs) this.diffs.shift() + } + this.snapshots.set(key, current) + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editableRegion.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editableRegion.ts new file mode 100644 index 0000000000..16be4a992b --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editableRegion.ts @@ -0,0 +1,43 @@ +import { + DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN, + DEFAULT_EDITABLE_REGION_TOP_MARGIN, + MAX_EDITABLE_REGION_LINES, +} from "./constants" + +export interface EditableRegionInputs { + cursorLine: number + totalLines: number + topMargin?: number + bottomMargin?: number +} + +export interface EditableRegion { + startLine: number + endLine: number +} + +/** + * Editable region selection per the Mercury docs: center [-top, +bottom] around + * the cursor, clipped to file bounds. Capped to MAX_EDITABLE_REGION_LINES (~25) + * because output tokens dominate latency. + */ +export function computeEditableRegion({ + cursorLine, + totalLines, + topMargin = DEFAULT_EDITABLE_REGION_TOP_MARGIN, + bottomMargin = DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN, +}: EditableRegionInputs): EditableRegion { + if (totalLines <= 0) return { startLine: 0, endLine: 0 } + + const lastLine = totalLines - 1 + let start = Math.max(0, cursorLine - topMargin) + let end = Math.min(lastLine, cursorLine + bottomMargin) + + const span = end - start + 1 + if (span > MAX_EDITABLE_REGION_LINES) { + const overflow = span - MAX_EDITABLE_REGION_LINES + // Prefer trimming below the cursor, where we have less semantic context. + end = Math.max(start, end - overflow) + } + return { startLine: start, endLine: end } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts new file mode 100644 index 0000000000..2aac37f5bb --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts @@ -0,0 +1,39 @@ +import * as vscode from "vscode" + +const CHANNEL_NAME = "Kilo Code · Next Edit" +const DEBUG_SETTING = "kilo-code.new.autocomplete.nextEdit.debug" + +let channel: vscode.OutputChannel | null = null + +function getChannel(): vscode.OutputChannel { + if (!channel) channel = vscode.window.createOutputChannel(CHANNEL_NAME) + return channel +} + +function debugEnabled(): boolean { + return ( + vscode.workspace.getConfiguration().get(DEBUG_SETTING) === true || + process.env.KILO_NES_DEBUG === "1" + ) +} + +/** + * Append a single log line to the dedicated NES output channel. Always goes to + * the channel (so a user troubleshooting can flip it on without rebuilding); + * `console.log` is mirrored only when the debug setting is enabled. + */ +export function nesLog(message: string): void { + getChannel().appendLine(`[${new Date().toISOString()}] ${message}`) + if (debugEnabled()) console.log(`[NES] ${message}`) +} + +/** Equivalent of `console.warn` for the channel. */ +export function nesWarn(message: string): void { + getChannel().appendLine(`[${new Date().toISOString()}] WARN ${message}`) + if (debugEnabled()) console.warn(`[NES] ${message}`) +} + +export function disposeLog(): void { + channel?.dispose() + channel = null +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/mercuryPromptTemplate.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/mercuryPromptTemplate.ts new file mode 100644 index 0000000000..62034cbdb5 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/mercuryPromptTemplate.ts @@ -0,0 +1,95 @@ +import { + MERCURY_CODE_TO_EDIT_CLOSE, + MERCURY_CODE_TO_EDIT_OPEN, + MERCURY_CURRENT_FILE_CONTENT_CLOSE, + MERCURY_CURRENT_FILE_CONTENT_OPEN, + MERCURY_CURSOR, + MERCURY_EDIT_DIFF_HISTORY_CLOSE, + MERCURY_EDIT_DIFF_HISTORY_OPEN, + MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_CLOSE, + MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN, + MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_CLOSE, + MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_OPEN, + MERCURY_UNIQUE_TOKEN, +} from "./constants" +import type { MercuryEditRequestContext, MercuryRecentSnippet } from "./types" + +function insertCursorToken(lines: string[], cursorLine: number, cursorCharacter: number): string[] { + if (cursorLine < 0 || cursorLine >= lines.length) return lines + const line = lines[cursorLine] + const safeChar = Math.min(Math.max(cursorCharacter, 0), line.length) + const next = line.slice(0, safeChar) + MERCURY_CURSOR + line.slice(safeChar) + return [...lines.slice(0, cursorLine), next, ...lines.slice(cursorLine + 1)] +} + +export function recentlyViewedSnippetsBlock(snippets: MercuryRecentSnippet[]): string { + const inner = snippets + .map((s) => + [ + MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_OPEN, + `code_snippet_file_path: ${s.filepath}`, + s.content, + MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_CLOSE, + ].join("\n"), + ) + .join("\n") + return [MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN, inner, MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_CLOSE].join("\n") +} + +export function currentFileContentBlock( + currentFilePath: string, + currentFileContent: string, + editableRegionStartLine: number, + editableRegionEndLine: number, + cursorLine: number, + cursorCharacter: number, +): string { + const rawLines = currentFileContent.split("\n") + const withCursor = insertCursorToken(rawLines, cursorLine, cursorCharacter) + const start = Math.max(0, Math.min(editableRegionStartLine, withCursor.length)) + const end = Math.max(start, Math.min(editableRegionEndLine, withCursor.length - 1)) + const instrumented = [ + ...withCursor.slice(0, start), + MERCURY_CODE_TO_EDIT_OPEN, + ...withCursor.slice(start, end + 1), + MERCURY_CODE_TO_EDIT_CLOSE, + ...withCursor.slice(end + 1), + ] + return [ + MERCURY_CURRENT_FILE_CONTENT_OPEN, + `current_file_path: ${currentFilePath}`, + instrumented.join("\n"), + MERCURY_CURRENT_FILE_CONTENT_CLOSE, + ].join("\n") +} + +export function editDiffHistoryBlock(diffs: string[]): string { + // Each unidiff from `diff.createPatch` starts with an Index line and a + // separator we strip — matches the POC's editHistoryBlock. Diffs are + // separated by a blank line so the model parses them as distinct hunks. + const trimmed = diffs.map((d) => { + const lines = d.split("\n") + return lines.length > 2 ? lines.slice(2).join("\n") : d + }) + return [MERCURY_EDIT_DIFF_HISTORY_OPEN, trimmed.join("\n\n"), MERCURY_EDIT_DIFF_HISTORY_CLOSE].join("\n") +} + +export function buildMercuryEditPrompt(ctx: MercuryEditRequestContext): string { + // Trailing unique token signals "this is a next-edit request" to the model. + return [ + recentlyViewedSnippetsBlock(ctx.recentlyViewedSnippets), + "", + currentFileContentBlock( + ctx.currentFilePath, + ctx.currentFileContent, + ctx.editableRegionStartLine, + ctx.editableRegionEndLine, + ctx.cursorLine, + ctx.cursorCharacter, + ), + "", + editDiffHistoryBlock(ctx.editDiffHistory), + "", + MERCURY_UNIQUE_TOKEN, + ].join("\n") +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts new file mode 100644 index 0000000000..a4751b4f07 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts @@ -0,0 +1,45 @@ +import type { AutocompleteCodeSnippet } from "../continuedev/core/autocomplete/types" +import * as vscode from "vscode" +import type { MercuryRecentSnippet } from "./types" + +const MAX_SNIPPET_LINES = 20 +const MAX_SNIPPETS = 5 + +/** + * Convert kilocode's already-collected `RecentlyVisitedRangesService` output + * into the shape Mercury Edit expects for the `<|recently_viewed_code_snippets|>` + * block. Per docs: 3–5 snippets × ~20 lines, oldest → newest, excluding the + * currently active file (the service already filters that out). + * + * `RecentlyVisitedRangesService.getSnippets()` returns snippets newest→oldest; + * we reverse so Mercury sees them in chronological order. + */ +export function toMercuryRecentSnippets( + snippets: ReadonlyArray>, +): MercuryRecentSnippet[] { + return snippets + .slice(0, MAX_SNIPPETS) + .reverse() + .map((s) => ({ + filepath: shortenPath(s.filepath), + content: trimToLines(s.content, MAX_SNIPPET_LINES), + })) +} + +function trimToLines(content: string, maxLines: number): string { + const lines = content.split("\n") + if (lines.length <= maxLines) return content + // Center the trim window — keep the most semantically meaningful core. + const start = Math.floor((lines.length - maxLines) / 2) + return lines.slice(start, start + maxLines).join("\n") +} + +function shortenPath(uri: string): string { + // Convert file:// URI strings to workspace-relative paths so the prompt is compact. + try { + const parsed = vscode.Uri.parse(uri) + return vscode.workspace.asRelativePath(parsed, false) + } catch { + return uri + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/types.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/types.ts new file mode 100644 index 0000000000..a88e346a36 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/types.ts @@ -0,0 +1,26 @@ +export interface MercuryRecentSnippet { + filepath: string + content: string +} + +export interface MercuryEditRequestContext { + currentFilePath: string + currentFileContent: string + cursorLine: number + cursorCharacter: number + editableRegionStartLine: number + editableRegionEndLine: number + recentlyViewedSnippets: MercuryRecentSnippet[] + editDiffHistory: string[] +} + +export interface MercuryEditSuggestion { + /** The replacement text for lines [editableRegionStartLine, editableRegionEndLine]. */ + replacement: string + editableRegionStartLine: number + editableRegionEndLine: number + /** Latency in milliseconds from request send to response parse. */ + latencyMs: number + inputTokens?: number + outputTokens?: number +} diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts index df8a574701..df59e87847 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts @@ -144,6 +144,27 @@ export const FimBody = Schema.Struct({ temperature: Schema.optional(Schema.Finite), }) +// Next Edit (NES) — non-streaming. The VSCode side builds the sentinel-tagged +// prompt (Mercury contract is documented at +// https://docs.inceptionlabs.ai/capabilities/next-edit) and the gateway just +// forwards the message to the upstream edit endpoint. +export const EditBody = Schema.Struct({ + content: Schema.String, + provider: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + maxTokens: Schema.optional(Schema.Finite), +}) + +export const EditResponse = Schema.Struct({ + content: Schema.String, + usage: Schema.optional( + Schema.Struct({ + prompt_tokens: Schema.optional(Schema.Finite), + completion_tokens: Schema.optional(Schema.Finite), + }), + ), +}) + export const AudioTranscriptionsBody = Schema.Struct({ model: Schema.String, input_audio: Schema.Struct({ @@ -195,6 +216,7 @@ export const KiloGatewayPaths = { modes: `${root}/modes`, profile: `${root}/profile`, fim: `${root}/fim`, + edit: `${root}/edit`, audioTranscriptions: `${root}/audio/transcriptions`, notifications: `${root}/notifications`, organization: `${root}/organization`, @@ -239,6 +261,20 @@ export const KiloGatewayApi = HttpApi.make("kilo") description: "Proxy a Fill-in-the-Middle completion request to the Kilo Gateway", }), ), + HttpApiEndpoint.post("edit", KiloGatewayPaths.edit, { + payload: EditBody, + success: described(EditResponse, "Next Edit completion"), + error: [HttpApiError.BadRequest, HttpApiError.Unauthorized], + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilo.edit", + summary: "Next Edit completion", + description: + "Proxy a Mercury-style Next Edit request. The user supplies the already-templated " + + "sentinel-tagged prompt in `content`; the gateway forwards to the upstream edit endpoint " + + "(currently Inception's /v1/edit/completions) and returns the unwrapped reply.", + }), + ), HttpApiEndpoint.post("audioTranscriptions", KiloGatewayPaths.audioTranscriptions, { payload: AudioTranscriptionsBody, success: described(TranscriptionResponse, "Transcription response"), diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index 27b99522c1..e38617ba56 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -20,6 +20,7 @@ import { fetchProfile, } from "@kilocode/kilo-gateway" import { DIRECT_FIM_ENV, requestMistralFim, resolveFimTarget } from "@kilocode/kilo-gateway/fim" +import { DIRECT_EDIT_ENV, resolveEditTarget } from "@kilocode/kilo-gateway/edit" import { buildKiloHeaders } from "@kilocode/kilo-gateway" import { Effect } from "effect" import * as Stream from "effect/Stream" @@ -36,10 +37,29 @@ import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" import { MessageTable, PartTable, SessionTable } from "@/session/session.sql" import { Session } from "@/session/session" import { Database } from "@/storage/db" -import { AudioTranscriptionsBody, FimBody } from "../groups/kilo-gateway" +import { AudioTranscriptionsBody, EditBody, FimBody } from "../groups/kilo-gateway" const FIM_TIMEOUT_MS = 30_000 +/** + * Strip Mercury's triple-backtick fence (and any `<|code_to_edit|>` sentinels + * inside) so the gateway's NES response is just the rewritten code. + */ +function extractFencedBody(message: string): string { + if (!message) return "" + const fenceOpen = message.indexOf("```") + if (fenceOpen === -1) return message + const afterFenceOpen = message.indexOf("\n", fenceOpen + 3) + if (afterFenceOpen === -1) return "" + const fenceClose = message.lastIndexOf("```") + if (fenceClose <= afterFenceOpen) return "" + let body = message.slice(afterFenceOpen + 1, fenceClose) + if (body.endsWith("\n")) body = body.slice(0, -1) + body = body.replace(/^<\|code_to_edit\|>\n?/, "") + body = body.replace(/\n?<\|\/code_to_edit\|>$/, "") + return body +} + export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", (handlers) => Effect.gen(function* () { const auth = yield* Auth.Service @@ -153,6 +173,63 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", ) }) + const edit = Effect.fn("KiloGatewayHttpApi.edit")(function* (ctx: { payload: typeof EditBody.Type }) { + const target = resolveEditTarget(ctx.payload.provider, ctx.payload.model) + if (target.provider !== "inception") { + return yield* Effect.fail(new HttpApiError.BadRequest({})) + } + const token = yield* Effect.gen(function* () { + const item = yield* auth.get(target.provider).pipe(Effect.mapError(() => new HttpApiError.Unauthorized({}))) + if (item?.type === "api") return item.key + return DIRECT_EDIT_ENV[target.provider].map((key) => process.env[key]).find(Boolean) + }) + if (!token) return yield* Effect.fail(new HttpApiError.Unauthorized({})) + + const request = yield* HttpServerRequest.HttpServerRequest + const signal = + request.source instanceof Request + ? AbortSignal.any([request.source.signal, AbortSignal.timeout(FIM_TIMEOUT_MS)]) + : AbortSignal.timeout(FIM_TIMEOUT_MS) + + const response = yield* Effect.promise(async () => { + console.info(`[EDIT] request provider=${target.provider} model=${target.model} chars=${ctx.payload.content.length}`) + return fetch(target.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + signal, + body: JSON.stringify({ + model: target.model, + max_tokens: ctx.payload.maxTokens ?? 512, + // Mercury rejects role:"system" on this endpoint — must be a single user message. + messages: [{ role: "user", content: ctx.payload.content }], + }), + }) + }) + + if (!response.ok) { + return yield* Effect.fail(new HttpApiError.BadRequest({})) + } + + const json = yield* Effect.promise(() => response.json() as Promise<{ + choices?: Array<{ message?: { content?: string } }> + usage?: { prompt_tokens?: number; completion_tokens?: number } + }>) + const raw = json.choices?.[0]?.message?.content ?? "" + const body = extractFencedBody(raw) + return { + content: body, + usage: json.usage + ? { + prompt_tokens: json.usage.prompt_tokens, + completion_tokens: json.usage.completion_tokens, + } + : undefined, + } + }) + const audioTranscriptions = Effect.fn("KiloGatewayHttpApi.audioTranscriptions")(function* (ctx: { payload: typeof AudioTranscriptionsBody.Type }) { @@ -321,6 +398,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", .handle("profile", profile) .handle("modes", modes) .handle("fim", fim) + .handle("edit", edit) .handle("audioTranscriptions", audioTranscriptions) .handle("notifications", notifications) .handle("organization", organization) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 950df5f0f4..f86f833faf 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -97,6 +97,8 @@ import type { KilocodeSessionImportProjectResponses, KilocodeSessionImportSessionErrors, KilocodeSessionImportSessionResponses, + KiloEditErrors, + KiloEditResponses, KiloFimErrors, KiloFimResponses, KiloModesResponses, @@ -5797,6 +5799,49 @@ export class Kilo extends HeyApiClient { }) } + /** + * Next Edit completion + * + * Proxy a Mercury-style Next Edit request. The user supplies the already-templated sentinel-tagged prompt in `content`; the gateway forwards to the upstream edit endpoint (currently Inception's /v1/edit/completions) and returns the unwrapped reply. + */ + public edit( + parameters?: { + directory?: string + workspace?: string + content?: string + provider?: string + model?: string + maxTokens?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "content" }, + { in: "body", key: "provider" }, + { in: "body", key: "model" }, + { in: "body", key: "maxTokens" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/kilo/edit", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Get Kilo notifications * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e49830dc36..77bdc0bca9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8,16 +8,18 @@ export type Event = | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventKilocodeAgentManagerStart + | EventIndexingStatus | EventServerInstanceDisposed | EventLspClientDiagnostics | EventLspUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventSessionNetworkAsked @@ -46,7 +48,6 @@ export type Event = | EventSessionCompacted | EventCommandExecuted | EventProjectUpdated - | EventKilocodeAgentManagerStart | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -91,7 +92,6 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventIndexingStatus export type OAuth = { type: "oauth" @@ -118,6 +118,71 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" + +export type IndexingStatus = { + state: IndexingStatusState + message: string + processedFiles: number + totalFiles: number + percent: number +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -180,61 +245,6 @@ export type QuestionRejected = { requestID: string } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type SessionNetworkWait = { id: string sessionID: string @@ -857,16 +867,6 @@ export type Prompt = { agents?: Array } -export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" - -export type IndexingStatus = { - state: IndexingStatusState - message: string - processedFiles: number - totalFiles: number - percent: number -} - export type GlobalEvent = { directory: string project?: string @@ -875,16 +875,18 @@ export type GlobalEvent = { | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventKilocodeAgentManagerStart + | EventIndexingStatus | EventServerInstanceDisposed | EventLspClientDiagnostics | EventLspUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventSessionNetworkAsked @@ -913,7 +915,6 @@ export type GlobalEvent = { | EventSessionCompacted | EventCommandExecuted | EventProjectUpdated - | EventKilocodeAgentManagerStart | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -958,7 +959,6 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventIndexingStatus | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2545,6 +2545,30 @@ export type EventGlobalConfigUpdated = { } } +export type EventKilocodeAgentManagerStart = { + id: string + type: "kilocode.agent_manager.start" + properties: { + requestID: string + sessionID: string + mode: "worktree" | "local" + versions?: boolean + tasks: Array<{ + prompt?: string + name?: string + branchName?: string + }> + } +} + +export type EventIndexingStatus = { + id: string + type: "indexing.status" + properties: { + status: IndexingStatus + } +} + export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -2846,22 +2870,6 @@ export type EventProjectUpdated = { properties: Project } -export type EventKilocodeAgentManagerStart = { - id: string - type: "kilocode.agent_manager.start" - properties: { - requestID: string - sessionID: string - mode: "worktree" | "local" - versions?: boolean - tasks: Array<{ - prompt?: string - name?: string - branchName?: string - }> - } -} - export type EventVcsBranchUpdated = { id: string type: "vcs.branch.updated" @@ -3388,14 +3396,6 @@ export type EventSessionNextCompactionEnded = { } } -export type EventIndexingStatus = { - id: string - type: "indexing.status" - properties: { - status: IndexingStatus - } -} - export type SessionInfo = { id: string parentID?: string @@ -7781,6 +7781,45 @@ export type KiloFimResponses = { export type KiloFimResponse = KiloFimResponses[keyof KiloFimResponses] +export type KiloEditData = { + body?: { + content: string + provider?: string + model?: string + maxTokens?: number + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilo/edit" +} + +export type KiloEditErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KiloEditError = KiloEditErrors[keyof KiloEditErrors] + +export type KiloEditResponses = { + /** + * Next Edit completion + */ + 200: { + content: string + usage?: { + prompt_tokens?: number + completion_tokens?: number + } + } +} + +export type KiloEditResponse = KiloEditResponses[keyof KiloEditResponses] + export type KiloAudioTranscriptionsData = { body?: { model: string From ca2642438ff771d32a410c2c9b5243ce711f30a1 Mon Sep 17 00:00:00 2001 From: Firas Trabelsi Date: Tue, 26 May 2026 16:14:29 -0700 Subject: [PATCH 02/33] Address review feedback: NES correctness, dedupe, tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-agent review pass on the gateway-routed NES change. Fixes: Correctness (🔴) - MercuryEditProvider read the HTTP status off the parsed error body (`error.status`, always undefined), so 401/402 never reached onFatalError and NES had no credit-exhausted/auth backoff. Now reads `response.status` from the SDK result. - NextEditSuggestionManager applied "insert" suggestions without the apply-time drift re-validation the "replace" path already had — edits between the anchor and insertion point could land the insert in the wrong place. Now re-checks the anchor line before inserting. - The opencode Effect edit handler collapsed every upstream failure to HTTP 400; now passes the real status through (mirrors the FIM handler) so 401/402/429/5xx are distinguishable under the experimental backend. Conciseness / DRY (🔴) - Deleted dead module editCompletionParser.ts (+ spec): the gateway unwraps the fence server-side now, so the VSCode-side parser was unused. - Hoisted the triplicated extractFencedBody into a single exported function in kilo-gateway/src/edit.ts; both the hono and Effect handlers import it. Added a shared EditUpstreamResponse type to replace three inline copies. Robustness (🟡) - extractFencedBody now keeps the body when the closing fence is missing (truncated/max_tokens output) instead of dropping the suggestion. - EditHistoryTracker seeds snapshots on document open so the first edit in a freshly-opened file is captured (was previously dropped). - Single-line inserts that span non-blank lines below the cursor (a multi-line→single-line collapse) now route to the decoration path instead of emitting a ghost item VSCode can't render. Cleanup (🟡) - Removed unused constants (MERCURY_EDIT_MODEL_ID, INCEPTION_API_BASE_URL, INCEPTION_EDIT_PATH). - getProviderKey typed to DirectAutocompleteProviderID (matches FIM). - resolveEditTarget keys on kind==="edit" defensively, so a future FIM-only Inception model can't resolve to the edit endpoint. - AutocompleteModelDef doc comments made endpoint-neutral (not "FIM"). - Declared the internal accept command in contributes.commands. Tests - New packages/kilo-gateway/test/edit.test.ts: resolveEditTarget routing (incl. the mercury-edit-2 FIM model must NOT reach the edit endpoint) and extractFencedBody variants (lang tag, sentinels, truncation, blank lines, no-fence). - typecheck clean across kilo-gateway, opencode, kilo-vscode; lint clean; all unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kilo-gateway/src/autocomplete.ts | 8 +- packages/kilo-gateway/src/edit.ts | 30 +++++- packages/kilo-gateway/src/server/edit.ts | 34 +------ packages/kilo-gateway/test/edit.test.ts | 59 +++++++++++ packages/kilo-vscode/package.json | 11 ++- .../next-edit/MercuryEditProvider.ts | 20 ++-- .../NextEditInlineCompletionProvider.ts | 97 ++++++++++--------- .../next-edit/NextEditSuggestionManager.ts | 9 ++ .../__tests__/editCompletionParser.spec.ts | 26 ----- .../autocomplete/next-edit/constants.ts | 8 -- .../next-edit/editCompletionParser.ts | 33 ------- .../next-edit/editHistoryTracker.ts | 18 +++- .../server/httpapi/handlers/kilo-gateway.ts | 30 ++---- 13 files changed, 198 insertions(+), 185 deletions(-) create mode 100644 packages/kilo-gateway/test/edit.test.ts delete mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editCompletionParser.spec.ts delete mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/editCompletionParser.ts diff --git a/packages/kilo-gateway/src/autocomplete.ts b/packages/kilo-gateway/src/autocomplete.ts index 451eb91dd9..356a19cc57 100644 --- a/packages/kilo-gateway/src/autocomplete.ts +++ b/packages/kilo-gateway/src/autocomplete.ts @@ -4,7 +4,7 @@ export type DirectAutocompleteProviderID = Exclude + usage?: { prompt_tokens?: number; completion_tokens?: number } +} + const INCEPTION_EDIT_URL = "https://api.inceptionlabs.ai/v1/edit/completions" /** @@ -22,7 +28,7 @@ const INCEPTION_EDIT_URL = "https://api.inceptionlabs.ai/v1/edit/completions" */ export function resolveEditTarget(provider?: string, model?: string): EditTarget { const info = getAutocompleteModel(provider, model) - if (info.directProvider === "inception") { + if (info.kind === "edit" && info.directProvider === "inception") { return { provider: "inception", model: info.requestModel, url: INCEPTION_EDIT_URL } } // Kilo Gateway does not currently proxy an edit endpoint; callers should @@ -30,3 +36,25 @@ export function resolveEditTarget(provider?: string, model?: string): EditTarget // a 400 rather than silently routing somewhere unexpected. return { provider: "kilo", model: info.requestModel, url: "" } } + +/** + * Mercury wraps the rewritten editable region in a triple-backtick fence, + * sometimes with a language tag and sometimes with `<|code_to_edit|>` sentinels + * inside. Strip all of that down to the bare code. Shared by both the hono and + * the Effect HttpApi edit handlers so the parsing can't drift between them. + */ +export function extractFencedBody(message: string): string { + if (!message) return "" + const fenceOpen = message.indexOf("```") + if (fenceOpen === -1) return message + const afterFenceOpen = message.indexOf("\n", fenceOpen + 3) + if (afterFenceOpen === -1) return "" + // A missing closing fence means the response was truncated (max_tokens hit). + // Take everything after the opening fence rather than dropping the suggestion. + const fenceClose = message.indexOf("```", afterFenceOpen + 1) + let body = fenceClose === -1 ? message.slice(afterFenceOpen + 1) : message.slice(afterFenceOpen + 1, fenceClose) + if (body.endsWith("\n")) body = body.slice(0, -1) + body = body.replace(/^<\|code_to_edit\|>\n?/, "") + body = body.replace(/\n?<\|\/code_to_edit\|>$/, "") + return body +} diff --git a/packages/kilo-gateway/src/server/edit.ts b/packages/kilo-gateway/src/server/edit.ts index eaea04e012..620f30510a 100644 --- a/packages/kilo-gateway/src/server/edit.ts +++ b/packages/kilo-gateway/src/server/edit.ts @@ -1,4 +1,5 @@ -import { DIRECT_EDIT_ENV, resolveEditTarget, type EditTarget } from "../edit.js" +import { DIRECT_EDIT_ENV, extractFencedBody, resolveEditTarget, type EditTarget, type EditUpstreamResponse } from "../edit.js" +import type { DirectAutocompleteProviderID } from "../autocomplete.js" import type { AuthStore } from "./handlers.js" type Auth = Pick @@ -6,39 +7,12 @@ type Auth = Pick const EDIT_TIMEOUT_MS = 30_000 const MAX_TOKENS_DEFAULT = 512 -async function getProviderKey(Auth: Auth, provider: "inception" | "mistral"): Promise { +async function getProviderKey(Auth: Auth, provider: DirectAutocompleteProviderID): Promise { const auth = await Auth.get(provider) if (auth?.type === "api") return auth.key return DIRECT_EDIT_ENV[provider].map((key) => process.env[key]).find(Boolean) } -/** - * Extract the rewritten code from Mercury's reply. Mercury always wraps the - * editable region in a triple-backtick fence, sometimes with a language tag - * and sometimes with `<|code_to_edit|>` markers inside. Mirrors the parser the - * VSCode side used to run; doing it gateway-side keeps the Mercury contract - * in one place. - */ -function extractFencedBody(message: string): string { - if (!message) return "" - const fenceOpen = message.indexOf("```") - if (fenceOpen === -1) return message - const afterFenceOpen = message.indexOf("\n", fenceOpen + 3) - if (afterFenceOpen === -1) return "" - const fenceClose = message.lastIndexOf("```") - if (fenceClose <= afterFenceOpen) return "" - let body = message.slice(afterFenceOpen + 1, fenceClose) - if (body.endsWith("\n")) body = body.slice(0, -1) - body = body.replace(/^<\|code_to_edit\|>\n?/, "") - body = body.replace(/\n?<\|\/code_to_edit\|>$/, "") - return body -} - -interface UpstreamResponse { - choices?: Array<{ message?: { content?: string } }> - usage?: { prompt_tokens?: number; completion_tokens?: number } -} - export function createEditHandler(Auth: Auth) { return async (c: any) => { const { content, provider, model, maxTokens } = c.req.valid("json") @@ -86,7 +60,7 @@ export function createEditHandler(Auth: Auth) { return c.json({ error: `Edit request failed: ${response.status} ${text}` }, response.status as any) } - const json = (await response.json()) as UpstreamResponse + const json = (await response.json()) as EditUpstreamResponse const replyContent = json.choices?.[0]?.message?.content ?? "" const body = extractFencedBody(replyContent) return c.json({ diff --git a/packages/kilo-gateway/test/edit.test.ts b/packages/kilo-gateway/test/edit.test.ts new file mode 100644 index 0000000000..5a707571bb --- /dev/null +++ b/packages/kilo-gateway/test/edit.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { extractFencedBody, resolveEditTarget } from "../src/edit" + +describe("Edit target resolution", () => { + test("routes the Inception next-edit model to Inception's edit endpoint", () => { + expect(resolveEditTarget("inception", "mercury-next-edit")).toEqual({ + provider: "inception", + model: "mercury-edit-2", + url: "https://api.inceptionlabs.ai/v1/edit/completions", + }) + }) + + test("does NOT route the FIM Mercury model to the edit endpoint", () => { + // `mercury-edit-2` (kind: fim) must fall through to the kilo placeholder, + // not the edit endpoint — only `mercury-next-edit` (kind: edit) is NES. + expect(resolveEditTarget("inception", "mercury-edit-2").provider).toBe("kilo") + }) + + test("falls back to a kilo placeholder (no upstream) for non-edit models", () => { + expect(resolveEditTarget("kilo", "mistralai/codestral-2508")).toEqual({ + provider: "kilo", + model: "mistralai/codestral-2508", + url: "", + }) + expect(resolveEditTarget()).toMatchObject({ provider: "kilo", url: "" }) + }) +}) + +describe("extractFencedBody", () => { + test("extracts a plain triple-backtick fenced body", () => { + expect(extractFencedBody("```\nconst x = 1\n```")).toBe("const x = 1") + }) + + test("handles a language tag on the opening fence", () => { + expect(extractFencedBody("```typescript\nconst x = 1\n```")).toBe("const x = 1") + }) + + test("strips embedded <|code_to_edit|> sentinels", () => { + expect(extractFencedBody("```\n<|code_to_edit|>\nconst x = 2\n<|/code_to_edit|>\n```")).toBe("const x = 2") + }) + + test("returns the raw message when there is no fence", () => { + expect(extractFencedBody("just text, no fence")).toBe("just text, no fence") + }) + + test("returns the empty string for empty input", () => { + expect(extractFencedBody("")).toBe("") + }) + + test("takes the rest when the closing fence is missing (truncated output)", () => { + // max_tokens hit mid-stream → no closing ``` — keep what we have. + expect(extractFencedBody("```\nconst x = 1\nconst y = ")).toBe("const x = 1\nconst y = ") + }) + + test("preserves internal blank lines and indentation", () => { + const body = "def f():\n if True:\n\n return 1" + expect(extractFencedBody("```python\n" + body + "\n```")).toBe(body) + }) +}) diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index b9b03b843c..1758065c4f 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -204,6 +204,11 @@ "title": "Next Edit: Dismiss Pending Suggestion", "category": "Kilo Code" }, + { + "command": "kilo-code.autocomplete.next-edit.accepted", + "title": "Next Edit: Suggestion Accepted (internal)", + "category": "Kilo Code" + }, { "command": "kilo-code.new.agentManager.previousSession", "title": "Agent Manager: Previous Session", @@ -846,13 +851,15 @@ "mistralai/codestral-2508", "inception/mercury-edit-2", "codestral-2508", - "mercury-edit-2" + "mercury-edit-2", + "mercury-next-edit" ], "enumDescriptions": [ "Codestral via Kilo Gateway (default)", "Mercury Edit 2 via Kilo Gateway", "Codestral via your connected Mistral provider API key", - "Mercury Edit 2 via your connected Inception provider API key" + "Mercury Edit 2 (FIM) via your connected Inception provider API key", + "Mercury Next Edit (multi-line edit predictions with jump-to-edit UX) via your connected Inception provider API key" ], "description": "Model to use for inline autocomplete suggestions" }, diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts index bfc5ebf733..4ed46c0263 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts @@ -7,6 +7,8 @@ const MERCURY_MAX_TOKENS = 512 const PROVIDER_ID = "inception" const MODEL_ID = "mercury-next-edit" +type EditResponseData = { content?: string; usage?: { prompt_tokens?: number; completion_tokens?: number } } + export interface MercuryEditProviderOptions { connectionService: KiloConnectionService /** AbortSignal for cancellation (cursor moves, escape, etc.). */ @@ -14,11 +16,10 @@ export interface MercuryEditProviderOptions { } /** - * Thin wrapper around the SDK's `client.kilo.edit(...)` SSE endpoint. - * The gateway (in `packages/kilo-gateway/src/server/edit.ts`) handles auth, - * routing to Mercury's `/v1/edit/completions`, and unwrapping the - * triple-backtick fence from the model response — so the VSCode side only - * deals in already-parsed code. + * Thin wrapper around the SDK's `client.kilo.edit(...)` endpoint (non-streaming). + * The gateway (`packages/kilo-gateway/src/server/edit.ts`) handles auth, routing + * to Mercury's `/v1/edit/completions`, and unwrapping the triple-backtick fence — + * so the VSCode side only deals in already-parsed code. */ export class MercuryEditProvider { constructor(private readonly options: MercuryEditProviderOptions) {} @@ -32,7 +33,7 @@ export class MercuryEditProvider { const client = await this.options.connectionService.getClientAsync() try { - const { data, error } = await client.kilo.edit( + const { data, error, response } = await client.kilo.edit( { content: userContent, provider: PROVIDER_ID, @@ -43,9 +44,10 @@ export class MercuryEditProvider { ) const latencyMs = Date.now() - start if (error) { - const status = typeof (error as any)?.status === "number" ? (error as any).status : null + // HTTP status lives on the Response object, not the parsed error body. + const status = typeof response?.status === "number" ? response.status : null nesWarn(`<- error ${status ?? "?"} (${latencyMs}ms): ${safeStringify(error)}`) - throw new MercuryEditError(`Edit request failed: ${safeStringify(error)}`, status) + throw new MercuryEditError(`Edit request failed: ${status ?? "?"} ${safeStringify(error)}`, status) } return this.parseSuccess(ctx, data, latencyMs) } catch (err) { @@ -59,7 +61,7 @@ export class MercuryEditProvider { private parseSuccess( ctx: MercuryEditRequestContext, - data: { content?: string; usage?: { prompt_tokens?: number; completion_tokens?: number } } | undefined, + data: EditResponseData | undefined, latencyMs: number, ): MercuryEditSuggestion | null { const replacement = data?.content ?? null diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index 3f00a3c3ed..6071826163 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -31,6 +31,16 @@ export interface NextEditSuggestionEvent { outputTokens?: number } +/** A parsed Mercury suggestion plus the editable region it targets. */ +type SuggestionResult = { + replacement: string + editableRegionStartLine: number + editableRegionEndLine: number + latencyMs: number + inputTokens?: number + outputTokens?: number +} + export class NextEditInlineCompletionProvider implements vscode.InlineCompletionItemProvider, vscode.Disposable { private readonly editHistoryTracker: EditHistoryTracker private debounceTimer: NodeJS.Timeout | null = null @@ -109,7 +119,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion private toCompletionItems( document: vscode.TextDocument, position: vscode.Position, - suggestion: { replacement: string; editableRegionStartLine: number; editableRegionEndLine: number; latencyMs: number; inputTokens?: number; outputTokens?: number }, + suggestion: SuggestionResult, ): vscode.InlineCompletionItem[] | undefined { const endLine = Math.min(suggestion.editableRegionEndLine, document.lineCount - 1) const fullRange = new vscode.Range( @@ -118,13 +128,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion ) const currentText = document.getText(fullRange) if (currentText === suggestion.replacement) { - this.deps.onSuggestion?.({ - shown: false, - latencyMs: suggestion.latencyMs, - status: "no-replacement", - inputTokens: suggestion.inputTokens, - outputTokens: suggestion.outputTokens, - }) + this.emitNotShown(suggestion) return undefined } @@ -163,47 +167,40 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion // Same-line diff: clear any prior off-cursor pending state so we don't render // two competing affordances. this.deps.suggestionManager?.clear() + return this.renderSameLineItem(document, position, proposedLines, prefixLines, suffixLines, diffStartLineInFile, diffEndLineInFile, trimmedReplacement, suggestion) + } - // Same-line diff: build a range that starts at the cursor's exact position - // and provide insertText for everything from the cursor onward. + /** Build the cursor-position ghost-text item for a same-line diff. */ + private renderSameLineItem( + document: vscode.TextDocument, + position: vscode.Position, + proposedLines: string[], + prefixLines: number, + suffixLines: number, + diffStartLine: number, + diffEndLine: number, + trimmedReplacement: string, + suggestion: SuggestionResult, + ): vscode.InlineCompletionItem[] | undefined { const cursorLineText = document.lineAt(position.line).text const cursorLineCurrent = cursorLineText.slice(position.character) const cursorLineProposed = proposedLines[prefixLines] - // Guard: if the proposal has fewer lines than the prefix consumed (a pure - // deletion at the trim seam), there's no cursor-line replacement to show. - if (cursorLineProposed === undefined) { - nesLog(`skipping render — proposal has no line at the cursor's index after trim`) - this.deps.onSuggestion?.({ - shown: false, - latencyMs: suggestion.latencyMs, - status: "no-replacement", - inputTokens: suggestion.inputTokens, - outputTokens: suggestion.outputTokens, - }) - return undefined - } - if (!cursorLineProposed.startsWith(cursorLineText.slice(0, position.character))) { - // The model wants to change characters BEFORE the cursor on the same line — - // can't render that as ghost text either. Skip for v0. - nesLog(`skipping render — diff edits characters before cursor on its line`) - this.deps.onSuggestion?.({ - shown: false, - latencyMs: suggestion.latencyMs, - status: "no-replacement", - inputTokens: suggestion.inputTokens, - outputTokens: suggestion.outputTokens, - }) + // No cursor-line replacement (pure deletion at the trim seam), or the model + // wants to change characters BEFORE the cursor — neither renders as ghost text. + if (cursorLineProposed === undefined || !cursorLineProposed.startsWith(cursorLineText.slice(0, position.character))) { + this.emitNotShown(suggestion) return undefined } const insertText = [cursorLineProposed.slice(position.character), ...proposedLines.slice(prefixLines + 1, proposedLines.length - suffixLines)].join("\n") - const renderEndLine = pickRenderEndLine(document, position.line, diffEndLineInFile, insertText) - const renderRange = new vscode.Range(position, new vscode.Position(renderEndLine, document.lineAt(renderEndLine).range.end.character)) - // Compute the existing text from cursor → end of diff region for sanity. - const _existingFromCursor = document.getText(renderRange) - if (_existingFromCursor === cursorLineCurrent && cursorLineCurrent === insertText) { - nesLog(`post-trim no-op`) + const renderEndLine = pickRenderEndLine(document, position.line, diffEndLine, insertText) + // A single-line insert spanning non-blank lines below the cursor can't be + // represented as inline ghost text — route it to the decoration path. + if (renderEndLine > position.line && !insertText.includes("\n")) { + this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, suggestion) return undefined } + const renderRange = new vscode.Range(position, new vscode.Position(renderEndLine, document.lineAt(renderEndLine).range.end.character)) + if (document.getText(renderRange) === cursorLineCurrent && cursorLineCurrent === insertText) return undefined const item = new vscode.InlineCompletionItem(insertText, renderRange, { command: INLINE_COMPLETION_ACCEPTED_COMMAND, @@ -220,26 +217,30 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion return [item] } + private emitNotShown(suggestion: SuggestionResult): void { + this.deps.onSuggestion?.({ + shown: false, + latencyMs: suggestion.latencyMs, + status: "no-replacement", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + } + private stashOffCursorSuggestion( document: vscode.TextDocument, diffStartLine: number, diffEndLine: number, trimmedReplacement: string, isPureInsertion: boolean, - suggestion: { latencyMs: number; inputTokens?: number; outputTokens?: number }, + suggestion: SuggestionResult, ): void { const mgr = this.deps.suggestionManager if (!mgr) { // Manager wasn't wired — fall through silently. The classic path // already covers same-line completions; this branch only matters in // tests or misconfigured embeds. - this.deps.onSuggestion?.({ - shown: false, - latencyMs: suggestion.latencyMs, - status: "no-replacement", - inputTokens: suggestion.inputTokens, - outputTokens: suggestion.outputTokens, - }) + this.emitNotShown(suggestion) return } if (isPureInsertion) { diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts index be00e64f47..d654b7b619 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -182,6 +182,15 @@ export class NextEditSuggestionManager implements vscode.Disposable { let ok = false if (p.kind === "insert") { + // Re-validate before applying: the anchor line must still hold its + // original text. Without this, edits between the anchor and the insertion + // point can shift line numbers and land the insert in the wrong place. + const anchorLine = Math.min(p.diffStartLine, editor.document.lineCount - 1) + const anchorText = anchorLine >= 0 ? editor.document.lineAt(anchorLine).text : undefined + if (anchorText !== p.originalText) { + nesLog(`document drifted since suggestion was made — dropping insert at line ${p.diffStartLine}`) + return + } const pos = new vscode.Position(p.diffStartLine, 0) ok = await editor.edit((b) => b.insert(pos, p.replacement)) nesLog(`applied insert at line ${pos.line} (${p.replacement.length} chars, ok=${ok})`) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editCompletionParser.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editCompletionParser.spec.ts deleted file mode 100644 index 5d340487e5..0000000000 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editCompletionParser.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { parseMercuryEditReply } from "../editCompletionParser" - -describe("parseMercuryEditReply", () => { - it("extracts the fenced body when the model returns plain triple backticks", () => { - const reply = "Some preamble\n```\nfunction foo() {\n return 1\n}\n```\n" - expect(parseMercuryEditReply(reply)).toBe("function foo() {\n return 1\n}") - }) - - it("extracts the body when the fence has a language tag", () => { - const reply = "```typescript\nconst x = 1\n```" - expect(parseMercuryEditReply(reply)).toBe("const x = 1") - }) - - it("strips Mercury <|code_to_edit|> sentinels when the model includes them", () => { - const reply = "```\n<|code_to_edit|>\nconst x = 2\n<|/code_to_edit|>\n```" - expect(parseMercuryEditReply(reply)).toBe("const x = 2") - }) - - it("returns null when no fenced block is present", () => { - expect(parseMercuryEditReply("just text, no fence")).toBeNull() - }) - - it("returns null on an empty string", () => { - expect(parseMercuryEditReply("")).toBeNull() - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts index a2c3fa4ae4..f82f08781e 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts @@ -17,17 +17,9 @@ export const MERCURY_EDIT_DIFF_HISTORY_OPEN = "<|edit_diff_history|>" export const MERCURY_EDIT_DIFF_HISTORY_CLOSE = "<|/edit_diff_history|>" export const MERCURY_CURSOR = "<|cursor|>" -export const MERCURY_EDIT_MODEL_ID = "mercury-edit-2" -export const INCEPTION_API_BASE_URL = "https://api.inceptionlabs.ai/v1" -export const INCEPTION_EDIT_PATH = "/edit/completions" - /** Token Mercury Edit uses to distinguish next-edit calls from regular chat. */ export const MERCURY_UNIQUE_TOKEN = "<|!@#IS_NEXT_EDIT!@#|>" -// Note: the /v1/edit/completions endpoint accepts only a `role: "user"` -// message — Mercury bakes the system prompt in server-side. Do not send a -// client-side system prompt; the endpoint returns 400 if you do. - /** * Per docs: editable region size dominates output latency. Centering around * the cursor with [-5, +10] is the recommended starting point. diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editCompletionParser.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editCompletionParser.ts deleted file mode 100644 index 99623afdf7..0000000000 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/editCompletionParser.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { MERCURY_CODE_TO_EDIT_CLOSE, MERCURY_CODE_TO_EDIT_OPEN } from "./constants" - -/** - * Mercury Edit 2 returns the rewritten editable region wrapped in a triple-backtick - * fence. The system prompt asks the model to include `<|code_to_edit|>` markers, - * so we strip those as well when present. - */ -export function parseMercuryEditReply(message: string): string | null { - if (!message) return null - - const fenceOpen = message.indexOf("```") - if (fenceOpen === -1) return null - // Skip past the opening fence + optional language tag + newline. - const afterFenceOpen = message.indexOf("\n", fenceOpen + 3) - if (afterFenceOpen === -1) return null - - const fenceClose = message.lastIndexOf("```") - if (fenceClose <= afterFenceOpen) return null - - let body = message.slice(afterFenceOpen + 1, fenceClose) - // Trim a single trailing newline if the model added one before the fence. - if (body.endsWith("\n")) body = body.slice(0, -1) - - // Strip Mercury's `<|code_to_edit|>` markers when included. - body = body.replace(new RegExp(`^${escape(MERCURY_CODE_TO_EDIT_OPEN)}\\n?`), "") - body = body.replace(new RegExp(`\\n?${escape(MERCURY_CODE_TO_EDIT_CLOSE)}$`), "") - - return body -} - -function escape(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts index 710da5132c..93465f6a0f 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts @@ -25,6 +25,18 @@ export class EditHistoryTracker implements vscode.Disposable { ) { const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS + // Seed snapshots on open so the FIRST edit in a freshly-opened file is + // captured in the diff history (otherwise the common "open, type, trigger" + // flow ships an empty edit-history block). + this.subscriptions.push( + vscode.workspace.onDidOpenTextDocument((doc) => { + if (doc.uri.scheme !== "file") return + if (!this.snapshots.has(doc.uri.fsPath)) this.snapshots.set(doc.uri.fsPath, doc.getText()) + }), + ) + for (const doc of vscode.workspace.textDocuments) { + if (doc.uri.scheme === "file") this.snapshots.set(doc.uri.fsPath, doc.getText()) + } this.subscriptions.push( vscode.workspace.onDidChangeTextDocument((event) => { if (event.document.uri.scheme !== "file") return @@ -71,9 +83,9 @@ export class EditHistoryTracker implements vscode.Disposable { private scheduleSnapshotDiff(document: vscode.TextDocument, debounceMs: number): void { const key = document.uri.fsPath if (!this.snapshots.has(key)) { - // Seed the snapshot lazily — the first change is lost (we never saw - // the pre-edit state) but every subsequent edit window produces a - // useful diff. + // Fallback seed for documents we never saw open (e.g. opened before the + // tracker existed). The triggering change is lost, but subsequent edits + // produce useful diffs. this.snapshots.set(key, document.getText()) return } diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index e38617ba56..3aa008d522 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -20,7 +20,7 @@ import { fetchProfile, } from "@kilocode/kilo-gateway" import { DIRECT_FIM_ENV, requestMistralFim, resolveFimTarget } from "@kilocode/kilo-gateway/fim" -import { DIRECT_EDIT_ENV, resolveEditTarget } from "@kilocode/kilo-gateway/edit" +import { DIRECT_EDIT_ENV, extractFencedBody, resolveEditTarget } from "@kilocode/kilo-gateway/edit" import { buildKiloHeaders } from "@kilocode/kilo-gateway" import { Effect } from "effect" import * as Stream from "effect/Stream" @@ -41,25 +41,6 @@ import { AudioTranscriptionsBody, EditBody, FimBody } from "../groups/kilo-gatew const FIM_TIMEOUT_MS = 30_000 -/** - * Strip Mercury's triple-backtick fence (and any `<|code_to_edit|>` sentinels - * inside) so the gateway's NES response is just the rewritten code. - */ -function extractFencedBody(message: string): string { - if (!message) return "" - const fenceOpen = message.indexOf("```") - if (fenceOpen === -1) return message - const afterFenceOpen = message.indexOf("\n", fenceOpen + 3) - if (afterFenceOpen === -1) return "" - const fenceClose = message.lastIndexOf("```") - if (fenceClose <= afterFenceOpen) return "" - let body = message.slice(afterFenceOpen + 1, fenceClose) - if (body.endsWith("\n")) body = body.slice(0, -1) - body = body.replace(/^<\|code_to_edit\|>\n?/, "") - body = body.replace(/\n?<\|\/code_to_edit\|>$/, "") - return body -} - export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", (handlers) => Effect.gen(function* () { const auth = yield* Auth.Service @@ -210,7 +191,14 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", }) if (!response.ok) { - return yield* Effect.fail(new HttpApiError.BadRequest({})) + // Pass the upstream status through (mirrors the FIM handler) so the + // client can distinguish auth/credit/rate-limit/server failures + // instead of collapsing everything to 400. + const text = yield* Effect.promise(() => response.text()) + return HttpServerResponse.jsonUnsafe( + { error: `Edit request failed: ${response.status} ${text}` }, + { status: response.status }, + ) } const json = yield* Effect.promise(() => response.json() as Promise<{ From 7437f77374f394f3e88c03c47f94575a4e38a1d6 Mon Sep 17 00:00:00 2001 From: Firas Trabelsi Date: Tue, 26 May 2026 17:04:07 -0700 Subject: [PATCH 03/33] Move NES prompt templating to the gateway + wire FileIgnoreController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two remaining points from @markijbema's review. Prompt templating now lives in the gateway ------------------------------------------- The Mercury sentinel-prompt assembly moved out of the VSCode extension into packages/kilo-gateway/src/edit-prompt.ts. Clients now send structured editor context (currentFileContent, cursor position, editable region, recently-viewed snippets, edit-diff history) and the gateway builds the sentinel-tagged prompt. This keeps the entire Mercury contract — endpoint, auth, prompt format, response parsing — in one place that VS Code, JetBrains, and the TUI can all share, instead of each editor re-implementing the templating. - New EditBody is the structured context (was: a pre-built `content` string). Updated the opencode HttpApi schema, the hono zod validator, both handlers, and regenerated the SDK. - Deleted the VSCode-side mercuryPromptTemplate.ts (+ spec); the tests moved to packages/kilo-gateway/test/edit-prompt.test.ts. - VSCode constants.ts now holds only the editable-region sizing; the sentinel tokens live in the gateway. FileIgnoreController -------------------- NES must not send ignored files (.env, secrets, anything matched by .gitignore/.kilocodeignore) to the server. The NES provider now: - skips the request entirely if the active document fails ignoreController.validateAccess(), and - filters recently-viewed snippets through the same controller before they go into the prompt. It reuses the classic provider's FileIgnoreController instance (now public) rather than building a second one. Also: dropped the implicit nextEdit.debug config read in log.ts (debug is env-only via KILO_NES_DEBUG) so no VSCode autocomplete config is added — per the "config should move to the backend" guidance. Validation: typecheck clean across kilo-gateway, opencode, kilo-vscode; lint clean; gateway 46 tests, vscode next-edit 10 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kilo-gateway/package.json | 1 + packages/kilo-gateway/src/edit-prompt.ts | 107 +++++++++++++++ packages/kilo-gateway/src/server/edit.ts | 6 +- packages/kilo-gateway/src/server/routes.ts | 13 +- .../kilo-gateway/test/edit-prompt.test.ts | 92 +++++++++++++ .../AutocompleteServiceManager.ts | 16 ++- .../AutocompleteInlineCompletionProvider.ts | 2 +- .../next-edit/MercuryEditProvider.ts | 14 +- .../NextEditInlineCompletionProvider.ts | 8 ++ .../__tests__/mercuryPromptTemplate.spec.ts | 125 ------------------ .../autocomplete/next-edit/constants.ts | 28 +--- .../services/autocomplete/next-edit/log.ts | 8 +- .../next-edit/mercuryPromptTemplate.ts | 95 ------------- .../server/httpapi/groups/kilo-gateway.ts | 17 ++- .../server/httpapi/handlers/kilo-gateway.ts | 18 ++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 21 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 12 +- 17 files changed, 314 insertions(+), 269 deletions(-) create mode 100644 packages/kilo-gateway/src/edit-prompt.ts create mode 100644 packages/kilo-gateway/test/edit-prompt.test.ts delete mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/mercuryPromptTemplate.spec.ts delete mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/mercuryPromptTemplate.ts diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 2412a4f876..99f3c2826d 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -20,6 +20,7 @@ "./autocomplete": "./src/autocomplete.ts", "./fim": "./src/fim.ts", "./edit": "./src/edit.ts", + "./edit-prompt": "./src/edit-prompt.ts", "./tui": "./src/tui.ts" }, "files": [ diff --git a/packages/kilo-gateway/src/edit-prompt.ts b/packages/kilo-gateway/src/edit-prompt.ts new file mode 100644 index 0000000000..60c3802d8e --- /dev/null +++ b/packages/kilo-gateway/src/edit-prompt.ts @@ -0,0 +1,107 @@ +/** + * Mercury Next Edit prompt assembly. Lives in the gateway so every client + * (VS Code, JetBrains, TUI) sends the same structured editor context and the + * Mercury-specific sentinel format is defined in exactly one place. + * + * Tag set is defined by the model and must be reproduced verbatim — see + * https://docs.inceptionlabs.ai/capabilities/next-edit + */ + +const RECENTLY_VIEWED_SNIPPETS_OPEN = "<|recently_viewed_code_snippets|>" +const RECENTLY_VIEWED_SNIPPETS_CLOSE = "<|/recently_viewed_code_snippets|>" +const RECENTLY_VIEWED_SNIPPET_OPEN = "<|recently_viewed_code_snippet|>" +const RECENTLY_VIEWED_SNIPPET_CLOSE = "<|/recently_viewed_code_snippet|>" +const CURRENT_FILE_CONTENT_OPEN = "<|current_file_content|>" +const CURRENT_FILE_CONTENT_CLOSE = "<|/current_file_content|>" +const CODE_TO_EDIT_OPEN = "<|code_to_edit|>" +const CODE_TO_EDIT_CLOSE = "<|/code_to_edit|>" +const EDIT_DIFF_HISTORY_OPEN = "<|edit_diff_history|>" +const EDIT_DIFF_HISTORY_CLOSE = "<|/edit_diff_history|>" +const CURSOR = "<|cursor|>" +/** Trailing token that tells the model this is a next-edit (not chat) request. */ +const UNIQUE_TOKEN = "<|!@#IS_NEXT_EDIT!@#|>" + +export interface MercuryRecentSnippet { + filepath: string + content: string +} + +/** Editor-derived context a client sends; the gateway turns it into a prompt. */ +export interface MercuryEditContext { + currentFilePath: string + currentFileContent: string + cursorLine: number + cursorCharacter: number + editableRegionStartLine: number + editableRegionEndLine: number + recentlyViewedSnippets: MercuryRecentSnippet[] + editDiffHistory: string[] +} + +function insertCursorToken(lines: string[], cursorLine: number, cursorCharacter: number): string[] { + if (cursorLine < 0 || cursorLine >= lines.length) return lines + const line = lines[cursorLine] + const safeChar = Math.min(Math.max(cursorCharacter, 0), line.length) + const next = line.slice(0, safeChar) + CURSOR + line.slice(safeChar) + return [...lines.slice(0, cursorLine), next, ...lines.slice(cursorLine + 1)] +} + +export function recentlyViewedSnippetsBlock(snippets: MercuryRecentSnippet[]): string { + const inner = snippets + .map((s) => + [RECENTLY_VIEWED_SNIPPET_OPEN, `code_snippet_file_path: ${s.filepath}`, s.content, RECENTLY_VIEWED_SNIPPET_CLOSE].join("\n"), + ) + .join("\n") + return [RECENTLY_VIEWED_SNIPPETS_OPEN, inner, RECENTLY_VIEWED_SNIPPETS_CLOSE].join("\n") +} + +export function currentFileContentBlock( + currentFilePath: string, + currentFileContent: string, + editableRegionStartLine: number, + editableRegionEndLine: number, + cursorLine: number, + cursorCharacter: number, +): string { + const rawLines = currentFileContent.split("\n") + const withCursor = insertCursorToken(rawLines, cursorLine, cursorCharacter) + const start = Math.max(0, Math.min(editableRegionStartLine, withCursor.length)) + const end = Math.max(start, Math.min(editableRegionEndLine, withCursor.length - 1)) + const instrumented = [ + ...withCursor.slice(0, start), + CODE_TO_EDIT_OPEN, + ...withCursor.slice(start, end + 1), + CODE_TO_EDIT_CLOSE, + ...withCursor.slice(end + 1), + ] + return [CURRENT_FILE_CONTENT_OPEN, `current_file_path: ${currentFilePath}`, instrumented.join("\n"), CURRENT_FILE_CONTENT_CLOSE].join("\n") +} + +export function editDiffHistoryBlock(diffs: string[]): string { + // Each unidiff from `diff.createPatch` opens with an Index line + separator we + // strip. Diffs are blank-line separated so the model reads them as distinct hunks. + const trimmed = diffs.map((d) => { + const lines = d.split("\n") + return lines.length > 2 ? lines.slice(2).join("\n") : d + }) + return [EDIT_DIFF_HISTORY_OPEN, trimmed.join("\n\n"), EDIT_DIFF_HISTORY_CLOSE].join("\n") +} + +export function buildMercuryEditPrompt(ctx: MercuryEditContext): string { + return [ + recentlyViewedSnippetsBlock(ctx.recentlyViewedSnippets), + "", + currentFileContentBlock( + ctx.currentFilePath, + ctx.currentFileContent, + ctx.editableRegionStartLine, + ctx.editableRegionEndLine, + ctx.cursorLine, + ctx.cursorCharacter, + ), + "", + editDiffHistoryBlock(ctx.editDiffHistory), + "", + UNIQUE_TOKEN, + ].join("\n") +} diff --git a/packages/kilo-gateway/src/server/edit.ts b/packages/kilo-gateway/src/server/edit.ts index 620f30510a..c568618694 100644 --- a/packages/kilo-gateway/src/server/edit.ts +++ b/packages/kilo-gateway/src/server/edit.ts @@ -1,4 +1,5 @@ import { DIRECT_EDIT_ENV, extractFencedBody, resolveEditTarget, type EditTarget, type EditUpstreamResponse } from "../edit.js" +import { buildMercuryEditPrompt, type MercuryEditContext } from "../edit-prompt.js" import type { DirectAutocompleteProviderID } from "../autocomplete.js" import type { AuthStore } from "./handlers.js" @@ -15,7 +16,7 @@ async function getProviderKey(Auth: Auth, provider: DirectAutocompleteProviderID export function createEditHandler(Auth: Auth) { return async (c: any) => { - const { content, provider, model, maxTokens } = c.req.valid("json") + const { provider, model, maxTokens, ...context } = c.req.valid("json") const target = resolveEditTarget(provider, model) if (target.provider !== "inception") { @@ -27,6 +28,9 @@ export function createEditHandler(Auth: Auth) { return c.json({ error: `Missing ${target.provider} provider API key` }, 401 as any) } + // Build the Mercury sentinel prompt here so every client only sends + // structured editor context. + const content = buildMercuryEditPrompt(context as MercuryEditContext) const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(EDIT_TIMEOUT_MS)]) console.info(`[EDIT] request provider=${target.provider} model=${target.model} url=${target.url} chars=${content.length}`) diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 8e27aac021..cfc4818db1 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -341,8 +341,8 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { describeRoute({ summary: "Next Edit completion", description: - "Proxy a Mercury-style Next Edit request. The user supplies the already-templated " + - "sentinel-tagged prompt in `content`; the gateway forwards to the upstream edit endpoint.", + "Proxy a Mercury-style Next Edit request. The client supplies structured editor " + + "context; the gateway assembles the sentinel-tagged prompt and forwards to the upstream edit endpoint.", operationId: "kilo.edit", responses: { 200: { @@ -359,10 +359,17 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { validator( "json", z.object({ - content: z.string(), provider: z.string().optional(), model: z.string().optional(), maxTokens: z.number().optional(), + currentFilePath: z.string(), + currentFileContent: z.string(), + cursorLine: z.number(), + cursorCharacter: z.number(), + editableRegionStartLine: z.number(), + editableRegionEndLine: z.number(), + recentlyViewedSnippets: z.array(z.object({ filepath: z.string(), content: z.string() })), + editDiffHistory: z.array(z.string()), }), ), createEditHandler(Auth), diff --git a/packages/kilo-gateway/test/edit-prompt.test.ts b/packages/kilo-gateway/test/edit-prompt.test.ts new file mode 100644 index 0000000000..6aafce62a6 --- /dev/null +++ b/packages/kilo-gateway/test/edit-prompt.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { + buildMercuryEditPrompt, + currentFileContentBlock, + editDiffHistoryBlock, + recentlyViewedSnippetsBlock, +} from "../src/edit-prompt" + +describe("recentlyViewedSnippetsBlock", () => { + test("wraps in open/close sentinels even when empty", () => { + const out = recentlyViewedSnippetsBlock([]) + expect(out.startsWith("<|recently_viewed_code_snippets|>")).toBe(true) + expect(out.endsWith("<|/recently_viewed_code_snippets|>")).toBe(true) + }) + + test("emits one inner block per snippet with the file-path header", () => { + const out = recentlyViewedSnippetsBlock([ + { filepath: "src/a.ts", content: "const a = 1" }, + { filepath: "src/b.ts", content: "const b = 2" }, + ]) + expect(out).toContain("code_snippet_file_path: src/a.ts") + expect(out).toContain("code_snippet_file_path: src/b.ts") + expect(out).toContain("const a = 1") + expect(out).toContain("const b = 2") + }) +}) + +describe("currentFileContentBlock", () => { + test("inserts <|cursor|> at the right character and wraps the editable region", () => { + const file = ["function foo() {", " return 1", "}"].join("\n") + const out = currentFileContentBlock("src/foo.ts", file, 1, 1, 1, 2) + expect(out).toContain("<|current_file_content|>") + expect(out).toContain("<|/current_file_content|>") + expect(out).toContain("current_file_path: src/foo.ts") + expect(out).toContain(" <|cursor|>return 1") + const openIdx = out.indexOf("<|code_to_edit|>") + const lineIdx = out.indexOf("return 1") + const closeIdx = out.indexOf("<|/code_to_edit|>") + expect(openIdx).toBeGreaterThan(-1) + expect(closeIdx).toBeGreaterThan(openIdx) + expect(lineIdx).toBeGreaterThan(openIdx) + expect(lineIdx).toBeLessThan(closeIdx) + }) + + test("clamps an out-of-range cursor instead of throwing", () => { + const out = currentFileContentBlock("p.ts", "only-line", 0, 0, 0, 9999) + expect(out).toContain("only-line<|cursor|>") + }) +}) + +describe("editDiffHistoryBlock", () => { + test("strips the createPatch index+separator lines from each diff", () => { + const fakeDiff = ["Index: foo.ts", "===", "@@ -1,1 +1,1 @@", "-old", "+new"].join("\n") + const out = editDiffHistoryBlock([fakeDiff]) + expect(out).toContain("@@ -1,1 +1,1 @@") + expect(out).not.toContain("Index: foo.ts") + expect(out.startsWith("<|edit_diff_history|>")).toBe(true) + expect(out.endsWith("<|/edit_diff_history|>")).toBe(true) + }) + + test("separates multiple diffs with a blank line", () => { + const diff1 = ["Index: a.ts", "===", "@@ -1,1 +1,1 @@", "-a", "+aa"].join("\n") + const diff2 = ["Index: b.ts", "===", "@@ -2,1 +2,1 @@", "-b", "+bb"].join("\n") + const out = editDiffHistoryBlock([diff1, diff2]) + const idx1 = out.indexOf("@@ -1,1 +1,1 @@") + const idx2 = out.indexOf("@@ -2,1 +2,1 @@") + expect(idx2).toBeGreaterThan(idx1) + expect(out.slice(idx1, idx2)).toContain("\n\n") + }) +}) + +describe("buildMercuryEditPrompt", () => { + test("assembles the three blocks in order and ends with the NES token", () => { + const out = buildMercuryEditPrompt({ + currentFilePath: "p.ts", + currentFileContent: "a\nb\nc", + cursorLine: 1, + cursorCharacter: 0, + editableRegionStartLine: 1, + editableRegionEndLine: 1, + recentlyViewedSnippets: [], + editDiffHistory: [], + }) + const snippetsIdx = out.indexOf("<|recently_viewed_code_snippets|>") + const fileIdx = out.indexOf("<|current_file_content|>") + const diffIdx = out.indexOf("<|edit_diff_history|>") + expect(snippetsIdx).toBeGreaterThan(-1) + expect(fileIdx).toBeGreaterThan(snippetsIdx) + expect(diffIdx).toBeGreaterThan(fileIdx) + expect(out.endsWith("<|!@#IS_NEXT_EDIT!@#|>")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 30bcb3db1b..7dda302050 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -70,6 +70,9 @@ export class AutocompleteServiceManager { private inlineCompletionProviderKind: "classic" | "next-edit" | null = null private unsubscribeState: (() => void) | null = null private unsubscribeEvent: (() => void) | null = null + // Resolved copy of the classic provider's ignore controller for synchronous + // snippet filtering. Null until the async initialize() resolves. + private ignoreControllerSync: { validateAccess(fsPath: string): boolean } | null = null constructor(context: vscode.ExtensionContext, connectionService: KiloConnectionService) { if (AutocompleteServiceManager._instance) { @@ -96,16 +99,27 @@ export class AutocompleteServiceManager { new AutocompleteTelemetry(), (status) => this.handleFatalAutocompleteError(status), ) + // Cache the resolved ignore controller for synchronous snippet filtering. + void this.inlineCompletionProvider.ignoreController.then((ic) => { + this.ignoreControllerSync = ic + }) this.nextEditSuggestionManager = new NextEditSuggestionManager() this.nextEditProvider = new NextEditInlineCompletionProvider({ connectionService, suggestionManager: this.nextEditSuggestionManager, + isFileAllowed: async (fsPath) => { + const ignore = await this.inlineCompletionProvider.ignoreController + return ignore.validateAccess(fsPath) + }, getRecentlyViewedSnippets: () => { // Reuse the LRU populated by the classic provider — keeps a single // RecentlyVisitedRangesService instance instead of double-tracking. + // Snippets are filtered against the ignore controller before sending. const raw = this.inlineCompletionProvider.recentlyVisitedRangesService.getSnippets() - return toMercuryRecentSnippets(raw) + const ignore = this.ignoreControllerSync + const allowed = ignore ? raw.filter((s) => ignore.validateAccess(s.filepath)) : raw + return toMercuryRecentSnippets(allowed) }, onFatalError: (status) => this.handleFatalAutocompleteError(status), onSuggestion: (event) => { diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index 33cf8f16ea..6e6eef24c7 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -117,7 +117,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple /** The pending request associated with the current debounce timer (if any) */ private debouncedPendingRequest: PendingRequest | null = null private isFirstCall: boolean = true - private ignoreController: Promise + public readonly ignoreController: Promise /** Abort controller for the current in-flight FIM request */ private fimAbortController: AbortController | null = null private acceptedCommand: vscode.Disposable | null = null diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts index 4ed46c0263..165af0d5be 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts @@ -1,6 +1,5 @@ import type { KiloConnectionService } from "../../cli-backend" import { nesLog, nesWarn } from "./log" -import { buildMercuryEditPrompt } from "./mercuryPromptTemplate" import type { MercuryEditRequestContext, MercuryEditSuggestion } from "./types" const MERCURY_MAX_TOKENS = 512 @@ -25,20 +24,27 @@ export class MercuryEditProvider { constructor(private readonly options: MercuryEditProviderOptions) {} async suggest(ctx: MercuryEditRequestContext): Promise { - const userContent = buildMercuryEditPrompt(ctx) const start = Date.now() nesLog( - `-> /kilo/edit model=${MODEL_ID} promptChars=${userContent.length} region=[${ctx.editableRegionStartLine},${ctx.editableRegionEndLine}] diffs=${ctx.editDiffHistory.length} snippets=${ctx.recentlyViewedSnippets.length}`, + `-> /kilo/edit model=${MODEL_ID} region=[${ctx.editableRegionStartLine},${ctx.editableRegionEndLine}] diffs=${ctx.editDiffHistory.length} snippets=${ctx.recentlyViewedSnippets.length}`, ) const client = await this.options.connectionService.getClientAsync() try { + // Send structured editor context; the gateway assembles the Mercury prompt. const { data, error, response } = await client.kilo.edit( { - content: userContent, provider: PROVIDER_ID, model: MODEL_ID, maxTokens: MERCURY_MAX_TOKENS, + currentFilePath: ctx.currentFilePath, + currentFileContent: ctx.currentFileContent, + cursorLine: ctx.cursorLine, + cursorCharacter: ctx.cursorCharacter, + editableRegionStartLine: ctx.editableRegionStartLine, + editableRegionEndLine: ctx.editableRegionEndLine, + recentlyViewedSnippets: ctx.recentlyViewedSnippets, + editDiffHistory: ctx.editDiffHistory, }, { signal: this.options.signal, throwOnError: false }, ) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index 6071826163..e0eeb59ca3 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -15,6 +15,8 @@ export interface NextEditProviderDeps { connectionService: KiloConnectionService /** Optional source of recently-viewed snippets (kilocode's VisibleCodeTracker can adapt to this). */ getRecentlyViewedSnippets?: (document: vscode.TextDocument) => MercuryRecentSnippet[] + /** Returns false for files that must not be sent to a server (.env etc). */ + isFileAllowed?: (fsPath: string) => Promise /** Telemetry hook fired on every suggestion result. */ onSuggestion?: (event: NextEditSuggestionEvent) => void onFatalError?: (status: number | null) => void @@ -65,6 +67,12 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion if (document.uri.scheme !== "file") return undefined if (this.deps.suggestionManager?.isPending()) return undefined + // Never send an ignored file (.env, secrets, etc.) to the model. + if (this.deps.isFileAllowed && !(await this.deps.isFileAllowed(document.uri.fsPath))) { + nesLog("skip — file is gitignore/kilocodeignore-excluded") + return undefined + } + const isExplicit = context.triggerKind === vscode.InlineCompletionTriggerKind.Invoke if (!isExplicit) { await this.debounce(DEFAULT_DEBOUNCE_MS, token) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/mercuryPromptTemplate.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/mercuryPromptTemplate.spec.ts deleted file mode 100644 index c01c4890fa..0000000000 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/mercuryPromptTemplate.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - MERCURY_CODE_TO_EDIT_CLOSE, - MERCURY_CODE_TO_EDIT_OPEN, - MERCURY_CURRENT_FILE_CONTENT_CLOSE, - MERCURY_CURRENT_FILE_CONTENT_OPEN, - MERCURY_CURSOR, - MERCURY_EDIT_DIFF_HISTORY_CLOSE, - MERCURY_EDIT_DIFF_HISTORY_OPEN, - MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_CLOSE, - MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN, - MERCURY_UNIQUE_TOKEN, -} from "../constants" -import { - buildMercuryEditPrompt, - currentFileContentBlock, - editDiffHistoryBlock, - recentlyViewedSnippetsBlock, -} from "../mercuryPromptTemplate" - -describe("mercuryPromptTemplate", () => { - describe("recentlyViewedSnippetsBlock", () => { - it("wraps in open/close sentinels even when empty", () => { - const out = recentlyViewedSnippetsBlock([]) - expect(out.startsWith(MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN)).toBe(true) - expect(out.endsWith(MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_CLOSE)).toBe(true) - }) - - it("emits one inner block per snippet with the file-path header", () => { - const out = recentlyViewedSnippetsBlock([ - { filepath: "src/a.ts", content: "const a = 1" }, - { filepath: "src/b.ts", content: "const b = 2" }, - ]) - expect(out).toContain("code_snippet_file_path: src/a.ts") - expect(out).toContain("code_snippet_file_path: src/b.ts") - expect(out).toContain("const a = 1") - expect(out).toContain("const b = 2") - }) - }) - - describe("currentFileContentBlock", () => { - it("inserts <|cursor|> at the right character and wraps the editable region", () => { - const file = ["function foo() {", " return 1", "}"].join("\n") - const out = currentFileContentBlock("src/foo.ts", file, 1, 1, 1, 2) - expect(out).toContain(MERCURY_CURRENT_FILE_CONTENT_OPEN) - expect(out).toContain(MERCURY_CURRENT_FILE_CONTENT_CLOSE) - expect(out).toContain("current_file_path: src/foo.ts") - expect(out).toContain(` ${MERCURY_CURSOR}return 1`) - // Open marker precedes the editable region's first line; close marker follows it. - const openIdx = out.indexOf(MERCURY_CODE_TO_EDIT_OPEN) - const lineIdx = out.indexOf("return 1") - const closeIdx = out.indexOf(MERCURY_CODE_TO_EDIT_CLOSE) - expect(openIdx).toBeGreaterThan(-1) - expect(closeIdx).toBeGreaterThan(openIdx) - expect(lineIdx).toBeGreaterThan(openIdx) - expect(lineIdx).toBeLessThan(closeIdx) - }) - - it("clamps an out-of-range cursor instead of throwing", () => { - const file = "only-line" - const out = currentFileContentBlock("p.ts", file, 0, 0, 0, 9999) - expect(out).toContain(`only-line${MERCURY_CURSOR}`) - }) - }) - - describe("editDiffHistoryBlock", () => { - it("strips the createPatch index+separator lines from each diff", () => { - const fakeDiff = ["Index: foo.ts", "===", "@@ -1,1 +1,1 @@", "-old", "+new"].join("\n") - const out = editDiffHistoryBlock([fakeDiff]) - expect(out).toContain("@@ -1,1 +1,1 @@") - expect(out).not.toContain("Index: foo.ts") - expect(out).not.toContain("===") - expect(out.startsWith(MERCURY_EDIT_DIFF_HISTORY_OPEN)).toBe(true) - expect(out.endsWith(MERCURY_EDIT_DIFF_HISTORY_CLOSE)).toBe(true) - }) - - it("separates multiple diffs with a blank line so Mercury parses them as distinct hunks", () => { - const diff1 = ["Index: a.ts", "===", "@@ -1,1 +1,1 @@", "-a", "+aa"].join("\n") - const diff2 = ["Index: b.ts", "===", "@@ -2,1 +2,1 @@", "-b", "+bb"].join("\n") - const out = editDiffHistoryBlock([diff1, diff2]) - // Both hunk headers should appear separated by a blank line. - const idx1 = out.indexOf("@@ -1,1 +1,1 @@") - const idx2 = out.indexOf("@@ -2,1 +2,1 @@") - expect(idx1).toBeGreaterThan(-1) - expect(idx2).toBeGreaterThan(idx1) - const between = out.slice(idx1, idx2) - // The body between the two hunk headers must contain at least one empty line. - expect(between).toContain("\n\n") - }) - }) - - describe("buildMercuryEditPrompt", () => { - it("assembles all three blocks in the documented order", () => { - const out = buildMercuryEditPrompt({ - currentFilePath: "p.ts", - currentFileContent: "a\nb\nc", - cursorLine: 1, - cursorCharacter: 0, - editableRegionStartLine: 1, - editableRegionEndLine: 1, - recentlyViewedSnippets: [], - editDiffHistory: [], - }) - const snippetsIdx = out.indexOf(MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN) - const fileIdx = out.indexOf(MERCURY_CURRENT_FILE_CONTENT_OPEN) - const diffIdx = out.indexOf(MERCURY_EDIT_DIFF_HISTORY_OPEN) - expect(snippetsIdx).toBeGreaterThan(-1) - expect(fileIdx).toBeGreaterThan(snippetsIdx) - expect(diffIdx).toBeGreaterThan(fileIdx) - }) - - it("trails the user prompt with the NES unique token so Mercury recognises the call as next-edit", () => { - const out = buildMercuryEditPrompt({ - currentFilePath: "p.ts", - currentFileContent: "a\nb", - cursorLine: 0, - cursorCharacter: 0, - editableRegionStartLine: 0, - editableRegionEndLine: 1, - recentlyViewedSnippets: [], - editDiffHistory: [], - }) - expect(out.endsWith(MERCURY_UNIQUE_TOKEN)).toBe(true) - }) - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts index f82f08781e..cf7ec8d4e0 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts @@ -1,28 +1,8 @@ /** - * Sentinel tokens used to template the prompt for Mercury Edit 2 via the - * Inception `/v1/edit/completions` endpoint. The tag set is defined by the - * model and must be reproduced verbatim — see - * https://docs.inceptionlabs.ai/capabilities/next-edit - */ - -export const MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN = "<|recently_viewed_code_snippets|>" -export const MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_CLOSE = "<|/recently_viewed_code_snippets|>" -export const MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_OPEN = "<|recently_viewed_code_snippet|>" -export const MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_CLOSE = "<|/recently_viewed_code_snippet|>" -export const MERCURY_CURRENT_FILE_CONTENT_OPEN = "<|current_file_content|>" -export const MERCURY_CURRENT_FILE_CONTENT_CLOSE = "<|/current_file_content|>" -export const MERCURY_CODE_TO_EDIT_OPEN = "<|code_to_edit|>" -export const MERCURY_CODE_TO_EDIT_CLOSE = "<|/code_to_edit|>" -export const MERCURY_EDIT_DIFF_HISTORY_OPEN = "<|edit_diff_history|>" -export const MERCURY_EDIT_DIFF_HISTORY_CLOSE = "<|/edit_diff_history|>" -export const MERCURY_CURSOR = "<|cursor|>" - -/** Token Mercury Edit uses to distinguish next-edit calls from regular chat. */ -export const MERCURY_UNIQUE_TOKEN = "<|!@#IS_NEXT_EDIT!@#|>" - -/** - * Per docs: editable region size dominates output latency. Centering around - * the cursor with [-5, +10] is the recommended starting point. + * Editable-region sizing for Next Edit. Per the Mercury docs, region size + * dominates output latency; centering [-5, +10] around the cursor is the + * recommended starting point. (The Mercury prompt sentinel tokens live in the + * gateway — see packages/kilo-gateway/src/edit-prompt.ts.) */ export const DEFAULT_EDITABLE_REGION_TOP_MARGIN = 5 export const DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN = 10 diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts index 2aac37f5bb..7e7c3239ba 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts @@ -1,7 +1,6 @@ import * as vscode from "vscode" const CHANNEL_NAME = "Kilo Code · Next Edit" -const DEBUG_SETTING = "kilo-code.new.autocomplete.nextEdit.debug" let channel: vscode.OutputChannel | null = null @@ -11,10 +10,9 @@ function getChannel(): vscode.OutputChannel { } function debugEnabled(): boolean { - return ( - vscode.workspace.getConfiguration().get(DEBUG_SETTING) === true || - process.env.KILO_NES_DEBUG === "1" - ) + // Toggled via env only — deliberately not a VSCode setting, to avoid adding + // new autocomplete config (config is migrating to the backend). + return process.env.KILO_NES_DEBUG === "1" } /** diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/mercuryPromptTemplate.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/mercuryPromptTemplate.ts deleted file mode 100644 index 62034cbdb5..0000000000 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/mercuryPromptTemplate.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - MERCURY_CODE_TO_EDIT_CLOSE, - MERCURY_CODE_TO_EDIT_OPEN, - MERCURY_CURRENT_FILE_CONTENT_CLOSE, - MERCURY_CURRENT_FILE_CONTENT_OPEN, - MERCURY_CURSOR, - MERCURY_EDIT_DIFF_HISTORY_CLOSE, - MERCURY_EDIT_DIFF_HISTORY_OPEN, - MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_CLOSE, - MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN, - MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_CLOSE, - MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_OPEN, - MERCURY_UNIQUE_TOKEN, -} from "./constants" -import type { MercuryEditRequestContext, MercuryRecentSnippet } from "./types" - -function insertCursorToken(lines: string[], cursorLine: number, cursorCharacter: number): string[] { - if (cursorLine < 0 || cursorLine >= lines.length) return lines - const line = lines[cursorLine] - const safeChar = Math.min(Math.max(cursorCharacter, 0), line.length) - const next = line.slice(0, safeChar) + MERCURY_CURSOR + line.slice(safeChar) - return [...lines.slice(0, cursorLine), next, ...lines.slice(cursorLine + 1)] -} - -export function recentlyViewedSnippetsBlock(snippets: MercuryRecentSnippet[]): string { - const inner = snippets - .map((s) => - [ - MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_OPEN, - `code_snippet_file_path: ${s.filepath}`, - s.content, - MERCURY_RECENTLY_VIEWED_CODE_SNIPPET_CLOSE, - ].join("\n"), - ) - .join("\n") - return [MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_OPEN, inner, MERCURY_RECENTLY_VIEWED_CODE_SNIPPETS_CLOSE].join("\n") -} - -export function currentFileContentBlock( - currentFilePath: string, - currentFileContent: string, - editableRegionStartLine: number, - editableRegionEndLine: number, - cursorLine: number, - cursorCharacter: number, -): string { - const rawLines = currentFileContent.split("\n") - const withCursor = insertCursorToken(rawLines, cursorLine, cursorCharacter) - const start = Math.max(0, Math.min(editableRegionStartLine, withCursor.length)) - const end = Math.max(start, Math.min(editableRegionEndLine, withCursor.length - 1)) - const instrumented = [ - ...withCursor.slice(0, start), - MERCURY_CODE_TO_EDIT_OPEN, - ...withCursor.slice(start, end + 1), - MERCURY_CODE_TO_EDIT_CLOSE, - ...withCursor.slice(end + 1), - ] - return [ - MERCURY_CURRENT_FILE_CONTENT_OPEN, - `current_file_path: ${currentFilePath}`, - instrumented.join("\n"), - MERCURY_CURRENT_FILE_CONTENT_CLOSE, - ].join("\n") -} - -export function editDiffHistoryBlock(diffs: string[]): string { - // Each unidiff from `diff.createPatch` starts with an Index line and a - // separator we strip — matches the POC's editHistoryBlock. Diffs are - // separated by a blank line so the model parses them as distinct hunks. - const trimmed = diffs.map((d) => { - const lines = d.split("\n") - return lines.length > 2 ? lines.slice(2).join("\n") : d - }) - return [MERCURY_EDIT_DIFF_HISTORY_OPEN, trimmed.join("\n\n"), MERCURY_EDIT_DIFF_HISTORY_CLOSE].join("\n") -} - -export function buildMercuryEditPrompt(ctx: MercuryEditRequestContext): string { - // Trailing unique token signals "this is a next-edit request" to the model. - return [ - recentlyViewedSnippetsBlock(ctx.recentlyViewedSnippets), - "", - currentFileContentBlock( - ctx.currentFilePath, - ctx.currentFileContent, - ctx.editableRegionStartLine, - ctx.editableRegionEndLine, - ctx.cursorLine, - ctx.cursorCharacter, - ), - "", - editDiffHistoryBlock(ctx.editDiffHistory), - "", - MERCURY_UNIQUE_TOKEN, - ].join("\n") -} diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts index df59e87847..9b5db50d08 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts @@ -144,15 +144,22 @@ export const FimBody = Schema.Struct({ temperature: Schema.optional(Schema.Finite), }) -// Next Edit (NES) — non-streaming. The VSCode side builds the sentinel-tagged -// prompt (Mercury contract is documented at -// https://docs.inceptionlabs.ai/capabilities/next-edit) and the gateway just -// forwards the message to the upstream edit endpoint. +// Next Edit (NES) — non-streaming. Clients send structured editor context; the +// gateway assembles the Mercury sentinel-tagged prompt (contract documented at +// https://docs.inceptionlabs.ai/capabilities/next-edit) so the prompt format +// lives in one place and is shared across editors. export const EditBody = Schema.Struct({ - content: Schema.String, provider: Schema.optional(Schema.String), model: Schema.optional(Schema.String), maxTokens: Schema.optional(Schema.Finite), + currentFilePath: Schema.String, + currentFileContent: Schema.String, + cursorLine: Schema.Finite, + cursorCharacter: Schema.Finite, + editableRegionStartLine: Schema.Finite, + editableRegionEndLine: Schema.Finite, + recentlyViewedSnippets: Schema.Array(Schema.Struct({ filepath: Schema.String, content: Schema.String })), + editDiffHistory: Schema.Array(Schema.String), }) export const EditResponse = Schema.Struct({ diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index 3aa008d522..ab67f09713 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -21,6 +21,7 @@ import { } from "@kilocode/kilo-gateway" import { DIRECT_FIM_ENV, requestMistralFim, resolveFimTarget } from "@kilocode/kilo-gateway/fim" import { DIRECT_EDIT_ENV, extractFencedBody, resolveEditTarget } from "@kilocode/kilo-gateway/edit" +import { buildMercuryEditPrompt } from "@kilocode/kilo-gateway/edit-prompt" import { buildKiloHeaders } from "@kilocode/kilo-gateway" import { Effect } from "effect" import * as Stream from "effect/Stream" @@ -172,8 +173,21 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", ? AbortSignal.any([request.source.signal, AbortSignal.timeout(FIM_TIMEOUT_MS)]) : AbortSignal.timeout(FIM_TIMEOUT_MS) + // Assemble the Mercury sentinel prompt from the structured context the + // client sent — same builder every editor frontend shares. + const content = buildMercuryEditPrompt({ + currentFilePath: ctx.payload.currentFilePath, + currentFileContent: ctx.payload.currentFileContent, + cursorLine: ctx.payload.cursorLine, + cursorCharacter: ctx.payload.cursorCharacter, + editableRegionStartLine: ctx.payload.editableRegionStartLine, + editableRegionEndLine: ctx.payload.editableRegionEndLine, + recentlyViewedSnippets: [...ctx.payload.recentlyViewedSnippets], + editDiffHistory: [...ctx.payload.editDiffHistory], + }) + const response = yield* Effect.promise(async () => { - console.info(`[EDIT] request provider=${target.provider} model=${target.model} chars=${ctx.payload.content.length}`) + console.info(`[EDIT] request provider=${target.provider} model=${target.model} chars=${content.length}`) return fetch(target.url, { method: "POST", headers: { @@ -185,7 +199,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", model: target.model, max_tokens: ctx.payload.maxTokens ?? 512, // Mercury rejects role:"system" on this endpoint — must be a single user message. - messages: [{ role: "user", content: ctx.payload.content }], + messages: [{ role: "user", content }], }), }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f86f833faf..ce6ac869f9 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5808,10 +5808,20 @@ export class Kilo extends HeyApiClient { parameters?: { directory?: string workspace?: string - content?: string provider?: string model?: string maxTokens?: number + currentFilePath?: string + currentFileContent?: string + cursorLine?: number + cursorCharacter?: number + editableRegionStartLine?: number + editableRegionEndLine?: number + recentlyViewedSnippets?: Array<{ + filepath: string + content: string + }> + editDiffHistory?: Array }, options?: Options, ) { @@ -5822,10 +5832,17 @@ export class Kilo extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "content" }, { in: "body", key: "provider" }, { in: "body", key: "model" }, { in: "body", key: "maxTokens" }, + { in: "body", key: "currentFilePath" }, + { in: "body", key: "currentFileContent" }, + { in: "body", key: "cursorLine" }, + { in: "body", key: "cursorCharacter" }, + { in: "body", key: "editableRegionStartLine" }, + { in: "body", key: "editableRegionEndLine" }, + { in: "body", key: "recentlyViewedSnippets" }, + { in: "body", key: "editDiffHistory" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 77bdc0bca9..7e2bf68cad 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -7783,10 +7783,20 @@ export type KiloFimResponse = KiloFimResponses[keyof KiloFimResponses] export type KiloEditData = { body?: { - content: string provider?: string model?: string maxTokens?: number + currentFilePath: string + currentFileContent: string + cursorLine: number + cursorCharacter: number + editableRegionStartLine: number + editableRegionEndLine: number + recentlyViewedSnippets: Array<{ + filepath: string + content: string + }> + editDiffHistory: Array } path?: never query?: { From 2bbd4d7d7d1fd5d5cb861647c7340c8a3a461b8d Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 11:27:37 +0200 Subject: [PATCH 04/33] fix(vscode): restore extension release version --- packages/kilo-vscode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 1758065c4f..0470ff3161 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.3.10", + "version": "7.3.12", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", From ecbbe7faebfa2b0f06327a63a861d68bf9c88ba2 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 11:28:03 +0200 Subject: [PATCH 05/33] fix: remove noisy next-edit hot-path logs --- packages/kilo-gateway/src/server/edit.ts | 1 - .../next-edit/NextEditInlineCompletionProvider.ts | 5 +---- .../src/kilocode/server/httpapi/handlers/kilo-gateway.ts | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/kilo-gateway/src/server/edit.ts b/packages/kilo-gateway/src/server/edit.ts index c568618694..5256881b23 100644 --- a/packages/kilo-gateway/src/server/edit.ts +++ b/packages/kilo-gateway/src/server/edit.ts @@ -32,7 +32,6 @@ export function createEditHandler(Auth: Auth) { // structured editor context. const content = buildMercuryEditPrompt(context as MercuryEditContext) const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(EDIT_TIMEOUT_MS)]) - console.info(`[EDIT] request provider=${target.provider} model=${target.model} url=${target.url} chars=${content.length}`) let response: Response try { diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index e0eeb59ca3..48f15037e3 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -68,10 +68,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion if (this.deps.suggestionManager?.isPending()) return undefined // Never send an ignored file (.env, secrets, etc.) to the model. - if (this.deps.isFileAllowed && !(await this.deps.isFileAllowed(document.uri.fsPath))) { - nesLog("skip — file is gitignore/kilocodeignore-excluded") - return undefined - } + if (this.deps.isFileAllowed && !(await this.deps.isFileAllowed(document.uri.fsPath))) return undefined const isExplicit = context.triggerKind === vscode.InlineCompletionTriggerKind.Invoke if (!isExplicit) { diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index ab67f09713..67aafe3c5d 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -187,7 +187,6 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", }) const response = yield* Effect.promise(async () => { - console.info(`[EDIT] request provider=${target.provider} model=${target.model} chars=${content.length}`) return fetch(target.url, { method: "POST", headers: { From 27eaa97cddd9e5ff1fb84d624027ab163eb94727 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 11:29:11 +0200 Subject: [PATCH 06/33] test(vscode): preserve cross-file next-edit history --- .../__tests__/editHistoryTracker.spec.ts | 58 +++++++++++++++++++ .../next-edit/editHistoryTracker.ts | 9 ++- 2 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts new file mode 100644 index 0000000000..739b66ca0c --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest" +import * as vscode from "vscode" +import { EditHistoryTracker } from "../editHistoryTracker" + +vi.mock("vscode", () => { + const opens: Array<(doc: unknown) => void> = [] + return { + workspace: { + textDocuments: [], + asRelativePath: (uri: { fsPath: string }) => uri.fsPath.replace("/workspace/", ""), + onDidOpenTextDocument: (cb: (doc: unknown) => void) => { + opens.push(cb) + return { dispose: vi.fn() } + }, + onDidChangeTextDocument: () => ({ dispose: vi.fn() }), + onDidCloseTextDocument: () => ({ dispose: vi.fn() }), + open: (doc: unknown) => opens.forEach((cb) => cb(doc)), + }, + } +}) + +type Doc = vscode.TextDocument & { setText(text: string): void } + +function doc(path: string, initial: string): Doc { + const state = { text: initial } + return { + uri: { fsPath: path, scheme: "file" }, + getText: () => state.text, + setText: (text: string) => { + state.text = text + }, + } as unknown as Doc +} + +describe("EditHistoryTracker", () => { + it("retains chronological edits across files for Mercury context", () => { + const tracker = new EditHistoryTracker() + const a = doc("/workspace/a.ts", "const a = 1\n") + const b = doc("/workspace/b.ts", "const b = 1\n") + const open = (vscode.workspace as unknown as { open(doc: vscode.TextDocument): void }).open + + open(a) + open(b) + a.setText("const a = 2\n") + tracker.flush(a) + b.setText("const b = 2\n") + tracker.flush(b) + + const diffs = tracker.getRecentDiffs() + expect(diffs).toHaveLength(2) + expect(diffs[0]).toContain("a.ts") + expect(diffs[0]).toContain("+const a = 2") + expect(diffs[1]).toContain("b.ts") + expect(diffs[1]).toContain("+const b = 2") + + tracker.dispose() + }) +}) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts index 93465f6a0f..ce9a00af02 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts @@ -5,10 +5,9 @@ const DEFAULT_DEBOUNCE_MS = 1500 const DEFAULT_MAX_DIFFS = 5 /** - * Per-file snapshot tracker that emits range-based unidiffs after a short - * idle window — matching the Mercury docs' guidance: "if a user made multiple - * modifications in the same area, combine them into a single unidiff rather - * than many granular diffs." + * Tracks per-file snapshots and emits a workspace-wide chronological stream + * of range-based unidiffs after a short idle window. Cross-file history is + * intentional: Mercury uses recent edits from any file to infer user intent. * * Diffs are produced lazily; the tracker holds the previously-emitted state * per file and computes the diff against the current document content when @@ -68,7 +67,7 @@ export class EditHistoryTracker implements vscode.Disposable { this.emitDiffNow(document) } - /** Oldest → newest, matching the Mercury prompt-history convention. */ + /** Workspace-wide oldest to newest, matching the Mercury prompt-history convention. */ public getRecentDiffs(): string[] { return [...this.diffs] } From e4c259311832d18c5d7363c401bdfaa9e5cbcb0e Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 11:29:33 +0200 Subject: [PATCH 07/33] fix(vscode): report selected next-edit telemetry model --- .../src/services/autocomplete/AutocompleteServiceManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 7dda302050..d59319686f 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -131,7 +131,7 @@ export class AutocompleteServiceManager { : TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_COMPLETED TelemetryProxy.capture(eventName, { mode: "next-edit", - model: "inception/mercury-next-edit", + model: getAutocompleteModel(this.settings?.provider, this.settings?.model).id, latencyMs: event.latencyMs, inputTokens: event.inputTokens, outputTokens: event.outputTokens, From c25d60d68dd67c880021f782dd94f21f132341dd Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 11:30:19 +0200 Subject: [PATCH 08/33] fix(vscode): retain next-edit pre-cursor rewrites --- .../NextEditInlineCompletionProvider.ts | 11 ++- .../NextEditInlineCompletionProvider.spec.ts | 76 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index 48f15037e3..34f5e5762d 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -190,12 +190,17 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion const cursorLineText = document.lineAt(position.line).text const cursorLineCurrent = cursorLineText.slice(position.character) const cursorLineProposed = proposedLines[prefixLines] - // No cursor-line replacement (pure deletion at the trim seam), or the model - // wants to change characters BEFORE the cursor — neither renders as ghost text. - if (cursorLineProposed === undefined || !cursorLineProposed.startsWith(cursorLineText.slice(0, position.character))) { + // A pure deletion at the trim seam has no cursor-line replacement to render. + if (cursorLineProposed === undefined) { this.emitNotShown(suggestion) return undefined } + // Native ghost text cannot alter text before the cursor; present that edit + // through the decoration/apply flow rather than silently discarding it. + if (!cursorLineProposed.startsWith(cursorLineText.slice(0, position.character))) { + this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, suggestion) + return undefined + } const insertText = [cursorLineProposed.slice(position.character), ...proposedLines.slice(prefixLines + 1, proposedLines.length - suffixLines)].join("\n") const renderEndLine = pickRenderEndLine(document, position.line, diffEndLine, insertText) // A single-line insert spanning non-blank lines below the cursor can't be diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts new file mode 100644 index 0000000000..273f187f26 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest" +import * as vscode from "vscode" +import type { KiloConnectionService } from "../../../cli-backend" +import { NextEditInlineCompletionProvider } from "../NextEditInlineCompletionProvider" +import type { NextEditSuggestionManager } from "../NextEditSuggestionManager" + +vi.mock("vscode", () => { + class Position { + constructor( + public line: number, + public character: number, + ) {} + } + class Range { + constructor( + public start: Position, + public end: Position, + ) {} + } + return { + Position, + Range, + InlineCompletionItem: class {}, + workspace: { + textDocuments: [], + onDidOpenTextDocument: () => ({ dispose: vi.fn() }), + onDidChangeTextDocument: () => ({ dispose: vi.fn() }), + onDidCloseTextDocument: () => ({ dispose: vi.fn() }), + }, + window: { + createOutputChannel: () => ({ appendLine: vi.fn(), dispose: vi.fn() }), + }, + } +}) + +type Subject = { + toCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + suggestion: { + replacement: string + editableRegionStartLine: number + editableRegionEndLine: number + latencyMs: number + }, + ): vscode.InlineCompletionItem[] | undefined +} + +describe("NextEditInlineCompletionProvider", () => { + it("stashes same-line rewrites before the cursor for decorated acceptance", () => { + const mgr = { clear: vi.fn(), setPending: vi.fn() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: {} as KiloConnectionService, + suggestionManager: mgr as unknown as NextEditSuggestionManager, + }) + const text = "const oldName = make()" + const document = { + lineCount: 1, + lineAt: () => ({ text, range: { end: new vscode.Position(0, text.length) } }), + getText: () => text, + } as unknown as vscode.TextDocument + + const out = (provider as unknown as Subject).toCompletionItems(document, new vscode.Position(0, 13), { + replacement: "const newName = make()", + editableRegionStartLine: 0, + editableRegionEndLine: 0, + latencyMs: 1, + }) + + expect(out).toBeUndefined() + expect(mgr.setPending).toHaveBeenCalledWith( + expect.objectContaining({ kind: "replace", replacement: "const newName = make()" }), + ) + provider.dispose() + }) +}) From 47bcb389c81d6e64bec0fb74fc3319ebf7f66f1d Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 11:30:48 +0200 Subject: [PATCH 09/33] fix(vscode): hide internal next-edit acceptance command --- packages/kilo-vscode/package.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 0470ff3161..68ac0d5ba1 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -204,11 +204,6 @@ "title": "Next Edit: Dismiss Pending Suggestion", "category": "Kilo Code" }, - { - "command": "kilo-code.autocomplete.next-edit.accepted", - "title": "Next Edit: Suggestion Accepted (internal)", - "category": "Kilo Code" - }, { "command": "kilo-code.new.agentManager.previousSession", "title": "Agent Manager: Previous Session", From 543d98ec173e8ecba955789328bbb1c5323cef2b Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 11:31:19 +0200 Subject: [PATCH 10/33] refactor(vscode): dispose next-edit log directly --- .../src/services/autocomplete/AutocompleteServiceManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index d59319686f..9bc9b378bf 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -7,6 +7,7 @@ import { AutocompleteCodeActionProvider } from "./AutocompleteCodeActionProvider import { AutocompleteInlineCompletionProvider } from "./classic-auto-complete/AutocompleteInlineCompletionProvider" import { AutocompleteTelemetry } from "./classic-auto-complete/AutocompleteTelemetry" import { NextEditInlineCompletionProvider } from "./next-edit/NextEditInlineCompletionProvider" +import { disposeLog } from "./next-edit/log" import { NextEditSuggestionManager } from "./next-edit/NextEditSuggestionManager" import { toMercuryRecentSnippets } from "./next-edit/recentSnippetsAdapter" import type { KiloConnectionService } from "../cli-backend" @@ -488,7 +489,7 @@ export class AutocompleteServiceManager { // Drop the dedicated Next Edit OutputChannel so it doesn't leak across // extension reloads. - void import("./next-edit/log").then((m) => m.disposeLog()).catch(() => undefined) + disposeLog() // Clear singleton instance AutocompleteServiceManager._instance = null From 30fbd0ab55d9b330c87caff1e96be30367d17d67 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 11:31:37 +0200 Subject: [PATCH 11/33] refactor(vscode): name chained next-edit delay --- .../autocomplete/next-edit/NextEditSuggestionManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts index d654b7b619..0aece725a0 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode" import { nesLog } from "./log" const PENDING_CONTEXT_KEY = "kilo-code.nextEdit.hasPendingSuggestion" +const CHAIN_DELAY_MS = 60 export type PendingNextEdit = | { @@ -325,7 +326,7 @@ export class NextEditSuggestionManager implements vscode.Disposable { * `provideInlineCompletionItems`, and gives the user a moment to abandon the * chain by typing or moving the cursor. */ -export function chainNextPrediction(delayMs = 60): void { +export function chainNextPrediction(delayMs = CHAIN_DELAY_MS): void { setTimeout(() => { void vscode.commands.executeCommand("editor.action.inlineSuggest.trigger") }, delayMs) From 24b40391d6e15ccf1cbdda45266e1bfcd3d5671d Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 11:32:27 +0200 Subject: [PATCH 12/33] docs(kilo-docs): index next-edit source link --- packages/kilo-docs/source-links.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 6da75f9785..e4b29c0538 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -47,6 +47,8 @@ - +- + - - From 3db8e3ccae9b349f755df83bee84926ce89db17d Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 11:32:42 +0200 Subject: [PATCH 13/33] fix(vscode): remove unused next-edit context export --- .../services/autocomplete/next-edit/NextEditSuggestionManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts index 0aece725a0..647a2a830f 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -341,4 +341,3 @@ function visualize(line: string): string { export const NEXT_EDIT_ACCEPT_OR_JUMP_COMMAND = "kilo-code.next-edit.acceptOrJump" export const NEXT_EDIT_DISMISS_COMMAND = "kilo-code.next-edit.dismiss" -export const NEXT_EDIT_PENDING_CONTEXT_KEY = PENDING_CONTEXT_KEY From 57e359a30218034ec45a5ab35be315b9dbed076f Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 10:59:11 +0000 Subject: [PATCH 14/33] fix(vscode): filter next edit history through access policy --- .../NextEditInlineCompletionProvider.ts | 13 +-- .../__tests__/editHistoryTracker.spec.ts | 35 +++++++- .../next-edit/editHistoryTracker.ts | 86 +++++++++++++++---- 3 files changed, 109 insertions(+), 25 deletions(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index 34f5e5762d..0c04e28512 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -49,7 +49,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion private currentAbort: AbortController | null = null constructor(private readonly deps: NextEditProviderDeps) { - this.editHistoryTracker = new EditHistoryTracker() + this.editHistoryTracker = new EditHistoryTracker({ isFileAllowed: deps.isFileAllowed }) } dispose(): void { @@ -77,7 +77,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion } const abort = this.swapAbortController(token) - const ctx = this.buildRequestContext(document, position) + const ctx = await this.buildRequestContext(document, position) const provider = new MercuryEditProvider({ connectionService: this.deps.connectionService, signal: abort.signal, @@ -103,12 +103,15 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion return abort } - private buildRequestContext(document: vscode.TextDocument, position: vscode.Position): MercuryEditRequestContext { + private async buildRequestContext( + document: vscode.TextDocument, + position: vscode.Position, + ): Promise { const { startLine, endLine } = computeEditableRegion({ cursorLine: position.line, totalLines: document.lineCount, }) - this.editHistoryTracker.flush(document) + await this.editHistoryTracker.flush(document) return { currentFilePath: document.uri.fsPath, currentFileContent: document.getText(), @@ -117,7 +120,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion editableRegionStartLine: startLine, editableRegionEndLine: endLine, recentlyViewedSnippets: this.deps.getRecentlyViewedSnippets?.(document) ?? [], - editDiffHistory: this.editHistoryTracker.getRecentDiffs(), + editDiffHistory: await this.editHistoryTracker.getRecentDiffs(), } } diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts index 739b66ca0c..0b5f0406d3 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts @@ -33,7 +33,7 @@ function doc(path: string, initial: string): Doc { } describe("EditHistoryTracker", () => { - it("retains chronological edits across files for Mercury context", () => { + it("retains chronological edits across files for Mercury context", async () => { const tracker = new EditHistoryTracker() const a = doc("/workspace/a.ts", "const a = 1\n") const b = doc("/workspace/b.ts", "const b = 1\n") @@ -42,11 +42,11 @@ describe("EditHistoryTracker", () => { open(a) open(b) a.setText("const a = 2\n") - tracker.flush(a) + await tracker.flush(a) b.setText("const b = 2\n") - tracker.flush(b) + await tracker.flush(b) - const diffs = tracker.getRecentDiffs() + const diffs = await tracker.getRecentDiffs() expect(diffs).toHaveLength(2) expect(diffs[0]).toContain("a.ts") expect(diffs[0]).toContain("+const a = 2") @@ -55,4 +55,31 @@ describe("EditHistoryTracker", () => { tracker.dispose() }) + + it("never returns edits from denied documents", async () => { + const denied = new Set(["/workspace/.env"]) + const tracker = new EditHistoryTracker({ isFileAllowed: async (path) => !denied.has(path) }) + const safe = doc("/workspace/app.ts", "const safe = 1\n") + const secret = doc("/workspace/.env", "TOKEN=old\n") + const open = (vscode.workspace as unknown as { open(doc: vscode.TextDocument): void }).open + + open(safe) + open(secret) + await Promise.resolve() + await Promise.resolve() + secret.setText("TOKEN=secret\n") + await tracker.flush(secret) + safe.setText("const safe = 2\n") + await tracker.flush(safe) + + const diffs = await tracker.getRecentDiffs() + expect(diffs).toHaveLength(1) + expect(diffs[0]).toContain("app.ts") + expect(diffs[0]).not.toContain("TOKEN=secret") + + denied.add("/workspace/app.ts") + expect(await tracker.getRecentDiffs()).toEqual([]) + + tracker.dispose() + }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts index ce9a00af02..215fefc626 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts @@ -4,6 +4,17 @@ import * as vscode from "vscode" const DEFAULT_DEBOUNCE_MS = 1500 const DEFAULT_MAX_DIFFS = 5 +type Options = { + debounceMs?: number + maxDiffs?: number + isFileAllowed?: (fsPath: string) => Promise +} + +type Diff = { + key: string + patch: string +} + /** * Tracks per-file snapshots and emits a workspace-wide chronological stream * of range-based unidiffs after a short idle window. Cross-file history is @@ -16,31 +27,30 @@ const DEFAULT_MAX_DIFFS = 5 export class EditHistoryTracker implements vscode.Disposable { private readonly snapshots = new Map() private readonly pendingTimers = new Map() - private readonly diffs: string[] = [] + private readonly diffs: Diff[] = [] private readonly subscriptions: vscode.Disposable[] = [] - constructor( - private readonly options: { debounceMs?: number; maxDiffs?: number } = {}, - ) { + constructor(private readonly options: Options = {}) { const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS // Seed snapshots on open so the FIRST edit in a freshly-opened file is // captured in the diff history (otherwise the common "open, type, trigger" - // flow ships an empty edit-history block). + // flow ships an empty edit-history block). Access checks happen before + // reading text so ignored documents are never retained as edit context. this.subscriptions.push( vscode.workspace.onDidOpenTextDocument((doc) => { if (doc.uri.scheme !== "file") return - if (!this.snapshots.has(doc.uri.fsPath)) this.snapshots.set(doc.uri.fsPath, doc.getText()) + void this.seed(doc) }), ) for (const doc of vscode.workspace.textDocuments) { - if (doc.uri.scheme === "file") this.snapshots.set(doc.uri.fsPath, doc.getText()) + if (doc.uri.scheme === "file") void this.seed(doc) } this.subscriptions.push( vscode.workspace.onDidChangeTextDocument((event) => { if (event.document.uri.scheme !== "file") return if (event.contentChanges.length === 0) return - this.scheduleSnapshotDiff(event.document, debounceMs) + void this.scheduleSnapshotDiff(event.document, debounceMs) }), ) this.subscriptions.push( @@ -59,17 +69,25 @@ export class EditHistoryTracker implements vscode.Disposable { * this immediately before building a request so the freshest user edit * makes it into the prompt. */ - public flush(document: vscode.TextDocument): void { + public async flush(document: vscode.TextDocument): Promise { const key = document.uri.fsPath + if (!(await this.allowed(key))) { + this.reject(key) + return + } const t = this.pendingTimers.get(key) if (t) clearTimeout(t) this.pendingTimers.delete(key) - this.emitDiffNow(document) + await this.emitDiffNow(document) } /** Workspace-wide oldest to newest, matching the Mercury prompt-history convention. */ - public getRecentDiffs(): string[] { - return [...this.diffs] + public async getRecentDiffs(): Promise { + const kept = ( + await Promise.all(this.diffs.map(async (diff) => ((await this.allowed(diff.key)) ? diff : undefined))) + ).filter((diff): diff is Diff => diff !== undefined) + this.diffs.splice(0, this.diffs.length, ...kept) + return kept.map((diff) => diff.patch) } public dispose(): void { @@ -79,8 +97,26 @@ export class EditHistoryTracker implements vscode.Disposable { this.subscriptions.length = 0 } - private scheduleSnapshotDiff(document: vscode.TextDocument, debounceMs: number): void { + private async seed(document: vscode.TextDocument): Promise { const key = document.uri.fsPath + if (this.snapshots.has(key)) return + if (!this.options.isFileAllowed) { + this.snapshots.set(key, document.getText()) + return + } + if (!(await this.allowed(key))) { + this.reject(key) + return + } + if (!this.snapshots.has(key)) this.snapshots.set(key, document.getText()) + } + + private async scheduleSnapshotDiff(document: vscode.TextDocument, debounceMs: number): Promise { + const key = document.uri.fsPath + if (!(await this.allowed(key))) { + this.reject(key) + return + } if (!this.snapshots.has(key)) { // Fallback seed for documents we never saw open (e.g. opened before the // tracker existed). The triggering change is lost, but subsequent edits @@ -92,13 +128,17 @@ export class EditHistoryTracker implements vscode.Disposable { if (existing) clearTimeout(existing) const timer = setTimeout(() => { this.pendingTimers.delete(key) - this.emitDiffNow(document) + void this.emitDiffNow(document) }, debounceMs) this.pendingTimers.set(key, timer) } - private emitDiffNow(document: vscode.TextDocument): void { + private async emitDiffNow(document: vscode.TextDocument): Promise { const key = document.uri.fsPath + if (!(await this.allowed(key))) { + this.reject(key) + return + } const previous = this.snapshots.get(key) if (previous === undefined) return const current = document.getText() @@ -108,10 +148,24 @@ export class EditHistoryTracker implements vscode.Disposable { const patch = createPatch(filename, previous, current, undefined, undefined, { context: 1 }) // `createPatch` returns "" for identical inputs; guard anyway. if (patch && patch.trim().length > 0) { - this.diffs.push(patch) + this.diffs.push({ key, patch }) const maxDiffs = this.options.maxDiffs ?? DEFAULT_MAX_DIFFS if (this.diffs.length > maxDiffs) this.diffs.shift() } this.snapshots.set(key, current) } + + private async allowed(key: string): Promise { + if (!this.options.isFileAllowed) return true + return this.options.isFileAllowed(key).catch(() => false) + } + + private reject(key: string): void { + const timer = this.pendingTimers.get(key) + if (timer) clearTimeout(timer) + this.pendingTimers.delete(key) + this.snapshots.delete(key) + const kept = this.diffs.filter((diff) => diff.key !== key) + this.diffs.splice(0, this.diffs.length, ...kept) + } } From 50a91dccbca134ae78446f68544afd435c535421 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 10:59:54 +0000 Subject: [PATCH 15/33] fix(gateway): suppress truncated next edit replacements --- packages/kilo-gateway/src/edit.ts | 7 ++++--- packages/kilo-gateway/test/edit.test.ts | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/kilo-gateway/src/edit.ts b/packages/kilo-gateway/src/edit.ts index 900cca088d..70151b0132 100644 --- a/packages/kilo-gateway/src/edit.ts +++ b/packages/kilo-gateway/src/edit.ts @@ -49,10 +49,11 @@ export function extractFencedBody(message: string): string { if (fenceOpen === -1) return message const afterFenceOpen = message.indexOf("\n", fenceOpen + 3) if (afterFenceOpen === -1) return "" - // A missing closing fence means the response was truncated (max_tokens hit). - // Take everything after the opening fence rather than dropping the suggestion. + // A missing closing fence means the replacement was truncated. Applying a + // partial editable region can delete valid trailing code, so suppress it. const fenceClose = message.indexOf("```", afterFenceOpen + 1) - let body = fenceClose === -1 ? message.slice(afterFenceOpen + 1) : message.slice(afterFenceOpen + 1, fenceClose) + if (fenceClose === -1) return "" + let body = message.slice(afterFenceOpen + 1, fenceClose) if (body.endsWith("\n")) body = body.slice(0, -1) body = body.replace(/^<\|code_to_edit\|>\n?/, "") body = body.replace(/\n?<\|\/code_to_edit\|>$/, "") diff --git a/packages/kilo-gateway/test/edit.test.ts b/packages/kilo-gateway/test/edit.test.ts index 5a707571bb..ba01ea163e 100644 --- a/packages/kilo-gateway/test/edit.test.ts +++ b/packages/kilo-gateway/test/edit.test.ts @@ -47,9 +47,8 @@ describe("extractFencedBody", () => { expect(extractFencedBody("")).toBe("") }) - test("takes the rest when the closing fence is missing (truncated output)", () => { - // max_tokens hit mid-stream → no closing ``` — keep what we have. - expect(extractFencedBody("```\nconst x = 1\nconst y = ")).toBe("const x = 1\nconst y = ") + test("suppresses a replacement when the closing fence is missing", () => { + expect(extractFencedBody("```\nconst x = 1\nconst y = ")).toBe("") }) test("preserves internal blank lines and indentation", () => { From 7834aeb2b3a90241ee459791153cb57915add14f Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 11:08:12 +0000 Subject: [PATCH 16/33] fix(vscode): apply next edit insertions safely at eof --- .../next-edit/NextEditSuggestionManager.ts | 13 +++++++---- .../autocomplete/next-edit/pendingEdit.ts | 18 +++++++++++++++ .../tests/unit/next-edit-pending-edit.test.ts | 22 +++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts create mode 100644 packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts index 647a2a830f..8787abe51b 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode" import { nesLog } from "./log" +import { planInsertion } from "./pendingEdit" const PENDING_CONTEXT_KEY = "kilo-code.nextEdit.hasPendingSuggestion" const CHAIN_DELAY_MS = 60 @@ -20,7 +21,7 @@ export type PendingNextEdit = | { kind: "insert" document: vscode.TextDocument - /** Existing line BEFORE which the new content will be inserted. */ + /** Existing line before insertion, or `lineCount` when appending at EOF. */ diffStartLine: number /** Same as diffStartLine for hint/jump-target purposes. */ diffEndLine: number @@ -192,9 +193,13 @@ export class NextEditSuggestionManager implements vscode.Disposable { nesLog(`document drifted since suggestion was made — dropping insert at line ${p.diffStartLine}`) return } - const pos = new vscode.Position(p.diffStartLine, 0) - ok = await editor.edit((b) => b.insert(pos, p.replacement)) - nesLog(`applied insert at line ${pos.line} (${p.replacement.length} chars, ok=${ok})`) + const edit = planInsertion(p, { + lineCount: editor.document.lineCount, + end: (line) => editor.document.lineAt(line).range.end.character, + }) + const pos = new vscode.Position(edit.line, edit.character) + ok = await editor.edit((b) => b.insert(pos, edit.text)) + nesLog(`applied insert at line ${pos.line} (${edit.text.length} chars, ok=${ok})`) } else { const range = new vscode.Range( new vscode.Position(p.diffStartLine, 0), diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts new file mode 100644 index 0000000000..164ca7b19b --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts @@ -0,0 +1,18 @@ +type Input = { + diffStartLine: number + replacement: string +} + +type Document = { + lineCount: number + end(line: number): number +} + +export function planInsertion(input: Input, document: Document) { + if (input.diffStartLine < document.lineCount) { + return { line: input.diffStartLine, character: 0, text: input.replacement } + } + const line = Math.max(0, document.lineCount - 1) + const text = input.replacement.endsWith("\n") ? input.replacement.slice(0, -1) : input.replacement + return { line, character: document.end(line), text: `\n${text}` } +} diff --git a/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts b/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts new file mode 100644 index 0000000000..713c30bc64 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" +import { planInsertion } from "../../src/services/autocomplete/next-edit/pendingEdit" + +describe("planInsertion", () => { + it("appends after the final unterminated line at EOF", () => { + const edit = planInsertion( + { diffStartLine: 2, replacement: "third\n" }, + { lineCount: 2, end: (line) => [5, 6][line] }, + ) + + expect(edit).toEqual({ line: 1, character: 6, text: "\nthird" }) + }) + + it("keeps insertion-before-line semantics for a trailing empty line", () => { + const edit = planInsertion( + { diffStartLine: 1, replacement: "second\n" }, + { lineCount: 2, end: (line) => [5, 0][line] }, + ) + + expect(edit).toEqual({ line: 1, character: 0, text: "second\n" }) + }) +}) From c6d35f1987947ca79f3fb17e758e2f8e85a2ca66 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 11:10:57 +0000 Subject: [PATCH 17/33] fix(vscode): remove next edit deleted lines cleanly --- .../NextEditInlineCompletionProvider.ts | 14 +++-- .../next-edit/NextEditSuggestionManager.ts | 14 ++++- .../NextEditInlineCompletionProvider.spec.ts | 54 +++++++++++++++++++ .../autocomplete/next-edit/pendingEdit.ts | 37 ++++++++++--- .../tests/unit/next-edit-pending-edit.test.ts | 43 ++++++++++++++- 5 files changed, 148 insertions(+), 14 deletions(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index 0c04e28512..1e1695c1b4 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -160,7 +160,8 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion const diffStartLineInFile = suggestion.editableRegionStartLine + prefixLines const diffEndLineInFile = suggestion.editableRegionStartLine + currentLines.length - 1 - suffixLines - const trimmedReplacement = proposedLines.slice(prefixLines, proposedLines.length - suffixLines).join("\n") + const trimmedLines = proposedLines.slice(prefixLines, proposedLines.length - suffixLines) + const trimmedReplacement = trimmedLines.join("\n") nesLog(`diff at lines [${diffStartLineInFile}..${diffEndLineInFile}], cursor at line ${position.line}, ${trimmedReplacement.length} chars`) @@ -168,8 +169,9 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion // For off-cursor diffs, stash the suggestion in the manager — it renders a // decoration-based "jump to next edit" affordance and Tab handles the move/apply. const isPureInsertion = diffEndLineInFile < diffStartLineInFile - if (isPureInsertion || diffStartLineInFile !== position.line) { - this.stashOffCursorSuggestion(document, diffStartLineInFile, diffEndLineInFile, trimmedReplacement, isPureInsertion, suggestion) + const removesLines = trimmedLines.length === 0 + if (isPureInsertion || removesLines || diffStartLineInFile !== position.line) { + this.stashOffCursorSuggestion(document, diffStartLineInFile, diffEndLineInFile, trimmedReplacement, isPureInsertion, removesLines, suggestion) return undefined } // Same-line diff: clear any prior off-cursor pending state so we don't render @@ -201,7 +203,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion // Native ghost text cannot alter text before the cursor; present that edit // through the decoration/apply flow rather than silently discarding it. if (!cursorLineProposed.startsWith(cursorLineText.slice(0, position.character))) { - this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, suggestion) + this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, false, suggestion) return undefined } const insertText = [cursorLineProposed.slice(position.character), ...proposedLines.slice(prefixLines + 1, proposedLines.length - suffixLines)].join("\n") @@ -209,7 +211,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion // A single-line insert spanning non-blank lines below the cursor can't be // represented as inline ghost text — route it to the decoration path. if (renderEndLine > position.line && !insertText.includes("\n")) { - this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, suggestion) + this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, false, suggestion) return undefined } const renderRange = new vscode.Range(position, new vscode.Position(renderEndLine, document.lineAt(renderEndLine).range.end.character)) @@ -246,6 +248,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion diffEndLine: number, trimmedReplacement: string, isPureInsertion: boolean, + removesLines: boolean, suggestion: SuggestionResult, ): void { const mgr = this.deps.suggestionManager @@ -287,6 +290,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion diffStartLine, diffEndLine, replacement: trimmedReplacement, + removesLines, originalText: document.getText(originalRange), }) nesLog(`replace suggestion stashed at lines [${diffStartLine}..${diffEndLine}]`) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts index 8787abe51b..8be10b9348 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" import { nesLog } from "./log" -import { planInsertion } from "./pendingEdit" +import { planInsertion, planReplacement } from "./pendingEdit" const PENDING_CONTEXT_KEY = "kilo-code.nextEdit.hasPendingSuggestion" const CHAIN_DELAY_MS = 60 @@ -15,6 +15,8 @@ export type PendingNextEdit = diffEndLine: number /** New text to substitute for [diffStartLine, diffEndLine]. */ replacement: string + /** Whether the suggestion omits complete lines rather than rewriting one as blank. */ + removesLines: boolean /** Snapshot of the original text — used to detect drift. */ originalText: string } @@ -210,7 +212,15 @@ export class NextEditSuggestionManager implements vscode.Disposable { nesLog(`document drifted since suggestion was made — dropping range [${p.diffStartLine}..${p.diffEndLine}]`) return } - ok = await editor.edit((b) => b.replace(range, p.replacement)) + const edit = planReplacement(p, { + lineCount: editor.document.lineCount, + end: (line) => editor.document.lineAt(line).range.end.character, + }) + const target = new vscode.Range( + new vscode.Position(edit.start.line, edit.start.character), + new vscode.Position(edit.end.line, edit.end.character), + ) + ok = await editor.edit((b) => b.replace(target, edit.text)) nesLog(`applied replace at lines [${p.diffStartLine}..${p.diffEndLine}] (ok=${ok})`) } if (ok) chainNextPrediction() diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts index 273f187f26..f33cf81939 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts @@ -46,6 +46,18 @@ type Subject = { ): vscode.InlineCompletionItem[] | undefined } +function doc(text: string): vscode.TextDocument { + const lines = text.split("\n") + return { + lineCount: lines.length, + lineAt: (line: number) => ({ + text: lines[line], + range: { end: new vscode.Position(line, lines[line].length) }, + }), + getText: () => text, + } as unknown as vscode.TextDocument +} + describe("NextEditInlineCompletionProvider", () => { it("stashes same-line rewrites before the cursor for decorated acceptance", () => { const mgr = { clear: vi.fn(), setPending: vi.fn() } @@ -73,4 +85,46 @@ describe("NextEditInlineCompletionProvider", () => { ) provider.dispose() }) + + it("stashes complete-line deletion intent for acceptance", () => { + const mgr = { clear: vi.fn(), setPending: vi.fn() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: {} as KiloConnectionService, + suggestionManager: mgr as unknown as NextEditSuggestionManager, + }) + + const out = (provider as unknown as Subject).toCompletionItems(doc("before\nremove\nafter"), new vscode.Position(1, 0), { + replacement: "before\nafter", + editableRegionStartLine: 0, + editableRegionEndLine: 2, + latencyMs: 1, + }) + + expect(out).toBeUndefined() + expect(mgr.setPending).toHaveBeenCalledWith( + expect.objectContaining({ kind: "replace", replacement: "", removesLines: true }), + ) + provider.dispose() + }) + + it("does not classify a blank-line rewrite as deletion", () => { + const mgr = { clear: vi.fn(), setPending: vi.fn() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: {} as KiloConnectionService, + suggestionManager: mgr as unknown as NextEditSuggestionManager, + }) + + const out = (provider as unknown as Subject).toCompletionItems(doc("before\nremove\nafter"), new vscode.Position(0, 0), { + replacement: "before\n\nafter", + editableRegionStartLine: 0, + editableRegionEndLine: 2, + latencyMs: 1, + }) + + expect(out).toBeUndefined() + expect(mgr.setPending).toHaveBeenCalledWith( + expect.objectContaining({ kind: "replace", replacement: "", removesLines: false }), + ) + provider.dispose() + }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts index 164ca7b19b..bea2c9c1d8 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts @@ -1,14 +1,19 @@ -type Input = { - diffStartLine: number - replacement: string -} - type Document = { lineCount: number end(line: number): number } -export function planInsertion(input: Input, document: Document) { +type Insertion = { + diffStartLine: number + replacement: string +} + +type Replacement = Insertion & { + diffEndLine: number + removesLines: boolean +} + +export function planInsertion(input: Insertion, document: Document) { if (input.diffStartLine < document.lineCount) { return { line: input.diffStartLine, character: 0, text: input.replacement } } @@ -16,3 +21,23 @@ export function planInsertion(input: Input, document: Document) { const text = input.replacement.endsWith("\n") ? input.replacement.slice(0, -1) : input.replacement return { line, character: document.end(line), text: `\n${text}` } } + +export function planReplacement(input: Replacement, document: Document) { + const end = { line: input.diffEndLine, character: document.end(input.diffEndLine) } + if (!input.removesLines) { + return { start: { line: input.diffStartLine, character: 0 }, end, text: input.replacement } + } + if (input.diffEndLine < document.lineCount - 1) { + return { + start: { line: input.diffStartLine, character: 0 }, + end: { line: input.diffEndLine + 1, character: 0 }, + text: input.replacement, + } + } + if (input.diffStartLine === 0) return { start: { line: 0, character: 0 }, end, text: input.replacement } + return { + start: { line: input.diffStartLine - 1, character: document.end(input.diffStartLine - 1) }, + end, + text: input.replacement, + } +} diff --git a/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts b/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts index 713c30bc64..1f6fdb0c6a 100644 --- a/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts +++ b/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { planInsertion } from "../../src/services/autocomplete/next-edit/pendingEdit" +import { planInsertion, planReplacement } from "../../src/services/autocomplete/next-edit/pendingEdit" describe("planInsertion", () => { it("appends after the final unterminated line at EOF", () => { @@ -20,3 +20,44 @@ describe("planInsertion", () => { expect(edit).toEqual({ line: 1, character: 0, text: "second\n" }) }) }) + +describe("planReplacement", () => { + it("removes a middle line through the following separator", () => { + const edit = planReplacement( + { diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: true }, + { lineCount: 3, end: (line) => [6, 6, 5][line] }, + ) + + expect(edit).toEqual({ + start: { line: 1, character: 0 }, + end: { line: 2, character: 0 }, + text: "", + }) + }) + + it("removes a final line through the preceding separator", () => { + const edit = planReplacement( + { diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: true }, + { lineCount: 2, end: (line) => [6, 6][line] }, + ) + + expect(edit).toEqual({ + start: { line: 0, character: 6 }, + end: { line: 1, character: 6 }, + text: "", + }) + }) + + it("preserves a line intentionally rewritten as blank", () => { + const edit = planReplacement( + { diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: false }, + { lineCount: 3, end: (line) => [6, 6, 5][line] }, + ) + + expect(edit).toEqual({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 6 }, + text: "", + }) + }) +}) From 0210a3b41181540b39fd533831e3857c5df70640 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 11:12:28 +0000 Subject: [PATCH 18/33] fix(vscode): dismiss next edit state when unregistered --- .../src/services/autocomplete/AutocompleteServiceManager.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 9bc9b378bf..0f2709ff89 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -197,11 +197,15 @@ export class AutocompleteServiceManager { this.inlineCompletionProviderKind !== null && this.inlineCompletionProviderKind !== desiredKind ) { + if (this.inlineCompletionProviderKind === "next-edit") this.nextEditSuggestionManager.clear() this.inlineCompletionProviderDisposable?.dispose() this.inlineCompletionProviderDisposable = null this.inlineCompletionProviderKind = null } + if (!shouldBeRegistered && this.inlineCompletionProviderKind === "next-edit") { + this.nextEditSuggestionManager.clear() + } const isRegistered = this.inlineCompletionProviderDisposable !== null if (shouldBeRegistered === isRegistered) return From 63fedf42a524180eb1c4ab9ed89e1be74a78f49b Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 13:18:29 +0200 Subject: [PATCH 19/33] refactor(vscode): simplify next edit history access check --- .../next-edit/__tests__/editHistoryTracker.spec.ts | 2 ++ .../src/services/autocomplete/next-edit/editHistoryTracker.ts | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts index 0b5f0406d3..67515b654d 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts @@ -41,6 +41,8 @@ describe("EditHistoryTracker", () => { open(a) open(b) + await Promise.resolve() + await Promise.resolve() a.setText("const a = 2\n") await tracker.flush(a) b.setText("const b = 2\n") diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts index 215fefc626..2d1baa6dd7 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts @@ -100,10 +100,6 @@ export class EditHistoryTracker implements vscode.Disposable { private async seed(document: vscode.TextDocument): Promise { const key = document.uri.fsPath if (this.snapshots.has(key)) return - if (!this.options.isFileAllowed) { - this.snapshots.set(key, document.getText()) - return - } if (!(await this.allowed(key))) { this.reject(key) return From fadf72bdbf0e3dd08e9ef641a292a2b1b1d4dca4 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 13:29:01 +0200 Subject: [PATCH 20/33] fix(vscode): fail closed for next edit file access --- .../AutocompleteServiceManager.ts | 5 ++- .../NextEditInlineCompletionProvider.ts | 12 ++++-- .../NextEditInlineCompletionProvider.spec.ts | 41 ++++++++++++++++++- .../__tests__/editHistoryTracker.spec.ts | 18 +++++++- .../next-edit/editHistoryTracker.ts | 9 ++-- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 0f2709ff89..23e1dcf297 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -116,10 +116,11 @@ export class AutocompleteServiceManager { getRecentlyViewedSnippets: () => { // Reuse the LRU populated by the classic provider — keeps a single // RecentlyVisitedRangesService instance instead of double-tracking. - // Snippets are filtered against the ignore controller before sending. + // Suppress snippets until access checks are available, then include + // only content explicitly approved by the ignore controller. const raw = this.inlineCompletionProvider.recentlyVisitedRangesService.getSnippets() const ignore = this.ignoreControllerSync - const allowed = ignore ? raw.filter((s) => ignore.validateAccess(s.filepath)) : raw + const allowed = ignore ? raw.filter((s) => ignore.validateAccess(s.filepath)) : [] return toMercuryRecentSnippets(allowed) }, onFatalError: (status) => this.handleFatalAutocompleteError(status), diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index 1e1695c1b4..e1d426579a 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -16,7 +16,7 @@ export interface NextEditProviderDeps { /** Optional source of recently-viewed snippets (kilocode's VisibleCodeTracker can adapt to this). */ getRecentlyViewedSnippets?: (document: vscode.TextDocument) => MercuryRecentSnippet[] /** Returns false for files that must not be sent to a server (.env etc). */ - isFileAllowed?: (fsPath: string) => Promise + isFileAllowed: (fsPath: string) => Promise /** Telemetry hook fired on every suggestion result. */ onSuggestion?: (event: NextEditSuggestionEvent) => void onFatalError?: (status: number | null) => void @@ -67,8 +67,8 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion if (document.uri.scheme !== "file") return undefined if (this.deps.suggestionManager?.isPending()) return undefined - // Never send an ignored file (.env, secrets, etc.) to the model. - if (this.deps.isFileAllowed && !(await this.deps.isFileAllowed(document.uri.fsPath))) return undefined + // Never send a file unless the access policy explicitly approves it. + if (!(await this.allowed(document.uri.fsPath))) return undefined const isExplicit = context.triggerKind === vscode.InlineCompletionTriggerKind.Invoke if (!isExplicit) { @@ -95,6 +95,12 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion } } + private async allowed(path: string): Promise { + const allow = this.deps.isFileAllowed + if (!allow) return false + return allow(path).catch(() => false) + } + private swapAbortController(token: vscode.CancellationToken): AbortController { this.currentAbort?.abort() const abort = new AbortController() diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts index f33cf81939..3c12778974 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest" import * as vscode from "vscode" import type { KiloConnectionService } from "../../../cli-backend" -import { NextEditInlineCompletionProvider } from "../NextEditInlineCompletionProvider" +import { NextEditInlineCompletionProvider, type NextEditProviderDeps } from "../NextEditInlineCompletionProvider" import type { NextEditSuggestionManager } from "../NextEditSuggestionManager" vi.mock("vscode", () => { @@ -55,14 +55,51 @@ function doc(text: string): vscode.TextDocument { range: { end: new vscode.Position(line, lines[line].length) }, }), getText: () => text, + uri: { fsPath: "/workspace/test.ts", scheme: "file" }, } as unknown as vscode.TextDocument } describe("NextEditInlineCompletionProvider", () => { + it("does not send a document when the access policy is missing at runtime", async () => { + const connection = { getClientAsync: vi.fn() } + const provider = new NextEditInlineCompletionProvider({ connectionService: connection } as unknown as NextEditProviderDeps) + + const out = await provider.provideInlineCompletionItems( + doc("const value = 1"), + new vscode.Position(0, 0), + {} as vscode.InlineCompletionContext, + {} as vscode.CancellationToken, + ) + + expect(out).toBeUndefined() + expect(connection.getClientAsync).not.toHaveBeenCalled() + provider.dispose() + }) + + it("does not send a document when the access policy fails", async () => { + const connection = { getClientAsync: vi.fn() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: connection as unknown as KiloConnectionService, + isFileAllowed: async () => Promise.reject(new Error("unavailable")), + }) + + const out = await provider.provideInlineCompletionItems( + doc("const value = 1"), + new vscode.Position(0, 0), + {} as vscode.InlineCompletionContext, + {} as vscode.CancellationToken, + ) + + expect(out).toBeUndefined() + expect(connection.getClientAsync).not.toHaveBeenCalled() + provider.dispose() + }) + it("stashes same-line rewrites before the cursor for decorated acceptance", () => { const mgr = { clear: vi.fn(), setPending: vi.fn() } const provider = new NextEditInlineCompletionProvider({ connectionService: {} as KiloConnectionService, + isFileAllowed: async () => true, suggestionManager: mgr as unknown as NextEditSuggestionManager, }) const text = "const oldName = make()" @@ -90,6 +127,7 @@ describe("NextEditInlineCompletionProvider", () => { const mgr = { clear: vi.fn(), setPending: vi.fn() } const provider = new NextEditInlineCompletionProvider({ connectionService: {} as KiloConnectionService, + isFileAllowed: async () => true, suggestionManager: mgr as unknown as NextEditSuggestionManager, }) @@ -111,6 +149,7 @@ describe("NextEditInlineCompletionProvider", () => { const mgr = { clear: vi.fn(), setPending: vi.fn() } const provider = new NextEditInlineCompletionProvider({ connectionService: {} as KiloConnectionService, + isFileAllowed: async () => true, suggestionManager: mgr as unknown as NextEditSuggestionManager, }) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts index 67515b654d..00f22b85b2 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts @@ -34,7 +34,7 @@ function doc(path: string, initial: string): Doc { describe("EditHistoryTracker", () => { it("retains chronological edits across files for Mercury context", async () => { - const tracker = new EditHistoryTracker() + const tracker = new EditHistoryTracker({ isFileAllowed: async () => true }) const a = doc("/workspace/a.ts", "const a = 1\n") const b = doc("/workspace/b.ts", "const b = 1\n") const open = (vscode.workspace as unknown as { open(doc: vscode.TextDocument): void }).open @@ -43,6 +43,7 @@ describe("EditHistoryTracker", () => { open(b) await Promise.resolve() await Promise.resolve() + await Promise.resolve() a.setText("const a = 2\n") await tracker.flush(a) b.setText("const b = 2\n") @@ -58,6 +59,21 @@ describe("EditHistoryTracker", () => { tracker.dispose() }) + it("does not retain edits when the access policy is missing at runtime", async () => { + const tracker = new EditHistoryTracker({} as { isFileAllowed: (path: string) => Promise }) + const a = doc("/workspace/a.ts", "const a = 1\n") + const open = (vscode.workspace as unknown as { open(doc: vscode.TextDocument): void }).open + + open(a) + await Promise.resolve() + await Promise.resolve() + a.setText("const a = 2\n") + await tracker.flush(a) + + expect(await tracker.getRecentDiffs()).toEqual([]) + tracker.dispose() + }) + it("never returns edits from denied documents", async () => { const denied = new Set(["/workspace/.env"]) const tracker = new EditHistoryTracker({ isFileAllowed: async (path) => !denied.has(path) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts index 2d1baa6dd7..9edebc298f 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts @@ -7,7 +7,7 @@ const DEFAULT_MAX_DIFFS = 5 type Options = { debounceMs?: number maxDiffs?: number - isFileAllowed?: (fsPath: string) => Promise + isFileAllowed: (fsPath: string) => Promise } type Diff = { @@ -30,7 +30,7 @@ export class EditHistoryTracker implements vscode.Disposable { private readonly diffs: Diff[] = [] private readonly subscriptions: vscode.Disposable[] = [] - constructor(private readonly options: Options = {}) { + constructor(private readonly options: Options) { const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS // Seed snapshots on open so the FIRST edit in a freshly-opened file is @@ -152,8 +152,9 @@ export class EditHistoryTracker implements vscode.Disposable { } private async allowed(key: string): Promise { - if (!this.options.isFileAllowed) return true - return this.options.isFileAllowed(key).catch(() => false) + const allow = this.options.isFileAllowed + if (!allow) return false + return allow(key).catch(() => false) } private reject(key: string): void { From fcbdee46dcf465d976be239f5005097d6fb41240 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 13:36:20 +0200 Subject: [PATCH 21/33] test(vscode): settle next edit history setup cleanly --- .../next-edit/__tests__/editHistoryTracker.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts index 00f22b85b2..7d4484df28 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts @@ -32,6 +32,10 @@ function doc(path: string, initial: string): Doc { } as unknown as Doc } +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + describe("EditHistoryTracker", () => { it("retains chronological edits across files for Mercury context", async () => { const tracker = new EditHistoryTracker({ isFileAllowed: async () => true }) @@ -41,9 +45,7 @@ describe("EditHistoryTracker", () => { open(a) open(b) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await settle() a.setText("const a = 2\n") await tracker.flush(a) b.setText("const b = 2\n") @@ -65,8 +67,7 @@ describe("EditHistoryTracker", () => { const open = (vscode.workspace as unknown as { open(doc: vscode.TextDocument): void }).open open(a) - await Promise.resolve() - await Promise.resolve() + await settle() a.setText("const a = 2\n") await tracker.flush(a) @@ -83,8 +84,7 @@ describe("EditHistoryTracker", () => { open(safe) open(secret) - await Promise.resolve() - await Promise.resolve() + await settle() secret.setText("TOKEN=secret\n") await tracker.flush(secret) safe.setText("const safe = 2\n") From 9a0f38ade5f85b56d6239a0d1f514164a5821ac9 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 13:49:40 +0200 Subject: [PATCH 22/33] fix(vscode): namespace next edit commands --- packages/kilo-vscode/package.json | 8 ++++---- .../kilo-vscode/src/services/autocomplete/index.ts | 10 +++------- .../next-edit/NextEditSuggestionManager.ts | 3 --- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 68ac0d5ba1..3627dd898a 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -195,12 +195,12 @@ "category": "Kilo Code" }, { - "command": "kilo-code.next-edit.acceptOrJump", + "command": "kilo-code.new.autocomplete.nextEdit.acceptOrJump", "title": "Next Edit: Accept or Jump to Suggested Edit", "category": "Kilo Code" }, { - "command": "kilo-code.next-edit.dismiss", + "command": "kilo-code.new.autocomplete.nextEdit.dismiss", "title": "Next Edit: Dismiss Pending Suggestion", "category": "Kilo Code" }, @@ -768,12 +768,12 @@ "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && kilocode.autocomplete.enableSmartInlineTaskKeybinding && github.copilot.completions.enabled" }, { - "command": "kilo-code.next-edit.acceptOrJump", + "command": "kilo-code.new.autocomplete.nextEdit.acceptOrJump", "key": "tab", "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && !suggestWidgetVisible && kilo-code.nextEdit.hasPendingSuggestion" }, { - "command": "kilo-code.next-edit.dismiss", + "command": "kilo-code.new.autocomplete.nextEdit.dismiss", "key": "escape", "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && kilo-code.nextEdit.hasPendingSuggestion" } diff --git a/packages/kilo-vscode/src/services/autocomplete/index.ts b/packages/kilo-vscode/src/services/autocomplete/index.ts index f207191b52..a27f838616 100644 --- a/packages/kilo-vscode/src/services/autocomplete/index.ts +++ b/packages/kilo-vscode/src/services/autocomplete/index.ts @@ -3,11 +3,7 @@ import { AutocompleteServiceManager } from "./AutocompleteServiceManager" import { ensureBackendForAutocomplete } from "./ensure-backend" import { nesLog } from "./next-edit/log" import { INLINE_COMPLETION_ACCEPTED_COMMAND as NEXT_EDIT_ACCEPTED_COMMAND } from "./next-edit/NextEditInlineCompletionProvider" -import { - NEXT_EDIT_ACCEPT_OR_JUMP_COMMAND, - NEXT_EDIT_DISMISS_COMMAND, - chainNextPrediction, -} from "./next-edit/NextEditSuggestionManager" +import { chainNextPrediction } from "./next-edit/NextEditSuggestionManager" import type { KiloConnectionService } from "../cli-backend" export const registerAutocompleteProvider = ( @@ -60,13 +56,13 @@ export const registerAutocompleteProvider = ( // Tab handler for off-cursor pending suggestions: first press teleports the // cursor to the predicted edit, second press applies. context.subscriptions.push( - vscode.commands.registerCommand(NEXT_EDIT_ACCEPT_OR_JUMP_COMMAND, async () => { + vscode.commands.registerCommand("kilo-code.new.autocomplete.nextEdit.acceptOrJump", async () => { await autocompleteManager.nextEditSuggestionManager.acceptOrJump() }), ) // Esc handler: dismiss the pending suggestion without applying. context.subscriptions.push( - vscode.commands.registerCommand(NEXT_EDIT_DISMISS_COMMAND, () => { + vscode.commands.registerCommand("kilo-code.new.autocomplete.nextEdit.dismiss", () => { autocompleteManager.nextEditSuggestionManager.clear() }), ) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts index 8be10b9348..fdeebe6199 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -353,6 +353,3 @@ function visualize(line: string): string { const collapsed = line.replace(/\s+$/g, "").replace(/^\t+/, (t) => " ".repeat(t.length)) return collapsed.length > 120 ? collapsed.slice(0, 117) + "…" : collapsed } - -export const NEXT_EDIT_ACCEPT_OR_JUMP_COMMAND = "kilo-code.next-edit.acceptOrJump" -export const NEXT_EDIT_DISMISS_COMMAND = "kilo-code.next-edit.dismiss" From aa1f857ba4de088625aaa7cf0ad01bef47797f47 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 13:50:39 +0200 Subject: [PATCH 23/33] style(vscode): format next edit integration files --- .../docs/mercury-next-edit-testing.html | 1151 +++++++++++------ .../docs/nes-examples/js_07_async_await.js | 17 +- .../docs/nes-examples/js_08_express_route.js | 28 +- .../nes-examples/ts_07_array_transform.ts | 18 +- .../docs/nes-examples/ts_08_param_types.ts | 14 +- .../docs/nes-examples/ts_09_jsx_handler.tsx | 32 +- .../next-edit/MercuryEditProvider.ts | 5 +- .../NextEditInlineCompletionProvider.ts | 44 +- .../next-edit/NextEditSuggestionManager.ts | 7 +- .../NextEditInlineCompletionProvider.spec.ts | 36 +- 10 files changed, 852 insertions(+), 500 deletions(-) diff --git a/packages/kilo-vscode/docs/mercury-next-edit-testing.html b/packages/kilo-vscode/docs/mercury-next-edit-testing.html index ba66680952..5990db29f7 100644 --- a/packages/kilo-vscode/docs/mercury-next-edit-testing.html +++ b/packages/kilo-vscode/docs/mercury-next-edit-testing.html @@ -1,256 +1,507 @@ - - -Mercury Next Edit — Testing Playground (Kilo Code) - - - - -
+ + + Mercury Next Edit — Testing Playground (Kilo Code) + + + + +
+

Mercury Next Edit — Testing Playground

+

+ A walk-through guide for Kilo Code reviewers to validate the new + Next Edit Suggestion integration powered by Mercury Edit 2 from Inception Labs. +

-

Mercury Next Edit — Testing Playground

-

A walk-through guide for Kilo Code reviewers to validate the new Next Edit Suggestion integration powered by Mercury Edit 2 from Inception Labs.

+
+

On this page

+ +
-
-

On this page

- -
+

What is Mercury Next Edit?

+

+ Mercury Edit 2 is a code-edit model from Inception Labs. + Unlike FIM completion, it predicts the user's next multi-line edit given the current file, cursor + position, and recent edit history. It typically responds in under 250 ms. +

+

+ This PR adds Mercury Next Edit as a new, opt-in autocomplete option in Kilo Code. It lives + alongside everything that's already shipping — Codestral FIM and Mercury Edit 2 via the Kilo gateway are + bit-for-bit unchanged. Selecting Mercury Next Edit (Inception) from the model dropdown switches + to a separate render pipeline: +

+
    +
  • + Same-line predictions render as inline ghost text (just like FIM — + Tab accepts). +
  • +
  • + Off-cursor predictions render as a decoration (red strikethrough + green ghost annotation) at + the predicted edit location. First Tab teleports the cursor there; second Tab applies. +
  • +
  • + After any accept, the integration immediately re-triggers Mercury so the user can walk a refactor with + repeated Tab presses ("Tab-Tab-Tab"). +
  • +
-

What is Mercury Next Edit?

-

Mercury Edit 2 is a code-edit model from Inception Labs. Unlike FIM completion, it predicts the user's next multi-line edit given the current file, cursor position, and recent edit history. It typically responds in under 250 ms.

-

This PR adds Mercury Next Edit as a new, opt-in autocomplete option in Kilo Code. It lives alongside everything that's already shipping — Codestral FIM and Mercury Edit 2 via the Kilo gateway are bit-for-bit unchanged. Selecting Mercury Next Edit (Inception) from the model dropdown switches to a separate render pipeline:

-
    -
  • Same-line predictions render as inline ghost text (just like FIM — Tab accepts).
  • -
  • Off-cursor predictions render as a decoration (red strikethrough + green ghost annotation) at the predicted edit location. First Tab teleports the cursor there; second Tab applies.
  • -
  • After any accept, the integration immediately re-triggers Mercury so the user can walk a refactor with repeated Tab presses ("Tab-Tab-Tab").
  • -
+

Install the PR locally

+

+ You'll need to pull this PR's branch and run the extension in a development VSCode window ("Extension + Development Host"). The whole loop is about 3 minutes once you have the prerequisites. +

-

Install the PR locally

-

You'll need to pull this PR's branch and run the extension in a development VSCode window ("Extension Development Host"). The whole loop is about 3 minutes once you have the prerequisites.

+

Prerequisites

+
    +
  • VSCode ≥ 1.105.1 (matches kilocode's engines.vscode)
  • +
  • + Bun ≥ 1.3.13 (the build script checks the version) — install via + brew install bun or bun.sh +
  • +
  • GitHub CLI (gh) — optional but makes the PR checkout one command
  • +
  • + An Inception API key — create one at + platform.inceptionlabs.ai if you don't already have one +
  • +
-

Prerequisites

-
    -
  • VSCode ≥ 1.105.1 (matches kilocode's engines.vscode)
  • -
  • Bun ≥ 1.3.13 (the build script checks the version) — install via brew install bun or bun.sh
  • -
  • GitHub CLI (gh) — optional but makes the PR checkout one command
  • -
  • An Inception API key — create one at platform.inceptionlabs.ai if you don't already have one
  • -
- -

1. Check out the PR branch

-

From an empty directory:

-
gh repo clone Kilo-Org/kilocode
+      

1. Check out the PR branch

+

From an empty directory:

+
gh repo clone Kilo-Org/kilocode
 cd kilocode
 gh pr checkout 10536
-

Or without gh:

-
git clone https://github.com/Kilo-Org/kilocode.git
+      

Or without gh:

+
git clone https://github.com/Kilo-Org/kilocode.git
 cd kilocode
 git fetch origin pull/10536/head:mercury-next-edit-integration
 git checkout mercury-next-edit-integration
-

2. Install dependencies

-
bun install
-

(First install pulls the full monorepo — takes 30–60 seconds.)

+

2. Install dependencies

+
bun install
+

(First install pulls the full monorepo — takes 30–60 seconds.)

-

3. Start the dev build

-
cd packages/kilo-vscode
+      

3. Start the dev build

+
cd packages/kilo-vscode
 bun run watch
-

Leave that terminal running. It rebuilds the extension on every save and runs the TypeScript compiler in watch mode.

+

+ Leave that terminal running. It rebuilds the extension on every save and runs the TypeScript compiler in watch + mode. +

-

4. Open kilocode in VSCode and launch the Extension Development Host

-

From a separate terminal (or your IDE launcher):

-
code /path/to/kilocode
-

Inside that VSCode window, press F5 (or Run → Start Debugging). A second VSCode window opens, titled [Extension Development Host]. That window has this PR's build of the kilocode extension loaded.

+

4. Open kilocode in VSCode and launch the Extension Development Host

+

From a separate terminal (or your IDE launcher):

+
code /path/to/kilocode
+

+ Inside that VSCode window, press F5 (or Run → Start Debugging). A second VSCode + window opens, titled [Extension Development Host]. That window has this PR's build of the + kilocode extension loaded. +

-
-

Pre-push turbo typecheck may fail on packages that need Java (JetBrains plugin). That's environment, not code — not relevant to the NES feature. Use --no-verify on any local pushes if you hit it.

-
+
+

+ Pre-push turbo typecheck may fail on packages that need Java (JetBrains plugin). That's + environment, not code — not relevant to the NES feature. Use --no-verify on any local + pushes if you hit it. +

+
-

5. Open the test playground

-

In the Dev Host window: File → Open Folder… → choose packages/kilo-vscode/docs/nes-examples/ inside this same repo. That gives you the 20 self-contained test files described below.

+

5. Open the test playground

+

+ In the Dev Host window: File → Open Folder… → choose + packages/kilo-vscode/docs/nes-examples/ inside this same repo. That gives you the 20 self-contained + test files described below. +

-

6. Configure NES

-

Settings (Cmd+,) in the Dev Host, search kilo-code.new.autocomplete:

-
    -
  • modelMercury Next Edit (Inception)not "Mercury Edit 2", which is the classic FIM-via-gateway option
  • -
  • nextEdit.apiKey → paste your sk_… Inception API key (or set INCEPTION_API_KEY env before launching)
  • -
  • enableAutoTrigger → ✓ (already the default)
  • -
+

6. Configure NES

+

Settings (Cmd+,) in the Dev Host, search kilo-code.new.autocomplete:

+
    +
  • + modelMercury Next Edit (Inception)not "Mercury Edit 2", + which is the classic FIM-via-gateway option +
  • +
  • + nextEdit.apiKey → paste your sk_… Inception API key (or set + INCEPTION_API_KEY env before launching) +
  • +
  • enableAutoTrigger → ✓ (already the default)
  • +
-

7. Watch the pipeline live

-

In the Dev Host: View → Output → in the dropdown, select "Kilo Code · Next Edit". Every request, response, and render decision is logged here with timestamps. Keep this panel visible while testing — it's the single best diagnostic.

+

7. Watch the pipeline live

+

+ In the Dev Host: View → Output → in the dropdown, select + "Kilo Code · Next Edit". Every request, response, and render decision is logged here with + timestamps. Keep this panel visible while testing — it's the single best diagnostic. +

-

You're set. Skip to the test cases below.

+

You're set. Skip to the test cases below.

-

Enabling the feature (settings reference)

-

In VSCode Settings (Cmd+,), search kilo-code.new.autocomplete:

- - - - - - - -
SettingValue
modelMercury Next Edit (Inception)not "Mercury Edit 2", which is the original FIM-via-gateway option
nextEdit.apiKeyyour Inception API key (sk_...); also accepts INCEPTION_API_KEY env var
enableAutoTrigger✓ (default)
nextEdit.baseUrl(optional) override the API base, defaults to https://api.inceptionlabs.ai/v1
nextEdit.debug(optional) mirror diagnostic logs to DevTools console
-

To watch the pipeline live: View → Output in the Dev Host, choose the "Kilo Code · Next Edit" channel.

+

Enabling the feature (settings reference)

+

In VSCode Settings (Cmd+,), search kilo-code.new.autocomplete:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
SettingValue
model + Mercury Next Edit (Inception)not "Mercury Edit 2", which is the original + FIM-via-gateway option +
nextEdit.apiKeyyour Inception API key (sk_...); also accepts INCEPTION_API_KEY env var
enableAutoTrigger✓ (default)
nextEdit.baseUrl(optional) override the API base, defaults to https://api.inceptionlabs.ai/v1
nextEdit.debug(optional) mirror diagnostic logs to DevTools console
+

+ To watch the pipeline live: View → Output in the Dev Host, choose the + "Kilo Code · Next Edit" channel. +

-

How the integration works

-

The AutocompleteServiceManager instantiates both providers up front. Provider registration with vscode.languages.registerInlineCompletionItemProvider is driven by the configured model:

-
    -
  • inception/mercury-next-editNES provider (this PR's new pipeline)
  • -
  • anything else → classic FIM provider (unchanged)
  • -
-

The NES provider, per keystroke:

-
    -
  1. Debounces 250 ms (skipped for explicit invocations).
  2. -
  3. Builds a Mercury prompt: current file + cursor + an editable region [cursor − 5, cursor + 10] + 3–5 recently-viewed-snippet ranges (from the shared RecentlyVisitedRangesService) + the last 5 debounced unidiffs (from a new per-file EditHistoryTracker).
  4. -
  5. Sends a single role: "user" message to POST /v1/edit/completions with max_tokens: 512.
  6. -
  7. Parses the triple-backtick fenced reply, strips Mercury's sentinel tokens, computes the minimal line-diff against the current document.
  8. -
  9. Branches: same-line diff → InlineCompletionItem; off-cursor diff → NextEditSuggestionManager with a decoration + Tab/Esc keybinding gated on a context flag (kilo-code.nextEdit.hasPendingSuggestion).
  10. -
+

How the integration works

+

+ The AutocompleteServiceManager instantiates both providers up front. Provider + registration with vscode.languages.registerInlineCompletionItemProvider is driven by the + configured model: +

+
    +
  • inception/mercury-next-editNES provider (this PR's new pipeline)
  • +
  • anything else → classic FIM provider (unchanged)
  • +
+

The NES provider, per keystroke:

+
    +
  1. Debounces 250 ms (skipped for explicit invocations).
  2. +
  3. + Builds a Mercury prompt: current file + cursor + an editable region [cursor − 5, cursor + 10] + + 3–5 recently-viewed-snippet ranges (from the shared RecentlyVisitedRangesService) + the last 5 + debounced unidiffs (from a new per-file EditHistoryTracker). +
  4. +
  5. + Sends a single role: "user" message to POST /v1/edit/completions with + max_tokens: 512. +
  6. +
  7. + Parses the triple-backtick fenced reply, strips Mercury's sentinel tokens, computes the minimal line-diff + against the current document. +
  8. +
  9. + Branches: same-line diff → InlineCompletionItem; off-cursor diff → + NextEditSuggestionManager with a decoration + Tab/Esc keybinding gated on a context flag + (kilo-code.nextEdit.hasPendingSuggestion). +
  10. +
-

Test cases

-

Each test below is a self-contained file at packages/kilo-vscode/docs/nes-examples/ in this repo. Open that folder in the Extension Development Host (step 5 above), then work through the cases. Place your cursor where indicated, wait ~300 ms idle, and observe.

-

Tip: keep this page open in a separate window from the Dev Host — the descriptions below would otherwise leak into Mercury's prompt context and bias the test.

+

Test cases

+

+ Each test below is a self-contained file at packages/kilo-vscode/docs/nes-examples/ in this repo. + Open that folder in the Extension Development Host (step 5 above), then work through the cases. Place your + cursor where indicated, wait ~300 ms idle, and observe. +

+

+ Tip: keep this page open in a separate window from the Dev Host — the descriptions below would otherwise + leak into Mercury's prompt context and bias the test. +

-

Render-path legend:

-

- same-line ghost appears as inline ghost text at the cursor (Tab accepts) · - off-cursor decoration renders away from the cursor; first Tab jumps, second Tab applies · - suppressed negative case — nothing should render -

+

Render-path legend:

+

+ same-line ghost appears as inline ghost text at the cursor (Tab + accepts) · off-cursor decoration renders away from the cursor; first + Tab jumps, second Tab applies · + suppressed negative case — nothing should render +

-

Python — core tests

+

Python — core tests

-
-

01 — Finish a recursive function body same-line

-
def factorial(n):
+      
+

01 — Finish a recursive function body same-line

+
def factorial(n):
     if n <= 1:
         return 1
 
 
-
Cursor
-

The empty indented line at the end of factorial (column 4).

-
Expected
-

Ghost text proposing the recursive case (e.g. return n * factorial(n - 1)). Tab accepts.

-
+
Cursor
+

The empty indented line at the end of factorial (column 4).

+
Expected
+

+ Ghost text proposing the recursive case (e.g. return n * factorial(n - 1)). + Tab accepts. +

+
-
-

02 — Pattern continuation same-line

-
COLOR_RED = "#ff0000"
+      
+

02 — Pattern continuation same-line

+
COLOR_RED = "#ff0000"
 COLOR_GREEN = "#00ff00"
 COLOR_BLUE =
-
Cursor
-

End of line 3 (right after =).

-
Expected
-

Ghost text appending a hex color like "#0000ff".

-
+
Cursor
+

End of line 3 (right after =).

+
Expected
+

Ghost text appending a hex color like "#0000ff".

+
-
-

03 — Mid-identifier completion same-line

-
def calculate_total(items):
+      
+

03 — Mid-identifier completion same-line

+
def calculate_total(items):
     total = 0
     for item in items:
         total += item.price
     return tot
-
Cursor
-

End of file (after return tot).

-
Expected
-

Ghost text completing the identifier (likely altotal).

-
+
Cursor
+

End of file (after return tot).

+
Expected
+

Ghost text completing the identifier (likely altotal).

+
-
-

04 — Loop body inference same-line

-
def calculate_total(items):
+      
+

04 — Loop body inference same-line

+
def calculate_total(items):
     total = 0
     for item in items:
 
     return total
-
Cursor
-

The empty indented line inside the for loop (column 8).

-
Expected
-

Ghost text proposing the accumulator update.

-
+
Cursor
+

The empty indented line inside the for loop (column 8).

+
Expected
+

Ghost text proposing the accumulator update.

+
-
-

05 — Sibling method body same-line

-
class Stack:
+      
+

05 — Sibling method body same-line

+
class Stack:
     def __init__(self):
         self.items = []
 
@@ -262,17 +513,17 @@ COLOR_BLUE =
def peek(self): return self.items[-1] if self.items else None
-
Cursor
-

Empty indented line inside pop (column 8).

-
Expected
-

Ghost text proposing a body consistent with the symmetric push.

-
+
Cursor
+

Empty indented line inside pop (column 8).

+
Expected
+

Ghost text proposing a body consistent with the symmetric push.

+
-

Python — advanced

+

Python — advanced

-
-

07 — Multi-line rename refactor off-cursor

-
def compute_user_score(u, w):
+      
+

07 — Multi-line rename refactor off-cursor

+
def compute_user_score(u, w):
     base = u * 10
     bonus = w * 5
     penalty = u - w
@@ -284,42 +535,45 @@ def compute_user_score(user_id, weight):
     bonus = w * 5
     penalty = u - w
     return base + bonus - penalty
-
Cursor
-

End of the renamed signature line (def compute_user_score(user_id, weight):).

-
Expected
-

Strikethrough on the body lines below + ghost showing the renamed body. First Tab jumps, second applies.

-
+
Cursor
+

End of the renamed signature line (def compute_user_score(user_id, weight):).

+
Expected
+

+ Strikethrough on the body lines below + ghost showing the renamed body. First Tab jumps, second + applies. +

+
-
-

08 — Mixed insert + replace off-cursor

-
def sum_prices(items):
+      
+

08 — Mixed insert + replace off-cursor

+
def sum_prices(items):
     total = 0
     for item in items:
     return total
-
Cursor
-

End of total = 0.

-
Expected
-

Decoration on the broken for-loop area showing the corrected body (insertion + replacement combined).

-
+
Cursor
+

End of total = 0.

+
Expected
+

Decoration on the broken for-loop area showing the corrected body (insertion + replacement combined).

+
-
-

10 — Mid-token completion same-line

-
def fibonacci(n):
+      
+

10 — Mid-token completion same-line

+
def fibonacci(n):
     if n <= 1:
         return n
     return fibonacci(n - 1) + fibonacci(n - 2)
 
 
 result = fib
-
Cursor
-

End of the file (after result = fib).

-
Expected
-

Ghost text extending the identifier and supplying a call, e.g. onacci(10).

-
+
Cursor
+

End of the file (after result = fib).

+
Expected
+

Ghost text extending the identifier and supplying a call, e.g. onacci(10).

+
-
-

11 — Stub method with implemented siblings same-line

-
class Queue:
+      
+

11 — Stub method with implemented siblings same-line

+
class Queue:
     def __init__(self):
         self.items = []
 
@@ -340,15 +594,18 @@ result = fib
def clear(self): self.items.clear()
-
Cursor
-

Empty indented line inside dequeue (column 8).

-
Expected
-

Ghost text proposing a FIFO pop, e.g. return self.items.pop(0).

-
+
Cursor
+

Empty indented line inside dequeue (column 8).

+
Expected
+

Ghost text proposing a FIFO pop, e.g. return self.items.pop(0).

+
-
-

12 — Type annotation insertion same-line / off-cursor

-
def multiply(a: int, b: int) -> int:
+      
+

+ 12 — Type annotation insertion same-line / + off-cursor +

+
def multiply(a: int, b: int) -> int:
     return a * b
 
 
@@ -358,15 +615,19 @@ def subtract(a: int, b: int) -> int:
 
 def add(a, b):
     return a + b
-
Cursor
-

End of def add(a, b): (the only un-annotated function).

-
Expected
-

Strikethrough on the signature line + ghost showing the typed version (def add(a: int, b: int) -> int:). May render same-line or off-cursor depending on where on the line you clicked.

-
+
Cursor
+

End of def add(a, b): (the only un-annotated function).

+
Expected
+

+ Strikethrough on the signature line + ghost showing the typed version (def add(a: int, b: int) -> int:). May render same-line or off-cursor depending on where on the line you clicked. +

+
-
-

13 — Docstring generation same-line

-
import datetime
+      
+

13 — Docstring generation same-line

+
import datetime
 
 
 def parse_iso_datetime(s):
@@ -377,15 +638,15 @@ def parse_iso_datetime(s):
 def parse_iso_date(s):
 
     return datetime.date.fromisoformat(s)
-
Cursor
-

Empty indented line under def parse_iso_date(s): (column 4).

-
Expected
-

Ghost text inserting a one-line docstring matching the sibling's style.

-
+
Cursor
+

Empty indented line under def parse_iso_date(s): (column 4).

+
Expected
+

Ghost text inserting a one-line docstring matching the sibling's style.

+
-
-

14 — No-op suppression suppressed

-
def add(a: int, b: int) -> int:
+      
+

14 — No-op suppression suppressed

+
def add(a: int, b: int) -> int:
     """Return the sum of two integers."""
     return a + b
 
@@ -398,19 +659,22 @@ def multiply(a: int, b: int) -> int:
 def subtract(a: int, b: int) -> int:
     """Return a minus b."""
     return a - b
-
Cursor
-

End of return a + b.

-
Expected
-

Nothing. The code is already correct — either Mercury returns an identical reply or our suppression branch drops the proposal. Channel should show "no-op" or skip lines, never a render.

-
Failure mode
-

Any visible suggestion that just replays the existing code is a false positive worth reporting.

-
+
Cursor
+

End of return a + b.

+
Expected
+

+ Nothing. The code is already correct — either Mercury returns an identical reply or our + suppression branch drops the proposal. Channel should show "no-op" or skip lines, never a render. +

+
Failure mode
+

Any visible suggestion that just replays the existing code is a false positive worth reporting.

+
-

TypeScript

+

TypeScript

-
-

ts_07 — Array transform completion same-line

-
interface User {
+      
+

ts_07 — Array transform completion same-line

+
interface User {
     id: number;
     name: string;
     active: boolean;
@@ -427,15 +691,15 @@ const sample: User[] = [
 ];
 
 console.log(getActiveUserNames(sample));
-
Cursor
-

End of return users inside getActiveUserNames.

-
Expected
-

Ghost text completing the chain, e.g. .filter(u => u.active).map(u => u.name).

-
+
Cursor
+

End of return users inside getActiveUserNames.

+
Expected
+

Ghost text completing the chain, e.g. .filter(u => u.active).map(u => u.name).

+
-
-

ts_08 — Param type annotations off-cursor

-
function double(x: number): number {
+      
+

ts_08 — Param type annotations off-cursor

+
function double(x: number): number {
     return x * 2;
 }
 
@@ -454,15 +718,15 @@ function main(): void {
 }
 
 main();
-
Cursor
-

End of file (after main();).

-
Expected
-

Decoration on the add(a, b) signature proposing the typed version.

-
+
Cursor
+

End of file (after main();).

+
Expected
+

Decoration on the add(a, b) signature proposing the typed version.

+
-
-

ts_09 — React event handler same-line

-
declare const React: {
+      
+

ts_09 — React event handler same-line

+
declare const React: {
     useState: <T>(initial: T) => [T, (next: T) => void];
 };
 
@@ -482,17 +746,17 @@ function Counter(): JSX.Element {
 }
 
 export default Counter;
-
Cursor
-

Empty indented line inside handleClick (column 4).

-
Expected
-

Ghost text incrementing count via setCount.

-
+
Cursor
+

Empty indented line inside handleClick (column 4).

+
Expected
+

Ghost text incrementing count via setCount.

+
-

Go

+

Go

-
-

go_07 — Error handling block same-line

-
package main
+      
+

go_07 — Error handling block same-line

+
package main
 
 import (
 	"fmt"
@@ -513,15 +777,15 @@ func main() {
 	}
 	fmt.Println(string(cfg))
 }
-
Cursor
-

Empty line right after data, err := os.ReadFile(path).

-
Expected
-

Ghost text proposing the canonical if err != nil { return nil, err }.

-
+
Cursor
+

Empty line right after data, err := os.ReadFile(path).

+
Expected
+

Ghost text proposing the canonical if err != nil { return nil, err }.

+
-
-

go_08 — Struct method body same-line

-
package main
+      
+

go_08 — Struct method body same-line

+
package main
 
 import "fmt"
 
@@ -543,15 +807,15 @@ func main() {
 	fmt.Println("perimeter:", r.Perimeter())
 	fmt.Println("area:", r.Area())
 }
-
Cursor
-

Empty indented line inside Area().

-
Expected
-

Ghost text computing area from Width and Height.

-
+
Cursor
+

Empty indented line inside Area().

+
Expected
+

Ghost text computing area from Width and Height.

+
-
-

go_09 — Goroutine + channel same-line

-
package main
+      
+

go_09 — Goroutine + channel same-line

+
package main
 
 import "fmt"
 
@@ -566,17 +830,17 @@ func main() {
 		fmt.Println("got:", v)
 	}
 }
-
Cursor
-

Empty indented line inside the goroutine.

-
Expected
-

Ghost text producing values onto the channel and closing it.

-
+
Cursor
+

Empty indented line inside the goroutine.

+
Expected
+

Ghost text producing values onto the channel and closing it.

+
-

Rust

+

Rust

-
-

rs_07 — Match-arm completion same-line

-
enum Shape {
+      
+

rs_07 — Match-arm completion same-line

+
enum Shape {
     Circle(f64),
     Square(f64),
     Rectangle(f64, f64),
@@ -601,15 +865,15 @@ fn main() {
         println!("area = {}", area(s));
     }
 }
-
Cursor
-

Empty indented line inside the match body, after the Square arm.

-
Expected
-

Ghost text adding the missing Rectangle and Triangle arms.

-
+
Cursor
+

Empty indented line inside the match body, after the Square arm.

+
Expected
+

Ghost text adding the missing Rectangle and Triangle arms.

+
-
-

rs_08 — Result/Option chaining same-line

-
fn parse_int(s: &str) -> Option<i32> {
+      
+

rs_08 — Result/Option chaining same-line

+
fn parse_int(s: &str) -> Option<i32> {
     let n = s.trim()
     Some(n * 2)
 }
@@ -623,15 +887,15 @@ fn main() {
         }
     }
 }
-
Cursor
-

End of let n = s.trim() (no semicolon yet).

-
Expected
-

Ghost text continuing the chain into a parsed i32.

-
+
Cursor
+

End of let n = s.trim() (no semicolon yet).

+
Expected
+

Ghost text continuing the chain into a parsed i32.

+
-
-

rs_09 — Lifetime annotations off-cursor

-
fn longest(a: &str, b: &str) -> &str {
+      
+

rs_09 — Lifetime annotations off-cursor

+
fn longest(a: &str, b: &str) -> &str {
     if a.len() >= b.len() {
         a
     } else {
@@ -645,17 +909,17 @@ fn main() {
     let out = longest(&s1, &s2);
     println!("longest = {}", out);
 }
-
Cursor
-

End of file.

-
Expected
-

Decoration on the fn longest signature proposing lifetime annotations.

-
+
Cursor
+

End of file.

+
Expected
+

Decoration on the fn longest signature proposing lifetime annotations.

+
-

JavaScript

+

JavaScript

-
-

js_07 — Async/await fetch same-line

-
async function fetchUser(id) {
+      
+

js_07 — Async/await fetch same-line

+
async function fetchUser(id) {
     try {
 
     } catch (err) {
@@ -670,15 +934,15 @@ async function main() {
 }
 
 main();
-
Cursor
-

Empty indented line inside the try { block (column 8).

-
Expected
-

Ghost text completing the fetch + json parse.

-
+
Cursor
+

Empty indented line inside the try { block (column 8).

+
Expected
+

Ghost text completing the fetch + json parse.

+
-
-

js_08 — Express GET handler same-line

-
const app = {
+      
+

js_08 — Express GET handler same-line

+
const app = {
     get: (_path, _handler) => app,
     post: (_path, _handler) => app,
     listen: (_port, cb) => cb && cb(),
@@ -700,17 +964,17 @@ app.post("/users", (req, res) => {
 });
 
 app.listen(3000, () => console.log("listening on :3000"));
-
Cursor
-

Empty indented line inside the GET handler (column 4).

-
Expected
-

Ghost text proposing a get-by-id (lookup, 404, json response).

-
+
Cursor
+

Empty indented line inside the GET handler (column 4).

+
Expected
+

Ghost text proposing a get-by-id (lookup, 404, json response).

+
-

SQL

+

SQL

-
-

sql_07 — Missing JOIN same-line

-
SELECT
+      
+

sql_07 — Missing JOIN same-line

+
SELECT
     c.name,
     SUM(o.total) AS total_spent
 FROM orders o
@@ -719,29 +983,29 @@ WHERE o.created_at >= '2026-01-01'
 GROUP BY c.name
 ORDER BY total_spent DESC
 LIMIT 10;
-
Cursor
-

End of the line FROM orders o.

-
Expected
-

Ghost text completing the JOIN against customers.

-
+
Cursor
+

End of the line FROM orders o.

+
Expected
+

Ghost text completing the JOIN against customers.

+
-
-

sql_08 — WHERE filter same-line

-
SELECT id, email
+      
+

sql_08 — WHERE filter same-line

+
SELECT id, email
 FROM users
 WHERE
 ORDER BY last_login_at DESC;
-
Cursor
-

End of the bare WHERE line.

-
Expected
-

Ghost text proposing a predicate.

-
+
Cursor
+

End of the bare WHERE line.

+
Expected
+

Ghost text proposing a predicate.

+
-

Markdown (negative case)

+

Markdown (negative case)

-
-

md_07 — Prose should stay quiet suppressed

-
# Mercury Edit 2 — Quick Notes
+      
+

md_07 — Prose should stay quiet suppressed

+
# Mercury Edit 2 — Quick Notes
 
 Mercury Edit 2 is a small, fast model trained to predict the user's
 next single edit given the current file, cursor position, and recent
@@ -751,40 +1015,93 @@ returns a unified-diff-like patch scoped to a window around the cursor.
 Unlike chat-style completions, the model is biased toward minimal,
 local changes — finishing a function body, fixing a typo, propagating
 a rename — rather than generating new files from scratch.
-
Cursor
-

End of the last sentence.

-
Expected
-

Nothing. If Mercury does propose a prose continuation it counts as a soft fail — we don't want a code model writing README content.

-
+
Cursor
+

End of the last sentence.

+
Expected
+

+ Nothing. If Mercury does propose a prose continuation it counts as a soft fail — we + don't want a code model writing README content. +

+
-

Troubleshooting

-
-

If nothing happens when you type, open View → Output → "Kilo Code · Next Edit" and watch the log. The pipeline is verbose enough that 90% of issues are obvious from the first few lines.

-
+

Troubleshooting

+
+

+ If nothing happens when you type, open View → Output → "Kilo Code · Next Edit" and watch the + log. The pipeline is verbose enough that 90% of issues are obvious from the first few lines. +

+
- - - - - - - - -
SymptomLikely causeFix
No log lines at allWrong model selected, or Dev Host wasn't reloaded after rebuildCmd+R in the Dev Host; confirm model = Mercury Next Edit (Inception)
skip — no API key resolvedSetting not savedRe-paste the key in nextEdit.apiKey, press Enter, reload
<- 401 UnauthorizedWrong key or wrong tierVerify the key at platform.inceptionlabs.ai
<- 400 Bad RequestPrompt-shape regression (we shouldn't ship this, but if it happens during dev)Capture the response body from the channel and ping the integration owner
Suggestion shown for a wrong-looking modelSelecting "Mercury Edit 2" routes through the classic FIM provider, not NES — that's by design (the old behavior is preserved)Switch to "Mercury Next Edit (Inception)" to use the new pipeline
Inline ghost text never appears, but logs show RENDERAnother extension (Copilot, Tabnine) is winning the inline-completion raceTemporarily disable conflicting extensions in the Dev Host
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SymptomLikely causeFix
No log lines at allWrong model selected, or Dev Host wasn't reloaded after rebuild + Cmd+R in the Dev Host; confirm model = Mercury Next Edit (Inception) +
skip — no API key resolvedSetting not savedRe-paste the key in nextEdit.apiKey, press Enter, reload
<- 401 UnauthorizedWrong key or wrong tierVerify the key at platform.inceptionlabs.ai
<- 400 Bad RequestPrompt-shape regression (we shouldn't ship this, but if it happens during dev)Capture the response body from the channel and ping the integration owner
Suggestion shown for a wrong-looking model + Selecting "Mercury Edit 2" routes through the classic FIM provider, not NES — that's by design (the + old behavior is preserved) + Switch to "Mercury Next Edit (Inception)" to use the new pipeline
Inline ghost text never appears, but logs show RENDERAnother extension (Copilot, Tabnine) is winning the inline-completion raceTemporarily disable conflicting extensions in the Dev Host
-

Feedback we'd love

-
    -
  • Where the prediction was wrong but the UX was correct. Note the file + cursor position + what Mercury proposed. Helps us tune the model.
  • -
  • Where the UX got in the way. Tab semantics, decoration appearance, chained-prediction timing, anything that felt clumsy compared to other NES products you've used.
  • -
  • Performance regressions in classic FIM autocomplete. The PR is supposed to leave the classic path untouched — if Codestral or Mercury Edit 2 (FIM) feel different in this build, that's a regression we want to know about.
  • -
  • Things you tried that aren't in this doc. The 20 tests are a starting point, not a contract. Real codebases will be different.
  • -
+

Feedback we'd love

+
    +
  • + Where the prediction was wrong but the UX was correct. Note the file + cursor position + what + Mercury proposed. Helps us tune the model. +
  • +
  • + Where the UX got in the way. Tab semantics, decoration appearance, chained-prediction timing, + anything that felt clumsy compared to other NES products you've used. +
  • +
  • + Performance regressions in classic FIM autocomplete. The PR is supposed to leave the classic + path untouched — if Codestral or Mercury Edit 2 (FIM) feel different in this build, that's a regression + we want to know about. +
  • +
  • + Things you tried that aren't in this doc. The 20 tests are a starting point, not a contract. + Real codebases will be different. +
  • +
- - - - + + + diff --git a/packages/kilo-vscode/docs/nes-examples/js_07_async_await.js b/packages/kilo-vscode/docs/nes-examples/js_07_async_await.js index 30e4f0ba51..f61b734ccb 100644 --- a/packages/kilo-vscode/docs/nes-examples/js_07_async_await.js +++ b/packages/kilo-vscode/docs/nes-examples/js_07_async_await.js @@ -1,15 +1,14 @@ async function fetchUser(id) { - try { - - } catch (err) { - console.error("fetchUser failed", err); - return null; - } + try { + } catch (err) { + console.error("fetchUser failed", err) + return null + } } async function main() { - const user = await fetchUser(42); - console.log("user:", user); + const user = await fetchUser(42) + console.log("user:", user) } -main(); +main() diff --git a/packages/kilo-vscode/docs/nes-examples/js_08_express_route.js b/packages/kilo-vscode/docs/nes-examples/js_08_express_route.js index 75e3f2bcc9..83d4c9ce26 100644 --- a/packages/kilo-vscode/docs/nes-examples/js_08_express_route.js +++ b/packages/kilo-vscode/docs/nes-examples/js_08_express_route.js @@ -1,22 +1,20 @@ const app = { - get: (_path, _handler) => app, - post: (_path, _handler) => app, - listen: (_port, cb) => cb && cb(), -}; + get: (_path, _handler) => app, + post: (_path, _handler) => app, + listen: (_port, cb) => cb && cb(), +} const users = [ - { id: 1, name: "ada" }, - { id: 2, name: "lin" }, -]; + { id: 1, name: "ada" }, + { id: 2, name: "lin" }, +] -app.get("/users/:id", (req, res) => { - -}); +app.get("/users/:id", (req, res) => {}) app.post("/users", (req, res) => { - const user = { id: users.length + 1, name: req.body.name }; - users.push(user); - res.status(201).json(user); -}); + const user = { id: users.length + 1, name: req.body.name } + users.push(user) + res.status(201).json(user) +}) -app.listen(3000, () => console.log("listening on :3000")); +app.listen(3000, () => console.log("listening on :3000")) diff --git a/packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts b/packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts index 1cd15cd17b..f939ce4cd2 100644 --- a/packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts +++ b/packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts @@ -1,17 +1,17 @@ interface User { - id: number; - name: string; - active: boolean; + id: number + name: string + active: boolean } function getActiveUserNames(users: User[]): string[] { - return users + return users } const sample: User[] = [ - { id: 1, name: "ada", active: true }, - { id: 2, name: "lin", active: false }, - { id: 3, name: "rin", active: true }, -]; + { id: 1, name: "ada", active: true }, + { id: 2, name: "lin", active: false }, + { id: 3, name: "rin", active: true }, +] -console.log(getActiveUserNames(sample)); +console.log(getActiveUserNames(sample)) diff --git a/packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts b/packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts index c114bc3f3c..5330b68f14 100644 --- a/packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts +++ b/packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts @@ -1,19 +1,19 @@ function double(x: number): number { - return x * 2; + return x * 2 } function add(a, b) { - return a + b; + return a + b } function negate(x: number): number { - return -x; + return -x } function main(): void { - console.log(double(3)); - console.log(add(2, 4)); - console.log(negate(7)); + console.log(double(3)) + console.log(add(2, 4)) + console.log(negate(7)) } -main(); +main() diff --git a/packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx b/packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx index 8d8481f2e4..5c5ed11bc2 100644 --- a/packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx +++ b/packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx @@ -1,20 +1,18 @@ declare const React: { - useState: (initial: T) => [T, (next: T) => void]; -}; - -function Counter(): JSX.Element { - const [count, setCount] = React.useState(0); - - function handleClick() { - - } - - return ( -
-

Count: {count}

- -
- ); + useState: (initial: T) => [T, (next: T) => void] } -export default Counter; +function Counter(): JSX.Element { + const [count, setCount] = React.useState(0) + + function handleClick() {} + + return ( +
+

Count: {count}

+ +
+ ) +} + +export default Counter diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts index 165af0d5be..4079a35a40 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts @@ -86,7 +86,10 @@ export class MercuryEditProvider { } export class MercuryEditError extends Error { - constructor(message: string, public readonly status: number | null) { + constructor( + message: string, + public readonly status: number | null, + ) { super(message) this.name = "MercuryEditError" } diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index e1d426579a..f92ad608c2 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -169,7 +169,9 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion const trimmedLines = proposedLines.slice(prefixLines, proposedLines.length - suffixLines) const trimmedReplacement = trimmedLines.join("\n") - nesLog(`diff at lines [${diffStartLineInFile}..${diffEndLineInFile}], cursor at line ${position.line}, ${trimmedReplacement.length} chars`) + nesLog( + `diff at lines [${diffStartLineInFile}..${diffEndLineInFile}], cursor at line ${position.line}, ${trimmedReplacement.length} chars`, + ) // VSCode's inline ghost text only renders when the diff starts on the cursor's line. // For off-cursor diffs, stash the suggestion in the manager — it renders a @@ -177,13 +179,31 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion const isPureInsertion = diffEndLineInFile < diffStartLineInFile const removesLines = trimmedLines.length === 0 if (isPureInsertion || removesLines || diffStartLineInFile !== position.line) { - this.stashOffCursorSuggestion(document, diffStartLineInFile, diffEndLineInFile, trimmedReplacement, isPureInsertion, removesLines, suggestion) + this.stashOffCursorSuggestion( + document, + diffStartLineInFile, + diffEndLineInFile, + trimmedReplacement, + isPureInsertion, + removesLines, + suggestion, + ) return undefined } // Same-line diff: clear any prior off-cursor pending state so we don't render // two competing affordances. this.deps.suggestionManager?.clear() - return this.renderSameLineItem(document, position, proposedLines, prefixLines, suffixLines, diffStartLineInFile, diffEndLineInFile, trimmedReplacement, suggestion) + return this.renderSameLineItem( + document, + position, + proposedLines, + prefixLines, + suffixLines, + diffStartLineInFile, + diffEndLineInFile, + trimmedReplacement, + suggestion, + ) } /** Build the cursor-position ghost-text item for a same-line diff. */ @@ -212,7 +232,10 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, false, suggestion) return undefined } - const insertText = [cursorLineProposed.slice(position.character), ...proposedLines.slice(prefixLines + 1, proposedLines.length - suffixLines)].join("\n") + const insertText = [ + cursorLineProposed.slice(position.character), + ...proposedLines.slice(prefixLines + 1, proposedLines.length - suffixLines), + ].join("\n") const renderEndLine = pickRenderEndLine(document, position.line, diffEndLine, insertText) // A single-line insert spanning non-blank lines below the cursor can't be // represented as inline ghost text — route it to the decoration path. @@ -220,14 +243,19 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, false, suggestion) return undefined } - const renderRange = new vscode.Range(position, new vscode.Position(renderEndLine, document.lineAt(renderEndLine).range.end.character)) + const renderRange = new vscode.Range( + position, + new vscode.Position(renderEndLine, document.lineAt(renderEndLine).range.end.character), + ) if (document.getText(renderRange) === cursorLineCurrent && cursorLineCurrent === insertText) return undefined const item = new vscode.InlineCompletionItem(insertText, renderRange, { command: INLINE_COMPLETION_ACCEPTED_COMMAND, title: "Next Edit Accepted", }) - nesLog(`RENDER range=[${renderRange.start.line}:${renderRange.start.character}..${renderRange.end.line}:${renderRange.end.character}] insertChars=${insertText.length}`) + nesLog( + `RENDER range=[${renderRange.start.line}:${renderRange.start.character}..${renderRange.end.line}:${renderRange.end.character}] insertChars=${insertText.length}`, + ) this.deps.onSuggestion?.({ shown: true, latencyMs: suggestion.latencyMs, @@ -284,7 +312,9 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion replacement: trimmedReplacement + "\n", originalText: document.lineAt(anchorLine).text, }) - nesLog(`insert suggestion stashed at line ${diffStartLine} (anchor=${anchorLine}, eof=${isEof}, ${trimmedReplacement.length} chars)`) + nesLog( + `insert suggestion stashed at line ${diffStartLine} (anchor=${anchorLine}, eof=${isEof}, ${trimmedReplacement.length} chars)`, + ) } else { const originalRange = new vscode.Range( new vscode.Position(diffStartLine, 0), diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts index fdeebe6199..a8ba61f16b 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -288,8 +288,7 @@ export class NextEditSuggestionManager implements vscode.Disposable { // Hint anchor + cursor check use the active editor if it's one of ours, // else fall back to the first visible editor for this document. const active = vscode.window.activeTextEditor - const referenceEditor = - active && editors.includes(active) ? active : editors[0] + const referenceEditor = active && editors.includes(active) ? active : editors[0] const hintAnchor = Math.min(p.diffStartLine, p.document.lineCount - 1) const hintLineEnd = p.document.lineAt(Math.max(0, hintAnchor)).range.end const cursor = referenceEditor.selection.active @@ -297,9 +296,7 @@ export class NextEditSuggestionManager implements vscode.Disposable { p.kind === "replace" ? cursor.line >= p.diffStartLine && cursor.line <= p.diffEndLine : cursor.line === p.diffStartLine || cursor.line === p.diffStartLine - 1 - const hintText = cursorAtDiff - ? " ↳ Tab to apply · Esc to dismiss" - : " ↳ Tab to jump here · Esc to dismiss" + const hintText = cursorAtDiff ? " ↳ Tab to apply · Esc to dismiss" : " ↳ Tab to jump here · Esc to dismiss" const hintOptions: vscode.DecorationOptions[] = [ { range: new vscode.Range(hintLineEnd, hintLineEnd), diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts index 3c12778974..1610c5ce74 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts @@ -62,7 +62,9 @@ function doc(text: string): vscode.TextDocument { describe("NextEditInlineCompletionProvider", () => { it("does not send a document when the access policy is missing at runtime", async () => { const connection = { getClientAsync: vi.fn() } - const provider = new NextEditInlineCompletionProvider({ connectionService: connection } as unknown as NextEditProviderDeps) + const provider = new NextEditInlineCompletionProvider({ + connectionService: connection, + } as unknown as NextEditProviderDeps) const out = await provider.provideInlineCompletionItems( doc("const value = 1"), @@ -131,12 +133,16 @@ describe("NextEditInlineCompletionProvider", () => { suggestionManager: mgr as unknown as NextEditSuggestionManager, }) - const out = (provider as unknown as Subject).toCompletionItems(doc("before\nremove\nafter"), new vscode.Position(1, 0), { - replacement: "before\nafter", - editableRegionStartLine: 0, - editableRegionEndLine: 2, - latencyMs: 1, - }) + const out = (provider as unknown as Subject).toCompletionItems( + doc("before\nremove\nafter"), + new vscode.Position(1, 0), + { + replacement: "before\nafter", + editableRegionStartLine: 0, + editableRegionEndLine: 2, + latencyMs: 1, + }, + ) expect(out).toBeUndefined() expect(mgr.setPending).toHaveBeenCalledWith( @@ -153,12 +159,16 @@ describe("NextEditInlineCompletionProvider", () => { suggestionManager: mgr as unknown as NextEditSuggestionManager, }) - const out = (provider as unknown as Subject).toCompletionItems(doc("before\nremove\nafter"), new vscode.Position(0, 0), { - replacement: "before\n\nafter", - editableRegionStartLine: 0, - editableRegionEndLine: 2, - latencyMs: 1, - }) + const out = (provider as unknown as Subject).toCompletionItems( + doc("before\nremove\nafter"), + new vscode.Position(0, 0), + { + replacement: "before\n\nafter", + editableRegionStartLine: 0, + editableRegionEndLine: 2, + latencyMs: 1, + }, + ) expect(out).toBeUndefined() expect(mgr.setPending).toHaveBeenCalledWith( From 29c3798faae2b82cba8ce531304630fee10f23b3 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 12:44:19 +0000 Subject: [PATCH 24/33] fix(vscode): send workspace-relative current file path for next edit Match classic autocomplete's policy of never sending absolute fsPaths upstream. Mercury only needs the path for language/context hints, and the workspace-relative form is what recentlyViewedSnippets already uses. --- .changeset/mercury-next-edit.md | 5 +++++ .../next-edit/NextEditInlineCompletionProvider.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .changeset/mercury-next-edit.md diff --git a/.changeset/mercury-next-edit.md b/.changeset/mercury-next-edit.md new file mode 100644 index 0000000000..8df4ee70bb --- /dev/null +++ b/.changeset/mercury-next-edit.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add Mercury Next Edit as an opt-in autocomplete mode. Predicts multi-line edits beyond the cursor (including off-cursor and pure-insertion edits) and surfaces them with a Tab-to-jump / Tab-to-apply affordance. Select "Mercury Next Edit" under the autocomplete model setting to enable it (requires an Inception API key). diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index f92ad608c2..67a85f04dd 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -119,7 +119,10 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion }) await this.editHistoryTracker.flush(document) return { - currentFilePath: document.uri.fsPath, + // Mirror classic autocomplete's policy: never send an absolute fsPath upstream. + // Mercury only needs the path for language/context hints, and the workspace-relative + // form is what `recentlyViewedSnippets` already uses (see recentSnippetsAdapter.ts). + currentFilePath: vscode.workspace.asRelativePath(document.uri, false), currentFileContent: document.getText(), cursorLine: position.line, cursorCharacter: position.character, From 2c3b6bb0aff850d42d01d40cffde2b9cdbf891c6 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 16:09:01 +0200 Subject: [PATCH 25/33] test(vscode): run next edit suites in CI --- .../unit/next-edit-editable-region.test.ts} | 4 ++-- .../unit/next-edit-history-tracker.test.ts} | 2 +- .../unit/next-edit-inline-completion-provider.test.ts} | 9 ++++++--- .../unit/next-edit-recent-snippets.test.ts} | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) rename packages/kilo-vscode/{src/services/autocomplete/next-edit/__tests__/editableRegion.spec.ts => tests/unit/next-edit-editable-region.test.ts} (84%) rename packages/kilo-vscode/{src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts => tests/unit/next-edit-history-tracker.test.ts} (97%) rename packages/kilo-vscode/{src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts => tests/unit/next-edit-inline-completion-provider.test.ts} (94%) rename packages/kilo-vscode/{src/services/autocomplete/next-edit/__tests__/recentSnippetsAdapter.spec.ts => tests/unit/next-edit-recent-snippets.test.ts} (93%) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editableRegion.spec.ts b/packages/kilo-vscode/tests/unit/next-edit-editable-region.test.ts similarity index 84% rename from packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editableRegion.spec.ts rename to packages/kilo-vscode/tests/unit/next-edit-editable-region.test.ts index d11f3543c1..d80a6a7a06 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editableRegion.spec.ts +++ b/packages/kilo-vscode/tests/unit/next-edit-editable-region.test.ts @@ -1,5 +1,5 @@ -import { MAX_EDITABLE_REGION_LINES } from "../constants" -import { computeEditableRegion } from "../editableRegion" +import { MAX_EDITABLE_REGION_LINES } from "../../src/services/autocomplete/next-edit/constants" +import { computeEditableRegion } from "../../src/services/autocomplete/next-edit/editableRegion" describe("computeEditableRegion", () => { it("returns the default [-5, +10] window around the cursor", () => { diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts b/packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts similarity index 97% rename from packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts rename to packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts index 7d4484df28..d674f047e4 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts +++ b/packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest" import * as vscode from "vscode" -import { EditHistoryTracker } from "../editHistoryTracker" +import { EditHistoryTracker } from "../../src/services/autocomplete/next-edit/editHistoryTracker" vi.mock("vscode", () => { const opens: Array<(doc: unknown) => void> = [] diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts b/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts similarity index 94% rename from packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts rename to packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts index 1610c5ce74..2c183ae259 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts +++ b/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from "vitest" import * as vscode from "vscode" -import type { KiloConnectionService } from "../../../cli-backend" -import { NextEditInlineCompletionProvider, type NextEditProviderDeps } from "../NextEditInlineCompletionProvider" -import type { NextEditSuggestionManager } from "../NextEditSuggestionManager" +import type { KiloConnectionService } from "../../src/services/cli-backend" +import { + NextEditInlineCompletionProvider, + type NextEditProviderDeps, +} from "../../src/services/autocomplete/next-edit/NextEditInlineCompletionProvider" +import type { NextEditSuggestionManager } from "../../src/services/autocomplete/next-edit/NextEditSuggestionManager" vi.mock("vscode", () => { class Position { diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/recentSnippetsAdapter.spec.ts b/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts similarity index 93% rename from packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/recentSnippetsAdapter.spec.ts rename to packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts index 828f5ac09d..e051323624 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/recentSnippetsAdapter.spec.ts +++ b/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts @@ -1,4 +1,4 @@ -import { toMercuryRecentSnippets } from "../recentSnippetsAdapter" +import { toMercuryRecentSnippets } from "../../src/services/autocomplete/next-edit/recentSnippetsAdapter" describe("toMercuryRecentSnippets", () => { it("returns an empty array when no snippets are supplied", () => { From e2dcf35e2f86098c1251d2350e4379da83788106 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 16:09:58 +0200 Subject: [PATCH 26/33] test(gateway): run edit suites in CI --- packages/kilo-gateway/package.json | 3 ++- turbo.json | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 99f3c2826d..6a17d9d1cb 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -28,7 +28,8 @@ ], "scripts": { "typecheck": "tsgo --noEmit", - "build": "tsc" + "build": "tsc", + "test:ci": "mkdir -p .artifacts/unit && bun test test --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "dependencies": { "@kilocode/plugin": "workspace:*", diff --git a/turbo.json b/turbo.json index 8c569b72fe..55a8e6ca22 100644 --- a/turbo.json +++ b/turbo.json @@ -24,6 +24,9 @@ "outputs": [".artifacts/unit/junit.xml"], "passThroughEnv": ["*"] }, + "@kilocode/kilo-gateway#test:ci": { + "outputs": [".artifacts/unit/junit.xml"] + }, "@kilocode/kilo-docs#build": { "dependsOn": ["^build"], "outputs": [".next/**"], From eccea2bc9ef0094050a7943c0cb69a50b8a2e42f Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 16:11:23 +0200 Subject: [PATCH 27/33] test(vscode): cover denied next edit snippets --- .../autocomplete/AutocompleteServiceManager.ts | 6 +++--- .../next-edit/recentSnippetsAdapter.ts | 7 +++++++ .../unit/next-edit-recent-snippets.test.ts | 18 +++++++++++++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 23e1dcf297..2e00bb4389 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -9,7 +9,7 @@ import { AutocompleteTelemetry } from "./classic-auto-complete/AutocompleteTelem import { NextEditInlineCompletionProvider } from "./next-edit/NextEditInlineCompletionProvider" import { disposeLog } from "./next-edit/log" import { NextEditSuggestionManager } from "./next-edit/NextEditSuggestionManager" -import { toMercuryRecentSnippets } from "./next-edit/recentSnippetsAdapter" +import { toAllowedMercuryRecentSnippets } from "./next-edit/recentSnippetsAdapter" import type { KiloConnectionService } from "../cli-backend" import { hasValidCredentials } from "./fim" import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models" @@ -120,8 +120,8 @@ export class AutocompleteServiceManager { // only content explicitly approved by the ignore controller. const raw = this.inlineCompletionProvider.recentlyVisitedRangesService.getSnippets() const ignore = this.ignoreControllerSync - const allowed = ignore ? raw.filter((s) => ignore.validateAccess(s.filepath)) : [] - return toMercuryRecentSnippets(allowed) + if (!ignore) return [] + return toAllowedMercuryRecentSnippets(raw, (path) => ignore.validateAccess(path)) }, onFatalError: (status) => this.handleFatalAutocompleteError(status), onSuggestion: (event) => { diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts index a4751b4f07..b7295ded35 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts @@ -26,6 +26,13 @@ export function toMercuryRecentSnippets( })) } +export function toAllowedMercuryRecentSnippets( + snippets: ReadonlyArray>, + allowed: (filepath: string) => boolean, +): MercuryRecentSnippet[] { + return toMercuryRecentSnippets(snippets.filter((snippet) => allowed(snippet.filepath))) +} + function trimToLines(content: string, maxLines: number): string { const lines = content.split("\n") if (lines.length <= maxLines) return content diff --git a/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts b/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts index e051323624..d5bfa69f27 100644 --- a/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts +++ b/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts @@ -1,4 +1,7 @@ -import { toMercuryRecentSnippets } from "../../src/services/autocomplete/next-edit/recentSnippetsAdapter" +import { + toAllowedMercuryRecentSnippets, + toMercuryRecentSnippets, +} from "../../src/services/autocomplete/next-edit/recentSnippetsAdapter" describe("toMercuryRecentSnippets", () => { it("returns an empty array when no snippets are supplied", () => { @@ -36,4 +39,17 @@ describe("toMercuryRecentSnippets", () => { const [out] = toMercuryRecentSnippets([{ filepath: "not a uri", content: "x" }]) expect(out.filepath).toBe("not a uri") }) + + it("excludes denied snippets before constructing next edit request context", () => { + const out = toAllowedMercuryRecentSnippets( + [ + { filepath: "secrets/.env", content: "TOKEN=do-not-send" }, + { filepath: "src/app.ts", content: "const safe = true" }, + ], + (path) => !path.endsWith(".env"), + ) + + expect(out).toEqual([{ filepath: "src/app.ts", content: "const safe = true" }]) + expect(JSON.stringify(out)).not.toContain("do-not-send") + }) }) From 70dc821219b2550976759e516cabb38380cf2b5d Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 16:13:46 +0200 Subject: [PATCH 28/33] test(vscode): isolate next edit vscode mocks --- packages/kilo-vscode/package.json | 2 +- .../edit-history-tracker.test.ts} | 0 .../inline-completion-provider.test.ts} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/kilo-vscode/tests/{unit/next-edit-history-tracker.test.ts => next-edit/edit-history-tracker.test.ts} (100%) rename packages/kilo-vscode/tests/{unit/next-edit-inline-completion-provider.test.ts => next-edit/inline-completion-provider.test.ts} (100%) diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 3627dd898a..0c033aedac 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1009,7 +1009,7 @@ "check-kilocode-change": "! grep -rIn 'kilocode_change' . ../kilo-ui/ --exclude='package.json' --exclude='*.md' --exclude-dir='node_modules' --exclude-dir='dist' | grep -v '`kilocode_change`'", "lint": "eslint src webview-ui", "test": "vscode-test", - "test:unit": "bun test tests/unit/", + "test:unit": "bun test tests/unit/ && bun test tests/next-edit/", "rebuild-sdk": "bun run --cwd ../sdk/js build", "storybook": "storybook dev -p 6007", "build-storybook": "storybook build -o storybook-static", diff --git a/packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts b/packages/kilo-vscode/tests/next-edit/edit-history-tracker.test.ts similarity index 100% rename from packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts rename to packages/kilo-vscode/tests/next-edit/edit-history-tracker.test.ts diff --git a/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts b/packages/kilo-vscode/tests/next-edit/inline-completion-provider.test.ts similarity index 100% rename from packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts rename to packages/kilo-vscode/tests/next-edit/inline-completion-provider.test.ts From 774b090905c614e595c20fef202b78eafe9f4f85 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 16:14:15 +0200 Subject: [PATCH 29/33] chore(gateway): ignore CI test artifacts --- packages/kilo-gateway/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/kilo-gateway/.gitignore diff --git a/packages/kilo-gateway/.gitignore b/packages/kilo-gateway/.gitignore new file mode 100644 index 0000000000..b6f2962c39 --- /dev/null +++ b/packages/kilo-gateway/.gitignore @@ -0,0 +1 @@ +.artifacts From b5aaa821e60687dd449c1dd2fd33d3e16a600bf2 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 16:24:44 +0200 Subject: [PATCH 30/33] test(vscode): share unit vscode mock for next edit --- packages/kilo-vscode/package.json | 2 +- .../kilo-vscode/tests/setup/vscode-mock.ts | 11 +++++ .../next-edit-history-tracker.test.ts} | 42 ++++++------------- ...t-edit-inline-completion-provider.test.ts} | 41 +++--------------- 4 files changed, 31 insertions(+), 65 deletions(-) rename packages/kilo-vscode/tests/{next-edit/edit-history-tracker.test.ts => unit/next-edit-history-tracker.test.ts} (72%) rename packages/kilo-vscode/tests/{next-edit/inline-completion-provider.test.ts => unit/next-edit-inline-completion-provider.test.ts} (83%) diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 0c033aedac..3627dd898a 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1009,7 +1009,7 @@ "check-kilocode-change": "! grep -rIn 'kilocode_change' . ../kilo-ui/ --exclude='package.json' --exclude='*.md' --exclude-dir='node_modules' --exclude-dir='dist' | grep -v '`kilocode_change`'", "lint": "eslint src webview-ui", "test": "vscode-test", - "test:unit": "bun test tests/unit/ && bun test tests/next-edit/", + "test:unit": "bun test tests/unit/", "rebuild-sdk": "bun run --cwd ../sdk/js build", "storybook": "storybook dev -p 6007", "build-storybook": "storybook build -o storybook-static", diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index b8f2110711..799f0bc4a2 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -46,6 +46,10 @@ const mockVscode = { version: "1.90.0", workspace: { workspaceFolders: [{ uri: { fsPath: "/repo" } }], + textDocuments: [] as Array, + onDidOpenTextDocument: () => ({ dispose: noop }), + onDidChangeTextDocument: () => ({ dispose: noop }), + onDidCloseTextDocument: () => ({ dispose: noop }), getConfiguration: () => ({ get: (_key: string, value?: T) => value, update: async () => {}, @@ -134,6 +138,13 @@ const mockVscode = { public end: { line: number; character: number }, ) {} }, + InlineCompletionItem: class { + constructor( + public insertText: string, + public range?: unknown, + public command?: unknown, + ) {} + }, Disposable: class { constructor(private callback: () => void = noop) {} dispose() { diff --git a/packages/kilo-vscode/tests/next-edit/edit-history-tracker.test.ts b/packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts similarity index 72% rename from packages/kilo-vscode/tests/next-edit/edit-history-tracker.test.ts rename to packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts index d674f047e4..23acde6d8b 100644 --- a/packages/kilo-vscode/tests/next-edit/edit-history-tracker.test.ts +++ b/packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts @@ -1,24 +1,7 @@ -import { describe, expect, it, vi } from "vitest" +import { afterEach, describe, expect, it } from "bun:test" import * as vscode from "vscode" import { EditHistoryTracker } from "../../src/services/autocomplete/next-edit/editHistoryTracker" -vi.mock("vscode", () => { - const opens: Array<(doc: unknown) => void> = [] - return { - workspace: { - textDocuments: [], - asRelativePath: (uri: { fsPath: string }) => uri.fsPath.replace("/workspace/", ""), - onDidOpenTextDocument: (cb: (doc: unknown) => void) => { - opens.push(cb) - return { dispose: vi.fn() } - }, - onDidChangeTextDocument: () => ({ dispose: vi.fn() }), - onDidCloseTextDocument: () => ({ dispose: vi.fn() }), - open: (doc: unknown) => opens.forEach((cb) => cb(doc)), - }, - } -}) - type Doc = vscode.TextDocument & { setText(text: string): void } function doc(path: string, initial: string): Doc { @@ -32,19 +15,23 @@ function doc(path: string, initial: string): Doc { } as unknown as Doc } +function docs(...items: Doc[]): void { + ;(vscode.workspace.textDocuments as unknown as Doc[]).splice(0, Infinity, ...items) +} + function settle(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)) } +afterEach(() => docs()) + describe("EditHistoryTracker", () => { it("retains chronological edits across files for Mercury context", async () => { - const tracker = new EditHistoryTracker({ isFileAllowed: async () => true }) const a = doc("/workspace/a.ts", "const a = 1\n") const b = doc("/workspace/b.ts", "const b = 1\n") - const open = (vscode.workspace as unknown as { open(doc: vscode.TextDocument): void }).open + docs(a, b) + const tracker = new EditHistoryTracker({ isFileAllowed: async () => true }) - open(a) - open(b) await settle() a.setText("const a = 2\n") await tracker.flush(a) @@ -62,11 +49,10 @@ describe("EditHistoryTracker", () => { }) it("does not retain edits when the access policy is missing at runtime", async () => { - const tracker = new EditHistoryTracker({} as { isFileAllowed: (path: string) => Promise }) const a = doc("/workspace/a.ts", "const a = 1\n") - const open = (vscode.workspace as unknown as { open(doc: vscode.TextDocument): void }).open + docs(a) + const tracker = new EditHistoryTracker({} as { isFileAllowed: (path: string) => Promise }) - open(a) await settle() a.setText("const a = 2\n") await tracker.flush(a) @@ -77,13 +63,11 @@ describe("EditHistoryTracker", () => { it("never returns edits from denied documents", async () => { const denied = new Set(["/workspace/.env"]) - const tracker = new EditHistoryTracker({ isFileAllowed: async (path) => !denied.has(path) }) const safe = doc("/workspace/app.ts", "const safe = 1\n") const secret = doc("/workspace/.env", "TOKEN=old\n") - const open = (vscode.workspace as unknown as { open(doc: vscode.TextDocument): void }).open + docs(safe, secret) + const tracker = new EditHistoryTracker({ isFileAllowed: async (path) => !denied.has(path) }) - open(safe) - open(secret) await settle() secret.setText("TOKEN=secret\n") await tracker.flush(secret) diff --git a/packages/kilo-vscode/tests/next-edit/inline-completion-provider.test.ts b/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts similarity index 83% rename from packages/kilo-vscode/tests/next-edit/inline-completion-provider.test.ts rename to packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts index 2c183ae259..f4d44fd5c9 100644 --- a/packages/kilo-vscode/tests/next-edit/inline-completion-provider.test.ts +++ b/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest" +import { describe, expect, it, mock } from "bun:test" import * as vscode from "vscode" import type { KiloConnectionService } from "../../src/services/cli-backend" import { @@ -7,35 +7,6 @@ import { } from "../../src/services/autocomplete/next-edit/NextEditInlineCompletionProvider" import type { NextEditSuggestionManager } from "../../src/services/autocomplete/next-edit/NextEditSuggestionManager" -vi.mock("vscode", () => { - class Position { - constructor( - public line: number, - public character: number, - ) {} - } - class Range { - constructor( - public start: Position, - public end: Position, - ) {} - } - return { - Position, - Range, - InlineCompletionItem: class {}, - workspace: { - textDocuments: [], - onDidOpenTextDocument: () => ({ dispose: vi.fn() }), - onDidChangeTextDocument: () => ({ dispose: vi.fn() }), - onDidCloseTextDocument: () => ({ dispose: vi.fn() }), - }, - window: { - createOutputChannel: () => ({ appendLine: vi.fn(), dispose: vi.fn() }), - }, - } -}) - type Subject = { toCompletionItems( document: vscode.TextDocument, @@ -64,7 +35,7 @@ function doc(text: string): vscode.TextDocument { describe("NextEditInlineCompletionProvider", () => { it("does not send a document when the access policy is missing at runtime", async () => { - const connection = { getClientAsync: vi.fn() } + const connection = { getClientAsync: mock() } const provider = new NextEditInlineCompletionProvider({ connectionService: connection, } as unknown as NextEditProviderDeps) @@ -82,7 +53,7 @@ describe("NextEditInlineCompletionProvider", () => { }) it("does not send a document when the access policy fails", async () => { - const connection = { getClientAsync: vi.fn() } + const connection = { getClientAsync: mock() } const provider = new NextEditInlineCompletionProvider({ connectionService: connection as unknown as KiloConnectionService, isFileAllowed: async () => Promise.reject(new Error("unavailable")), @@ -101,7 +72,7 @@ describe("NextEditInlineCompletionProvider", () => { }) it("stashes same-line rewrites before the cursor for decorated acceptance", () => { - const mgr = { clear: vi.fn(), setPending: vi.fn() } + const mgr = { clear: mock(), setPending: mock() } const provider = new NextEditInlineCompletionProvider({ connectionService: {} as KiloConnectionService, isFileAllowed: async () => true, @@ -129,7 +100,7 @@ describe("NextEditInlineCompletionProvider", () => { }) it("stashes complete-line deletion intent for acceptance", () => { - const mgr = { clear: vi.fn(), setPending: vi.fn() } + const mgr = { clear: mock(), setPending: mock() } const provider = new NextEditInlineCompletionProvider({ connectionService: {} as KiloConnectionService, isFileAllowed: async () => true, @@ -155,7 +126,7 @@ describe("NextEditInlineCompletionProvider", () => { }) it("does not classify a blank-line rewrite as deletion", () => { - const mgr = { clear: vi.fn(), setPending: vi.fn() } + const mgr = { clear: mock(), setPending: mock() } const provider = new NextEditInlineCompletionProvider({ connectionService: {} as KiloConnectionService, isFileAllowed: async () => true, From ecd6db5204e2f57991416387a0c378be94b07405 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 16:30:44 +0200 Subject: [PATCH 31/33] fix(cli): correct next edit API description --- .../src/kilocode/server/httpapi/groups/kilo-gateway.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts index 9b5db50d08..2f80923322 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts @@ -277,9 +277,8 @@ export const KiloGatewayApi = HttpApi.make("kilo") identifier: "kilo.edit", summary: "Next Edit completion", description: - "Proxy a Mercury-style Next Edit request. The user supplies the already-templated " + - "sentinel-tagged prompt in `content`; the gateway forwards to the upstream edit endpoint " + - "(currently Inception's /v1/edit/completions) and returns the unwrapped reply.", + "Proxy a Mercury-style Next Edit request. The client supplies structured editor " + + "context; the gateway assembles the sentinel-tagged prompt and forwards to the upstream edit endpoint.", }), ), HttpApiEndpoint.post("audioTranscriptions", KiloGatewayPaths.audioTranscriptions, { From 35d568dc1c60ad9b4445a0a148b30d0b2cd46413 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 27 May 2026 16:31:31 +0200 Subject: [PATCH 32/33] fix(vscode): namespace internal next edit command --- .../autocomplete/next-edit/NextEditInlineCompletionProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index 67a85f04dd..3a99f66b14 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -7,7 +7,7 @@ import { MercuryEditError, MercuryEditProvider } from "./MercuryEditProvider" import type { NextEditSuggestionManager } from "./NextEditSuggestionManager" import type { MercuryEditRequestContext, MercuryRecentSnippet } from "./types" -const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilo-code.autocomplete.next-edit.accepted" +const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilo-code.new.autocomplete.nextEdit.accepted" const DEFAULT_DEBOUNCE_MS = 250 export interface NextEditProviderDeps { From 1bd5e729ff83fae6293df9d45dc75ea5f9ed7c0b Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 14:34:42 +0000 Subject: [PATCH 33/33] docs: update mercury-next-edit changeset description Add contributor credit to the Mercury Next Edit changeset documentation. --- .changeset/mercury-next-edit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mercury-next-edit.md b/.changeset/mercury-next-edit.md index 8df4ee70bb..976b7990df 100644 --- a/.changeset/mercury-next-edit.md +++ b/.changeset/mercury-next-edit.md @@ -2,4 +2,4 @@ "kilo-code": minor --- -Add Mercury Next Edit as an opt-in autocomplete mode. Predicts multi-line edits beyond the cursor (including off-cursor and pure-insertion edits) and surfaces them with a Tab-to-jump / Tab-to-apply affordance. Select "Mercury Next Edit" under the autocomplete model setting to enable it (requires an Inception API key). +Add Mercury Next Edit as an opt-in autocomplete mode. Predicts multi-line edits beyond the cursor (including off-cursor and pure-insertion edits) and surfaces them with a Tab-to-jump / Tab-to-apply affordance. Select "Mercury Next Edit" under the autocomplete model setting to enable it (requires an Inception API key). Thanks [@tfiras](https://github.com/tfiras)!