diff --git a/.changeset/mercury-next-edit.md b/.changeset/mercury-next-edit.md new file mode 100644 index 0000000000..976b7990df --- /dev/null +++ b/.changeset/mercury-next-edit.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add Mercury Next Edit as an opt-in autocomplete mode. Predicts multi-line edits beyond the cursor (including off-cursor and pure-insertion edits) and surfaces them with a Tab-to-jump / Tab-to-apply affordance. Select "Mercury Next Edit" under the autocomplete model setting to enable it (requires an Inception API key). Thanks [@tfiras](https://github.com/tfiras)! diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 6da75f9785..e4b29c0538 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -47,6 +47,8 @@ - +- + - - diff --git a/packages/kilo-gateway/.gitignore b/packages/kilo-gateway/.gitignore new file mode 100644 index 0000000000..b6f2962c39 --- /dev/null +++ b/packages/kilo-gateway/.gitignore @@ -0,0 +1 @@ +.artifacts diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 09d6fd08e5..c50a79a6df 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -19,6 +19,8 @@ ".": "./src/index.ts", "./autocomplete": "./src/autocomplete.ts", "./fim": "./src/fim.ts", + "./edit": "./src/edit.ts", + "./edit-prompt": "./src/edit-prompt.ts", "./tui": "./src/tui.ts" }, "files": [ @@ -26,7 +28,8 @@ ], "scripts": { "typecheck": "tsgo --noEmit", - "build": "tsc" + "build": "tsc", + "test:ci": "mkdir -p .artifacts/unit && bun test test --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "dependencies": { "@kilocode/plugin": "workspace:*", diff --git a/packages/kilo-gateway/src/autocomplete.ts b/packages/kilo-gateway/src/autocomplete.ts index 561da4b09e..356a19cc57 100644 --- a/packages/kilo-gateway/src/autocomplete.ts +++ b/packages/kilo-gateway/src/autocomplete.ts @@ -4,7 +4,7 @@ export type DirectAutocompleteProviderID = Exclude= 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/edit.ts b/packages/kilo-gateway/src/edit.ts new file mode 100644 index 0000000000..70151b0132 --- /dev/null +++ b/packages/kilo-gateway/src/edit.ts @@ -0,0 +1,61 @@ +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 } + +/** Shape of the upstream (Mercury) chat/edit completion response we read from. */ +export interface EditUpstreamResponse { + choices?: Array<{ message?: { content?: string } }> + usage?: { prompt_tokens?: number; completion_tokens?: number } +} + +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.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 + // 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: "" } +} + +/** + * 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 replacement was truncated. Applying a + // partial editable region can delete valid trailing code, so suppress it. + const fenceClose = message.indexOf("```", afterFenceOpen + 1) + if (fenceClose === -1) return "" + let body = message.slice(afterFenceOpen + 1, fenceClose) + if (body.endsWith("\n")) body = body.slice(0, -1) + body = body.replace(/^<\|code_to_edit\|>\n?/, "") + body = body.replace(/\n?<\|\/code_to_edit\|>$/, "") + return body +} diff --git a/packages/kilo-gateway/src/server/edit.ts b/packages/kilo-gateway/src/server/edit.ts new file mode 100644 index 0000000000..5256881b23 --- /dev/null +++ b/packages/kilo-gateway/src/server/edit.ts @@ -0,0 +1,90 @@ +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" + +type Auth = Pick + +const EDIT_TIMEOUT_MS = 30_000 +const MAX_TOKENS_DEFAULT = 512 + +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) +} + +export function createEditHandler(Auth: Auth) { + return async (c: any) => { + const { provider, model, maxTokens, ...context } = 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) + } + + // 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)]) + + 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 EditUpstreamResponse + 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 fba7f6f6fa..c90c4f1253 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,44 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { ), createFimHandler(Auth), ) + .post( + "/edit", + describeRoute({ + summary: "Next Edit completion", + description: + "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: { + description: "Next Edit completion", + content: { + "application/json": { + schema: resolver(EditCompletionResponse), + }, + }, + }, + ...errors(400, 401), + }, + }), + validator( + "json", + z.object({ + 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), + ) .post( "/audio/transcriptions", describeRoute({ diff --git a/packages/kilo-gateway/test/edit-prompt.test.ts b/packages/kilo-gateway/test/edit-prompt.test.ts new file mode 100644 index 0000000000..6aafce62a6 --- /dev/null +++ b/packages/kilo-gateway/test/edit-prompt.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { + buildMercuryEditPrompt, + currentFileContentBlock, + editDiffHistoryBlock, + recentlyViewedSnippetsBlock, +} from "../src/edit-prompt" + +describe("recentlyViewedSnippetsBlock", () => { + test("wraps in open/close sentinels even when empty", () => { + const out = recentlyViewedSnippetsBlock([]) + expect(out.startsWith("<|recently_viewed_code_snippets|>")).toBe(true) + expect(out.endsWith("<|/recently_viewed_code_snippets|>")).toBe(true) + }) + + test("emits one inner block per snippet with the file-path header", () => { + const out = recentlyViewedSnippetsBlock([ + { filepath: "src/a.ts", content: "const a = 1" }, + { filepath: "src/b.ts", content: "const b = 2" }, + ]) + expect(out).toContain("code_snippet_file_path: src/a.ts") + expect(out).toContain("code_snippet_file_path: src/b.ts") + expect(out).toContain("const a = 1") + expect(out).toContain("const b = 2") + }) +}) + +describe("currentFileContentBlock", () => { + test("inserts <|cursor|> at the right character and wraps the editable region", () => { + const file = ["function foo() {", " return 1", "}"].join("\n") + const out = currentFileContentBlock("src/foo.ts", file, 1, 1, 1, 2) + expect(out).toContain("<|current_file_content|>") + expect(out).toContain("<|/current_file_content|>") + expect(out).toContain("current_file_path: src/foo.ts") + expect(out).toContain(" <|cursor|>return 1") + const openIdx = out.indexOf("<|code_to_edit|>") + const lineIdx = out.indexOf("return 1") + const closeIdx = out.indexOf("<|/code_to_edit|>") + expect(openIdx).toBeGreaterThan(-1) + expect(closeIdx).toBeGreaterThan(openIdx) + expect(lineIdx).toBeGreaterThan(openIdx) + expect(lineIdx).toBeLessThan(closeIdx) + }) + + test("clamps an out-of-range cursor instead of throwing", () => { + const out = currentFileContentBlock("p.ts", "only-line", 0, 0, 0, 9999) + expect(out).toContain("only-line<|cursor|>") + }) +}) + +describe("editDiffHistoryBlock", () => { + test("strips the createPatch index+separator lines from each diff", () => { + const fakeDiff = ["Index: foo.ts", "===", "@@ -1,1 +1,1 @@", "-old", "+new"].join("\n") + const out = editDiffHistoryBlock([fakeDiff]) + expect(out).toContain("@@ -1,1 +1,1 @@") + expect(out).not.toContain("Index: foo.ts") + expect(out.startsWith("<|edit_diff_history|>")).toBe(true) + expect(out.endsWith("<|/edit_diff_history|>")).toBe(true) + }) + + test("separates multiple diffs with a blank line", () => { + const diff1 = ["Index: a.ts", "===", "@@ -1,1 +1,1 @@", "-a", "+aa"].join("\n") + const diff2 = ["Index: b.ts", "===", "@@ -2,1 +2,1 @@", "-b", "+bb"].join("\n") + const out = editDiffHistoryBlock([diff1, diff2]) + const idx1 = out.indexOf("@@ -1,1 +1,1 @@") + const idx2 = out.indexOf("@@ -2,1 +2,1 @@") + expect(idx2).toBeGreaterThan(idx1) + expect(out.slice(idx1, idx2)).toContain("\n\n") + }) +}) + +describe("buildMercuryEditPrompt", () => { + test("assembles the three blocks in order and ends with the NES token", () => { + const out = buildMercuryEditPrompt({ + currentFilePath: "p.ts", + currentFileContent: "a\nb\nc", + cursorLine: 1, + cursorCharacter: 0, + editableRegionStartLine: 1, + editableRegionEndLine: 1, + recentlyViewedSnippets: [], + editDiffHistory: [], + }) + const snippetsIdx = out.indexOf("<|recently_viewed_code_snippets|>") + const fileIdx = out.indexOf("<|current_file_content|>") + const diffIdx = out.indexOf("<|edit_diff_history|>") + expect(snippetsIdx).toBeGreaterThan(-1) + expect(fileIdx).toBeGreaterThan(snippetsIdx) + expect(diffIdx).toBeGreaterThan(fileIdx) + expect(out.endsWith("<|!@#IS_NEXT_EDIT!@#|>")).toBe(true) + }) +}) diff --git a/packages/kilo-gateway/test/edit.test.ts b/packages/kilo-gateway/test/edit.test.ts new file mode 100644 index 0000000000..ba01ea163e --- /dev/null +++ b/packages/kilo-gateway/test/edit.test.ts @@ -0,0 +1,58 @@ +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("suppresses a replacement when the closing fence is missing", () => { + expect(extractFencedBody("```\nconst x = 1\nconst y = ")).toBe("") + }) + + 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/docs/mercury-next-edit-testing.html b/packages/kilo-vscode/docs/mercury-next-edit-testing.html new file mode 100644 index 0000000000..5990db29f7 --- /dev/null +++ b/packages/kilo-vscode/docs/mercury-next-edit-testing.html @@ -0,0 +1,1107 @@ + + + + + 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
model + Mercury Next Edit (Inception)not "Mercury Edit 2", which is the original + FIM-via-gateway option +
nextEdit.apiKeyyour Inception API key (sk_...); also accepts INCEPTION_API_KEY env var
enableAutoTrigger✓ (default)
nextEdit.baseUrl(optional) override the API base, defaults to https://api.inceptionlabs.ai/v1
nextEdit.debug(optional) mirror diagnostic logs to DevTools console
+

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

+ +

How the integration works

+

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

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

The NES provider, per keystroke:

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

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

Feedback we'd love

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

Count: {count}

+ +
+ ) +} + +export default Counter diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index c7469182c2..3627dd898a 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.new.autocomplete.nextEdit.acceptOrJump", + "title": "Next Edit: Accept or Jump to Suggested Edit", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.autocomplete.nextEdit.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.new.autocomplete.nextEdit.acceptOrJump", + "key": "tab", + "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && !suggestWidgetVisible && kilo-code.nextEdit.hasPendingSuggestion" + }, + { + "command": "kilo-code.new.autocomplete.nextEdit.dismiss", + "key": "escape", + "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && kilo-code.nextEdit.hasPendingSuggestion" } ], "configuration": { @@ -826,13 +846,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/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index d3d0d67c85..2e00bb4389 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -6,6 +6,10 @@ 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 { disposeLog } from "./next-edit/log" +import { NextEditSuggestionManager } from "./next-edit/NextEditSuggestionManager" +import { toAllowedMercuryRecentSnippets } from "./next-edit/recentSnippetsAdapter" import type { KiloConnectionService } from "../cli-backend" import { hasValidCredentials } from "./fim" import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models" @@ -61,9 +65,15 @@ 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 + // 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) { @@ -90,6 +100,48 @@ 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. + // Suppress snippets until access checks are available, then include + // only content explicitly approved by the ignore controller. + const raw = this.inlineCompletionProvider.recentlyVisitedRangesService.getSnippets() + const ignore = this.ignoreControllerSync + if (!ignore) return [] + return toAllowedMercuryRecentSnippets(raw, (path) => ignore.validateAccess(path)) + }, + 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: getAutocompleteModel(this.settings?.provider, this.settings?.model).id, + 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. @@ -136,25 +188,47 @@ export class AutocompleteServiceManager { */ private async ensureInlineCompletionProviderRegistration() { const shouldBeRegistered = (this.settings?.enableAutoTrigger ?? false) && !this.isSnoozed() - const isRegistered = this.inlineCompletionProviderDisposable !== null + const info = getAutocompleteModel(this.settings?.provider, this.settings?.model) + const desiredKind: "classic" | "next-edit" = info.kind === "edit" ? "next-edit" : "classic" - // Already in the correct state — nothing to do - if (shouldBeRegistered === isRegistered) { - return + // Mode change while still enabled requires a swap: tear down the old + // registration so the new provider takes over. + if ( + shouldBeRegistered && + this.inlineCompletionProviderKind !== null && + this.inlineCompletionProviderKind !== desiredKind + ) { + if (this.inlineCompletionProviderKind === "next-edit") this.nextEditSuggestionManager.clear() + this.inlineCompletionProviderDisposable?.dispose() + this.inlineCompletionProviderDisposable = null + this.inlineCompletionProviderKind = null } + if (!shouldBeRegistered && this.inlineCompletionProviderKind === "next-edit") { + this.nextEditSuggestionManager.clear() + } + const isRegistered = this.inlineCompletionProviderDisposable !== null + if (shouldBeRegistered === isRegistered) return + 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 +484,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. + disposeLog() // Clear singleton instance AutocompleteServiceManager._instance = null diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index 32ee332e67..6e6eef24c7 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -111,13 +111,13 @@ 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) */ 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/index.ts b/packages/kilo-vscode/src/services/autocomplete/index.ts index 9b8a22eba1..a27f838616 100644 --- a/packages/kilo-vscode/src/services/autocomplete/index.ts +++ b/packages/kilo-vscode/src/services/autocomplete/index.ts @@ -1,6 +1,9 @@ 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 { chainNextPrediction } from "./next-edit/NextEditSuggestionManager" import type { KiloConnectionService } from "../cli-backend" export const registerAutocompleteProvider = ( @@ -42,6 +45,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("kilo-code.new.autocomplete.nextEdit.acceptOrJump", async () => { + await autocompleteManager.nextEditSuggestionManager.acceptOrJump() + }), + ) + // Esc handler: dismiss the pending suggestion without applying. + context.subscriptions.push( + vscode.commands.registerCommand("kilo-code.new.autocomplete.nextEdit.dismiss", () => { + autocompleteManager.nextEditSuggestionManager.clear() + }), + ) // Register AutocompleteServiceManager Code Actions context.subscriptions.push( diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts new file mode 100644 index 0000000000..4079a35a40 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts @@ -0,0 +1,105 @@ +import type { KiloConnectionService } from "../../cli-backend" +import { nesLog, nesWarn } from "./log" +import type { MercuryEditRequestContext, MercuryEditSuggestion } from "./types" + +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.). */ + signal?: AbortSignal +} + +/** + * 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) {} + + async suggest(ctx: MercuryEditRequestContext): Promise { + const start = Date.now() + nesLog( + `-> /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( + { + 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 }, + ) + const latencyMs = Date.now() - start + if (error) { + // 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: ${status ?? "?"} ${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: EditResponseData | undefined, + latencyMs: number, + ): MercuryEditSuggestion | null { + const replacement = data?.content ?? null + const usage = data?.usage + nesLog(`<- ok (${latencyMs}ms) tokens=${usage?.completion_tokens ?? "?"} parsedChars=${replacement?.length ?? 0}`) + if (replacement === null || replacement.length === 0) return null + return { + replacement, + editableRegionStartLine: ctx.editableRegionStartLine, + editableRegionEndLine: ctx.editableRegionEndLine, + latencyMs, + inputTokens: usage?.prompt_tokens, + outputTokens: usage?.completion_tokens, + } + } +} + +export class MercuryEditError extends Error { + constructor( + message: string, + public readonly status: number | null, + ) { + super(message) + this.name = "MercuryEditError" + } +} + +function safeStringify(value: unknown): string { + try { + if (typeof value === "string") return value + return JSON.stringify(value) + } catch { + return String(value) + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts new file mode 100644 index 0000000000..3a99f66b14 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -0,0 +1,392 @@ +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.new.autocomplete.nextEdit.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[] + /** 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 + /** 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 +} + +/** 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 + private currentAbort: AbortController | null = null + + constructor(private readonly deps: NextEditProviderDeps) { + this.editHistoryTracker = new EditHistoryTracker({ isFileAllowed: deps.isFileAllowed }) + } + + 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 + + // Never send a file unless the access policy explicitly approves it. + if (!(await this.allowed(document.uri.fsPath))) return undefined + + const isExplicit = context.triggerKind === vscode.InlineCompletionTriggerKind.Invoke + if (!isExplicit) { + await this.debounce(DEFAULT_DEBOUNCE_MS, token) + if (token.isCancellationRequested) return undefined + } + + const abort = this.swapAbortController(token) + const ctx = await 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 async allowed(path: string): Promise { + const allow = this.deps.isFileAllowed + if (!allow) return false + return allow(path).catch(() => false) + } + + private swapAbortController(token: vscode.CancellationToken): AbortController { + this.currentAbort?.abort() + const abort = new AbortController() + this.currentAbort = abort + token.onCancellationRequested(() => abort.abort()) + return abort + } + + private async buildRequestContext( + document: vscode.TextDocument, + position: vscode.Position, + ): Promise { + const { startLine, endLine } = computeEditableRegion({ + cursorLine: position.line, + totalLines: document.lineCount, + }) + await this.editHistoryTracker.flush(document) + return { + // Mirror classic autocomplete's policy: never send an absolute fsPath upstream. + // Mercury only needs the path for language/context hints, and the workspace-relative + // form is what `recentlyViewedSnippets` already uses (see recentSnippetsAdapter.ts). + currentFilePath: vscode.workspace.asRelativePath(document.uri, false), + currentFileContent: document.getText(), + cursorLine: position.line, + cursorCharacter: position.character, + editableRegionStartLine: startLine, + editableRegionEndLine: endLine, + recentlyViewedSnippets: this.deps.getRecentlyViewedSnippets?.(document) ?? [], + editDiffHistory: await this.editHistoryTracker.getRecentDiffs(), + } + } + + private toCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + suggestion: SuggestionResult, + ): 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.emitNotShown(suggestion) + 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 trimmedLines = proposedLines.slice(prefixLines, proposedLines.length - suffixLines) + const trimmedReplacement = trimmedLines.join("\n") + + nesLog( + `diff at lines [${diffStartLineInFile}..${diffEndLineInFile}], cursor at line ${position.line}, ${trimmedReplacement.length} chars`, + ) + + // 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 + const removesLines = trimmedLines.length === 0 + if (isPureInsertion || removesLines || diffStartLineInFile !== position.line) { + this.stashOffCursorSuggestion( + document, + diffStartLineInFile, + diffEndLineInFile, + trimmedReplacement, + isPureInsertion, + removesLines, + suggestion, + ) + return undefined + } + // Same-line diff: clear any prior off-cursor pending state so we don't render + // two competing affordances. + this.deps.suggestionManager?.clear() + return this.renderSameLineItem( + document, + position, + proposedLines, + prefixLines, + suffixLines, + diffStartLineInFile, + diffEndLineInFile, + trimmedReplacement, + suggestion, + ) + } + + /** 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] + // A pure deletion at the trim seam has no cursor-line replacement to render. + if (cursorLineProposed === undefined) { + this.emitNotShown(suggestion) + return undefined + } + // Native ghost text cannot alter text before the cursor; present that edit + // through the decoration/apply flow rather than silently discarding it. + if (!cursorLineProposed.startsWith(cursorLineText.slice(0, position.character))) { + this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, false, suggestion) + return undefined + } + const insertText = [ + cursorLineProposed.slice(position.character), + ...proposedLines.slice(prefixLines + 1, proposedLines.length - suffixLines), + ].join("\n") + const renderEndLine = pickRenderEndLine(document, position.line, diffEndLine, insertText) + // A single-line insert spanning non-blank lines below the cursor can't be + // 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, 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, + 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 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, + removesLines: boolean, + 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.emitNotShown(suggestion) + 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, + removesLines, + originalText: document.getText(originalRange), + }) + nesLog(`replace suggestion stashed at lines [${diffStartLine}..${diffEndLine}]`) + } + this.deps.onSuggestion?.({ + shown: true, + latencyMs: suggestion.latencyMs, + status: "ok", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + } + + private handleError(err: unknown): undefined { + if ((err as Error)?.name === "AbortError") return undefined + const status = err instanceof MercuryEditError ? err.status : null + this.deps.onSuggestion?.({ + shown: false, + latencyMs: 0, + status: "error", + errorStatus: status ?? undefined, + }) + if (status === 401 || status === 402) this.deps.onFatalError?.(status) + return undefined + } + + private debounce(ms: number, token: vscode.CancellationToken): Promise { + if (this.debounceTimer) clearTimeout(this.debounceTimer) + return new Promise((resolve) => { + this.debounceTimer = setTimeout(resolve, ms) + token.onCancellationRequested(() => { + if (this.debounceTimer) clearTimeout(this.debounceTimer) + resolve() + }) + }) + } +} + +export { INLINE_COMPLETION_ACCEPTED_COMMAND } + +/** + * VSCode's inline ghost text silently fails to render when the completion's + * range crosses a line boundary but the insert text has no newline (typical + * when Mercury implicitly drops a trailing blank line as file-end + * normalization). When that happens — and the lines past the cursor are + * blank — cap the range at the cursor's line so the ghost renders cleanly. + */ +function pickRenderEndLine( + document: vscode.TextDocument, + cursorLine: number, + diffEndLine: number, + insertText: string, +): number { + if (diffEndLine <= cursorLine) return diffEndLine + if (insertText.includes("\n")) return diffEndLine + for (let l = cursorLine + 1; l <= diffEndLine; l++) { + if (document.lineAt(l).text.trim() !== "") return diffEndLine + } + return cursorLine +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts new file mode 100644 index 0000000000..a8ba61f16b --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -0,0 +1,352 @@ +import * as vscode from "vscode" +import { nesLog } from "./log" +import { planInsertion, planReplacement } from "./pendingEdit" + +const PENDING_CONTEXT_KEY = "kilo-code.nextEdit.hasPendingSuggestion" +const CHAIN_DELAY_MS = 60 + +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 + /** Whether the suggestion omits complete lines rather than rewriting one as blank. */ + removesLines: boolean + /** Snapshot of the original text — used to detect drift. */ + originalText: string + } + | { + kind: "insert" + document: vscode.TextDocument + /** Existing line before insertion, or `lineCount` when appending at EOF. */ + 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") { + // 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 edit = planInsertion(p, { + lineCount: editor.document.lineCount, + end: (line) => editor.document.lineAt(line).range.end.character, + }) + const pos = new vscode.Position(edit.line, edit.character) + ok = await editor.edit((b) => b.insert(pos, edit.text)) + nesLog(`applied insert at line ${pos.line} (${edit.text.length} chars, ok=${ok})`) + } else { + const range = new vscode.Range( + new vscode.Position(p.diffStartLine, 0), + 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 + } + const edit = planReplacement(p, { + lineCount: editor.document.lineCount, + end: (line) => editor.document.lineAt(line).range.end.character, + }) + const target = new vscode.Range( + new vscode.Position(edit.start.line, edit.start.character), + new vscode.Position(edit.end.line, edit.end.character), + ) + ok = await editor.edit((b) => b.replace(target, edit.text)) + nesLog(`applied replace at lines [${p.diffStartLine}..${p.diffEndLine}] (ok=${ok})`) + } + if (ok) chainNextPrediction() + } + + 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 = CHAIN_DELAY_MS): 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 +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts new file mode 100644 index 0000000000..cf7ec8d4e0 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts @@ -0,0 +1,9 @@ +/** + * 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 +export const MAX_EDITABLE_REGION_LINES = 25 diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts new file mode 100644 index 0000000000..9edebc298f --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts @@ -0,0 +1,168 @@ +import { createPatch } from "diff" +import * as vscode from "vscode" + +const DEFAULT_DEBOUNCE_MS = 1500 +const DEFAULT_MAX_DIFFS = 5 + +type Options = { + debounceMs?: number + maxDiffs?: number + isFileAllowed: (fsPath: string) => Promise +} + +type Diff = { + key: string + patch: string +} + +/** + * Tracks per-file snapshots and emits a workspace-wide chronological stream + * of range-based unidiffs after a short idle window. Cross-file history is + * intentional: Mercury uses recent edits from any file to infer user intent. + * + * Diffs are produced lazily; the tracker holds the previously-emitted state + * per file and computes the diff against the current document content when + * the debounce fires. + */ +export class EditHistoryTracker implements vscode.Disposable { + private readonly snapshots = new Map() + private readonly pendingTimers = new Map() + private readonly diffs: Diff[] = [] + private readonly subscriptions: vscode.Disposable[] = [] + + constructor(private readonly options: Options) { + const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS + + // Seed snapshots on open so the FIRST edit in a freshly-opened file is + // captured in the diff history (otherwise the common "open, type, trigger" + // flow ships an empty edit-history block). Access checks happen before + // reading text so ignored documents are never retained as edit context. + this.subscriptions.push( + vscode.workspace.onDidOpenTextDocument((doc) => { + if (doc.uri.scheme !== "file") return + void this.seed(doc) + }), + ) + for (const doc of vscode.workspace.textDocuments) { + if (doc.uri.scheme === "file") void this.seed(doc) + } + this.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((event) => { + if (event.document.uri.scheme !== "file") return + if (event.contentChanges.length === 0) return + void 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 async flush(document: vscode.TextDocument): Promise { + const key = document.uri.fsPath + if (!(await this.allowed(key))) { + this.reject(key) + return + } + const t = this.pendingTimers.get(key) + if (t) clearTimeout(t) + this.pendingTimers.delete(key) + await this.emitDiffNow(document) + } + + /** Workspace-wide oldest to newest, matching the Mercury prompt-history convention. */ + public async getRecentDiffs(): Promise { + const kept = ( + await Promise.all(this.diffs.map(async (diff) => ((await this.allowed(diff.key)) ? diff : undefined))) + ).filter((diff): diff is Diff => diff !== undefined) + this.diffs.splice(0, this.diffs.length, ...kept) + return kept.map((diff) => diff.patch) + } + + public dispose(): void { + 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 async seed(document: vscode.TextDocument): Promise { + const key = document.uri.fsPath + if (this.snapshots.has(key)) return + if (!(await this.allowed(key))) { + this.reject(key) + return + } + if (!this.snapshots.has(key)) this.snapshots.set(key, document.getText()) + } + + private async scheduleSnapshotDiff(document: vscode.TextDocument, debounceMs: number): Promise { + const key = document.uri.fsPath + if (!(await this.allowed(key))) { + this.reject(key) + return + } + if (!this.snapshots.has(key)) { + // Fallback seed for documents we never saw open (e.g. opened before the + // tracker existed). The triggering change is lost, but subsequent edits + // produce useful diffs. + this.snapshots.set(key, document.getText()) + return + } + const existing = this.pendingTimers.get(key) + if (existing) clearTimeout(existing) + const timer = setTimeout(() => { + this.pendingTimers.delete(key) + void this.emitDiffNow(document) + }, debounceMs) + this.pendingTimers.set(key, timer) + } + + private async emitDiffNow(document: vscode.TextDocument): Promise { + const key = document.uri.fsPath + if (!(await this.allowed(key))) { + this.reject(key) + return + } + const previous = this.snapshots.get(key) + if (previous === undefined) return + const current = document.getText() + 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({ key, patch }) + const maxDiffs = this.options.maxDiffs ?? DEFAULT_MAX_DIFFS + if (this.diffs.length > maxDiffs) this.diffs.shift() + } + this.snapshots.set(key, current) + } + + private async allowed(key: string): Promise { + const allow = this.options.isFileAllowed + if (!allow) return false + return allow(key).catch(() => false) + } + + private reject(key: string): void { + const timer = this.pendingTimers.get(key) + if (timer) clearTimeout(timer) + this.pendingTimers.delete(key) + this.snapshots.delete(key) + const kept = this.diffs.filter((diff) => diff.key !== key) + this.diffs.splice(0, this.diffs.length, ...kept) + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editableRegion.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editableRegion.ts new file mode 100644 index 0000000000..16be4a992b --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editableRegion.ts @@ -0,0 +1,43 @@ +import { + DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN, + DEFAULT_EDITABLE_REGION_TOP_MARGIN, + MAX_EDITABLE_REGION_LINES, +} from "./constants" + +export interface EditableRegionInputs { + cursorLine: number + totalLines: number + topMargin?: number + bottomMargin?: number +} + +export interface EditableRegion { + startLine: number + endLine: number +} + +/** + * Editable region selection per the Mercury docs: center [-top, +bottom] around + * the cursor, clipped to file bounds. Capped to MAX_EDITABLE_REGION_LINES (~25) + * because output tokens dominate latency. + */ +export function computeEditableRegion({ + cursorLine, + totalLines, + topMargin = DEFAULT_EDITABLE_REGION_TOP_MARGIN, + bottomMargin = DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN, +}: EditableRegionInputs): EditableRegion { + if (totalLines <= 0) return { startLine: 0, endLine: 0 } + + const lastLine = totalLines - 1 + let start = Math.max(0, cursorLine - topMargin) + let end = Math.min(lastLine, cursorLine + bottomMargin) + + const span = end - start + 1 + if (span > MAX_EDITABLE_REGION_LINES) { + const overflow = span - MAX_EDITABLE_REGION_LINES + // Prefer trimming below the cursor, where we have less semantic context. + end = Math.max(start, end - overflow) + } + return { startLine: start, endLine: end } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts new file mode 100644 index 0000000000..7e7c3239ba --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts @@ -0,0 +1,37 @@ +import * as vscode from "vscode" + +const CHANNEL_NAME = "Kilo Code · Next Edit" + +let channel: vscode.OutputChannel | null = null + +function getChannel(): vscode.OutputChannel { + if (!channel) channel = vscode.window.createOutputChannel(CHANNEL_NAME) + return channel +} + +function debugEnabled(): boolean { + // 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" +} + +/** + * 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/pendingEdit.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts new file mode 100644 index 0000000000..bea2c9c1d8 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts @@ -0,0 +1,43 @@ +type Document = { + lineCount: number + end(line: number): number +} + +type Insertion = { + diffStartLine: number + replacement: string +} + +type Replacement = Insertion & { + diffEndLine: number + removesLines: boolean +} + +export function planInsertion(input: Insertion, document: Document) { + if (input.diffStartLine < document.lineCount) { + return { line: input.diffStartLine, character: 0, text: input.replacement } + } + const line = Math.max(0, document.lineCount - 1) + const text = input.replacement.endsWith("\n") ? input.replacement.slice(0, -1) : input.replacement + return { line, character: document.end(line), text: `\n${text}` } +} + +export function planReplacement(input: Replacement, document: Document) { + const end = { line: input.diffEndLine, character: document.end(input.diffEndLine) } + if (!input.removesLines) { + return { start: { line: input.diffStartLine, character: 0 }, end, text: input.replacement } + } + if (input.diffEndLine < document.lineCount - 1) { + return { + start: { line: input.diffStartLine, character: 0 }, + end: { line: input.diffEndLine + 1, character: 0 }, + text: input.replacement, + } + } + if (input.diffStartLine === 0) return { start: { line: 0, character: 0 }, end, text: input.replacement } + return { + start: { line: input.diffStartLine - 1, character: document.end(input.diffStartLine - 1) }, + end, + text: input.replacement, + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts new file mode 100644 index 0000000000..b7295ded35 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts @@ -0,0 +1,52 @@ +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), + })) +} + +export function toAllowedMercuryRecentSnippets( + snippets: ReadonlyArray>, + allowed: (filepath: string) => boolean, +): MercuryRecentSnippet[] { + return toMercuryRecentSnippets(snippets.filter((snippet) => allowed(snippet.filepath))) +} + +function trimToLines(content: string, maxLines: number): string { + const lines = content.split("\n") + if (lines.length <= maxLines) return content + // Center the trim window — keep the most semantically meaningful core. + const start = Math.floor((lines.length - maxLines) / 2) + return lines.slice(start, start + maxLines).join("\n") +} + +function shortenPath(uri: string): string { + // Convert file:// URI strings to workspace-relative paths so the prompt is compact. + try { + const parsed = vscode.Uri.parse(uri) + return vscode.workspace.asRelativePath(parsed, false) + } catch { + return uri + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/types.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/types.ts new file mode 100644 index 0000000000..a88e346a36 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/types.ts @@ -0,0 +1,26 @@ +export interface MercuryRecentSnippet { + filepath: string + content: string +} + +export interface MercuryEditRequestContext { + currentFilePath: string + currentFileContent: string + cursorLine: number + cursorCharacter: number + editableRegionStartLine: number + editableRegionEndLine: number + recentlyViewedSnippets: MercuryRecentSnippet[] + editDiffHistory: string[] +} + +export interface MercuryEditSuggestion { + /** The replacement text for lines [editableRegionStartLine, editableRegionEndLine]. */ + replacement: string + editableRegionStartLine: number + editableRegionEndLine: number + /** Latency in milliseconds from request send to response parse. */ + latencyMs: number + inputTokens?: number + outputTokens?: number +} diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index b8f2110711..799f0bc4a2 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -46,6 +46,10 @@ const mockVscode = { version: "1.90.0", workspace: { workspaceFolders: [{ uri: { fsPath: "/repo" } }], + textDocuments: [] as Array, + onDidOpenTextDocument: () => ({ dispose: noop }), + onDidChangeTextDocument: () => ({ dispose: noop }), + onDidCloseTextDocument: () => ({ dispose: noop }), getConfiguration: () => ({ get: (_key: string, value?: T) => value, update: async () => {}, @@ -134,6 +138,13 @@ const mockVscode = { public end: { line: number; character: number }, ) {} }, + InlineCompletionItem: class { + constructor( + public insertText: string, + public range?: unknown, + public command?: unknown, + ) {} + }, Disposable: class { constructor(private callback: () => void = noop) {} dispose() { diff --git a/packages/kilo-vscode/tests/unit/next-edit-editable-region.test.ts b/packages/kilo-vscode/tests/unit/next-edit-editable-region.test.ts new file mode 100644 index 0000000000..d80a6a7a06 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-editable-region.test.ts @@ -0,0 +1,37 @@ +import { MAX_EDITABLE_REGION_LINES } from "../../src/services/autocomplete/next-edit/constants" +import { computeEditableRegion } from "../../src/services/autocomplete/next-edit/editableRegion" + +describe("computeEditableRegion", () => { + it("returns the default [-5, +10] window around the cursor", () => { + 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/tests/unit/next-edit-history-tracker.test.ts b/packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts new file mode 100644 index 0000000000..23acde6d8b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { EditHistoryTracker } from "../../src/services/autocomplete/next-edit/editHistoryTracker" + +type Doc = vscode.TextDocument & { setText(text: string): void } + +function doc(path: string, initial: string): Doc { + const state = { text: initial } + return { + uri: { fsPath: path, scheme: "file" }, + getText: () => state.text, + setText: (text: string) => { + state.text = text + }, + } as unknown as Doc +} + +function docs(...items: Doc[]): void { + ;(vscode.workspace.textDocuments as unknown as Doc[]).splice(0, Infinity, ...items) +} + +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +afterEach(() => docs()) + +describe("EditHistoryTracker", () => { + it("retains chronological edits across files for Mercury context", async () => { + const a = doc("/workspace/a.ts", "const a = 1\n") + const b = doc("/workspace/b.ts", "const b = 1\n") + docs(a, b) + const tracker = new EditHistoryTracker({ isFileAllowed: async () => true }) + + await settle() + a.setText("const a = 2\n") + await tracker.flush(a) + b.setText("const b = 2\n") + await tracker.flush(b) + + const diffs = await tracker.getRecentDiffs() + expect(diffs).toHaveLength(2) + expect(diffs[0]).toContain("a.ts") + expect(diffs[0]).toContain("+const a = 2") + expect(diffs[1]).toContain("b.ts") + expect(diffs[1]).toContain("+const b = 2") + + tracker.dispose() + }) + + it("does not retain edits when the access policy is missing at runtime", async () => { + const a = doc("/workspace/a.ts", "const a = 1\n") + docs(a) + const tracker = new EditHistoryTracker({} as { isFileAllowed: (path: string) => Promise }) + + await settle() + a.setText("const a = 2\n") + await tracker.flush(a) + + expect(await tracker.getRecentDiffs()).toEqual([]) + tracker.dispose() + }) + + it("never returns edits from denied documents", async () => { + const denied = new Set(["/workspace/.env"]) + const safe = doc("/workspace/app.ts", "const safe = 1\n") + const secret = doc("/workspace/.env", "TOKEN=old\n") + docs(safe, secret) + const tracker = new EditHistoryTracker({ isFileAllowed: async (path) => !denied.has(path) }) + + await settle() + secret.setText("TOKEN=secret\n") + await tracker.flush(secret) + safe.setText("const safe = 2\n") + await tracker.flush(safe) + + const diffs = await tracker.getRecentDiffs() + expect(diffs).toHaveLength(1) + expect(diffs[0]).toContain("app.ts") + expect(diffs[0]).not.toContain("TOKEN=secret") + + denied.add("/workspace/app.ts") + expect(await tracker.getRecentDiffs()).toEqual([]) + + tracker.dispose() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts b/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts new file mode 100644 index 0000000000..f4d44fd5c9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, mock } from "bun:test" +import * as vscode from "vscode" +import type { KiloConnectionService } from "../../src/services/cli-backend" +import { + NextEditInlineCompletionProvider, + type NextEditProviderDeps, +} from "../../src/services/autocomplete/next-edit/NextEditInlineCompletionProvider" +import type { NextEditSuggestionManager } from "../../src/services/autocomplete/next-edit/NextEditSuggestionManager" + +type Subject = { + toCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + suggestion: { + replacement: string + editableRegionStartLine: number + editableRegionEndLine: number + latencyMs: number + }, + ): vscode.InlineCompletionItem[] | undefined +} + +function doc(text: string): vscode.TextDocument { + const lines = text.split("\n") + return { + lineCount: lines.length, + lineAt: (line: number) => ({ + text: lines[line], + range: { end: new vscode.Position(line, lines[line].length) }, + }), + getText: () => text, + uri: { fsPath: "/workspace/test.ts", scheme: "file" }, + } as unknown as vscode.TextDocument +} + +describe("NextEditInlineCompletionProvider", () => { + it("does not send a document when the access policy is missing at runtime", async () => { + const connection = { getClientAsync: mock() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: connection, + } as unknown as NextEditProviderDeps) + + const out = await provider.provideInlineCompletionItems( + doc("const value = 1"), + new vscode.Position(0, 0), + {} as vscode.InlineCompletionContext, + {} as vscode.CancellationToken, + ) + + expect(out).toBeUndefined() + expect(connection.getClientAsync).not.toHaveBeenCalled() + provider.dispose() + }) + + it("does not send a document when the access policy fails", async () => { + const connection = { getClientAsync: mock() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: connection as unknown as KiloConnectionService, + isFileAllowed: async () => Promise.reject(new Error("unavailable")), + }) + + const out = await provider.provideInlineCompletionItems( + doc("const value = 1"), + new vscode.Position(0, 0), + {} as vscode.InlineCompletionContext, + {} as vscode.CancellationToken, + ) + + expect(out).toBeUndefined() + expect(connection.getClientAsync).not.toHaveBeenCalled() + provider.dispose() + }) + + it("stashes same-line rewrites before the cursor for decorated acceptance", () => { + const mgr = { clear: mock(), setPending: mock() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: {} as KiloConnectionService, + isFileAllowed: async () => true, + suggestionManager: mgr as unknown as NextEditSuggestionManager, + }) + const text = "const oldName = make()" + const document = { + lineCount: 1, + lineAt: () => ({ text, range: { end: new vscode.Position(0, text.length) } }), + getText: () => text, + } as unknown as vscode.TextDocument + + const out = (provider as unknown as Subject).toCompletionItems(document, new vscode.Position(0, 13), { + replacement: "const newName = make()", + editableRegionStartLine: 0, + editableRegionEndLine: 0, + latencyMs: 1, + }) + + expect(out).toBeUndefined() + expect(mgr.setPending).toHaveBeenCalledWith( + expect.objectContaining({ kind: "replace", replacement: "const newName = make()" }), + ) + provider.dispose() + }) + + it("stashes complete-line deletion intent for acceptance", () => { + const mgr = { clear: mock(), setPending: mock() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: {} as KiloConnectionService, + isFileAllowed: async () => true, + suggestionManager: mgr as unknown as NextEditSuggestionManager, + }) + + const out = (provider as unknown as Subject).toCompletionItems( + doc("before\nremove\nafter"), + new vscode.Position(1, 0), + { + replacement: "before\nafter", + editableRegionStartLine: 0, + editableRegionEndLine: 2, + latencyMs: 1, + }, + ) + + expect(out).toBeUndefined() + expect(mgr.setPending).toHaveBeenCalledWith( + expect.objectContaining({ kind: "replace", replacement: "", removesLines: true }), + ) + provider.dispose() + }) + + it("does not classify a blank-line rewrite as deletion", () => { + const mgr = { clear: mock(), setPending: mock() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: {} as KiloConnectionService, + isFileAllowed: async () => true, + suggestionManager: mgr as unknown as NextEditSuggestionManager, + }) + + const out = (provider as unknown as Subject).toCompletionItems( + doc("before\nremove\nafter"), + new vscode.Position(0, 0), + { + replacement: "before\n\nafter", + editableRegionStartLine: 0, + editableRegionEndLine: 2, + latencyMs: 1, + }, + ) + + expect(out).toBeUndefined() + expect(mgr.setPending).toHaveBeenCalledWith( + expect.objectContaining({ kind: "replace", replacement: "", removesLines: false }), + ) + provider.dispose() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts b/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts new file mode 100644 index 0000000000..1f6fdb0c6a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test" +import { planInsertion, planReplacement } from "../../src/services/autocomplete/next-edit/pendingEdit" + +describe("planInsertion", () => { + it("appends after the final unterminated line at EOF", () => { + const edit = planInsertion( + { diffStartLine: 2, replacement: "third\n" }, + { lineCount: 2, end: (line) => [5, 6][line] }, + ) + + expect(edit).toEqual({ line: 1, character: 6, text: "\nthird" }) + }) + + it("keeps insertion-before-line semantics for a trailing empty line", () => { + const edit = planInsertion( + { diffStartLine: 1, replacement: "second\n" }, + { lineCount: 2, end: (line) => [5, 0][line] }, + ) + + expect(edit).toEqual({ line: 1, character: 0, text: "second\n" }) + }) +}) + +describe("planReplacement", () => { + it("removes a middle line through the following separator", () => { + const edit = planReplacement( + { diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: true }, + { lineCount: 3, end: (line) => [6, 6, 5][line] }, + ) + + expect(edit).toEqual({ + start: { line: 1, character: 0 }, + end: { line: 2, character: 0 }, + text: "", + }) + }) + + it("removes a final line through the preceding separator", () => { + const edit = planReplacement( + { diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: true }, + { lineCount: 2, end: (line) => [6, 6][line] }, + ) + + expect(edit).toEqual({ + start: { line: 0, character: 6 }, + end: { line: 1, character: 6 }, + text: "", + }) + }) + + it("preserves a line intentionally rewritten as blank", () => { + const edit = planReplacement( + { diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: false }, + { lineCount: 3, end: (line) => [6, 6, 5][line] }, + ) + + expect(edit).toEqual({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 6 }, + text: "", + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts b/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts new file mode 100644 index 0000000000..d5bfa69f27 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts @@ -0,0 +1,55 @@ +import { + toAllowedMercuryRecentSnippets, + toMercuryRecentSnippets, +} from "../../src/services/autocomplete/next-edit/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") + }) + + it("excludes denied snippets before constructing next edit request context", () => { + const out = toAllowedMercuryRecentSnippets( + [ + { filepath: "secrets/.env", content: "TOKEN=do-not-send" }, + { filepath: "src/app.ts", content: "const safe = true" }, + ], + (path) => !path.endsWith(".env"), + ) + + expect(out).toEqual([{ filepath: "src/app.ts", content: "const safe = true" }]) + expect(JSON.stringify(out)).not.toContain("do-not-send") + }) +}) diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts index df8a574701..2f80923322 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,34 @@ export const FimBody = Schema.Struct({ temperature: Schema.optional(Schema.Finite), }) +// 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({ + 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({ + 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 +223,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 +268,19 @@ 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 client supplies structured editor " + + "context; the gateway assembles the sentinel-tagged prompt and forwards to the upstream edit endpoint.", + }), + ), 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 d823b4b10f..2f9f4a679a 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,8 @@ import { fetchProfile, } 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" @@ -36,7 +38,7 @@ 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 @@ -154,6 +156,82 @@ 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) + + // 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 () => { + 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 }], + }), + }) + }) + + if (!response.ok) { + // 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<{ + 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 }) { @@ -322,6 +400,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", .handle("profile", profile) .handle("modes", modes) .handle("fim", fim) + .handle("edit", edit) .handle("audioTranscriptions", audioTranscriptions) .handle("notifications", notifications) .handle("organization", organization) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 950df5f0f4..ce6ac869f9 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,66 @@ 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 + 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, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { 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" }, + ], + }, + ], + ) + 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 f1dfec75d6..6e4762fd2e 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 @@ -2544,6 +2544,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" @@ -2845,22 +2869,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" @@ -3387,14 +3395,6 @@ export type EventSessionNextCompactionEnded = { } } -export type EventIndexingStatus = { - id: string - type: "indexing.status" - properties: { - status: IndexingStatus - } -} - export type SessionInfo = { id: string parentID?: string @@ -7780,6 +7780,55 @@ export type KiloFimResponses = { export type KiloFimResponse = KiloFimResponses[keyof KiloFimResponses] +export type KiloEditData = { + body?: { + 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?: { + 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 diff --git a/turbo.json b/turbo.json index c1798ac2a7..5ff4dba17e 100644 --- a/turbo.json +++ b/turbo.json @@ -24,6 +24,9 @@ "outputs": [".artifacts/unit/junit.xml"], "passThroughEnv": ["*"] }, + "@kilocode/kilo-gateway#test:ci": { + "outputs": [".artifacts/unit/junit.xml"] + }, "@kilocode/kilo-docs#build": { "dependsOn": ["^build"], "outputs": [".next/**"],