From fe60a467d1e102b0580ca8d7515404b96683e4d7 Mon Sep 17 00:00:00 2001 From: Firas Trabelsi Date: Tue, 26 May 2026 09:17:53 -0700 Subject: [PATCH 1/3] 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 | 77 +- .../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, 3365 insertions(+), 111 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 8520685d839..2412a4f876f 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 561da4b09e3..451eb91dd99 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 00000000000..a244107f794 --- /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 00000000000..eaea04e012f --- /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 6b211545915..8e27aac0218 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 00000000000..ba66680952e --- /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 00000000000..939b1044889 --- /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 00000000000..87d3616b278 --- /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 00000000000..932190bfe8b --- /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 00000000000..7d7c9629b8a --- /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 00000000000..2613e953eb5 --- /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 00000000000..7846ab08f4b --- /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 00000000000..94d9332e062 --- /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 00000000000..5139ba755d3 --- /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 00000000000..f2c4845e20e --- /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 00000000000..8013be0a2a8 --- /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 00000000000..928cbb322bc --- /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 00000000000..81d91f5554a --- /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 00000000000..bc5f8822d0d --- /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 00000000000..96d50b1e931 --- /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 00000000000..dbfb598a331 --- /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 00000000000..4fe79b9fe87 --- /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 00000000000..30e4f0ba513 --- /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 00000000000..75e3f2bcc93 --- /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 00000000000..3751a203cf2 --- /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 00000000000..e666f246a03 --- /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 00000000000..2c51c0c6172 --- /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 00000000000..86868749707 --- /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 00000000000..b692494f243 --- /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 00000000000..ed2fd4a6a7f --- /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 00000000000..1cd15cd17b2 --- /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 00000000000..c114bc3f3cc --- /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 00000000000..8d8481f2e46 --- /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 bcb8a931e32..b9b03b843c4 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 d3d0d67c85b..30bcb3db1b3 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 - - // Already in the correct state — nothing to do - if (shouldBeRegistered === isRegistered) { - return + const info = getAutocompleteModel(this.settings?.provider, this.settings?.model) + const desiredKind: "classic" | "next-edit" = info.kind === "edit" ? "next-edit" : "classic" + + // 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 32ee332e675..33cf8f16ea5 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 9b8a22eba1f..f207191b52b 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 00000000000..bfc5ebf7335 --- /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 00000000000..3f00a3c3ed9 --- /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 00000000000..be00e64f476 --- /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 00000000000..5d340487e56 --- /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 00000000000..d11f3543c1a --- /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 00000000000..c01c4890faf --- /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 00000000000..828f5ac09db --- /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 00000000000..a2c3fa4ae43 --- /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 00000000000..99623afdf78 --- /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 00000000000..710da5132cd --- /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 00000000000..16be4a992b5 --- /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 00000000000..2aac37f5bb9 --- /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 00000000000..62034cbdb55 --- /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 00000000000..a4751b4f071 --- /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 00000000000..a88e346a360 --- /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 df8a5747013..df59e878470 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 27b99522c10..e38617ba564 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 950df5f0f4d..f86f833faf6 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 e49830dc36a..77bdc0bca9a 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 2/3] 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 451eb91dd99..356a19cc578 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 eaea04e012f..620f30510af 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 00000000000..5a707571bbb --- /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 b9b03b843c4..1758065c4f2 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 bfc5ebf7335..4ed46c0263b 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 3f00a3c3ed9..60718261637 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 be00e64f476..d654b7b619a 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 5d340487e56..00000000000 --- 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 a2c3fa4ae43..f82f08781e4 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 99623afdf78..00000000000 --- 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 710da5132cd..93465f6a0fb 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 e38617ba564..3aa008d5221 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 3/3] 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 2412a4f876f..99f3c2826d8 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 00000000000..60c3802d8e6 --- /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 620f30510af..c5686186949 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 8e27aac0218..cfc4818db11 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 00000000000..6aafce62a69 --- /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 30bcb3db1b3..7dda3020503 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 33cf8f16ea5..6e6eef24c77 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 4ed46c0263b..165af0d5bed 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 60718261637..e0eeb59ca37 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 c01c4890faf..00000000000 --- 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 f82f08781e4..cf7ec8d4e09 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 2aac37f5bb9..7e7c3239ba3 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 62034cbdb55..00000000000 --- 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 df59e878470..9b5db50d083 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 3aa008d5221..ab67f09713b 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 f86f833faf6..ce6ac869f9e 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 77bdc0bca9a..7e2bf68cadc 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?: {