mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge pull request #10621 from Kilo-Org/mark/mercury-next-edit-integration-v2
Add Mercury Next Edit as an opt-in autocomplete mode
This commit is contained in:
@@ -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)!
|
||||
@@ -47,6 +47,8 @@
|
||||
<!-- packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts -->
|
||||
- <https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services>
|
||||
<!-- packages/opencode/src/cli/cmd/github.ts -->
|
||||
- <https://docs.inceptionlabs.ai/capabilities/next-edit>
|
||||
<!-- packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts -->
|
||||
- <https://docs.mistral.ai/capabilities/reasoning/adjustable>
|
||||
<!-- packages/opencode/src/provider/transform.ts -->
|
||||
- <https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.artifacts
|
||||
@@ -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:*",
|
||||
|
||||
@@ -4,7 +4,7 @@ export type DirectAutocompleteProviderID = Exclude<AutocompleteProviderID, "kilo
|
||||
export interface AutocompleteModelDef {
|
||||
/** Stable combined value for internal comparisons. */
|
||||
readonly id: string
|
||||
/** Model value stored in settings and sent to the FIM API. */
|
||||
/** Model value stored in settings and sent to the autocomplete API. */
|
||||
readonly modelID: string
|
||||
/** Human-readable label shown in settings. */
|
||||
readonly label: string
|
||||
@@ -12,12 +12,18 @@ export interface AutocompleteModelDef {
|
||||
readonly providerID: AutocompleteProviderID
|
||||
/** Provider display name for status bar / telemetry. */
|
||||
readonly provider: string
|
||||
/** Full model ID sent upstream by the FIM route. */
|
||||
/** Full model ID sent upstream by the autocomplete route. */
|
||||
readonly requestModel: string
|
||||
/** Provider key to use for direct BYOK FIM. Empty means Kilo Gateway. */
|
||||
/** Provider key to use for direct BYOK. Empty means Kilo Gateway. */
|
||||
readonly directProvider?: DirectAutocompleteProviderID
|
||||
/** FIM request temperature. */
|
||||
/** Request temperature. */
|
||||
readonly temperature: number
|
||||
/**
|
||||
* Which gateway endpoint this model targets. Defaults to "fim" if omitted
|
||||
* (back-compat with existing entries). Models with `kind: "edit"` route
|
||||
* through `/kilo/edit` and use Mercury's Next Edit pipeline.
|
||||
*/
|
||||
readonly kind?: "fim" | "edit"
|
||||
}
|
||||
|
||||
const models: AutocompleteModelDef[] = [
|
||||
@@ -59,6 +65,20 @@ const models: AutocompleteModelDef[] = [
|
||||
directProvider: "inception",
|
||||
temperature: 0,
|
||||
},
|
||||
{
|
||||
// Same wire-level model as `mercury-edit-2`, but routed through the
|
||||
// Mercury Next Edit endpoint instead of FIM. Picked by users who want
|
||||
// multi-line next-edit predictions with the jump-to-edit UX.
|
||||
id: "inception/mercury-next-edit",
|
||||
modelID: "mercury-next-edit",
|
||||
label: "Mercury Next Edit",
|
||||
providerID: "inception",
|
||||
provider: "Inception",
|
||||
requestModel: "mercury-edit-2",
|
||||
directProvider: "inception",
|
||||
temperature: 0,
|
||||
kind: "edit",
|
||||
},
|
||||
]
|
||||
|
||||
export const AUTOCOMPLETE_MODELS: readonly AutocompleteModelDef[] = models
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Mercury Next Edit prompt assembly. Lives in the gateway so every client
|
||||
* (VS Code, JetBrains, TUI) sends the same structured editor context and the
|
||||
* Mercury-specific sentinel format is defined in exactly one place.
|
||||
*
|
||||
* Tag set is defined by the model and must be reproduced verbatim — see
|
||||
* https://docs.inceptionlabs.ai/capabilities/next-edit
|
||||
*/
|
||||
|
||||
const RECENTLY_VIEWED_SNIPPETS_OPEN = "<|recently_viewed_code_snippets|>"
|
||||
const RECENTLY_VIEWED_SNIPPETS_CLOSE = "<|/recently_viewed_code_snippets|>"
|
||||
const RECENTLY_VIEWED_SNIPPET_OPEN = "<|recently_viewed_code_snippet|>"
|
||||
const RECENTLY_VIEWED_SNIPPET_CLOSE = "<|/recently_viewed_code_snippet|>"
|
||||
const CURRENT_FILE_CONTENT_OPEN = "<|current_file_content|>"
|
||||
const CURRENT_FILE_CONTENT_CLOSE = "<|/current_file_content|>"
|
||||
const CODE_TO_EDIT_OPEN = "<|code_to_edit|>"
|
||||
const CODE_TO_EDIT_CLOSE = "<|/code_to_edit|>"
|
||||
const EDIT_DIFF_HISTORY_OPEN = "<|edit_diff_history|>"
|
||||
const EDIT_DIFF_HISTORY_CLOSE = "<|/edit_diff_history|>"
|
||||
const CURSOR = "<|cursor|>"
|
||||
/** Trailing token that tells the model this is a next-edit (not chat) request. */
|
||||
const UNIQUE_TOKEN = "<|!@#IS_NEXT_EDIT!@#|>"
|
||||
|
||||
export interface MercuryRecentSnippet {
|
||||
filepath: string
|
||||
content: string
|
||||
}
|
||||
|
||||
/** Editor-derived context a client sends; the gateway turns it into a prompt. */
|
||||
export interface MercuryEditContext {
|
||||
currentFilePath: string
|
||||
currentFileContent: string
|
||||
cursorLine: number
|
||||
cursorCharacter: number
|
||||
editableRegionStartLine: number
|
||||
editableRegionEndLine: number
|
||||
recentlyViewedSnippets: MercuryRecentSnippet[]
|
||||
editDiffHistory: string[]
|
||||
}
|
||||
|
||||
function insertCursorToken(lines: string[], cursorLine: number, cursorCharacter: number): string[] {
|
||||
if (cursorLine < 0 || cursorLine >= lines.length) return lines
|
||||
const line = lines[cursorLine]
|
||||
const safeChar = Math.min(Math.max(cursorCharacter, 0), line.length)
|
||||
const next = line.slice(0, safeChar) + CURSOR + line.slice(safeChar)
|
||||
return [...lines.slice(0, cursorLine), next, ...lines.slice(cursorLine + 1)]
|
||||
}
|
||||
|
||||
export function recentlyViewedSnippetsBlock(snippets: MercuryRecentSnippet[]): string {
|
||||
const inner = snippets
|
||||
.map((s) =>
|
||||
[RECENTLY_VIEWED_SNIPPET_OPEN, `code_snippet_file_path: ${s.filepath}`, s.content, RECENTLY_VIEWED_SNIPPET_CLOSE].join("\n"),
|
||||
)
|
||||
.join("\n")
|
||||
return [RECENTLY_VIEWED_SNIPPETS_OPEN, inner, RECENTLY_VIEWED_SNIPPETS_CLOSE].join("\n")
|
||||
}
|
||||
|
||||
export function currentFileContentBlock(
|
||||
currentFilePath: string,
|
||||
currentFileContent: string,
|
||||
editableRegionStartLine: number,
|
||||
editableRegionEndLine: number,
|
||||
cursorLine: number,
|
||||
cursorCharacter: number,
|
||||
): string {
|
||||
const rawLines = currentFileContent.split("\n")
|
||||
const withCursor = insertCursorToken(rawLines, cursorLine, cursorCharacter)
|
||||
const start = Math.max(0, Math.min(editableRegionStartLine, withCursor.length))
|
||||
const end = Math.max(start, Math.min(editableRegionEndLine, withCursor.length - 1))
|
||||
const instrumented = [
|
||||
...withCursor.slice(0, start),
|
||||
CODE_TO_EDIT_OPEN,
|
||||
...withCursor.slice(start, end + 1),
|
||||
CODE_TO_EDIT_CLOSE,
|
||||
...withCursor.slice(end + 1),
|
||||
]
|
||||
return [CURRENT_FILE_CONTENT_OPEN, `current_file_path: ${currentFilePath}`, instrumented.join("\n"), CURRENT_FILE_CONTENT_CLOSE].join("\n")
|
||||
}
|
||||
|
||||
export function editDiffHistoryBlock(diffs: string[]): string {
|
||||
// Each unidiff from `diff.createPatch` opens with an Index line + separator we
|
||||
// strip. Diffs are blank-line separated so the model reads them as distinct hunks.
|
||||
const trimmed = diffs.map((d) => {
|
||||
const lines = d.split("\n")
|
||||
return lines.length > 2 ? lines.slice(2).join("\n") : d
|
||||
})
|
||||
return [EDIT_DIFF_HISTORY_OPEN, trimmed.join("\n\n"), EDIT_DIFF_HISTORY_CLOSE].join("\n")
|
||||
}
|
||||
|
||||
export function buildMercuryEditPrompt(ctx: MercuryEditContext): string {
|
||||
return [
|
||||
recentlyViewedSnippetsBlock(ctx.recentlyViewedSnippets),
|
||||
"",
|
||||
currentFileContentBlock(
|
||||
ctx.currentFilePath,
|
||||
ctx.currentFileContent,
|
||||
ctx.editableRegionStartLine,
|
||||
ctx.editableRegionEndLine,
|
||||
ctx.cursorLine,
|
||||
ctx.cursorCharacter,
|
||||
),
|
||||
"",
|
||||
editDiffHistoryBlock(ctx.editDiffHistory),
|
||||
"",
|
||||
UNIQUE_TOKEN,
|
||||
].join("\n")
|
||||
}
|
||||
@@ -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<DirectAutocompleteProviderID, string[]> = {
|
||||
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
|
||||
}
|
||||
@@ -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<AuthStore, "get">
|
||||
|
||||
const EDIT_TIMEOUT_MS = 30_000
|
||||
const MAX_TOKENS_DEFAULT = 512
|
||||
|
||||
async function getProviderKey(Auth: Auth, provider: DirectAutocompleteProviderID): Promise<string | undefined> {
|
||||
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<string> {
|
||||
try {
|
||||
return await res.text()
|
||||
} catch {
|
||||
return "<unreadable>"
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the target type for tests + the opencode handler
|
||||
export type { EditTarget }
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
def factorial(n):
|
||||
if n <= 1:
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
COLOR_RED = "#ff0000"
|
||||
COLOR_GREEN = "#00ff00"
|
||||
COLOR_BLUE =
|
||||
@@ -0,0 +1,5 @@
|
||||
def calculate_total(items):
|
||||
total = 0
|
||||
for item in items:
|
||||
total += item.price
|
||||
return tot
|
||||
@@ -0,0 +1,5 @@
|
||||
def calculate_total(items):
|
||||
total = 0
|
||||
for item in items:
|
||||
|
||||
return total
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
def sum_prices(items):
|
||||
total = 0
|
||||
for item in items:
|
||||
return total
|
||||
@@ -0,0 +1,7 @@
|
||||
def fibonacci(n):
|
||||
if n <= 1:
|
||||
return n
|
||||
return fibonacci(n - 1) + fibonacci(n - 2)
|
||||
|
||||
|
||||
result = fib
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
ch := make(chan int)
|
||||
|
||||
go func() {
|
||||
|
||||
}()
|
||||
|
||||
for v := range ch {
|
||||
fmt.Println("got:", v)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
SELECT id, email
|
||||
FROM users
|
||||
WHERE
|
||||
ORDER BY last_login_at DESC;
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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<FileIgnoreController>
|
||||
public readonly ignoreController: Promise<FileIgnoreController>
|
||||
/** Abort controller for the current in-flight FIM request */
|
||||
private fimAbortController: AbortController | null = null
|
||||
private acceptedCommand: vscode.Disposable | null = null
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<MercuryEditSuggestion | null> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+392
@@ -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<boolean>
|
||||
/** 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<vscode.InlineCompletionItem[] | vscode.InlineCompletionList | undefined> {
|
||||
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<boolean> {
|
||||
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<MercuryEditRequestContext> {
|
||||
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<void> {
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
||||
return new Promise<void>((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
|
||||
}
|
||||
+352
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<boolean>
|
||||
}
|
||||
|
||||
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<string, string>()
|
||||
private readonly pendingTimers = new Map<string, NodeJS.Timeout>()
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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<Pick<AutocompleteCodeSnippet, "filepath" | "content">>,
|
||||
): 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<Pick<AutocompleteCodeSnippet, "filepath" | "content">>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -46,6 +46,10 @@ const mockVscode = {
|
||||
version: "1.90.0",
|
||||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: "/repo" } }],
|
||||
textDocuments: [] as Array<unknown>,
|
||||
onDidOpenTextDocument: () => ({ dispose: noop }),
|
||||
onDidChangeTextDocument: () => ({ dispose: noop }),
|
||||
onDidCloseTextDocument: () => ({ dispose: noop }),
|
||||
getConfiguration: () => ({
|
||||
get: <T>(_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() {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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<boolean> })
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: "",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ThrowOnError extends boolean = false>(
|
||||
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<string>
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
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<KiloEditResponses, KiloEditErrors, ThrowOnError>({
|
||||
url: "/kilo/edit",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Kilo notifications
|
||||
*
|
||||
|
||||
@@ -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<PromptAgentAttachment>
|
||||
}
|
||||
|
||||
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<string>
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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/**"],
|
||||
|
||||
Reference in New Issue
Block a user