Skip to content
Merged
101 changes: 91 additions & 10 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import {
Expand All @@ -7,6 +8,8 @@ import {
type KiloConnectionService,
type KilocodeNotification,
} from "./services/cli-backend"
import type { EditorContext } from "./services/cli-backend/types"
import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController"
import { handleChatCompletionRequest } from "./services/autocomplete/chat-autocomplete/handleChatCompletionRequest"
import { handleChatCompletionAccepted } from "./services/autocomplete/chat-autocomplete/handleChatCompletionAccepted"
import { buildWebviewHtml } from "./utils"
Expand Down Expand Up @@ -48,6 +51,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private unsubscribeNotificationDismiss: (() => void) | null = null
private webviewMessageDisposable: vscode.Disposable | null = null

/** Lazily initialized ignore controller for .kilocodeignore filtering */
private ignoreController: FileIgnoreController | null = null
private ignoreControllerDir: string | null = null

/** Optional interceptor called before the standard message handler.
* Return null to consume the message, or return a (possibly transformed) message. */
private onBeforeMessage: ((msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>) | null = null
Expand Down Expand Up @@ -1055,16 +1062,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Build parts array with file context and user text
const parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string }> = []

// Inject active editor file as context
const editor = vscode.window.activeTextEditor
if (editor && editor.document.uri.scheme === "file") {
const url = editor.document.uri.toString()
const already = files?.some((f) => f.url === url)
if (!already) {
parts.push({ type: "file", mime: "text/plain", url })
}
}

// Add any explicitly attached files from the webview
if (files) {
for (const f of files) {
Expand All @@ -1074,11 +1071,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

parts.push({ type: "text", text })

const editorContext = await this.gatherEditorContext()

await this.httpClient.sendMessage(targetSessionID, parts, workspaceDir, {
providerID,
modelID,
agent,
variant,
editorContext,
})
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to send message:", error)
Expand Down Expand Up @@ -1506,6 +1506,86 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}

/**
* Gather VS Code editor context to send alongside messages to the CLI backend.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Orphaned JSDoc comment — this doc block ("Gather VS Code editor context...") was likely intended for gatherEditorContext() at line 1527, but it's now attached to nothing since getIgnoreController() has its own JSDoc immediately below. Consider removing this block or moving it above gatherEditorContext().

/**
* Get or create a FileIgnoreController for the current workspace directory.
* Reinitializes if the workspace directory has changed.
*/
private async getIgnoreController(workspaceDir: string): Promise<FileIgnoreController> {
if (this.ignoreController && this.ignoreControllerDir === workspaceDir) {
return this.ignoreController
}
const controller = new FileIgnoreController(workspaceDir)
await controller.initialize()
this.ignoreController = controller

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The old ignoreController is replaced but never disposed when the workspace directory changes. This leaks the previous controller's internal state (loaded contents, realpath cache).

Consider disposing the old controller before replacing it:

this.ignoreController?.dispose()

before assigning the new one.

this.ignoreControllerDir = workspaceDir
return controller
}

private async gatherEditorContext(): Promise<EditorContext> {
const workspaceDir = this.getWorkspaceDirectory()
const controller = await this.getIgnoreController(workspaceDir)

const toRelative = (fsPath: string): string | undefined => {
if (!workspaceDir) {
return undefined
}
const relative = path.relative(workspaceDir, fsPath)
if (relative.startsWith("..")) {
return undefined
}
return relative
}

// Visible files (capped to avoid bloating context, filtered through .kilocodeignore)
const visibleFiles = vscode.window.visibleTextEditors
.map((e) => e.document.uri)
.filter((uri) => uri.scheme === "file")
.map((uri) => toRelative(uri.fsPath))
.filter((p): p is string => p !== undefined && controller.validateAccess(path.resolve(workspaceDir, p)))
.slice(0, 200)

// Open tabs — use instanceof TabInputText to exclude notebooks, diffs, custom editors
const openTabSet = new Set<string>()
for (const group of vscode.window.tabGroups.all) {
for (const tab of group.tabs) {
if (tab.input instanceof vscode.TabInputText) {
const uri = tab.input.uri
if (uri.scheme === "file") {
const rel = toRelative(uri.fsPath)
if (rel && controller.validateAccess(uri.fsPath)) {
openTabSet.add(rel)
}
}
}
}
}
const openTabs = [...openTabSet].slice(0, 20)

// Active file (also filtered through .kilocodeignore)
const activeEditor = vscode.window.activeTextEditor
const activeRel =
activeEditor?.document.uri.scheme === "file" ? toRelative(activeEditor.document.uri.fsPath) : undefined
const activeFile =
activeRel && controller.validateAccess(activeEditor!.document.uri.fsPath) ? activeRel : undefined

// Shell
const shell = vscode.env.shell || undefined

// Timezone
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined

return {
...(visibleFiles.length > 0 ? { visibleFiles } : {}),
...(openTabs.length > 0 ? { openTabs } : {}),
...(activeFile ? { activeFile } : {}),
...(shell ? { shell } : {}),
...(timezone ? { timezone } : {}),
}
}

/**
* Get the workspace directory for a session.
* Checks session directory overrides first (e.g., worktree paths), then falls back to workspace root.
Expand Down Expand Up @@ -1544,5 +1624,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.webviewMessageDisposable?.dispose()
this.trackedSessionIds.clear()
this.sessionDirectories.clear()
this.ignoreController?.dispose()
}
}
6 changes: 5 additions & 1 deletion packages/kilo-vscode/src/services/cli-backend/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
McpConfig,
Config,
KilocodeNotification,
EditorContext,
} from "./types"
import { extractHttpErrorMessage, parseSSEDataLine } from "./http-utils"

Expand Down Expand Up @@ -214,7 +215,7 @@ export class HttpClient {
sessionId: string,
parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string }>,
directory: string,
options?: { providerID?: string; modelID?: string; agent?: string; variant?: string },
options?: { providerID?: string; modelID?: string; agent?: string; variant?: string; editorContext?: EditorContext },
): Promise<void> {
const body: Record<string, unknown> = { parts }
if (options?.providerID && options?.modelID) {
Expand All @@ -227,6 +228,9 @@ export class HttpClient {
if (options?.variant) {
body.variant = options.variant
}
if (options?.editorContext) {
body.editorContext = options.editorContext
}

await this.request<void>("POST", `/session/${sessionId}/message`, body, { directory, allowEmpty: true })
}
Expand Down
14 changes: 14 additions & 0 deletions packages/kilo-vscode/src/services/cli-backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,17 @@ export interface Config {
layout?: "auto" | "stretch"
experimental?: ExperimentalConfig
}

/** VS Code editor context sent alongside messages to the CLI backend */
export interface EditorContext {
/** Workspace-relative paths of currently visible editors */
visibleFiles?: string[]
/** Workspace-relative paths of open tabs */
openTabs?: string[]
/** Workspace-relative path of the active editor file */
activeFile?: string
/** User's default shell (from vscode.env.shell) */
shell?: string
/** User's timezone (e.g. "Europe/Amsterdam") */
timezone?: string
}
41 changes: 41 additions & 0 deletions packages/opencode/src/kilocode/editor-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export interface EditorContext {
visibleFiles?: string[]
openTabs?: string[]
activeFile?: string
shell?: string
timezone?: string
}

function formatTime(timezone?: string): string[] {
const now = new Date()
const lines = [` Current time: ${now.toISOString()}`]
if (timezone) {
const offset = -now.getTimezoneOffset()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Timezone offset mismatch — getTimezoneOffset() returns the server/CLI process local offset, not the offset for the user's timezone string.

When the CLI backend runs in a different timezone than the VS Code client (e.g., remote dev server in UTC, user in Europe/Amsterdam), the displayed offset will be wrong while the timezone name is correct.

To compute the correct offset from the timezone string, you could use Intl.DateTimeFormat:

function getUtcOffset(timezone: string): string {
  const now = new Date()
  const formatter = new Intl.DateTimeFormat("en-US", {
    timeZone: timezone,
    timeZoneName: "shortOffset",
  })
  const parts = formatter.formatToParts(now)
  const tzPart = parts.find((p) => p.type === "timeZoneName")
  return tzPart?.value ?? ""
}

Or simply omit the computed offset and just display the timezone name, since the name is authoritative.

const sign = offset >= 0 ? "+" : "-"
const hours = Math.floor(Math.abs(offset) / 60)
const mins = Math.abs(offset) % 60
lines.push(` User timezone: ${timezone}, UTC${sign}${hours}:${mins.toString().padStart(2, "0")}`)
}
return lines
}

/**
* Build additional <env> lines from VS Code editor context.
* Returns an array of pre-formatted ` key: value` strings.
*/
export function editorContextEnvLines(ctx?: EditorContext): string[] {
const lines = formatTime(ctx?.timezone)
if (ctx?.shell) {
lines.push(` Default shell: ${ctx.shell}`)
}
if (ctx?.activeFile) {
lines.push(` Active file: ${ctx.activeFile}`)
}
if (ctx?.visibleFiles?.length) {
lines.push(` Visible files: ${ctx.visibleFiles.join(", ")}`)
}
if (ctx?.openTabs?.length) {
lines.push(` Open tabs: ${ctx.openTabs.join(", ")}`)
}
return lines
}
11 changes: 11 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,17 @@ export namespace MessageV2 {
system: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
variant: z.string().optional(),
// kilocode_change start
editorContext: z
.object({
visibleFiles: z.array(z.string()).optional(),
openTabs: z.array(z.string()).optional(),
activeFile: z.string().optional(),
shell: z.string().optional(),
timezone: z.string().optional(),
})
.optional(),
// kilocode_change end
}).meta({
ref: "UserMessage",
})
Expand Down
14 changes: 13 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ export namespace SessionPrompt {
format: MessageV2.Format.optional(),
system: z.string().optional(),
variant: z.string().optional(),
// kilocode_change start
editorContext: z
.object({
visibleFiles: z.array(z.string()).optional(),
openTabs: z.array(z.string()).optional(),
activeFile: z.string().optional(),
shell: z.string().optional(),
timezone: z.string().optional(),
})
.optional(),
// kilocode_change end
parts: z.array(
z.discriminatedUnion("type", [
MessageV2.TextPart.omit({
Expand Down Expand Up @@ -667,7 +678,7 @@ export namespace SessionPrompt {
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages })

// Build system prompt, adding structured output instruction if needed
const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]
const system = [...(await SystemPrompt.environment(model, lastUser.editorContext)), ...(await InstructionPrompt.system())] // kilocode_change
const format = lastUser.format ?? { type: "text" }
if (format.type === "json_schema") {
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
Expand Down Expand Up @@ -994,6 +1005,7 @@ export namespace SessionPrompt {
system: input.system,
format: input.format,
variant,
editorContext: input.editorContext, // kilocode_change
}
using _ = defer(() => InstructionPrompt.clear(info.id))

Expand Down
7 changes: 5 additions & 2 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Provider } from "@/provider/provider"

// kilocode_change start
import SOUL from "../kilocode/soul.txt"
import { editorContextEnvLines, type EditorContext } from "../kilocode/editor-context"
// kilocode_change end

export namespace SystemPrompt {
Expand All @@ -36,7 +37,9 @@ export namespace SystemPrompt {
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
}

export async function environment(model: Provider.Model) {
// kilocode_change start
export async function environment(model: Provider.Model, editorContext?: EditorContext) {
// kilocode_change end
const project = Instance.project
return [
[
Expand All @@ -46,7 +49,7 @@ export namespace SystemPrompt {
` Working directory: ${Instance.directory}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
...editorContextEnvLines(editorContext), // kilocode_change
`</env>`,
`<directories>`,
` ${
Expand Down
Loading