From 1c98ba6f51b0010a7d8562384d2bdf84c1da033a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 18 Feb 2026 14:29:39 -0300 Subject: [PATCH 01/73] feat(vscode): add Kilo notifications banner to VS Code extension Ports the CLI's Kilo news/notifications feature to the VS Code extension. Shows a dismissable carousel banner at the top of the chat view when no session is active, fetching notifications from the kilo-gateway API with dismissed IDs persisted in extension globalState. --- packages/kilo-vscode/src/KiloProvider.ts | 53 ++++++++++++- packages/kilo-vscode/src/extension.ts | 2 +- .../src/services/cli-backend/http-client.ts | 13 ++++ .../src/services/cli-backend/index.ts | 2 + .../src/services/cli-backend/types.ts | 14 ++++ packages/kilo-vscode/webview-ui/src/App.tsx | 13 ++-- .../src/components/chat/ChatView.tsx | 4 + .../src/components/chat/KiloNotifications.tsx | 67 ++++++++++++++++ .../webview-ui/src/context/notifications.tsx | 78 +++++++++++++++++++ .../webview-ui/src/styles/chat.css | 75 ++++++++++++++++++ .../webview-ui/src/types/messages.ts | 32 ++++++++ 11 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/context/notifications.tsx diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 96c7052a7b..d9335af4c2 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,6 +1,12 @@ import * as vscode from "vscode" import { z } from "zod" -import { type HttpClient, type SessionInfo, type SSEEvent, type KiloConnectionService } from "./services/cli-backend" +import { + type HttpClient, + type SessionInfo, + type SSEEvent, + type KiloConnectionService, + type KilocodeNotification, +} from "./services/cli-backend" import { handleChatCompletionRequest } from "./services/autocomplete/chat-autocomplete/handleChatCompletionRequest" import { handleChatCompletionAccepted } from "./services/autocomplete/chat-autocomplete/handleChatCompletionAccepted" import { buildWebviewHtml } from "./utils" @@ -21,6 +27,8 @@ export class KiloProvider implements vscode.WebviewViewProvider { private cachedAgentsMessage: unknown = null /** Cached configLoaded payload so requestConfig can be served before httpClient is ready */ private cachedConfigMessage: unknown = null + /** Cached notificationsLoaded payload */ + private cachedNotificationsMessage: unknown = null private trackedSessionIds: Set = new Set() /** Per-session directory overrides (e.g., worktree paths registered by AgentManagerProvider). */ @@ -36,6 +44,7 @@ export class KiloProvider implements vscode.WebviewViewProvider { constructor( private readonly extensionUri: vscode.Uri, private readonly connectionService: KiloConnectionService, + private readonly extensionContext?: vscode.ExtensionContext, ) {} /** @@ -383,6 +392,12 @@ export class KiloProvider implements vscode.WebviewViewProvider { case "requestNotificationSettings": this.sendNotificationSettings() break + case "requestNotifications": + await this.fetchAndSendNotifications() + break + case "dismissNotification": + await this.handleDismissNotification(message.notificationId) + break case "resetAllSettings": await this.handleResetAllSettings() break @@ -469,6 +484,7 @@ export class KiloProvider implements vscode.WebviewViewProvider { await this.fetchAndSendProviders() await this.fetchAndSendAgents() await this.fetchAndSendConfig() + await this.fetchAndSendNotifications() this.sendNotificationSettings() console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully") @@ -836,6 +852,41 @@ export class KiloProvider implements vscode.WebviewViewProvider { } } + /** + * Fetch Kilo news/notifications and send to webview. + * Uses the cached message pattern so the webview gets data immediately on refresh. + */ + private async fetchAndSendNotifications(): Promise { + if (!this.httpClient) { + if (this.cachedNotificationsMessage) { + this.postMessage(this.cachedNotificationsMessage) + } + return + } + + try { + const notifications = await this.httpClient.getNotifications() + const dismissedIds = this.extensionContext?.globalState.get("kilo.dismissedNotificationIds", []) ?? [] + const message = { type: "notificationsLoaded", notifications, dismissedIds } + this.cachedNotificationsMessage = message + this.postMessage(message) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to fetch notifications:", error) + } + } + + /** + * Persist a dismissed notification ID in globalState and push updated lists to webview. + */ + private async handleDismissNotification(notificationId: string): Promise { + if (!this.extensionContext) return + const existing = this.extensionContext.globalState.get("kilo.dismissedNotificationIds", []) + if (!existing.includes(notificationId)) { + await this.extensionContext.globalState.update("kilo.dismissedNotificationIds", [...existing, notificationId]) + } + await this.fetchAndSendNotifications() + } + /** * Read notification/sound settings from VS Code config and push to webview. */ diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index a2dfdb2713..70bbbb401c 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -24,7 +24,7 @@ export function activate(context: vscode.ExtensionContext) { }) // Create the provider with shared service - const provider = new KiloProvider(context.extensionUri, connectionService) + const provider = new KiloProvider(context.extensionUri, connectionService, context) // Register the webview view provider for the sidebar. // retainContextWhenHidden keeps the webview alive when switching to other sidebar panels. diff --git a/packages/kilo-vscode/src/services/cli-backend/http-client.ts b/packages/kilo-vscode/src/services/cli-backend/http-client.ts index faf0a9a0dd..f4fe3a7c62 100644 --- a/packages/kilo-vscode/src/services/cli-backend/http-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/http-client.ts @@ -10,6 +10,7 @@ import type { McpStatus, McpConfig, Config, + KilocodeNotification, } from "./types" /** @@ -313,6 +314,18 @@ export class HttpClient { } } + /** + * Fetch Kilo notifications for the current user from the kilo-gateway. + * Returns an empty array if not logged in or if the request fails. + */ + async getNotifications(): Promise { + try { + return await this.request("GET", "/kilo/notifications") + } catch { + return [] + } + } + /** * Switch the active organization. * Pass null to switch back to personal account. diff --git a/packages/kilo-vscode/src/services/cli-backend/index.ts b/packages/kilo-vscode/src/services/cli-backend/index.ts index 8ceccad455..10e9ac968d 100644 --- a/packages/kilo-vscode/src/services/cli-backend/index.ts +++ b/packages/kilo-vscode/src/services/cli-backend/index.ts @@ -26,6 +26,8 @@ export type { McpRemoteConfig, McpConfig, Config, + KilocodeNotification, + KilocodeNotificationAction, } from "./types" export { ServerManager } from "./server-manager" diff --git a/packages/kilo-vscode/src/services/cli-backend/types.ts b/packages/kilo-vscode/src/services/cli-backend/types.ts index 06c3a90755..4610822556 100644 --- a/packages/kilo-vscode/src/services/cli-backend/types.ts +++ b/packages/kilo-vscode/src/services/cli-backend/types.ts @@ -174,6 +174,20 @@ export interface ProviderAuthAuthorization { instructions: string } +// Kilo notification from kilo-gateway +export interface KilocodeNotificationAction { + actionText: string + actionURL: string +} + +export interface KilocodeNotification { + id: string + title: string + message: string + action?: KilocodeNotificationAction + showIn?: string[] +} + // Profile types from kilo-gateway export interface KilocodeOrganization { id: string diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index f940c36e46..05f46c2285 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -18,6 +18,7 @@ import { SessionProvider, useSession } from "./context/session" import { LanguageProvider } from "./context/language" import { ChatView } from "./components/chat" import SessionList from "./components/history/SessionList" +import { NotificationsProvider } from "./context/notifications" import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2" import "./styles/chat.css" @@ -178,11 +179,13 @@ const App: Component = () => { - - - - - + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index d5d3ef118f..3845977181 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -10,6 +10,7 @@ import { TaskHeader } from "./TaskHeader" import { MessageList } from "./MessageList" import { PromptInput } from "./PromptInput" import { QuestionDock } from "./QuestionDock" +import { KiloNotifications } from "./KiloNotifications" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" @@ -42,6 +43,9 @@ export const ChatView: Component = (props) => { return (
+ + +
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx new file mode 100644 index 0000000000..e0a702825e --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx @@ -0,0 +1,67 @@ +import { Component, Show, createMemo, createSignal } from "solid-js" +import { Button } from "@kilocode/kilo-ui/button" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Icon } from "@kilocode/kilo-ui/icon" +import { useNotifications } from "../../context/notifications" +import { useVSCode } from "../../context/vscode" + +export const KiloNotifications: Component = () => { + const { filteredNotifications, dismiss } = useNotifications() + const vscode = useVSCode() + const [index, setIndex] = createSignal(0) + + const items = filteredNotifications + const total = () => items().length + const safeIndex = () => Math.min(index(), Math.max(0, total() - 1)) + const current = createMemo(() => items()[safeIndex()]) + + const prev = () => setIndex((i) => (i - 1 + total()) % total()) + const next = () => setIndex((i) => (i + 1) % total()) + + const handleAction = (url: string) => { + vscode.postMessage({ type: "openExternal", url }) + } + + const handleDismiss = () => { + const n = current() + if (!n) return + dismiss(n.id) + setIndex((i) => Math.min(i, Math.max(0, total() - 2))) + } + + return ( + 0}> +
+
+
+ {current()?.title} + +
+

{current()?.message}

+ +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/notifications.tsx b/packages/kilo-vscode/webview-ui/src/context/notifications.tsx new file mode 100644 index 0000000000..5c45d7a095 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/notifications.tsx @@ -0,0 +1,78 @@ +import { + createContext, + useContext, + createSignal, + createMemo, + onMount, + onCleanup, + ParentComponent, + Accessor, +} from "solid-js" +import { useVSCode } from "./vscode" +import type { KilocodeNotification, ExtensionMessage } from "../types/messages" + +interface NotificationsContextValue { + notifications: Accessor + filteredNotifications: Accessor + dismiss: (id: string) => void +} + +const NotificationsContext = createContext() + +export const NotificationsProvider: ParentComponent = (props) => { + const vscode = useVSCode() + const [notifications, setNotifications] = createSignal([]) + const [dismissedIds, setDismissedIds] = createSignal([]) + + const unsubscribe = vscode.onMessage((message: ExtensionMessage) => { + if (message.type === "notificationsLoaded") { + setNotifications(message.notifications) + setDismissedIds(message.dismissedIds) + } + }) + + onMount(() => { + let retries = 0 + const request = () => { + vscode.postMessage({ type: "requestNotifications" }) + } + request() + const interval = setInterval(() => { + if (notifications().length > 0 || retries >= 5) { + clearInterval(interval) + return + } + retries++ + request() + }, 500) + onCleanup(() => { + clearInterval(interval) + unsubscribe() + }) + }) + + const filteredNotifications = createMemo(() => { + const dismissed = dismissedIds() + return notifications().filter((n) => !dismissed.includes(n.id)) + }) + + const dismiss = (id: string) => { + vscode.postMessage({ type: "dismissNotification", notificationId: id }) + } + + const value: NotificationsContextValue = { + notifications, + filteredNotifications, + dismiss, + } + + return {props.children} +} + +export function useNotifications(): NotificationsContextValue { + const context = useContext(NotificationsContext) + if (!context) { + throw new Error("useNotifications must be used within a NotificationsProvider") + } + return context +} diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index 9323b03e45..0922bc39ad 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -745,3 +745,78 @@ color: var(--text-weak, var(--vscode-descriptionForeground)); white-space: nowrap; } + +/* ============================================ + Kilo Notifications + ============================================ */ + +.kilo-notifications { + flex-shrink: 0; + width: 100%; + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.kilo-notifications-card { + background-color: var(--vscode-editor-background); + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.kilo-notifications-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.kilo-notifications-title { + font-weight: 600; + color: var(--vscode-foreground); + font-size: 13px; + flex: 1; +} + +.kilo-notifications-message { + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 12px; + line-height: 1.5; +} + +.kilo-notifications-footer { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 28px; +} + +.kilo-notifications-nav { + display: flex; + align-items: center; + gap: 2px; +} + +.kilo-notifications-nav-btn { + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + color: var(--vscode-descriptionForeground); + display: inline-flex; + align-items: center; +} + +.kilo-notifications-nav-btn:hover { + color: var(--vscode-foreground); +} + +.kilo-notifications-nav-count { + font-size: 12px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + padding: 0 4px; +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index caea516dab..4e8b89f4d8 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -174,6 +174,20 @@ export interface DeviceAuthState { error?: string } +// Kilo notification types (mirrored from kilo-gateway) +export interface KilocodeNotificationAction { + actionText: string + actionURL: string +} + +export interface KilocodeNotification { + id: string + title: string + message: string + action?: KilocodeNotificationAction + showIn?: string[] +} + // Profile types from kilo-gateway export interface KilocodeBalance { balance: number @@ -501,6 +515,12 @@ export interface NotificationSettingsLoadedMessage { } } +export interface NotificationsLoadedMessage { + type: "notificationsLoaded" + notifications: KilocodeNotification[] + dismissedIds: string[] +} + // Agent Manager worktree session metadata export interface AgentManagerSessionMetaMessage { type: "agentManager.sessionMeta" @@ -553,6 +573,7 @@ export type ExtensionMessage = | ConfigLoadedMessage | ConfigUpdatedMessage | NotificationSettingsLoadedMessage + | NotificationsLoadedMessage | AgentManagerSessionMetaMessage | AgentManagerWorktreeSetupMessage @@ -729,6 +750,15 @@ export interface ResetAllSettingsRequest { type: "resetAllSettings" } +export interface RequestNotificationsMessage { + type: "requestNotifications" +} + +export interface DismissNotificationMessage { + type: "dismissNotification" + notificationId: string +} + export interface SyncSessionRequest { type: "syncSession" sessionID: string @@ -780,6 +810,8 @@ export type WebviewMessage = | ResetAllSettingsRequest | SyncSessionRequest | CreateWorktreeSessionRequest + | RequestNotificationsMessage + | DismissNotificationMessage // ============================================ // VS Code API type From 35dc27e805c1fad7e989e4fb54365f9bb876f95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20ceylan?= <33789758+haliliceylan@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:53:50 +0000 Subject: [PATCH 02/73] fix(ui): show horizontal scrollbar on code blocks in markdown --- packages/ui/src/components/markdown.css | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 68ae93bda4..fb54436d6d 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -155,9 +155,26 @@ margin-bottom: 2rem; overflow: auto; - scrollbar-width: none; + /* Hide vertical scrollbar, show horizontal */ + scrollbar-width: thin; + scrollbar-color: var(--border-weak-base) transparent; + &::-webkit-scrollbar { - display: none; + width: 0; + height: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-weak-base); + border-radius: 4px; + + &:hover { + background: var(--border-strong-base); + } } } From 2d46b17f6c19223622554d8deef13885f64b8785 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 19 Feb 2026 10:23:19 +0200 Subject: [PATCH 03/73] feat: Add predefined suggestions after finishing the planning session to improve context management --- .../opencode/src/kilocode/plan-followup.ts | 130 +++++++ packages/opencode/src/session/prompt.ts | 11 + .../test/kilocode/plan-followup.test.ts | 332 ++++++++++++++++++ 3 files changed, 473 insertions(+) create mode 100644 packages/opencode/src/kilocode/plan-followup.ts create mode 100644 packages/opencode/test/kilocode/plan-followup.test.ts diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts new file mode 100644 index 0000000000..48079b324e --- /dev/null +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -0,0 +1,130 @@ +import { Bus } from "@/bus" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { Identifier } from "@/id/id" +import { Question } from "@/question" +import { Session } from "@/session" +import { MessageV2 } from "@/session/message-v2" +import { Log } from "@/util/log" + +function isUser(item: MessageV2.WithParts): item is MessageV2.WithParts & { info: MessageV2.User } { + return item.info.role === "user" +} + +function isAssistant(item: MessageV2.WithParts): item is MessageV2.WithParts & { info: MessageV2.Assistant } { + return item.info.role === "assistant" +} + +function toText(item: MessageV2.WithParts): string { + return item.parts + .filter((part): part is MessageV2.TextPart => part.type === "text") + .map((part) => part.text) + .join("\n") + .trim() +} + +export namespace PlanFollowup { + const log = Log.create({ service: "plan.followup" }) + + async function inject(input: { sessionID: string; agent: string; model: MessageV2.User["model"]; text: string }) { + const msg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID: input.sessionID, + role: "user", + time: { + created: Date.now(), + }, + agent: input.agent, + model: input.model, + } + await Session.updateMessage(msg) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: msg.id, + sessionID: input.sessionID, + type: "text", + text: input.text, + synthetic: true, + } satisfies MessageV2.TextPart) + } + + export async function ask(input: { + sessionID: string + messages: MessageV2.WithParts[] + abort: AbortSignal + }): Promise<"continue" | "break"> { + if (input.abort.aborted) return "break" + + const latest = input.messages.slice().reverse() + const assistant = latest.find(isAssistant) + if (!assistant) return "break" + + const plan = toText(assistant) + if (!plan) return "break" + + const user = latest.find(isUser)?.info + if (!user?.model) return "break" + + const answers = await Question.ask({ + sessionID: input.sessionID, + questions: [ + { + question: "Ready to implement?", + header: "Implement", + custom: true, + options: [ + { + label: "Start new session", + description: "Implement in a fresh session with a clean context", + }, + { + label: "Continue here", + description: "Implement the plan in this session", + }, + ], + }, + ], + }).catch((error) => { + if (error instanceof Question.RejectedError) return undefined + throw error + }) + if (!answers) return "break" + + const answer = answers[0]?.[0]?.trim() + if (!answer) return "break" + + if (answer === "Start new session") { + const next = await Session.create({}) + await inject({ + sessionID: next.id, + agent: "code", + model: user.model, + text: `Implement the following plan:\n\n${plan}`, + }) + await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id }) + void import("@/session/prompt") + .then((item) => item.SessionPrompt.loop({ sessionID: next.id })) + .catch((error) => { + log.error("failed to start follow-up session", { sessionID: next.id, error }) + }) + return "break" + } + + if (answer === "Continue here") { + await inject({ + sessionID: input.sessionID, + agent: "code", + model: user.model, + text: "Implement the plan above.", + }) + return "continue" + } + + await inject({ + sessionID: input.sessionID, + agent: "plan", + model: user.model, + text: answer, + }) + return "continue" + } +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4948fb5a29..ccc02f4299 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,6 +45,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { PlanFollowup } from "@/kilocode/plan-followup" // kilocode_change // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -336,6 +337,16 @@ export namespace SessionPrompt { !["tool-calls", "unknown"].includes(lastAssistant.finish) && lastUser.id < lastAssistant.id ) { + // kilocode_change start - ask follow-up after plan agent completes + if ( + lastUser.agent === "plan" && + !abort.aborted && + ["cli", "vscode"].includes(Flag.KILO_CLIENT) + ) { + const action = await PlanFollowup.ask({ sessionID, messages: msgs, abort }) + if (action === "continue") continue + } + // kilocode_change end log.info("exiting loop", { sessionID }) break } diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts new file mode 100644 index 0000000000..57cf32723e --- /dev/null +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, spyOn, test } from "bun:test" +import { Bus } from "../../src/bus" +import { TuiEvent } from "../../src/cli/cmd/tui/event" +import { Identifier } from "../../src/id/id" +import { PlanFollowup } from "../../src/kilocode/plan-followup" +import { Instance } from "../../src/project/instance" +import { Question } from "../../src/question" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionPrompt } from "../../src/session/prompt" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +const model = { + providerID: "openai", + modelID: "gpt-4", +} + +async function seed(input: { text: string }) { + const session = await Session.create({}) + const user = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + time: { + created: Date.now(), + }, + agent: "plan", + model, + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: user.id, + sessionID: session.id, + type: "text", + text: "Create a plan", + }) + + const assistant: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID: session.id, + time: { + created: Date.now(), + }, + parentID: user.id, + modelID: model.modelID, + providerID: model.providerID, + mode: "plan", + agent: "plan", + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { + read: 0, + write: 0, + }, + }, + finish: "end_turn", + } + await Session.updateMessage(assistant) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistant.id, + sessionID: session.id, + type: "text", + text: input.text, + }) + + const messages = await Session.messages({ sessionID: session.id }) + return { + sessionID: session.id, + messages, + } +} + +async function latestUser(sessionID: string) { + const messages = await Session.messages({ sessionID }) + return messages.slice().reverse().find((item) => item.info.role === "user") +} + +async function sessions() { + return Array.fromAsync(Session.list()) +} + +describe("plan follow-up", () => { + test("ask - returns break when dismissed", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ text: "1. Step one\n2. Step two" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const list = await Question.list() + expect(list).toHaveLength(1) + await Question.reject(list[0].id) + + await expect(pending).resolves.toBe("break") + }, + }) + }) + + test("ask - returns continue and creates code message on Continue here", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ text: "1. Build\n2. Test" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const list = await Question.list() + await Question.reply({ + requestID: list[0].id, + answers: [["Continue here"]], + }) + + await expect(pending).resolves.toBe("continue") + + const user = await latestUser(seeded.sessionID) + expect(user?.info.role).toBe("user") + if (!user || user.info.role !== "user") return + expect(user.info.agent).toBe("code") + + const part = user.parts.find((item) => item.type === "text") + expect(part?.type).toBe("text") + if (!part || part.type !== "text") return + expect(part.text).toBe("Implement the plan above.") + expect(part.synthetic).toBe(true) + }, + }) + }) + + test("ask - returns continue and creates plan message for custom text", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ text: "1. Build\n2. Test" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + await Question.reply({ + requestID: (await Question.list())[0].id, + answers: [["Add rollback support too"]], + }) + + await expect(pending).resolves.toBe("continue") + + const user = await latestUser(seeded.sessionID) + expect(user?.info.role).toBe("user") + if (!user || user.info.role !== "user") return + expect(user.info.agent).toBe("plan") + + const part = user.parts.find((item) => item.type === "text") + expect(part?.type).toBe("text") + if (!part || part.type !== "text") return + expect(part.text).toBe("Add rollback support too") + expect(part.synthetic).toBe(true) + }, + }) + }) + + test("ask - creates a new session on Start new session", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({ + info: { + id: "msg_test", + role: "assistant", + sessionID: "ses_test", + time: { + created: Date.now(), + }, + parentID: "msg_parent", + modelID: "test", + providerID: "test", + mode: "code", + agent: "code", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { + read: 0, + write: 0, + }, + }, + }, + parts: [], + }) + using _ = { + [Symbol.dispose]() { + loop.mockRestore() + }, + } + const seeded = await seed({ text: "1. Add API\n2. Add tests" }) + const before = await sessions() + const created = [] as string[] + const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => { + created.push(event.properties.sessionID) + }) + + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + await Question.reply({ + requestID: (await Question.list())[0].id, + answers: [["Start new session"]], + }) + + await expect(pending).resolves.toBe("break") + unsub() + + const after = await sessions() + const prev = new Set(before.map((item) => item.id)) + const added = after.filter((item) => !prev.has(item.id)) + expect(added).toHaveLength(1) + expect(created).toHaveLength(1) + expect(loop).toHaveBeenCalledTimes(1) + + const newSessionID = created[0] + expect(added[0].id).toBe(newSessionID) + const messages = await Session.messages({ sessionID: newSessionID }) + const user = messages.find((item) => item.info.role === "user") + expect(user?.info.role).toBe("user") + if (!user || user.info.role !== "user") throw new Error("expected seeded user message") + expect(user.info.agent).toBe("code") + + const part = user.parts.find((item) => item.type === "text") + expect(part?.type).toBe("text") + if (!part || part.type !== "text") throw new Error("expected text part") + expect(part.text).toContain("Implement the following plan:") + expect(part.text).toContain("1. Add API\n2. Add tests") + expect(part.synthetic).toBe(true) + + SessionPrompt.cancel(newSessionID) + }, + }) + }) + + test("ask - returns break when assistant text is empty", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ text: " " }) + const result = await PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + expect(result).toBe("break") + expect(await Question.list()).toHaveLength(0) + }, + }) + }) + + test("ask - returns break when aborted", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const abort = new AbortController() + abort.abort() + + const result = await PlanFollowup.ask({ + sessionID: "ses_test", + messages: [], + abort: abort.signal, + }) + + expect(result).toBe("break") + }, + }) + }) + + test("ask - returns break for blank custom answer", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ text: "1. Build\n2. Test" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + await Question.reply({ + requestID: (await Question.list())[0].id, + answers: [[" "]], + }) + + await expect(pending).resolves.toBe("break") + expect((await Session.messages({ sessionID: seeded.sessionID })).length).toBe(2) + }, + }) + }) +}) From a5e6ceb76ebf9bda4c706b91c60c49867f789854 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 19 Feb 2026 11:42:35 +0200 Subject: [PATCH 04/73] feat: When starting a new session after planning, include a compact summary of what was explored and which files were read so the AI doesn't have to re-search the codebase. --- .../opencode/src/kilocode/plan-followup.ts | 60 +++- .../test/kilocode/plan-followup.test.ts | 292 +++++++++++++++++- 2 files changed, 347 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 48079b324e..0888bbcad6 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -22,6 +22,63 @@ function toText(item: MessageV2.WithParts): string { .trim() } +const CONTEXT_LIMIT = 10_000 + +function isTool(part: MessageV2.Part): part is MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } { + return part.type === "tool" && part.state.status === "completed" +} + +export function extractContext(messages: MessageV2.WithParts[]): string { + const tasks = [] as string[] + const files = [] as string[] + const seen = new Set() + + for (const msg of messages) { + for (const part of msg.parts) { + if (!isTool(part)) continue + if (part.tool === "task" && part.state.output.trim()) { + const match = part.state.output.match(/([\s\S]*?)<\/task_result>/) + tasks.push(match ? match[1].trim() : part.state.output.trim()) + } + if (part.tool === "read" && part.state.input.filePath) { + const path = part.state.input.filePath as string + const offset = part.state.input.offset as number | undefined + const limit = part.state.input.limit as number | undefined + const range = + offset !== undefined && limit !== undefined + ? ` (lines ${offset}-${offset + limit - 1})` + : offset !== undefined + ? ` (from line ${offset})` + : limit !== undefined + ? ` (first ${limit} lines)` + : "" + const entry = `- ${path}${range}` + if (!seen.has(entry)) { + seen.add(entry) + files.push(entry) + } + } + } + } + + if (!tasks.length && !files.length) return "" + + const sections = [] as string[] + if (tasks.length) { + sections.push("### Explored\n\n" + tasks.join("\n\n")) + } + if (files.length) { + sections.push("### Files read\n\n" + files.join("\n")) + } + + const full = "\n\n## Context from planning research\n\n" + sections.join("\n\n") + if (full.length <= CONTEXT_LIMIT) return full + const marker = "\n\n[context truncated]" + const cut = full.slice(0, CONTEXT_LIMIT - marker.length) + const last = cut.lastIndexOf("\n") + return (last > 0 ? cut.slice(0, last) : cut) + marker +} + export namespace PlanFollowup { const log = Log.create({ service: "plan.followup" }) @@ -93,12 +150,13 @@ export namespace PlanFollowup { if (!answer) return "break" if (answer === "Start new session") { + const context = extractContext(input.messages) const next = await Session.create({}) await inject({ sessionID: next.id, agent: "code", model: user.model, - text: `Implement the following plan:\n\n${plan}`, + text: `Implement the following plan:\n\n${plan}\n\nContext:\n\n${context}`, }) await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id }) void import("@/session/prompt") diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 57cf32723e..08f40d4d64 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -2,7 +2,7 @@ import { describe, expect, spyOn, test } from "bun:test" import { Bus } from "../../src/bus" import { TuiEvent } from "../../src/cli/cmd/tui/event" import { Identifier } from "../../src/id/id" -import { PlanFollowup } from "../../src/kilocode/plan-followup" +import { extractContext, PlanFollowup } from "../../src/kilocode/plan-followup" import { Instance } from "../../src/project/instance" import { Question } from "../../src/question" import { Session } from "../../src/session" @@ -18,7 +18,10 @@ const model = { modelID: "gpt-4", } -async function seed(input: { text: string }) { +async function seed(input: { + text: string + tools?: Array<{ tool: string; input: Record; output: string }> +}) { const session = await Session.create({}) const user = await Session.updateMessage({ id: Identifier.ascending("message"), @@ -76,6 +79,25 @@ async function seed(input: { text: string }) { text: input.text, }) + for (const t of input.tools ?? []) { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistant.id, + sessionID: session.id, + type: "tool", + callID: Identifier.ascending("tool"), + tool: t.tool, + state: { + status: "completed", + input: t.input, + output: t.output, + title: t.tool, + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + } satisfies MessageV2.ToolPart) + } + const messages = await Session.messages({ sessionID: session.id }) return { sessionID: session.id, @@ -85,7 +107,10 @@ async function seed(input: { text: string }) { async function latestUser(sessionID: string) { const messages = await Session.messages({ sessionID }) - return messages.slice().reverse().find((item) => item.info.role === "user") + return messages + .slice() + .reverse() + .find((item) => item.info.role === "user") } async function sessions() { @@ -222,7 +247,21 @@ describe("plan follow-up", () => { loop.mockRestore() }, } - const seeded = await seed({ text: "1. Add API\n2. Add tests" }) + const seeded = await seed({ + text: "1. Add API\n2. Add tests", + tools: [ + { + tool: "task", + input: { prompt: "explore the codebase", subagent_type: "explore" }, + output: "Found src/api.ts with REST endpoints and src/db.ts with database layer", + }, + { + tool: "read", + input: { filePath: "/project/src/api.ts", offset: 1, limit: 50 }, + output: "file content here", + }, + ], + }) const before = await sessions() const created = [] as string[] const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => { @@ -263,6 +302,9 @@ describe("plan follow-up", () => { if (!part || part.type !== "text") throw new Error("expected text part") expect(part.text).toContain("Implement the following plan:") expect(part.text).toContain("1. Add API\n2. Add tests") + expect(part.text).toContain("## Context from planning research") + expect(part.text).toContain("Found src/api.ts with REST endpoints") + expect(part.text).toContain("- /project/src/api.ts (lines 1-50)") expect(part.synthetic).toBe(true) SessionPrompt.cancel(newSessionID) @@ -329,4 +371,246 @@ describe("plan follow-up", () => { }, }) }) + + test("extractContext - returns empty string with no tool results", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ text: "1. Build\n2. Test" }) + expect(extractContext(seeded.messages)).toBe("") + }, + }) + }) + + test("extractContext - includes task outputs and read paths", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "task", + input: { prompt: "explore", subagent_type: "explore" }, + output: "The auth module lives in src/auth/", + }, + { + tool: "read", + input: { filePath: "/project/src/auth/login.ts" }, + output: "file content", + }, + { + tool: "read", + input: { filePath: "/project/src/auth/session.ts", offset: 10, limit: 20 }, + output: "file content", + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context).toContain("## Context from planning research") + expect(context).toContain("### Explored") + expect(context).toContain("The auth module lives in src/auth/") + expect(context).toContain("### Files read") + expect(context).toContain("- /project/src/auth/login.ts") + expect(context).toContain("- /project/src/auth/session.ts (lines 10-29)") + }, + }) + }) + + test("extractContext - filters empty and whitespace-only task outputs", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "task", + input: { prompt: "explore", subagent_type: "explore" }, + output: "", + }, + { + tool: "task", + input: { prompt: "explore more", subagent_type: "explore" }, + output: " ", + }, + ], + }) + expect(extractContext(seeded.messages)).toBe("") + }, + }) + }) + + test("extractContext - deduplicates file reads", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "read", + input: { filePath: "/project/src/api.ts" }, + output: "content", + }, + { + tool: "read", + input: { filePath: "/project/src/api.ts" }, + output: "content again", + }, + { + tool: "read", + input: { filePath: "/project/src/api.ts", offset: 10, limit: 20 }, + output: "different range", + }, + ], + }) + const context = extractContext(seeded.messages) + const matches = context.match(/- \/project\/src\/api\.ts\b/g) + expect(matches).toHaveLength(2) + }, + }) + }) + + test("extractContext - includes only explored section when no reads", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "task", + input: { prompt: "explore", subagent_type: "explore" }, + output: "Found important patterns", + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context).toContain("### Explored") + expect(context).toContain("Found important patterns") + expect(context).not.toContain("### Files read") + }, + }) + }) + + test("extractContext - includes only files section when no tasks", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "read", + input: { filePath: "/project/src/index.ts" }, + output: "content", + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context).not.toContain("### Explored") + expect(context).toContain("### Files read") + expect(context).toContain("- /project/src/index.ts") + }, + }) + }) + + test("extractContext - ignores non-task non-read tools", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "grep", + input: { pattern: "foo", path: "/project" }, + output: "Found 5 matches", + }, + { + tool: "bash", + input: { command: "ls" }, + output: "file1.ts\nfile2.ts", + }, + ], + }) + expect(extractContext(seeded.messages)).toBe("") + }, + }) + }) + + test("extractContext - strips task_id prefix and task_result tags", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "task", + input: { prompt: "explore", subagent_type: "explore" }, + output: + "task_id: ses_abc123 (for resuming)\n\n\nThe auth module is in src/auth/\n", + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context).toContain("The auth module is in src/auth/") + expect(context).not.toContain("task_id:") + expect(context).not.toContain("") + }, + }) + }) + + test("extractContext - shows first N lines for limit-only reads", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "read", + input: { filePath: "/project/src/config.ts", limit: 50 }, + output: "content", + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context).toContain("- /project/src/config.ts (first 50 lines)") + }, + }) + }) + + test("extractContext - truncates at 10000 chars", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "task", + input: { prompt: "explore", subagent_type: "explore" }, + output: "x".repeat(12_000), + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context.length).toBeLessThanOrEqual(10_000) + expect(context).toEndWith("[context truncated]") + }, + }) + }) }) From 52ef5c926fd03438a657c081c176478a8f1de4c9 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 19 Feb 2026 11:48:21 +0200 Subject: [PATCH 05/73] fix: Avoid empty context --- packages/opencode/src/kilocode/plan-followup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 0888bbcad6..5662b9ac62 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -156,7 +156,7 @@ export namespace PlanFollowup { sessionID: next.id, agent: "code", model: user.model, - text: `Implement the following plan:\n\n${plan}\n\nContext:\n\n${context}`, + text: `Implement the following plan:\n\n${plan}${context ? `\n\nContext:\n\n${context}` : ""}`, }) await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id }) void import("@/session/prompt") From 294846f70691b5735cb7af68d2101a4d4b4452c5 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 19 Feb 2026 11:59:57 +0200 Subject: [PATCH 06/73] fix: Reject pending plan followup question on abort signal --- .../opencode/src/kilocode/plan-followup.ts | 21 ++++++++++++--- .../test/kilocode/plan-followup.test.ts | 26 ++++++++++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 5662b9ac62..ec3031af51 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -121,7 +121,7 @@ export namespace PlanFollowup { const user = latest.find(isUser)?.info if (!user?.model) return "break" - const answers = await Question.ask({ + const questionPromise = Question.ask({ sessionID: input.sessionID, questions: [ { @@ -140,10 +140,23 @@ export namespace PlanFollowup { ], }, ], - }).catch((error) => { - if (error instanceof Question.RejectedError) return undefined - throw error }) + + const listener = () => + Question.list().then((qs) => { + const match = qs.find((q) => q.sessionID === input.sessionID) + if (match) Question.reject(match.id) + }) + input.abort.addEventListener("abort", listener, { once: true }) + + const answers = await questionPromise + .catch((error) => { + if (error instanceof Question.RejectedError) return undefined + throw error + }) + .finally(() => { + input.abort.removeEventListener("abort", listener) + }) if (!answers) return "break" const answer = answers[0]?.[0]?.trim() diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 08f40d4d64..6001b63330 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -330,7 +330,7 @@ describe("plan follow-up", () => { }) }) - test("ask - returns break when aborted", async () => { + test("ask - returns break when already aborted", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -349,6 +349,30 @@ describe("plan follow-up", () => { }) }) + test("ask - returns break when aborted while question is pending", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const abort = new AbortController() + const seeded = await seed({ text: "1. Step one\n2. Step two" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: abort.signal, + }) + + const list = await Question.list() + expect(list).toHaveLength(1) + + abort.abort() + + await expect(pending).resolves.toBe("break") + expect(await Question.list()).toHaveLength(0) + }, + }) + }) + test("ask - returns break for blank custom answer", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ From b2ff4b088aad4dd521a9ba838a85f57e96fa3962 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 19 Feb 2026 13:20:00 +0200 Subject: [PATCH 07/73] refactor: split ask(), extract constants, remove redundant context label --- .../opencode/src/kilocode/plan-followup.ts | 93 ++++++++++--------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index ec3031af51..9846a7c0cf 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -6,14 +6,6 @@ import { Session } from "@/session" import { MessageV2 } from "@/session/message-v2" import { Log } from "@/util/log" -function isUser(item: MessageV2.WithParts): item is MessageV2.WithParts & { info: MessageV2.User } { - return item.info.role === "user" -} - -function isAssistant(item: MessageV2.WithParts): item is MessageV2.WithParts & { info: MessageV2.Assistant } { - return item.info.role === "assistant" -} - function toText(item: MessageV2.WithParts): string { return item.parts .filter((part): part is MessageV2.TextPart => part.type === "text") @@ -82,6 +74,9 @@ export function extractContext(messages: MessageV2.WithParts[]): string { export namespace PlanFollowup { const log = Log.create({ service: "plan.followup" }) + export const ANSWER_NEW_SESSION = "Start new session" + export const ANSWER_CONTINUE = "Continue here" + async function inject(input: { sessionID: string; agent: string; model: MessageV2.User["model"]; text: string }) { const msg: MessageV2.User = { id: Identifier.ascending("message"), @@ -104,24 +99,8 @@ export namespace PlanFollowup { } satisfies MessageV2.TextPart) } - export async function ask(input: { - sessionID: string - messages: MessageV2.WithParts[] - abort: AbortSignal - }): Promise<"continue" | "break"> { - if (input.abort.aborted) return "break" - - const latest = input.messages.slice().reverse() - const assistant = latest.find(isAssistant) - if (!assistant) return "break" - - const plan = toText(assistant) - if (!plan) return "break" - - const user = latest.find(isUser)?.info - if (!user?.model) return "break" - - const questionPromise = Question.ask({ + function prompt(input: { sessionID: string; abort: AbortSignal }) { + const promise = Question.ask({ sessionID: input.sessionID, questions: [ { @@ -130,11 +109,11 @@ export namespace PlanFollowup { custom: true, options: [ { - label: "Start new session", + label: ANSWER_NEW_SESSION, description: "Implement in a fresh session with a clean context", }, { - label: "Continue here", + label: ANSWER_CONTINUE, description: "Implement the plan in this session", }, ], @@ -149,7 +128,7 @@ export namespace PlanFollowup { }) input.abort.addEventListener("abort", listener, { once: true }) - const answers = await questionPromise + return promise .catch((error) => { if (error instanceof Question.RejectedError) return undefined throw error @@ -157,30 +136,54 @@ export namespace PlanFollowup { .finally(() => { input.abort.removeEventListener("abort", listener) }) + } + + async function startNew(input: { plan: string; messages: MessageV2.WithParts[]; model: MessageV2.User["model"] }) { + const context = extractContext(input.messages) + const next = await Session.create({}) + await inject({ + sessionID: next.id, + agent: "code", + model: input.model, + text: `Implement the following plan:\n\n${input.plan}${context ? `\n${context}` : ""}`, + }) + await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id }) + void import("@/session/prompt") + .then((item) => item.SessionPrompt.loop({ sessionID: next.id })) + .catch((error) => { + log.error("failed to start follow-up session", { sessionID: next.id, error }) + }) + } + + export async function ask(input: { + sessionID: string + messages: MessageV2.WithParts[] + abort: AbortSignal + }): Promise<"continue" | "break"> { + if (input.abort.aborted) return "break" + + const latest = input.messages.slice().reverse() + const assistant = latest.find((msg) => msg.info.role === "assistant") + if (!assistant) return "break" + + const plan = toText(assistant) + if (!plan) return "break" + + const user = latest.find((msg) => msg.info.role === "user")?.info + if (!user || user.role !== "user" || !user.model) return "break" + + const answers = await prompt({ sessionID: input.sessionID, abort: input.abort }) if (!answers) return "break" const answer = answers[0]?.[0]?.trim() if (!answer) return "break" - if (answer === "Start new session") { - const context = extractContext(input.messages) - const next = await Session.create({}) - await inject({ - sessionID: next.id, - agent: "code", - model: user.model, - text: `Implement the following plan:\n\n${plan}${context ? `\n\nContext:\n\n${context}` : ""}`, - }) - await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id }) - void import("@/session/prompt") - .then((item) => item.SessionPrompt.loop({ sessionID: next.id })) - .catch((error) => { - log.error("failed to start follow-up session", { sessionID: next.id, error }) - }) + if (answer === ANSWER_NEW_SESSION) { + await startNew({ plan, messages: input.messages, model: user.model }) return "break" } - if (answer === "Continue here") { + if (answer === ANSWER_CONTINUE) { await inject({ sessionID: input.sessionID, agent: "code", From 9a9d675ada52e7238ae7a51389a97541ad63b8ae Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 19 Feb 2026 13:20:04 +0200 Subject: [PATCH 08/73] test: add withInstance helper, use answer constants --- .../test/kilocode/plan-followup.test.ts | 907 ++++++++---------- 1 file changed, 411 insertions(+), 496 deletions(-) diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 6001b63330..c4913136db 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -18,6 +18,11 @@ const model = { modelID: "gpt-4", } +async function withInstance(fn: () => Promise) { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ directory: tmp.path, fn }) +} + async function seed(input: { text: string tools?: Array<{ tool: string; input: Record; output: string }> @@ -118,523 +123,433 @@ async function sessions() { } describe("plan follow-up", () => { - test("ask - returns break when dismissed", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ text: "1. Step one\n2. Step two" }) - const pending = PlanFollowup.ask({ - sessionID: seeded.sessionID, - messages: seeded.messages, - abort: AbortSignal.any([]), - }) + test("ask - returns break when dismissed", () => + withInstance(async () => { + const seeded = await seed({ text: "1. Step one\n2. Step two" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) - const list = await Question.list() - expect(list).toHaveLength(1) - await Question.reject(list[0].id) + const list = await Question.list() + expect(list).toHaveLength(1) + await Question.reject(list[0].id) - await expect(pending).resolves.toBe("break") - }, - }) - }) + await expect(pending).resolves.toBe("break") + })) - test("ask - returns continue and creates code message on Continue here", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ text: "1. Build\n2. Test" }) - const pending = PlanFollowup.ask({ - sessionID: seeded.sessionID, - messages: seeded.messages, - abort: AbortSignal.any([]), - }) + test("ask - returns continue and creates code message on Continue here", () => + withInstance(async () => { + const seeded = await seed({ text: "1. Build\n2. Test" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) - const list = await Question.list() - await Question.reply({ - requestID: list[0].id, - answers: [["Continue here"]], - }) + const list = await Question.list() + await Question.reply({ + requestID: list[0].id, + answers: [[PlanFollowup.ANSWER_CONTINUE]], + }) - await expect(pending).resolves.toBe("continue") + await expect(pending).resolves.toBe("continue") - const user = await latestUser(seeded.sessionID) - expect(user?.info.role).toBe("user") - if (!user || user.info.role !== "user") return - expect(user.info.agent).toBe("code") + const user = await latestUser(seeded.sessionID) + expect(user?.info.role).toBe("user") + if (!user || user.info.role !== "user") return + expect(user.info.agent).toBe("code") - const part = user.parts.find((item) => item.type === "text") - expect(part?.type).toBe("text") - if (!part || part.type !== "text") return - expect(part.text).toBe("Implement the plan above.") - expect(part.synthetic).toBe(true) - }, - }) - }) + const part = user.parts.find((item) => item.type === "text") + expect(part?.type).toBe("text") + if (!part || part.type !== "text") return + expect(part.text).toBe("Implement the plan above.") + expect(part.synthetic).toBe(true) + })) - test("ask - returns continue and creates plan message for custom text", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ text: "1. Build\n2. Test" }) - const pending = PlanFollowup.ask({ - sessionID: seeded.sessionID, - messages: seeded.messages, - abort: AbortSignal.any([]), - }) + test("ask - returns continue and creates plan message for custom text", () => + withInstance(async () => { + const seeded = await seed({ text: "1. Build\n2. Test" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) - await Question.reply({ - requestID: (await Question.list())[0].id, - answers: [["Add rollback support too"]], - }) + await Question.reply({ + requestID: (await Question.list())[0].id, + answers: [["Add rollback support too"]], + }) - await expect(pending).resolves.toBe("continue") + await expect(pending).resolves.toBe("continue") - const user = await latestUser(seeded.sessionID) - expect(user?.info.role).toBe("user") - if (!user || user.info.role !== "user") return - expect(user.info.agent).toBe("plan") + const user = await latestUser(seeded.sessionID) + expect(user?.info.role).toBe("user") + if (!user || user.info.role !== "user") return + expect(user.info.agent).toBe("plan") - const part = user.parts.find((item) => item.type === "text") - expect(part?.type).toBe("text") - if (!part || part.type !== "text") return - expect(part.text).toBe("Add rollback support too") - expect(part.synthetic).toBe(true) - }, - }) - }) + const part = user.parts.find((item) => item.type === "text") + expect(part?.type).toBe("text") + if (!part || part.type !== "text") return + expect(part.text).toBe("Add rollback support too") + expect(part.synthetic).toBe(true) + })) - test("ask - creates a new session on Start new session", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({ - info: { - id: "msg_test", - role: "assistant", - sessionID: "ses_test", - time: { - created: Date.now(), - }, - parentID: "msg_parent", - modelID: "test", - providerID: "test", - mode: "code", - agent: "code", - path: { - cwd: tmp.path, - root: tmp.path, - }, - cost: 0, - tokens: { - total: 0, - input: 0, - output: 0, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - }, - parts: [], - }) - using _ = { - [Symbol.dispose]() { - loop.mockRestore() - }, - } - const seeded = await seed({ - text: "1. Add API\n2. Add tests", - tools: [ - { - tool: "task", - input: { prompt: "explore the codebase", subagent_type: "explore" }, - output: "Found src/api.ts with REST endpoints and src/db.ts with database layer", - }, - { - tool: "read", - input: { filePath: "/project/src/api.ts", offset: 1, limit: 50 }, - output: "file content here", - }, - ], - }) - const before = await sessions() - const created = [] as string[] - const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => { - created.push(event.properties.sessionID) - }) - - const pending = PlanFollowup.ask({ - sessionID: seeded.sessionID, - messages: seeded.messages, - abort: AbortSignal.any([]), - }) - - await Question.reply({ - requestID: (await Question.list())[0].id, - answers: [["Start new session"]], - }) - - await expect(pending).resolves.toBe("break") - unsub() - - const after = await sessions() - const prev = new Set(before.map((item) => item.id)) - const added = after.filter((item) => !prev.has(item.id)) - expect(added).toHaveLength(1) - expect(created).toHaveLength(1) - expect(loop).toHaveBeenCalledTimes(1) - - const newSessionID = created[0] - expect(added[0].id).toBe(newSessionID) - const messages = await Session.messages({ sessionID: newSessionID }) - const user = messages.find((item) => item.info.role === "user") - expect(user?.info.role).toBe("user") - if (!user || user.info.role !== "user") throw new Error("expected seeded user message") - expect(user.info.agent).toBe("code") - - const part = user.parts.find((item) => item.type === "text") - expect(part?.type).toBe("text") - if (!part || part.type !== "text") throw new Error("expected text part") - expect(part.text).toContain("Implement the following plan:") - expect(part.text).toContain("1. Add API\n2. Add tests") - expect(part.text).toContain("## Context from planning research") - expect(part.text).toContain("Found src/api.ts with REST endpoints") - expect(part.text).toContain("- /project/src/api.ts (lines 1-50)") - expect(part.synthetic).toBe(true) - - SessionPrompt.cancel(newSessionID) - }, - }) - }) - - test("ask - returns break when assistant text is empty", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ text: " " }) - const result = await PlanFollowup.ask({ - sessionID: seeded.sessionID, - messages: seeded.messages, - abort: AbortSignal.any([]), - }) - - expect(result).toBe("break") - expect(await Question.list()).toHaveLength(0) - }, - }) - }) - - test("ask - returns break when already aborted", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const abort = new AbortController() - abort.abort() - - const result = await PlanFollowup.ask({ + test("ask - creates a new session on Start new session", () => + withInstance(async () => { + const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({ + info: { + id: "msg_test", + role: "assistant", sessionID: "ses_test", - messages: [], - abort: abort.signal, - }) - - expect(result).toBe("break") - }, - }) - }) - - test("ask - returns break when aborted while question is pending", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const abort = new AbortController() - const seeded = await seed({ text: "1. Step one\n2. Step two" }) - const pending = PlanFollowup.ask({ - sessionID: seeded.sessionID, - messages: seeded.messages, - abort: abort.signal, - }) - - const list = await Question.list() - expect(list).toHaveLength(1) - - abort.abort() - - await expect(pending).resolves.toBe("break") - expect(await Question.list()).toHaveLength(0) - }, - }) - }) - - test("ask - returns break for blank custom answer", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ text: "1. Build\n2. Test" }) - const pending = PlanFollowup.ask({ - sessionID: seeded.sessionID, - messages: seeded.messages, - abort: AbortSignal.any([]), - }) - - await Question.reply({ - requestID: (await Question.list())[0].id, - answers: [[" "]], - }) - - await expect(pending).resolves.toBe("break") - expect((await Session.messages({ sessionID: seeded.sessionID })).length).toBe(2) - }, - }) - }) - - test("extractContext - returns empty string with no tool results", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ text: "1. Build\n2. Test" }) - expect(extractContext(seeded.messages)).toBe("") - }, - }) - }) - - test("extractContext - includes task outputs and read paths", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "task", - input: { prompt: "explore", subagent_type: "explore" }, - output: "The auth module lives in src/auth/", + time: { + created: Date.now(), + }, + parentID: "msg_parent", + modelID: "test", + providerID: "test", + mode: "code", + agent: "code", + path: { + cwd: "/tmp", + root: "/tmp", + }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { + read: 0, + write: 0, }, - { - tool: "read", - input: { filePath: "/project/src/auth/login.ts" }, - output: "file content", - }, - { - tool: "read", - input: { filePath: "/project/src/auth/session.ts", offset: 10, limit: 20 }, - output: "file content", - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context).toContain("## Context from planning research") - expect(context).toContain("### Explored") - expect(context).toContain("The auth module lives in src/auth/") - expect(context).toContain("### Files read") - expect(context).toContain("- /project/src/auth/login.ts") - expect(context).toContain("- /project/src/auth/session.ts (lines 10-29)") - }, - }) - }) + }, + }, + parts: [], + }) + using _ = { + [Symbol.dispose]() { + loop.mockRestore() + }, + } + const seeded = await seed({ + text: "1. Add API\n2. Add tests", + tools: [ + { + tool: "task", + input: { prompt: "explore the codebase", subagent_type: "explore" }, + output: "Found src/api.ts with REST endpoints and src/db.ts with database layer", + }, + { + tool: "read", + input: { filePath: "/project/src/api.ts", offset: 1, limit: 50 }, + output: "file content here", + }, + ], + }) + const before = await sessions() + const created = [] as string[] + const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => { + created.push(event.properties.sessionID) + }) - test("extractContext - filters empty and whitespace-only task outputs", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "task", - input: { prompt: "explore", subagent_type: "explore" }, - output: "", - }, - { - tool: "task", - input: { prompt: "explore more", subagent_type: "explore" }, - output: " ", - }, - ], - }) - expect(extractContext(seeded.messages)).toBe("") - }, - }) - }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) - test("extractContext - deduplicates file reads", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "read", - input: { filePath: "/project/src/api.ts" }, - output: "content", - }, - { - tool: "read", - input: { filePath: "/project/src/api.ts" }, - output: "content again", - }, - { - tool: "read", - input: { filePath: "/project/src/api.ts", offset: 10, limit: 20 }, - output: "different range", - }, - ], - }) - const context = extractContext(seeded.messages) - const matches = context.match(/- \/project\/src\/api\.ts\b/g) - expect(matches).toHaveLength(2) - }, - }) - }) + await Question.reply({ + requestID: (await Question.list())[0].id, + answers: [[PlanFollowup.ANSWER_NEW_SESSION]], + }) - test("extractContext - includes only explored section when no reads", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "task", - input: { prompt: "explore", subagent_type: "explore" }, - output: "Found important patterns", - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context).toContain("### Explored") - expect(context).toContain("Found important patterns") - expect(context).not.toContain("### Files read") - }, - }) - }) + await expect(pending).resolves.toBe("break") + unsub() - test("extractContext - includes only files section when no tasks", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "read", - input: { filePath: "/project/src/index.ts" }, - output: "content", - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context).not.toContain("### Explored") - expect(context).toContain("### Files read") - expect(context).toContain("- /project/src/index.ts") - }, - }) - }) + const after = await sessions() + const prev = new Set(before.map((item) => item.id)) + const added = after.filter((item) => !prev.has(item.id)) + expect(added).toHaveLength(1) + expect(created).toHaveLength(1) + expect(loop).toHaveBeenCalledTimes(1) - test("extractContext - ignores non-task non-read tools", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "grep", - input: { pattern: "foo", path: "/project" }, - output: "Found 5 matches", - }, - { - tool: "bash", - input: { command: "ls" }, - output: "file1.ts\nfile2.ts", - }, - ], - }) - expect(extractContext(seeded.messages)).toBe("") - }, - }) - }) + const newSessionID = created[0] + expect(added[0].id).toBe(newSessionID) + const messages = await Session.messages({ sessionID: newSessionID }) + const user = messages.find((item) => item.info.role === "user") + expect(user?.info.role).toBe("user") + if (!user || user.info.role !== "user") throw new Error("expected seeded user message") + expect(user.info.agent).toBe("code") - test("extractContext - strips task_id prefix and task_result tags", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "task", - input: { prompt: "explore", subagent_type: "explore" }, - output: - "task_id: ses_abc123 (for resuming)\n\n\nThe auth module is in src/auth/\n", - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context).toContain("The auth module is in src/auth/") - expect(context).not.toContain("task_id:") - expect(context).not.toContain("") - }, - }) - }) + const part = user.parts.find((item) => item.type === "text") + expect(part?.type).toBe("text") + if (!part || part.type !== "text") throw new Error("expected text part") + expect(part.text).toContain("Implement the following plan:") + expect(part.text).toContain("1. Add API\n2. Add tests") + expect(part.text).toContain("## Context from planning research") + expect(part.text).toContain("Found src/api.ts with REST endpoints") + expect(part.text).toContain("- /project/src/api.ts (lines 1-50)") + expect(part.synthetic).toBe(true) - test("extractContext - shows first N lines for limit-only reads", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "read", - input: { filePath: "/project/src/config.ts", limit: 50 }, - output: "content", - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context).toContain("- /project/src/config.ts (first 50 lines)") - }, - }) - }) + SessionPrompt.cancel(newSessionID) + })) - test("extractContext - truncates at 10000 chars", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "task", - input: { prompt: "explore", subagent_type: "explore" }, - output: "x".repeat(12_000), - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context.length).toBeLessThanOrEqual(10_000) - expect(context).toEndWith("[context truncated]") - }, - }) - }) + test("ask - returns break when assistant text is empty", () => + withInstance(async () => { + const seeded = await seed({ text: " " }) + const result = await PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + expect(result).toBe("break") + expect(await Question.list()).toHaveLength(0) + })) + + test("ask - returns break when already aborted", () => + withInstance(async () => { + const abort = new AbortController() + abort.abort() + + const result = await PlanFollowup.ask({ + sessionID: "ses_test", + messages: [], + abort: abort.signal, + }) + + expect(result).toBe("break") + })) + + test("ask - returns break when aborted while question is pending", () => + withInstance(async () => { + const abort = new AbortController() + const seeded = await seed({ text: "1. Step one\n2. Step two" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: abort.signal, + }) + + const list = await Question.list() + expect(list).toHaveLength(1) + + abort.abort() + + await expect(pending).resolves.toBe("break") + expect(await Question.list()).toHaveLength(0) + })) + + test("ask - returns break for blank custom answer", () => + withInstance(async () => { + const seeded = await seed({ text: "1. Build\n2. Test" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + await Question.reply({ + requestID: (await Question.list())[0].id, + answers: [[" "]], + }) + + await expect(pending).resolves.toBe("break") + expect((await Session.messages({ sessionID: seeded.sessionID })).length).toBe(2) + })) + + test("extractContext - returns empty string with no tool results", () => + withInstance(async () => { + const seeded = await seed({ text: "1. Build\n2. Test" }) + expect(extractContext(seeded.messages)).toBe("") + })) + + test("extractContext - includes task outputs and read paths", () => + withInstance(async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "task", + input: { prompt: "explore", subagent_type: "explore" }, + output: "The auth module lives in src/auth/", + }, + { + tool: "read", + input: { filePath: "/project/src/auth/login.ts" }, + output: "file content", + }, + { + tool: "read", + input: { filePath: "/project/src/auth/session.ts", offset: 10, limit: 20 }, + output: "file content", + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context).toContain("## Context from planning research") + expect(context).toContain("### Explored") + expect(context).toContain("The auth module lives in src/auth/") + expect(context).toContain("### Files read") + expect(context).toContain("- /project/src/auth/login.ts") + expect(context).toContain("- /project/src/auth/session.ts (lines 10-29)") + })) + + test("extractContext - filters empty and whitespace-only task outputs", () => + withInstance(async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "task", + input: { prompt: "explore", subagent_type: "explore" }, + output: "", + }, + { + tool: "task", + input: { prompt: "explore more", subagent_type: "explore" }, + output: " ", + }, + ], + }) + expect(extractContext(seeded.messages)).toBe("") + })) + + test("extractContext - deduplicates file reads", () => + withInstance(async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "read", + input: { filePath: "/project/src/api.ts" }, + output: "content", + }, + { + tool: "read", + input: { filePath: "/project/src/api.ts" }, + output: "content again", + }, + { + tool: "read", + input: { filePath: "/project/src/api.ts", offset: 10, limit: 20 }, + output: "different range", + }, + ], + }) + const context = extractContext(seeded.messages) + const matches = context.match(/- \/project\/src\/api\.ts\b/g) + expect(matches).toHaveLength(2) + })) + + test("extractContext - includes only explored section when no reads", () => + withInstance(async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "task", + input: { prompt: "explore", subagent_type: "explore" }, + output: "Found important patterns", + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context).toContain("### Explored") + expect(context).toContain("Found important patterns") + expect(context).not.toContain("### Files read") + })) + + test("extractContext - includes only files section when no tasks", () => + withInstance(async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "read", + input: { filePath: "/project/src/index.ts" }, + output: "content", + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context).not.toContain("### Explored") + expect(context).toContain("### Files read") + expect(context).toContain("- /project/src/index.ts") + })) + + test("extractContext - ignores non-task non-read tools", () => + withInstance(async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "grep", + input: { pattern: "foo", path: "/project" }, + output: "Found 5 matches", + }, + { + tool: "bash", + input: { command: "ls" }, + output: "file1.ts\nfile2.ts", + }, + ], + }) + expect(extractContext(seeded.messages)).toBe("") + })) + + test("extractContext - strips task_id prefix and task_result tags", () => + withInstance(async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "task", + input: { prompt: "explore", subagent_type: "explore" }, + output: + "task_id: ses_abc123 (for resuming)\n\n\nThe auth module is in src/auth/\n", + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context).toContain("The auth module is in src/auth/") + expect(context).not.toContain("task_id:") + expect(context).not.toContain("") + })) + + test("extractContext - shows first N lines for limit-only reads", () => + withInstance(async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "read", + input: { filePath: "/project/src/config.ts", limit: 50 }, + output: "content", + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context).toContain("- /project/src/config.ts (first 50 lines)") + })) + + test("extractContext - truncates at 10000 chars", () => + withInstance(async () => { + const seeded = await seed({ + text: "Plan text", + tools: [ + { + tool: "task", + input: { prompt: "explore", subagent_type: "explore" }, + output: "x".repeat(12_000), + }, + ], + }) + const context = extractContext(seeded.messages) + expect(context.length).toBeLessThanOrEqual(10_000) + expect(context).toEndWith("[context truncated]") + })) }) From 3a82fee72a1428b4d8fde36ae1fad4022e9c3518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 19 Feb 2026 10:57:54 -0300 Subject: [PATCH 09/73] refactor: move changes to kilo-ui --- packages/kilo-ui/src/components/markdown.css | 28 ++++++++++++++++++++ packages/kilo-ui/src/styles/globals.css | 6 +++-- packages/ui/src/components/markdown.css | 21 ++------------- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/packages/kilo-ui/src/components/markdown.css b/packages/kilo-ui/src/components/markdown.css index 52fdf38542..0cf0b2dd48 100644 --- a/packages/kilo-ui/src/components/markdown.css +++ b/packages/kilo-ui/src/components/markdown.css @@ -2,4 +2,32 @@ .shiki { margin: 0rem; } + + pre { + scrollbar-width: thin; + scrollbar-color: var(--border-weak-base) transparent; + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-corner { + background: transparent; + } + + &::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-weak-base); + border-radius: 4px; + + &:hover { + background: var(--border-strong-base); + } + } + } } diff --git a/packages/kilo-ui/src/styles/globals.css b/packages/kilo-ui/src/styles/globals.css index e6fac23f6f..63bd138e31 100644 --- a/packages/kilo-ui/src/styles/globals.css +++ b/packages/kilo-ui/src/styles/globals.css @@ -49,7 +49,8 @@ } ::-webkit-scrollbar-button { - display: block; + display: none; + width: 0; height: 0; } @@ -63,7 +64,8 @@ html[data-theme="kilo-vscode"] { font-weight: var(--vscode-font-weight); ::-webkit-scrollbar-button { - display: block; + display: none; + width: 0; height: 0; } diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index fb54436d6d..68ae93bda4 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -155,26 +155,9 @@ margin-bottom: 2rem; overflow: auto; - /* Hide vertical scrollbar, show horizontal */ - scrollbar-width: thin; - scrollbar-color: var(--border-weak-base) transparent; - + scrollbar-width: none; &::-webkit-scrollbar { - width: 0; - height: 8px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: var(--border-weak-base); - border-radius: 4px; - - &:hover { - background: var(--border-strong-base); - } + display: none; } } From 65432a1275113a815ac2d5f23c0c2d39ea1f9b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 19 Feb 2026 10:59:42 -0300 Subject: [PATCH 10/73] feat: update flake --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 10fa973cfe..18fa20d60b 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1770073757, - "narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=", + "lastModified": 1771207753, + "narHash": "sha256-b9uG8yN50DRQ6A7JdZBfzq718ryYrlmGgqkRm9OOwCE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "47472570b1e607482890801aeaf29bfb749884f6", + "rev": "d1c15b7d5806069da59e819999d70e1cec0760bf", "type": "github" }, "original": { From 0743eb16b60a2f3812adf231a73a670ad52b8624 Mon Sep 17 00:00:00 2001 From: pandemicsyn Date: Thu, 19 Feb 2026 21:11:43 -0600 Subject: [PATCH 11/73] Add quick docs for KiloClaw's changelog --- packages/kilo-docs/pages/automate/kiloclaw.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/kilo-docs/pages/automate/kiloclaw.md b/packages/kilo-docs/pages/automate/kiloclaw.md index a6150c3d18..0ef093760e 100644 --- a/packages/kilo-docs/pages/automate/kiloclaw.md +++ b/packages/kilo-docs/pages/automate/kiloclaw.md @@ -58,6 +58,22 @@ Once created, you can control your instance from the dashboard. | **Settings** | Model configuration and instance parameters | | **Actions** | Quick actions and connected platform management | +### Changelog + +Your instance page includes a changelog with recent KiloClaw platform updates. + +Each changelog entry is labeled by update type: + +- **Feature** — New capability or enhancement +- **Bug** — Fix for incorrect or broken behavior + +Some entries also include a redeploy label: + +- **Redeploy required** — You must redeploy your instance to fully take advantage of the change +- **Redeploy suggested** — Redeploy is optional and only needed if you want to use the new behavior + +For example, if you manually configured a channel such as Telegram, and would prefer to have KiloClaw manage the channel for you, you would need to redeploy. + ## Accessing Your Agent To connect to your agent's web interface: From d5bceac21e47864387bc9f144b20bc391cf72155 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 20 Feb 2026 12:25:11 +0200 Subject: [PATCH 12/73] feat: Handover task list and compacted context to next session after planning --- .../opencode/src/kilocode/plan-followup.ts | 156 +++++--- .../test/kilocode/plan-followup.test.ts | 375 +++++++++--------- 2 files changed, 285 insertions(+), 246 deletions(-) diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 9846a7c0cf..d599bf1ecc 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -1,9 +1,13 @@ +import { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" import { Identifier } from "@/id/id" +import { Provider } from "@/provider/provider" import { Question } from "@/question" import { Session } from "@/session" +import { LLM } from "@/session/llm" import { MessageV2 } from "@/session/message-v2" +import { Todo } from "@/session/todo" import { Log } from "@/util/log" function toText(item: MessageV2.WithParts): string { @@ -14,61 +18,90 @@ function toText(item: MessageV2.WithParts): string { .trim() } -const CONTEXT_LIMIT = 10_000 +const HANDOVER_PROMPT = `You are summarizing a planning session to hand off to an implementation session. -function isTool(part: MessageV2.Part): part is MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } { - return part.type === "tool" && part.state.status === "completed" +The plan itself will be provided separately — do NOT repeat it. Instead, focus on information discovered during planning that would help the implementing agent but is NOT already in the plan text. + +Produce a concise summary using this template: +--- +## Discoveries + +[Key findings from code exploration — architecture patterns, gotchas, edge cases, relevant existing code that the plan references but doesn't fully explain] + +## Relevant Files + +[Structured list of files/directories that were read or discussed, with brief notes on what's relevant in each] + +## Implementation Notes + +[Any important context: conventions to follow, potential pitfalls, dependencies between steps, things the implementing agent should watch out for] +--- + +If there is nothing useful to add beyond what the plan already says, respond with an empty string. +Keep the summary concise — focus on high-entropy information that would save the implementing agent time.` + +export function formatTodos(todos: Todo.Info[]): string { + if (!todos.length) return "" + const icons: Record = { + completed: "[x]", + in_progress: "[~]", + cancelled: "[-]", + } + return todos.map((t) => `- ${icons[t.status] ?? "[ ]"} ${t.content}`).join("\n") } -export function extractContext(messages: MessageV2.WithParts[]): string { - const tasks = [] as string[] - const files = [] as string[] - const seen = new Set() +export async function generateHandover(input: { + messages: MessageV2.WithParts[] + model: MessageV2.User["model"] + abort?: AbortSignal +}): Promise { + const log = Log.create({ service: "plan.followup" }) + try { + const agent = await Agent.get("compaction") + const model = agent?.model + ? await Provider.getModel(agent.model.providerID, agent.model.modelID) + : await Provider.getModel(input.model.providerID, input.model.modelID) - for (const msg of messages) { - for (const part of msg.parts) { - if (!isTool(part)) continue - if (part.tool === "task" && part.state.output.trim()) { - const match = part.state.output.match(/([\s\S]*?)<\/task_result>/) - tasks.push(match ? match[1].trim() : part.state.output.trim()) - } - if (part.tool === "read" && part.state.input.filePath) { - const path = part.state.input.filePath as string - const offset = part.state.input.offset as number | undefined - const limit = part.state.input.limit as number | undefined - const range = - offset !== undefined && limit !== undefined - ? ` (lines ${offset}-${offset + limit - 1})` - : offset !== undefined - ? ` (from line ${offset})` - : limit !== undefined - ? ` (first ${limit} lines)` - : "" - const entry = `- ${path}${range}` - if (!seen.has(entry)) { - seen.add(entry) - files.push(entry) - } - } + const sessionID = Identifier.ascending("session") + const userMsg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "plan", + model: input.model, } - } - if (!tasks.length && !files.length) return "" + const stream = await LLM.stream({ + agent: agent ?? { + name: "compaction", + mode: "subagent", + permission: [], + options: {}, + }, + user: userMsg, + tools: {}, + model, + small: true, + messages: [ + ...MessageV2.toModelMessages(input.messages, model), + { + role: "user" as const, + content: HANDOVER_PROMPT, + }, + ], + abort: input.abort ? AbortSignal.any([input.abort, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000), + sessionID, + system: [], + retries: 1, + }) - const sections = [] as string[] - if (tasks.length) { - sections.push("### Explored\n\n" + tasks.join("\n\n")) + const result = await stream.text + return result.trim() + } catch (error) { + log.error("handover generation failed", { error }) + return "" } - if (files.length) { - sections.push("### Files read\n\n" + files.join("\n")) - } - - const full = "\n\n## Context from planning research\n\n" + sections.join("\n\n") - if (full.length <= CONTEXT_LIMIT) return full - const marker = "\n\n[context truncated]" - const cut = full.slice(0, CONTEXT_LIMIT - marker.length) - const last = cut.lastIndexOf("\n") - return (last > 0 ? cut.slice(0, last) : cut) + marker } export namespace PlanFollowup { @@ -138,14 +171,35 @@ export namespace PlanFollowup { }) } - async function startNew(input: { plan: string; messages: MessageV2.WithParts[]; model: MessageV2.User["model"] }) { - const context = extractContext(input.messages) + async function startNew(input: { + sessionID: string + plan: string + messages: MessageV2.WithParts[] + model: MessageV2.User["model"] + abort?: AbortSignal + }) { + const [handover, todos] = await Promise.all([ + generateHandover({ messages: input.messages, model: input.model, abort: input.abort }), + Todo.get(input.sessionID), + ]) + + const sections = [`Implement the following plan:\n\n${input.plan}`] + + if (handover) { + sections.push(`## Handover from Planning Session\n\n${handover}`) + } + + const todoList = formatTodos(todos) + if (todoList) { + sections.push(`## Todo List\n\n${todoList}`) + } + const next = await Session.create({}) await inject({ sessionID: next.id, agent: "code", model: input.model, - text: `Implement the following plan:\n\n${input.plan}${context ? `\n${context}` : ""}`, + text: sections.join("\n\n"), }) await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id }) void import("@/session/prompt") @@ -179,7 +233,7 @@ export namespace PlanFollowup { if (!answer) return "break" if (answer === ANSWER_NEW_SESSION) { - await startNew({ plan, messages: input.messages, model: user.model }) + await startNew({ sessionID: input.sessionID, plan, messages: input.messages, model: user.model, abort: input.abort }) return "break" } diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index c4913136db..1f311a6e9d 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -1,13 +1,17 @@ import { describe, expect, spyOn, test } from "bun:test" +import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { TuiEvent } from "../../src/cli/cmd/tui/event" import { Identifier } from "../../src/id/id" -import { extractContext, PlanFollowup } from "../../src/kilocode/plan-followup" +import { formatTodos, generateHandover, PlanFollowup } from "../../src/kilocode/plan-followup" import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" import { Question } from "../../src/question" import { Session } from "../../src/session" +import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" +import { Todo } from "../../src/session/todo" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" @@ -122,6 +126,39 @@ async function sessions() { return Array.fromAsync(Session.list()) } +const fakeAgent: Agent.Info = { + name: "compaction", + mode: "subagent", + permission: [], + options: {}, +} + +const fakeModel = { + id: "gpt-4", + providerID: "openai", + limit: { context: 128000, input: 0 }, + api: { id: "openai", npm: "@ai-sdk/openai" }, + capabilities: {}, +} as Provider.Model + +function mockHandoverDeps(text: string, opts?: { agent?: Agent.Info | null }) { + const agentSpy = spyOn(Agent, "get").mockResolvedValue((opts?.agent === null ? undefined : (opts?.agent ?? fakeAgent)) as any) + const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel) + const llmSpy = spyOn(LLM, "stream").mockResolvedValue({ + text: Promise.resolve(text), + } as any) + return { + agentSpy, + modelSpy, + llmSpy, + [Symbol.dispose]() { + agentSpy.mockRestore() + modelSpy.mockRestore() + llmSpy.mockRestore() + }, + } +} + describe("plan follow-up", () => { test("ask - returns break when dismissed", () => withInstance(async () => { @@ -196,7 +233,7 @@ describe("plan follow-up", () => { expect(part.synthetic).toBe(true) })) - test("ask - creates a new session on Start new session", () => + test("ask - creates a new session on Start new session with handover and todos", () => withInstance(async () => { const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({ info: { @@ -229,26 +266,24 @@ describe("plan follow-up", () => { }, parts: [], }) - using _ = { + using _mocks = mockHandoverDeps("## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer") + using _loop = { [Symbol.dispose]() { loop.mockRestore() }, } const seeded = await seed({ text: "1. Add API\n2. Add tests", - tools: [ - { - tool: "task", - input: { prompt: "explore the codebase", subagent_type: "explore" }, - output: "Found src/api.ts with REST endpoints and src/db.ts with database layer", - }, - { - tool: "read", - input: { filePath: "/project/src/api.ts", offset: 1, limit: 50 }, - output: "file content here", - }, + }) + + await Todo.update({ + sessionID: seeded.sessionID, + todos: [ + { id: "1", content: "Add API endpoint", status: "completed", priority: "high" }, + { id: "2", content: "Write tests", status: "pending", priority: "medium" }, ], }) + const before = await sessions() const created = [] as string[] const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => { @@ -275,6 +310,7 @@ describe("plan follow-up", () => { expect(added).toHaveLength(1) expect(created).toHaveLength(1) expect(loop).toHaveBeenCalledTimes(1) + expect(_mocks.llmSpy).toHaveBeenCalledTimes(1) const newSessionID = created[0] expect(added[0].id).toBe(newSessionID) @@ -289,14 +325,79 @@ describe("plan follow-up", () => { if (!part || part.type !== "text") throw new Error("expected text part") expect(part.text).toContain("Implement the following plan:") expect(part.text).toContain("1. Add API\n2. Add tests") - expect(part.text).toContain("## Context from planning research") - expect(part.text).toContain("Found src/api.ts with REST endpoints") - expect(part.text).toContain("- /project/src/api.ts (lines 1-50)") + expect(part.text).toContain("## Handover from Planning Session") + expect(part.text).toContain("Found REST endpoints in src/api.ts") + expect(part.text).toContain("## Todo List") + expect(part.text).toContain("[x] Add API endpoint") + expect(part.text).toContain("[ ] Write tests") expect(part.synthetic).toBe(true) SessionPrompt.cancel(newSessionID) })) + test("ask - new session omits handover section when LLM returns empty", () => + withInstance(async () => { + const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({ + info: { + id: "msg_test", + role: "assistant", + sessionID: "ses_test", + time: { created: Date.now() }, + parentID: "msg_parent", + modelID: "test", + providerID: "test", + mode: "code", + agent: "code", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + parts: [], + }) + using _mocks = mockHandoverDeps("") + using _loop = { + [Symbol.dispose]() { + loop.mockRestore() + }, + } + const seeded = await seed({ text: "1. Add API\n2. Add tests" }) + const created = [] as string[] + const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => { + created.push(event.properties.sessionID) + }) + + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + await Question.reply({ + requestID: (await Question.list())[0].id, + answers: [[PlanFollowup.ANSWER_NEW_SESSION]], + }) + + await expect(pending).resolves.toBe("break") + unsub() + + const messages = await Session.messages({ sessionID: created[0] }) + const user = messages.find((item) => item.info.role === "user") + if (!user || user.info.role !== "user") throw new Error("expected user message") + const part = user.parts.find((item) => item.type === "text") + if (!part || part.type !== "text") throw new Error("expected text part") + expect(part.text).toContain("Implement the following plan:") + expect(part.text).not.toContain("## Handover from Planning Session") + expect(part.text).not.toContain("## Todo List") + + SessionPrompt.cancel(created[0]) + })) + test("ask - returns break when assistant text is empty", () => withInstance(async () => { const seeded = await seed({ text: " " }) @@ -361,195 +462,79 @@ describe("plan follow-up", () => { expect((await Session.messages({ sessionID: seeded.sessionID })).length).toBe(2) })) - test("extractContext - returns empty string with no tool results", () => + test("formatTodos - returns empty string for no todos", () => { + expect(formatTodos([])).toBe("") + }) + + test("formatTodos - formats todos with status icons", () => { + const todos: Todo.Info[] = [ + { id: "1", content: "Set up project", status: "completed", priority: "high" }, + { id: "2", content: "Write code", status: "in_progress", priority: "high" }, + { id: "3", content: "Add tests", status: "pending", priority: "medium" }, + { id: "4", content: "Dropped task", status: "cancelled", priority: "low" }, + ] + const result = formatTodos(todos) + expect(result).toBe( + "- [x] Set up project\n- [~] Write code\n- [ ] Add tests\n- [-] Dropped task", + ) + }) + + test("generateHandover - returns empty string on LLM.stream failure", () => withInstance(async () => { + const agentSpy = spyOn(Agent, "get").mockResolvedValue(fakeAgent) + const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel) + const llmSpy = spyOn(LLM, "stream").mockRejectedValue(new Error("provider unavailable")) + using _ = { + [Symbol.dispose]() { + agentSpy.mockRestore() + modelSpy.mockRestore() + llmSpy.mockRestore() + }, + } const seeded = await seed({ text: "1. Build\n2. Test" }) - expect(extractContext(seeded.messages)).toBe("") + const result = await generateHandover({ messages: seeded.messages, model }) + expect(result).toBe("") })) - test("extractContext - includes task outputs and read paths", () => + test("generateHandover - returns empty string on stream.text rejection", () => withInstance(async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "task", - input: { prompt: "explore", subagent_type: "explore" }, - output: "The auth module lives in src/auth/", - }, - { - tool: "read", - input: { filePath: "/project/src/auth/login.ts" }, - output: "file content", - }, - { - tool: "read", - input: { filePath: "/project/src/auth/session.ts", offset: 10, limit: 20 }, - output: "file content", - }, - ], + const agentSpy = spyOn(Agent, "get").mockResolvedValue(fakeAgent) + const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel) + const textPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("stream aborted")), 0) }) - const context = extractContext(seeded.messages) - expect(context).toContain("## Context from planning research") - expect(context).toContain("### Explored") - expect(context).toContain("The auth module lives in src/auth/") - expect(context).toContain("### Files read") - expect(context).toContain("- /project/src/auth/login.ts") - expect(context).toContain("- /project/src/auth/session.ts (lines 10-29)") + textPromise.catch(() => {}) + const llmSpy = spyOn(LLM, "stream").mockResolvedValue({ + text: textPromise, + } as any) + using _ = { + [Symbol.dispose]() { + agentSpy.mockRestore() + modelSpy.mockRestore() + llmSpy.mockRestore() + }, + } + const seeded = await seed({ text: "1. Build\n2. Test" }) + const result = await generateHandover({ messages: seeded.messages, model }) + expect(result).toBe("") })) - test("extractContext - filters empty and whitespace-only task outputs", () => + test("generateHandover - uses fallback agent when compaction agent is not configured", () => withInstance(async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "task", - input: { prompt: "explore", subagent_type: "explore" }, - output: "", - }, - { - tool: "task", - input: { prompt: "explore more", subagent_type: "explore" }, - output: " ", - }, - ], - }) - expect(extractContext(seeded.messages)).toBe("") + using mocks = mockHandoverDeps("## Discoveries\n\nFallback works", { agent: null }) + const seeded = await seed({ text: "1. Build\n2. Test" }) + const result = await generateHandover({ messages: seeded.messages, model }) + expect(result).toBe("## Discoveries\n\nFallback works") + expect(mocks.agentSpy).toHaveBeenCalledWith("compaction") + expect(mocks.llmSpy).toHaveBeenCalledTimes(1) })) - test("extractContext - deduplicates file reads", () => + test("generateHandover - returns LLM output on success", () => withInstance(async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "read", - input: { filePath: "/project/src/api.ts" }, - output: "content", - }, - { - tool: "read", - input: { filePath: "/project/src/api.ts" }, - output: "content again", - }, - { - tool: "read", - input: { filePath: "/project/src/api.ts", offset: 10, limit: 20 }, - output: "different range", - }, - ], - }) - const context = extractContext(seeded.messages) - const matches = context.match(/- \/project\/src\/api\.ts\b/g) - expect(matches).toHaveLength(2) - })) - - test("extractContext - includes only explored section when no reads", () => - withInstance(async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "task", - input: { prompt: "explore", subagent_type: "explore" }, - output: "Found important patterns", - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context).toContain("### Explored") - expect(context).toContain("Found important patterns") - expect(context).not.toContain("### Files read") - })) - - test("extractContext - includes only files section when no tasks", () => - withInstance(async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "read", - input: { filePath: "/project/src/index.ts" }, - output: "content", - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context).not.toContain("### Explored") - expect(context).toContain("### Files read") - expect(context).toContain("- /project/src/index.ts") - })) - - test("extractContext - ignores non-task non-read tools", () => - withInstance(async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "grep", - input: { pattern: "foo", path: "/project" }, - output: "Found 5 matches", - }, - { - tool: "bash", - input: { command: "ls" }, - output: "file1.ts\nfile2.ts", - }, - ], - }) - expect(extractContext(seeded.messages)).toBe("") - })) - - test("extractContext - strips task_id prefix and task_result tags", () => - withInstance(async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "task", - input: { prompt: "explore", subagent_type: "explore" }, - output: - "task_id: ses_abc123 (for resuming)\n\n\nThe auth module is in src/auth/\n", - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context).toContain("The auth module is in src/auth/") - expect(context).not.toContain("task_id:") - expect(context).not.toContain("") - })) - - test("extractContext - shows first N lines for limit-only reads", () => - withInstance(async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "read", - input: { filePath: "/project/src/config.ts", limit: 50 }, - output: "content", - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context).toContain("- /project/src/config.ts (first 50 lines)") - })) - - test("extractContext - truncates at 10000 chars", () => - withInstance(async () => { - const seeded = await seed({ - text: "Plan text", - tools: [ - { - tool: "task", - input: { prompt: "explore", subagent_type: "explore" }, - output: "x".repeat(12_000), - }, - ], - }) - const context = extractContext(seeded.messages) - expect(context.length).toBeLessThanOrEqual(10_000) - expect(context).toEndWith("[context truncated]") + using mocks = mockHandoverDeps("## Discoveries\n\nKey finding here") + const seeded = await seed({ text: "1. Build\n2. Test" }) + const result = await generateHandover({ messages: seeded.messages, model }) + expect(result).toBe("## Discoveries\n\nKey finding here") + expect(mocks.llmSpy).toHaveBeenCalledTimes(1) })) }) From 27a6b81816e02889d3dc1afe38d1545979469a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:40:13 +0100 Subject: [PATCH 13/73] test(vscode): add support-prompt createPrompt tests --- .../tests/unit/support-prompt.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/kilo-vscode/tests/unit/support-prompt.test.ts diff --git a/packages/kilo-vscode/tests/unit/support-prompt.test.ts b/packages/kilo-vscode/tests/unit/support-prompt.test.ts new file mode 100644 index 0000000000..76934ecda4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/support-prompt.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from "bun:test" +import { createPrompt } from "../../src/services/code-actions/support-prompt" + +const base = { + filePath: "src/foo.ts", + startLine: "10", + endLine: "20", + selectedText: "const x = 1", + userInput: "", +} + +describe("createPrompt", () => { + describe("EXPLAIN", () => { + it("includes file path and line range", () => { + const result = createPrompt("EXPLAIN", base) + expect(result).toContain("src/foo.ts:10-20") + }) + + it("includes selected text in code fence", () => { + const result = createPrompt("EXPLAIN", base) + expect(result).toContain("```\nconst x = 1\n```") + }) + + it("includes userInput when provided", () => { + const result = createPrompt("EXPLAIN", { ...base, userInput: "explain this" }) + expect(result).toContain("explain this") + }) + + it("does not include diagnostics section", () => { + const result = createPrompt("EXPLAIN", base) + expect(result).not.toContain("Current problems detected") + }) + }) + + describe("FIX", () => { + it("includes file path and line range", () => { + const result = createPrompt("FIX", base) + expect(result).toContain("src/foo.ts:10-20") + }) + + it("includes no diagnostics section when diagnostics is empty", () => { + const result = createPrompt("FIX", { ...base, diagnostics: [] }) + expect(result).not.toContain("Current problems detected") + }) + + it("includes diagnostics section when diagnostics provided", () => { + const diag = [{ source: "ts", message: "Type error", code: 2322 }] + const result = createPrompt("FIX", { ...base, diagnostics: diag }) + expect(result).toContain("Current problems detected") + expect(result).toContain("[ts] Type error (2322)") + }) + + it("formats diagnostic without code", () => { + const diag = [{ source: "eslint", message: "no-unused-vars" }] + const result = createPrompt("FIX", { ...base, diagnostics: diag }) + expect(result).toContain("[eslint] no-unused-vars") + expect(result).not.toContain("undefined") + }) + + it("formats diagnostic without source using 'Error' fallback", () => { + const diag = [{ message: "something went wrong" }] + const result = createPrompt("FIX", { ...base, diagnostics: diag }) + expect(result).toContain("[Error] something went wrong") + }) + + it("includes multiple diagnostics", () => { + const diag = [ + { source: "ts", message: "Err1", code: 1 }, + { source: "ts", message: "Err2", code: 2 }, + ] + const result = createPrompt("FIX", { ...base, diagnostics: diag }) + expect(result).toContain("[ts] Err1 (1)") + expect(result).toContain("[ts] Err2 (2)") + }) + }) + + describe("IMPROVE", () => { + it("includes file path and line range", () => { + const result = createPrompt("IMPROVE", base) + expect(result).toContain("src/foo.ts:10-20") + }) + + it("includes selected text in code fence", () => { + const result = createPrompt("IMPROVE", base) + expect(result).toContain("```\nconst x = 1\n```") + }) + + it("does not render diagnosticText placeholder", () => { + const result = createPrompt("IMPROVE", base) + expect(result).not.toContain("${diagnosticText}") + }) + }) + + describe("ADD_TO_CONTEXT", () => { + it("produces compact file reference with code fence", () => { + const result = createPrompt("ADD_TO_CONTEXT", base) + expect(result).toContain("src/foo.ts:10-20") + expect(result).toContain("```\nconst x = 1\n```") + }) + + it("does not include explanatory prose", () => { + const result = createPrompt("ADD_TO_CONTEXT", base) + expect(result).not.toContain("Please") + }) + }) + + describe("TERMINAL_ADD_TO_CONTEXT", () => { + it("includes terminalContent in code fence", () => { + const result = createPrompt("TERMINAL_ADD_TO_CONTEXT", { + userInput: "", + terminalContent: "npm install", + }) + expect(result).toContain("```\nnpm install\n```") + }) + + it("includes userInput when provided", () => { + const result = createPrompt("TERMINAL_ADD_TO_CONTEXT", { + userInput: "context here", + terminalContent: "ls", + }) + expect(result).toContain("context here") + }) + + it("renders empty string for missing terminalContent", () => { + const result = createPrompt("TERMINAL_ADD_TO_CONTEXT", { userInput: "" }) + expect(result).toContain("```\n\n```") + }) + }) + + describe("TERMINAL_FIX", () => { + it("includes terminalContent in code fence", () => { + const result = createPrompt("TERMINAL_FIX", { + userInput: "", + terminalContent: "gti status", + }) + expect(result).toContain("```\ngti status\n```") + }) + + it("asks to fix the command", () => { + const result = createPrompt("TERMINAL_FIX", { userInput: "", terminalContent: "" }) + expect(result).toContain("Fix this terminal command") + }) + }) + + describe("TERMINAL_EXPLAIN", () => { + it("includes terminalContent in code fence", () => { + const result = createPrompt("TERMINAL_EXPLAIN", { + userInput: "", + terminalContent: "grep -r foo .", + }) + expect(result).toContain("```\ngrep -r foo .\n```") + }) + + it("asks to explain the command", () => { + const result = createPrompt("TERMINAL_EXPLAIN", { userInput: "", terminalContent: "" }) + expect(result).toContain("Explain this terminal command") + }) + }) + + describe("missing params", () => { + it("renders empty string for unknown template variable", () => { + const result = createPrompt("ADD_TO_CONTEXT", { + filePath: "f.ts", + startLine: "1", + endLine: "2", + }) + expect(result).not.toContain("${selectedText}") + expect(result).toContain("```\n\n```") + }) + + it("does not include literal placeholder text", () => { + const result = createPrompt("EXPLAIN", { + filePath: "x.ts", + startLine: "1", + endLine: "1", + selectedText: "code", + userInput: "", + }) + expect(result).not.toMatch(/\$\{[a-zA-Z]+\}/) + }) + }) +}) From 803dea9844d724d0250e42255b60553d122ef8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:40:59 +0100 Subject: [PATCH 14/73] test(vscode): add telemetry error class and type guard tests --- .../tests/unit/telemetry-errors.test.ts | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 packages/kilo-vscode/tests/unit/telemetry-errors.test.ts diff --git a/packages/kilo-vscode/tests/unit/telemetry-errors.test.ts b/packages/kilo-vscode/tests/unit/telemetry-errors.test.ts new file mode 100644 index 0000000000..c4ef37b8bf --- /dev/null +++ b/packages/kilo-vscode/tests/unit/telemetry-errors.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from "bun:test" +import { + ApiProviderError, + isApiProviderError, + getApiProviderErrorProperties, + ConsecutiveMistakeError, + isConsecutiveMistakeError, + getConsecutiveMistakeErrorProperties, +} from "../../src/services/telemetry/errors" + +describe("ApiProviderError", () => { + it("constructs with required fields", () => { + const err = new ApiProviderError("failed", "openai", "gpt-4", "chat") + expect(err.message).toBe("failed") + expect(err.provider).toBe("openai") + expect(err.modelId).toBe("gpt-4") + expect(err.operation).toBe("chat") + expect(err.errorCode).toBeUndefined() + expect(err.name).toBe("ApiProviderError") + }) + + it("constructs with optional errorCode", () => { + const err = new ApiProviderError("rate limited", "anthropic", "claude-3", "stream", 429) + expect(err.errorCode).toBe(429) + }) + + it("is an instance of Error", () => { + const err = new ApiProviderError("x", "p", "m", "o") + expect(err instanceof Error).toBe(true) + }) + + it("errorCode of 0 is preserved", () => { + const err = new ApiProviderError("x", "p", "m", "o", 0) + expect(err.errorCode).toBe(0) + }) +}) + +describe("isApiProviderError", () => { + it("returns true for ApiProviderError instance", () => { + const err = new ApiProviderError("x", "openai", "gpt-4", "chat") + expect(isApiProviderError(err)).toBe(true) + }) + + it("returns false for plain Error", () => { + expect(isApiProviderError(new Error("x"))).toBe(false) + }) + + it("returns false for null", () => { + expect(isApiProviderError(null)).toBe(false) + }) + + it("returns false for undefined", () => { + expect(isApiProviderError(undefined)).toBe(false) + }) + + it("returns false for plain object", () => { + expect(isApiProviderError({ name: "ApiProviderError", provider: "x" })).toBe(false) + }) + + it("returns false when name differs", () => { + const err = new ApiProviderError("x", "p", "m", "o") + err.name = "SomethingElse" + expect(isApiProviderError(err)).toBe(false) + }) + + it("returns true for deserialized error with matching name and properties", () => { + const err = Object.assign(new Error("x"), { + name: "ApiProviderError", + provider: "openai", + modelId: "gpt-4", + operation: "chat", + }) + expect(isApiProviderError(err)).toBe(true) + }) +}) + +describe("getApiProviderErrorProperties", () => { + it("returns all required properties", () => { + const err = new ApiProviderError("x", "openai", "gpt-4", "chat") + const props = getApiProviderErrorProperties(err) + expect(props).toEqual({ provider: "openai", modelId: "gpt-4", operation: "chat" }) + }) + + it("includes errorCode when present", () => { + const err = new ApiProviderError("x", "openai", "gpt-4", "chat", 429) + const props = getApiProviderErrorProperties(err) + expect(props).toEqual({ provider: "openai", modelId: "gpt-4", operation: "chat", errorCode: 429 }) + }) + + it("omits errorCode when undefined", () => { + const err = new ApiProviderError("x", "openai", "gpt-4", "chat") + const props = getApiProviderErrorProperties(err) + expect("errorCode" in props).toBe(false) + }) + + it("includes errorCode of 0", () => { + const err = new ApiProviderError("x", "openai", "gpt-4", "chat", 0) + const props = getApiProviderErrorProperties(err) + expect(props.errorCode).toBe(0) + }) +}) + +describe("ConsecutiveMistakeError", () => { + it("constructs with required fields and default reason", () => { + const err = new ConsecutiveMistakeError("too many", "task-1", 3, 5) + expect(err.message).toBe("too many") + expect(err.taskId).toBe("task-1") + expect(err.consecutiveMistakeCount).toBe(3) + expect(err.consecutiveMistakeLimit).toBe(5) + expect(err.reason).toBe("unknown") + expect(err.provider).toBeUndefined() + expect(err.modelId).toBeUndefined() + expect(err.name).toBe("ConsecutiveMistakeError") + }) + + it("constructs with all optional fields", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3, "no_tools_used", "openai", "gpt-4") + expect(err.reason).toBe("no_tools_used") + expect(err.provider).toBe("openai") + expect(err.modelId).toBe("gpt-4") + }) + + it("is an instance of Error", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3) + expect(err instanceof Error).toBe(true) + }) +}) + +describe("isConsecutiveMistakeError", () => { + it("returns true for ConsecutiveMistakeError instance", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3) + expect(isConsecutiveMistakeError(err)).toBe(true) + }) + + it("returns false for plain Error", () => { + expect(isConsecutiveMistakeError(new Error("x"))).toBe(false) + }) + + it("returns false for ApiProviderError", () => { + const err = new ApiProviderError("x", "p", "m", "o") + expect(isConsecutiveMistakeError(err)).toBe(false) + }) + + it("returns false for null", () => { + expect(isConsecutiveMistakeError(null)).toBe(false) + }) + + it("returns false when name differs", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3) + err.name = "OtherError" + expect(isConsecutiveMistakeError(err)).toBe(false) + }) +}) + +describe("getConsecutiveMistakeErrorProperties", () => { + it("returns all required properties", () => { + const err = new ConsecutiveMistakeError("x", "task-1", 3, 5) + const props = getConsecutiveMistakeErrorProperties(err) + expect(props).toEqual({ + taskId: "task-1", + consecutiveMistakeCount: 3, + consecutiveMistakeLimit: 5, + reason: "unknown", + }) + }) + + it("includes provider and modelId when present", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3, "tool_repetition", "anthropic", "claude-3") + const props = getConsecutiveMistakeErrorProperties(err) + expect(props.provider).toBe("anthropic") + expect(props.modelId).toBe("claude-3") + }) + + it("omits provider and modelId when undefined", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3) + const props = getConsecutiveMistakeErrorProperties(err) + expect("provider" in props).toBe(false) + expect("modelId" in props).toBe(false) + }) + + it("includes reason correctly for each reason type", () => { + const reasons = ["no_tools_used", "tool_repetition", "unknown"] as const + for (const reason of reasons) { + const err = new ConsecutiveMistakeError("x", "t", 1, 3, reason) + expect(getConsecutiveMistakeErrorProperties(err).reason).toBe(reason) + } + }) +}) From f94689522f5e09349d3f20d99f102b0d39352f52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:41:52 +0100 Subject: [PATCH 15/73] test(vscode): add permission queue upsert and removal tests --- .../tests/unit/permission-queue.test.ts | 120 ++++++++++++------ 1 file changed, 80 insertions(+), 40 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/permission-queue.test.ts b/packages/kilo-vscode/tests/unit/permission-queue.test.ts index e1fc360586..fa60612746 100644 --- a/packages/kilo-vscode/tests/unit/permission-queue.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-queue.test.ts @@ -1,53 +1,93 @@ -import { describe, expect, it } from "bun:test" -import { removeSessionPermissions, upsertPermission } from "../../webview-ui/src/context/permission-queue" +import { describe, it, expect } from "bun:test" +import { + upsertPermission, + removeSessionPermissions, +} from "../../webview-ui/src/context/permission-queue" import type { PermissionRequest } from "../../webview-ui/src/types/messages" -const permission = (input: Partial = {}): PermissionRequest => ({ - id: input.id ?? "perm-1", - sessionID: input.sessionID ?? "session-1", - toolName: input.toolName ?? "read", - patterns: input.patterns ?? ["/tmp/*"], - args: input.args ?? {}, - message: input.message, - tool: input.tool, -}) +function perm(id: string, sessionID: string): PermissionRequest { + return { id, sessionID, toolName: "read_file", patterns: [], args: {} } +} -describe("permission queue", () => { - it("appends a new permission id", () => { - const result = upsertPermission([], permission({ id: "perm-1" })) +describe("upsertPermission", () => { + it("appends new permission to empty list", () => { + const result = upsertPermission([], perm("p1", "s1")) expect(result).toHaveLength(1) - expect(result[0].id).toBe("perm-1") + expect(result[0]!.id).toBe("p1") }) - it("updates an existing permission id instead of duplicating", () => { - const existing = permission({ id: "perm-1", toolName: "read", patterns: ["a"] }) - const incoming = permission({ id: "perm-1", toolName: "write", patterns: ["b"] }) - - const result = upsertPermission([existing], incoming) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual(incoming) - }) - - it("keeps other permission entries when updating one id", () => { - const first = permission({ id: "perm-1", sessionID: "session-1" }) - const second = permission({ id: "perm-2", sessionID: "session-2" }) - const incoming = permission({ id: "perm-1", toolName: "edit" }) - - const result = upsertPermission([first, second], incoming) - + it("appends new permission to non-empty list", () => { + const list = [perm("p1", "s1")] + const result = upsertPermission(list, perm("p2", "s1")) expect(result).toHaveLength(2) - expect(result.find((item) => item.id === "perm-1")).toEqual(incoming) - expect(result.find((item) => item.id === "perm-2")).toEqual(second) + expect(result[1]!.id).toBe("p2") }) - it("removes only permissions from the deleted session", () => { - const first = permission({ id: "perm-1", sessionID: "session-1" }) - const second = permission({ id: "perm-2", sessionID: "session-2" }) - const third = permission({ id: "perm-3", sessionID: "session-1" }) + it("replaces existing permission with same id", () => { + const list = [perm("p1", "s1")] + const updated = { ...perm("p1", "s1"), toolName: "write_file" } + const result = upsertPermission(list, updated) + expect(result).toHaveLength(1) + expect(result[0]!.toolName).toBe("write_file") + }) - const result = removeSessionPermissions([first, second, third], "session-1") + it("does not mutate the original list on append", () => { + const list = [perm("p1", "s1")] + upsertPermission(list, perm("p2", "s1")) + expect(list).toHaveLength(1) + }) - expect(result).toEqual([second]) + it("does not mutate the original list on replace", () => { + const list = [perm("p1", "s1")] + upsertPermission(list, { ...perm("p1", "s1"), toolName: "write_file" }) + expect(list[0]!.toolName).toBe("read_file") + }) + + it("replaces by id regardless of position", () => { + const list = [perm("p1", "s1"), perm("p2", "s1"), perm("p3", "s1")] + const updated = { ...perm("p2", "s1"), toolName: "write_file" } + const result = upsertPermission(list, updated) + expect(result).toHaveLength(3) + expect(result[1]!.toolName).toBe("write_file") + expect(result[0]!.toolName).toBe("read_file") + expect(result[2]!.toolName).toBe("read_file") + }) + + it("handles upsert of same permission idempotently", () => { + const list: PermissionRequest[] = [] + const r1 = upsertPermission(list, perm("p1", "s1")) + const r2 = upsertPermission(r1, perm("p1", "s1")) + expect(r2).toHaveLength(1) + }) +}) + +describe("removeSessionPermissions", () => { + it("returns empty list when input is empty", () => { + expect(removeSessionPermissions([], "s1")).toEqual([]) + }) + + it("removes all permissions for given session", () => { + const list = [perm("p1", "s1"), perm("p2", "s1"), perm("p3", "s2")] + const result = removeSessionPermissions(list, "s1") + expect(result).toHaveLength(1) + expect(result[0]!.sessionID).toBe("s2") + }) + + it("returns all items when session has no permissions", () => { + const list = [perm("p1", "s1"), perm("p2", "s2")] + const result = removeSessionPermissions(list, "s3") + expect(result).toHaveLength(2) + }) + + it("does not mutate the original list", () => { + const list = [perm("p1", "s1"), perm("p2", "s1")] + removeSessionPermissions(list, "s1") + expect(list).toHaveLength(2) + }) + + it("removes all items when all share the session", () => { + const list = [perm("p1", "s1"), perm("p2", "s1")] + const result = removeSessionPermissions(list, "s1") + expect(result).toHaveLength(0) }) }) From 7194b2c6fd1c24b1319d1276decf634ab56f2307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:42:16 +0100 Subject: [PATCH 16/73] test(vscode): add formatRelativeDate boundary tests --- packages/kilo-vscode/tests/unit/date.test.ts | 83 ++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 packages/kilo-vscode/tests/unit/date.test.ts diff --git a/packages/kilo-vscode/tests/unit/date.test.ts b/packages/kilo-vscode/tests/unit/date.test.ts new file mode 100644 index 0000000000..fa84447bb6 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/date.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "bun:test" +import { formatRelativeDate } from "../../webview-ui/src/utils/date" + +function ago(ms: number): string { + return new Date(Date.now() - ms).toISOString() +} + +const SEC = 1000 +const MIN = 60 * SEC +const HOUR = 60 * MIN +const DAY = 24 * HOUR +const MONTH = 30 * DAY + +describe("formatRelativeDate", () => { + it("returns 'just now' for future timestamps", () => { + const future = new Date(Date.now() + 5000).toISOString() + expect(formatRelativeDate(future)).toBe("just now") + }) + + it("returns 'just now' for 0 seconds ago", () => { + expect(formatRelativeDate(new Date().toISOString())).toBe("just now") + }) + + it("returns 'just now' for 30 seconds ago", () => { + expect(formatRelativeDate(ago(30 * SEC))).toBe("just now") + }) + + it("returns 'just now' for 59 seconds ago", () => { + expect(formatRelativeDate(ago(59 * SEC))).toBe("just now") + }) + + it("returns '1 min ago' for exactly 1 minute ago", () => { + expect(formatRelativeDate(ago(MIN))).toBe("1 min ago") + }) + + it("returns '5 min ago' for 5 minutes ago", () => { + expect(formatRelativeDate(ago(5 * MIN))).toBe("5 min ago") + }) + + it("returns '59 min ago' for 59 minutes ago", () => { + expect(formatRelativeDate(ago(59 * MIN))).toBe("59 min ago") + }) + + it("returns '1h ago' for exactly 1 hour ago", () => { + expect(formatRelativeDate(ago(HOUR))).toBe("1h ago") + }) + + it("returns '12h ago' for 12 hours ago", () => { + expect(formatRelativeDate(ago(12 * HOUR))).toBe("12h ago") + }) + + it("returns '23h ago' for 23 hours ago", () => { + expect(formatRelativeDate(ago(23 * HOUR))).toBe("23h ago") + }) + + it("returns '1d ago' for exactly 1 day ago", () => { + expect(formatRelativeDate(ago(DAY))).toBe("1d ago") + }) + + it("returns '7d ago' for 7 days ago", () => { + expect(formatRelativeDate(ago(7 * DAY))).toBe("7d ago") + }) + + it("returns '29d ago' for 29 days ago", () => { + expect(formatRelativeDate(ago(29 * DAY))).toBe("29d ago") + }) + + it("returns '1mo ago' for exactly 30 days ago", () => { + expect(formatRelativeDate(ago(MONTH))).toBe("1mo ago") + }) + + it("returns '6mo ago' for 6 months ago", () => { + expect(formatRelativeDate(ago(6 * MONTH))).toBe("6mo ago") + }) + + it("returns 'just now' for invalid ISO string (fallback to now)", () => { + expect(formatRelativeDate("not-a-date")).toBe("just now") + }) + + it("returns 'just now' for empty string (fallback to now)", () => { + expect(formatRelativeDate("")).toBe("just now") + }) +}) From 442c84326469993e45a1d90774a0d32150c0e579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:42:46 +0100 Subject: [PATCH 17/73] test(vscode): add autocomplete duplicate/repetition filter tests --- .../unit/useless-suggestion-filter.test.ts | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts diff --git a/packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts b/packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts new file mode 100644 index 0000000000..3bea2aad07 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "bun:test" +import { + suggestionConsideredDuplication, + postprocessAutocompleteSuggestion, +} from "../../src/services/autocomplete/classic-auto-complete/uselessSuggestionFilter" + +describe("suggestionConsideredDuplication", () => { + describe("DuplicatesFromPrefixOrSuffix", () => { + it("filters empty suggestion", () => { + expect(suggestionConsideredDuplication({ suggestion: "", prefix: "abc", suffix: "" })).toBe(true) + }) + + it("filters whitespace-only suggestion", () => { + expect(suggestionConsideredDuplication({ suggestion: " ", prefix: "abc", suffix: "" })).toBe(true) + }) + + it("filters suggestion already at end of prefix", () => { + expect( + suggestionConsideredDuplication({ suggestion: "return x", prefix: "function foo() {\n return x", suffix: "" }), + ).toBe(true) + }) + + it("filters suggestion already at start of suffix", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "const y = 2", + prefix: "const x = 1\n", + suffix: "const y = 2\n", + }), + ).toBe(true) + }) + + it("passes unique suggestion not in prefix or suffix", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "const result = x + y", + prefix: "function add(x, y) {\n ", + suffix: "\n}", + }), + ).toBe(false) + }) + }) + + describe("DuplicatesFromEdgeLines (multiline)", () => { + it("filters multiline when first line matches last prefix line", () => { + expect( + suggestionConsideredDuplication({ + suggestion: " return x\n return y", + prefix: "function foo() {\n return x", + suffix: "\n}", + }), + ).toBe(true) + }) + + it("filters multiline when last line matches first suffix line", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "const a = 1\nconst b = 2", + prefix: "function setup() {\n", + suffix: "const b = 2\n}", + }), + ).toBe(true) + }) + + it("does not treat single-line suggestion as edge-line duplicate", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "const x = 1", + prefix: "function foo() {\n", + suffix: "const x = 2\n}", + }), + ).toBe(false) + }) + }) + + describe("containsRepetitivePhraseFromPrefix", () => { + it("filters looping suggestion with repeated phrase", () => { + const phrase = "the beginning. We are going to start from " + const suggestion = phrase + phrase + phrase + phrase + expect( + suggestionConsideredDuplication({ + suggestion, + prefix: "Let's start from ", + suffix: "", + }), + ).toBe(true) + }) + + it("passes short suggestion without repetition", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "const x = getValue()", + prefix: "// compute\n", + suffix: "", + }), + ).toBe(false) + }) + }) + + describe("normalizeToCompleteLine", () => { + it("expands partial prefix tail + suffix head and detects duplication", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "onst x = 1", + prefix: "// line\nc", + suffix: " // end\nmore", + }), + ).toBe(false) + }) + }) +}) + +describe("postprocessAutocompleteSuggestion", () => { + it("returns undefined for duplicate suggestion", () => { + const result = postprocessAutocompleteSuggestion({ + suggestion: "return x", + prefix: "function foo() {\n return x", + suffix: "", + model: "codestral", + }) + expect(result).toBeUndefined() + }) + + it("returns the suggestion when it is unique", () => { + const result = postprocessAutocompleteSuggestion({ + suggestion: " return x + y;", + prefix: "function add(x, y) {\n", + suffix: "\n}", + model: "codestral", + }) + expect(typeof result === "string" || result === undefined).toBe(true) + }) + + it("returns undefined for empty suggestion", () => { + const result = postprocessAutocompleteSuggestion({ + suggestion: "", + prefix: "const x = ", + suffix: "", + model: "gpt-4", + }) + expect(result).toBeUndefined() + }) +}) From a11efbc1a9e3f24aaa04b30212871c8e6f5a37a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:43:20 +0100 Subject: [PATCH 18/73] test(vscode): add contextual skip and language terminator tests --- .../tests/unit/contextual-skip.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 packages/kilo-vscode/tests/unit/contextual-skip.test.ts diff --git a/packages/kilo-vscode/tests/unit/contextual-skip.test.ts b/packages/kilo-vscode/tests/unit/contextual-skip.test.ts new file mode 100644 index 0000000000..a02e69c4c0 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/contextual-skip.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "bun:test" +import { + shouldSkipAutocomplete, + getTerminatorsForLanguage, +} from "../../src/services/autocomplete/classic-auto-complete/contextualSkip" + +describe("getTerminatorsForLanguage", () => { + it("returns c-like terminators for typescript", () => { + const t = getTerminatorsForLanguage("typescript") + expect(t).toContain(";") + expect(t).toContain("}") + expect(t).toContain(")") + expect(t).not.toContain(",") + }) + + it("returns python terminators (brackets, no semicolon)", () => { + const t = getTerminatorsForLanguage("python") + expect(t).toContain(")") + expect(t).toContain("]") + expect(t).toContain("}") + expect(t).not.toContain(";") + }) + + it("returns empty terminators for html", () => { + expect(getTerminatorsForLanguage("html")).toHaveLength(0) + }) + + it("returns shell terminators including fi and done", () => { + const t = getTerminatorsForLanguage("shellscript") + expect(t).toContain(";") + expect(t).toContain("fi") + expect(t).toContain("done") + }) + + it("returns default terminators for unknown language", () => { + const t = getTerminatorsForLanguage("unknown-lang") + expect(t).toContain(";") + expect(t).toContain("}") + expect(t).toContain(")") + }) + + it("returns default terminators when languageId is undefined", () => { + const t = getTerminatorsForLanguage(undefined) + expect(t).toContain(";") + }) +}) + +describe("shouldSkipAutocomplete - end of statement", () => { + it("skips after semicolon in typescript", () => { + expect(shouldSkipAutocomplete("const x = 5;", "\n", "typescript")).toBe(true) + }) + + it("skips after closing brace in typescript", () => { + expect(shouldSkipAutocomplete("}", "\n", "typescript")).toBe(true) + }) + + it("skips after closing paren in typescript", () => { + expect(shouldSkipAutocomplete("myFunction()", "\n", "typescript")).toBe(true) + }) + + it("does not skip after colon in typescript", () => { + expect(shouldSkipAutocomplete(" key:", "\n", "typescript")).toBe(false) + }) + + it("does not skip after opening brace", () => { + expect(shouldSkipAutocomplete("if (condition) {", "\n", "typescript")).toBe(false) + }) + + it("does not skip when suffix has non-whitespace on same line", () => { + expect(shouldSkipAutocomplete("const x = ", " + 1;\n", "typescript")).toBe(false) + }) + + it("does not skip on empty line", () => { + expect(shouldSkipAutocomplete("", "\n", "typescript")).toBe(false) + }) + + it("skips after semicolon with trailing whitespace", () => { + expect(shouldSkipAutocomplete("const x = 5; ", "\n", "typescript")).toBe(true) + }) + + it("does not skip in python after colon (block start)", () => { + expect(shouldSkipAutocomplete("def foo():", "\n", "python")).toBe(false) + }) + + it("skips in python after closing paren", () => { + expect(shouldSkipAutocomplete("print('hello')", "\n", "python")).toBe(true) + }) + + it("skips in html due to mid-word typing (not terminator)", () => { + expect(shouldSkipAutocomplete(" { + expect(shouldSkipAutocomplete("
", "\n", "html")).toBe(false) + }) +}) + +describe("shouldSkipAutocomplete - mid-word typing", () => { + it("skips when typing a word longer than 2 chars", () => { + expect(shouldSkipAutocomplete("myVariable", "\n", "typescript")).toBe(true) + }) + + it("does not skip for 1-2 char word", () => { + expect(shouldSkipAutocomplete("my", "\n", "typescript")).toBe(false) + expect(shouldSkipAutocomplete("x", "\n", "typescript")).toBe(false) + }) + + it("skips when suffix starts with word character", () => { + expect(shouldSkipAutocomplete("if (", "condition) {\n", "typescript")).toBe(true) + }) + + it("does not skip when prefix is empty", () => { + expect(shouldSkipAutocomplete("", "", "typescript")).toBe(false) + }) +}) + +describe("shouldSkipAutocomplete - defaults", () => { + it("uses default terminators when no languageId provided", () => { + expect(shouldSkipAutocomplete("const x = 5;", "\n")).toBe(true) + expect(shouldSkipAutocomplete("}", "\n")).toBe(true) + }) + + it("does not skip on empty input with no language", () => { + expect(shouldSkipAutocomplete("", "")).toBe(false) + }) +}) From 81601ff715150ec24ab49bd89a368f8a5749e6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:43:50 +0100 Subject: [PATCH 19/73] test(vscode): add autocomplete i18n shim interpolation tests --- .../kilo-vscode/tests/unit/i18n-shim.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/kilo-vscode/tests/unit/i18n-shim.test.ts diff --git a/packages/kilo-vscode/tests/unit/i18n-shim.test.ts b/packages/kilo-vscode/tests/unit/i18n-shim.test.ts new file mode 100644 index 0000000000..ce586428cc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/i18n-shim.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "bun:test" +import { t } from "../../src/services/autocomplete/shims/i18n" + +describe("t()", () => { + it("returns translated string for known key", () => { + const result = t("kilocode:autocomplete.statusBar.enabled") + expect(typeof result).toBe("string") + expect(result.length).toBeGreaterThan(0) + expect(result).not.toBe("kilocode:autocomplete.statusBar.enabled") + }) + + it("returns the key itself for unknown key", () => { + expect(t("nonexistent.key.that.does.not.exist")).toBe("nonexistent.key.that.does.not.exist") + }) + + it("returns empty string for empty key", () => { + expect(t("")).toBe("") + }) + + it("interpolates a single variable", () => { + const result = t("kilocode:autocomplete.statusBar.tooltip.noUsableProvider", { + providers: "OpenAI, Anthropic", + }) + expect(result).toContain("OpenAI, Anthropic") + expect(result).not.toContain("{{providers}}") + }) + + it("interpolates multiple variables", () => { + const result = t("kilocode:autocomplete.statusBar.tooltip.completionSummary", { + count: "5", + startTime: "10:00", + endTime: "11:00", + cost: "$0.05", + }) + expect(result).toContain("5") + expect(result).toContain("10:00") + expect(result).toContain("11:00") + expect(result).toContain("$0.05") + expect(result).not.toContain("{{") + }) + + it("interpolates numeric variable as string", () => { + const result = t("kilocode:autocomplete.statusBar.tooltip.noUsableProvider", { + providers: 42 as unknown as string, + }) + expect(result).toContain("42") + }) + + it("leaves unreferenced vars intact in template", () => { + const key = "kilocode:autocomplete.statusBar.tooltip.noUsableProvider" + const result = t(key, { unrelated: "value" }) + expect(result).toContain("{{providers}}") + }) + + it("returns the raw key when called without vars on a template key", () => { + const result = t("kilocode:autocomplete.statusBar.tooltip.noUsableProvider") + expect(result).toContain("{{providers}}") + }) + + it("handles empty vars object (no interpolation)", () => { + const result = t("kilocode:autocomplete.statusBar.enabled", {}) + expect(typeof result).toBe("string") + expect(result).not.toContain("{{") + }) +}) From dda9d3498ecdd6d4a1a37adf31c09fec909d023b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:46:21 +0100 Subject: [PATCH 20/73] test(vscode): extract KiloProvider pure transforms and add unit tests --- packages/kilo-vscode/src/KiloProvider.ts | 161 ++------- .../kilo-vscode/src/kilo-provider-utils.ts | 147 ++++++++ .../tests/unit/kilo-provider-utils.test.ts | 339 ++++++++++++++++++ 3 files changed, 508 insertions(+), 139 deletions(-) create mode 100644 packages/kilo-vscode/src/kilo-provider-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index a5dabc9d98..d85e5ceb82 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -5,6 +5,13 @@ import { handleChatCompletionRequest } from "./services/autocomplete/chat-autoco import { handleChatCompletionAccepted } from "./services/autocomplete/chat-autocomplete/handleChatCompletionAccepted" import { buildWebviewHtml } from "./utils" import { TelemetryProxy, type TelemetryPropertiesProvider } from "./services/telemetry" +import { + sessionToWebview, + normalizeProviders, + filterVisibleAgents, + buildSettingPath, + mapSSEEventToWebviewMessage, +} from "./kilo-provider-utils" export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider { public static readonly viewType = "kilo-code.new.sidebarView" @@ -505,16 +512,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - /** - * Convert SessionInfo to webview format. - */ private sessionToWebview(session: SessionInfo) { - return { - id: session.id, - title: session.title, - createdAt: new Date(session.time.created).toISOString(), - updatedAt: new Date(session.time.updated).toISOString(), - } + return sessionToWebview(session) } /** @@ -786,11 +785,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const workspaceDir = this.getWorkspaceDirectory() const response = await this.httpClient.listProviders(workspaceDir) - // Re-key providers from numeric indices to provider.id - const normalized: typeof response.all = {} - for (const provider of Object.values(response.all)) { - normalized[provider.id] = provider - } + const normalized = normalizeProviders(response.all) const config = vscode.workspace.getConfiguration("kilo-code.new.model") const providerID = config.get("providerID", "kilo") @@ -825,11 +820,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const workspaceDir = this.getWorkspaceDirectory() const agents = await this.httpClient.listAgents(workspaceDir) - // Filter to only visible primary/all modes (not subagents, not hidden) - const visible = agents.filter((a) => a.mode !== "subagent" && !a.hidden) - - // Find default agent: first one in list (CLI sorts default first) - const defaultAgent = visible.length > 0 ? visible[0].name : "code" + const { visible, defaultAgent } = filterVisibleAgents(agents) const message = { type: "agentsLoaded", @@ -1256,9 +1247,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * The key uses dot notation relative to `kilo-code.new` (e.g. "browserAutomation.enabled"). */ private async handleUpdateSetting(key: string, value: unknown): Promise { - const parts = key.split(".") - const section = parts.slice(0, -1).join(".") - const leaf = parts[parts.length - 1] + const { section, leaf } = buildSettingPath(key) const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`) await config.update(leaf, value, vscode.ConfigurationTarget.Global) } @@ -1342,124 +1331,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } // Forward relevant events to webview - switch (event.type) { - case "message.part.updated": { - // The part contains the full part data including messageID, delta is optional text delta - const part = event.properties.part as { messageID?: string; sessionID?: string } - const messageID = part.messageID || "" + // Side effects that must happen before the webview message is sent + if (event.type === "session.created" && !this.currentSession) { + this.currentSession = event.properties.info + this.trackedSessionIds.add(event.properties.info.id) + } + if (event.type === "session.updated" && this.currentSession?.id === event.properties.info.id) { + this.currentSession = event.properties.info + } - const resolvedSessionID = sessionID - if (!resolvedSessionID) { - return - } - this.postMessage({ - type: "partUpdated", - sessionID: resolvedSessionID, - messageID, - part: event.properties.part, - delta: event.properties.delta ? { type: "text-delta", textDelta: event.properties.delta } : undefined, - }) - break - } - - case "message.updated": - // Message info updated — forward cost/tokens for assistant messages - this.postMessage({ - type: "messageCreated", - message: { - id: event.properties.info.id, - sessionID: event.properties.info.sessionID, - role: event.properties.info.role, - createdAt: new Date(event.properties.info.time.created).toISOString(), - cost: event.properties.info.cost, - tokens: event.properties.info.tokens, - }, - }) - break - - case "session.status": { - const info = event.properties.status - this.postMessage({ - type: "sessionStatus", - sessionID: event.properties.sessionID, - status: info.type, - ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}), - }) - break - } - - case "permission.asked": - this.postMessage({ - type: "permissionRequest", - permission: { - id: event.properties.id, - sessionID: event.properties.sessionID, - toolName: event.properties.permission, - patterns: event.properties.patterns ?? [], - args: event.properties.metadata, - message: `Permission required: ${event.properties.permission}`, - tool: event.properties.tool, - }, - }) - break - - case "todo.updated": - this.postMessage({ - type: "todoUpdated", - sessionID: event.properties.sessionID, - items: event.properties.items, - }) - break - - case "question.asked": - this.postMessage({ - type: "questionRequest", - question: { - id: event.properties.id, - sessionID: event.properties.sessionID, - questions: event.properties.questions, - tool: event.properties.tool, - }, - }) - break - - case "question.replied": - this.postMessage({ - type: "questionResolved", - requestID: event.properties.requestID, - }) - break - - case "question.rejected": - this.postMessage({ - type: "questionResolved", - requestID: event.properties.requestID, - }) - break - - case "session.created": - // Store session if we don't have one yet - if (!this.currentSession) { - this.currentSession = event.properties.info - this.trackedSessionIds.add(event.properties.info.id) - } - // Notify webview - this.postMessage({ - type: "sessionCreated", - session: this.sessionToWebview(event.properties.info), - }) - break - - case "session.updated": - // Keep local state in sync (e.g. title generation) - if (this.currentSession?.id === event.properties.info.id) { - this.currentSession = event.properties.info - } - this.postMessage({ - type: "sessionUpdated", - session: this.sessionToWebview(event.properties.info), - }) - break + const msg = mapSSEEventToWebviewMessage(event, sessionID) + if (msg) { + this.postMessage(msg) } } diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts new file mode 100644 index 0000000000..9abd47f501 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -0,0 +1,147 @@ +import type { SessionInfo, AgentInfo, Provider, SSEEvent } from "./services/cli-backend/types" + +export function sessionToWebview(session: SessionInfo) { + return { + id: session.id, + title: session.title, + createdAt: new Date(session.time.created).toISOString(), + updatedAt: new Date(session.time.updated).toISOString(), + } +} + +export function normalizeProviders(all: Record): Record { + const normalized: Record = {} + for (const provider of Object.values(all)) { + normalized[provider.id] = provider + } + return normalized +} + +export function filterVisibleAgents(agents: AgentInfo[]): { visible: AgentInfo[]; defaultAgent: string } { + const visible = agents.filter((a) => a.mode !== "subagent" && !a.hidden) + const defaultAgent = visible.length > 0 ? visible[0]!.name : "code" + return { visible, defaultAgent } +} + +export function buildSettingPath(key: string): { section: string; leaf: string } { + const parts = key.split(".") + const section = parts.slice(0, -1).join(".") + const leaf = parts[parts.length - 1]! + return { section, leaf } +} + +export type WebviewMessage = + | { + type: "partUpdated" + sessionID: string + messageID: string + part: unknown + delta?: { type: "text-delta"; textDelta: string } + } + | { + type: "messageCreated" + message: { id: string; sessionID: string; role: string; createdAt: string; cost?: number; tokens?: unknown } + } + | { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number } + | { + type: "permissionRequest" + permission: { + id: string + sessionID: string + toolName: string + patterns: string[] + args: Record + message: string + tool?: { messageID: string; callID: string } + } + } + | { type: "todoUpdated"; sessionID: string; items: unknown[] } + | { type: "questionRequest"; question: { id: string; sessionID: string; questions: unknown[]; tool?: unknown } } + | { type: "questionResolved"; requestID: string } + | { type: "sessionCreated"; session: ReturnType } + | { type: "sessionUpdated"; session: ReturnType } + | null + +export function mapSSEEventToWebviewMessage(event: SSEEvent, sessionID: string | undefined): WebviewMessage { + switch (event.type) { + case "message.part.updated": { + const part = event.properties.part as { messageID?: string; sessionID?: string } + if (!sessionID) return null + return { + type: "partUpdated", + sessionID, + messageID: part.messageID || "", + part: event.properties.part, + delta: event.properties.delta ? { type: "text-delta", textDelta: event.properties.delta } : undefined, + } + } + case "message.updated": + return { + type: "messageCreated", + message: { + id: event.properties.info.id, + sessionID: event.properties.info.sessionID, + role: event.properties.info.role, + createdAt: new Date(event.properties.info.time.created).toISOString(), + cost: event.properties.info.cost, + tokens: event.properties.info.tokens, + }, + } + case "session.status": { + const info = event.properties.status + return { + type: "sessionStatus", + sessionID: event.properties.sessionID, + status: info.type, + ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}), + } + } + case "permission.asked": + return { + type: "permissionRequest", + permission: { + id: event.properties.id, + sessionID: event.properties.sessionID, + toolName: event.properties.permission, + patterns: event.properties.patterns ?? [], + args: event.properties.metadata, + message: `Permission required: ${event.properties.permission}`, + tool: event.properties.tool, + }, + } + case "todo.updated": + return { + type: "todoUpdated", + sessionID: event.properties.sessionID, + items: event.properties.items, + } + case "question.asked": + return { + type: "questionRequest", + question: { + id: event.properties.id, + sessionID: event.properties.sessionID, + questions: event.properties.questions, + tool: event.properties.tool, + }, + } + case "question.replied": + case "question.rejected": + return { + type: "questionResolved", + requestID: event.properties.requestID, + } + case "session.created": + return { + type: "sessionCreated", + session: sessionToWebview(event.properties.info), + } + case "session.updated": + return { + type: "sessionUpdated", + session: sessionToWebview(event.properties.info), + } + default: + return null + } +} diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts new file mode 100644 index 0000000000..3806763777 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect } from "bun:test" +import { + sessionToWebview, + normalizeProviders, + filterVisibleAgents, + buildSettingPath, + mapSSEEventToWebviewMessage, +} from "../../src/kilo-provider-utils" +import type { SessionInfo, AgentInfo, Provider, SSEEvent } from "../../src/services/cli-backend/types" + +function makeSession(overrides: Partial = {}): SessionInfo { + return { + id: "sess-1", + title: "Test Session", + directory: "/tmp", + time: { created: 1700000000000, updated: 1700001000000 }, + ...overrides, + } +} + +function makeProvider(id: string): Provider { + return { id, name: id.toUpperCase(), models: {} } +} + +function makeAgent(overrides: Partial = {}): AgentInfo { + return { name: "code", mode: "primary", ...overrides } +} + +describe("sessionToWebview", () => { + it("converts epoch timestamps to ISO strings", () => { + const result = sessionToWebview(makeSession()) + expect(result.createdAt).toBe(new Date(1700000000000).toISOString()) + expect(result.updatedAt).toBe(new Date(1700001000000).toISOString()) + }) + + it("preserves id and title", () => { + const result = sessionToWebview(makeSession({ id: "abc", title: "My Session" })) + expect(result.id).toBe("abc") + expect(result.title).toBe("My Session") + }) + + it("produces valid ISO format", () => { + const result = sessionToWebview(makeSession()) + expect(() => new Date(result.createdAt)).not.toThrow() + expect(new Date(result.createdAt).getTime()).toBe(1700000000000) + }) +}) + +describe("normalizeProviders", () => { + it("re-keys providers from numeric indices to provider.id", () => { + const input = { "0": makeProvider("openai"), "1": makeProvider("anthropic") } + const result = normalizeProviders(input as Record) + expect(result["openai"]).toBeDefined() + expect(result["anthropic"]).toBeDefined() + expect(result["0"]).toBeUndefined() + expect(result["1"]).toBeUndefined() + }) + + it("handles empty input", () => { + expect(normalizeProviders({})).toEqual({}) + }) + + it("preserves provider data", () => { + const p = makeProvider("openai") + const result = normalizeProviders({ "0": p }) + expect(result["openai"]).toEqual(p) + }) + + it("handles already-keyed-by-id input (idempotent)", () => { + const p = makeProvider("openai") + const result = normalizeProviders({ openai: p }) + expect(result["openai"]).toEqual(p) + }) +}) + +describe("filterVisibleAgents", () => { + it("filters out subagent mode", () => { + const agents = [makeAgent({ name: "code", mode: "primary" }), makeAgent({ name: "sub", mode: "subagent" })] + const { visible } = filterVisibleAgents(agents) + expect(visible).toHaveLength(1) + expect(visible[0]!.name).toBe("code") + }) + + it("filters out hidden agents", () => { + const agents = [makeAgent({ name: "code" }), makeAgent({ name: "hidden", hidden: true })] + const { visible } = filterVisibleAgents(agents) + expect(visible).toHaveLength(1) + expect(visible[0]!.name).toBe("code") + }) + + it("uses first visible agent as default", () => { + const agents = [makeAgent({ name: "first" }), makeAgent({ name: "second" })] + const { defaultAgent } = filterVisibleAgents(agents) + expect(defaultAgent).toBe("first") + }) + + it("falls back to 'code' when no visible agents", () => { + const agents = [makeAgent({ mode: "subagent" }), makeAgent({ hidden: true })] + const { defaultAgent } = filterVisibleAgents(agents) + expect(defaultAgent).toBe("code") + }) + + it("handles empty agent list", () => { + const { visible, defaultAgent } = filterVisibleAgents([]) + expect(visible).toHaveLength(0) + expect(defaultAgent).toBe("code") + }) + + it("passes through all modes that are primary or all", () => { + const agents = [makeAgent({ name: "a", mode: "primary" }), makeAgent({ name: "b", mode: "all" })] + const { visible } = filterVisibleAgents(agents) + expect(visible).toHaveLength(2) + }) +}) + +describe("buildSettingPath", () => { + it("splits single-segment key into empty section and leaf", () => { + const { section, leaf } = buildSettingPath("enabled") + expect(section).toBe("") + expect(leaf).toBe("enabled") + }) + + it("splits two-segment key", () => { + const { section, leaf } = buildSettingPath("browserAutomation.enabled") + expect(section).toBe("browserAutomation") + expect(leaf).toBe("enabled") + }) + + it("splits three-segment key", () => { + const { section, leaf } = buildSettingPath("a.b.c") + expect(section).toBe("a.b") + expect(leaf).toBe("c") + }) + + it("handles empty-looking intermediate segments", () => { + const { section, leaf } = buildSettingPath("foo..bar") + expect(leaf).toBe("bar") + expect(section).toBe("foo.") + }) +}) + +describe("mapSSEEventToWebviewMessage", () => { + it("maps message.part.updated to partUpdated", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { + part: { type: "text", id: "p1", text: "hello", messageID: "m1" }, + delta: "hello", + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("partUpdated") + if (msg?.type === "partUpdated") { + expect(msg.sessionID).toBe("sess-1") + expect(msg.messageID).toBe("m1") + expect(msg.delta).toEqual({ type: "text-delta", textDelta: "hello" }) + } + }) + + it("returns null for message.part.updated when sessionID is undefined", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { part: { type: "text", id: "p1", text: "" } }, + } + expect(mapSSEEventToWebviewMessage(event, undefined)).toBeNull() + }) + + it("maps message.updated to messageCreated with ISO date", () => { + const event: SSEEvent = { + type: "message.updated", + properties: { + info: { + id: "msg-1", + sessionID: "sess-1", + role: "assistant", + time: { created: 1700000000000 }, + cost: 0.001, + }, + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("messageCreated") + if (msg?.type === "messageCreated") { + expect(msg.message.createdAt).toBe(new Date(1700000000000).toISOString()) + expect(msg.message.cost).toBe(0.001) + } + }) + + it("maps session.status idle to sessionStatus", () => { + const event: SSEEvent = { + type: "session.status", + properties: { sessionID: "sess-1", status: { type: "idle" } }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("sessionStatus") + if (msg?.type === "sessionStatus") { + expect(msg.status).toBe("idle") + expect(msg.attempt).toBeUndefined() + } + }) + + it("maps session.status retry with attempt/message/next", () => { + const event: SSEEvent = { + type: "session.status", + properties: { + sessionID: "sess-1", + status: { type: "retry", attempt: 2, message: "trying again", next: 5000 }, + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + if (msg?.type === "sessionStatus") { + expect(msg.attempt).toBe(2) + expect(msg.message).toBe("trying again") + expect(msg.next).toBe(5000) + } + }) + + it("maps permission.asked to permissionRequest", () => { + const event: SSEEvent = { + type: "permission.asked", + properties: { + id: "perm-1", + sessionID: "sess-1", + permission: "read_file", + patterns: ["**/*.ts"], + metadata: { path: "/foo" }, + always: [], + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("permissionRequest") + if (msg?.type === "permissionRequest") { + expect(msg.permission.toolName).toBe("read_file") + expect(msg.permission.args).toEqual({ path: "/foo" }) + expect(msg.permission.message).toBe("Permission required: read_file") + expect(msg.permission.patterns).toEqual(["**/*.ts"]) + } + }) + + it("defaults patterns to [] when not provided in permission.asked", () => { + const event = { + type: "permission.asked" as const, + properties: { + id: "p1", + sessionID: "s1", + permission: "write_file", + metadata: {}, + always: [], + }, + } + const msg = mapSSEEventToWebviewMessage(event, "s1") + if (msg?.type === "permissionRequest") { + expect(msg.permission.patterns).toEqual([]) + } + }) + + it("maps todo.updated to todoUpdated", () => { + const event: SSEEvent = { + type: "todo.updated", + properties: { + sessionID: "sess-1", + items: [{ id: "t1", content: "do something", status: "pending" }], + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("todoUpdated") + if (msg?.type === "todoUpdated") { + expect(msg.items).toHaveLength(1) + } + }) + + it("maps question.asked to questionRequest", () => { + const event: SSEEvent = { + type: "question.asked", + properties: { + id: "q1", + sessionID: "sess-1", + questions: [], + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("questionRequest") + }) + + it("maps question.replied to questionResolved", () => { + const event: SSEEvent = { + type: "question.replied", + properties: { sessionID: "sess-1", requestID: "req-1", answers: [] }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("questionResolved") + if (msg?.type === "questionResolved") { + expect(msg.requestID).toBe("req-1") + } + }) + + it("maps question.rejected to questionResolved", () => { + const event: SSEEvent = { + type: "question.rejected", + properties: { sessionID: "sess-1", requestID: "req-2" }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("questionResolved") + if (msg?.type === "questionResolved") { + expect(msg.requestID).toBe("req-2") + } + }) + + it("maps session.created to sessionCreated with ISO dates", () => { + const event: SSEEvent = { + type: "session.created", + properties: { info: makeSession() }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("sessionCreated") + if (msg?.type === "sessionCreated") { + expect(msg.session.createdAt).toBe(new Date(1700000000000).toISOString()) + } + }) + + it("maps session.updated to sessionUpdated with ISO dates", () => { + const event: SSEEvent = { + type: "session.updated", + properties: { info: makeSession({ id: "sess-2" }) }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-2") + expect(msg?.type).toBe("sessionUpdated") + }) + + it("returns null for server.connected (no webview message)", () => { + const event: SSEEvent = { type: "server.connected", properties: {} } + expect(mapSSEEventToWebviewMessage(event, undefined)).toBeNull() + }) + + it("returns null for server.heartbeat", () => { + const event: SSEEvent = { type: "server.heartbeat", properties: {} } + expect(mapSSEEventToWebviewMessage(event, undefined)).toBeNull() + }) +}) From f9fce33c114269d2c754473d3e2ceafee8bfecbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:47:18 +0100 Subject: [PATCH 21/73] test(vscode): extract resolveEventSessionId and add SSE routing tests --- .../cli-backend/connection-service.ts | 36 +--- .../services/cli-backend/connection-utils.ts | 44 +++++ .../tests/unit/connection-utils.test.ts | 156 ++++++++++++++++++ 3 files changed, 206 insertions(+), 30 deletions(-) create mode 100644 packages/kilo-vscode/src/services/cli-backend/connection-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/connection-utils.test.ts diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 004585bd70..99634fef03 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -3,6 +3,7 @@ import { ServerManager } from "./server-manager" import { HttpClient } from "./http-client" import { SSEClient } from "./sse-client" import type { ServerConfig, SSEEvent } from "./types" +import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" type SSEEventListener = (event: SSEEvent) => void @@ -131,36 +132,11 @@ export class KiloConnectionService { * Returns undefined for global events. */ resolveEventSessionId(event: SSEEvent): string | undefined { - switch (event.type) { - case "session.created": - case "session.updated": - return event.properties.info.id - case "session.status": - case "session.idle": - case "todo.updated": - return event.properties.sessionID - case "message.updated": - this.recordMessageSessionId(event.properties.info.id, event.properties.info.sessionID) - return event.properties.info.sessionID - case "message.part.updated": { - const part = event.properties.part as { messageID?: string; sessionID?: string } - if (part.sessionID) { - return part.sessionID - } - if (!part.messageID) { - return undefined - } - return this.messageSessionIdsByMessageId.get(part.messageID) - } - case "permission.asked": - case "permission.replied": - case "question.asked": - case "question.replied": - case "question.rejected": - return event.properties.sessionID - default: - return undefined - } + return resolveEventSessionIdPure( + event, + (messageId) => this.messageSessionIdsByMessageId.get(messageId), + (messageId, sessionId) => this.recordMessageSessionId(messageId, sessionId), + ) } /** diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts new file mode 100644 index 0000000000..b6cc01d845 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -0,0 +1,44 @@ +import type { SSEEvent } from "./types" + +/** + * Pure session ID resolution for SSE events. + * The lookupMessageSessionId callback is used for message.part.updated fallback lookup, + * and onMessageUpdated is called when message.updated is encountered so the caller can + * record the messageID -> sessionID mapping. + */ +export function resolveEventSessionId( + event: SSEEvent, + lookupMessageSessionId: (messageId: string) => string | undefined, + onMessageUpdated?: (messageId: string, sessionId: string) => void, +): string | undefined { + switch (event.type) { + case "session.created": + case "session.updated": + return event.properties.info.id + case "session.status": + case "session.idle": + case "todo.updated": + return event.properties.sessionID + case "message.updated": + onMessageUpdated?.(event.properties.info.id, event.properties.info.sessionID) + return event.properties.info.sessionID + case "message.part.updated": { + const part = event.properties.part as { messageID?: string; sessionID?: string } + if (part.sessionID) { + return part.sessionID + } + if (!part.messageID) { + return undefined + } + return lookupMessageSessionId(part.messageID) + } + case "permission.asked": + case "permission.replied": + case "question.asked": + case "question.replied": + case "question.rejected": + return event.properties.sessionID + default: + return undefined + } +} diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts new file mode 100644 index 0000000000..e981e7df69 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "bun:test" +import { resolveEventSessionId } from "../../src/services/cli-backend/connection-utils" +import type { SSEEvent } from "../../src/services/cli-backend/types" + +const noLookup = (_: string) => undefined + +describe("resolveEventSessionId", () => { + it("returns session id from session.created", () => { + const event: SSEEvent = { + type: "session.created", + properties: { + info: { id: "s1", title: "", directory: "", time: { created: 0, updated: 0 } }, + }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s1") + }) + + it("returns session id from session.updated", () => { + const event: SSEEvent = { + type: "session.updated", + properties: { + info: { id: "s2", title: "", directory: "", time: { created: 0, updated: 0 } }, + }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s2") + }) + + it("returns sessionID from session.status", () => { + const event: SSEEvent = { + type: "session.status", + properties: { sessionID: "s3", status: { type: "idle" } }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s3") + }) + + it("returns sessionID from todo.updated", () => { + const event: SSEEvent = { + type: "todo.updated", + properties: { sessionID: "s4", items: [] }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s4") + }) + + it("returns sessionID from message.updated and calls onMessageUpdated", () => { + const event: SSEEvent = { + type: "message.updated", + properties: { + info: { id: "m1", sessionID: "s5", role: "assistant", time: { created: 0 } }, + }, + } + const recorded: [string, string][] = [] + const result = resolveEventSessionId(event, noLookup, (mid, sid) => recorded.push([mid, sid])) + expect(result).toBe("s5") + expect(recorded).toEqual([["m1", "s5"]]) + }) + + it("message.updated does not require onMessageUpdated callback", () => { + const event: SSEEvent = { + type: "message.updated", + properties: { + info: { id: "m1", sessionID: "s5", role: "assistant", time: { created: 0 } }, + }, + } + expect(() => resolveEventSessionId(event, noLookup)).not.toThrow() + }) + + it("returns sessionID directly from message.part.updated when part has sessionID", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { + part: { type: "text", id: "p1", text: "", sessionID: "s6", messageID: "m1" }, + }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s6") + }) + + it("falls back to lookup when message.part.updated has no sessionID but has messageID", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { + part: { type: "text", id: "p1", text: "", messageID: "m2" }, + }, + } + const lookup = (id: string) => (id === "m2" ? "s7" : undefined) + expect(resolveEventSessionId(event, lookup)).toBe("s7") + }) + + it("returns undefined for message.part.updated with no sessionID and messageID not in map", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { + part: { type: "text", id: "p1", text: "", messageID: "unknown" }, + }, + } + expect(resolveEventSessionId(event, noLookup)).toBeUndefined() + }) + + it("returns undefined for message.part.updated with no messageID and no sessionID", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { + part: { type: "text", id: "p1", text: "" }, + }, + } + expect(resolveEventSessionId(event, noLookup)).toBeUndefined() + }) + + it("returns sessionID from permission.asked", () => { + const event: SSEEvent = { + type: "permission.asked", + properties: { + id: "p1", + sessionID: "s8", + permission: "read_file", + patterns: [], + metadata: {}, + always: [], + }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s8") + }) + + it("returns sessionID from question.asked", () => { + const event: SSEEvent = { + type: "question.asked", + properties: { id: "q1", sessionID: "s9", questions: [] }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s9") + }) + + it("returns sessionID from question.replied", () => { + const event: SSEEvent = { + type: "question.replied", + properties: { sessionID: "s10", requestID: "r1", answers: [] }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s10") + }) + + it("returns sessionID from question.rejected", () => { + const event: SSEEvent = { + type: "question.rejected", + properties: { sessionID: "s11", requestID: "r2" }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s11") + }) + + it("returns undefined for server.connected (global event)", () => { + const event: SSEEvent = { type: "server.connected", properties: {} } + expect(resolveEventSessionId(event, noLookup)).toBeUndefined() + }) + + it("returns undefined for server.heartbeat (global event)", () => { + const event: SSEEvent = { type: "server.heartbeat", properties: {} } + expect(resolveEventSessionId(event, noLookup)).toBeUndefined() + }) +}) From 268f6b53061344097f10609e8d3bd5f540b88063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:48:26 +0100 Subject: [PATCH 22/73] test(vscode): extract HTTP error parsing and SSE line parser with tests --- .../src/services/cli-backend/http-client.ts | 49 ++------ .../src/services/cli-backend/http-utils.ts | 58 +++++++++ .../tests/unit/http-client-utils.test.ts | 115 ++++++++++++++++++ 3 files changed, 181 insertions(+), 41 deletions(-) create mode 100644 packages/kilo-vscode/src/services/cli-backend/http-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/http-client-utils.test.ts diff --git a/packages/kilo-vscode/src/services/cli-backend/http-client.ts b/packages/kilo-vscode/src/services/cli-backend/http-client.ts index 6871465b13..09a709376e 100644 --- a/packages/kilo-vscode/src/services/cli-backend/http-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/http-client.ts @@ -12,6 +12,7 @@ import type { McpConfig, Config, } from "./types" +import { extractHttpErrorMessage, parseSSEDataLine } from "./http-utils" /** * HTTP Client for communicating with the CLI backend server. @@ -67,15 +68,7 @@ export class HttpClient { // Non-2xx: try to extract an error message from JSON, otherwise fall back to raw text. if (!response.ok) { - let errorMessage = response.statusText - if (rawText.trim().length > 0) { - try { - const errorJson = JSON.parse(rawText) as { error?: string; message?: string } - errorMessage = errorJson.error || errorJson.message || errorMessage - } catch { - errorMessage = rawText - } - } + const errorMessage = extractHttpErrorMessage(response.statusText, rawText) console.error("[Kilo New] HTTP: ❌ Request failed", { method, @@ -398,38 +391,12 @@ export class HttpClient { buffer = lines.pop() ?? "" // Keep incomplete line in buffer for (const line of lines) { - if (!line.startsWith("data: ")) { - continue - } - - const data = line.slice(6).trim() - if (data === "[DONE]") { - continue - } - - try { - const parsed = JSON.parse(data) as { - choices?: Array<{ delta?: { content?: string } }> - usage?: { prompt_tokens?: number; completion_tokens?: number } - cost?: number - } - - const content = parsed.choices?.[0]?.delta?.content - if (content) { - onChunk(content) - } - - if (parsed.usage) { - inputTokens = parsed.usage.prompt_tokens ?? 0 - outputTokens = parsed.usage.completion_tokens ?? 0 - } - - if (parsed.cost !== undefined) { - cost = parsed.cost - } - } catch { - // Skip malformed JSON lines - } + const chunk = parseSSEDataLine(line) + if (!chunk) continue + if (chunk.content) onChunk(chunk.content) + if (chunk.inputTokens !== undefined) inputTokens = chunk.inputTokens + if (chunk.outputTokens !== undefined) outputTokens = chunk.outputTokens + if (chunk.cost !== undefined) cost = chunk.cost } } diff --git a/packages/kilo-vscode/src/services/cli-backend/http-utils.ts b/packages/kilo-vscode/src/services/cli-backend/http-utils.ts new file mode 100644 index 0000000000..5b9e9374d0 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/http-utils.ts @@ -0,0 +1,58 @@ +/** + * Extract a human-readable error message from an HTTP error response. + * Tries to parse JSON and look for `error` or `message` fields; falls back to raw text. + */ +export function extractHttpErrorMessage(statusText: string, rawText: string): string { + if (rawText.trim().length === 0) { + return statusText + } + try { + const errorJson = JSON.parse(rawText) as { error?: string; message?: string } + return errorJson.error || errorJson.message || statusText + } catch { + return rawText + } +} + +export type SSEChunkResult = { + content?: string + inputTokens?: number + outputTokens?: number + cost?: number +} + +/** + * Parse a single SSE data line (starting with "data: ") into its structured parts. + * Returns null for non-data lines and the [DONE] sentinel. + */ +export function parseSSEDataLine(line: string): SSEChunkResult | null { + if (!line.startsWith("data: ")) { + return null + } + const data = line.slice(6).trim() + if (data === "[DONE]") { + return null + } + try { + const parsed = JSON.parse(data) as { + choices?: Array<{ delta?: { content?: string } }> + usage?: { prompt_tokens?: number; completion_tokens?: number } + cost?: number + } + const result: SSEChunkResult = {} + const content = parsed.choices?.[0]?.delta?.content + if (content) { + result.content = content + } + if (parsed.usage) { + result.inputTokens = parsed.usage.prompt_tokens ?? 0 + result.outputTokens = parsed.usage.completion_tokens ?? 0 + } + if (parsed.cost !== undefined) { + result.cost = parsed.cost + } + return result + } catch { + return null + } +} diff --git a/packages/kilo-vscode/tests/unit/http-client-utils.test.ts b/packages/kilo-vscode/tests/unit/http-client-utils.test.ts new file mode 100644 index 0000000000..80704b76ab --- /dev/null +++ b/packages/kilo-vscode/tests/unit/http-client-utils.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "bun:test" +import { extractHttpErrorMessage, parseSSEDataLine } from "../../src/services/cli-backend/http-utils" + +describe("extractHttpErrorMessage", () => { + it("extracts error field from JSON", () => { + const result = extractHttpErrorMessage("Bad Request", '{"error":"invalid token"}') + expect(result).toBe("invalid token") + }) + + it("extracts message field from JSON when error is absent", () => { + const result = extractHttpErrorMessage("Not Found", '{"message":"resource not found"}') + expect(result).toBe("resource not found") + }) + + it("prefers error over message when both present", () => { + const result = extractHttpErrorMessage("Bad Request", '{"error":"err","message":"msg"}') + expect(result).toBe("err") + }) + + it("falls back to statusText when JSON has neither error nor message", () => { + const result = extractHttpErrorMessage("Bad Request", '{"code":400}') + expect(result).toBe("Bad Request") + }) + + it("falls back to raw text when JSON parse fails", () => { + const result = extractHttpErrorMessage("Internal Server Error", "not json at all") + expect(result).toBe("not json at all") + }) + + it("returns statusText when rawText is empty", () => { + expect(extractHttpErrorMessage("Unauthorized", "")).toBe("Unauthorized") + }) + + it("returns statusText when rawText is whitespace only", () => { + expect(extractHttpErrorMessage("Forbidden", " ")).toBe("Forbidden") + }) + + it("falls back to statusText when error field is falsy empty string", () => { + const result = extractHttpErrorMessage("Bad Request", '{"error":""}') + expect(result).toBe("Bad Request") + }) +}) + +describe("parseSSEDataLine", () => { + it("returns null for non-data lines", () => { + expect(parseSSEDataLine("event: message")).toBeNull() + expect(parseSSEDataLine("id: 123")).toBeNull() + expect(parseSSEDataLine(": heartbeat")).toBeNull() + expect(parseSSEDataLine("")).toBeNull() + }) + + it("returns null for [DONE] sentinel", () => { + expect(parseSSEDataLine("data: [DONE]")).toBeNull() + }) + + it("returns null for malformed JSON", () => { + expect(parseSSEDataLine("data: {not json}")).toBeNull() + }) + + it("extracts content from choices delta", () => { + const line = 'data: {"choices":[{"delta":{"content":"hello"}}]}' + const result = parseSSEDataLine(line) + expect(result?.content).toBe("hello") + }) + + it("omits content when delta content is empty string", () => { + const line = 'data: {"choices":[{"delta":{"content":""}}]}' + const result = parseSSEDataLine(line) + expect(result?.content).toBeUndefined() + }) + + it("omits content when choices array is empty", () => { + const line = 'data: {"choices":[]}' + const result = parseSSEDataLine(line) + expect(result?.content).toBeUndefined() + }) + + it("extracts usage tokens", () => { + const line = 'data: {"usage":{"prompt_tokens":10,"completion_tokens":20}}' + const result = parseSSEDataLine(line) + expect(result?.inputTokens).toBe(10) + expect(result?.outputTokens).toBe(20) + }) + + it("defaults token counts to 0 when usage fields are missing", () => { + const line = 'data: {"usage":{}}' + const result = parseSSEDataLine(line) + expect(result?.inputTokens).toBe(0) + expect(result?.outputTokens).toBe(0) + }) + + it("extracts cost", () => { + const line = 'data: {"cost":0.0042}' + const result = parseSSEDataLine(line) + expect(result?.cost).toBe(0.0042) + }) + + it("extracts all fields in one chunk", () => { + const line = + 'data: {"choices":[{"delta":{"content":"world"}}],"usage":{"prompt_tokens":5,"completion_tokens":3},"cost":0.001}' + const result = parseSSEDataLine(line) + expect(result?.content).toBe("world") + expect(result?.inputTokens).toBe(5) + expect(result?.outputTokens).toBe(3) + expect(result?.cost).toBe(0.001) + }) + + it("returns empty object for valid JSON with no recognized fields", () => { + const line = 'data: {"id":"abc"}' + const result = parseSSEDataLine(line) + expect(result).not.toBeNull() + expect(result?.content).toBeUndefined() + expect(result?.cost).toBeUndefined() + }) +}) From d9f7d602ee909c437350489229a64c803d151c41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:49:17 +0100 Subject: [PATCH 23/73] test(vscode): extract CLI port parsing and add regex contract tests --- .../services/cli-backend/server-manager.ts | 7 ++- .../src/services/cli-backend/server-utils.ts | 10 ++++ .../tests/unit/server-manager-utils.test.ts | 46 +++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 packages/kilo-vscode/src/services/cli-backend/server-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/server-manager-utils.test.ts diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 679df2045b..f2dd0fa7cd 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -3,6 +3,7 @@ import * as crypto from "crypto" import * as fs from "fs" import * as path from "path" import * as vscode from "vscode" +import { parseServerPort } from "./server-utils" export interface ServerInstance { port: number @@ -85,11 +86,9 @@ export class ServerManager { const output = data.toString() console.log("[Kilo New] ServerManager: 📥 CLI Server stdout:", output) - // Parse: "kilo server listening on http://127.0.0.1:12345" - const match = output.match(/listening on http:\/\/[\w.]+:(\d+)/) - if (match && !resolved) { + const port = parseServerPort(output) + if (port !== null && !resolved) { resolved = true - const port = parseInt(match[1], 10) console.log("[Kilo New] ServerManager: 🎯 Port detected:", port) resolve({ port, password, process: serverProcess }) } diff --git a/packages/kilo-vscode/src/services/cli-backend/server-utils.ts b/packages/kilo-vscode/src/services/cli-backend/server-utils.ts new file mode 100644 index 0000000000..0daf711d02 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/server-utils.ts @@ -0,0 +1,10 @@ +/** + * Parse the port number from CLI server startup output. + * Matches lines like: "kilo server listening on http://127.0.0.1:12345" + * Returns the port number or null if not found. + */ +export function parseServerPort(output: string): number | null { + const match = output.match(/listening on http:\/\/[\w.]+:(\d+)/) + if (!match) return null + return parseInt(match[1]!, 10) +} diff --git a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts new file mode 100644 index 0000000000..84a1c5219e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "bun:test" +import { parseServerPort } from "../../src/services/cli-backend/server-utils" + +describe("parseServerPort", () => { + it("parses port from standard CLI startup message", () => { + expect(parseServerPort("kilo server listening on http://127.0.0.1:12345")).toBe(12345) + }) + + it("parses port from localhost variant", () => { + expect(parseServerPort("listening on http://localhost:8080")).toBe(8080) + }) + + it("parses port when embedded in longer output", () => { + const output = "[INFO] 2024-01-01 kilo server listening on http://127.0.0.1:54321\n[INFO] ready" + expect(parseServerPort(output)).toBe(54321) + }) + + it("returns null for output without listening message", () => { + expect(parseServerPort("Starting server...")).toBeNull() + }) + + it("returns null for empty string", () => { + expect(parseServerPort("")).toBeNull() + }) + + it("returns null when no port in URL", () => { + expect(parseServerPort("listening on http://127.0.0.1")).toBeNull() + }) + + it("parses high port numbers", () => { + expect(parseServerPort("listening on http://127.0.0.1:65535")).toBe(65535) + }) + + it("parses port 1 (edge case)", () => { + expect(parseServerPort("listening on http://127.0.0.1:1")).toBe(1) + }) + + it("returns null for stderr-style messages without port", () => { + expect(parseServerPort("[ERROR] failed to bind port")).toBeNull() + }) + + it("matches only first occurrence when multiple ports present", () => { + const output = "listening on http://127.0.0.1:3000 and http://127.0.0.1:4000" + expect(parseServerPort(output)).toBe(3000) + }) +}) From 6bf7d99d5a4ecdb06b598872a6c02bdc3c29f3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:50:21 +0100 Subject: [PATCH 24/73] test(vscode): extract SSE payload unwrapper and add parsing tests --- .../src/services/cli-backend/sse-client.ts | 6 +- .../src/services/cli-backend/sse-utils.ts | 16 +++++ .../tests/unit/sse-client-utils.test.ts | 64 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 packages/kilo-vscode/src/services/cli-backend/sse-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/sse-client-utils.test.ts diff --git a/packages/kilo-vscode/src/services/cli-backend/sse-client.ts b/packages/kilo-vscode/src/services/cli-backend/sse-client.ts index 69d0a45615..ed80b4c597 100644 --- a/packages/kilo-vscode/src/services/cli-backend/sse-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/sse-client.ts @@ -1,5 +1,6 @@ import EventSource from "eventsource" import type { ServerConfig, SSEEvent } from "./types" +import { unwrapSSEPayload } from "./sse-utils" // Type definitions for handlers export type SSEEventHandler = (event: SSEEvent) => void @@ -67,9 +68,8 @@ export class SSEClient { console.log("[Kilo New] SSE: 📨 Received message event:", messageEvent.data) try { const raw = JSON.parse(messageEvent.data) - // Global endpoint wraps events as { directory, payload: { type, properties } } - const event = (raw.payload ?? raw) as SSEEvent - if (!event.type) { + const event = unwrapSSEPayload(raw) + if (!event) { console.warn("[Kilo New] SSE: ⚠️ Received event without type:", raw) return } diff --git a/packages/kilo-vscode/src/services/cli-backend/sse-utils.ts b/packages/kilo-vscode/src/services/cli-backend/sse-utils.ts new file mode 100644 index 0000000000..0d77a41f99 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/sse-utils.ts @@ -0,0 +1,16 @@ +import type { SSEEvent } from "./types" + +/** + * Unwrap an SSE message payload. + * The global /global/event endpoint wraps events as { directory, payload: SSEEvent }. + * Direct event endpoints return the SSEEvent directly. + * Returns null if the parsed data has no `type` field (malformed or unknown event). + */ +export function unwrapSSEPayload(raw: unknown): SSEEvent | null { + if (!raw || typeof raw !== "object") return null + const event = ((raw as { payload?: SSEEvent }).payload ?? raw) as SSEEvent + if (!event || typeof event !== "object" || !("type" in event)) { + return null + } + return event +} diff --git a/packages/kilo-vscode/tests/unit/sse-client-utils.test.ts b/packages/kilo-vscode/tests/unit/sse-client-utils.test.ts new file mode 100644 index 0000000000..1670d25e8a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/sse-client-utils.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "bun:test" +import { unwrapSSEPayload } from "../../src/services/cli-backend/sse-utils" + +describe("unwrapSSEPayload", () => { + it("unwraps global endpoint payload wrapper", () => { + const raw = { + directory: "/workspace", + payload: { type: "session.created", properties: { info: {} } }, + } + const event = unwrapSSEPayload(raw) + expect(event?.type).toBe("session.created") + }) + + it("returns direct event when no payload wrapper", () => { + const raw = { type: "server.connected", properties: {} } + const event = unwrapSSEPayload(raw) + expect(event?.type).toBe("server.connected") + }) + + it("returns null when no type field in direct event", () => { + const raw = { properties: {} } + expect(unwrapSSEPayload(raw)).toBeNull() + }) + + it("returns null when payload wrapper exists but has no type", () => { + const raw = { directory: "/workspace", payload: { properties: {} } } + expect(unwrapSSEPayload(raw)).toBeNull() + }) + + it("returns null for null input", () => { + expect(unwrapSSEPayload(null)).toBeNull() + }) + + it("returns null for empty object", () => { + expect(unwrapSSEPayload({})).toBeNull() + }) + + it("returns null for non-object input", () => { + expect(unwrapSSEPayload("string")).toBeNull() + expect(unwrapSSEPayload(42)).toBeNull() + }) + + it("uses payload over root when both have type", () => { + const raw = { + type: "root-type", + payload: { type: "payload-type", properties: {} }, + } + const event = unwrapSSEPayload(raw) + expect(event?.type).toBe("payload-type") + }) + + it("handles nested event types correctly", () => { + const raw = { + payload: { + type: "message.updated", + properties: { + info: { id: "m1", sessionID: "s1", role: "assistant", time: { created: 0 } }, + }, + }, + } + const event = unwrapSSEPayload(raw) + expect(event?.type).toBe("message.updated") + }) +}) From e96d83ac7a6da175c46324fd35a93ca76ef6e0de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:53:55 +0100 Subject: [PATCH 25/73] test(vscode): extract autocomplete inline pure functions and add tests --- .../AutocompleteInlineCompletionProvider.ts | 172 ++--------------- .../classic-auto-complete/inline-utils.ts | 96 +++++++++ .../unit/autocomplete-inline-utils.test.ts | 182 ++++++++++++++++++ 3 files changed, 294 insertions(+), 156 deletions(-) create mode 100644 packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index bd39c826de..a7aac27ce6 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -13,6 +13,15 @@ import { AutocompleteContext, LastSuggestionInfo, } from "../types" +import { + findMatchingSuggestion as _findMatchingSuggestion, + applyFirstLineOnly as _applyFirstLineOnly, + countLines as _countLines, + shouldShowOnlyFirstLine as _shouldShowOnlyFirstLine, + getFirstLine as _getFirstLine, + calcDebounceDelay, + MatchingSuggestionWithFillIn as _MatchingSuggestionWithFillIn, +} from "./inline-utils" import { HoleFiller } from "./HoleFiller" import { FimPromptBuilder } from "./FillInTheMiddle" import { AutocompleteModel } from "../AutocompleteModel" @@ -59,101 +68,21 @@ const LATENCY_SAMPLE_SIZE = 10 export type { CostTrackingCallback, AutocompletePrompt, MatchingSuggestionResult, LLMRetrievalResult } -/** - * Result from findMatchingSuggestion including the original suggestion for telemetry tracking - */ -export interface MatchingSuggestionWithFillIn extends MatchingSuggestionResult { - /** The original FillInAtCursorSuggestion for telemetry tracking */ - fillInAtCursor: FillInAtCursorSuggestion -} +export type MatchingSuggestionWithFillIn = _MatchingSuggestionWithFillIn -/** - * Find a matching suggestion from the history based on current prefix and suffix. - * - * @param prefix - The text before the cursor position - * @param suffix - The text after the cursor position - * @param suggestionsHistory - Array of previous suggestions (most recent last) - * @returns The matching suggestion with match type and the original FillInAtCursorSuggestion, or null if no match found - */ export function findMatchingSuggestion( prefix: string, suffix: string, suggestionsHistory: FillInAtCursorSuggestion[], ): MatchingSuggestionWithFillIn | null { - // Search from most recent to least recent - for (let i = suggestionsHistory.length - 1; i >= 0; i--) { - const fillInAtCursor = suggestionsHistory[i] - - // First, try exact prefix/suffix match - if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) { - return { - text: fillInAtCursor.text, - matchType: "exact", - fillInAtCursor, - } - } - - // If no exact match, but suggestion is available, check for partial typing - // The user may have started typing the suggested text - if (fillInAtCursor.text !== "" && prefix.startsWith(fillInAtCursor.prefix) && suffix === fillInAtCursor.suffix) { - // Extract what the user has typed between the original prefix and current position - const typedContent = prefix.substring(fillInAtCursor.prefix.length) - - // Check if the typed content matches the beginning of the suggestion - if (fillInAtCursor.text.startsWith(typedContent)) { - // Return the remaining part of the suggestion (with already-typed portion removed) - return { - text: fillInAtCursor.text.substring(typedContent.length), - matchType: "partial_typing", - fillInAtCursor, - } - } - } - - // Check for backward deletion: user deleted characters from the end of the prefix - // The stored prefix should start with the current prefix (current is shorter) - // Only use this logic if the original suggestion is non-empty - if (fillInAtCursor.text !== "" && fillInAtCursor.prefix.startsWith(prefix) && suffix === fillInAtCursor.suffix) { - // Extract the deleted portion of the prefix - const deletedContent = fillInAtCursor.prefix.substring(prefix.length) - - // Return the deleted portion plus the original suggestion text - return { - text: deletedContent + fillInAtCursor.text, - matchType: "backward_deletion", - fillInAtCursor, - } - } - } - - return null + return _findMatchingSuggestion(prefix, suffix, suggestionsHistory) } -/** - * Transforms a matching suggestion result by applying first-line-only logic if needed. - * Use this at call sites where you want to show only the first line of multi-line completions - * when the cursor is in the middle of a line. - * - * @param result - The result from findMatchingSuggestion - * @param prefix - The text before the cursor position - * @returns A new result with potentially truncated text, or null if input was null - */ export function applyFirstLineOnly( result: MatchingSuggestionWithFillIn | null, prefix: string, ): MatchingSuggestionWithFillIn | null { - if (result === null || result.text === "") { - return result - } - if (shouldShowOnlyFirstLine(prefix, result.text)) { - const firstLineText = getFirstLine(result.text) - return { - text: firstLineText, - matchType: result.matchType, - fillInAtCursor: result.fillInAtCursor, - } - } - return result + return _applyFirstLineOnly(result, prefix) } /** @@ -162,76 +91,16 @@ export function applyFirstLineOnly( */ export const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilocode.autocomplete.inline-completion.accepted" -/** - * Counts the number of lines in a text string. - * - * Notes: - * - Returns 0 for an empty string - * - A single trailing newline (or CRLF) does not count as an additional line - * - * @param text - The text to count lines in - * @returns The number of lines - */ export function countLines(text: string): number { - if (text === "") { - return 0 - } - - // Count line breaks and add 1 for the first line. - // If the text ends with a line break, don't count the implicit trailing empty line. - const lineBreakCount = (text.match(/\r?\n/g) || []).length - const endsWithLineBreak = text.endsWith("\n") - - return lineBreakCount + 1 - (endsWithLineBreak ? 1 : 0) + return _countLines(text) } -/** - * Determines if only the first line of a completion should be shown. - * - * The logic is: - * - If the suggestion starts with a newline → show the whole block - * - If the prefix's last line has non-whitespace text → show only the first line - * - If at start of line and suggestion is 3+ lines → show only the first line - * - Otherwise → show the whole block - * - * @param prefix - The text before the cursor position - * @param suggestion - The completion text being suggested - * @returns true if only the first line should be shown - */ export function shouldShowOnlyFirstLine(prefix: string, suggestion: string): boolean { - // If the suggestion starts with a newline, show the whole block - if (suggestion.startsWith("\n") || suggestion.startsWith("\r\n")) { - return false - } - - // Check if the current line (before cursor) has non-whitespace text - const lastNewlineIndex = prefix.lastIndexOf("\n") - const currentLinePrefix = prefix.slice(lastNewlineIndex + 1) - - // if the first line contains no word characters, show the whole block - if (!currentLinePrefix.match(/\w/)) { - return false - } - - // If the current line prefix contains non-whitespace, only show the first line - if (currentLinePrefix.trim().length > 0) { - return true - } - - // At start of line (only whitespace before cursor on this line) - // Show only first line if suggestion is 3 or more lines - const lineCount = countLines(suggestion) - return lineCount >= 3 + return _shouldShowOnlyFirstLine(prefix, suggestion) } -/** - * Extracts the first line from a completion text. - * - * @param text - The full completion text - * @returns The first line of the completion (without the newline) - */ export function getFirstLine(text: string): string { - return text.split(/\r?\n/, 1)[0] + return _getFirstLine(text) } export function stringToInlineCompletions(text: string, position: vscode.Position): vscode.InlineCompletionItem[] { @@ -398,19 +267,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple * @param latencyMs - The latency of the most recent request in milliseconds */ public recordLatency(latencyMs: number): void { - // Add the new latency to the history this.latencyHistory.push(latencyMs) - - // Remove oldest if we exceed the sample size if (this.latencyHistory.length > LATENCY_SAMPLE_SIZE) { this.latencyHistory.shift() - - // Once we have enough samples, update the debounce delay to the average - const sum = this.latencyHistory.reduce((acc, val) => acc + val, 0) - const averageLatency = Math.round(sum / this.latencyHistory.length) - - // Clamp the debounce delay between MIN and MAX - this.debounceDelayMs = Math.max(MIN_DEBOUNCE_DELAY_MS, Math.min(averageLatency, MAX_DEBOUNCE_DELAY_MS)) + this.debounceDelayMs = calcDebounceDelay(this.latencyHistory) } } diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts new file mode 100644 index 0000000000..b570605e74 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts @@ -0,0 +1,96 @@ +import type { FillInAtCursorSuggestion, MatchingSuggestionResult } from "../types" + +export interface MatchingSuggestionWithFillIn extends MatchingSuggestionResult { + fillInAtCursor: FillInAtCursorSuggestion +} + +const MIN_DEBOUNCE_DELAY_MS = 150 +const MAX_DEBOUNCE_DELAY_MS = 1000 + +/** + * Find a matching suggestion from history based on current prefix and suffix. + * Searches from most recent to least recent. + */ +export function findMatchingSuggestion( + prefix: string, + suffix: string, + suggestionsHistory: FillInAtCursorSuggestion[], +): MatchingSuggestionWithFillIn | null { + for (let i = suggestionsHistory.length - 1; i >= 0; i--) { + const fillInAtCursor = suggestionsHistory[i]! + + if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) { + return { text: fillInAtCursor.text, matchType: "exact", fillInAtCursor } + } + + if (fillInAtCursor.text !== "" && prefix.startsWith(fillInAtCursor.prefix) && suffix === fillInAtCursor.suffix) { + const typedContent = prefix.substring(fillInAtCursor.prefix.length) + if (fillInAtCursor.text.startsWith(typedContent)) { + return { + text: fillInAtCursor.text.substring(typedContent.length), + matchType: "partial_typing", + fillInAtCursor, + } + } + } + + if (fillInAtCursor.text !== "" && fillInAtCursor.prefix.startsWith(prefix) && suffix === fillInAtCursor.suffix) { + const deletedContent = fillInAtCursor.prefix.substring(prefix.length) + return { text: deletedContent + fillInAtCursor.text, matchType: "backward_deletion", fillInAtCursor } + } + } + return null +} + +/** + * Counts the number of lines in a text string. + * A single trailing newline does not count as an additional line. + */ +export function countLines(text: string): number { + if (text === "") return 0 + const lineBreakCount = (text.match(/\r?\n/g) || []).length + const endsWithLineBreak = text.endsWith("\n") + return lineBreakCount + 1 - (endsWithLineBreak ? 1 : 0) +} + +/** + * Returns true if only the first line of a completion should be shown. + */ +export function shouldShowOnlyFirstLine(prefix: string, suggestion: string): boolean { + if (suggestion.startsWith("\n") || suggestion.startsWith("\r\n")) return false + const lastNewlineIndex = prefix.lastIndexOf("\n") + const currentLinePrefix = prefix.slice(lastNewlineIndex + 1) + if (!currentLinePrefix.match(/\w/)) return false + if (currentLinePrefix.trim().length > 0) return true + return countLines(suggestion) >= 3 +} + +/** Extracts the first line from a completion text. */ +export function getFirstLine(text: string): string { + return text.split(/\r?\n/, 1)[0]! +} + +/** + * Apply first-line-only logic to a matching suggestion result. + */ +export function applyFirstLineOnly( + result: MatchingSuggestionWithFillIn | null, + prefix: string, +): MatchingSuggestionWithFillIn | null { + if (result === null || result.text === "") return result + if (shouldShowOnlyFirstLine(prefix, result.text)) { + return { text: getFirstLine(result.text), matchType: result.matchType, fillInAtCursor: result.fillInAtCursor } + } + return result +} + +/** + * Calculate adaptive debounce delay from a latency history. + * Clamps result between MIN_DEBOUNCE_DELAY_MS and MAX_DEBOUNCE_DELAY_MS. + */ +export function calcDebounceDelay(latencyHistory: number[]): number { + if (latencyHistory.length === 0) return MIN_DEBOUNCE_DELAY_MS + const sum = latencyHistory.reduce((acc, v) => acc + v, 0) + const avg = Math.round(sum / latencyHistory.length) + return Math.max(MIN_DEBOUNCE_DELAY_MS, Math.min(avg, MAX_DEBOUNCE_DELAY_MS)) +} diff --git a/packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts new file mode 100644 index 0000000000..7f171f84b7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from "bun:test" +import { + findMatchingSuggestion, + applyFirstLineOnly, + countLines, + shouldShowOnlyFirstLine, + getFirstLine, + calcDebounceDelay, +} from "../../src/services/autocomplete/classic-auto-complete/inline-utils" +import type { FillInAtCursorSuggestion } from "../../src/services/autocomplete/types" + +function makeSuggestion(prefix: string, text: string, suffix = ""): FillInAtCursorSuggestion { + return { prefix, suffix, text } +} + +describe("countLines", () => { + it("returns 0 for empty string", () => { + expect(countLines("")).toBe(0) + }) + + it("returns 1 for single line without newline", () => { + expect(countLines("hello")).toBe(1) + }) + + it("returns 1 for single line with trailing newline", () => { + expect(countLines("hello\n")).toBe(1) + }) + + it("returns 2 for two lines", () => { + expect(countLines("line1\nline2")).toBe(2) + }) + + it("returns 2 for two lines with trailing newline", () => { + expect(countLines("line1\nline2\n")).toBe(2) + }) + + it("handles CRLF line endings", () => { + expect(countLines("a\r\nb\r\nc")).toBe(3) + }) + + it("handles CRLF with trailing newline", () => { + expect(countLines("a\r\nb\r\n")).toBe(2) + }) +}) + +describe("getFirstLine", () => { + it("returns the first line of multi-line text", () => { + expect(getFirstLine("line1\nline2\nline3")).toBe("line1") + }) + + it("returns the full text when single line", () => { + expect(getFirstLine("hello")).toBe("hello") + }) + + it("returns empty string for empty input", () => { + expect(getFirstLine("")).toBe("") + }) + + it("handles CRLF line endings", () => { + expect(getFirstLine("line1\r\nline2")).toBe("line1") + }) +}) + +describe("shouldShowOnlyFirstLine", () => { + it("returns false when suggestion starts with newline", () => { + expect(shouldShowOnlyFirstLine("const x = ", "\n return x")).toBe(false) + }) + + it("returns true when cursor is mid-line with code", () => { + expect(shouldShowOnlyFirstLine("function foo() { return ", "bar\nbaz")).toBe(true) + }) + + it("returns false when prefix last line has no word chars (empty line)", () => { + expect(shouldShowOnlyFirstLine("code\n", "line1\nline2\nline3")).toBe(false) + }) + + it("returns false for 2-line suggestion at start of line", () => { + expect(shouldShowOnlyFirstLine(" ", "line1\nline2")).toBe(false) + }) + + it("returns true for 3-line suggestion at start of line with word chars", () => { + expect(shouldShowOnlyFirstLine(" code", "line1\nline2\nline3")).toBe(true) + }) + + it("returns false for empty prefix", () => { + expect(shouldShowOnlyFirstLine("", "any text")).toBe(false) + }) +}) + +describe("findMatchingSuggestion", () => { + it("returns null for empty history", () => { + expect(findMatchingSuggestion("prefix", "suffix", [])).toBeNull() + }) + + it("returns exact match", () => { + const hist = [makeSuggestion("hello ", "world")] + const result = findMatchingSuggestion("hello ", "", hist) + expect(result?.matchType).toBe("exact") + expect(result?.text).toBe("world") + }) + + it("returns partial_typing match when user typed beginning of suggestion", () => { + const hist = [makeSuggestion("he", "llo world")] + const result = findMatchingSuggestion("hell", "", hist) + expect(result?.matchType).toBe("partial_typing") + expect(result?.text).toBe("o world") + }) + + it("returns backward_deletion match when user deleted chars", () => { + const hist = [makeSuggestion("hello world", "more", "suffix")] + const result = findMatchingSuggestion("hello", "suffix", hist) + expect(result?.matchType).toBe("backward_deletion") + expect(result?.text).toBe(" worldmore") + }) + + it("prefers most recent suggestion (searches from end)", () => { + const hist = [makeSuggestion("prefix", "old suggestion"), makeSuggestion("prefix", "new suggestion")] + const result = findMatchingSuggestion("prefix", "", hist) + expect(result?.text).toBe("new suggestion") + }) + + it("returns null when no match found", () => { + const hist = [makeSuggestion("different", "no match")] + expect(findMatchingSuggestion("unrelated", "suffix", hist)).toBeNull() + }) + + it("does not match empty suggestion text for partial_typing", () => { + const hist = [makeSuggestion("prefix", "")] + const result = findMatchingSuggestion("prefix more", "", hist) + expect(result).toBeNull() + }) +}) + +describe("applyFirstLineOnly", () => { + it("returns null when input is null", () => { + expect(applyFirstLineOnly(null, "prefix")).toBeNull() + }) + + it("returns empty result unchanged", () => { + const hist = [makeSuggestion("p", "")] + const result = findMatchingSuggestion("p", "", hist)! + const applied = applyFirstLineOnly(result, "p") + expect(applied?.text).toBe("") + }) + + it("truncates to first line when mid-line suggestion", () => { + const hist = [makeSuggestion("function foo() { return ", "x\n const y = 1\n}")] + const result = findMatchingSuggestion("function foo() { return ", "", hist)! + const applied = applyFirstLineOnly(result, "function foo() { return ") + expect(applied?.text).toBe("x") + }) + + it("preserves full multi-line suggestion when starting with newline", () => { + const hist = [makeSuggestion("foo", "\n const x = 1\n const y = 2")] + const result = findMatchingSuggestion("foo", "", hist)! + const applied = applyFirstLineOnly(result, "foo") + expect(applied?.text).toBe("\n const x = 1\n const y = 2") + }) +}) + +describe("calcDebounceDelay", () => { + it("returns MIN when history is empty", () => { + expect(calcDebounceDelay([])).toBe(150) + }) + + it("returns average of latencies clamped to min", () => { + expect(calcDebounceDelay([50, 50, 50])).toBe(150) + }) + + it("returns average of latencies in normal range", () => { + expect(calcDebounceDelay([400, 400, 400])).toBe(400) + }) + + it("clamps to MAX for very high latencies", () => { + expect(calcDebounceDelay([2000, 2000, 2000])).toBe(1000) + }) + + it("rounds to nearest integer", () => { + const result = calcDebounceDelay([300, 301]) + expect(Number.isInteger(result)).toBe(true) + }) +}) From 88ab67600dfba0a5622a0d468f0e9c07c913a125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:55:08 +0100 Subject: [PATCH 26/73] test(vscode): extract parseAutocompleteResponse and add COMPLETION tag tests --- .../classic-auto-complete/HoleFiller.ts | 20 +----- .../hole-filler-utils.ts | 19 ++++++ .../tests/unit/hole-filler-utils.test.ts | 61 +++++++++++++++++++ 3 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/hole-filler-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/hole-filler-utils.test.ts diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts index 1e67e83e96..df4096b604 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts @@ -5,6 +5,7 @@ import { FillInAtCursorSuggestion, ChatCompletionResult, } from "../types" +import { parseAutocompleteResponse as _parseAutocompleteResponse } from "./hole-filler-utils" import { getProcessedSnippets } from "./getProcessedSnippets" import { formatSnippets } from "../continuedev/core/autocomplete/templating/formatting" import { AutocompleteModel, ApiStreamChunk } from "../AutocompleteModel" @@ -20,24 +21,7 @@ export function parseAutocompleteResponse( prefix: string, suffix: string, ): FillInAtCursorSuggestion { - let fimText: string = "" - - // Match content strictly between and tags - const completionMatch = fullResponse.match(/([\s\S]*?)<\/COMPLETION>/i) - - if (completionMatch) { - // Extract the captured group (content between tags) - fimText = completionMatch[1] || "" - } - // Remove any accidentally captured tag remnants - fimText = fimText.replace(/<\/?COMPLETION>/gi, "") - - // Return FillInAtCursorSuggestion with the text (empty string if nothing found) - return { - text: fimText, - prefix, - suffix, - } + return _parseAutocompleteResponse(fullResponse, prefix, suffix) } export class HoleFiller { diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/hole-filler-utils.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/hole-filler-utils.ts new file mode 100644 index 0000000000..7db068bc65 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/hole-filler-utils.ts @@ -0,0 +1,19 @@ +import type { FillInAtCursorSuggestion } from "../types" + +/** + * Parse a chat completion response and extract the text between tags. + * Returns a FillInAtCursorSuggestion with the extracted text, or empty string if not found. + */ +export function parseAutocompleteResponse( + fullResponse: string, + prefix: string, + suffix: string, +): FillInAtCursorSuggestion { + let fimText = "" + const completionMatch = fullResponse.match(/([\s\S]*?)<\/COMPLETION>/i) + if (completionMatch) { + fimText = completionMatch[1] || "" + } + fimText = fimText.replace(/<\/?COMPLETION>/gi, "") + return { text: fimText, prefix, suffix } +} diff --git a/packages/kilo-vscode/tests/unit/hole-filler-utils.test.ts b/packages/kilo-vscode/tests/unit/hole-filler-utils.test.ts new file mode 100644 index 0000000000..9623b79474 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hole-filler-utils.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "bun:test" +import { parseAutocompleteResponse } from "../../src/services/autocomplete/classic-auto-complete/hole-filler-utils" + +describe("parseAutocompleteResponse", () => { + const prefix = "function foo() {\n " + const suffix = "\n}" + + it("extracts content between COMPLETION tags", () => { + const result = parseAutocompleteResponse("return 42;", prefix, suffix) + expect(result.text).toBe("return 42;") + expect(result.prefix).toBe(prefix) + expect(result.suffix).toBe(suffix) + }) + + it("returns empty text when no COMPLETION tags", () => { + const result = parseAutocompleteResponse("return 42;", prefix, suffix) + expect(result.text).toBe("") + }) + + it("handles multiline completion content", () => { + const result = parseAutocompleteResponse("const x = 1;\nreturn x;", prefix, suffix) + expect(result.text).toBe("const x = 1;\nreturn x;") + }) + + it("handles case-insensitive tags", () => { + const result = parseAutocompleteResponse("return x;", prefix, suffix) + expect(result.text).toBe("return x;") + }) + + it("handles empty COMPLETION tags", () => { + const result = parseAutocompleteResponse("", prefix, suffix) + expect(result.text).toBe("") + }) + + it("handles whitespace-only content in tags", () => { + const result = parseAutocompleteResponse(" ", prefix, suffix) + expect(result.text).toBe(" ") + }) + + it("handles response with prose before and after tags", () => { + const response = "Here is your completion:\nreturn value;\nHope that helps!" + const result = parseAutocompleteResponse(response, prefix, suffix) + expect(result.text).toBe("return value;") + }) + + it("removes accidentally captured tag remnants", () => { + const result = parseAutocompleteResponse("inner", prefix, suffix) + expect(result.text).toBe("inner") + }) + + it("returns empty text for empty response", () => { + const result = parseAutocompleteResponse("", prefix, suffix) + expect(result.text).toBe("") + }) + + it("preserves prefix and suffix in result", () => { + const result = parseAutocompleteResponse("x", "pre", "suf") + expect(result.prefix).toBe("pre") + expect(result.suffix).toBe("suf") + }) +}) From 2f56af1cf5668ce6f5a76ad195ba66da3e42b26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:56:17 +0100 Subject: [PATCH 27/73] test(vscode): extract chat autocomplete finalization and prefix-building tests --- .../ChatTextAreaAutocomplete.ts | 46 ++------ .../chat-autocomplete-utils.ts | 45 ++++++++ .../unit/chat-autocomplete-utils.test.ts | 102 ++++++++++++++++++ 3 files changed, 153 insertions(+), 40 deletions(-) create mode 100644 packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts diff --git a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts index 7f86c55efe..113247c89e 100644 --- a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts +++ b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts @@ -4,6 +4,7 @@ import { removePrefixOverlap } from "../continuedev/core/autocomplete/postproces import { AutocompleteTelemetry } from "../classic-auto-complete/AutocompleteTelemetry" import { postprocessAutocompleteSuggestion } from "../classic-auto-complete/uselessSuggestionFilter" import type { KiloConnectionService } from "../../cli-backend" +import { finalizeChatSuggestion, buildChatPrefix } from "./chat-autocomplete-utils" export class ChatTextAreaAutocomplete { private model: AutocompleteModel @@ -134,52 +135,17 @@ TASK: Complete the user's message naturally. } private async buildPrefix(userText: string, visibleCodeContext?: VisibleCodeContext): Promise { - const contextParts: string[] = [] - - // Add visible code context (replaces cursor-based prefix/suffix) - if (visibleCodeContext && visibleCodeContext.editors.length > 0) { - contextParts.push("// Code visible in editor:") - - for (const editor of visibleCodeContext.editors) { - const fileName = editor.filePath.split("/").pop() || editor.filePath - contextParts.push(`\n// File: ${fileName} (${editor.languageId})`) - - for (const range of editor.visibleRanges) { - contextParts.push(range.content) - } - } - } - - contextParts.push("\n// User's message:") - contextParts.push(userText) - - return contextParts.join("\n") + return buildChatPrefix(userText, visibleCodeContext?.editors) } public cleanSuggestion(suggestion: string, userText: string): string { - let cleaned = postprocessAutocompleteSuggestion({ + const cleaned = postprocessAutocompleteSuggestion({ suggestion: removePrefixOverlap(suggestion, userText), prefix: userText, - suffix: "", // Chat textarea has no suffix + suffix: "", model: this.model.getModelName() ?? "unknown", }) - - if (cleaned === undefined) { - return "" - } - - // Filter suggestions that look like code rather than natural language - if (cleaned.match(/^(\/\/|\/\*|\*|#)/)) { - return "" - } - - // Chat-specific: truncate at first newline for single-line suggestions - const firstNewline = cleaned.indexOf("\n") - if (firstNewline !== -1) { - cleaned = cleaned.substring(0, firstNewline) - } - cleaned = cleaned.trimEnd() - - return cleaned + if (cleaned === undefined) return "" + return finalizeChatSuggestion(cleaned) } } diff --git a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils.ts b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils.ts new file mode 100644 index 0000000000..110efbb381 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils.ts @@ -0,0 +1,45 @@ +/** + * Apply chat-specific post-processing to a suggestion: + * - Filter suggestions that look like code comments + * - Truncate at first newline (chat is single-line) + * - Trim trailing whitespace + * Returns empty string when the suggestion should be discarded. + */ +export function finalizeChatSuggestion(cleaned: string): string { + if (!cleaned) return "" + + if (cleaned.match(/^(\/\/|\/\*|\*|#)/)) { + return "" + } + + const firstNewline = cleaned.indexOf("\n") + const truncated = firstNewline !== -1 ? cleaned.substring(0, firstNewline) : cleaned + return truncated.trimEnd() +} + +/** + * Build the prefix string for a chat completion request from user text and visible code context. + */ +export function buildChatPrefix( + userText: string, + editors?: Array<{ + filePath: string + languageId: string + visibleRanges: Array<{ content: string }> + }>, +): string { + const parts: string[] = [] + if (editors && editors.length > 0) { + parts.push("// Code visible in editor:") + for (const editor of editors) { + const fileName = editor.filePath.split("/").pop() || editor.filePath + parts.push(`\n// File: ${fileName} (${editor.languageId})`) + for (const range of editor.visibleRanges) { + parts.push(range.content) + } + } + } + parts.push("\n// User's message:") + parts.push(userText) + return parts.join("\n") +} diff --git a/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts b/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts new file mode 100644 index 0000000000..e63beb261c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "bun:test" +import { + finalizeChatSuggestion, + buildChatPrefix, +} from "../../src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils" + +describe("finalizeChatSuggestion", () => { + it("returns empty string for empty input", () => { + expect(finalizeChatSuggestion("")).toBe("") + }) + + it("filters suggestions starting with // (JS comment)", () => { + expect(finalizeChatSuggestion("// this is a comment")).toBe("") + }) + + it("filters suggestions starting with /* (block comment)", () => { + expect(finalizeChatSuggestion("/* block */")).toBe("") + }) + + it("filters suggestions starting with * (JSDoc line)", () => { + expect(finalizeChatSuggestion("* @param foo")).toBe("") + }) + + it("filters suggestions starting with # (shell/Python comment)", () => { + expect(finalizeChatSuggestion("# python comment")).toBe("") + }) + + it("returns the suggestion as-is for normal text", () => { + expect(finalizeChatSuggestion("hello world")).toBe("hello world") + }) + + it("truncates at first newline", () => { + expect(finalizeChatSuggestion("first line\nsecond line")).toBe("first line") + }) + + it("trims trailing whitespace", () => { + expect(finalizeChatSuggestion("hello ")).toBe("hello") + }) + + it("truncates AND trims", () => { + expect(finalizeChatSuggestion("first line \nsecond")).toBe("first line") + }) + + it("handles single word without newline", () => { + expect(finalizeChatSuggestion("world")).toBe("world") + }) +}) + +describe("buildChatPrefix", () => { + it("includes user message without editor context", () => { + const result = buildChatPrefix("fix this bug") + expect(result).toContain("fix this bug") + expect(result).toContain("User's message") + }) + + it("does not include editor header when no editors provided", () => { + const result = buildChatPrefix("hello") + expect(result).not.toContain("Code visible in editor") + }) + + it("includes editor context when editors provided", () => { + const editors = [ + { + filePath: "/workspace/src/foo.ts", + languageId: "typescript", + visibleRanges: [{ content: "const x = 1" }], + }, + ] + const result = buildChatPrefix("fix this", editors) + expect(result).toContain("Code visible in editor") + expect(result).toContain("foo.ts (typescript)") + expect(result).toContain("const x = 1") + expect(result).toContain("fix this") + }) + + it("includes multiple editors", () => { + const editors = [ + { filePath: "/a.ts", languageId: "typescript", visibleRanges: [{ content: "code a" }] }, + { filePath: "/b.py", languageId: "python", visibleRanges: [{ content: "code b" }] }, + ] + const result = buildChatPrefix("question", editors) + expect(result).toContain("a.ts") + expect(result).toContain("b.py") + expect(result).toContain("code a") + expect(result).toContain("code b") + }) + + it("uses last segment of file path as filename", () => { + const editors = [ + { filePath: "/deep/path/to/myfile.ts", languageId: "typescript", visibleRanges: [] }, + ] + const result = buildChatPrefix("q", editors) + expect(result).toContain("myfile.ts") + expect(result).not.toContain("deep/path") + }) + + it("handles empty editors array as if no context", () => { + const result = buildChatPrefix("hi", []) + expect(result).not.toContain("Code visible in editor") + expect(result).toContain("hi") + }) +}) From be398e935c9f57b5c1591c8451cf1e6481e24a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:57:50 +0100 Subject: [PATCH 28/73] test(vscode): extract status bar formatting helpers and add cost/time tests --- .../autocomplete/AutocompleteStatusBar.ts | 13 +---- .../services/autocomplete/statusbar-utils.ts | 24 ++++++++ .../unit/autocomplete-statusbar-utils.test.ts | 56 +++++++++++++++++++ 3 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 packages/kilo-vscode/src/services/autocomplete/statusbar-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteStatusBar.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteStatusBar.ts index b371f0b0c2..06eaf6b1a7 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteStatusBar.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteStatusBar.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import { t } from "./shims/i18n" import type { AutocompleteStatusBarStateProps } from "./types" +import { humanFormatSessionCost, formatTime } from "./statusbar-utils" const SUPPORTED_PROVIDER_DISPLAY_NAME = "Kilo Gateway" @@ -40,14 +41,7 @@ export class AutocompleteStatusBar { } private humanFormatSessionCost(): string { - const cost = this.props.totalSessionCost - if (cost === 0) { - return t("kilocode:autocomplete.statusBar.cost.zero") - } - if (cost > 0 && cost < 0.01) { - return t("kilocode:autocomplete.statusBar.cost.lessThanCent") - } - return `$${cost.toFixed(2)}` + return humanFormatSessionCost(this.props.totalSessionCost) } public update(params: Partial) { @@ -60,8 +54,7 @@ export class AutocompleteStatusBar { } private formatTime(timestamp: number): string { - const date = new Date(timestamp) - return date.toLocaleTimeString() + return formatTime(timestamp) } private renderDefault() { diff --git a/packages/kilo-vscode/src/services/autocomplete/statusbar-utils.ts b/packages/kilo-vscode/src/services/autocomplete/statusbar-utils.ts new file mode 100644 index 0000000000..6fb98fe9b5 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/statusbar-utils.ts @@ -0,0 +1,24 @@ +import { t } from "./shims/i18n" + +/** + * Format a session cost value to a human-readable string. + * - $0 → translated zero string + * - $0.001 → translated "less than a cent" + * - $0.12 → "$0.12" + */ +export function humanFormatSessionCost(cost: number): string { + if (cost === 0) { + return t("kilocode:autocomplete.statusBar.cost.zero") + } + if (cost > 0 && cost < 0.01) { + return t("kilocode:autocomplete.statusBar.cost.lessThanCent") + } + return `$${cost.toFixed(2)}` +} + +/** + * Format a Unix timestamp (ms) as a locale time string. + */ +export function formatTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString() +} diff --git a/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts new file mode 100644 index 0000000000..7a7c81b23c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "bun:test" +import { humanFormatSessionCost, formatTime } from "../../src/services/autocomplete/statusbar-utils" + +describe("humanFormatSessionCost", () => { + it("returns '$0.00' for 0 cost", () => { + expect(humanFormatSessionCost(0)).toBe("$0.00") + }) + + it("returns '<$0.01' for very small cost", () => { + expect(humanFormatSessionCost(0.001)).toBe("<$0.01") + }) + + it("formats exactly $0.01 as dollar string (not less-than-cent)", () => { + const result = humanFormatSessionCost(0.01) + expect(result).toBe("$0.01") + }) + + it("formats $0.12 correctly", () => { + expect(humanFormatSessionCost(0.12)).toBe("$0.12") + }) + + it("formats $1.00 correctly", () => { + expect(humanFormatSessionCost(1.0)).toBe("$1.00") + }) + + it("formats $1.005 rounded to 2 decimal places", () => { + const result = humanFormatSessionCost(1.005) + expect(result.startsWith("$")).toBe(true) + expect(result).toMatch(/^\$\d+\.\d{2}$/) + }) + + it("formats $0.009 as '<$0.01'", () => { + expect(humanFormatSessionCost(0.009)).toBe("<$0.01") + }) +}) + +describe("formatTime", () => { + it("returns a string for a valid timestamp", () => { + const result = formatTime(Date.now()) + expect(typeof result).toBe("string") + expect(result.length).toBeGreaterThan(0) + }) + + it("includes time components (hours, minutes, seconds are present)", () => { + const ts = new Date("2024-01-15T14:30:45").getTime() + const result = formatTime(ts) + expect(typeof result).toBe("string") + expect(result.length).toBeGreaterThan(0) + }) + + it("produces different output for different timestamps", () => { + const ts1 = new Date("2024-01-01T10:00:00").getTime() + const ts2 = new Date("2024-01-01T15:30:00").getTime() + expect(formatTime(ts1)).not.toBe(formatTime(ts2)) + }) +}) From 51430b228c1adb1c391a6fcca7fb404ddf460a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:59:12 +0100 Subject: [PATCH 29/73] test(vscode): extract autocomplete telemetry key and LRU eviction tests --- .../AutocompleteTelemetry.ts | 16 +--- .../classic-auto-complete/telemetry-utils.ts | 24 ++++++ .../unit/autocomplete-telemetry-utils.test.ts | 78 +++++++++++++++++++ 3 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/telemetry-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteTelemetry.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteTelemetry.ts index 7dc1fb5ccd..8c7328a56f 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteTelemetry.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteTelemetry.ts @@ -1,14 +1,11 @@ import { TelemetryProxy, TelemetryEventName } from "../../telemetry" import type { AutocompleteContext, CacheMatchType, FillInAtCursorSuggestion } from "../types" +import { getSuggestionKey as _getSuggestionKey, insertWithLRUEviction } from "./telemetry-utils" export type { AutocompleteContext, CacheMatchType, FillInAtCursorSuggestion } -/** - * Generate a unique key for a suggestion based on its content and context. - * This key is used to track whether the same suggestion is still being displayed. - */ export function getSuggestionKey(suggestion: FillInAtCursorSuggestion): string { - return `${suggestion.prefix}|${suggestion.suffix}|${suggestion.text}` + return _getSuggestionKey(suggestion) } /** @@ -64,14 +61,7 @@ export class AutocompleteTelemetry { private firedUniqueTelemetryKeys: Map = new Map() private markSuggestionKeyAsFired(suggestionKey: string): void { - this.firedUniqueTelemetryKeys.set(suggestionKey, true) - - if (this.firedUniqueTelemetryKeys.size > MAX_FIRED_UNIQUE_TELEMETRY_KEYS) { - const oldestKey = this.firedUniqueTelemetryKeys.keys().next().value as string | undefined - if (oldestKey) { - this.firedUniqueTelemetryKeys.delete(oldestKey) - } - } + insertWithLRUEviction(this.firedUniqueTelemetryKeys, suggestionKey, MAX_FIRED_UNIQUE_TELEMETRY_KEYS) } /** diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/telemetry-utils.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/telemetry-utils.ts new file mode 100644 index 0000000000..1bf8805f5e --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/telemetry-utils.ts @@ -0,0 +1,24 @@ +import type { FillInAtCursorSuggestion } from "../types" + +/** + * Generate a unique key for a suggestion based on its content and context. + * Used to deduplicate telemetry for the same suggestion shown multiple times. + */ +export function getSuggestionKey(suggestion: FillInAtCursorSuggestion): string { + return `${suggestion.prefix}|${suggestion.suffix}|${suggestion.text}` +} + +/** + * Insert a key into a Map used as a bounded LRU set. + * Evicts the oldest entry when the map exceeds `maxSize`. + * Returns the (possibly evicted) updated map. + */ +export function insertWithLRUEviction(map: Map, key: string, maxSize: number): void { + map.set(key, true) + if (map.size > maxSize) { + const oldest = map.keys().next().value as string | undefined + if (oldest !== undefined) { + map.delete(oldest) + } + } +} diff --git a/packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts new file mode 100644 index 0000000000..9848c21c9e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "bun:test" +import { + getSuggestionKey, + insertWithLRUEviction, +} from "../../src/services/autocomplete/classic-auto-complete/telemetry-utils" + +describe("getSuggestionKey", () => { + it("combines prefix, suffix, and text with pipe separators", () => { + const key = getSuggestionKey({ prefix: "hello ", suffix: "\n}", text: "world" }) + expect(key).toBe("hello |\n}|world") + }) + + it("produces unique keys for different suggestions", () => { + const k1 = getSuggestionKey({ prefix: "a", suffix: "c", text: "b" }) + const k2 = getSuggestionKey({ prefix: "a", suffix: "c", text: "x" }) + expect(k1).not.toBe(k2) + }) + + it("same content produces same key (stable)", () => { + const s = { prefix: "const x = ", suffix: ";", text: "42" } + expect(getSuggestionKey(s)).toBe(getSuggestionKey(s)) + }) + + it("different prefix produces different key", () => { + const k1 = getSuggestionKey({ prefix: "a", suffix: "", text: "t" }) + const k2 = getSuggestionKey({ prefix: "b", suffix: "", text: "t" }) + expect(k1).not.toBe(k2) + }) + + it("handles empty strings", () => { + const key = getSuggestionKey({ prefix: "", suffix: "", text: "" }) + expect(key).toBe("||") + }) +}) + +describe("insertWithLRUEviction", () => { + it("inserts key into map", () => { + const map = new Map() + insertWithLRUEviction(map, "k1", 5) + expect(map.has("k1")).toBe(true) + }) + + it("does not evict when under limit", () => { + const map = new Map() + insertWithLRUEviction(map, "k1", 3) + insertWithLRUEviction(map, "k2", 3) + insertWithLRUEviction(map, "k3", 3) + expect(map.size).toBe(3) + expect(map.has("k1")).toBe(true) + }) + + it("evicts oldest key when limit exceeded", () => { + const map = new Map() + insertWithLRUEviction(map, "k1", 3) + insertWithLRUEviction(map, "k2", 3) + insertWithLRUEviction(map, "k3", 3) + insertWithLRUEviction(map, "k4", 3) + expect(map.size).toBe(3) + expect(map.has("k1")).toBe(false) + expect(map.has("k4")).toBe(true) + }) + + it("handles maxSize of 1", () => { + const map = new Map() + insertWithLRUEviction(map, "k1", 1) + insertWithLRUEviction(map, "k2", 1) + expect(map.size).toBe(1) + expect(map.has("k2")).toBe(true) + expect(map.has("k1")).toBe(false) + }) + + it("updating existing key does not increase size", () => { + const map = new Map() + insertWithLRUEviction(map, "k1", 2) + insertWithLRUEviction(map, "k1", 2) + expect(map.size).toBe(1) + }) +}) From db96bd833c337f0b486a44ef314ba458dff8cee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:00:22 +0100 Subject: [PATCH 30/73] test(vscode): extract git diff URI parsing and add scheme/ref tests --- .../context/VisibleCodeTracker.ts | 35 +------------- .../context/visible-code-utils.ts | 19 ++++++++ .../tests/unit/visible-code-utils.test.ts | 47 +++++++++++++++++++ 3 files changed, 68 insertions(+), 33 deletions(-) create mode 100644 packages/kilo-vscode/src/services/autocomplete/context/visible-code-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/visible-code-utils.test.ts diff --git a/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts b/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts index 7003b04ed9..dfe2b6a8f2 100644 --- a/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts +++ b/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts @@ -18,6 +18,7 @@ function toRelativePath(absolutePath: string, workspacePath: string): string { } import { VisibleCodeContext, VisibleEditorInfo, VisibleRange, DiffInfo } from "../types" +import { extractDiffInfo as _extractDiffInfo } from "./visible-code-utils" // Git-related URI schemes that should be captured for diff support const GIT_SCHEMES = ["git", "gitfs", "file", "vscode-remote"] @@ -120,38 +121,6 @@ export class VisibleCodeTracker { * Git URIs typically look like: git:/path/to/file.ts?ref=HEAD~1 */ private extractDiffInfo(uri: vscode.Uri): DiffInfo | undefined { - const scheme = uri.scheme - - // Only extract diff info for git-related schemes - if (scheme === "git" || scheme === "gitfs") { - // Parse query parameters for git reference - const query = uri.query - let gitRef: string | undefined - - if (query) { - // Common patterns: ref=HEAD, ref=abc123 - const refMatch = query.match(/ref=([^&]+)/) - if (refMatch) { - gitRef = refMatch[1] - } - } - - return { - scheme, - side: "old", // Git scheme documents are typically the "old" side - gitRef, - originalPath: uri.fsPath, - } - } - - // File scheme in a diff view is the "new" side - // We can't always tell if it's in a diff, so we mark it as new when there's a paired git doc - if (scheme === "file") { - // This will be marked as diffInfo only if we detect it's paired with a git document - // For now, we don't set diffInfo for regular file scheme documents - return undefined - } - - return undefined + return _extractDiffInfo(uri.scheme, uri.query, uri.fsPath) } } diff --git a/packages/kilo-vscode/src/services/autocomplete/context/visible-code-utils.ts b/packages/kilo-vscode/src/services/autocomplete/context/visible-code-utils.ts new file mode 100644 index 0000000000..53f5c02dad --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/context/visible-code-utils.ts @@ -0,0 +1,19 @@ +import type { DiffInfo } from "../types" + +/** + * Extract git diff metadata from a URI. + * Returns DiffInfo for git/gitfs scheme URIs, undefined for regular file URIs. + */ +export function extractDiffInfo(scheme: string, query: string, fsPath: string): DiffInfo | undefined { + if (scheme === "git" || scheme === "gitfs") { + let gitRef: string | undefined + if (query) { + const refMatch = query.match(/ref=([^&]+)/) + if (refMatch) { + gitRef = refMatch[1] + } + } + return { scheme, side: "old", gitRef, originalPath: fsPath } + } + return undefined +} diff --git a/packages/kilo-vscode/tests/unit/visible-code-utils.test.ts b/packages/kilo-vscode/tests/unit/visible-code-utils.test.ts new file mode 100644 index 0000000000..2609cc4492 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/visible-code-utils.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "bun:test" +import { extractDiffInfo } from "../../src/services/autocomplete/context/visible-code-utils" + +describe("extractDiffInfo", () => { + it("extracts info for git scheme with ref query param", () => { + const result = extractDiffInfo("git", "ref=HEAD", "/workspace/foo.ts") + expect(result).not.toBeUndefined() + expect(result?.scheme).toBe("git") + expect(result?.side).toBe("old") + expect(result?.gitRef).toBe("HEAD") + expect(result?.originalPath).toBe("/workspace/foo.ts") + }) + + it("extracts info for gitfs scheme", () => { + const result = extractDiffInfo("gitfs", "ref=abc123", "/workspace/bar.ts") + expect(result?.scheme).toBe("gitfs") + expect(result?.gitRef).toBe("abc123") + }) + + it("extracts ref with additional query params", () => { + const result = extractDiffInfo("git", "ref=main&other=value", "/f.ts") + expect(result?.gitRef).toBe("main") + }) + + it("extracts commit SHA as ref", () => { + const result = extractDiffInfo("git", "ref=a1b2c3d4e5f6", "/f.ts") + expect(result?.gitRef).toBe("a1b2c3d4e5f6") + }) + + it("handles git scheme with no query (no ref)", () => { + const result = extractDiffInfo("git", "", "/f.ts") + expect(result).not.toBeUndefined() + expect(result?.gitRef).toBeUndefined() + }) + + it("returns undefined for file scheme", () => { + expect(extractDiffInfo("file", "", "/f.ts")).toBeUndefined() + }) + + it("returns undefined for unknown scheme", () => { + expect(extractDiffInfo("https", "", "/f.ts")).toBeUndefined() + }) + + it("returns undefined for vscode-remote scheme", () => { + expect(extractDiffInfo("vscode-remote", "", "/f.ts")).toBeUndefined() + }) +}) From 277f45e2493bdd564b14f65e96d184005df2321b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:02:20 +0100 Subject: [PATCH 31/73] test(vscode): extract session status/cost/context-usage utils and add tests --- .../tests/unit/session-utils.test.ts | 127 ++++++++++++++++++ .../webview-ui/src/context/session-utils.ts | 64 +++++++++ .../webview-ui/src/context/session.tsx | 54 +------- 3 files changed, 196 insertions(+), 49 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/session-utils.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/session-utils.ts diff --git a/packages/kilo-vscode/tests/unit/session-utils.test.ts b/packages/kilo-vscode/tests/unit/session-utils.test.ts new file mode 100644 index 0000000000..bd18a874ef --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-utils.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "bun:test" +import { computeStatus, calcTotalCost, calcContextUsage } from "../../webview-ui/src/context/session-utils" +import type { Part } from "../../webview-ui/src/types/messages" + +const t = (key: string) => key + +describe("computeStatus", () => { + it("returns undefined for undefined part", () => { + expect(computeStatus(undefined, t)).toBeUndefined() + }) + + it("maps task tool to delegating status", () => { + const part: Part = { type: "tool", id: "p1", tool: "task", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.delegating") + }) + + it("maps todowrite tool to planning status", () => { + const part: Part = { type: "tool", id: "p1", tool: "todowrite", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.planning") + }) + + it("maps todoread tool to planning status", () => { + const part: Part = { type: "tool", id: "p1", tool: "todoread", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.planning") + }) + + it("maps read tool to gatheringContext status", () => { + const part: Part = { type: "tool", id: "p1", tool: "read", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.gatheringContext") + }) + + it("maps list/grep/glob tools to searchingCodebase status", () => { + for (const tool of ["list", "grep", "glob"] as const) { + const part: Part = { type: "tool", id: "p1", tool, state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.searchingCodebase") + } + }) + + it("maps webfetch tool to searchingWeb status", () => { + const part: Part = { type: "tool", id: "p1", tool: "webfetch", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.searchingWeb") + }) + + it("maps edit/write tools to makingEdits status", () => { + for (const tool of ["edit", "write"] as const) { + const part: Part = { type: "tool", id: "p1", tool, state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.makingEdits") + } + }) + + it("maps bash tool to runningCommands status", () => { + const part: Part = { type: "tool", id: "p1", tool: "bash", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.runningCommands") + }) + + it("returns undefined for unknown tool", () => { + const part: Part = { type: "tool", id: "p1", tool: "unknown_tool", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBeUndefined() + }) + + it("maps reasoning part to thinking status", () => { + const part: Part = { type: "reasoning", id: "p1", text: "thinking..." } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.thinking") + }) + + it("maps text part to writingResponse status", () => { + const part: Part = { type: "text", id: "p1", text: "hello" } + expect(computeStatus(part, t)).toBe("session.status.writingResponse") + }) +}) + +describe("calcTotalCost", () => { + it("returns 0 for empty messages", () => { + expect(calcTotalCost([])).toBe(0) + }) + + it("sums costs from assistant messages only", () => { + const msgs = [ + { role: "user", cost: 1 }, + { role: "assistant", cost: 0.05 }, + { role: "assistant", cost: 0.03 }, + ] + expect(calcTotalCost(msgs)).toBeCloseTo(0.08) + }) + + it("ignores user messages", () => { + const msgs = [ + { role: "user", cost: 999 }, + { role: "assistant", cost: 0.01 }, + ] + expect(calcTotalCost(msgs)).toBeCloseTo(0.01) + }) + + it("handles missing cost as 0", () => { + const msgs = [{ role: "assistant" }, { role: "assistant", cost: 0.02 }] + expect(calcTotalCost(msgs)).toBeCloseTo(0.02) + }) +}) + +describe("calcContextUsage", () => { + it("sums all token types", () => { + const tokens = { input: 100, output: 50, reasoning: 20, cache: { read: 10, write: 5 } } + const result = calcContextUsage(tokens, undefined) + expect(result.tokens).toBe(185) + }) + + it("returns null percentage when no context limit", () => { + const result = calcContextUsage({ input: 100, output: 50 }, undefined) + expect(result.percentage).toBeNull() + }) + + it("calculates percentage correctly", () => { + const result = calcContextUsage({ input: 1000, output: 1000 }, 4000) + expect(result.percentage).toBe(50) + }) + + it("rounds percentage to integer", () => { + const result = calcContextUsage({ input: 1, output: 2 }, 3) + expect(Number.isInteger(result.percentage)).toBe(true) + }) + + it("handles missing optional fields as 0", () => { + const result = calcContextUsage({ input: 100, output: 0 }, 1000) + expect(result.tokens).toBe(100) + expect(result.percentage).toBe(10) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts new file mode 100644 index 0000000000..5e774d32ba --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -0,0 +1,64 @@ +import type { Part } from "../types/messages" + +/** + * Derive a human-readable status string from the last streaming part. + * Returns undefined for part types that don't map to a status. + */ +export function computeStatus( + part: Part | undefined, + t: (key: string, params?: Record) => string, +): string | undefined { + if (!part) return undefined + if (part.type === "tool") { + switch (part.tool) { + case "task": + return t("ui.sessionTurn.status.delegating") + case "todowrite": + case "todoread": + return t("ui.sessionTurn.status.planning") + case "read": + return t("ui.sessionTurn.status.gatheringContext") + case "list": + case "grep": + case "glob": + return t("ui.sessionTurn.status.searchingCodebase") + case "webfetch": + return t("ui.sessionTurn.status.searchingWeb") + case "edit": + case "write": + return t("ui.sessionTurn.status.makingEdits") + case "bash": + return t("ui.sessionTurn.status.runningCommands") + default: + return undefined + } + } + if (part.type === "reasoning") return t("ui.sessionTurn.status.thinking") + if (part.type === "text") return t("session.status.writingResponse") + return undefined +} + +/** + * Calculate total cost across all assistant messages. + */ +export function calcTotalCost(messages: Array<{ role: string; cost?: number }>): number { + return messages.reduce((sum, m) => sum + (m.role === "assistant" ? (m.cost ?? 0) : 0), 0) +} + +/** + * Calculate context usage percentage given token counts and a context limit. + */ +export function calcContextUsage( + tokens: { + input: number + output: number + reasoning?: number + cache?: { read: number; write: number } + }, + contextLimit: number | undefined, +): { tokens: number; percentage: number | null } { + const total = + tokens.input + tokens.output + (tokens.reasoning ?? 0) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0) + const percentage = contextLimit ? Math.round((total / contextLimit) * 100) : null + return { tokens: total, percentage } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 00a454de29..d295d0e8b0 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -38,41 +38,7 @@ import type { FileAttachment, } from "../types/messages" import { removeSessionPermissions, upsertPermission } from "./permission-queue" - -// Derive human-readable status from the last streaming part -function computeStatus( - part: Part | undefined, - t: (key: string, params?: Record) => string, -): string | undefined { - if (!part) return undefined - if (part.type === "tool") { - switch (part.tool) { - case "task": - return t("ui.sessionTurn.status.delegating") - case "todowrite": - case "todoread": - return t("ui.sessionTurn.status.planning") - case "read": - return t("ui.sessionTurn.status.gatheringContext") - case "list": - case "grep": - case "glob": - return t("ui.sessionTurn.status.searchingCodebase") - case "webfetch": - return t("ui.sessionTurn.status.searchingWeb") - case "edit": - case "write": - return t("ui.sessionTurn.status.makingEdits") - case "bash": - return t("ui.sessionTurn.status.runningCommands") - default: - return undefined - } - } - if (part.type === "reasoning") return t("ui.sessionTurn.status.thinking") - if (part.type === "text") return t("session.status.writingResponse") - return undefined -} +import { computeStatus, calcTotalCost, calcContextUsage } from "./session-utils" // Store structure for messages and parts interface SessionStore { @@ -832,10 +798,7 @@ export const SessionProvider: ParentComponent = (props) => { Object.values(store.sessions).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()), ) - // Total cost across all assistant messages in the current session - const totalCost = createMemo(() => { - return messages().reduce((sum, m) => sum + (m.role === "assistant" ? (m.cost ?? 0) : 0), 0) - }) + const totalCost = createMemo(() => calcTotalCost(messages())) // Status text derived from last assistant message parts const statusText = createMemo(() => { @@ -851,24 +814,17 @@ export const SessionProvider: ParentComponent = (props) => { return fallback }) - // Context usage from the last assistant message that has token data const contextUsage = createMemo(() => { const msgs = messages() for (let i = msgs.length - 1; i >= 0; i--) { const m = msgs[i] if (m.role !== "assistant" || !m.tokens) continue - const total = - m.tokens.input + - m.tokens.output + - (m.tokens.reasoning ?? 0) + - (m.tokens.cache?.read ?? 0) + - (m.tokens.cache?.write ?? 0) - if (total === 0) continue + const usage = calcContextUsage(m.tokens, undefined) + if (usage.tokens === 0) continue const sel = selected() const model = sel ? provider.findModel(sel) : undefined const limit = model?.limit?.context ?? model?.contextLength - const percentage = limit ? Math.round((total / limit) * 100) : null - return { tokens: total, percentage } + return calcContextUsage(m.tokens, limit) } return undefined }) From 464c3e1541ba3d9560e399ea9e91d6ad6ca4a480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:03:20 +0100 Subject: [PATCH 32/73] test(vscode): extract provider model flattening and lookup with tests --- .../tests/unit/provider-utils.test.ts | 80 +++++++++++++++++++ .../webview-ui/src/context/provider-utils.ts | 30 +++++++ .../webview-ui/src/context/provider.tsx | 25 +----- 3 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/provider-utils.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/provider-utils.ts diff --git a/packages/kilo-vscode/tests/unit/provider-utils.test.ts b/packages/kilo-vscode/tests/unit/provider-utils.test.ts new file mode 100644 index 0000000000..315a589f9a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/provider-utils.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "bun:test" +import { flattenModels, findModel } from "../../webview-ui/src/context/provider-utils" +import type { Provider } from "../../webview-ui/src/types/messages" + +function makeProvider(id: string, name: string, modelIds: string[]): Provider { + const models: Provider["models"] = {} + for (const mid of modelIds) { + models[mid] = { id: mid, name: mid.toUpperCase() } + } + return { id, name, models } +} + +describe("flattenModels", () => { + it("returns empty array for empty providers", () => { + expect(flattenModels({})).toEqual([]) + }) + + it("enriches each model with providerID and providerName", () => { + const providers = { openai: makeProvider("openai", "OpenAI", ["gpt-4"]) } + const models = flattenModels(providers) + expect(models).toHaveLength(1) + expect(models[0]!.providerID).toBe("openai") + expect(models[0]!.providerName).toBe("OpenAI") + expect(models[0]!.id).toBe("gpt-4") + }) + + it("flattens multiple providers", () => { + const providers = { + openai: makeProvider("openai", "OpenAI", ["gpt-4", "gpt-3.5"]), + anthropic: makeProvider("anthropic", "Anthropic", ["claude-3"]), + } + const models = flattenModels(providers) + expect(models).toHaveLength(3) + const ids = models.map((m) => m.id) + expect(ids).toContain("gpt-4") + expect(ids).toContain("gpt-3.5") + expect(ids).toContain("claude-3") + }) + + it("handles provider with no models", () => { + const providers = { empty: makeProvider("empty", "Empty", []) } + expect(flattenModels(providers)).toEqual([]) + }) +}) + +describe("findModel", () => { + const providers = { + openai: makeProvider("openai", "OpenAI", ["gpt-4", "gpt-3.5"]), + anthropic: makeProvider("anthropic", "Anthropic", ["claude-3"]), + } + const models = flattenModels(providers) + + it("returns undefined for null selection", () => { + expect(findModel(models, null)).toBeUndefined() + }) + + it("finds model by providerID and modelID", () => { + const result = findModel(models, { providerID: "openai", modelID: "gpt-4" }) + expect(result).not.toBeUndefined() + expect(result?.id).toBe("gpt-4") + expect(result?.providerID).toBe("openai") + }) + + it("returns undefined when providerID does not match", () => { + expect(findModel(models, { providerID: "unknown", modelID: "gpt-4" })).toBeUndefined() + }) + + it("returns undefined when modelID does not match", () => { + expect(findModel(models, { providerID: "openai", modelID: "unknown-model" })).toBeUndefined() + }) + + it("finds model from second provider", () => { + const result = findModel(models, { providerID: "anthropic", modelID: "claude-3" }) + expect(result?.providerName).toBe("Anthropic") + }) + + it("returns undefined for empty model list", () => { + expect(findModel([], { providerID: "openai", modelID: "gpt-4" })).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/context/provider-utils.ts b/packages/kilo-vscode/webview-ui/src/context/provider-utils.ts new file mode 100644 index 0000000000..0121b09d67 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/provider-utils.ts @@ -0,0 +1,30 @@ +import type { Provider, ProviderModel, ModelSelection } from "../types/messages" + +export type EnrichedModel = ProviderModel & { providerID: string; providerName: string } + +/** + * Flatten a provider map into a list of models enriched with provider info. + */ +export function flattenModels(providers: Record): EnrichedModel[] { + const result: EnrichedModel[] = [] + for (const providerID of Object.keys(providers)) { + const provider = providers[providerID]! + for (const modelID of Object.keys(provider.models)) { + result.push({ + ...provider.models[modelID]!, + id: modelID, + providerID, + providerName: provider.name, + }) + } + } + return result +} + +/** + * Find an enriched model from a flat model list by provider ID and model ID. + */ +export function findModel(models: EnrichedModel[], selection: ModelSelection | null): EnrichedModel | undefined { + if (!selection) return undefined + return models.find((m) => m.providerID === selection.providerID && m.id === selection.modelID) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/provider.tsx b/packages/kilo-vscode/webview-ui/src/context/provider.tsx index 4f2e4c96a6..fe69d70216 100644 --- a/packages/kilo-vscode/webview-ui/src/context/provider.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/provider.tsx @@ -7,6 +7,7 @@ import { createContext, useContext, createSignal, createMemo, onCleanup, ParentComponent, Accessor } from "solid-js" import { useVSCode } from "./vscode" import type { Provider, ProviderModel, ModelSelection, ExtensionMessage } from "../types/messages" +import { flattenModels, findModel as _findModel } from "./provider-utils" export type EnrichedModel = ProviderModel & { providerID: string; providerName: string } @@ -31,30 +32,10 @@ export const ProviderProvider: ParentComponent = (props) => { const [defaults, setDefaults] = createSignal>({}) const [defaultSelection, setDefaultSelection] = createSignal(KILO_AUTO) - // Flat list of all models enriched with provider info - const models = createMemo(() => { - const result: EnrichedModel[] = [] - const provs = providers() - for (const providerID of Object.keys(provs)) { - const provider = provs[providerID] - for (const modelID of Object.keys(provider.models)) { - result.push({ - ...provider.models[modelID], - id: modelID, - providerID, - providerName: provider.name, - }) - } - } - return result - }) + const models = createMemo(() => flattenModels(providers())) - // Look up an enriched model by selection function findModel(selection: ModelSelection | null): EnrichedModel | undefined { - if (!selection) { - return undefined - } - return models().find((m) => m.providerID === selection.providerID && m.id === selection.modelID) + return _findModel(models(), selection) } // Register handler immediately (not in onMount) so we never miss From 36c223b55c24213eb3bbd3fed5a3464cf5799552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:05:27 +0100 Subject: [PATCH 33/73] test(vscode): extract locale normalization and template interpolation tests --- .../tests/unit/language-utils.test.ts | 94 +++++++++++++++++++ .../webview-ui/src/context/language-utils.ts | 70 ++++++++++++++ .../webview-ui/src/context/language.tsx | 67 ++----------- 3 files changed, 171 insertions(+), 60 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/language-utils.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/language-utils.ts diff --git a/packages/kilo-vscode/tests/unit/language-utils.test.ts b/packages/kilo-vscode/tests/unit/language-utils.test.ts new file mode 100644 index 0000000000..75ad9bd5f8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/language-utils.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "bun:test" +import { normalizeLocale, resolveTemplate } from "../../webview-ui/src/context/language-utils" + +describe("normalizeLocale", () => { + it("returns 'en' for English", () => { + expect(normalizeLocale("en")).toBe("en") + expect(normalizeLocale("en-US")).toBe("en") + expect(normalizeLocale("en-GB")).toBe("en") + }) + + it("returns 'zh' for Simplified Chinese", () => { + expect(normalizeLocale("zh")).toBe("zh") + expect(normalizeLocale("zh-CN")).toBe("zh") + expect(normalizeLocale("zh-Hans")).toBe("zh") + }) + + it("returns 'zht' for Traditional Chinese", () => { + expect(normalizeLocale("zh-Hant")).toBe("zht") + expect(normalizeLocale("zh-TW")).toBe("zh") + expect(normalizeLocale("zh-hant-TW")).toBe("zht") + }) + + it("returns 'de' for German", () => { + expect(normalizeLocale("de")).toBe("de") + expect(normalizeLocale("de-AT")).toBe("de") + }) + + it("returns 'ko' for Korean", () => { + expect(normalizeLocale("ko")).toBe("ko") + expect(normalizeLocale("ko-KR")).toBe("ko") + }) + + it("returns 'no' for Norwegian Bokmål", () => { + expect(normalizeLocale("nb")).toBe("no") + expect(normalizeLocale("nb-NO")).toBe("no") + }) + + it("returns 'no' for Norwegian Nynorsk", () => { + expect(normalizeLocale("nn")).toBe("no") + }) + + it("returns 'br' for Portuguese", () => { + expect(normalizeLocale("pt")).toBe("br") + expect(normalizeLocale("pt-BR")).toBe("br") + expect(normalizeLocale("pt-PT")).toBe("br") + }) + + it("falls back to 'en' for unknown locale", () => { + expect(normalizeLocale("xx")).toBe("en") + expect(normalizeLocale("xyz-ZZ")).toBe("en") + }) + + it("is case-insensitive", () => { + expect(normalizeLocale("EN")).toBe("en") + expect(normalizeLocale("DE")).toBe("de") + expect(normalizeLocale("ZH-HANT")).toBe("zht") + }) +}) + +describe("resolveTemplate", () => { + it("returns text unchanged when no params", () => { + expect(resolveTemplate("hello world")).toBe("hello world") + }) + + it("returns text unchanged when params is undefined", () => { + expect(resolveTemplate("no {{var}} here", undefined)).toBe("no {{var}} here") + }) + + it("interpolates a single variable", () => { + expect(resolveTemplate("Hello {{name}}!", { name: "World" })).toBe("Hello World!") + }) + + it("interpolates multiple variables", () => { + const result = resolveTemplate("{{a}} + {{b}} = {{c}}", { a: "1", b: "2", c: "3" }) + expect(result).toBe("1 + 2 = 3") + }) + + it("replaces missing variable with empty string", () => { + expect(resolveTemplate("{{missing}}", {})).toBe("") + }) + + it("handles numeric variable values", () => { + expect(resolveTemplate("count: {{n}}", { n: 42 })).toBe("count: 42") + }) + + it("handles whitespace around key in braces", () => { + expect(resolveTemplate("{{ name }}", { name: "test" })).toBe("test") + }) + + it("leaves unrelated text intact", () => { + const result = resolveTemplate("prefix {{x}} suffix", { x: "VALUE" }) + expect(result).toBe("prefix VALUE suffix") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/context/language-utils.ts b/packages/kilo-vscode/webview-ui/src/context/language-utils.ts new file mode 100644 index 0000000000..46b6b04f79 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/language-utils.ts @@ -0,0 +1,70 @@ +export type Locale = + | "en" + | "zh" + | "zht" + | "ko" + | "de" + | "es" + | "fr" + | "da" + | "ja" + | "pl" + | "ru" + | "ar" + | "no" + | "br" + | "th" + | "bs" + +export const LOCALES: readonly Locale[] = [ + "en", + "zh", + "zht", + "ko", + "de", + "es", + "fr", + "da", + "ja", + "pl", + "ru", + "ar", + "no", + "br", + "th", + "bs", +] + +/** + * Normalize a BCP 47 language tag to one of the supported Locale values. + * Falls back to "en" for unrecognized locales. + */ +export function normalizeLocale(lang: string): Locale { + const lower = lang.toLowerCase() + if (lower.startsWith("zh")) { + return lower.includes("hant") ? "zht" : "zh" + } + for (const loc of LOCALES) { + if (lower.startsWith(loc)) { + return loc + } + } + if (lower.startsWith("nb") || lower.startsWith("nn")) { + return "no" + } + if (lower.startsWith("pt")) { + return "br" + } + return "en" +} + +/** + * Perform {{key}} template interpolation against a params record. + */ +export function resolveTemplate(text: string, params?: Record): string { + if (!params) return text + return text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, rawKey) => { + const value = params[String(rawKey)] + return value === undefined ? "" : String(value) + }) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/language.tsx b/packages/kilo-vscode/webview-ui/src/context/language.tsx index ae2bb64553..2fd2d63122 100644 --- a/packages/kilo-vscode/webview-ui/src/context/language.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/language.tsx @@ -58,43 +58,12 @@ import { dict as kiloBr } from "@kilocode/kilo-i18n/br" import { dict as kiloTh } from "@kilocode/kilo-i18n/th" import { dict as kiloBs } from "@kilocode/kilo-i18n/bs" import { useVSCode } from "./vscode" +import { normalizeLocale as _normalizeLocale, resolveTemplate as _resolveTemplate } from "./language-utils" -export type Locale = - | "en" - | "zh" - | "zht" - | "ko" - | "de" - | "es" - | "fr" - | "da" - | "ja" - | "pl" - | "ru" - | "ar" - | "no" - | "br" - | "th" - | "bs" - -export const LOCALES: readonly Locale[] = [ - "en", - "zh", - "zht", - "ko", - "de", - "es", - "fr", - "da", - "ja", - "pl", - "ru", - "ar", - "no", - "br", - "th", - "bs", -] +export type { Locale } from "./language-utils" +export { LOCALES } from "./language-utils" +import type { Locale } from "./language-utils" +import { LOCALES } from "./language-utils" export const LOCALE_LABELS: Record = { en: "English", @@ -137,33 +106,11 @@ const dicts: Record> = { } function normalizeLocale(lang: string): Locale { - const lower = lang.toLowerCase() - if (lower.startsWith("zh")) { - return lower.includes("hant") ? "zht" : "zh" - } - for (const loc of LOCALES) { - if (lower.startsWith(loc)) { - return loc - } - } - // Special cases - if (lower.startsWith("nb") || lower.startsWith("nn")) { - return "no" - } - if (lower.startsWith("pt")) { - return "br" - } - return "en" + return _normalizeLocale(lang) } function resolveTemplate(text: string, params?: UiI18nParams) { - if (!params) { - return text - } - return text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, rawKey) => { - const value = params[String(rawKey)] - return value === undefined ? "" : String(value) - }) + return _resolveTemplate(text, params as Record) } interface LanguageProviderProps { From 1257c675b1bc69f4f50cdf793c63e81917e9b03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:07:14 +0100 Subject: [PATCH 34/73] test(vscode): extract file mention pattern matching and attachment building tests --- .../tests/unit/file-mention-utils.test.ts | 116 ++++++++++++++++++ .../src/hooks/file-mention-utils.ts | 48 ++++++++ .../webview-ui/src/hooks/useFileMention.ts | 31 ++--- 3 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/file-mention-utils.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts new file mode 100644 index 0000000000..bc294c5f45 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "bun:test" +import { + AT_PATTERN, + syncMentionedPaths, + buildTextAfterMentionSelect, + buildFileAttachments, +} from "../../webview-ui/src/hooks/file-mention-utils" + +describe("AT_PATTERN", () => { + it("matches @mention at start of string", () => { + expect(AT_PATTERN.test("@foo")).toBe(true) + }) + + it("matches @mention after whitespace", () => { + expect(AT_PATTERN.test("hello @foo")).toBe(true) + }) + + it("does not match @mention in middle of word", () => { + expect(AT_PATTERN.test("hello@foo")).toBe(false) + }) + + it("captures the path after @", () => { + const match = "hello @path/to/file.ts".match(AT_PATTERN) + expect(match?.[1]).toBe("path/to/file.ts") + }) + + it("matches empty @", () => { + expect(AT_PATTERN.test("@")).toBe(true) + }) +}) + +describe("syncMentionedPaths", () => { + it("keeps paths still referenced in text", () => { + const paths = new Set(["foo.ts", "bar.ts"]) + const result = syncMentionedPaths(paths, "see @foo.ts for details") + expect(result.has("foo.ts")).toBe(true) + expect(result.has("bar.ts")).toBe(false) + }) + + it("returns empty set when text has no @references", () => { + const paths = new Set(["foo.ts"]) + const result = syncMentionedPaths(paths, "no references here") + expect(result.size).toBe(0) + }) + + it("keeps multiple paths that are all referenced", () => { + const paths = new Set(["a.ts", "b.ts"]) + const result = syncMentionedPaths(paths, "@a.ts and @b.ts are both here") + expect(result.size).toBe(2) + }) + + it("does not mutate the original set", () => { + const paths = new Set(["foo.ts"]) + syncMentionedPaths(paths, "no references") + expect(paths.has("foo.ts")).toBe(true) + }) +}) + +describe("buildTextAfterMentionSelect", () => { + it("replaces @mention with selected path", () => { + const before = "hello @par" + const after = " world" + const result = buildTextAfterMentionSelect(before, after, "src/component.ts") + expect(result).toBe("hello @src/component.ts world") + }) + + it("handles @mention at start of string", () => { + const result = buildTextAfterMentionSelect("@par", "", "foo.ts") + expect(result).toBe("@foo.ts") + }) + + it("preserves space prefix before @mention", () => { + const result = buildTextAfterMentionSelect("text @par", "", "foo.ts") + expect(result).toBe("text @foo.ts") + }) + + it("appends suffix after replacement", () => { + const result = buildTextAfterMentionSelect("before @q", " after text", "file.ts") + expect(result).toContain("after text") + }) +}) + +describe("buildFileAttachments", () => { + it("returns empty array for empty paths set", () => { + expect(buildFileAttachments("hello @foo.ts", new Set(), "/workspace")).toEqual([]) + }) + + it("returns attachment for mentioned path", () => { + const paths = new Set(["src/foo.ts"]) + const result = buildFileAttachments("check @src/foo.ts", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.mime).toBe("text/plain") + expect(result[0]!.url).toContain("file://") + expect(result[0]!.url).toContain("src/foo.ts") + }) + + it("skips paths not in text", () => { + const paths = new Set(["foo.ts", "bar.ts"]) + const result = buildFileAttachments("only @foo.ts here", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.url).toContain("foo.ts") + }) + + it("handles absolute paths directly", () => { + const paths = new Set(["/abs/path/file.ts"]) + const result = buildFileAttachments("@/abs/path/file.ts", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.url).toContain("/abs/path/file.ts") + }) + + it("normalizes Windows backslashes in workspaceDir", () => { + const paths = new Set(["foo.ts"]) + const result = buildFileAttachments("@foo.ts", paths, "C:\\Users\\workspace") + expect(result[0]!.url).not.toContain("\\") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts new file mode 100644 index 0000000000..1e71f0e8fe --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -0,0 +1,48 @@ +import type { FileAttachment } from "../types/messages" + +export const AT_PATTERN = /(?:^|\s)@(\S*)$/ + +/** + * Sync the set of mentioned paths against the current text. + * Removes any paths that are no longer present in the text as @path mentions. + */ +export function syncMentionedPaths(prev: Set, text: string): Set { + const next = new Set() + for (const path of prev) { + if (text.includes(`@${path}`)) next.add(path) + } + return next +} + +/** + * Replace the @mention pattern before the cursor with the selected file path. + * Returns the new text string. + */ +export function buildTextAfterMentionSelect(before: string, after: string, path: string): string { + const replaced = before.replace(AT_PATTERN, (match) => { + const prefix = match.startsWith(" ") ? " " : "" + return `${prefix}@${path}` + }) + return replaced + after +} + +/** + * Build FileAttachment objects from currently mentioned paths in the text. + */ +export function buildFileAttachments( + text: string, + mentionedPaths: Set, + workspaceDir: string, +): FileAttachment[] { + const result: FileAttachment[] = [] + const dir = workspaceDir.replaceAll("\\", "/") + for (const path of mentionedPaths) { + if (text.includes(`@${path}`)) { + const abs = path.startsWith("/") ? path : `${dir}/${path}` + const url = new URL("file://") + url.pathname = abs.startsWith("/") ? abs : `/${abs}` + result.push({ mime: "text/plain", url: url.href }) + } + } + return result +} diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts index 25e9a7abd6..4a68f3cd6d 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts @@ -1,9 +1,14 @@ import { createEffect, createSignal, onCleanup } from "solid-js" import type { Accessor } from "solid-js" import type { FileAttachment, WebviewMessage, ExtensionMessage } from "../types/messages" +import { + AT_PATTERN, + syncMentionedPaths as _syncMentionedPaths, + buildTextAfterMentionSelect, + buildFileAttachments, +} from "./file-mention-utils" const FILE_SEARCH_DEBOUNCE_MS = 150 -const AT_PATTERN = /(?:^|\s)@(\S*)$/ interface VSCodeContext { postMessage: (message: WebviewMessage) => void @@ -78,13 +83,7 @@ export function useFileMention(vscode: VSCodeContext): FileMention { } const syncMentionedPaths = (text: string) => { - setMentionedPaths((prev) => { - const next = new Set() - for (const path of prev) { - if (text.includes(`@${path}`)) next.add(path) - } - return next - }) + setMentionedPaths((prev) => _syncMentionedPaths(prev, text)) } const selectMentionFile = ( @@ -161,20 +160,8 @@ export function useFileMention(vscode: VSCodeContext): FileMention { return false } - const parseFileAttachments = (text: string): FileAttachment[] => { - const paths = mentionedPaths() - const result: FileAttachment[] = [] - const dir = workspaceDir.replaceAll("\\", "/") - for (const path of paths) { - if (text.includes(`@${path}`)) { - const abs = path.startsWith("/") ? path : `${dir}/${path}` - const url = new URL("file://") - url.pathname = abs.startsWith("/") ? abs : `/${abs}` - result.push({ mime: "text/plain", url: url.href }) - } - } - return result - } + const parseFileAttachments = (text: string): FileAttachment[] => + buildFileAttachments(text, mentionedPaths(), workspaceDir) return { mentionedPaths, From cd36bfa9736460cc945ed3244ebba765914c794c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:08:49 +0100 Subject: [PATCH 35/73] test(vscode): extract image MIME filtering and drag-leave detection tests --- .../unit/image-attachments-utils.test.ts | 58 +++++++++++++++++++ .../src/hooks/image-attachments-utils.ts | 15 +++++ .../src/hooks/useImageAttachments.ts | 7 ++- 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/image-attachments-utils.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/hooks/image-attachments-utils.ts diff --git a/packages/kilo-vscode/tests/unit/image-attachments-utils.test.ts b/packages/kilo-vscode/tests/unit/image-attachments-utils.test.ts new file mode 100644 index 0000000000..c5b50bbaeb --- /dev/null +++ b/packages/kilo-vscode/tests/unit/image-attachments-utils.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "bun:test" +import { + ACCEPTED_IMAGE_TYPES, + isAcceptedImageType, + isDragLeavingComponent, +} from "../../webview-ui/src/hooks/image-attachments-utils" + +describe("ACCEPTED_IMAGE_TYPES", () => { + it("includes the standard image MIME types", () => { + expect(ACCEPTED_IMAGE_TYPES).toContain("image/png") + expect(ACCEPTED_IMAGE_TYPES).toContain("image/jpeg") + expect(ACCEPTED_IMAGE_TYPES).toContain("image/gif") + expect(ACCEPTED_IMAGE_TYPES).toContain("image/webp") + }) +}) + +describe("isAcceptedImageType", () => { + it("returns true for accepted types", () => { + expect(isAcceptedImageType("image/png")).toBe(true) + expect(isAcceptedImageType("image/jpeg")).toBe(true) + expect(isAcceptedImageType("image/gif")).toBe(true) + expect(isAcceptedImageType("image/webp")).toBe(true) + }) + + it("returns false for non-image types", () => { + expect(isAcceptedImageType("application/pdf")).toBe(false) + expect(isAcceptedImageType("text/plain")).toBe(false) + expect(isAcceptedImageType("video/mp4")).toBe(false) + }) + + it("returns false for empty string", () => { + expect(isAcceptedImageType("")).toBe(false) + }) + + it("returns false for image types not in the accepted list", () => { + expect(isAcceptedImageType("image/svg+xml")).toBe(false) + expect(isAcceptedImageType("image/bmp")).toBe(false) + }) +}) + +describe("isDragLeavingComponent", () => { + it("returns true when relatedTarget is null (left the page)", () => { + const el = { contains: () => false } as unknown as HTMLElement + expect(isDragLeavingComponent(null, el)).toBe(true) + }) + + it("returns false when relatedTarget is a child (contains returns true)", () => { + const child = {} as EventTarget + const parent = { contains: (n: Node) => n === child } as unknown as HTMLElement + expect(isDragLeavingComponent(child, parent)).toBe(false) + }) + + it("returns true when relatedTarget is outside (contains returns false)", () => { + const outside = {} as EventTarget + const container = { contains: () => false } as unknown as HTMLElement + expect(isDragLeavingComponent(outside, container)).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/hooks/image-attachments-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/image-attachments-utils.ts new file mode 100644 index 0000000000..39bd74e01f --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/hooks/image-attachments-utils.ts @@ -0,0 +1,15 @@ +export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] + +/** Returns true if the given MIME type is an accepted image type. */ +export function isAcceptedImageType(mimeType: string): boolean { + return ACCEPTED_IMAGE_TYPES.includes(mimeType) +} + +/** + * Check if a drag-leave event is leaving the component (not just entering a child). + * Returns true if dragging has actually left the component boundary. + */ +export function isDragLeavingComponent(relatedTarget: EventTarget | null, currentTarget: HTMLElement): boolean { + if (!relatedTarget) return true + return !currentTarget.contains(relatedTarget as Node) +} diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useImageAttachments.ts b/packages/kilo-vscode/webview-ui/src/hooks/useImageAttachments.ts index 7853908337..f9f128cd35 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useImageAttachments.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useImageAttachments.ts @@ -1,6 +1,7 @@ import { createSignal } from "solid-js" +import { ACCEPTED_IMAGE_TYPES, isAcceptedImageType, isDragLeavingComponent } from "./image-attachments-utils" -export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +export { ACCEPTED_IMAGE_TYPES } export interface ImageAttachment { id: string @@ -14,7 +15,7 @@ export function useImageAttachments() { const [dragging, setDragging] = createSignal(false) const add = (file: File) => { - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) return + if (!isAcceptedImageType(file.type)) return const reader = new FileReader() reader.onload = () => { const attachment: ImageAttachment = { @@ -53,7 +54,7 @@ export function useImageAttachments() { } const handleDragLeave = (event: DragEvent) => { - if (!event.relatedTarget || !(event.currentTarget as HTMLElement).contains(event.relatedTarget as Node)) { + if (isDragLeavingComponent(event.relatedTarget, event.currentTarget as HTMLElement)) { setDragging(false) } } From 568d21a8165924d74db345ea2cdcc8e79683acc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:09:52 +0100 Subject: [PATCH 36/73] test(vscode): extract CSP string building and add policy directive tests --- packages/kilo-vscode/src/utils.ts | 14 +--- .../kilo-vscode/src/webview-html-utils.ts | 34 +++++++++ .../tests/unit/webview-html.test.ts | 75 +++++++++++++++++++ 3 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 packages/kilo-vscode/src/webview-html-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/webview-html.test.ts diff --git a/packages/kilo-vscode/src/utils.ts b/packages/kilo-vscode/src/utils.ts index 72beab466d..948c20e2ee 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -1,5 +1,6 @@ import * as crypto from "crypto" import * as vscode from "vscode" +import { buildCspString } from "./webview-html-utils" export function getNonce(): string { return crypto.randomBytes(16).toString("hex") @@ -17,18 +18,7 @@ export function buildWebviewHtml( }, ): string { const nonce = getNonce() - const connectSrc = opts.port - ? `http://127.0.0.1:${opts.port} http://localhost:${opts.port} ws://127.0.0.1:${opts.port} ws://localhost:${opts.port}` - : "http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*" - - const csp = [ - "default-src 'none'", - `style-src 'unsafe-inline' ${webview.cspSource}`, - `script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`, - `font-src ${webview.cspSource}`, - `connect-src ${connectSrc}`, - `img-src ${webview.cspSource} data: https:`, - ].join("; ") + const csp = buildCspString(webview.cspSource, nonce, opts.port) return ` diff --git a/packages/kilo-vscode/src/webview-html-utils.ts b/packages/kilo-vscode/src/webview-html-utils.ts new file mode 100644 index 0000000000..629320dfa0 --- /dev/null +++ b/packages/kilo-vscode/src/webview-html-utils.ts @@ -0,0 +1,34 @@ +/** + * Build the Content-Security-Policy connect-src directive value. + * If a port is specified, restricts connections to that port. + * Otherwise, allows any localhost/127.0.0.1 port. + */ +export function buildConnectSrc(port?: number): string { + if (port) { + return `http://127.0.0.1:${port} http://localhost:${port} ws://127.0.0.1:${port} ws://localhost:${port}` + } + return "http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*" +} + +/** + * Join an array of CSP directives into a policy string. + */ +export function joinCspDirectives(directives: string[]): string { + return directives.join("; ") +} + +/** + * Build the full CSP policy string for a webview. + */ +export function buildCspString(cspSource: string, nonce: string, port?: number): string { + const connectSrc = buildConnectSrc(port) + const directives = [ + "default-src 'none'", + `style-src 'unsafe-inline' ${cspSource}`, + `script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`, + `font-src ${cspSource}`, + `connect-src ${connectSrc}`, + `img-src ${cspSource} data: https:`, + ] + return joinCspDirectives(directives) +} diff --git a/packages/kilo-vscode/tests/unit/webview-html.test.ts b/packages/kilo-vscode/tests/unit/webview-html.test.ts new file mode 100644 index 0000000000..2b930f163f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/webview-html.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "bun:test" +import { buildConnectSrc, buildCspString } from "../../src/webview-html-utils" + +describe("buildConnectSrc", () => { + it("uses wildcard ports when no port specified", () => { + const result = buildConnectSrc() + expect(result).toContain("http://127.0.0.1:*") + expect(result).toContain("http://localhost:*") + expect(result).toContain("ws://127.0.0.1:*") + expect(result).toContain("ws://localhost:*") + }) + + it("restricts to specific port when port provided", () => { + const result = buildConnectSrc(3000) + expect(result).toContain("http://127.0.0.1:3000") + expect(result).toContain("http://localhost:3000") + expect(result).toContain("ws://127.0.0.1:3000") + expect(result).toContain("ws://localhost:3000") + }) + + it("does not include wildcard when port is provided", () => { + const result = buildConnectSrc(3000) + expect(result).not.toContain(":*") + }) + + it("uses the exact port number", () => { + expect(buildConnectSrc(54321)).toContain(":54321") + }) +}) + +describe("buildCspString", () => { + const cspSource = "vscode-resource://test" + const nonce = "abc123" + + it("includes default-src 'none'", () => { + expect(buildCspString(cspSource, nonce)).toContain("default-src 'none'") + }) + + it("includes nonce in script-src", () => { + const result = buildCspString(cspSource, nonce) + expect(result).toContain(`'nonce-${nonce}'`) + expect(result).toContain("'wasm-unsafe-eval'") + }) + + it("includes cspSource in style-src and font-src", () => { + const result = buildCspString(cspSource, nonce) + expect(result).toContain(`style-src 'unsafe-inline' ${cspSource}`) + expect(result).toContain(`font-src ${cspSource}`) + }) + + it("includes cspSource and https: in img-src", () => { + const result = buildCspString(cspSource, nonce) + expect(result).toContain("img-src") + expect(result).toContain(cspSource) + expect(result).toContain("https:") + expect(result).toContain("data:") + }) + + it("uses wildcard connect-src when no port provided", () => { + const result = buildCspString(cspSource, nonce) + expect(result).toContain("http://127.0.0.1:*") + }) + + it("uses specific port in connect-src when port provided", () => { + const result = buildCspString(cspSource, nonce, 9000) + expect(result).toContain("http://127.0.0.1:9000") + expect(result).not.toContain(":*") + }) + + it("joins directives with semicolons", () => { + const result = buildCspString(cspSource, nonce) + const parts = result.split(";") + expect(parts.length).toBeGreaterThanOrEqual(5) + }) +}) From 5eac807d106f807574dbb1751da24b4d9b8439b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:11:03 +0100 Subject: [PATCH 37/73] test(vscode): extract telemetry payload building and auth header tests --- .../telemetry/telemetry-proxy-utils.ts | 21 +++++++ .../src/services/telemetry/telemetry-proxy.ts | 11 ++-- .../tests/unit/telemetry-proxy-utils.test.ts | 62 +++++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 packages/kilo-vscode/src/services/telemetry/telemetry-proxy-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/telemetry-proxy-utils.test.ts diff --git a/packages/kilo-vscode/src/services/telemetry/telemetry-proxy-utils.ts b/packages/kilo-vscode/src/services/telemetry/telemetry-proxy-utils.ts new file mode 100644 index 0000000000..a20348a164 --- /dev/null +++ b/packages/kilo-vscode/src/services/telemetry/telemetry-proxy-utils.ts @@ -0,0 +1,21 @@ +/** + * Build the merged properties object for a telemetry event. + * Provider properties are included first so event-specific properties can override them. + */ +export function buildTelemetryPayload( + event: string, + properties: Record | undefined, + providerProperties: Record | undefined, +): { event: string; properties: Record } { + return { + event, + properties: { ...providerProperties, ...properties }, + } +} + +/** + * Build the Authorization header value for the telemetry endpoint. + */ +export function buildTelemetryAuthHeader(password: string): string { + return `Basic ${Buffer.from(`kilo:${password}`).toString("base64")}` +} diff --git a/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts b/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts index 2ebe2e3789..4e5b97e4a4 100644 --- a/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts +++ b/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode" import { TelemetryEventName, type TelemetryPropertiesProvider } from "./types" +import { buildTelemetryPayload, buildTelemetryAuthHeader } from "./telemetry-proxy-utils" /** * Singleton proxy that captures telemetry events and forwards them to the CLI @@ -45,13 +46,9 @@ export class TelemetryProxy { if (!this.isVSCodeTelemetryEnabled()) return if (!this.url || !this.password) return - const merged = { - ...this.provider?.getTelemetryProperties(), - ...properties, - } - - const payload = JSON.stringify({ event, properties: merged }) - const auth = `Basic ${Buffer.from(`kilo:${this.password}`).toString("base64")}` + const built = buildTelemetryPayload(event, properties, this.provider?.getTelemetryProperties()) + const payload = JSON.stringify(built) + const auth = buildTelemetryAuthHeader(this.password) fetch(`${this.url}/telemetry/capture`, { method: "POST", diff --git a/packages/kilo-vscode/tests/unit/telemetry-proxy-utils.test.ts b/packages/kilo-vscode/tests/unit/telemetry-proxy-utils.test.ts new file mode 100644 index 0000000000..531045a232 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/telemetry-proxy-utils.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "bun:test" +import { buildTelemetryPayload, buildTelemetryAuthHeader } from "../../src/services/telemetry/telemetry-proxy-utils" + +describe("buildTelemetryPayload", () => { + it("includes event name in payload", () => { + const result = buildTelemetryPayload("test.event", {}, undefined) + expect(result.event).toBe("test.event") + }) + + it("merges provider properties with event properties", () => { + const result = buildTelemetryPayload( + "test.event", + { eventProp: "value" }, + { providerProp: "providerValue" }, + ) + expect(result.properties.eventProp).toBe("value") + expect(result.properties.providerProp).toBe("providerValue") + }) + + it("event properties override provider properties", () => { + const result = buildTelemetryPayload( + "test.event", + { shared: "from-event" }, + { shared: "from-provider" }, + ) + expect(result.properties.shared).toBe("from-event") + }) + + it("handles undefined event properties", () => { + const result = buildTelemetryPayload("test.event", undefined, { providerProp: "x" }) + expect(result.properties.providerProp).toBe("x") + }) + + it("handles undefined provider properties", () => { + const result = buildTelemetryPayload("test.event", { key: "val" }, undefined) + expect(result.properties.key).toBe("val") + }) + + it("handles both undefined", () => { + const result = buildTelemetryPayload("test.event", undefined, undefined) + expect(result.properties).toEqual({}) + }) +}) + +describe("buildTelemetryAuthHeader", () => { + it("returns a Basic auth header string", () => { + const result = buildTelemetryAuthHeader("mypassword") + expect(result.startsWith("Basic ")).toBe(true) + }) + + it("encodes kilo:password in base64", () => { + const result = buildTelemetryAuthHeader("secret") + const encoded = Buffer.from("kilo:secret").toString("base64") + expect(result).toBe(`Basic ${encoded}`) + }) + + it("handles empty password", () => { + const result = buildTelemetryAuthHeader("") + const encoded = Buffer.from("kilo:").toString("base64") + expect(result).toBe(`Basic ${encoded}`) + }) +}) From 992a1bbbf1bf61603082a0e64124804893bd1540 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 20 Feb 2026 13:12:57 +0100 Subject: [PATCH 38/73] feat(vscode): add resizable sidebar to agent manager (#509) * feat(vscode): add resizable sidebar to agent manager Add drag-to-resize support for the agent manager left sidebar using the existing ResizeHandle component. Width defaults to 260px, can be resized between 200px and 40% of the viewport, and persists across webview state recoveries. * fix(vscode): read window.innerWidth at drag time for accurate max sidebar width Evaluate the viewport-based max constraint inside the onResize callback so it reflects the current panel size, not the size at initial render. --- .../agent-manager/AgentManagerApp.tsx | 24 +++++++++++++++---- .../agent-manager/agent-manager.css | 6 ++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b2017be6af..3e4ddb2745 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -29,6 +29,7 @@ import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" import { Code } from "@kilocode/kilo-ui/code" import { Diff } from "@kilocode/kilo-ui/diff" import { Toast } from "@kilocode/kilo-ui/toast" +import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" @@ -136,9 +137,14 @@ const AgentManagerContent: Component = () => { const [repoBranch, setRepoBranch] = createSignal() const [deletingWorktrees, setDeletingWorktrees] = createSignal>(new Set()) + const DEFAULT_SIDEBAR_WIDTH = 260 + const MIN_SIDEBAR_WIDTH = 200 + const MAX_SIDEBAR_WIDTH_RATIO = 0.4 + // Recover persisted local session IDs from webview state - const persisted = vscode.getState<{ localSessionIDs?: string[] }>() + const persisted = vscode.getState<{ localSessionIDs?: string[]; sidebarWidth?: number }>() const [localSessionIDs, setLocalSessionIDs] = createSignal(persisted?.localSessionIDs ?? []) + const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH) // Pending local tab counter for generating unique IDs let pendingCounter = 0 @@ -158,9 +164,12 @@ const AgentManagerContent: Component = () => { return id } - // Persist local session IDs to webview state for recovery (exclude pending tabs) + // Persist local session IDs and sidebar width to webview state for recovery (exclude pending tabs) createEffect(() => { - vscode.setState({ localSessionIDs: localSessionIDs().filter((id) => !isPending(id)) }) + vscode.setState({ + localSessionIDs: localSessionIDs().filter((id) => !isPending(id)), + sidebarWidth: sidebarWidth(), + }) }) // Save the currently active tab for the current sidebar context before switching away @@ -625,7 +634,14 @@ const AgentManagerContent: Component = () => { return (
-
+
+ setSidebarWidth(Math.min(width, window.innerWidth * MAX_SIDEBAR_WIDTH_RATIO))} + /> {/* Local workspace item */} )} @@ -854,10 +945,18 @@ const AgentManagerContent: Component = () => { pending ? s.id === activePendingId() && !session.currentSessionID() : s.id === session.currentSessionID() + const tabDirection = () => { + if (active()) return "" + const ids = activeTabs().map((t) => t.id) + const activeId = session.currentSessionID() ?? activePendingId() ?? "" + return adjacentHint(s.id, activeId, ids, kb().previousTab ?? "", kb().nextTab ?? "") + } return ( { if (pending) { setActivePendingId(s.id) @@ -877,16 +976,18 @@ const AgentManagerContent: Component = () => {
- + + +
- + { if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) }} /> - +
@@ -921,7 +1022,7 @@ const AgentManagerContent: Component = () => {
No sessions open
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 3cb5c2dbd0..0f217669f0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -122,11 +122,16 @@ flex-direction: column; gap: 2px; overflow-y: auto; + overflow-x: hidden; max-height: 50vh; } /* Worktree item — larger card style */ +.am-worktree-list [data-slot="hover-card-trigger"] { + min-width: 0; +} + .am-worktree-item { position: relative; display: flex; @@ -137,6 +142,8 @@ cursor: pointer; font-size: var(--font-size-base); color: var(--text-base); + min-width: 0; + width: 100%; } .am-worktree-item:hover { @@ -628,3 +635,87 @@ opacity: 0.6; margin-left: 6px; } + +/* HoverCard popover for worktree items */ + +.am-hover-card { + padding: 10px 12px; + min-width: 160px; + max-width: 240px; + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} + +.am-hover-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.am-hover-card-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-weaker); + line-height: 1.4; +} + +.am-hover-card-branch { + font-size: 13px; + font-weight: 600; + color: var(--text-strong); + line-height: 1.4; + word-break: break-all; +} + +.am-hover-card-meta { + font-size: 12px; + color: var(--text-weaker); + line-height: 1.4; +} + +.am-hover-card-keybind { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + height: 20px; + padding: 0 6px; + border-radius: 3px; + background: var(--surface-inset-base); + border: 1px solid var(--border-weak-base); + font-family: var(--font-family-sans); + font-size: 11px; + font-weight: 500; + line-height: 1; + color: var(--text-weak); + white-space: nowrap; +} + +.am-hover-card-divider { + height: 1px; + background: var(--border-weak-base); + margin: 6px 0; +} + +.am-hover-card-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.am-hover-card-row-label { + font-size: 12px; + color: var(--text-weaker); +} + +.am-hover-card-row-value { + font-size: 12px; + font-weight: 500; + color: var(--text-base); +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts index 473c7908aa..615a07d8b6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts @@ -46,6 +46,34 @@ export function validateLocalSession(persisted: string | undefined, ids: string[ return persisted } +/** + * Return the keybinding hint for an item adjacent to the active item. + * Only returns a hint when the item is exactly one step away in the flat list. + * Returns empty string for non-adjacent items or the active item itself. + * + * @param itemId - The item being hovered + * @param activeId - The currently selected/active item (or undefined for LOCAL) + * @param flatIds - The full ordered sidebar list (LOCAL first, then worktrees, then sessions) + * @param prev - Display string for "go up" (e.g. "⌘↑" or keybinding) + * @param next - Display string for "go down" (e.g. "⌘↓" or keybinding) + */ +export function adjacentHint( + itemId: string, + activeId: string | undefined, + flatIds: string[], + prev: string, + next: string, +): string { + if (!activeId || itemId === activeId) return "" + const activeIdx = flatIds.indexOf(activeId) + const itemIdx = flatIds.indexOf(itemId) + if (activeIdx === -1 || itemIdx === -1) return "" + const diff = itemIdx - activeIdx + if (diff === -1) return prev + if (diff === 1) return next + return "" +} + /** * After removing a worktree, pick the nearest remaining sidebar neighbor. * Order: the worktree just below → the one above → LOCAL. diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx index 26e40ec6ca..2b0948fb4e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx @@ -8,7 +8,7 @@ import type { Transformer } from "@thisbeyond/solid-dnd" import { createRoot } from "solid-js" import type { SessionInfo } from "../src/types/messages" import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" /** Lock drag movement to the X axis (horizontal-only tab dragging). */ export const ConstrainDragYAxis: Component = () => { @@ -33,6 +33,8 @@ export const ConstrainDragYAxis: Component = () => { export const SortableTab: Component<{ tab: SessionInfo active: boolean + keybind?: string + closeKeybind?: string onSelect: () => void onMiddleClick: (e: MouseEvent) => void onClose: (e: MouseEvent) => void @@ -47,23 +49,30 @@ export const SortableTab: Component<{ class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`} data-tab-id={props.tab.id} > - +
{props.tab.title || "Untitled"} - + + +
-
+ ) } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index ab75edfa54..2be7c991e8 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -566,6 +566,12 @@ export interface AgentManagerStateMessage { tabOrder?: Record } +// Resolved keybindings for agent manager actions +export interface AgentManagerKeybindingsMessage { + type: "agentManager.keybindings" + bindings: Record +} + export type ExtensionMessage = | ReadyMessage | ConnectionStateMessage @@ -604,6 +610,7 @@ export type ExtensionMessage = | AgentManagerWorktreeSetupMessage | AgentManagerSessionAddedMessage | AgentManagerStateMessage + | AgentManagerKeybindingsMessage | SetChatBoxMessage | TriggerTaskMessage From 5bdcd646bcc9f59f6ea9fffd6a44c44979414b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:37:36 +0100 Subject: [PATCH 46/73] ci(vscode): add unit test script and CI workflow with path filter --- .github/workflows/test-vscode.yml | 32 +++++++++++++++++++++++++++++++ packages/kilo-vscode/package.json | 3 ++- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test-vscode.yml diff --git a/.github/workflows/test-vscode.yml b/.github/workflows/test-vscode.yml new file mode 100644 index 0000000000..899ef32176 --- /dev/null +++ b/.github/workflows/test-vscode.yml @@ -0,0 +1,32 @@ +name: test-vscode + +on: + push: + branches: + - dev + paths: + - "packages/kilo-vscode/**" + pull_request: + paths: + - "packages/kilo-vscode/**" + workflow_dispatch: + +jobs: + unit: + name: unit tests + runs-on: blacksmith-4vcpu-ubuntu-2404 + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Run unit tests + working-directory: packages/kilo-vscode + run: bun run test:unit diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index f2fee4118a..7f648e2852 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -509,7 +509,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint src", - "test": "vscode-test" + "test": "vscode-test", + "test:unit": "bun test tests/unit/" }, "devDependencies": { "@types/diff": "^6.0.0", From 7fc37ca2e3761ce04eca0d8e1cfec16c11a95d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:38:08 +0100 Subject: [PATCH 47/73] fix(vscode): remove unused buildTextAfterMentionSelect import in useFileMention --- .../kilo-vscode/webview-ui/src/hooks/useFileMention.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts index 4a68f3cd6d..c06b90ab2a 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts @@ -1,12 +1,7 @@ import { createEffect, createSignal, onCleanup } from "solid-js" import type { Accessor } from "solid-js" import type { FileAttachment, WebviewMessage, ExtensionMessage } from "../types/messages" -import { - AT_PATTERN, - syncMentionedPaths as _syncMentionedPaths, - buildTextAfterMentionSelect, - buildFileAttachments, -} from "./file-mention-utils" +import { AT_PATTERN, syncMentionedPaths as _syncMentionedPaths, buildFileAttachments } from "./file-mention-utils" const FILE_SEARCH_DEBOUNCE_MS = 150 From b1ff1d607a6b995c23f43637ca14e668d4fcc907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:39:03 +0100 Subject: [PATCH 48/73] fix(vscode): use parseAutocompleteResponse util in HoleFiller.getFromChat --- .../services/autocomplete/classic-auto-complete/HoleFiller.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts index df4096b604..9d73504f8e 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts @@ -184,8 +184,7 @@ Return the COMPLETION tags` const usageInfo = await model.generateResponse(systemPrompt, userPrompt, onChunk) // Extract just the text from the response - prefix/suffix are handled by the caller - const completionMatch = response.match(/([\s\S]*?)<\/COMPLETION>/i) - const suggestionText = completionMatch ? (completionMatch[1] || "").replace(/<\/?COMPLETION>/gi, "") : "" + const { text: suggestionText } = _parseAutocompleteResponse(response, "", "") const fillInAtCursorSuggestion = processSuggestion(suggestionText) From 2d2699f0727c9fe2cb0a67ad678bedf512b54754 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 20 Feb 2026 13:39:21 +0100 Subject: [PATCH 49/73] feat(vscode): add loading skeletons for Agent Manager sessions and worktrees (#518) * feat: add loading skeletons for agent manager sessions and worktrees Add skeleton loading states to the Agent Manager sidebar so users see pulsing placeholders while worktrees and sessions load from the backend. - Track worktreesLoaded/sessionsLoaded signals in the webview - Add agentManager.requestState message so the webview can pull state on mount (fixes race where initial pushState fires before mount) - Await stateReady in the extension before responding to requestState (ensures JSON is loaded from disk before sending data) - Mark sessions loaded from both agentManager.state and sessionsLoaded message paths for consistent behavior - Add skeleton CSS with staggered pulse animations * fix: gate worktree list on sessionsLoaded to prevent branch name flash The worktreeLabel() function falls back to wt.branch (e.g. kilo-...) when session data hasn't arrived yet. Gate the worktree list render on both worktreesLoaded and sessionsLoaded so the skeleton stays visible until session titles are available. - Listen for sessionsLoaded message directly instead of watching session.sessions() length (handles empty session lists too) - Remove premature sessionsLoaded=true from agentManager.state handler since that message only carries IDs, not session titles * fix: make skeleton shapes match actual worktree/session item layout Worktree skeleton: single row with branch icon placeholder + text bar, matching the .am-worktree-item padding (10px) and gap (8px). Session skeleton: title bar + timestamp bar on the right, matching the .am-item layout with justify-content: space-between, padding (6px 10px), and smaller font size for the time placeholder. * fix: add .catch() to requestState handler to prevent unhandled rejection If initializeState() rejects, pushState() still fires with whatever state is available (empty arrays), so skeletons resolve to the empty state instead of loading forever. * fix: log error in requestState catch handler per style guide --- .../src/agent-manager/AgentManagerProvider.ts | 12 +- .../agent-manager/AgentManagerApp.tsx | 266 ++++++++++-------- .../agent-manager/agent-manager.css | 83 ++++++ .../webview-ui/src/types/messages.ts | 5 + 4 files changed, 254 insertions(+), 112 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 349205d9a6..8239509744 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -27,6 +27,7 @@ export class AgentManagerProvider implements vscode.Disposable { private state: WorktreeStateManager | undefined private setupScript: SetupScriptService | undefined private terminalManager: SessionTerminalManager + private stateReady: Promise | undefined constructor( private readonly extensionUri: vscode.Uri, @@ -74,7 +75,7 @@ export class AgentManagerProvider implements vscode.Disposable { onBeforeMessage: (msg) => this.onMessage(msg), }) - void this.initializeState() + this.stateReady = this.initializeState() void this.sendRepoInfo() this.sendKeybindings() @@ -146,6 +147,15 @@ export class AgentManagerProvider implements vscode.Disposable { void this.sendRepoInfo() return null } + if (type === "agentManager.requestState") { + void this.stateReady + ?.then(() => this.pushState()) + .catch((err) => { + this.log("initializeState failed, pushing partial state:", err) + this.pushState() + }) + return null + } if (type === "agentManager.setTabOrder" && typeof msg.key === "string" && Array.isArray(msg.order)) { this.state?.setTabOrder(msg.key as string, msg.order as string[]) return null diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 77b1fa731b..e3a2a695ce 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -157,6 +157,8 @@ const AgentManagerContent: Component = () => { const [selection, setSelection] = createSignal(LOCAL) const [repoBranch, setRepoBranch] = createSignal() const [deletingWorktrees, setDeletingWorktrees] = createSignal>(new Set()) + const [worktreesLoaded, setWorktreesLoaded] = createSignal(false) + const [sessionsLoaded, setSessionsLoaded] = createSignal(false) const DEFAULT_SIDEBAR_WIDTH = 260 const MIN_SIDEBAR_WIDTH = 200 @@ -443,6 +445,11 @@ const AgentManagerContent: Component = () => { } }) + // Mark sessions loaded as soon as the session context receives data (even if empty) + const unsubSessions = vscode.onMessage((msg) => { + if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true) + }) + const unsub = vscode.onMessage((msg) => { if (msg.type === "agentManager.repoInfo") { const info = msg as AgentManagerRepoInfoMessage @@ -480,6 +487,7 @@ const AgentManagerContent: Component = () => { const state = msg as AgentManagerStateMessage setWorktrees(state.worktrees) setManagedSessions(state.sessions) + if (!worktreesLoaded()) setWorktreesLoaded(true) if (state.tabOrder) setWorktreeTabOrder(state.tabOrder) const current = session.currentSessionID() if (current) { @@ -509,6 +517,7 @@ const AgentManagerContent: Component = () => { window.removeEventListener("keydown", preventDefaults) window.removeEventListener("focus", onWindowFocus) unsubCreate() + unsubSessions() unsub() }) }) @@ -516,6 +525,9 @@ const AgentManagerContent: Component = () => { // Always select local on mount to initialize branch info and session state onMount(() => { selectLocal() + // Request worktree/session state from extension — handles race where + // initializeState() pushState fires before the webview is mounted + vscode.postMessage({ type: "agentManager.requestState" }) // Open a pending "New Session" tab if there are no persisted local sessions if (localSessionIDs().length === 0) { addPendingTab() @@ -787,100 +799,112 @@ const AgentManagerContent: Component = () => {
- {(() => { - const [hoveredWt, setHoveredWt] = createSignal(null) - const [overClose, setOverClose] = createSignal(false) - return ( - - {(wt, wtIdx) => { - const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id)) - const navHint = () => { - const flat = [ - LOCAL as string, - ...worktrees().map((w) => w.id), - ...unassignedSessions().map((s) => s.id), - ] - const active = selection() ?? session.currentSessionID() ?? "" - return adjacentHint(wt.id, active, flat, kb().previousSession ?? "", kb().nextSession ?? "") - } - return ( - setHoveredWt(open ? wt.id : null)} - trigger={ -
selectWorktree(wt.id)} - > - - {worktreeLabel(wt)} - } + +
+
+
+
+
+ } + > + {(() => { + const [hoveredWt, setHoveredWt] = createSignal(null) + const [overClose, setOverClose] = createSignal(false) + return ( + + {(wt, wtIdx) => { + const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id)) + const navHint = () => { + const flat = [ + LOCAL as string, + ...worktrees().map((w) => w.id), + ...unassignedSessions().map((s) => s.id), + ] + const active = selection() ?? session.currentSessionID() ?? "" + return adjacentHint(wt.id, active, flat, kb().previousSession ?? "", kb().nextSession ?? "") + } + return ( + setHoveredWt(open ? wt.id : null)} + trigger={ +
selectWorktree(wt.id)} > -
setOverClose(true)} - onMouseLeave={() => setOverClose(false)} + + {worktreeLabel(wt)} + } > - setOverClose(true)} + onMouseLeave={() => setOverClose(false)} > - handleDeleteWorktree(wt.id, e)} - /> - + + handleDeleteWorktree(wt.id, e)} + /> + +
+ +
+ } + > +
+
+
+
BRANCH
+
{wt.branch}
+
{formatRelativeDate(wt.createdAt)}
+
+ + {navHint()} + +
+ +
+
+ Base + {wt.parentBranch}
-
- } - > -
-
-
-
BRANCH
-
{wt.branch}
-
{formatRelativeDate(wt.createdAt)}
-
- - {navHint()} - -
-
- Base - {wt.parentBranch} + Sessions + {sessions().length}
- -
-
- Sessions - {sessions().length}
-
- - ) - }} - - ) - })()} - - + + ) + }} + + ) + })()} + + +
@@ -891,33 +915,53 @@ const AgentManagerContent: Component = () => {
- - {(s) => ( - - )} - +
+
+
+
+
+
+
+
+
+ } + > + + {(s) => ( + + )} + +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 0f217669f0..fec15a4f04 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -719,3 +719,86 @@ font-weight: 500; color: var(--text-base); } + +/* Skeleton loading states */ + +@keyframes am-skeleton-pulse { + 0%, + 100% { + opacity: 0.12; + } + 50% { + opacity: 0.28; + } +} + +.am-skeleton-list { + display: flex; + flex-direction: column; + gap: 2px; + animation: am-fade-in 0.2s ease; +} + +/* Worktree skeleton — matches .am-worktree-item layout */ +.am-skeleton-wt { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 10px; +} + +.am-skeleton-wt-icon { + width: 16px; + height: 16px; + border-radius: 3px; + background: var(--text-base); + animation: am-skeleton-pulse 1.5s ease-in-out infinite; + flex-shrink: 0; +} + +.am-skeleton-wt-text { + height: 13px; + border-radius: 3px; + background: var(--text-base); + animation: am-skeleton-pulse 1.5s ease-in-out infinite; +} + +.am-skeleton-wt:nth-child(2) .am-skeleton-wt-icon, +.am-skeleton-wt:nth-child(2) .am-skeleton-wt-text { + animation-delay: 0.15s; +} + +/* Session skeleton — matches .am-item layout */ +.am-skeleton-session { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; +} + +.am-skeleton-session-title { + height: 13px; + border-radius: 3px; + background: var(--text-base); + animation: am-skeleton-pulse 1.5s ease-in-out infinite; +} + +.am-skeleton-session-time { + height: 10px; + width: 52px; + border-radius: 3px; + background: var(--text-base); + animation: am-skeleton-pulse 1.5s ease-in-out infinite; + flex-shrink: 0; +} + +.am-skeleton-session:nth-child(2) .am-skeleton-session-title, +.am-skeleton-session:nth-child(2) .am-skeleton-session-time { + animation-delay: 0.15s; +} + +.am-skeleton-session:nth-child(3) .am-skeleton-session-title, +.am-skeleton-session:nth-child(3) .am-skeleton-session-time { + animation-delay: 0.3s; +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 2be7c991e8..81160ae396 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -841,6 +841,10 @@ export interface RequestRepoInfoMessage { type: "agentManager.requestRepoInfo" } +export interface RequestStateMessage { + type: "agentManager.requestState" +} + // Configure worktree setup script export interface ConfigureSetupScriptRequest { type: "agentManager.configureSetupScript" @@ -902,6 +906,7 @@ export type WebviewMessage = | CloseSessionRequest | TelemetryRequest | RequestRepoInfoMessage + | RequestStateMessage | ConfigureSetupScriptRequest | ShowTerminalRequest | SetTabOrderRequest From bc3bb46aa0d2c3df0415b1ebf976e3b117e74a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:39:39 +0100 Subject: [PATCH 50/73] fix(vscode): log warning in parseSSEDataLine catch instead of empty catch block --- packages/kilo-vscode/src/services/cli-backend/http-utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/cli-backend/http-utils.ts b/packages/kilo-vscode/src/services/cli-backend/http-utils.ts index 5b9e9374d0..30a25fbbee 100644 --- a/packages/kilo-vscode/src/services/cli-backend/http-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/http-utils.ts @@ -52,7 +52,8 @@ export function parseSSEDataLine(line: string): SSEChunkResult | null { result.cost = parsed.cost } return result - } catch { + } catch (err) { + console.warn("[Kilo New] Failed to parse SSE data line", { err, line }) return null } } From 1bad5eb06794b7e7b49d8bf4403b0d344cc2c17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:40:57 +0100 Subject: [PATCH 51/73] test(vscode): add code-action-provider tests for action set and command IDs --- .../tests/unit/code-action-provider.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 packages/kilo-vscode/tests/unit/code-action-provider.test.ts diff --git a/packages/kilo-vscode/tests/unit/code-action-provider.test.ts b/packages/kilo-vscode/tests/unit/code-action-provider.test.ts new file mode 100644 index 0000000000..669210689d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/code-action-provider.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, mock } from "bun:test" + +const makeAction = (title: string, kind: string) => ({ title, kind }) +const makeKind = (value: string) => ({ + value, + append: (v: string) => makeKind(`${value}.${v}`), +}) + +const QuickFix = makeKind("quickfix") +const RefactorRewrite = makeKind("refactor.rewrite") + +const mockVscode = { + CodeAction: class { + command?: { command: string; title: string } + isPreferred?: boolean + constructor( + public title: string, + public kind: { value: string }, + ) {} + }, + CodeActionKind: { + QuickFix, + RefactorRewrite, + }, +} + +mock.module("vscode", () => mockVscode) + +const { KiloCodeActionProvider } = await import("../../src/services/code-actions/code-action-provider") + +const provider = new KiloCodeActionProvider() + +function makeRange(isEmpty: boolean) { + return { isEmpty } +} + +function makeContext(diagnosticCount: number) { + return { diagnostics: Array.from({ length: diagnosticCount }) } +} + +describe("KiloCodeActionProvider", () => { + describe("provideCodeActions", () => { + it("returns empty array when range is empty", () => { + const result = provider.provideCodeActions({} as never, makeRange(true) as never, makeContext(0) as never) + expect(result).toEqual([]) + }) + + it("returns empty array when range is empty even with diagnostics", () => { + const result = provider.provideCodeActions({} as never, makeRange(true) as never, makeContext(3) as never) + expect(result).toEqual([]) + }) + + describe("non-empty range, no diagnostics", () => { + it("returns Add, Explain, Improve actions", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never) + const titles = result.map((a) => a.title) + expect(titles).toContain("Add to Kilo Code") + expect(titles).toContain("Explain with Kilo Code") + expect(titles).toContain("Improve with Kilo Code") + }) + + it("does not include Fix action", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never) + expect(result.map((a) => a.title)).not.toContain("Fix with Kilo Code") + }) + + it("returns exactly 3 actions", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never) + expect(result).toHaveLength(3) + }) + + it("uses correct command IDs", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never) + const commands = result.map((a) => a.command?.command) + expect(commands).toContain("kilo-code.new.addToContext") + expect(commands).toContain("kilo-code.new.explainCode") + expect(commands).toContain("kilo-code.new.improveCode") + }) + + it("no action is preferred", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never) + expect(result.every((a) => !a.isPreferred)).toBe(true) + }) + }) + + describe("non-empty range, with diagnostics", () => { + it("returns Add and Fix actions", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(2) as never) + const titles = result.map((a) => a.title) + expect(titles).toContain("Add to Kilo Code") + expect(titles).toContain("Fix with Kilo Code") + }) + + it("does not include Explain or Improve actions", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never) + const titles = result.map((a) => a.title) + expect(titles).not.toContain("Explain with Kilo Code") + expect(titles).not.toContain("Improve with Kilo Code") + }) + + it("returns exactly 2 actions", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never) + expect(result).toHaveLength(2) + }) + + it("Fix action is preferred", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never) + const fix = result.find((a) => a.title === "Fix with Kilo Code") + expect(fix?.isPreferred).toBe(true) + }) + + it("Fix action uses QuickFix kind", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never) + const fix = result.find((a) => a.title === "Fix with Kilo Code") + expect(fix?.kind.value).toBe("quickfix") + }) + + it("uses correct Fix command ID", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never) + const fix = result.find((a) => a.title === "Fix with Kilo Code") + expect(fix?.command?.command).toBe("kilo-code.new.fixCode") + }) + }) + }) +}) From b1d5fa104ff4f9978d1e7c1ffe5c36a0bb90b31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:41:51 +0100 Subject: [PATCH 52/73] test(vscode): add generateQRCode tests for data URL output and error on empty input --- .../kilo-vscode/tests/unit/qrcode.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 packages/kilo-vscode/tests/unit/qrcode.test.ts diff --git a/packages/kilo-vscode/tests/unit/qrcode.test.ts b/packages/kilo-vscode/tests/unit/qrcode.test.ts new file mode 100644 index 0000000000..47a87be67f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/qrcode.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "bun:test" +import { generateQRCode } from "../../webview-ui/src/utils/qrcode" + +describe("generateQRCode", () => { + it("returns a data URL for a valid string", async () => { + const result = await generateQRCode("https://example.com") + expect(result).toMatch(/^data:image\/png;base64,/) + }) + + it("returns a non-empty base64 payload", async () => { + const result = await generateQRCode("hello") + const base64 = result.replace("data:image/png;base64,", "") + expect(base64.length).toBeGreaterThan(0) + }) + + it("produces different outputs for different inputs", async () => { + const a = await generateQRCode("https://example.com/a") + const b = await generateQRCode("https://example.com/b") + expect(a).not.toBe(b) + }) + + it("throws on empty string", async () => { + expect(generateQRCode("")).rejects.toThrow() + }) +}) From 92f5c74f2070e56f0daae4d51dd46e8eb3189e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 13:42:59 +0100 Subject: [PATCH 53/73] test(vscode): strengthen formatTime and postprocessAutocompleteSuggestion assertions --- .../tests/unit/autocomplete-statusbar-utils.test.ts | 11 +++++------ .../tests/unit/useless-suggestion-filter.test.ts | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts index 7a7c81b23c..9060db822f 100644 --- a/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts @@ -35,17 +35,16 @@ describe("humanFormatSessionCost", () => { }) describe("formatTime", () => { - it("returns a string for a valid timestamp", () => { + it("contains at least two colon-separated time components", () => { const result = formatTime(Date.now()) - expect(typeof result).toBe("string") - expect(result.length).toBeGreaterThan(0) + expect(result).toMatch(/\d+:\d+/) }) - it("includes time components (hours, minutes, seconds are present)", () => { + it("formats a known timestamp with correct hour and minute", () => { const ts = new Date("2024-01-15T14:30:45").getTime() const result = formatTime(ts) - expect(typeof result).toBe("string") - expect(result.length).toBeGreaterThan(0) + expect(result).toMatch(/30/) + expect(result).toMatch(/45/) }) it("produces different output for different timestamps", () => { diff --git a/packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts b/packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts index 3bea2aad07..ae6011007d 100644 --- a/packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts +++ b/packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts @@ -128,7 +128,7 @@ describe("postprocessAutocompleteSuggestion", () => { suffix: "\n}", model: "codestral", }) - expect(typeof result === "string" || result === undefined).toBe(true) + expect(result).toBe(" return x + y;") }) it("returns undefined for empty suggestion", () => { From 5808ab0042ccf3a4e879880f04398e7722128818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 14:13:54 +0100 Subject: [PATCH 54/73] test(vscode): extract providerSortKey, isFree, buildTriggerLabel from ModelSelector and add tests --- .../tests/unit/model-selector-utils.test.ts | 105 ++++++++++++++++++ .../src/components/chat/ModelSelector.tsx | 43 +++---- .../components/chat/model-selector-utils.ts | 31 ++++++ 3 files changed, 150 insertions(+), 29 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/model-selector-utils.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/model-selector-utils.ts diff --git a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts new file mode 100644 index 0000000000..a2c035fe76 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "bun:test" +import { + providerSortKey, + isFree, + buildTriggerLabel, + KILO_GATEWAY_ID, + PROVIDER_ORDER, +} from "../../webview-ui/src/components/chat/model-selector-utils" + +const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" } + +describe("providerSortKey", () => { + it("returns 0 for kilo gateway", () => { + expect(providerSortKey(KILO_GATEWAY_ID)).toBe(0) + }) + + it("returns correct index for known providers", () => { + expect(providerSortKey("anthropic")).toBe(1) + expect(providerSortKey("openai")).toBe(2) + expect(providerSortKey("google")).toBe(3) + }) + + it("returns order length for unknown provider", () => { + expect(providerSortKey("unknown-provider")).toBe(PROVIDER_ORDER.length) + }) + + it("is case-insensitive", () => { + expect(providerSortKey("Anthropic")).toBe(providerSortKey("anthropic")) + expect(providerSortKey("OpenAI")).toBe(providerSortKey("openai")) + }) + + it("respects custom order array", () => { + const order = ["z-provider", "a-provider"] + expect(providerSortKey("z-provider", order)).toBe(0) + expect(providerSortKey("a-provider", order)).toBe(1) + expect(providerSortKey("other", order)).toBe(2) + }) + + it("sorts providers correctly when used with sort", () => { + const ids = ["google", "anthropic", "kilo", "openai"] + const sorted = ids.slice().sort((a, b) => providerSortKey(a) - providerSortKey(b)) + expect(sorted).toEqual(["kilo", "anthropic", "openai", "google"]) + }) +}) + +describe("isFree", () => { + it("returns true when inputPrice is 0", () => { + expect(isFree({ inputPrice: 0 })).toBe(true) + }) + + it("returns false when inputPrice is positive", () => { + expect(isFree({ inputPrice: 0.001 })).toBe(false) + }) + + it("returns false when inputPrice is non-zero", () => { + expect(isFree({ inputPrice: 5 })).toBe(false) + }) +}) + +describe("buildTriggerLabel", () => { + it("returns resolved model name when available", () => { + expect(buildTriggerLabel("GPT-4o", null, false, "", true, labels)).toBe("GPT-4o") + }) + + it("returns modelID for kilo gateway raw selection", () => { + const raw = { providerID: "kilo", modelID: "kilo/auto" } + expect(buildTriggerLabel(undefined, raw, false, "", true, labels)).toBe("kilo/auto") + }) + + it("returns providerID / modelID for non-kilo raw selection", () => { + const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" } + expect(buildTriggerLabel(undefined, raw, false, "", true, labels)).toBe("anthropic / claude-3-5-sonnet") + }) + + it("returns clearLabel when allowClear and no selection", () => { + expect(buildTriggerLabel(undefined, null, true, "None", true, labels)).toBe("None") + }) + + it("falls back to labels.notSet when allowClear and clearLabel is empty", () => { + expect(buildTriggerLabel(undefined, null, true, "", true, labels)).toBe("Not set") + }) + + it("returns labels.select when providers exist and no selection", () => { + expect(buildTriggerLabel(undefined, null, false, "", true, labels)).toBe("Select model") + }) + + it("returns labels.noProviders when no providers available", () => { + expect(buildTriggerLabel(undefined, null, false, "", false, labels)).toBe("No providers") + }) + + it("prefers resolvedName over raw selection", () => { + const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" } + expect(buildTriggerLabel("Claude Sonnet", raw, false, "", true, labels)).toBe("Claude Sonnet") + }) + + it("ignores partial raw selection (only providerID)", () => { + const raw = { providerID: "anthropic", modelID: "" } + expect(buildTriggerLabel(undefined, raw, false, "", true, labels)).toBe("Select model") + }) + + it("ignores partial raw selection (only modelID)", () => { + const raw = { providerID: "", modelID: "claude-3-5-sonnet" } + expect(buildTriggerLabel(undefined, raw, false, "", true, labels)).toBe("Select model") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ModelSelector.tsx index c5714e46cf..ed1379af9f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ModelSelector.tsx @@ -14,22 +14,13 @@ import { useProvider, EnrichedModel } from "../../context/provider" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" import type { ModelSelection } from "../../types/messages" +import { KILO_GATEWAY_ID, providerSortKey, isFree, buildTriggerLabel } from "./model-selector-utils" interface ModelGroup { providerName: string models: EnrichedModel[] } -const KILO_GATEWAY_ID = "kilo" - -/** Provider display order — popular providers sort first */ -const PROVIDER_ORDER = [KILO_GATEWAY_ID, "anthropic", "openai", "google"] - -function providerSortKey(providerID: string): number { - const idx = PROVIDER_ORDER.indexOf(providerID.toLowerCase()) - return idx >= 0 ? idx : PROVIDER_ORDER.length -} - // --------------------------------------------------------------------------- // Reusable base component // --------------------------------------------------------------------------- @@ -168,10 +159,6 @@ export const ModelSelectorBase: Component = (props) => { }) } - function isFree(model: EnrichedModel): boolean { - return model.inputPrice === 0 - } - function isSelected(model: EnrichedModel): boolean { const sel = selectedModel() return sel !== undefined && sel.providerID === model.providerID && sel.id === model.id @@ -182,21 +169,19 @@ export const ModelSelectorBase: Component = (props) => { return flatFiltered().indexOf(model) + clearOffset() } - const triggerLabel = () => { - const sel = selectedModel() - if (sel) { - return sel.name - } - // Fallback: raw selection exists but findModel didn't resolve — show raw IDs - const raw = props.value - if (raw?.providerID && raw?.modelID) { - return raw.providerID === KILO_GATEWAY_ID ? raw.modelID : `${raw.providerID} / ${raw.modelID}` - } - if (props.allowClear) { - return props.clearLabel ?? language.t("dialog.model.notSet") - } - return hasProviders() ? language.t("dialog.model.select.title") : language.t("dialog.model.noProviders") - } + const triggerLabel = () => + buildTriggerLabel( + selectedModel()?.name, + props.value, + props.allowClear ?? false, + props.clearLabel ?? "", + hasProviders(), + { + select: language.t("dialog.model.select.title"), + noProviders: language.t("dialog.model.noProviders"), + notSet: language.t("dialog.model.notSet"), + }, + ) return ( = 0 ? idx : order.length +} + +export function isFree(model: Pick): boolean { + return model.inputPrice === 0 +} + +export function buildTriggerLabel( + resolvedName: string | undefined, + raw: ModelSelection | null, + allowClear: boolean, + clearLabel: string, + hasProviders: boolean, + labels: { select: string; noProviders: string; notSet: string }, +): string { + if (resolvedName) return resolvedName + if (raw?.providerID && raw?.modelID) { + return raw.providerID === KILO_GATEWAY_ID ? raw.modelID : `${raw.providerID} / ${raw.modelID}` + } + if (allowClear) return clearLabel || labels.notSet + return hasProviders ? labels.select : labels.noProviders +} From 582566f3ecc9027795cd693ea9e5f2b8ac1a19d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 14:15:14 +0100 Subject: [PATCH 55/73] test(vscode): extract fileName, dirName, buildHighlightSegments from PromptInput and add tests --- .../tests/unit/prompt-input-utils.test.ts | 120 ++++++++++++++++++ .../src/components/chat/PromptInput.tsx | 48 +------ .../src/components/chat/prompt-input-utils.ts | 46 +++++++ 3 files changed, 168 insertions(+), 46 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts new file mode 100644 index 0000000000..e1460413e9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "bun:test" +import { fileName, dirName, buildHighlightSegments } from "../../webview-ui/src/components/chat/prompt-input-utils" + +describe("fileName", () => { + it("extracts the last segment of a unix path", () => { + expect(fileName("src/components/chat/PromptInput.tsx")).toBe("PromptInput.tsx") + }) + + it("extracts the last segment of a Windows path", () => { + expect(fileName("src\\components\\chat\\PromptInput.tsx")).toBe("PromptInput.tsx") + }) + + it("returns the path itself when no separator present", () => { + expect(fileName("README.md")).toBe("README.md") + }) + + it("returns the filename for a single directory segment", () => { + expect(fileName("src/foo.ts")).toBe("foo.ts") + }) + + it("handles mixed separators", () => { + expect(fileName("src\\components/chat/File.tsx")).toBe("File.tsx") + }) +}) + +describe("dirName", () => { + it("returns empty string for a file with no directory", () => { + expect(dirName("README.md")).toBe("") + }) + + it("returns the directory for a simple path", () => { + expect(dirName("src/foo.ts")).toBe("src") + }) + + it("returns full directory for a short path", () => { + expect(dirName("src/components/foo.ts")).toBe("src/components") + }) + + it("truncates long directories to last two segments", () => { + const path = "packages/kilo-vscode/webview-ui/src/components/chat/foo.ts" + const result = dirName(path) + expect(result).toMatch(/^…\//) + expect(result).toContain("components/chat") + }) + + it("does not truncate directories at exactly 30 chars", () => { + const dir = "a".repeat(15) + "/" + "b".repeat(14) + const result = dirName(`${dir}/file.ts`) + expect(result).toBe(dir) + }) + + it("truncates directories longer than 30 chars", () => { + const dir = "a".repeat(16) + "/" + "b".repeat(15) + const result = dirName(`${dir}/file.ts`) + expect(result.startsWith("…/")).toBe(true) + }) + + it("normalizes Windows backslashes before measuring length", () => { + const result = dirName("src\\foo.ts") + expect(result).toBe("src") + }) +}) + +describe("buildHighlightSegments", () => { + it("returns single non-highlighted segment when paths set is empty", () => { + const result = buildHighlightSegments("hello world", new Set()) + expect(result).toEqual([{ text: "hello world", highlight: false }]) + }) + + it("returns single non-highlighted segment when no mention present", () => { + const result = buildHighlightSegments("hello world", new Set(["foo.ts"])) + expect(result).toEqual([{ text: "hello world", highlight: false }]) + }) + + it("highlights a single mention token", () => { + const result = buildHighlightSegments("@foo.ts", new Set(["foo.ts"])) + expect(result).toEqual([{ text: "@foo.ts", highlight: true }]) + }) + + it("splits text before and highlight token", () => { + const result = buildHighlightSegments("see @foo.ts here", new Set(["foo.ts"])) + expect(result).toEqual([ + { text: "see ", highlight: false }, + { text: "@foo.ts", highlight: true }, + { text: " here", highlight: false }, + ]) + }) + + it("highlights multiple mentions in order", () => { + const result = buildHighlightSegments("@a.ts and @b.ts done", new Set(["a.ts", "b.ts"])) + expect(result).toEqual([ + { text: "@a.ts", highlight: true }, + { text: " and ", highlight: false }, + { text: "@b.ts", highlight: true }, + { text: " done", highlight: false }, + ]) + }) + + it("picks the earliest mention when multiple paths could match", () => { + const result = buildHighlightSegments("@b.ts then @a.ts", new Set(["a.ts", "b.ts"])) + expect(result[0]).toEqual({ text: "@b.ts", highlight: true }) + expect(result[2]).toEqual({ text: "@a.ts", highlight: true }) + }) + + it("handles back-to-back mentions with no separator", () => { + const result = buildHighlightSegments("@a.ts@b.ts", new Set(["a.ts", "b.ts"])) + const highlighted = result.filter((s) => s.highlight) + expect(highlighted).toHaveLength(2) + }) + + it("returns empty array for empty string", () => { + const result = buildHighlightSegments("", new Set(["foo.ts"])) + expect(result).toEqual([]) + }) + + it("does not partially match longer paths", () => { + const result = buildHighlightSegments("@foo.ts", new Set(["foo.tsx"])) + expect(result).toEqual([{ text: "@foo.ts", highlight: false }]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index b1ca8c75a8..64c26abaa8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -15,6 +15,7 @@ import { ModelSelector } from "./ModelSelector" import { ModeSwitcher } from "./ModeSwitcher" import { useFileMention } from "../../hooks/useFileMention" import { useImageAttachments } from "../../hooks/useImageAttachments" +import { fileName, dirName, buildHighlightSegments } from "./prompt-input-utils" const AUTOCOMPLETE_DEBOUNCE_MS = 500 const MIN_TEXT_LENGTH = 3 @@ -150,43 +151,6 @@ export const PromptInput: Component = () => { textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px` } - const buildHighlightSegments = (val: string) => { - const paths = mention.mentionedPaths() - if (paths.size === 0) return [{ text: val, highlight: false }] - - const segments: { text: string; highlight: boolean }[] = [] - let remaining = val - - while (remaining.length > 0) { - let earliest = -1 - let earliestPath = "" - - for (const path of paths) { - const token = `@${path}` - const idx = remaining.indexOf(token) - if (idx !== -1 && (earliest === -1 || idx < earliest)) { - earliest = idx - earliestPath = path - } - } - - if (earliest === -1) { - segments.push({ text: remaining, highlight: false }) - break - } - - if (earliest > 0) { - segments.push({ text: remaining.substring(0, earliest), highlight: false }) - } - - const token = `@${earliestPath}` - segments.push({ text: token, highlight: true }) - remaining = remaining.substring(earliest + token.length) - } - - return segments - } - const handleInput = (e: InputEvent) => { const target = e.target as HTMLTextAreaElement const val = target.value @@ -263,14 +227,6 @@ export const PromptInput: Component = () => { if (textareaRef) textareaRef.style.height = "auto" } - const fileName = (path: string) => path.replaceAll("\\", "/").split("/").pop() ?? path - const dirName = (path: string) => { - const parts = path.replaceAll("\\", "/").split("/") - if (parts.length <= 1) return "" - const dir = parts.slice(0, -1).join("/") - return dir.length > 30 ? `…/${parts.slice(-3, -1).join("/")}` : dir - } - return (
{